diff --git a/.env.example b/.env.example index 2dc99cf..2b13f57 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,15 @@ -# Captcha provider: mock | googleRecaptchaEnterprise -VITE_CAPTCHA_PROVIDER=googleRecaptchaEnterprise - # Google reCAPTCHA Enterprise checkbox site key VITE_RECAPTCHA_SITE_KEY= -# Enable mock/debug controls on CaptchaPage (true | false) -VITE_ENABLE_DEBUG_CAPTCHA=false +# Backend base URLs. Replace these with your deployed HTTPS endpoints. +VITE_BE1_BASE_URL=https://be1-backend.example.com +VITE_BE3_BASE_URL=https://be3-backend.example.com + +# Shared request timeout +VITE_API_TIMEOUT_MS=10000 + +# Upload request timeout +VITE_UPLOAD_TIMEOUT_MS=60000 -# Optional backend verification endpoint -# Example: http://localhost:8080/api/captcha/verify -VITE_CAPTCHA_VERIFY_ENDPOINT=http://localhost:8080/api/captcha/verify +# Maximum SSE reconnect attempts +VITE_SSE_RECONNECT_MAX=3 diff --git a/.gitignore b/.gitignore index 4fb8b47..2f8b07a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ out .env.server .env.local .env.*.local + +docs/local/ +.claude/ +.devserver.*.log diff --git a/package.json b/package.json index 9dfb609..871edaf 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,9 @@ "@tanstack/react-router": "^1.166.3", "@vanilla-extract/css": "^1.18.0", "antd": "^6.3.2", - "html2canvas": "^1.4.1", - "jspdf": "^4.2.1", + "axios": "1.15.1", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zustand": "5.0.12" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81182bd..bf6b0a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,18 +25,18 @@ importers: antd: specifier: ^6.3.2 version: 6.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - html2canvas: - specifier: ^1.4.1 - version: 1.4.1 - jspdf: - specifier: ^4.2.1 - version: 4.2.1 + axios: + specifier: 1.15.1 + version: 1.15.1 react: specifier: ^19.1.1 version: 19.2.4 react-dom: specifier: ^19.1.1 version: 19.2.4(react@19.2.4) + zustand: + specifier: 5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@secretlint/secretlint-rule-preset-recommend': specifier: ^11.3.0 @@ -1552,18 +1552,6 @@ packages: integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==, } - '@types/pako@2.0.4': - resolution: - { - integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==, - } - - '@types/raf@3.4.3': - resolution: - { - integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==, - } - '@types/react-dom@19.2.3': resolution: { @@ -1578,12 +1566,6 @@ packages: integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==, } - '@types/trusted-types@2.0.7': - resolution: - { - integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, - } - '@typescript-eslint/parser@8.56.1': resolution: { @@ -1880,6 +1862,12 @@ packages: } engines: { node: '>= 0.4' } + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, + } + available-typed-arrays@1.0.7: resolution: { @@ -1887,6 +1875,12 @@ packages: } engines: { node: '>= 0.4' } + axios@1.15.1: + resolution: + { + integrity: sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==, + } + balanced-match@1.0.2: resolution: { @@ -1900,13 +1894,6 @@ packages: } engines: { node: 18 || 20 || >=22 } - base64-arraybuffer@1.0.2: - resolution: - { - integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==, - } - engines: { node: '>= 0.6.0' } - baseline-browser-mapping@2.10.0: resolution: { @@ -2003,13 +1990,6 @@ packages: integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==, } - canvg@3.0.11: - resolution: - { - integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==, - } - engines: { node: '>=10.0.0' } - chai@5.3.3: resolution: { @@ -2078,6 +2058,13 @@ packages: integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==, } + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, + } + engines: { node: '>= 0.8' } + commander@14.0.3: resolution: { @@ -2121,12 +2108,6 @@ packages: integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==, } - core-js@3.48.0: - resolution: - { - integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==, - } - cross-spawn@7.0.6: resolution: { @@ -2134,12 +2115,6 @@ packages: } engines: { node: '>= 8' } - css-line-break@2.1.0: - resolution: - { - integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==, - } - css-what@6.2.2: resolution: { @@ -2262,18 +2237,19 @@ packages: } engines: { node: '>= 0.4' } - doctrine@2.1.0: + delayed-stream@1.0.0: resolution: { - integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==, + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, } - engines: { node: '>=0.10.0' } + engines: { node: '>=0.4.0' } - dompurify@3.3.3: + doctrine@2.1.0: resolution: { - integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==, + integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==, } + engines: { node: '>=0.10.0' } dunder-proto@1.0.1: resolution: @@ -2570,12 +2546,6 @@ packages: integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, } - fast-png@6.4.0: - resolution: - { - integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==, - } - fast-uri@3.1.0: resolution: { @@ -2600,12 +2570,6 @@ packages: picomatch: optional: true - fflate@0.8.2: - resolution: - { - integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==, - } - file-entry-cache@8.0.0: resolution: { @@ -2640,6 +2604,18 @@ packages: integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==, } + follow-redirects@1.16.0: + resolution: + { + integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==, + } + engines: { node: '>=4.0' } + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: { @@ -2647,6 +2623,13 @@ packages: } engines: { node: '>= 0.4' } + form-data@4.0.5: + resolution: + { + integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==, + } + engines: { node: '>= 6' } + fsevents@2.3.3: resolution: { @@ -2819,13 +2802,6 @@ packages: } engines: { node: ^16.14.0 || >=18.0.0 } - html2canvas@1.4.1: - resolution: - { - integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==, - } - engines: { node: '>=8.0.0' } - ignore@5.3.2: resolution: { @@ -2868,12 +2844,6 @@ packages: } engines: { node: '>= 0.4' } - iobuffer@5.4.0: - resolution: - { - integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==, - } - is-array-buffer@3.0.5: resolution: { @@ -3173,12 +3143,6 @@ packages: engines: { node: '>=6' } hasBin: true - jspdf@4.2.1: - resolution: - { - integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==, - } - keyv@4.5.4: resolution: { @@ -3377,6 +3341,20 @@ packages: } engines: { node: '>=8.6' } + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + } + engines: { node: '>= 0.6' } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + } + engines: { node: '>= 0.6' } + mimic-function@5.0.1: resolution: { @@ -3532,12 +3510,6 @@ packages: } engines: { node: '>=18' } - pako@2.1.0: - resolution: - { - integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==, - } - parent-module@1.0.1: resolution: { @@ -3592,12 +3564,6 @@ packages: } engines: { node: '>= 14.16' } - performance-now@2.1.0: - resolution: - { - integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==, - } - picocolors@1.1.1: resolution: { @@ -3666,6 +3632,13 @@ packages: engines: { node: '>=14' } hasBin: true + proxy-from-env@2.1.0: + resolution: + { + integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==, + } + engines: { node: '>=10' } + punycode@2.3.1: resolution: { @@ -3679,12 +3652,6 @@ packages: integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, } - raf@3.4.1: - resolution: - { - integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==, - } - rc-config-loader@4.1.4: resolution: { @@ -3733,12 +3700,6 @@ packages: } engines: { node: '>= 0.4' } - regenerator-runtime@0.13.11: - resolution: - { - integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==, - } - regexp.prototype.flags@1.5.4: resolution: { @@ -3800,13 +3761,6 @@ packages: integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, } - rgbcolor@1.0.1: - resolution: - { - integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==, - } - engines: { node: '>= 0.8.15' } - rollup@4.59.0: resolution: { @@ -4047,13 +4001,6 @@ packages: integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, } - stackblur-canvas@2.7.0: - resolution: - { - integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==, - } - engines: { node: '>=0.1.14' } - std-env@3.10.0: resolution: { @@ -4189,13 +4136,6 @@ packages: } engines: { node: '>= 0.4' } - svg-pathdata@6.0.3: - resolution: - { - integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==, - } - engines: { node: '>=12.0.0' } - table@6.9.0: resolution: { @@ -4218,12 +4158,6 @@ packages: engines: { node: '>=10' } hasBin: true - text-segmentation@1.0.3: - resolution: - { - integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==, - } - text-table@0.2.0: resolution: { @@ -4439,12 +4373,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - utrie@1.0.2: - resolution: - { - integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==, - } - validate-npm-package-license@3.0.4: resolution: { @@ -4619,6 +4547,27 @@ packages: } engines: { node: '>=10' } + zustand@5.0.12: + resolution: + { + integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==, + } + engines: { node: '>=12.20.0' } + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@ant-design/colors@8.0.1': dependencies: @@ -5563,11 +5512,6 @@ snapshots: '@types/normalize-package-data@2.4.4': {} - '@types/pako@2.0.4': {} - - '@types/raf@3.4.3': - optional: true - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -5576,9 +5520,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/trusted-types@2.0.7': - optional: true - '@typescript-eslint/parser@8.56.1(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.1 @@ -5916,16 +5857,24 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + axios@1.15.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} balanced-match@4.0.4: {} - base64-arraybuffer@1.0.2: {} - baseline-browser-mapping@2.10.0: {} binaryextensions@6.11.0: @@ -5981,18 +5930,6 @@ snapshots: caniuse-lite@1.0.30001777: {} - canvg@3.0.11: - dependencies: - '@babel/runtime': 7.28.6 - '@types/raf': 3.4.3 - core-js: 3.48.0 - raf: 3.4.1 - regenerator-runtime: 0.13.11 - rgbcolor: 1.0.1 - stackblur-canvas: 2.7.0 - svg-pathdata: 6.0.3 - optional: true - chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -6029,6 +5966,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.3: {} commander@2.20.3: @@ -6044,19 +5985,12 @@ snapshots: cookie-es@2.0.0: {} - core-js@3.48.0: - optional: true - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - css-line-break@2.1.0: - dependencies: - utrie: 1.0.2 - css-what@6.2.2: {} cssesc@3.0.0: {} @@ -6113,15 +6047,12 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 - dompurify@3.3.3: - optionalDependencies: - '@types/trusted-types': 2.0.7 - optional: true - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6403,12 +6334,6 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-png@6.4.0: - dependencies: - '@types/pako': 2.0.4 - iobuffer: 5.4.0 - pako: 2.1.0 - fast-uri@3.1.0: {} fastq@1.20.1: @@ -6419,8 +6344,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fflate@0.8.2: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6441,10 +6364,20 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true @@ -6548,11 +6481,6 @@ snapshots: dependencies: lru-cache: 10.4.3 - html2canvas@1.4.1: - dependencies: - css-line-break: 2.1.0 - text-segmentation: 1.0.3 - ignore@5.3.2: {} ignore@7.0.5: {} @@ -6572,8 +6500,6 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 - iobuffer@5.4.0: {} - is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -6732,17 +6658,6 @@ snapshots: json5@2.2.3: {} - jspdf@4.2.1: - dependencies: - '@babel/runtime': 7.28.6 - fast-png: 6.4.0 - fflate: 0.8.2 - optionalDependencies: - canvg: 3.0.11 - core-js: 3.48.0 - dompurify: 3.3.3 - html2canvas: 1.4.1 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6856,6 +6771,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-function@5.0.1: {} minimatch@10.2.4: @@ -6953,8 +6874,6 @@ snapshots: p-map@7.0.4: {} - pako@2.1.0: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6977,9 +6896,6 @@ snapshots: pathval@2.0.1: {} - performance-now@2.1.0: - optional: true - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -7008,15 +6924,12 @@ snapshots: prettier@3.8.1: {} + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} - raf@3.4.1: - dependencies: - performance-now: 2.1.0 - optional: true - rc-config-loader@4.1.4: dependencies: debug: 4.4.3 @@ -7056,9 +6969,6 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 - regenerator-runtime@0.13.11: - optional: true - regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -7092,9 +7002,6 @@ snapshots: rfdc@1.4.1: {} - rgbcolor@1.0.1: - optional: true - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -7283,9 +7190,6 @@ snapshots: stackback@0.0.2: {} - stackblur-canvas@2.7.0: - optional: true - std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -7370,9 +7274,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svg-pathdata@6.0.3: - optional: true - table@6.9.0: dependencies: ajv: 8.18.0 @@ -7394,10 +7295,6 @@ snapshots: source-map-support: 0.5.21 optional: true - text-segmentation@1.0.3: - dependencies: - utrie: 1.0.2 - text-table@0.2.0: {} textextensions@6.11.0: @@ -7520,10 +7417,6 @@ snapshots: dependencies: react: 19.2.4 - utrie@1.0.2: - dependencies: - base64-arraybuffer: 1.0.2 - validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -7671,3 +7564,9 @@ snapshots: yaml@2.8.3: {} yocto-queue@0.1.0: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/scripts/setupEnv.mjs b/scripts/setupEnv.mjs index dfe6801..29c71a9 100644 --- a/scripts/setupEnv.mjs +++ b/scripts/setupEnv.mjs @@ -14,18 +14,22 @@ const serverEnvLocalPath = path.join(rootDir, '.env.server.local'); const OWNER_READ_WRITE_MODE = 0o600; const defaultEnvContent = [ - '# Captcha provider: mock | googleRecaptchaEnterprise', - 'VITE_CAPTCHA_PROVIDER=googleRecaptchaEnterprise', - '', '# Google reCAPTCHA Enterprise checkbox site key (required when provider is googleRecaptchaEnterprise)', 'VITE_RECAPTCHA_SITE_KEY=', '', - '# Enable mock/debug controls on CaptchaPage (true | false)', - 'VITE_ENABLE_DEBUG_CAPTCHA=false', + '# Backend base URLs. Provide deployed values via .env.local or secret management.', + '# Local defaults are only for development proxy targets.', + 'VITE_BE1_BASE_URL=http://localhost:8081', + 'VITE_BE3_BASE_URL=http://localhost:8083', + '', + '# Shared request timeout', + 'VITE_API_TIMEOUT_MS=10000', + '', + '# Upload request timeout', + 'VITE_UPLOAD_TIMEOUT_MS=60000', '', - '# Optional backend verification endpoint', - '# Example: http://localhost:8080/api/captcha/verify', - 'VITE_CAPTCHA_VERIFY_ENDPOINT=http://localhost:8080/api/captcha/verify', + '# Maximum SSE reconnect attempts', + 'VITE_SSE_RECONNECT_MAX=3', '', ].join('\n'); diff --git a/src/features/scan-history/api/fetchScanHistory.ts b/src/features/scan-history/api/fetchScanHistory.ts new file mode 100644 index 0000000..b1eab87 --- /dev/null +++ b/src/features/scan-history/api/fetchScanHistory.ts @@ -0,0 +1,46 @@ +import { axiosBe3 } from '@/shared/api/axios'; +import { apiEndpoints } from '@/shared/api/endpoints'; +import { asArray, asRecord } from '@/shared/api/responseAccess/payloadAccess'; +import type { BackendHistoryItemResponse } from '@/shared/api/types'; +import { useGuestStore } from '@/shared/store/guestStore'; + +function toHistoryItems(value: unknown): BackendHistoryItemResponse[] { + return asArray(value) + .map((item) => asRecord(item)) + .filter((item): item is BackendHistoryItemResponse => item !== null); +} + +function resolveHistoryPayload(data: unknown): BackendHistoryItemResponse[] { + const directItems = toHistoryItems(data); + + if (directItems.length > 0) { + return directItems; + } + + const dataRecord = asRecord(data); + + if (!dataRecord) { + return []; + } + + const nestedCandidates = [ + toHistoryItems(dataRecord.items), + toHistoryItems(dataRecord.history), + toHistoryItems(dataRecord.results), + toHistoryItems(dataRecord.data), + ]; + + return nestedCandidates.find((items) => items.length > 0) ?? []; +} + +export async function fetchScanHistory(): Promise { + const guestUuid = + useGuestStore.getState().guestUuid ?? useGuestStore.getState().ensureGuestUuid(); + const response = await axiosBe3.get(apiEndpoints.scanHistory, { + headers: { + guest_uuid: guestUuid, + }, + }); + + return resolveHistoryPayload(response.data); +} diff --git a/src/features/scan-history/api/fetchScanHistoryData.ts b/src/features/scan-history/api/fetchScanHistoryData.ts new file mode 100644 index 0000000..5b56eaa --- /dev/null +++ b/src/features/scan-history/api/fetchScanHistoryData.ts @@ -0,0 +1,111 @@ +import { useGuestStore } from '@/shared/store/guestStore'; + +import { fetchScanHistory } from './fetchScanHistory'; +import { + buildHistoryItemId, + normalizeHistoryTimestampValue, + pickHistoryIsUrl, + pickHistoryRiskLevel, + pickHistoryScannedAt, + pickHistorySchemeType, + pickHistoryTargetValue, + resolveHistoryTimestamp, +} from '../lib/historyItemAccess'; + +import type { ScanHistoryData } from '../types/scanHistory.types'; + +const maxRecentScanHistoryItemCount = 5; + +type FetchScanHistoryDataOptions = { + limit?: number; +}; + +type HistoryItemWithTargetValue = { + item: unknown; + targetValue: string; +}; + +function resolveScanHistoryUuid(): string { + return useGuestStore.getState().guestUuid ?? useGuestStore.getState().ensureGuestUuid(); +} + +function getSortedHistoryItems(items: unknown[]): unknown[] { + return [...items].sort((previousItem, nextItem) => { + return ( + resolveHistoryTimestamp(pickHistoryScannedAt(nextItem)) - + resolveHistoryTimestamp(pickHistoryScannedAt(previousItem)) + ); + }); +} + +function getItemsWithTargetValue(items: unknown[]): HistoryItemWithTargetValue[] { + return items.flatMap((item) => { + const targetValue = pickHistoryTargetValue(item); + + return targetValue ? [{ item, targetValue }] : []; + }); +} + +function applyItemLimit(items: T[], limit?: number): T[] { + if (limit === undefined) { + return items; + } + + return items.slice(0, Math.max(0, limit)); +} + +function formatScannedAt(rawScannedAt: string | null): string { + if (!rawScannedAt) { + return '날짜 정보 없음'; + } + + const parsedDate = new Date(normalizeHistoryTimestampValue(rawScannedAt)); + + if (Number.isNaN(parsedDate.getTime())) { + return rawScannedAt; + } + + const year = parsedDate.getFullYear(); + const month = `${parsedDate.getMonth() + 1}`.padStart(2, '0'); + const day = `${parsedDate.getDate()}`.padStart(2, '0'); + + return `${year}.${month}.${day}`; +} + +export async function fetchScanHistoryData( + options: FetchScanHistoryDataOptions = {}, +): Promise { + const uuid = resolveScanHistoryUuid(); + const items = await fetchScanHistory(); + const visibleItems = applyItemLimit( + getItemsWithTargetValue(getSortedHistoryItems(items)), + options.limit, + ); + + return { + items: visibleItems.map(({ item, targetValue }, index) => { + return { + id: buildHistoryItemId(item, index), + isUrl: pickHistoryIsUrl(item), + scannedAt: formatScannedAt(pickHistoryScannedAt(item)), + schemeType: pickHistorySchemeType(item), + status: pickHistoryRiskLevel(item) ?? 'warning', + url: targetValue, + }; + }), + uuid, + }; +} + +export async function fetchRecentScanHistoryData(): Promise { + return fetchScanHistoryData({ + limit: maxRecentScanHistoryItemCount, + }); +} + +export function getInitialScanHistoryData(uuid = resolveScanHistoryUuid()): ScanHistoryData { + return { + items: [], + uuid, + }; +} diff --git a/src/features/scan-history/lib/historyItemAccess.test.ts b/src/features/scan-history/lib/historyItemAccess.test.ts new file mode 100644 index 0000000..1b9faed --- /dev/null +++ b/src/features/scan-history/lib/historyItemAccess.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildHistoryItemId, + pickHistoryIsUrl, + pickHistoryRiskLevel, + pickHistoryScannedAt, + pickHistorySchemeType, + pickHistoryTargetValue, + resolveHistoryTimestamp, +} from './historyItemAccess'; + +describe('historyItemAccess', () => { + it('reads current scan history payload fields from the backend', () => { + const historyItem = { + riskLevel: 'safe', + scannedAt: '2026-04-21 02:50:44', + is_url: false, + schemeType: 'URL', + typeInfo: 'https://r-generator.com/1Ugy3mkc', + }; + + expect(pickHistoryTargetValue(historyItem)).toBe('https://r-generator.com/1Ugy3mkc'); + expect(pickHistoryScannedAt(historyItem)).toBe('2026-04-21 02:50:44'); + expect(pickHistoryRiskLevel(historyItem)).toBe('safe'); + expect(pickHistorySchemeType(historyItem)).toBe('URL'); + expect(pickHistoryIsUrl(historyItem)).toBe(false); + expect(buildHistoryItemId(historyItem, 0)).toBe( + 'URL|https://r-generator.com/1Ugy3mkc|2026-04-21 02:50:44|0', + ); + }); + + it('sorts backend timestamp labels by recency', () => { + expect(resolveHistoryTimestamp('2026-04-21 02:57:53')).toBeGreaterThan( + resolveHistoryTimestamp('2026-04-21 02:50:44'), + ); + }); + + it('normalizes dotted timestamp labels before parsing', () => { + expect(resolveHistoryTimestamp('2026.04.21 02:50:44')).toBe( + resolveHistoryTimestamp('2026-04-21T02:50:44'), + ); + }); +}); diff --git a/src/features/scan-history/lib/historyItemAccess.ts b/src/features/scan-history/lib/historyItemAccess.ts new file mode 100644 index 0000000..fb7150a --- /dev/null +++ b/src/features/scan-history/lib/historyItemAccess.ts @@ -0,0 +1,73 @@ +import { pickBoolean, pickString } from '@/shared/api/responseAccess/payloadAccess'; +import { normalizeRiskLevel } from '@/shared/api/risk/normalizeRiskLevel'; +import type { ResultTone } from '@/shared/types/resultTone'; + +const historyIdKeys = ['id', 'scanId', 'scan_id', 'uuid', 'scanUuid', 'scan_uuid']; +const historyScannedAtKeys = ['scannedAt', 'scanned_at', 'createdAt', 'created_at']; +const historyRiskLevelKeys = ['riskLevel', 'risk_level', 'status', 'result']; +const historySchemeTypeKeys = ['schemeType', 'scheme_type']; +const historyTargetValueKeys = [ + 'typeInfo', + 'type_info', + 'url', + 'decodedUrl', + 'decoded_url', + 'destinationUrl', + 'destination_url', + 'finalUrl', + 'final_url', +]; +const historyTitleKeys = ['title', 'siteName', 'site_name', 'name']; + +export function normalizeHistoryTimestampValue(rawScannedAt: string): string { + return rawScannedAt.replace(/\./g, '-').replace(' ', 'T'); +} + +export function pickHistoryTargetValue(source: unknown): string | null { + return pickString(source, historyTargetValueKeys); +} + +export function pickHistoryScannedAt(source: unknown): string | null { + return pickString(source, historyScannedAtKeys); +} + +export function pickHistoryRiskLevel(source: unknown): ResultTone | null { + return normalizeRiskLevel(pickString(source, historyRiskLevelKeys)); +} + +export function pickHistorySchemeType(source: unknown): string | null { + return pickString(source, historySchemeTypeKeys); +} + +export function pickHistoryIsUrl(source: unknown): boolean | null { + return pickBoolean(source, ['isUrl', 'is_url']); +} + +export function pickHistoryTitle(source: unknown): string | null { + return pickString(source, historyTitleKeys); +} + +export function buildHistoryItemId(source: unknown, index: number): string { + const explicitId = pickString(source, historyIdKeys); + + if (explicitId) { + return explicitId; + } + + const stableParts = [ + pickHistorySchemeType(source), + pickHistoryTargetValue(source), + pickHistoryScannedAt(source), + ].filter((part): part is string => Boolean(part)); + + return stableParts.length > 0 ? `${stableParts.join('|')}|${index}` : `history-${index + 1}`; +} + +export function resolveHistoryTimestamp(rawScannedAt: string | null): number { + if (!rawScannedAt) { + return 0; + } + + const parsedTimestamp = Date.parse(normalizeHistoryTimestampValue(rawScannedAt)); + return Number.isNaN(parsedTimestamp) ? 0 : parsedTimestamp; +} diff --git a/src/pages/ScanHistory/types/scanHistoryPage.types.ts b/src/features/scan-history/types/scanHistory.types.ts similarity index 67% rename from src/pages/ScanHistory/types/scanHistoryPage.types.ts rename to src/features/scan-history/types/scanHistory.types.ts index 65208c6..1be66c0 100644 --- a/src/pages/ScanHistory/types/scanHistoryPage.types.ts +++ b/src/features/scan-history/types/scanHistory.types.ts @@ -2,12 +2,14 @@ export type ScanHistoryStatus = 'safe' | 'warning' | 'critical'; export type ScanHistoryItem = { id: string; + isUrl: boolean | null; scannedAt: string; - title: string; - url: string; + schemeType: string | null; status: ScanHistoryStatus; + url: string; }; -export type ScanHistoryPageData = { +export type ScanHistoryData = { items: ScanHistoryItem[]; + uuid: string; }; diff --git a/src/features/scan-url/api/submitQrImage.ts b/src/features/scan-url/api/submitQrImage.ts new file mode 100644 index 0000000..7baffd9 --- /dev/null +++ b/src/features/scan-url/api/submitQrImage.ts @@ -0,0 +1,32 @@ +import { getUploadTimeoutMs } from '@/shared/api/apiConfig'; +import { axiosBe1 } from '@/shared/api/axios'; +import { apiEndpoints } from '@/shared/api/endpoints'; +import type { BackendScanResponse } from '@/shared/api/types'; + +type SubmitQrImagePayload = { + file: Blob; + fileName?: string; +}; + +function resolveUploadFile({ file, fileName }: SubmitQrImagePayload): File { + if (file instanceof File) { + return file; + } + + return new File([file], fileName ?? `qr-scan-${Date.now()}.jpg`, { + type: file.type || 'image/jpeg', + }); +} + +export async function submitQrImage(payload: SubmitQrImagePayload): Promise { + const uploadFile = resolveUploadFile(payload); + const formData = new FormData(); + + formData.append('image', uploadFile, uploadFile.name); + + const response = await axiosBe1.post(apiEndpoints.scan, formData, { + timeout: getUploadTimeoutMs(), + }); + + return response.data; +} diff --git a/src/features/scan-url/api/uploadErrors.test.ts b/src/features/scan-url/api/uploadErrors.test.ts new file mode 100644 index 0000000..15256c1 --- /dev/null +++ b/src/features/scan-url/api/uploadErrors.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiError } from '@/shared/api/errors/apiError'; + +import { isCaptchaRequiredUploadError } from './uploadErrors'; + +describe('isCaptchaRequiredUploadError', () => { + it('returns true for 429 captcha responses', () => { + const error = new ApiError({ + data: { + error_code: 'CAPTCHA_REQUIRED', + }, + message: '요청 횟수 초과. 안전한 이용을 위해 캡차 인증이 필요합니다.', + statusCode: 429, + }); + + expect(isCaptchaRequiredUploadError(error)).toBe(true); + }); + + it('detects captcha markers from nested payload messages', () => { + const error = new ApiError({ + data: { + data: { + detail: 'captcha verification required', + }, + }, + message: 'Too many requests.', + statusCode: 429, + }); + + expect(isCaptchaRequiredUploadError(error)).toBe(true); + }); + + it('returns false for non-captcha responses', () => { + const error = new ApiError({ + message: 'Request failed.', + statusCode: 500, + }); + + expect(isCaptchaRequiredUploadError(error)).toBe(false); + }); + + it('returns false for 429 responses without captcha markers', () => { + const error = new ApiError({ + data: { + message: 'Too many requests.', + }, + message: 'Too many requests.', + statusCode: 429, + }); + + expect(isCaptchaRequiredUploadError(error)).toBe(false); + }); +}); diff --git a/src/features/scan-url/api/uploadErrors.ts b/src/features/scan-url/api/uploadErrors.ts new file mode 100644 index 0000000..10cd544 --- /dev/null +++ b/src/features/scan-url/api/uploadErrors.ts @@ -0,0 +1,26 @@ +import { isApiError } from '@/shared/api/errors/apiError'; +import { pickString } from '@/shared/api/responseAccess/payloadAccess'; + +function normalizeValue(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ''; +} + +export function isCaptchaRequiredUploadError(error: unknown): boolean { + if (!isApiError(error) || error.statusCode !== 429) { + return false; + } + + const errorCode = normalizeValue( + pickString(error.data, ['error_code', 'errorCode', 'code', 'status']), + ); + const payloadMessage = pickString(error.data, [ + 'message', + 'detail', + 'error', + 'error_description', + 'description', + ]); + const message = normalizeValue([error.message, payloadMessage].filter(Boolean).join(' ')); + + return errorCode.includes('captcha') || message.includes('captcha') || message.includes('캡차'); +} diff --git a/src/features/scan-url/ui/ScanResultCard.tsx b/src/features/scan-url/ui/ScanResultCard.tsx deleted file mode 100644 index 21ad921..0000000 --- a/src/features/scan-url/ui/ScanResultCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -type ScanResultCardProps = { - description: string; - hints: string[]; - state: 'idle' | 'safe' | 'warning'; - title: string; -}; - -const STATE_LABEL = { - idle: 'Preview', - safe: 'Check Ready', - warning: 'Watch Out', -} as const; - -export default function ScanResultCard({ description, hints, state, title }: ScanResultCardProps) { - return ( -
- {STATE_LABEL[state]} -

{title}

-

{description}

- -
    - {hints.map((hint) => ( -
  • {hint}
  • - ))} -
-
- ); -} diff --git a/src/features/scan-url/ui/UrlScanForm.tsx b/src/features/scan-url/ui/UrlScanForm.tsx deleted file mode 100644 index 3d3ef28..0000000 --- a/src/features/scan-url/ui/UrlScanForm.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useState } from 'react'; - -import { isSafeExternalUrl } from '@/shared/lib/security/isSafeExternalUrl'; - -import ScanResultCard from './ScanResultCard'; -type ScanState = 'idle' | 'safe' | 'warning'; - -function createScanFeedback(url: string): { - description: string; - hints: string[]; - state: ScanState; - title: string; -} { - if (!url) { - return { - state: 'idle', - title: '아직 링크를 점검하지 않았습니다.', - description: '현재는 초기 구조 단계라서 URL 형식과 일부 위험 신호만 먼저 확인합니다.', - hints: [ - '문자, 메신저, 이메일로 받은 주소를 그대로 붙여 넣어 보세요.', - '비정상 프로토콜이나 계정 정보 포함 URL은 바로 경고 대상으로 처리합니다.', - ], - }; - } - - const scriptProtocol = ['java', 'script:'].join(''); - - if (!isSafeExternalUrl(url)) { - return { - state: 'warning', - title: '주의가 필요한 링크 형식입니다.', - description: '입력한 주소가 비정상 프로토콜이거나 구조적으로 위험 신호를 포함하고 있습니다.', - hints: [ - `${scriptProtocol}, data:, file: 같은 프로토콜은 바로 차단하세요.`, - '아이디나 비밀번호가 URL에 포함되면 즉시 공유를 멈추세요.', - '다음 단계에서는 도메인 평판이나 신고 이력을 연결할 수 있습니다.', - ], - }; - } - - const hints = [ - '실제 도메인과 보낸 사람 정보를 함께 확인하세요.', - '로그인이나 결제를 유도하면 한 번 더 검증하세요.', - ]; - - if (url.startsWith('http://')) { - hints.unshift('보호되지 않은 http 주소입니다. 실제 여부를 더 엄격하게 확인하세요.'); - } - - return { - state: 'safe', - title: '기본 점검이 가능한 링크 형식입니다.', - description: - '기본 입력 조건은 통과했습니다. 다만 안전이 보장된 것은 아니며, 실제 탐지 로직은 다음 단계에서 붙어야 합니다.', - hints, - }; -} - -export default function UrlScanForm() { - const [inputValue, setInputValue] = useState(''); - const [submittedUrl, setSubmittedUrl] = useState(''); - - const feedback = createScanFeedback(submittedUrl); - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmittedUrl(inputValue.trim()); - } - - return ( -
-

빠른 링크 점검

-

- 사용자가 바로 입력하고 결과를 볼 수 있도록 필요한 최소 흐름만 먼저 구성했습니다. -

- -
- - setInputValue(event.target.value)} - placeholder="https://example.com" - type="url" - value={inputValue} - /> - - - 현재는 클라이언트 단계의 URL 형식 점검만 연결되어 있습니다. - -
- - -
- ); -} diff --git a/src/main.tsx b/src/main.tsx index 770dd11..f3f44e9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,8 @@ import { App as AntdApp, ConfigProvider } from 'antd'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { useGuestStore } from '@/shared/store/guestStore'; + import { router } from '@/routes/router'; import 'antd/dist/reset.css'; import '@/vars.css'; @@ -13,6 +15,8 @@ if (!rootElement) { throw new Error('Root element #app was not found.'); } +useGuestStore.getState().ensureGuestUuid(); + createRoot(rootElement).render( @@ -39,61 +26,24 @@ export default function CaptchaPage() {
- {isDebugCaptchaEnabled ? ( -
- - -
- ) : null} - -

{providerLabel}

+

Google reCAPTCHA Enterprise

- {isDebugCaptchaEnabled && isUsingMockFallback ? ( -

- Google Enterprise를 선택했지만 `VITE_RECAPTCHA_SITE_KEY`가 없어 Mock로 자동 - 전환되었습니다. -

- ) : null} - - {effectiveProvider === 'googleRecaptchaEnterprise' ? ( -

- 체크 후 바로 통과될 수도 있고, 필요 시 이미지 문제 풀이가 추가로 표시됩니다. -

- ) : null} +

+ 체크 후 바로 통과될 수도 있고, 필요 시 이미지 문제 풀이가 추가로 표시됩니다. +

{feedbackMessage ?

{feedbackMessage}

: null} - - {isDebugCaptchaEnabled ? ( - <> -

현재 기본 설정값: {configuredProvider}

-

- `.env.local`에 `VITE_ENABLE_DEBUG_CAPTCHA=true`를 설정하면 Mock/Provider 토글이 - 노출됩니다. -

- - ) : null}
diff --git a/src/pages/Captcha/api/captchaVerifyResponse.test.ts b/src/pages/Captcha/api/captchaVerifyResponse.test.ts new file mode 100644 index 0000000..98bd041 --- /dev/null +++ b/src/pages/Captcha/api/captchaVerifyResponse.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { toCaptchaVerifyResponse } from './captchaVerifyResponse'; + +describe('toCaptchaVerifyResponse', () => { + it('treats payloads without explicit success markers as failure', () => { + expect( + toCaptchaVerifyResponse({ + guestUuid: 'guest-1', + message: 'verified', + }), + ).toEqual({ + message: 'verified', + success: false, + }); + }); + + it('treats success statuses as success', () => { + expect( + toCaptchaVerifyResponse({ + message: 'verified', + status: 'success', + }), + ).toEqual({ + message: 'verified', + success: true, + }); + }); + + it('treats failure statuses as failure', () => { + expect( + toCaptchaVerifyResponse({ + message: 'failed', + status: 'failed', + }), + ).toEqual({ + message: 'failed', + success: false, + }); + }); + + it('fails closed for pending statuses', () => { + expect( + toCaptchaVerifyResponse({ + message: 'pending', + status: 'pending', + }), + ).toEqual({ + message: 'pending', + success: false, + }); + }); + + it('lets explicit error codes override success booleans', () => { + expect( + toCaptchaVerifyResponse({ + error_code: 'CAPTCHA_REQUIRED', + message: 'captcha required', + success: true, + }), + ).toEqual({ + message: 'captcha required', + success: false, + }); + }); + + it('respects explicit boolean failure fields', () => { + expect( + toCaptchaVerifyResponse({ + message: 'failed', + success: false, + }), + ).toEqual({ + message: 'failed', + success: false, + }); + }); +}); diff --git a/src/pages/Captcha/api/captchaVerifyResponse.ts b/src/pages/Captcha/api/captchaVerifyResponse.ts new file mode 100644 index 0000000..d50c4d6 --- /dev/null +++ b/src/pages/Captcha/api/captchaVerifyResponse.ts @@ -0,0 +1,50 @@ +import { pickBoolean, pickString } from '@/shared/api/responseAccess/payloadAccess'; + +import type { CaptchaVerifyResponse } from '../types/captcha.types'; + +const successStatuses = new Set([ + 'complete', + 'completed', + 'ok', + 'pass', + 'passed', + 'success', + 'succeeded', + 'verified', +]); + +const failureStatuses = new Set([ + 'blocked', + 'denied', + 'error', + 'fail', + 'failed', + 'invalid', + 'rejected', +]); + +function normalizeStatus(value: string | null): string { + return value?.trim().toLowerCase() ?? ''; +} + +export function toCaptchaVerifyResponse(payload: unknown): CaptchaVerifyResponse { + const explicitSuccess = pickBoolean(payload, ['success', 'verified', 'isVerified']); + const errorCode = pickString(payload, ['error_code', 'errorCode', 'code']); + const status = normalizeStatus(pickString(payload, ['status', 'result'])); + + const success = + Boolean(errorCode) || failureStatuses.has(status) + ? false + : explicitSuccess !== null + ? explicitSuccess + : successStatuses.has(status); + + const message = + pickString(payload, ['message']) ?? + (success ? '캡차 검증이 완료되었습니다.' : '캡차 검증에 실패했습니다.'); + + return { + message, + success, + }; +} diff --git a/src/pages/Captcha/api/submitCaptchaVerification.ts b/src/pages/Captcha/api/submitCaptchaVerification.ts index 3b00447..b712b91 100644 --- a/src/pages/Captcha/api/submitCaptchaVerification.ts +++ b/src/pages/Captcha/api/submitCaptchaVerification.ts @@ -1,129 +1,38 @@ -import type { +import { axiosBe1 } from '@/shared/api/axios'; +import { apiEndpoints } from '@/shared/api/endpoints'; +import { toApiError } from '@/shared/api/errors/apiError'; +import { useGuestStore } from '@/shared/store/guestStore'; + +import { toCaptchaVerifyResponse } from './captchaVerifyResponse'; + +import type { CaptchaVerifyResponse, SubmitCaptchaVerificationPayload, } from '../types/captcha.types'; -const MOCK_RESPONSE_DELAY_MS = 500; -const DEFAULT_VERIFY_TIMEOUT_MS = 5000; - -function getVerifyTimeoutMs(): number { - const rawTimeout = import.meta.env.VITE_CAPTCHA_VERIFY_TIMEOUT_MS; - const parsedTimeout = Number.parseInt(rawTimeout ?? '', 10); - - if (Number.isNaN(parsedTimeout) || parsedTimeout <= 0) { - return DEFAULT_VERIFY_TIMEOUT_MS; - } - - return parsedTimeout; -} - -function getVerifyEndpoint(): string | null { - const endpoint = import.meta.env.VITE_CAPTCHA_VERIFY_ENDPOINT; - - if (!endpoint) { - return null; - } - - const trimmedEndpoint = endpoint.trim(); - return trimmedEndpoint.length > 0 ? trimmedEndpoint : null; -} - -async function parseResponse(response: Response): Promise { - try { - const data = (await response.json()) as Partial; - return { - message: data.message, - success: data.success === true, - }; - } catch { - return { - message: 'Unable to parse response from verification server.', - success: false, - }; - } -} - export async function submitCaptchaVerification({ token, }: SubmitCaptchaVerificationPayload): Promise { - const endpoint = getVerifyEndpoint(); - const timeoutMs = getVerifyTimeoutMs(); - - if (!endpoint) { - await new Promise((resolve) => { - window.setTimeout(resolve, MOCK_RESPONSE_DELAY_MS); - }); - - if (!token) { - return { - message: 'Captcha token is empty.', - success: false, - }; - } - - const isDebugCaptchaEnabled = import.meta.env.VITE_ENABLE_DEBUG_CAPTCHA === 'true'; - const isMockToken = token.startsWith('mock-token-'); - - if (isDebugCaptchaEnabled && isMockToken) { - return { - message: 'Mock verification succeeded.', - success: true, - }; - } + const trimmedToken = token?.trim() ?? ''; + if (!trimmedToken) { return { - message: 'Captcha verification endpoint is not configured.', + message: 'Captcha token is empty.', success: false, }; } try { - const controller = new AbortController(); - const timeoutId = window.setTimeout(() => { - controller.abort(); - }, timeoutMs); - - let response: Response; - try { - response = await fetch(endpoint, { - body: JSON.stringify({ token }), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - signal: controller.signal, - }); - } finally { - window.clearTimeout(timeoutId); - } - - const result = await parseResponse(response); - - if (!response.ok) { - return { - message: result.message ?? `Verification request failed. (HTTP ${response.status})`, - success: false, - }; - } - - if (result.success) { - return result; - } + const guestUuid = useGuestStore.getState().ensureGuestUuid(); + const response = await axiosBe1.post(apiEndpoints.captchaVerify, { + captchaToken: trimmedToken, + guestUuid, + }); - return { - message: result.message ?? 'Captcha verification failed.', - success: false, - }; + return toCaptchaVerifyResponse(response.data); } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - return { - message: `Verification request timed out. (${timeoutMs}ms)`, - success: false, - }; - } - return { - message: 'Unable to connect to verification server.', + message: toApiError(error).message, success: false, }; } diff --git a/src/pages/Captcha/hooks/useCaptchaPage.ts b/src/pages/Captcha/hooks/useCaptchaPage.ts index 6907fd9..3a70925 100644 --- a/src/pages/Captcha/hooks/useCaptchaPage.ts +++ b/src/pages/Captcha/hooks/useCaptchaPage.ts @@ -1,94 +1,24 @@ -import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useCallback, useState } from 'react'; import { submitCaptchaVerification } from '../api/submitCaptchaVerification'; -import type { CaptchaProvider } from '../types/captcha.types'; - type UseCaptchaPageReturn = { - configuredProvider: CaptchaProvider; - effectiveProvider: CaptchaProvider; + canSubmit: boolean; feedbackMessage: string | null; handleCaptchaTokenChange: (nextToken: string | null) => void; - handleMockToggle: (checked: boolean) => void; - handleProviderChange: (provider: CaptchaProvider) => void; handleSubmit: () => Promise; - isUsingMockFallback: boolean; isVerifying: boolean; recaptchaSiteKey: string; - selectedProvider: CaptchaProvider; - token: string | null; }; -function resolveCaptchaProvider( - rawProvider: string | undefined, - isDebugCaptchaEnabled: boolean, -): CaptchaProvider { - if (rawProvider === 'googleRecaptchaV2' || rawProvider === 'googleRecaptchaEnterprise') { - return 'googleRecaptchaEnterprise'; - } - - if (rawProvider === 'mock' && isDebugCaptchaEnabled) { - return 'mock'; - } - - return 'googleRecaptchaEnterprise'; -} - export function useCaptchaPage(): UseCaptchaPageReturn { const navigate = useNavigate(); const [token, setToken] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(null); const [isVerifying, setIsVerifying] = useState(false); - - const isDebugCaptchaEnabled = import.meta.env.VITE_ENABLE_DEBUG_CAPTCHA === 'true'; - - const configuredProvider = useMemo( - () => resolveCaptchaProvider(import.meta.env.VITE_CAPTCHA_PROVIDER, isDebugCaptchaEnabled), - [isDebugCaptchaEnabled], - ); - const [selectedProvider, setSelectedProvider] = useState(configuredProvider); - const recaptchaSiteKey = (import.meta.env.VITE_RECAPTCHA_SITE_KEY ?? '').trim(); - - const shouldFallbackToMock = - isDebugCaptchaEnabled && - selectedProvider === 'googleRecaptchaEnterprise' && - recaptchaSiteKey.length === 0; - - const effectiveProvider: CaptchaProvider = shouldFallbackToMock ? 'mock' : selectedProvider; - - const isUsingMockFallback = shouldFallbackToMock; - - const handleProviderChange = useCallback( - (provider: CaptchaProvider) => { - if (!isDebugCaptchaEnabled) { - return; - } - - setSelectedProvider(provider); - setToken(null); - setFeedbackMessage(null); - }, - [isDebugCaptchaEnabled], - ); - - const handleMockToggle = useCallback( - (checked: boolean) => { - if (!isDebugCaptchaEnabled) { - return; - } - - if (checked) { - setToken(`mock-token-${Date.now()}`); - } else { - setToken(null); - } - - setFeedbackMessage(null); - }, - [isDebugCaptchaEnabled], - ); + const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0; const handleCaptchaTokenChange = useCallback((nextToken: string | null) => { setToken(nextToken); @@ -97,52 +27,41 @@ export function useCaptchaPage(): UseCaptchaPageReturn { const handleSubmit = useCallback(async () => { setFeedbackMessage(null); + const trimmedToken = token?.trim() ?? ''; - if (effectiveProvider === 'googleRecaptchaEnterprise' && recaptchaSiteKey.length === 0) { + if (recaptchaSiteKey.length === 0) { setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다. `.env.local`을 확인해 주세요.'); return; } - if (!token) { + if (!trimmedToken) { setFeedbackMessage('캡차를 먼저 완료해 주세요.'); return; } - if (effectiveProvider === 'mock') { - setFeedbackMessage('Mock 캡차 검증이 완료되었습니다. 로딩 화면으로 이동합니다.'); - void navigate({ to: '/loading' }); - return; - } - setIsVerifying(true); try { - const result = await submitCaptchaVerification({ token }); + const result = await submitCaptchaVerification({ token: trimmedToken }); if (!result.success) { setFeedbackMessage(result.message ?? '캡차 검증에 실패했습니다.'); return; } - setFeedbackMessage('캡차 검증이 완료되었습니다. 로딩 화면으로 이동합니다.'); - void navigate({ to: '/loading' }); + setFeedbackMessage('캡차 검증이 완료되었습니다. 스캔 화면으로 이동합니다.'); + void navigate({ to: '/qr-scan' }); } finally { setIsVerifying(false); } - }, [effectiveProvider, navigate, recaptchaSiteKey, token]); + }, [navigate, recaptchaSiteKey, token]); return { - configuredProvider, - effectiveProvider, + canSubmit, feedbackMessage, handleCaptchaTokenChange, - handleMockToggle, - handleProviderChange, handleSubmit, - isUsingMockFallback, isVerifying, recaptchaSiteKey, - selectedProvider, - token, }; } diff --git a/src/pages/Captcha/styles/captchaPage.css.ts b/src/pages/Captcha/styles/captchaPage.css.ts index d0a4b27..0f3e4ae 100644 --- a/src/pages/Captcha/styles/captchaPage.css.ts +++ b/src/pages/Captcha/styles/captchaPage.css.ts @@ -53,29 +53,6 @@ export const card = style({ padding: vars.spacing.lg, }); -export const modeSwitch = style({ - display: 'grid', - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - gap: vars.spacing.sm, -}); - -export const modeButton = style({ - border: '1px solid rgba(255, 255, 255, 0.35)', - borderRadius: vars.radius.md, - backgroundColor: 'transparent', - color: vars.colors.white, - padding: `${vars.spacing.sm} ${vars.spacing.md}`, - fontSize: vars.font.size.sm, - fontWeight: vars.font.weight.medium, - cursor: 'pointer', -}); - -export const modeButtonActive = style({ - backgroundColor: vars.colors.white, - color: vars.colors.black, - borderColor: vars.colors.white, -}); - export const providerLabel = style({ margin: 0, color: vars.colors.white, @@ -98,53 +75,6 @@ export const enterpriseBox = style({ minHeight: '78px', }); -export const mockCheckbox = style({ - display: 'grid', - gridTemplateColumns: '24px 1fr', - alignItems: 'center', - columnGap: vars.spacing.sm, - cursor: 'pointer', -}); - -export const mockCheckboxInput = style({ - position: 'absolute', - opacity: 0, - pointerEvents: 'none', -}); - -export const mockCheckboxIndicator = style({ - width: '24px', - height: '24px', - borderRadius: vars.radius.sm, - border: `1px solid ${vars.colors.border}`, - backgroundColor: vars.colors.white, - selectors: { - [`${mockCheckboxInput}:checked + &`]: { - backgroundColor: vars.colors.success, - borderColor: vars.colors.success, - boxShadow: `inset 0 0 0 3px ${vars.colors.white}`, - }, - [`${mockCheckboxInput}:focus-visible + &`]: { - outline: `2px solid ${vars.colors.main}`, - outlineOffset: '2px', - }, - }, -}); - -export const mockCheckboxText = style({ - color: vars.colors.black, - fontSize: vars.font.size.lg, - fontWeight: vars.font.weight.medium, - lineHeight: 1.3, -}); - -export const fallbackMessage = style({ - margin: 0, - color: vars.colors.warning, - fontSize: vars.font.size.sm, - lineHeight: 1.4, -}); - export const captchaHint = style({ margin: 0, color: vars.colors.mainXLight, @@ -181,11 +111,3 @@ export const feedback = style({ fontSize: vars.font.size.sm, lineHeight: 1.4, }); - -export const envHint = style({ - margin: 0, - color: vars.colors.subDark, - fontSize: vars.font.size.xs, - lineHeight: 1.5, - textAlign: 'center', -}); diff --git a/src/pages/Captcha/types/captcha.types.ts b/src/pages/Captcha/types/captcha.types.ts index 4a6b2bf..c6de047 100644 --- a/src/pages/Captcha/types/captcha.types.ts +++ b/src/pages/Captcha/types/captcha.types.ts @@ -1,5 +1,3 @@ -export type CaptchaProvider = 'mock' | 'googleRecaptchaEnterprise'; - export type CaptchaVerifyResponse = { message?: string; success: boolean; diff --git a/src/pages/Captcha/ui/CaptchaWidget.tsx b/src/pages/Captcha/ui/CaptchaWidget.tsx index addefcf..ee45efe 100644 --- a/src/pages/Captcha/ui/CaptchaWidget.tsx +++ b/src/pages/Captcha/ui/CaptchaWidget.tsx @@ -1,15 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import * as styles from '../styles/captchaPage.css'; -import type { CaptchaProvider } from '../types/captcha.types'; - type CaptchaWidgetProps = { - onMockToggle: (checked: boolean) => void; onTokenChange: (token: string | null) => void; - provider: CaptchaProvider; recaptchaSiteKey: string; - token: string | null; }; type GrecaptchaEnterprise = { @@ -24,6 +19,7 @@ type GrecaptchaEnterprise = { theme?: 'light' | 'dark'; }, ) => number; + reset?: (widgetId: number) => void; }; declare global { @@ -112,21 +108,13 @@ function loadEnterpriseScript(): Promise { return scriptPromise; } -export default function CaptchaWidget({ - onMockToggle, - onTokenChange, - provider, - recaptchaSiteKey, - token, -}: CaptchaWidgetProps) { +export default function CaptchaWidget({ onTokenChange, recaptchaSiteKey }: CaptchaWidgetProps) { const widgetContainerRef = useRef(null); useEffect(() => { - if (provider !== 'googleRecaptchaEnterprise') { - return; - } - const container = widgetContainerRef.current; + let cancelled = false; + let widgetId: number | null = null; if (!container || !recaptchaSiteKey) { return; @@ -136,54 +124,86 @@ export default function CaptchaWidget({ loadEnterpriseScript() .then(() => { + if (cancelled || widgetContainerRef.current !== container) { + return; + } + const enterprise = window.grecaptcha?.enterprise; if (!enterprise) { + if (cancelled) { + return; + } + onTokenChange(null); return; } enterprise.ready(() => { - enterprise.render(container, { - callback: (nextToken) => { - onTokenChange(nextToken); - }, - 'error-callback': () => { - onTokenChange(null); - }, - 'expired-callback': () => { - onTokenChange(null); - }, - sitekey: recaptchaSiteKey, - theme: 'light', - }); + if (cancelled || widgetContainerRef.current !== container) { + return; + } + + try { + widgetId = enterprise.render(container, { + callback: (nextToken) => { + if (cancelled) { + return; + } + + onTokenChange(nextToken); + }, + 'error-callback': () => { + if (cancelled) { + return; + } + + onTokenChange(null); + }, + 'expired-callback': () => { + if (cancelled) { + return; + } + + onTokenChange(null); + }, + sitekey: recaptchaSiteKey, + theme: 'light', + }); + } catch (error) { + widgetId = null; + + if (cancelled) { + return; + } + + console.error('Failed to render reCAPTCHA Enterprise widget.', error); + onTokenChange(null); + } }); }) .catch(() => { + if (cancelled) { + return; + } + onTokenChange(null); }); - }, [onTokenChange, provider, recaptchaSiteKey]); - if (provider === 'googleRecaptchaEnterprise') { - return ( -
-
-
- ); - } + return () => { + cancelled = true; + + if (widgetId !== null) { + window.grecaptcha?.enterprise?.reset?.(widgetId); + } + + container.innerHTML = ''; + }; + }, [onTokenChange, recaptchaSiteKey]); return ( - +
+
+
); } diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx deleted file mode 100644 index e9e0f2b..0000000 --- a/src/pages/Home/HomePage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Link } from '@tanstack/react-router'; - -const routeLinks = [ - { to: '/', label: '__root__' }, - { to: '/', label: '/' }, - { to: '/captcha', label: '/captcha' }, - { to: '/loading', label: '/loading' }, - { to: '/qr-scan', label: '/qr-scan' }, - { to: '/report', label: '/report' }, - { to: '/scan-history', label: '/scan-history' }, - { to: '/scan-list', label: '/scan-list' }, - { to: '/result/critical', label: '/result/critical' }, - { to: '/result/non-url', label: '/result/non-url' }, - { to: '/result/safe', label: '/result/safe' }, - { to: '/result/warning', label: '/result/warning' }, -] as const; - -export default function HomePage() { - return ( -
- -
- ); -} diff --git a/src/pages/Home/index.ts b/src/pages/Home/index.ts deleted file mode 100644 index bf97a3e..0000000 --- a/src/pages/Home/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './HomePage'; diff --git a/src/pages/Loading/LoadingPage.tsx b/src/pages/Loading/LoadingPage.tsx index cb600a1..8467121 100644 --- a/src/pages/Loading/LoadingPage.tsx +++ b/src/pages/Loading/LoadingPage.tsx @@ -31,7 +31,6 @@ type StepIconSet = { pending: string; }; -const availableLoadingCases = [1, 2, 3, 4, 5, 6, 7, 8] as const; const progressRadius = 100; const progressCircumference = 2 * Math.PI * progressRadius; @@ -41,37 +40,27 @@ const stateLabel: Record = { pending: '준비', }; +const analysisIconSet: StepIconSet = { + active: analysisReadyIcon, + done: analysisDoneIcon, + pending: analysisPendingIcon, +}; + const stepIconMap: Record = { aiAnalysis: { active: aiAnalysisReadyIcon, done: aiAnalysisDoneIcon, pending: aiAnalysisReadyIcon, }, - completed: { - active: analysisReadyIcon, - done: analysisDoneIcon, - pending: analysisPendingIcon, - }, + completed: analysisIconSet, decode: { active: decodingReadyIcon, done: decodingDoneIcon, pending: decodingReadyIcon, }, - externalApi: { - active: analysisReadyIcon, - done: analysisDoneIcon, - pending: analysisPendingIcon, - }, - internalDb: { - active: analysisReadyIcon, - done: analysisDoneIcon, - pending: analysisPendingIcon, - }, - redirect: { - active: analysisReadyIcon, - done: analysisDoneIcon, - pending: analysisPendingIcon, - }, + externalApi: analysisIconSet, + internalDb: analysisIconSet, + redirect: analysisIconSet, report: { active: reportReadyIcon, done: reportDoneIcon, @@ -82,32 +71,26 @@ const stepIconMap: Record = { done: riskScoreDoneIcon, pending: riskScoreReadyIcon, }, - shortUrlCheck: { - active: analysisReadyIcon, - done: analysisDoneIcon, - pending: analysisPendingIcon, - }, + ruleAnalysis: analysisIconSet, + shortUrlCheck: analysisIconSet, + urlNormalize: analysisIconSet, }; export default function LoadingPage() { + const { loadingPageData } = useLoadingPage(); const { - handleCaseChange, - handleRandomCaseChange, - handleSequentialDemoStart, - isSequentialDemoMode, - loadingCaseNumber, - loadingPageData, - } = useLoadingPage(); - const { getDetailStepState, getStepState, progress, statusDescription, visibleStepCount } = - useLoadingProgress(loadingPageData.steps, { - progressIntervalMs: loadingPageData.progressIntervalMs, - revealStepsSequentially: isSequentialDemoMode, - }); + getDetailStepState, + getStepState, + progress, + progressLabel, + progressMetaText, + statusDescription, + visibleStepIds, + } = useLoadingProgress(loadingPageData.steps); const visibleSteps = useMemo( - () => loadingPageData.steps.slice(0, visibleStepCount), - [loadingPageData.steps, visibleStepCount], + () => loadingPageData.steps.filter((step) => visibleStepIds.includes(step.id)), + [loadingPageData.steps, visibleStepIds], ); - const dashOffset = progressCircumference - (progress / 100) * progressCircumference; return ( @@ -136,7 +119,8 @@ export default function LoadingPage() { className={progress === 100 ? styles.centerIconDone : styles.centerIconPreparing} src={progress === 100 ? shieldPercentIcon : totalAnalysisReadyIcon} /> -

{progress}%

+

{progressLabel}

+

{progressMetaText}

@@ -218,53 +202,6 @@ export default function LoadingPage() { })} - -
-
-

테스트 케이스 선택

-

- 현재 case {loadingCaseNumber} - {isSequentialDemoMode ? ' · 순차 분기 데모' : ' · 전체 흐름 보기'} -

-
- -
- {availableLoadingCases.map((caseNumber) => ( - - ))} - - - - -
-
); diff --git a/src/pages/Loading/api/fetchLoadingPageData.ts b/src/pages/Loading/api/fetchLoadingPageData.ts deleted file mode 100644 index 845e4b5..0000000 --- a/src/pages/Loading/api/fetchLoadingPageData.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getLoadingSteps } from '../loadingScenario'; - -import type { - LoadingCaseNumber, - LoadingPageData, - LoadingRevealMode, -} from '../types/loadingPage.types'; - -const MOCK_LOADING_DELAY_MS = 120; -const DEFAULT_PROGRESS_INTERVAL_MS = 90; - -function buildLoadingPageData( - caseNumber: LoadingCaseNumber, - revealMode: LoadingRevealMode, -): LoadingPageData { - return { - caseNumber, - progressIntervalMs: DEFAULT_PROGRESS_INTERVAL_MS, - revealMode, - steps: getLoadingSteps(caseNumber), - }; -} - -export const mockLoadingPageDataByCase: Record = { - 1: buildLoadingPageData(1, 'full'), - 2: buildLoadingPageData(2, 'full'), - 3: buildLoadingPageData(3, 'full'), - 4: buildLoadingPageData(4, 'full'), - 5: buildLoadingPageData(5, 'full'), - 6: buildLoadingPageData(6, 'full'), - 7: buildLoadingPageData(7, 'full'), - 8: buildLoadingPageData(8, 'full'), -}; - -export const mockSequentialLoadingPageDataByCase: Record = { - 1: buildLoadingPageData(1, 'sequential'), - 2: buildLoadingPageData(2, 'sequential'), - 3: buildLoadingPageData(3, 'sequential'), - 4: buildLoadingPageData(4, 'sequential'), - 5: buildLoadingPageData(5, 'sequential'), - 6: buildLoadingPageData(6, 'sequential'), - 7: buildLoadingPageData(7, 'sequential'), - 8: buildLoadingPageData(8, 'sequential'), -}; - -export async function fetchLoadingPageData( - caseNumber: LoadingCaseNumber, - revealMode: LoadingRevealMode, -): Promise { - await new Promise((resolve) => { - window.setTimeout(resolve, MOCK_LOADING_DELAY_MS); - }); - - return getInitialLoadingPageData(caseNumber, revealMode); -} - -export function getInitialLoadingPageData( - caseNumber: LoadingCaseNumber, - revealMode: LoadingRevealMode, -): LoadingPageData { - return revealMode === 'sequential' - ? mockSequentialLoadingPageDataByCase[caseNumber] - : mockLoadingPageDataByCase[caseNumber]; -} diff --git a/src/pages/Loading/constants/stepText.ts b/src/pages/Loading/constants/stepText.ts new file mode 100644 index 0000000..4e266f7 --- /dev/null +++ b/src/pages/Loading/constants/stepText.ts @@ -0,0 +1,112 @@ +import type { LoadingDetailStep, LoadingStep, LoadingStepId } from '../types/loadingPage.types'; + +const analysisDetailSteps: LoadingDetailStep[] = [ + { + title: '도메인 신뢰도 확인', + weight: 25, + }, + { + title: '파라미터 위험 패턴 확인', + weight: 25, + }, + { + title: 'ML 위험도 추론', + weight: 25, + }, + { + title: '최종 점수 산정', + weight: 25, + }, +]; + +export const stepTextMap: Record = { + aiAnalysis: { + activeDescription: 'ML 모델로 URL의 위험 패턴을 분석하고 있습니다.', + details: analysisDetailSteps, + doneDescription: 'ML 기반 위험 분석이 완료되었습니다.', + id: 'aiAnalysis', + pendingDescription: 'ML 기반 위험 분석을 준비하고 있습니다.', + title: 'ML 위험 분석', + weight: 14, + }, + completed: { + activeDescription: '분석 결과를 정리하고 결과 페이지로 이동할 준비를 하고 있습니다.', + doneDescription: '분석이 완료되었습니다. 결과 페이지로 이동합니다.', + id: 'completed', + pendingDescription: '최종 결과 정리를 준비하고 있습니다.', + title: '분석 완료', + weight: 6, + }, + decode: { + activeDescription: 'QR 코드에서 분석할 URL 정보를 확인하고 있습니다.', + doneDescription: '분석 대상 URL 확인이 완료되었습니다.', + id: 'decode', + pendingDescription: 'QR 코드 해석을 준비하고 있습니다.', + title: 'QR 해석', + weight: 8, + }, + externalApi: { + activeDescription: '외부 위협 정보에서 악성 여부를 조회하고 있습니다.', + doneDescription: '외부 위협 정보 조회가 완료되었습니다.', + id: 'externalApi', + pendingDescription: '외부 위협 정보 조회를 준비하고 있습니다.', + title: '외부 위협 조회', + weight: 12, + }, + internalDb: { + activeDescription: '내부 DB에서 기존 분석 이력을 조회하고 있습니다.', + doneDescription: '내부 DB 조회가 완료되었습니다.', + id: 'internalDb', + pendingDescription: '내부 DB 조회를 준비하고 있습니다.', + title: '내부 DB 조회', + weight: 10, + }, + redirect: { + activeDescription: '리다이렉트 경로와 최종 도착 URL을 확인하고 있습니다.', + doneDescription: '리다이렉트 확인이 완료되었습니다.', + id: 'redirect', + pendingDescription: '리다이렉트 확인을 준비하고 있습니다.', + title: '리다이렉트 확인', + weight: 10, + }, + report: { + activeDescription: '분석 결과를 화면 데이터로 정리하고 있습니다.', + doneDescription: '결과 데이터 정리가 완료되었습니다.', + id: 'report', + pendingDescription: '결과 데이터 정리를 준비하고 있습니다.', + title: '결과 정리', + weight: 6, + }, + riskScore: { + activeDescription: '검사 결과를 바탕으로 최종 위험 점수를 계산하고 있습니다.', + doneDescription: '최종 위험도 계산이 완료되었습니다.', + id: 'riskScore', + pendingDescription: '최종 위험도 계산을 준비하고 있습니다.', + title: '위험도 계산', + weight: 10, + }, + ruleAnalysis: { + activeDescription: '도메인과 파라미터 규칙을 기준으로 위험 요소를 검사하고 있습니다.', + doneDescription: '규칙 기반 분석이 완료되었습니다.', + id: 'ruleAnalysis', + pendingDescription: '규칙 기반 분석을 준비하고 있습니다.', + title: '규칙 기반 분석', + weight: 12, + }, + shortUrlCheck: { + activeDescription: '단축 URL 여부를 확인하고 있습니다.', + doneDescription: '단축 URL 여부 확인이 완료되었습니다.', + id: 'shortUrlCheck', + pendingDescription: '단축 URL 여부 확인을 준비하고 있습니다.', + title: '단축 URL 확인', + weight: 8, + }, + urlNormalize: { + activeDescription: '분석에 사용할 URL 형식을 정규화하고 있습니다.', + doneDescription: 'URL 정규화가 완료되었습니다.', + id: 'urlNormalize', + pendingDescription: 'URL 정규화를 준비하고 있습니다.', + title: 'URL 정규화', + weight: 8, + }, +}; diff --git a/src/pages/Loading/hooks/useLoadingPage.ts b/src/pages/Loading/hooks/useLoadingPage.ts index c98b095..df02e41 100644 --- a/src/pages/Loading/hooks/useLoadingPage.ts +++ b/src/pages/Loading/hooks/useLoadingPage.ts @@ -1,123 +1,309 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { App } from 'antd'; +import { useCallback, useEffect, useRef } from 'react'; -import { fetchLoadingPageData, getInitialLoadingPageData } from '../api/fetchLoadingPageData'; -import { isLoadingCaseNumber } from '../loadingScenario'; +import { isApiError } from '@/shared/api/errors/apiError'; +import { pickString } from '@/shared/api/responseAccess/payloadAccess'; +import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import { mapSseStepId } from '@/shared/api/sse/sseStepMapper'; +import { ensureScanDetail } from '@/shared/lib/scan-session/ensureScanDetail'; +import { useScanSubscription } from '@/shared/lib/sse/useScanSubscription'; +import { useGuestStore } from '@/shared/store/guestStore'; +import { useScanProgressStore } from '@/shared/store/scanProgressStore'; +import { getScanSessionSnapshot, useScanSessionStore } from '@/shared/store/scanSessionStore'; -import type { - LoadingCaseNumber, - LoadingPageData, - LoadingRevealMode, -} from '../types/loadingPage.types'; +import { getLoadingSteps } from '../loadingScenario'; + +import type { LoadingPageData } from '../types/loadingPage.types'; type UseLoadingPageReturn = { - handleCaseChange: (caseNumber: LoadingCaseNumber) => void; - handleRandomCaseChange: () => void; - handleSequentialDemoStart: () => void; - isSequentialDemoMode: boolean; - loadingCaseNumber: LoadingCaseNumber; loadingPageData: LoadingPageData; }; -const DEFAULT_LOADING_CASE: LoadingCaseNumber = 6; -const AVAILABLE_LOADING_CASES: LoadingCaseNumber[] = [1, 2, 3, 4, 5, 6, 7, 8]; - -function resolveInitialLoadingCaseNumber() { - if (typeof window === 'undefined') { - return DEFAULT_LOADING_CASE; - } +const DETAIL_RETRY_DELAY_MS = 2_000; +const DETAIL_RETRY_MAX = 60; +const DETAIL_POLL_MAX_ATTEMPTS = 1; +const DETAIL_POLL_START_DELAY_MS = 5_000; +const DETAIL_RESOLUTION_ERROR_MESSAGE = 'Analysis detail could not be loaded. Please try again.'; +const DETAIL_RESOLUTION_TIMEOUT_MESSAGE = 'Analysis detail was not ready in time. Please retry.'; +const DEFAULT_LOADING_PAGE_DATA: LoadingPageData = { + steps: getLoadingSteps(), +}; - const caseParam = Number(new URLSearchParams(window.location.search).get('case')); +function openResultRouteForCurrentSession() { + const session = getScanSessionSnapshot(); - if (isLoadingCaseNumber(caseParam)) { - return caseParam; + if ( + session.isUrl === false || + (session.schemeType && session.schemeType.trim().toUpperCase() !== 'WEB') + ) { + window.location.assign('/result/non-url'); + return; } - return DEFAULT_LOADING_CASE; -} + const riskLevel = + resolveResultToneFromSources( + [session.analysisDetail, session.finalResult, session.scanResponse, session.historySelection], + session.riskLevel, + ) ?? 'warning'; + const routeByRiskLevel = { + critical: '/result/critical', + safe: '/result/safe', + warning: '/result/warning', + } as const; + const route = routeByRiskLevel[riskLevel]; -function updateCaseSearchParam(caseNumber: LoadingCaseNumber) { - if (typeof window === 'undefined') { + if (session.decodedUrl) { + window.location.assign(`${route}?url=${encodeURIComponent(session.decodedUrl)}`); return; } - const searchParams = new URLSearchParams(window.location.search); - searchParams.set('case', String(caseNumber)); + window.location.assign(route); +} + +function resolveScanIdentifier(source: unknown): string | null { + return pickString(source, ['scanId', 'scan_id', 'id']); +} - const nextSearch = searchParams.toString(); - const nextUrl = nextSearch - ? `${window.location.pathname}?${nextSearch}` - : window.location.pathname; +function isSameScanFinalResult(finalResult: unknown, scanResponse: unknown): boolean { + const finalResultId = resolveScanIdentifier(finalResult); + const scanResponseId = resolveScanIdentifier(scanResponse); - window.history.replaceState(null, '', nextUrl); + return Boolean(finalResultId && scanResponseId && finalResultId === scanResponseId); } export function useLoadingPage(): UseLoadingPageReturn { - const [loadingCaseNumber, setLoadingCaseNumber] = useState( - resolveInitialLoadingCaseNumber, - ); - const [revealMode, setRevealMode] = useState('full'); - const [scenarioVersion, setScenarioVersion] = useState(0); - const [loadingPageData, setLoadingPageData] = useState(() => - getInitialLoadingPageData(resolveInitialLoadingCaseNumber(), 'full'), - ); + const { message } = App.useApp(); + const navigate = useNavigate(); + const guestUuid = useGuestStore((state) => state.guestUuid); + const finalResult = useScanSessionStore((state) => state.finalResult); + const scanResponse = useScanSessionStore((state) => state.scanResponse); + const setFinalResult = useScanSessionStore((state) => state.setFinalResult); + const resetProgress = useScanProgressStore((state) => state.reset); + const setCompleted = useScanProgressStore((state) => state.setCompleted); + const setConnecting = useScanProgressStore((state) => state.setConnecting); + const setError = useScanProgressStore((state) => state.setError); + const updateFromProgressEvent = useScanProgressStore((state) => state.updateFromProgressEvent); + const detailResolutionFailedRef = useRef(false); + const detailResolutionStartedRef = useRef(false); + const isMountedRef = useRef(true); + const pollAttemptCountRef = useRef(0); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); useEffect(() => { - const fallbackData = getInitialLoadingPageData(loadingCaseNumber, revealMode); + if (!scanResponse && !finalResult) { + void navigate({ to: '/' }); + return; + } + + if (finalResult && scanResponse && isSameScanFinalResult(finalResult, scanResponse)) { + return; + } + + resetProgress(); + detailResolutionFailedRef.current = false; + detailResolutionStartedRef.current = false; + pollAttemptCountRef.current = 0; + setConnecting(); + }, [finalResult, navigate, resetProgress, scanResponse, setConnecting]); + + const failDetailResolution = useCallback( + (errorMessage: string) => { + if (detailResolutionFailedRef.current) { + return; + } + + detailResolutionFailedRef.current = true; + detailResolutionStartedRef.current = true; + setError(errorMessage); + message.error(errorMessage); + }, + [message, setError], + ); - setLoadingPageData(fallbackData); + const resolveDetailAndOpenResult = useCallback(async () => { + if (detailResolutionStartedRef.current || detailResolutionFailedRef.current) { + return false; + } - let isMounted = true; + detailResolutionStartedRef.current = true; - const loadLoadingPageData = async () => { + for (let attempt = 0; attempt < DETAIL_RETRY_MAX; attempt += 1) { try { - const response = await fetchLoadingPageData(loadingCaseNumber, revealMode); + await ensureScanDetail(); - if (isMounted) { - setLoadingPageData(response); + if (!isMountedRef.current) { + return false; } + + setCompleted(); + openResultRouteForCurrentSession(); + return true; } catch (error) { - console.error('Failed to load loading page data.', error); + if (!isMountedRef.current) { + return false; + } + + if (error instanceof Error && error.message === 'SCAN_SESSION_REQUIRED') { + detailResolutionStartedRef.current = false; + return false; + } - if (isMounted) { - setLoadingPageData(fallbackData); + if (!isApiError(error) || error.statusCode !== 404) { + failDetailResolution( + error instanceof Error ? error.message : DETAIL_RESOLUTION_ERROR_MESSAGE, + ); + throw error; } + + await new Promise((resolve) => { + window.setTimeout(() => resolve(), DETAIL_RETRY_DELAY_MS); + }); + } + } + + failDetailResolution(DETAIL_RESOLUTION_TIMEOUT_MESSAGE); + return false; + }, [failDetailResolution, setCompleted]); + + const tryResolveDetailAfterTerminalProgress = useCallback( + async (payload: Record) => { + const rawStatus = pickString(payload, ['status', 'state'])?.trim().toLowerCase(); + const rawStep = pickString(payload, [ + 'currentStepId', + 'current_step_id', + 'step', + 'stepId', + 'step_id', + ]); + const mappedStepId = mapSseStepId(rawStep); + const isTerminalStep = + mappedStepId === 'riskScore' || mappedStepId === 'report' || mappedStepId === 'completed'; + + if (rawStatus !== 'completed' || !isTerminalStep) { + return; } + + try { + await resolveDetailAndOpenResult(); + } catch { + return; + } + }, + [resolveDetailAndOpenResult], + ); + + useEffect(() => { + if (finalResult || !scanResponse) { + return; + } + + const session = getScanSessionSnapshot(); + + if (!session.decodedUrl && !session.historySelection?.url) { + return; + } + + let isDisposed = false; + let pollTimerId: number | null = null; + + const startPolling = async () => { + if ( + isDisposed || + detailResolutionFailedRef.current || + detailResolutionStartedRef.current || + pollAttemptCountRef.current >= DETAIL_POLL_MAX_ATTEMPTS + ) { + return; + } + + pollAttemptCountRef.current += 1; + + let isResolved = false; + + try { + isResolved = await resolveDetailAndOpenResult(); + } catch { + return; + } + + if (isDisposed || isResolved || detailResolutionFailedRef.current) { + return; + } + + if (pollAttemptCountRef.current >= DETAIL_POLL_MAX_ATTEMPTS) { + failDetailResolution(DETAIL_RESOLUTION_TIMEOUT_MESSAGE); + return; + } + + pollTimerId = window.setTimeout(() => { + void startPolling(); + }, DETAIL_POLL_START_DELAY_MS); }; - void loadLoadingPageData(); + pollTimerId = window.setTimeout(() => { + void startPolling(); + }, DETAIL_POLL_START_DELAY_MS); return () => { - isMounted = false; + isDisposed = true; + + if (pollTimerId !== null) { + window.clearTimeout(pollTimerId); + } }; - }, [loadingCaseNumber, revealMode, scenarioVersion]); + }, [failDetailResolution, finalResult, resolveDetailAndOpenResult, scanResponse]); - const handleCaseChange = useCallback((caseNumber: LoadingCaseNumber) => { - setLoadingCaseNumber(caseNumber); - setRevealMode('full'); - setScenarioVersion((prev) => prev + 1); - updateCaseSearchParam(caseNumber); - }, []); + useScanSubscription({ + enabled: Boolean(guestUuid) && Boolean(scanResponse || finalResult), + guestUuid, + onError: (errorMessage) => { + setError(errorMessage); + message.error(errorMessage); + }, + onFinal: async (payload) => { + setFinalResult(payload); - const handleRandomCaseChange = useCallback(() => { - const randomIndex = Math.floor(Math.random() * AVAILABLE_LOADING_CASES.length); - const randomCase = AVAILABLE_LOADING_CASES[randomIndex]; + const hasResolvedRiskLevel = resolveResultToneFromSources([payload], null) !== null; - handleCaseChange(randomCase); - }, [handleCaseChange]); + if (!hasResolvedRiskLevel) { + let isResolved = false; - const handleSequentialDemoStart = useCallback(() => { - setRevealMode('sequential'); - setScenarioVersion((prev) => prev + 1); - }, []); + try { + isResolved = await resolveDetailAndOpenResult(); + } catch { + return; + } + + if (isResolved) { + return; + } + + if (detailResolutionFailedRef.current) { + return; + } + + return; + } - const isSequentialDemoMode = revealMode === 'sequential'; + detailResolutionStartedRef.current = true; + setCompleted(); + openResultRouteForCurrentSession(); + }, + onProgress: (payload) => { + updateFromProgressEvent(payload); + void tryResolveDetailAfterTerminalProgress(payload); + }, + }); return { - handleCaseChange, - handleRandomCaseChange, - handleSequentialDemoStart, - isSequentialDemoMode, - loadingCaseNumber, - loadingPageData, + loadingPageData: DEFAULT_LOADING_PAGE_DATA, }; } diff --git a/src/pages/Loading/hooks/useLoadingProgress.ts b/src/pages/Loading/hooks/useLoadingProgress.ts index 45528a0..a02be8d 100644 --- a/src/pages/Loading/hooks/useLoadingProgress.ts +++ b/src/pages/Loading/hooks/useLoadingProgress.ts @@ -1,78 +1,84 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; -import { - getProgressRanges, - getProgressState, - getThresholds, - resolveLocalProgress, -} from '../loadingProgressUtils'; +import { useScanProgressStore } from '@/shared/store/scanProgressStore'; -import type { LoadingDetailStep, LoadingState, LoadingStep } from '../types/loadingPage.types'; - -const MAX_PROGRESS = 100; -const DEFAULT_PROGRESS_INTERVAL_MS = 90; +import type { LoadingState, LoadingStep } from '../types/loadingPage.types'; type LoadingProgressResult = { - visibleStepCount: number; getDetailStepState: (stepIndex: number, detailIndex: number) => LoadingState; getStepState: (index: number) => LoadingState; progress: number; + progressLabel: string; + progressMetaText: string; statusDescription: string; + visibleStepIds: string[]; }; -type UseLoadingProgressOptions = { - progressIntervalMs?: number; - revealStepsSequentially?: boolean; -}; - -export function useLoadingProgress( - steps: LoadingStep[], - options?: UseLoadingProgressOptions, -): LoadingProgressResult { - const [progress, setProgress] = useState(0); - const progressIntervalMs = options?.progressIntervalMs ?? DEFAULT_PROGRESS_INTERVAL_MS; - const revealStepsSequentially = options?.revealStepsSequentially ?? true; +function formatBackendStepLabel(rawStep: string): string { + return rawStep.trim().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').toUpperCase(); +} - useEffect(() => { - setProgress(0); +export function useLoadingProgress(steps: LoadingStep[]): LoadingProgressResult { + const backendMessage = useScanProgressStore((state) => state.backendMessage); + const backendStatus = useScanProgressStore((state) => state.backendStatus); + const backendStep = useScanProgressStore((state) => state.backendStep); + const completedStepIds = useScanProgressStore((state) => state.completedStepIds); + const currentStepId = useScanProgressStore((state) => state.currentStepId); + const errorMessage = useScanProgressStore((state) => state.errorMessage); + const progressFromStore = useScanProgressStore((state) => state.percent); + const progressStatus = useScanProgressStore((state) => state.status); + const progress = progressFromStore; + + const totalStepCount = useMemo(() => { + return steps.filter((step) => step.id !== 'completed').length || steps.length; }, [steps]); - useEffect(() => { - const intervalId = window.setInterval(() => { - setProgress((prev) => { - if (prev >= MAX_PROGRESS) { - return MAX_PROGRESS; - } + const completedStepCount = useMemo(() => { + return completedStepIds.filter((stepId) => stepId !== 'completed').length; + }, [completedStepIds]); - return prev + 1; - }); - }, progressIntervalMs); + const activeStepIndex = useMemo(() => { + return currentStepId ? steps.findIndex((step) => step.id === currentStepId) : -1; + }, [currentStepId, steps]); + + const visibleStepIds = useMemo(() => { + const currentStepIndex = currentStepId + ? steps.findIndex((step) => step.id === currentStepId) + : -1; + const lastCompletedStepIndex = completedStepIds.reduce((lastIndex, stepId) => { + const stepIndex = steps.findIndex((step) => step.id === stepId); + + return Math.max(lastIndex, stepIndex); + }, -1); + const lastReachedStepIndex = Math.max(currentStepIndex, lastCompletedStepIndex, 0); + const lastVisibleStepIndex = + progressStatus === 'completed' + ? steps.length - 1 + : Math.min(lastReachedStepIndex + 1, steps.length - 1); + + return steps.slice(0, lastVisibleStepIndex + 1).map((step) => step.id); + }, [completedStepIds, currentStepId, progressStatus, steps]); - return () => { - window.clearInterval(intervalId); - }; - }, [progressIntervalMs, steps]); + const getStepState = (index: number): LoadingState => { + const step = steps[index]; - const stepThresholds = useMemo(() => getThresholds(steps), [steps]); - const stepRanges = useMemo(() => getProgressRanges(stepThresholds), [stepThresholds]); - const activeStepIndex = useMemo(() => { - const foundIndex = stepThresholds.findIndex((threshold) => progress < threshold); - return foundIndex === -1 ? null : foundIndex; - }, [progress, stepThresholds]); - const visibleStepCount = useMemo(() => { - if (!revealStepsSequentially) { - return steps.length; + if (!step) { + return 'pending'; } - if (activeStepIndex === null) { - return steps.length; + if (completedStepIds.includes(step.id)) { + return 'done'; } - return Math.min(activeStepIndex + 1, steps.length); - }, [activeStepIndex, revealStepsSequentially, steps.length]); + if (currentStepId === step.id) { + return 'active'; + } - const getStepState = (index: number): LoadingState => { - return getProgressState(progress, stepThresholds, index); + if (progressStatus === 'connecting' && index === 0) { + return 'active'; + } + + return 'pending'; }; const getDetailStepState = (stepIndex: number, detailIndex: number): LoadingState => { @@ -85,39 +91,113 @@ export function useLoadingProgress( const stepState = getStepState(stepIndex); - if (stepState === 'pending') { - return 'pending'; - } - if (stepState === 'done') { return 'done'; } - const stepRange = stepRanges[stepIndex]; + if (stepState === 'active') { + return detailIndex === 0 ? 'active' : 'pending'; + } + + return 'pending'; + }; - if (!stepRange) { - return 'pending'; + const progressLabel = useMemo(() => { + if (progressStatus === 'completed') { + return '완료'; } - const localProgress = resolveLocalProgress(progress, stepRange); - const detailThresholds = getThresholds(detailSteps); + const derivedStepNumber = + activeStepIndex >= 0 + ? Math.min(activeStepIndex + 1, totalStepCount) + : Math.min(Math.max(completedStepCount + 1, 1), totalStepCount); - return getProgressState(localProgress, detailThresholds, detailIndex); - }; + return `${derivedStepNumber} / ${totalStepCount}`; + }, [activeStepIndex, completedStepCount, progressStatus, totalStepCount]); + + const progressMetaText = useMemo(() => { + if (progressStatus === 'completed') { + return '분석 종료'; + } + + const activeStep = steps.find((step) => step.id === currentStepId); + + if (activeStep) { + return activeStep.title; + } + + if (backendStep) { + return formatBackendStepLabel(backendStep); + } + + return '분석 단계'; + }, [backendStep, currentStepId, progressStatus, steps]); const statusDescription = useMemo(() => { - if (activeStepIndex === null) { - return steps[steps.length - 1]?.doneDescription ?? '분석이 완료되었습니다.'; + if (progressStatus === 'error') { + return errorMessage ?? '실시간 분석 중 문제가 발생했습니다.'; + } + + if (progressStatus === 'completed') { + return ( + backendMessage ?? + steps.find((step) => step.id === 'completed')?.doneDescription ?? + '분석이 완료되었습니다.' + ); + } + + if (backendMessage) { + return backendMessage; + } + + const activeStep = steps.find((step) => step.id === currentStepId); + + if (activeStep) { + if (completedStepIds.includes(activeStep.id)) { + const activeStepIndex = steps.findIndex((step) => step.id === activeStep.id); + const nextStep = steps[activeStepIndex + 1]; + + if (nextStep && nextStep.id !== 'completed') { + return `${activeStep.doneDescription} 다음 단계: ${nextStep.title}`; + } + + return activeStep.doneDescription; + } + + return activeStep.activeDescription; + } + + if (progressStatus === 'connecting') { + return '실시간 분석 연결을 준비하고 있습니다...'; + } + + if (backendStep && backendStatus === 'completed') { + return `${formatBackendStepLabel(backendStep)} 단계를 완료했습니다.`; + } + + if (backendStep) { + return `${formatBackendStepLabel(backendStep)} 단계를 처리하고 있습니다.`; } - return steps[activeStepIndex]?.activeDescription ?? '분석을 진행하고 있습니다.'; - }, [activeStepIndex, steps]); + return steps[0]?.pendingDescription ?? '분석을 준비하고 있습니다.'; + }, [ + backendMessage, + backendStatus, + backendStep, + completedStepIds, + currentStepId, + errorMessage, + progressStatus, + steps, + ]); return { - visibleStepCount, getDetailStepState, getStepState, progress, + progressLabel, + progressMetaText, statusDescription, + visibleStepIds, }; } diff --git a/src/pages/Loading/loadingProgressUtils.ts b/src/pages/Loading/loadingProgressUtils.ts deleted file mode 100644 index 8c3de5f..0000000 --- a/src/pages/Loading/loadingProgressUtils.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { LoadingState } from './types/loadingPage.types'; - -type WeightedStep = { - weight: number; -}; - -type ProgressRange = { - end: number; - start: number; -}; - -const MAX_PROGRESS = 100; - -export function getThresholds(items: T[]) { - const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); - let accumulatedWeight = 0; - - return items.map((item, index) => { - accumulatedWeight += item.weight; - - if (index === items.length - 1) { - return MAX_PROGRESS; - } - - return Math.round((accumulatedWeight / totalWeight) * MAX_PROGRESS); - }); -} - -export function getProgressRanges(thresholds: number[]): ProgressRange[] { - let previousThreshold = 0; - - return thresholds.map((threshold) => { - const range = { - end: threshold, - start: previousThreshold, - }; - - previousThreshold = threshold; - - return range; - }); -} - -export function getProgressState( - progress: number, - thresholds: number[], - index: number, -): LoadingState { - const activeIndex = thresholds.findIndex((threshold) => progress < threshold); - - if (activeIndex === -1) { - return 'done'; - } - - if (index < activeIndex) { - return 'done'; - } - - if (index === activeIndex) { - return 'active'; - } - - return 'pending'; -} - -export function resolveLocalProgress(progress: number, range: ProgressRange) { - const rangeSize = Math.max(range.end - range.start, 1); - const clampedProgress = Math.min(Math.max(progress, range.start), range.end); - - return ((clampedProgress - range.start) / rangeSize) * MAX_PROGRESS; -} diff --git a/src/pages/Loading/loadingScenario.ts b/src/pages/Loading/loadingScenario.ts index 3a56d7d..e9b1ec5 100644 --- a/src/pages/Loading/loadingScenario.ts +++ b/src/pages/Loading/loadingScenario.ts @@ -1,231 +1,29 @@ -import type { - LoadingCaseNumber, - LoadingDetailStep, - LoadingStep, - LoadingStepId, -} from './types/loadingPage.types'; - -type LoadingStepOverride = Partial>; - -const aiAnalysisDetailSteps: LoadingDetailStep[] = [ - { - title: '리다이렉트 경로 분석', - weight: 26, - }, - { - title: '글로벌 보안 DB 대조', - weight: 24, - }, - { - title: '도메인 사칭 분석', - weight: 25, - }, - { - title: '서버 및 인증서 검사', - weight: 25, - }, +import { stepTextMap } from './constants/stepText'; + +import type { LoadingStep, LoadingStepId } from './types/loadingPage.types'; + +const loadingStepOrder: LoadingStepId[] = [ + 'decode', + 'shortUrlCheck', + 'urlNormalize', + 'redirect', + 'internalDb', + 'externalApi', + 'ruleAnalysis', + 'aiAnalysis', + 'riskScore', + 'report', + 'completed', ]; -const loadingStepMap: Record = { - aiAnalysis: { - activeDescription: '딥러닝 엔진이 위험 요소를 탐지하고 있습니다...', - details: aiAnalysisDetailSteps, - doneDescription: 'AI 위험 분석이 완료되었습니다.', - id: 'aiAnalysis', - pendingDescription: 'AI 위험 분석을 준비하고 있습니다...', - title: 'AI 위험 분석', - weight: 18, - }, - completed: { - activeDescription: '분석 결과를 정리하고 결과 페이지로 이동할 준비를 하고 있습니다...', - doneDescription: '분석이 완료되었습니다. 결과 페이지로 이동합니다.', - id: 'completed', - pendingDescription: '최종 결과 정리를 준비하고 있습니다...', - title: '분석 완료', - weight: 8, - }, - decode: { - activeDescription: 'QR 코드의 실제 대상 URL을 읽어오고 있습니다...', - doneDescription: 'QR 코드 디코딩이 완료되었습니다.', - id: 'decode', - pendingDescription: 'QR 코드 디코딩을 준비하고 있습니다...', - title: 'QR 디코딩', - weight: 14, - }, - externalApi: { - activeDescription: '외부 보안 API에서 위협 및 평판 정보를 조회하고 있습니다...', - doneDescription: '외부 API 조회가 완료되었습니다.', - id: 'externalApi', - pendingDescription: '외부 API 조회를 준비하고 있습니다...', - title: '외부 API 조회', - weight: 12, - }, - internalDb: { - activeDescription: '내부 DB에서 기존 분석 이력과 수집 데이터를 조회하고 있습니다...', - doneDescription: '내부 DB 조회가 완료되었습니다.', - id: 'internalDb', - pendingDescription: '내부 DB 조회를 준비하고 있습니다...', - title: '내부 DB 조회', - weight: 12, - }, - redirect: { - activeDescription: '단축 URL 또는 중간 경유 경로를 추적하고 있습니다...', - doneDescription: '리다이렉션 추적이 완료되었습니다.', - id: 'redirect', - pendingDescription: '리다이렉션 추적을 준비하고 있습니다...', - title: '리다이렉션 추적', - weight: 14, - }, - report: { - activeDescription: '상세 분석 리포트를 작성하고 있습니다...', - doneDescription: '리포트 작성이 완료되었습니다.', - id: 'report', - pendingDescription: '리포트 작성을 준비하고 있습니다...', - title: '리포트 작성', - weight: 10, - }, - riskScore: { - activeDescription: '분석 결과를 바탕으로 종합 위험도를 산출하고 있습니다...', - doneDescription: '종합 위험도 산출이 완료되었습니다.', - id: 'riskScore', - pendingDescription: '종합 위험도 산출을 준비하고 있습니다...', - title: '종합 위험도 산출', - weight: 12, - }, - shortUrlCheck: { - activeDescription: '단축 URL 여부를 확인하고 있습니다...', - doneDescription: '단축 URL이 확인되었습니다.', - id: 'shortUrlCheck', - pendingDescription: '단축 URL 여부 확인을 준비하고 있습니다...', - title: '단축 URL 여부 확인', - weight: 8, - }, -}; - -const loadingCaseStepOrder: Record = { - 1: ['decode', 'shortUrlCheck', 'redirect', 'internalDb', 'riskScore', 'report', 'completed'], - 2: [ - 'decode', - 'shortUrlCheck', - 'redirect', - 'internalDb', - 'aiAnalysis', - 'riskScore', - 'report', - 'completed', - ], - 3: [ - 'decode', - 'shortUrlCheck', - 'redirect', - 'internalDb', - 'externalApi', - 'aiAnalysis', - 'riskScore', - 'report', - 'completed', - ], - 4: ['decode', 'internalDb', 'riskScore', 'report', 'completed'], - 5: ['decode', 'internalDb', 'aiAnalysis', 'redirect', 'riskScore', 'report', 'completed'], - 6: [ - 'decode', - 'internalDb', - 'externalApi', - 'aiAnalysis', - 'redirect', - 'riskScore', - 'report', - 'completed', - ], - 7: ['decode', 'externalApi', 'completed'], - 8: ['decode', 'completed'], -}; - -const loadingCaseStepOverrides: Record< - LoadingCaseNumber, - Partial> -> = { - 1: { - internalDb: { - doneDescription: '분석 이력이 확인되어 저장된 결과를 그대로 사용합니다.', - }, - shortUrlCheck: { - doneDescription: '단축 URL이 확인되어 리다이렉션 추적으로 이동합니다.', - }, - }, - 2: { - internalDb: { - doneDescription: '분석 이력은 없지만 수집된 데이터가 있어 AI 분석을 이어갑니다.', - }, - shortUrlCheck: { - doneDescription: '단축 URL이 확인되어 리다이렉션 추적으로 이동합니다.', - }, - }, - 3: { - externalApi: { - doneDescription: '외부 API 조회가 완료되어 AI 위험 분석으로 이동합니다.', - }, - internalDb: { - doneDescription: '내부 DB에 분석 데이터가 없어 외부 API 조회를 진행합니다.', - }, - shortUrlCheck: { - doneDescription: '단축 URL이 확인되어 리다이렉션 추적으로 이동합니다.', - }, - }, - 4: { - internalDb: { - doneDescription: '분석 이력이 확인되어 저장된 결과를 그대로 사용합니다.', - }, - }, - 5: { - internalDb: { - doneDescription: '수집 데이터가 확인되어 AI 위험 분석을 진행합니다.', - }, - }, - 6: { - externalApi: { - doneDescription: '외부 API 조회가 완료되어 AI 위험 분석으로 이동합니다.', - }, - internalDb: { - doneDescription: '내부 DB만으로는 부족하여 외부 API 조회를 진행합니다.', - }, - }, - 7: { - completed: { - activeDescription: '안전 판정이 확인되어 결과 페이지로 이동하고 있습니다...', - doneDescription: '외부 API에서 안전으로 판정되어 분석을 종료했습니다.', - }, - externalApi: { - doneDescription: '외부 API에서 안전 결과가 확인되어 추가 분석 없이 종료합니다.', - }, - }, - 8: { - completed: { - activeDescription: 'URL 형식이 아닌 QR 코드로 확인되어 분석을 종료하고 있습니다...', - doneDescription: 'URL 형식이 아닌 QR 코드로 확인되어 분석을 종료했습니다.', - }, - decode: { - doneDescription: 'QR 코드 내용을 확인한 결과 URL 형식이 아니었습니다.', - }, - }, -}; - -export function getLoadingSteps(caseNumber: LoadingCaseNumber): LoadingStep[] { - const stepOverrides = loadingCaseStepOverrides[caseNumber]; - - return loadingCaseStepOrder[caseNumber].map((stepId) => { - const step = loadingStepMap[stepId]; - const override = stepOverrides?.[stepId]; - const resolvedDetails = override?.details ?? step.details; +export function getLoadingSteps(): LoadingStep[] { + return loadingStepOrder.map((stepId) => { + const step = stepTextMap[stepId]; + const resolvedDetails = step.details; return { ...step, - ...override, details: resolvedDetails ? [...resolvedDetails] : undefined, }; }); } - -export function isLoadingCaseNumber(value: number): value is LoadingCaseNumber { - return Number.isInteger(value) && value >= 1 && value <= 8; -} diff --git a/src/pages/Loading/styles/loadingPage.css.ts b/src/pages/Loading/styles/loadingPage.css.ts index 9532989..f8b5011 100644 --- a/src/pages/Loading/styles/loadingPage.css.ts +++ b/src/pages/Loading/styles/loadingPage.css.ts @@ -90,6 +90,15 @@ export const percentText = style({ lineHeight: 1, }); +export const progressMetaText = style({ + margin: 0, + color: vars.colors.subText, + fontSize: vars.font.size.sm, + fontWeight: vars.font.weight.medium, + lineHeight: 1.3, + textAlign: 'center', +}); + export const title = style({ margin: 0, color: vars.colors.black, @@ -111,92 +120,6 @@ export const flowSection = style({ gap: vars.spacing.md, }); -export const caseControlSection = style({ - display: 'grid', - gap: vars.spacing.md, - paddingTop: vars.spacing.sm, - borderTop: `1px solid ${vars.colors.border}`, -}); - -export const caseControlHeader = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: vars.spacing.sm, - flexWrap: 'wrap', -}); - -export const caseControlTitle = style({ - margin: 0, - color: vars.colors.black, - fontSize: vars.font.size.lg, - fontWeight: vars.font.weight.semibold, -}); - -export const caseControlCurrent = style({ - margin: 0, - color: vars.colors.subText, - fontSize: vars.font.size.sm, - fontWeight: vars.font.weight.medium, -}); - -export const caseButtonList = style({ - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(92px, 1fr))', - gap: vars.spacing.sm, -}); - -export const caseButton = style({ - minHeight: '44px', - border: `1px solid ${vars.colors.border}`, - borderRadius: vars.radius.md, - backgroundColor: vars.colors.white, - color: vars.colors.black, - fontSize: vars.font.size.sm, - fontWeight: vars.font.weight.semibold, - cursor: 'pointer', - transition: 'background-color 160ms ease, border-color 160ms ease, color 160ms ease', - selectors: { - '&:hover': { - borderColor: vars.colors.mainBorder, - backgroundColor: vars.colors.mainXLight, - }, - }, -}); - -export const caseButtonActive = style({ - borderColor: vars.colors.main, - backgroundColor: vars.colors.main, - color: vars.colors.white, -}); - -export const randomCaseButton = style({ - borderColor: vars.colors.success, - color: vars.colors.success, - selectors: { - '&:hover': { - borderColor: vars.colors.success, - backgroundColor: 'rgba(34, 197, 94, 0.08)', - }, - }, -}); - -export const branchDemoButton = style({ - borderColor: vars.colors.black, - color: vars.colors.black, - selectors: { - '&:hover': { - borderColor: vars.colors.black, - backgroundColor: vars.colors.sub, - }, - }, -}); - -export const branchDemoButtonActive = style({ - backgroundColor: vars.colors.black, - color: vars.colors.white, -}); - export const flowList = style({ margin: 0, padding: 0, diff --git a/src/pages/Loading/types/loadingPage.types.ts b/src/pages/Loading/types/loadingPage.types.ts index 217ae10..6bf9a03 100644 --- a/src/pages/Loading/types/loadingPage.types.ts +++ b/src/pages/Loading/types/loadingPage.types.ts @@ -1,11 +1,11 @@ -export type LoadingCaseNumber = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; - export type LoadingStepId = | 'decode' | 'shortUrlCheck' + | 'urlNormalize' | 'redirect' | 'internalDb' | 'externalApi' + | 'ruleAnalysis' | 'aiAnalysis' | 'riskScore' | 'report' @@ -26,12 +26,7 @@ export type LoadingStep = { weight: number; }; -export type LoadingRevealMode = 'full' | 'sequential'; - export type LoadingPageData = { - caseNumber: LoadingCaseNumber; - progressIntervalMs: number; - revealMode: LoadingRevealMode; steps: LoadingStep[]; }; diff --git a/src/pages/QRScan/QRScanPage.tsx b/src/pages/QRScan/QRScanPage.tsx index ca93eff..517d3f7 100644 --- a/src/pages/QRScan/QRScanPage.tsx +++ b/src/pages/QRScan/QRScanPage.tsx @@ -1,10 +1,8 @@ -import { Link, useNavigate } from '@tanstack/react-router'; +import { Link } from '@tanstack/react-router'; import { qrIconByTone } from '@/shared/icon/resultIcons'; import AppHeader from '@/shared/ui/app-header'; -import type { ScanListStatus } from '@/pages/ScanList/types/scanListPage.types'; - import { useQRScanPage } from './hooks/useQRScanPage'; import * as styles from './styles/qrScanPage.css'; @@ -79,29 +77,19 @@ function HistoryNextIcon() { ); } -const resultRouteByStatus: Record< - ScanListStatus, - '/result/safe' | '/result/warning' | '/result/critical' -> = { - safe: '/result/safe', - warning: '/result/warning', - critical: '/result/critical', -}; - -const statusLabelByTone: Record = { +const statusLabelByTone = { safe: '안전', warning: '주의', critical: '위험', -}; +} as const; -const statusSummaryByTone: Record = { +const statusSummaryByTone = { safe: '안전으로 판정된 스캔 이력입니다.', warning: '주의가 필요한 스캔 이력입니다.', critical: '위험으로 분류된 스캔 이력입니다.', -}; +} as const; export default function QRScanPage() { - const navigate = useNavigate(); const { cameraStatus, cameraStatusText, @@ -110,6 +98,7 @@ export default function QRScanPage() { handleCapturePhoto, handleGalleryFileChange, handleOpenGallery, + handleOpenRecentScanResult, handleShowNextHistoryItem, handleShowPreviousHistoryItem, isCapturing, @@ -118,14 +107,6 @@ export default function QRScanPage() { videoRef, } = useQRScanPage(); - const handleOpenRecentScanResult = () => { - if (!recentScanItem) { - return; - } - - void navigate({ to: resultRouteByStatus[recentScanItem.status] }); - }; - return (
diff --git a/src/pages/QRScan/hooks/useQRScanPage.ts b/src/pages/QRScan/hooks/useQRScanPage.ts index 34074a1..21bc14f 100644 --- a/src/pages/QRScan/hooks/useQRScanPage.ts +++ b/src/pages/QRScan/hooks/useQRScanPage.ts @@ -1,14 +1,23 @@ import type { ChangeEvent, RefObject } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { App } from 'antd'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { showApiError } from '@/shared/lib/feedback/showApiError'; +import { useScanProgressStore } from '@/shared/store/scanProgressStore'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; + import { - defaultScanListUuid, - fetchScanListPageData, - getInitialScanListPageData, -} from '@/pages/ScanList/api/fetchScanListPageData'; -import type { ScanListItem } from '@/pages/ScanList/types/scanListPage.types'; + fetchRecentScanHistoryData, + getInitialScanHistoryData, +} from '@/features/scan-history/api/fetchScanHistoryData'; +import type { + ScanHistoryItem, + ScanHistoryStatus, +} from '@/features/scan-history/types/scanHistory.types'; +import { submitQrImage } from '@/features/scan-url/api/submitQrImage'; +import { isCaptchaRequiredUploadError } from '@/features/scan-url/api/uploadErrors'; type CameraStatus = 'loading' | 'ready' | 'error'; @@ -28,15 +37,26 @@ type UseQRScanPageReturn = { fileInputRef: RefObject; handleCapturePhoto: () => Promise; handleGalleryFileChange: (event: ChangeEvent) => void; - handleShowNextHistoryItem: () => void; handleOpenGallery: () => void; + handleOpenRecentScanResult: () => void; + handleShowNextHistoryItem: () => void; handleShowPreviousHistoryItem: () => void; isCapturing: boolean; isFlashVisible: boolean; - recentScanItem: ScanListItem | null; + recentScanItem: ScanHistoryItem | null; videoRef: RefObject; }; +type ResultRoute = '/result/critical' | '/result/non-url' | '/result/safe' | '/result/warning'; + +const nonUrlResultRoute = '/result/non-url'; + +const resultRouteByStatus: Record = { + safe: '/result/safe', + warning: '/result/warning', + critical: '/result/critical', +}; + function formatCapturedAt(date: Date) { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, '0'); @@ -116,8 +136,41 @@ async function requestCameraStream() { } } +function isNonWebScanResponse(scanResponse: Record): boolean { + const rawSchemeType = + typeof scanResponse.schemeType === 'string' + ? scanResponse.schemeType + : scanResponse.scheme_type; + const schemeType = typeof rawSchemeType === 'string' ? rawSchemeType.trim().toUpperCase() : ''; + const rawIsUrl = scanResponse.isUrl !== undefined ? scanResponse.isUrl : scanResponse.is_url; + + if (typeof rawIsUrl === 'boolean') { + return rawIsUrl === false; + } + + return schemeType.length > 0 && schemeType !== 'WEB'; +} + +function isWebHistoryItem(item: ScanHistoryItem): boolean { + return item.isUrl !== false && item.schemeType?.trim().toUpperCase() === 'WEB'; +} + +function openResultPage(route: ResultRoute, url: string) { + if (route === nonUrlResultRoute) { + window.location.assign(route); + return; + } + + window.location.assign(`${route}?url=${encodeURIComponent(url)}`); +} + export function useQRScanPage(): UseQRScanPageReturn { const { message } = App.useApp(); + const navigate = useNavigate(); + const resetScanProgress = useScanProgressStore((state) => state.reset); + const resetForNewScan = useScanSessionStore((state) => state.resetForNewScan); + const setHistorySelection = useScanSessionStore((state) => state.setHistorySelection); + const setScanResponse = useScanSessionStore((state) => state.setScanResponse); const videoRef = useRef(null); const fileInputRef = useRef(null); const streamRef = useRef(null); @@ -129,8 +182,8 @@ export function useQRScanPage(): UseQRScanPageReturn { const [captureRecord, setCaptureRecord] = useState(null); const [isCapturing, setIsCapturing] = useState(false); const [isFlashVisible, setIsFlashVisible] = useState(false); - const [scanHistoryItems, setScanHistoryItems] = useState(() => { - return getInitialScanListPageData(defaultScanListUuid).items; + const [scanHistoryItems, setScanHistoryItems] = useState(() => { + return getInitialScanHistoryData().items; }); const [currentHistoryIndex, setCurrentHistoryIndex] = useState(0); const recentScanItem = scanHistoryItems[currentHistoryIndex] ?? null; @@ -203,7 +256,14 @@ export function useQRScanPage(): UseQRScanPageReturn { setCameraStatus('ready'); setCameraStatusText('후면 카메라가 연결되었습니다. 버튼을 눌러 현재 화면을 촬영하세요.'); } catch (error) { - console.error('Failed to start QR camera preview.', error); + if ( + error instanceof DOMException && + (error.name === 'NotFoundError' || error.name === 'OverconstrainedError') + ) { + console.warn('QR camera preview is unavailable on this device.', error); + } else { + console.error('Failed to start QR camera preview.', error); + } if (!isMountedRef.current) { return; @@ -214,6 +274,47 @@ export function useQRScanPage(): UseQRScanPageReturn { } }, []); + const submitScanFile = useCallback( + async ({ + file, + fileName, + onSuccessMessage, + }: { + file: Blob; + fileName?: string; + onSuccessMessage: string; + }) => { + resetForNewScan(); + resetScanProgress(); + + try { + const scanResponse = await submitQrImage({ + file, + fileName, + }); + + setScanResponse(scanResponse); + message.success(onSuccessMessage); + + if (isNonWebScanResponse(scanResponse)) { + void navigate({ to: '/result/non-url' }); + return; + } + + void navigate({ to: '/loading' }); + } catch (error) { + if (isCaptchaRequiredUploadError(error)) { + message.warning('요청 횟수를 초과했습니다. 캡차 인증 후 다시 업로드해 주세요.'); + void navigate({ to: '/captcha' }); + return; + } + + throw error; + } + }, + [message, navigate, resetForNewScan, resetScanProgress, setScanResponse], + ); + useEffect(() => { isMountedRef.current = true; void startCamera(); @@ -235,7 +336,7 @@ export function useQRScanPage(): UseQRScanPageReturn { const loadRecentScanItems = async () => { try { - const response = await fetchScanListPageData(defaultScanListUuid); + const response = await fetchRecentScanHistoryData(); if (isMounted) { setScanHistoryItems(response.items); @@ -251,7 +352,7 @@ export function useQRScanPage(): UseQRScanPageReturn { console.error('Failed to load latest scan list items.', error); if (isMounted) { - const fallbackItems = getInitialScanListPageData(defaultScanListUuid).items; + const fallbackItems = getInitialScanHistoryData().items; setScanHistoryItems(fallbackItems); setCurrentHistoryIndex((previousIndex) => { @@ -292,6 +393,25 @@ export function useQRScanPage(): UseQRScanPageReturn { }); }, [scanHistoryItems.length]); + const handleOpenRecentScanResult = useCallback(() => { + if (!recentScanItem) { + return; + } + + setHistorySelection({ + isUrl: recentScanItem.isUrl, + riskLevel: recentScanItem.status, + scannedAt: recentScanItem.scannedAt, + schemeType: recentScanItem.schemeType, + url: recentScanItem.url, + }); + const route = isWebHistoryItem(recentScanItem) + ? resultRouteByStatus[recentScanItem.status] + : nonUrlResultRoute; + + openResultPage(route, recentScanItem.url); + }, [recentScanItem, setHistorySelection]); + const handleCapturePhoto = useCallback(async () => { if (cameraStatus !== 'ready') { await startCamera(); @@ -341,13 +461,28 @@ export function useQRScanPage(): UseQRScanPageReturn { summary: '현재 화면을 캡처해 최근 기록에 반영했습니다.', }); triggerCaptureFlash(); - message.success('사진을 촬영했습니다.'); + + await submitScanFile({ + file: photoBlob, + fileName: `capture-${Date.now()}.jpg`, + onSuccessMessage: '사진을 업로드하고 분석을 시작했습니다.', + }); + } catch (error) { + console.error('Failed to capture or upload QR scan image.', error); + showApiError(message, error, 'QR 이미지 업로드에 실패했습니다.'); } finally { if (isMountedRef.current) { setIsCapturing(false); } } - }, [cameraStatus, message, startCamera, triggerCaptureFlash, updateCaptureRecord]); + }, [ + cameraStatus, + message, + startCamera, + submitScanFile, + triggerCaptureFlash, + updateCaptureRecord, + ]); const handleOpenGallery = useCallback(() => { fileInputRef.current?.click(); @@ -370,10 +505,27 @@ export function useQRScanPage(): UseQRScanPageReturn { photoUrl, summary: '갤러리 이미지를 불러와 최근 기록에 반영했습니다.', }); - message.success('갤러리 이미지를 불러왔습니다.'); + + setIsCapturing(true); + + void submitScanFile({ + file: selectedFile, + fileName: selectedFile.name, + onSuccessMessage: '이미지를 업로드하고 분석을 시작했습니다.', + }) + .catch((error) => { + console.error('Failed to upload QR scan image from gallery.', error); + showApiError(message, error, '갤러리 이미지 업로드에 실패했습니다.'); + }) + .finally(() => { + if (isMountedRef.current) { + setIsCapturing(false); + } + }); + event.target.value = ''; }, - [message, updateCaptureRecord], + [message, submitScanFile, updateCaptureRecord], ); return { @@ -384,8 +536,9 @@ export function useQRScanPage(): UseQRScanPageReturn { fileInputRef, handleCapturePhoto, handleGalleryFileChange, - handleShowNextHistoryItem, handleOpenGallery, + handleOpenRecentScanResult, + handleShowNextHistoryItem, handleShowPreviousHistoryItem, isCapturing, isFlashVisible, diff --git a/src/pages/QRScan/styles/qrScanPage.css.ts b/src/pages/QRScan/styles/qrScanPage.css.ts index 8168462..0000763 100644 --- a/src/pages/QRScan/styles/qrScanPage.css.ts +++ b/src/pages/QRScan/styles/qrScanPage.css.ts @@ -291,7 +291,7 @@ export const actionSection = style({ gap: vars.spacing.sm, }); -export const actionButton = style({ +const actionButton = style({ width: '100%', minHeight: '56px', borderRadius: '999px', @@ -587,17 +587,3 @@ export const historyStatusTone = styleVariants({ color: vars.colors.warning, }, }); - -export const historyThumbnail = style({ - overflow: 'hidden', - borderRadius: '12px', - backgroundColor: '#dbe4ec', - aspectRatio: '16 / 9', -}); - -export const historyThumbnailImage = style({ - width: '100%', - height: '100%', - objectFit: 'cover', - display: 'block', -}); diff --git a/src/pages/Report/ReportPage.tsx b/src/pages/Report/ReportPage.tsx index 9150b65..eb059bf 100644 --- a/src/pages/Report/ReportPage.tsx +++ b/src/pages/Report/ReportPage.tsx @@ -1,9 +1,6 @@ -import { useRef, useState } from 'react'; - import { RiskLevelCard, TrustScoreCard } from '@/shared/component'; import googleSafeBrowsingIcon from '@/shared/icon/Google Safe Browsing.svg'; import { qrIconByTone, statusMarkIconByTone } from '@/shared/icon/resultIcons'; -import { exportElementToPdf } from '@/shared/lib/pdf/exportElementToPdf'; import AppHeader from '@/shared/ui/app-header'; import { resolveRiskDetectionContent } from './constants/riskDetectionCatalog'; @@ -55,9 +52,15 @@ const certificateStatusClassNameByTone: Record = { }; export default function ReportPage() { - const { reportPageData } = useReportPage(); - const [isExportingPdf, setIsExportingPdf] = useState(false); - const reportContentRef = useRef(null); + const { handleRescan, reportPageData } = useReportPage(); + + if (!reportPageData) { + return null; + } + + const handlePrintPdf = () => { + window.print(); + }; const totalReputationCount = reportPageData.reputation.summary.phishingCount + @@ -74,43 +77,20 @@ export default function ReportPage() { const providerStatusTone = totalReputationCount === 0 ? 'safe' : reportPageData.riskLevel; const detectedRiskCards = reportPageData.detectedRiskTypes.map((riskType, index) => ({ - ...resolveRiskDetectionContent(riskType), + ...resolveRiskDetectionContent(riskType, reportPageData.riskLevel), key: `${riskType}-${index}`, })); - const handleExportPdf = async () => { - if (isExportingPdf || !reportContentRef.current) { - return; - } - - setIsExportingPdf(true); - - try { - await exportElementToPdf({ - element: reportContentRef.current, - fileName: '상세 보고서.pdf', - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; - - console.error('PDF 내보내기 실패', { - error, - reportContentElement: reportContentRef.current, - }); - - window.alert(`PDF 내보내기 실패: ${errorMessage}`); - } finally { - setIsExportingPdf(false); - } - }; + const toneKey = reportPageData.riskLevel; return (
- +
+ +
-
+
@@ -160,10 +140,12 @@ export default function ReportPage() { />
-
+
- {sectionNumber.url} + + {sectionNumber.url} +

URL 분석 상세

원본 URL과 실제 도착 URL을 비교합니다.

@@ -208,10 +190,12 @@ export default function ReportPage() {

-
+
- {sectionNumber.reputation} + + {sectionNumber.reputation} +

평판 및 차단 기록 조회

@@ -282,10 +266,12 @@ export default function ReportPage() {

-
+
- {sectionNumber.domain} + + {sectionNumber.domain} +

도메인 비교 분석

@@ -294,20 +280,24 @@ export default function ReportPage() {

- + {reportPageData.domainComparison.riskBadgeText}
-
+

접속 시도 URL

{reportPageData.domainComparison.suspiciousUrl}

-
+

실제 공식 URL

{reportPageData.domainComparison.officialUrl} @@ -318,10 +308,12 @@ export default function ReportPage() {

{reportPageData.domainComparison.summary}

-
+
- {sectionNumber.server} + + {sectionNumber.server} +

서버 정보

@@ -367,10 +359,12 @@ export default function ReportPage() {

-
+
- {sectionNumber.detection} + + {sectionNumber.detection} +

탐지된 위험 유형 분석

@@ -382,9 +376,14 @@ export default function ReportPage() {

{detectedRiskCards.map((riskDetectionCard) => ( -
+
-
+

{riskDetectionCard.title}

{riskDetectionCard.englishLabel} @@ -392,12 +391,22 @@ export default function ReportPage() {

-
-

상세 설명

+
+

+ 상세 설명 +

{riskDetectionCard.description}

-

{riskDetectionCard.risk}

+

+ {riskDetectionCard.risk} +

))} @@ -407,16 +416,21 @@ export default function ReportPage() {
+ +
diff --git a/src/pages/Report/api/fetchReportPageData.ts b/src/pages/Report/api/fetchReportPageData.ts index 28160e1..c5b8864 100644 --- a/src/pages/Report/api/fetchReportPageData.ts +++ b/src/pages/Report/api/fetchReportPageData.ts @@ -1,50 +1,30 @@ +import { ensureScanDetail } from '@/shared/lib/scan-session/ensureScanDetail'; +import { getScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import { toReportPageData } from '../lib/toReportPageData'; + import type { ReportPageData } from '../types/reportPage.types'; -export const mockReportPageData: ReportPageData = { - detectedRiskTypes: ['유니코드 및 난독화'], - domainComparison: { - officialUrl: 'https://www.official-bank.com/login', - riskBadgeText: '위험 등급: 높음', - summary: - '접속 시도 URL이 공식 금융기관 주소와 매우 유사하게 설계되어 사칭 사이트일 가능성이 높습니다.', - suspiciousUrl: 'https://secure-bank-login.portal-verify.com/login', - }, - reputation: { - detailDescription: - 'Google Safe Browsing 조회 결과, 등재된 피싱·멀웨어·원치 않는 소프트웨어 위협은 발견되지 않았습니다.', - providerName: 'Google Safe Browsing', - providerStatusText: '검사 완료', - summary: { - malwareCount: 0, - phishingCount: 0, - spamCount: 0, - }, - }, - reportTitle: '상세 분석 리포트', - riskDescription: '의심 정황이 감지되어 추가 확인이 필요합니다.', - riskLevel: 'warning', - riskLevelText: '주의', - scannedAt: '2026-03-14 14:10', - scannedUrl: 'https://www.example-warning-site.com', - serverInfo: { - certificateIssuer: 'Google Trust Services LLC', - certificateStatusText: '만료됨', - certificateStatusTone: 'error', - certificateValidityPeriod: '2024.01.15 - 2025.01.15', - serverLocation: 'San Francisco, US', - serverType: 'cloudflare-nginx', - }, - trustScore: 68, - urlAnalysis: { - destinationUrl: 'https://secure-bank-login.portal-verify.com/login', - originalUrl: 'https://qr-code-scanner.app/scan/result?id=82731', - }, -}; +function hasReportSourceSession(): boolean { + const session = getScanSessionSnapshot(); + + return Boolean( + session.analysisDetail || + session.finalResult || + session.scanResponse || + session.historySelection, + ); +} export async function fetchReportPageData(): Promise { - return Promise.resolve(mockReportPageData); + const session = await ensureScanDetail(); + return toReportPageData(session); } -export function getInitialReportPageData(): ReportPageData { - return mockReportPageData; +export function getInitialReportPageData(): ReportPageData | null { + if (!hasReportSourceSession()) { + return null; + } + + return toReportPageData(getScanSessionSnapshot()); } diff --git a/src/pages/Report/constants/reportText.ts b/src/pages/Report/constants/reportText.ts new file mode 100644 index 0000000..6996b81 --- /dev/null +++ b/src/pages/Report/constants/reportText.ts @@ -0,0 +1,37 @@ +import type { ResultTone } from '@/shared/types/resultTone'; + +type ReportFallbackCopy = { + detectedRiskTypes: string[]; + domainRiskBadge: string; + riskDescription: string; + riskLevelText: string; +}; + +export const missingReportInfoLabel = '정보 없음'; + +export const reportFallbackCopyByTone: Record = { + critical: { + detectedRiskTypes: ['위험 점수 높음', '악성 또는 피싱 의심'], + domainRiskBadge: '위험 등급: 높음', + riskDescription: '악성 행위 또는 피싱과 관련된 강한 위험 신호가 감지되었습니다.', + riskLevelText: '위험', + }, + safe: { + detectedRiskTypes: ['위협 미검출'], + domainRiskBadge: '위험 등급: 낮음', + riskDescription: '현재 분석 기준에서 위험 신호가 발견되지 않았습니다.', + riskLevelText: '안전', + }, + warning: { + detectedRiskTypes: ['주의 필요', '추가 확인 필요'], + domainRiskBadge: '위험 등급: 중간', + riskDescription: '일부 의심 정황이 감지되어 추가 확인이 필요합니다.', + riskLevelText: '주의', + }, +}; + +export const trustScoreFallbackByTone: Record = { + critical: 82, + safe: 18, + warning: 45, +}; diff --git a/src/pages/Report/constants/riskDetectionCatalog.ts b/src/pages/Report/constants/riskDetectionCatalog.ts index 6a95655..d0c1e37 100644 --- a/src/pages/Report/constants/riskDetectionCatalog.ts +++ b/src/pages/Report/constants/riskDetectionCatalog.ts @@ -1,112 +1,19 @@ -export type RiskDetectionContent = { - description: string; - englishLabel: string; - risk: string; - title: string; -}; +import type { ResultTone } from '@/shared/types/resultTone'; -type RiskDetectionCatalogItem = RiskDetectionContent & { - names: string[]; -}; +import { + threatTextCatalog, + unknownThreatTextByTone, + type RiskDetectionContent, +} from './threatText'; -const riskDetectionCatalog: RiskDetectionCatalogItem[] = [ - { - description: - '네이버, 구글 등 유명 사이트와 주소가 한 글자만 다르거나(예: naver → navar) 철자 순서를 바꾼 가짜 사이트입니다.', - englishLabel: 'TYPOSQUATTING ANALYSIS', - names: ['위장 사이트 (사칭 및 오타 유도)', '위장 사이트', '사칭 사이트'], - risk: '육안으로 구분하기 어려운 점을 악용해 로그인 정보를 입력하도록 유도하고 계정을 탈취할 수 있습니다.', - title: '위장 사이트 감지', - }, - { - description: - '데이터 전송 시 암호화를 거치지 않는 구식 통신 방식(http://) 사용이 감지되었습니다.', - englishLabel: 'INSECURE HTTP', - names: ['HTTPS 미사용', 'HTTP 사용', '비암호화 HTTP 사용'], - risk: '아이디, 비밀번호 같은 민감 정보가 중간에서 탈취될 수 있어 금융/인증 화면에서는 매우 치명적입니다.', - title: 'HTTPS 미사용 감지', - }, - { - description: - '주소가 비정상적으로 길거나 주소 중간에 @ 기호를 사용해 실제 목적지를 숨기는 패턴이 감지되었습니다.', - englishLabel: 'URL LENGTH & @ OBFUSCATION', - names: ['비정상적인 URL 길이 및 @ 기호 사용', 'URL 길이 이상', 'URL @ 기호 사용'], - risk: '앞부분에 정상 사이트처럼 보이는 문자열을 넣어 사용자의 클릭을 유도하는 전형적인 피싱 수법입니다.', - title: '비정상 URL 구조 감지', - }, - { - description: - '알파벳 대신 특수 문자 또는 다른 언어의 유사 문자를 섞어 주소를 난독화한 패턴이 확인되었습니다.', - englishLabel: 'UNICODE OBFUSCATION', - names: ['유니코드 및 난독화', '유니 코드 및 난독화', '난독화 URL'], - risk: '육안으로는 정상 주소처럼 보여도 실제로는 다른 서버로 연결되어 보안 필터 우회를 시도할 수 있습니다.', - title: '유니코드/난독화 감지', - }, - { - description: - 'URL 내에 -, _, ., % 같은 특수문자나 인코딩 기호가 비정상적으로 많이 포함된 패턴입니다.', - englishLabel: 'SPECIAL CHARACTER OVERUSE', - names: ['특수문자 과사용', '특수 문자 과사용'], - risk: '실제 목적지를 숨겨 보안 탐지를 회피하고 사용자를 혼란시키는 의도가 강한 패턴입니다.', - title: '특수문자 과사용 감지', - }, - { - description: - '사람이 읽기 어려운 무작위 문자열 기반 도메인(a1b2c3d4.com 형태)이 감지되었습니다.', - englishLabel: 'RANDOM DOMAIN PATTERN', - names: ['무의미한 URL 감지', '난수 도메인', '무작위 URL'], - risk: '자동 생성된 일회성 악성 도메인일 가능성이 높고, 단기간 악성 유포 후 사라지는 서버에서 자주 보입니다.', - title: '무의미한 URL 감지', - }, - { - description: - 'naver.com 같은 도메인 대신 123.45.67.89 형태의 IP 주소로 직접 접속을 유도하는 패턴입니다.', - englishLabel: 'DIRECT IP ACCESS', - names: ['IP 주소 직접 사용', 'IP 직접 사용'], - risk: '정식 도메인 등록 없이 운영되는 불분명한 서버일 가능성이 높아 피싱/악성 서버 위험이 큽니다.', - title: 'IP 직접 접근 감지', - }, - { - description: - '접속 시 사용자 동의 없이 .apk, .exe 등 실행 파일 다운로드를 유도하거나 자동 시작하는 동작이 감지되었습니다.', - englishLabel: 'MALICIOUS DOWNLOAD ATTEMPT', - names: ['악성 파일 다운로드 유도', '악성 파일 유도'], - risk: '악성 앱 설치 후 문자 탈취, 원격 제어, 계정 탈취 등 보이스피싱 핵심 공격으로 이어질 수 있습니다.', - title: '악성 파일 유도 감지', - }, - { - description: - 'javascript 스킴 등 브라우저 내부 기능을 직접 제어하려는 스크립트 실행 패턴이 탐지되었습니다.', - englishLabel: 'BROWSER SCRIPT INJECTION', - names: ['브라우저 취약점 공격 (스크립트 실행)', '스크립트 실행 공격', 'javascript 스킴 공격'], - risk: '사용자 동의 없이 화면 조작, 세션 정보 탈취, 인증 우회 같은 고위험 공격으로 이어질 수 있습니다.', - title: '스크립트 실행 공격 감지', - }, - { - description: - 'bit.ly, tinyurl 등 최종 목적지를 숨기기 위해 URL을 짧게 줄인 단축 URL이 감지되었습니다.', - englishLabel: 'SHORT URL MASKING', - names: ['단축 URL 서비스 감지', '단축 URL 감지'], - risk: '피싱 목적지를 은폐하고 URL 필터링을 우회하기 위해 공격자가 자주 사용하는 대표적인 패턴입니다.', - title: '단축 URL 감지', - }, - { - description: - '최종 목적지로 가기 전에 여러 중간 서버를 거치도록 설계된 다중 리다이렉트 경로가 확인되었습니다.', - englishLabel: 'MULTI-REDIRECT CHAIN', - names: ['다중 리다이렉트 감지', '리다이렉트 체인'], - risk: '분석/추적을 방해하고 사용자를 예상치 못한 위험 사이트로 유도하려는 악의적 의도가 포함될 수 있습니다.', - title: '다중 리다이렉트 감지', - }, -]; - -const normalizeRiskType = (value: string): string => - value +function normalizeRiskType(value: string): string { + return value .trim() .toLowerCase() .replace(/[\s\-_/.,:%@()]/g, ''); +} -const riskDetectionLookup = riskDetectionCatalog.reduce>( +const riskDetectionLookup = threatTextCatalog.reduce>( (accumulator, catalogItem) => { catalogItem.names.forEach((name) => { accumulator.set(normalizeRiskType(name), { @@ -122,18 +29,21 @@ const riskDetectionLookup = riskDetectionCatalog.reduce = { + critical: { + description: + '백엔드에서 새로운 고위험 신호가 전달되었습니다. 아직 정책 테이블에 상세 설명이 등록되지 않았지만, 위험도가 높아 즉시 확인이 필요합니다.', + englishLabel: 'UNMAPPED CRITICAL RISK SIGNAL', + risk: '알려진 탐지 유형과 매핑되지 않은 고위험 신호입니다. 접속을 차단하고 운영 정책에 새 탐지 문구를 추가해야 합니다.', + title: '미등록 고위험 신호 감지', + }, + safe: { + description: + '백엔드에서 참고용 신호가 전달되었지만, 현재 위험도는 낮게 분류되었습니다. 정책 테이블에 상세 설명이 등록되지 않았습니다.', + englishLabel: 'UNMAPPED LOW RISK SIGNAL', + risk: '낮은 위험도로 분류되었지만, 반복적으로 발생하면 별도 탐지 항목으로 등록하는 것이 좋습니다.', + title: '미등록 참고 신호 감지', + }, + warning: { + description: + '백엔드에서 새로운 의심 신호가 전달되었습니다. 아직 정책 테이블에 상세 설명이 등록되지 않아 추가 확인이 필요합니다.', + englishLabel: 'UNMAPPED WARNING RISK SIGNAL', + risk: '정책 테이블에 매핑되지 않은 의심 유형입니다. 분석 후 사용자 안내 문구를 보강해야 합니다.', + title: '미등록 의심 신호 감지', + }, +}; diff --git a/src/pages/Report/hooks/useReportPage.ts b/src/pages/Report/hooks/useReportPage.ts index 4037441..f3b1c60 100644 --- a/src/pages/Report/hooks/useReportPage.ts +++ b/src/pages/Report/hooks/useReportPage.ts @@ -1,35 +1,38 @@ -import { useEffect, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { useCallback } from 'react'; + +import { useSessionGuardedPageData } from '@/shared/lib/page/useSessionGuardedPageData'; +import { useScanProgressStore } from '@/shared/store/scanProgressStore'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; import { fetchReportPageData, getInitialReportPageData } from '../api/fetchReportPageData'; import type { ReportPageData } from '../types/reportPage.types'; type UseReportPageReturn = { - reportPageData: ReportPageData; + handleRescan: () => void; + reportPageData: ReportPageData | null; }; export function useReportPage(): UseReportPageReturn { - const [reportPageData, setReportPageData] = useState(getInitialReportPageData); - - useEffect(() => { - let isMounted = true; - - const loadReportPageData = async () => { - const response = await fetchReportPageData(); - - if (isMounted) { - setReportPageData(response); - } - }; - - void loadReportPageData(); - - return () => { - isMounted = false; - }; - }, []); + const navigate = useNavigate(); + const clearScanSession = useScanSessionStore((state) => state.clearSession); + const resetScanProgress = useScanProgressStore((state) => state.reset); + const { data: reportPageData } = useSessionGuardedPageData({ + fetchPageData: fetchReportPageData, + getInitialPageData: getInitialReportPageData, + loadErrorMessage: '상세 리포트를 불러오지 못했습니다.', + logMessage: 'Failed to load report page data.', + }); + + const handleRescan = useCallback(() => { + clearScanSession(); + resetScanProgress(); + void navigate({ to: '/' }); + }, [clearScanSession, navigate, resetScanProgress]); return { + handleRescan, reportPageData, }; } diff --git a/src/pages/Report/lib/toReportPageData.test.ts b/src/pages/Report/lib/toReportPageData.test.ts new file mode 100644 index 0000000..994a978 --- /dev/null +++ b/src/pages/Report/lib/toReportPageData.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import { toReportPageData } from './toReportPageData'; + +describe('toReportPageData', () => { + it('maps detail payload into report page data', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + destinationUrl: 'https://danger.example', + detectedRiskTypes: ['phishing'], + domainComparison: { + officialUrl: 'https://official.example', + riskBadgeText: 'high risk', + summary: 'similar to official domain', + suspiciousUrl: 'https://danger.example', + }, + originalUrl: 'https://qr.example/source', + reputation: { + detailDescription: 'unsafe history was found', + providerName: 'Google Safe Browsing', + providerStatusText: 'checked', + summary: { + malwareCount: 1, + phishingCount: 2, + spamCount: 0, + }, + }, + riskLevel: 'HIGH', + scannedAt: '2026-04-20T12:34:00Z', + serverInfo: { + certificateIssuer: 'ACME CA', + certificateStatusText: 'expired', + certificateValidityPeriod: '2025-01-01 - 2026-01-01', + serverLocation: 'Seoul, KR', + serverType: 'nginx', + }, + trustScore: 83, + }, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const reportPageData = toReportPageData(session); + + expect(reportPageData.riskLevel).toBe('critical'); + expect(reportPageData.trustScore).toBe(83); + expect(reportPageData.urlAnalysis.destinationUrl).toBe('https://danger.example'); + expect(reportPageData.reputation.summary.phishingCount).toBe(2); + expect(reportPageData.serverInfo.certificateStatusTone).toBe('error'); + }); + + it('maps current backend analysis payloads by score and final destination URL', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + analysisTime: '2026-04-18T11:00:30.000000', + externalApi: { + checked: true, + provider: 'Google Safe Browsing', + result: 'False', + }, + internalDb: { + blockCount: 0, + exists: false, + reportCount: 0, + }, + ml: { + score: 61, + threats: ['dummy_ml_threat'], + }, + originalUrl: 'https://malware.testing.google/', + redirect: { + finalUrl: 'https://malware.testing.google/', + redirectCount: 0, + }, + riskLevel: 'safe', + score: 61, + serverInfo: { + certificate: { + issuer: null, + valid: false, + validFrom: null, + validTo: null, + }, + location: null, + type: null, + }, + }, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const reportPageData = toReportPageData(session); + + expect(reportPageData.riskLevel).toBe('critical'); + expect(reportPageData.trustScore).toBe(61); + expect(reportPageData.scannedAt).toBe('2026-04-18 11:00'); + expect(reportPageData.scannedUrl).toBe('https://malware.testing.google/'); + expect(reportPageData.urlAnalysis.destinationUrl).toBe('https://malware.testing.google/'); + expect(reportPageData.detectedRiskTypes).toContain('dummy_ml_threat'); + expect(reportPageData.reputation.providerName).toBe('Google Safe Browsing'); + expect(reportPageData.serverInfo.certificateStatusTone).toBe('error'); + }); + + it('keeps scanned and destination URLs distinct for redirect reports', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + originalUrl: 'https://short.example/a', + redirect: { + finalUrl: 'https://final.example/path', + }, + riskLevel: 'warning', + }, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const reportPageData = toReportPageData(session); + + expect(reportPageData.scannedUrl).toBe('https://short.example/a'); + expect(reportPageData.urlAnalysis.originalUrl).toBe('https://short.example/a'); + expect(reportPageData.urlAnalysis.destinationUrl).toBe('https://final.example/path'); + }); + + it('prefers session decoded URL over final URL when scanned URL fields are absent', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + finalUrl: 'https://final.example/path', + riskLevel: 'safe', + }, + decodedUrl: 'https://short.example/a', + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const reportPageData = toReportPageData(session); + + expect(reportPageData.scannedUrl).toBe('https://short.example/a'); + expect(reportPageData.urlAnalysis.destinationUrl).toBe('https://final.example/path'); + }); +}); diff --git a/src/pages/Report/lib/toReportPageData.ts b/src/pages/Report/lib/toReportPageData.ts new file mode 100644 index 0000000..63a8768 --- /dev/null +++ b/src/pages/Report/lib/toReportPageData.ts @@ -0,0 +1,359 @@ +import { + asRecord, + pickBoolean, + pickNumber, + pickRecord, + pickSourceNumber, + pickSourceRecord, + pickSourceString, + pickSourceStringArray, +} from '@/shared/api/responseAccess/payloadAccess'; +import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import { + missingReportInfoLabel, + reportFallbackCopyByTone, + trustScoreFallbackByTone, +} from '../constants/reportText'; + +import type { ReportPageData } from '../types/reportPage.types'; + +type ReportUrls = { + destinationUrl: string; + originalUrl: string; + scannedAt: string | null; + scannedUrl: string; +}; + +const scannedUrlKeys = [ + 'decodedUrl', + 'decoded_url', + 'originalUrl', + 'original_url', + 'scannedUrl', + 'scanned_url', + 'url', +]; + +const scannedUrlFallbackKeys = ['finalUrl', 'final_url', 'destinationUrl', 'destination_url']; + +const destinationUrlKeys = ['finalUrl', 'final_url', 'destinationUrl', 'destination_url']; + +function clampCount(value: number | null): number { + return value === null ? 0 : Math.max(0, Math.round(value)); +} + +function clampTrustScore(value: number | null, riskLevel: ResultTone): number { + if (value === null) { + return trustScoreFallbackByTone[riskLevel]; + } + + return Math.max(0, Math.min(100, Math.round(value))); +} + +function getSessionSources(session: ScanSessionSnapshot): unknown[] { + return [ + session.analysisDetail, + session.finalResult, + session.scanResponse, + session.historySelection, + ]; +} + +function formatDateLabel(rawDate: string | null, fallbackLabel = '분석 시각 정보 없음'): string { + if (!rawDate) { + return fallbackLabel; + } + + // TODO: Pin timezone formatting once backend timestamp semantics are confirmed. + const parsedDate = new Date(rawDate); + + if (Number.isNaN(parsedDate.getTime())) { + return fallbackLabel; + } + + const year = parsedDate.getFullYear(); + const month = `${parsedDate.getMonth() + 1}`.padStart(2, '0'); + const day = `${parsedDate.getDate()}`.padStart(2, '0'); + const hours = `${parsedDate.getHours()}`.padStart(2, '0'); + const minutes = `${parsedDate.getMinutes()}`.padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function formatCertificateStatus(certificateRecord: Record | null): string | null { + const isValid = pickBoolean(certificateRecord, ['valid', 'isValid', 'is_valid']); + + if (isValid === null) { + return null; + } + + return isValid ? '유효한 인증서' : '유효하지 않은 인증서'; +} + +function formatCertificateValidityPeriod( + certificateRecord: Record | null, +): string | null { + const validFrom = pickSourceString([certificateRecord], ['validFrom', 'valid_from']); + const validTo = pickSourceString([certificateRecord], ['validTo', 'valid_to']); + + if (!validFrom && !validTo) { + return null; + } + + return `${validFrom ?? '알 수 없음'} - ${validTo ?? '알 수 없음'}`; +} + +function resolveCertificateTone( + rawStatusText: string | null, +): ReportPageData['serverInfo']['certificateStatusTone'] { + if (!rawStatusText) { + return 'warning'; + } + + const normalizedStatusText = rawStatusText.trim().toLowerCase(); + const errorSignals = ['expired', 'invalid', 'revoked', '만료', '폐기', '유효하지 않']; + const successSignals = ['valid', 'active', '유효', '정상']; + + if (errorSignals.some((signal) => normalizedStatusText.includes(signal))) { + return 'error'; + } + + if (successSignals.some((signal) => normalizedStatusText.includes(signal))) { + return 'success'; + } + + return 'warning'; +} + +function resolveReportUrls(session: ScanSessionSnapshot, sources: unknown[]): ReportUrls { + const scannedUrl = + pickSourceString(sources, scannedUrlKeys) ?? + session.decodedUrl ?? + session.historySelection?.url ?? + pickSourceString(sources, scannedUrlFallbackKeys) ?? + ''; + const originalUrl = + pickSourceString(sources, ['originalUrl', 'original_url', 'sourceUrl', 'source_url']) ?? + scannedUrl; + const destinationUrl = pickSourceString(sources, destinationUrlKeys) ?? scannedUrl; + const scannedAt = + pickSourceString(sources, [ + 'analysisTime', + 'analysis_time', + 'scannedAt', + 'scanned_at', + 'createdAt', + 'created_at', + ]) ?? + session.historySelection?.scannedAt ?? + null; + + return { + destinationUrl, + originalUrl, + scannedAt, + scannedUrl, + }; +} + +function resolveDetectedRiskTypes(sources: unknown[], riskLevel: ResultTone): string[] { + const riskTypes = pickSourceStringArray( + sources, + ['detectedRiskTypes', 'detected_risk_types', 'riskTypes', 'risk_types', 'threats'], + ',', + ); + + return riskTypes.length > 0 ? riskTypes : reportFallbackCopyByTone[riskLevel].detectedRiskTypes; +} + +function resolveUrlComparisonSummary({ destinationUrl, originalUrl }: ReportUrls): string { + if (originalUrl && destinationUrl && originalUrl !== destinationUrl) { + return '스캔된 QR URL이 최종 목적지와 다른 주소로 리다이렉트됩니다.'; + } + + return '스캔된 QR URL과 최종 목적지가 동일합니다.'; +} + +function resolveExternalApiStatus( + externalApiRecord: Record | null, +): string | null { + const result = pickSourceString([externalApiRecord], ['result', 'status']); + + if (!result) { + return null; + } + + return `검사 결과: ${result}`; +} + +function resolveSummaryCount( + primaryRecord: Record | null, + primaryKeys: string[], + fallbackRecord?: Record | null, + fallbackKeys?: string[], +): number { + return clampCount( + pickNumber(primaryRecord, primaryKeys) ?? + (fallbackRecord && fallbackKeys ? pickNumber(fallbackRecord, fallbackKeys) : null), + ); +} + +function resolveServerInfoRecord(rawServerInfoRecord: Record | null) { + if (!rawServerInfoRecord) { + return { + certificateRecord: null, + serverInfoRecord: null, + }; + } + + const certificateRecord = pickSourceRecord([rawServerInfoRecord], ['certificate']); + + return { + certificateRecord, + serverInfoRecord: { + ...rawServerInfoRecord, + certificateStatusText: + pickSourceString( + [rawServerInfoRecord], + ['certificateStatusText', 'certificate_status_text'], + ) ?? formatCertificateStatus(certificateRecord), + certificateValidityPeriod: + pickSourceString( + [rawServerInfoRecord], + ['certificateValidityPeriod', 'certificate_validity_period'], + ) ?? formatCertificateValidityPeriod(certificateRecord), + serverLocation: pickSourceString( + [rawServerInfoRecord], + ['serverLocation', 'server_location', 'location'], + ), + serverType: pickSourceString([rawServerInfoRecord], ['serverType', 'server_type', 'type']), + }, + }; +} + +function buildDomainComparison( + domainComparisonRecord: Record | null, + riskLevel: ResultTone, + urls: ReportUrls, +): ReportPageData['domainComparison'] { + return { + officialUrl: + pickSourceString([domainComparisonRecord], ['officialUrl', 'official_url']) ?? + urls.destinationUrl, + riskBadgeText: + pickSourceString([domainComparisonRecord], ['riskBadgeText', 'risk_badge_text']) ?? + reportFallbackCopyByTone[riskLevel].domainRiskBadge, + summary: + pickSourceString([domainComparisonRecord], ['summary', 'description']) ?? + resolveUrlComparisonSummary(urls), + suspiciousUrl: + pickSourceString([domainComparisonRecord], ['suspiciousUrl', 'suspicious_url']) ?? + urls.originalUrl, + }; +} + +function buildReputation( + reputationRecord: Record | null, + internalDbRecord: Record | null, +): ReportPageData['reputation'] { + const reputationSummaryRecord = + pickRecord(reputationRecord, ['summary']) ?? asRecord(reputationRecord); + + return { + detailDescription: + pickSourceString([reputationRecord], ['detailDescription', 'detail_description']) ?? + '평판 상세 설명이 제공되지 않았습니다.', + providerName: + pickSourceString([reputationRecord], ['providerName', 'provider_name', 'provider']) ?? + 'Google Safe Browsing', + providerStatusText: + pickSourceString([reputationRecord], ['providerStatusText', 'provider_status_text']) ?? + resolveExternalApiStatus(reputationRecord) ?? + '검사 완료', + summary: { + malwareCount: resolveSummaryCount( + reputationSummaryRecord, + ['malwareCount', 'malware_count'], + internalDbRecord, + ['blockCount', 'block_count'], + ), + phishingCount: resolveSummaryCount( + reputationSummaryRecord, + ['phishingCount', 'phishing_count'], + internalDbRecord, + ['reportCount', 'report_count'], + ), + spamCount: resolveSummaryCount(reputationSummaryRecord, ['spamCount', 'spam_count']), + }, + }; +} + +function buildServerInfo( + serverInfoRecord: Record | null, + certificateRecord: Record | null, +): ReportPageData['serverInfo'] { + const certificateStatusText = + pickSourceString([serverInfoRecord], ['certificateStatusText', 'certificate_status_text']) ?? + formatCertificateStatus(certificateRecord) ?? + '확인 필요'; + + return { + certificateIssuer: + pickSourceString([serverInfoRecord], ['certificateIssuer', 'certificate_issuer']) ?? + pickSourceString([certificateRecord], ['issuer']) ?? + missingReportInfoLabel, + certificateStatusText, + certificateStatusTone: resolveCertificateTone(certificateStatusText), + certificateValidityPeriod: + pickSourceString( + [serverInfoRecord], + ['certificateValidityPeriod', 'certificate_validity_period'], + ) ?? missingReportInfoLabel, + serverLocation: + pickSourceString([serverInfoRecord], ['serverLocation', 'server_location']) ?? + missingReportInfoLabel, + serverType: + pickSourceString([serverInfoRecord], ['serverType', 'server_type']) ?? missingReportInfoLabel, + }; +} + +export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { + const sources = getSessionSources(session); + const riskLevel = resolveResultToneFromSources(sources, session.riskLevel) ?? 'warning'; + const urls = resolveReportUrls(session, sources); + const reputationRecord = pickSourceRecord(sources, [ + 'reputation', + 'reputationSummary', + 'externalApi', + 'external_api', + ]); + const domainComparisonRecord = pickSourceRecord(sources, ['domainComparison', 'domain_compare']); + const internalDbRecord = pickSourceRecord(sources, ['internalDb', 'internal_db']); + const { certificateRecord, serverInfoRecord } = resolveServerInfoRecord( + pickSourceRecord(sources, ['serverInfo', 'server_info']), + ); + + return { + detectedRiskTypes: resolveDetectedRiskTypes(sources, riskLevel), + domainComparison: buildDomainComparison(domainComparisonRecord, riskLevel, urls), + reputation: buildReputation(reputationRecord, internalDbRecord), + reportTitle: '상세 분석 리포트', + riskDescription: reportFallbackCopyByTone[riskLevel].riskDescription, + riskLevel, + riskLevelText: reportFallbackCopyByTone[riskLevel].riskLevelText, + scannedAt: formatDateLabel(urls.scannedAt), + scannedUrl: urls.scannedUrl, + serverInfo: buildServerInfo(serverInfoRecord, certificateRecord), + trustScore: clampTrustScore( + pickSourceNumber(sources, ['trustScore', 'trust_score', 'score']), + riskLevel, + ), + urlAnalysis: { + destinationUrl: urls.destinationUrl, + originalUrl: urls.originalUrl, + }, + }; +} diff --git a/src/pages/Report/styles/reportPage.css.ts b/src/pages/Report/styles/reportPage.css.ts index 5018846..c5b183d 100644 --- a/src/pages/Report/styles/reportPage.css.ts +++ b/src/pages/Report/styles/reportPage.css.ts @@ -8,6 +8,20 @@ const bpDesktop = 'screen and (min-width: 1200px)'; export const page = style({ minHeight: '100vh', backgroundColor: vars.colors.white, + '@media': { + print: { + minHeight: 'auto', + backgroundColor: vars.colors.white, + }, + }, +}); + +export const printHidden = style({ + '@media': { + print: { + display: 'none', + }, + }, }); export const shell = style({ @@ -20,6 +34,10 @@ export const shell = style({ [bpTablet]: { padding: `0 ${vars.spacing.lg} 48px`, }, + print: { + maxWidth: 'none', + padding: 0, + }, }, }); @@ -35,6 +53,12 @@ export const content = style({ [bpDesktop]: { gap: vars.spacing.xl, }, + print: { + maxWidth: 'none', + gap: vars.spacing.md, + paddingTop: 0, + paddingBottom: 0, + }, }, }); @@ -46,6 +70,12 @@ export const toneHeader = style({ display: 'grid', gap: vars.spacing.md, boxShadow: '0 8px 20px rgba(17, 29, 48, 0.06)', + '@media': { + print: { + breakInside: 'avoid', + boxShadow: 'none', + }, + }, }); export const toneHeaderTone = styleVariants({ @@ -201,6 +231,30 @@ export const sectionCard = style({ display: 'grid', gap: vars.spacing.md, boxShadow: '0 6px 16px rgba(138, 113, 0, 0.08)', + '@media': { + print: { + breakInside: 'avoid', + boxShadow: 'none', + }, + }, +}); + +export const sectionCardTone = styleVariants({ + critical: { + borderColor: '#F1CECE', + backgroundColor: '#FFF6F6', + boxShadow: '0 6px 16px rgba(193, 39, 39, 0.08)', + }, + safe: { + borderColor: '#CBEFDE', + backgroundColor: '#F3FBF7', + boxShadow: '0 6px 16px rgba(13, 138, 89, 0.08)', + }, + warning: { + borderColor: '#F0E4BE', + backgroundColor: '#FFFEFA', + boxShadow: '0 6px 16px rgba(138, 113, 0, 0.08)', + }, }); export const sectionHeader = style({ @@ -233,6 +287,24 @@ export const sectionNumber = style({ flexShrink: 0, }); +export const sectionNumberTone = styleVariants({ + critical: { + backgroundColor: '#FFD9D9', + color: '#A91212', + borderColor: '#F0A8A8', + }, + safe: { + backgroundColor: '#CFEEDF', + color: '#0F8B61', + borderColor: '#9DDDBA', + }, + warning: { + backgroundColor: '#FFE9A8', + color: '#7A6400', + borderColor: '#F2D86A', + }, +}); + export const sectionTitle = style({ margin: 0, color: '#1C2F4A', @@ -275,6 +347,24 @@ export const riskBadge = style({ }, }); +export const riskBadgeTone = styleVariants({ + critical: { + borderColor: '#F0A8A8', + backgroundColor: '#FFE5E5', + color: '#A91212', + }, + safe: { + borderColor: '#9DDDBA', + backgroundColor: '#E2F5EC', + color: '#0F8B61', + }, + warning: { + borderColor: '#E9D77A', + backgroundColor: '#FFF8D8', + color: '#6F5A00', + }, +}); + export const urlCompareGrid = style({ display: 'grid', gap: vars.spacing.sm, @@ -404,6 +494,21 @@ export const domainCompareRow = style({ gap: vars.spacing.xs, }); +export const domainCompareRowTone = styleVariants({ + critical: { + borderColor: 'rgba(242, 13, 13, 0.32)', + backgroundColor: '#FFF6F6', + }, + safe: { + borderColor: 'rgba(17, 212, 131, 0.32)', + backgroundColor: '#F3FBF7', + }, + warning: { + borderColor: 'rgba(242, 223, 13, 0.32)', + backgroundColor: '#FFFDF3', + }, +}); + export const domainCompareLabel = style({ margin: 0, color: vars.colors.subText, @@ -447,6 +552,30 @@ export const riskDetectionCard = style({ backgroundColor: '#FFFEFA', boxShadow: '0 8px 18px rgba(138, 113, 0, 0.08)', overflow: 'hidden', + '@media': { + print: { + breakInside: 'avoid', + boxShadow: 'none', + }, + }, +}); + +export const riskDetectionCardTone = styleVariants({ + critical: { + borderColor: '#F1CECE', + backgroundColor: '#FFF6F6', + boxShadow: '0 8px 18px rgba(193, 39, 39, 0.08)', + }, + safe: { + borderColor: '#CBEFDE', + backgroundColor: '#F3FBF7', + boxShadow: '0 8px 18px rgba(13, 138, 89, 0.08)', + }, + warning: { + borderColor: '#F0E5BF', + backgroundColor: '#FFFEFA', + boxShadow: '0 8px 18px rgba(138, 113, 0, 0.08)', + }, }); export const riskDetectionCardHeader = style({ @@ -456,17 +585,6 @@ export const riskDetectionCardHeader = style({ padding: `${vars.spacing.lg} ${vars.spacing.lg} ${vars.spacing.md}`, }); -export const riskDetectionIcon = style({ - width: '48px', - height: '48px', - borderRadius: '999px', - display: 'inline-flex', - flexShrink: 0, - background: - 'radial-gradient(circle at 30% 30%, rgba(255, 92, 128, 0.34) 0%, rgba(255, 59, 104, 0.2) 62%, rgba(255, 59, 104, 0.12) 100%)', - border: '1px solid rgba(255, 59, 104, 0.3)', -}); - export const riskDetectionHeaderTextBlock = style({ display: 'grid', gap: '2px', @@ -474,6 +592,18 @@ export const riskDetectionHeaderTextBlock = style({ paddingLeft: vars.spacing.sm, }); +export const riskDetectionHeaderTextBlockTone = styleVariants({ + critical: { + borderLeftColor: '#E15050', + }, + safe: { + borderLeftColor: '#11D483', + }, + warning: { + borderLeftColor: '#F2DF0D', + }, +}); + export const riskDetectionTitle = style({ margin: 0, color: '#1E314D', @@ -500,6 +630,21 @@ export const riskDetectionBody = style({ gap: vars.spacing.md, }); +export const riskDetectionBodyTone = styleVariants({ + critical: { + borderColor: '#F1CECE', + backgroundColor: '#FFF1F1', + }, + safe: { + borderColor: '#CBEFDE', + backgroundColor: '#EFFAF4', + }, + warning: { + borderColor: '#F3E8BE', + backgroundColor: '#FFFBF0', + }, +}); + export const riskDetectionBodyLabel = style({ margin: 0, color: '#7B6700', @@ -521,6 +666,36 @@ export const riskDetectionBodyLabel = style({ }, }); +export const riskDetectionBodyLabelTone = styleVariants({ + critical: { + color: '#A92E2E', + selectors: { + '&::before': { + backgroundColor: '#E15050', + boxShadow: '0 0 0 3px rgba(225, 80, 80, 0.24)', + }, + }, + }, + safe: { + color: '#0F7A55', + selectors: { + '&::before': { + backgroundColor: '#11D483', + boxShadow: '0 0 0 3px rgba(17, 212, 131, 0.24)', + }, + }, + }, + warning: { + color: '#7B6700', + selectors: { + '&::before': { + backgroundColor: '#D3B600', + boxShadow: '0 0 0 3px rgba(242, 223, 13, 0.24)', + }, + }, + }, +}); + export const riskDetectionDescription = style({ margin: 0, color: '#23354E', @@ -545,6 +720,33 @@ export const riskDetectionRisk = style({ }, }); +export const riskDetectionRiskTone = styleVariants({ + critical: { + borderTopColor: '#F1CECE', + selectors: { + '&::before': { + color: '#A91212', + }, + }, + }, + safe: { + borderTopColor: '#CBEFDE', + selectors: { + '&::before': { + color: '#0F8B61', + }, + }, + }, + warning: { + borderTopColor: '#EFE2B9', + selectors: { + '&::before': { + color: '#9B7200', + }, + }, + }, +}); + export const reputationBadge = style({ display: 'inline-flex', alignItems: 'center', @@ -793,16 +995,23 @@ export const exportActionWrap = style({ maxWidth: '860px', margin: '0 auto', display: 'flex', + alignItems: 'center', justifyContent: 'center', + gap: vars.spacing.sm, + flexWrap: 'wrap', + '@media': { + print: { + display: 'none', + }, + }, }); -export const exportPdfButton = style({ - minWidth: '220px', +export const printButton = style({ + minWidth: '160px', minHeight: '44px', borderRadius: vars.radius.md, - border: '1px solid #E6C85D', - backgroundColor: '#FFF6CF', - color: '#6D5800', + border: '1px solid transparent', + color: vars.colors.white, fontSize: vars.font.size.md, fontWeight: vars.font.weight.semibold, lineHeight: 1, @@ -813,40 +1022,79 @@ export const exportPdfButton = style({ padding: `0 ${vars.spacing.md}`, cursor: 'pointer', transition: 'background-color 160ms ease, box-shadow 160ms ease, transform 120ms ease', - boxShadow: '0 4px 12px rgba(138, 113, 0, 0.15)', + boxShadow: '0 6px 14px rgba(17, 29, 48, 0.12)', selectors: { '&:hover:not(:disabled)': { - backgroundColor: '#FDEFB6', - boxShadow: '0 6px 14px rgba(138, 113, 0, 0.2)', + boxShadow: '0 8px 16px rgba(17, 29, 48, 0.16)', transform: 'translateY(-1px)', }, '&:active:not(:disabled)': { transform: 'translateY(0)', }, '&:focus-visible': { - outline: '2px solid #D9B300', + outline: `2px solid ${vars.colors.main}`, outlineOffset: '2px', }, - '&:disabled': { - opacity: 0.7, - cursor: 'default', - transform: 'none', - boxShadow: 'none', + }, +}); + +export const printButtonTone = styleVariants({ + critical: { + backgroundColor: '#D94848', + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: '#C83434', + }, + }, + }, + safe: { + backgroundColor: vars.colors.success, + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: '#0FBD75', + }, + }, + }, + warning: { + backgroundColor: '#B49A00', + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: '#967F00', + }, }, }, }); -export const exportPdfButtonBadge = style({ - minWidth: '34px', - height: '22px', - padding: '0 8px', - borderRadius: '999px', - backgroundColor: '#6D5800', - color: '#FFFFFF', - fontSize: vars.font.size.xs, - fontWeight: vars.font.weight.bold, +export const rescanButton = style({ + minWidth: '220px', + minHeight: '44px', + borderRadius: vars.radius.md, + border: `1px solid ${vars.colors.border}`, + backgroundColor: vars.colors.white, + color: vars.colors.subDark, + fontSize: vars.font.size.md, + fontWeight: vars.font.weight.semibold, lineHeight: 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: vars.spacing.xs, + padding: `0 ${vars.spacing.md}`, + cursor: 'pointer', + transition: 'background-color 160ms ease, box-shadow 160ms ease, transform 120ms ease', + boxShadow: '0 4px 10px rgba(17, 29, 48, 0.06)', + selectors: { + '&:hover:not(:disabled)': { + backgroundColor: '#F4F6FA', + boxShadow: '0 6px 12px rgba(17, 29, 48, 0.08)', + transform: 'translateY(-1px)', + }, + '&:active:not(:disabled)': { + transform: 'translateY(0)', + }, + '&:focus-visible': { + outline: `2px solid ${vars.colors.subText}`, + outlineOffset: '2px', + }, + }, }); diff --git a/src/pages/Result/ResultPage.tsx b/src/pages/Result/ResultPage.tsx new file mode 100644 index 0000000..3013533 --- /dev/null +++ b/src/pages/Result/ResultPage.tsx @@ -0,0 +1,113 @@ +import type { ResultTone } from '@/shared/types/resultTone'; +import ResultHero from '@/shared/ui/resultHero'; + +import { useResultPage } from './hooks/useResultPage'; +import ResultStatusPage from './ui/ResultStatusPage'; + +type ResultPageProps = { + tone: ResultTone; +}; + +const resultViewConfig = { + critical: { + actionButtons: { + reportLabel: '신고하기', + }, + hero: ( + + ), + riskLevelCard: { + description: '피싱 의심 패턴이 강하게 감지됨', + levelText: '위험', + }, + siteCard: { + badgeLabel: '!', + statusLabel: '위험', + visitLabel: '접속 차단', + }, + }, + safe: { + actionButtons: undefined, + hero: ( + + 안심하세요! +
+ 안전한 사이트입니다 + + } + tone="safe" + /> + ), + riskLevelCard: undefined, + siteCard: undefined, + }, + warning: { + actionButtons: undefined, + hero: ( + + 주의하세요! +
+ 주의가 필요한 사이트입니다 + + } + tone="warning" + /> + ), + riskLevelCard: { + description: '의심 패턴 일부 감지됨', + levelText: '주의', + }, + siteCard: { + badgeLabel: '!', + visitLabel: '주의하여 사이트 방문하기', + }, + }, +} as const; + +export default function ResultPage({ tone }: ResultPageProps) { + const { + handleReport, + handleRescan, + handleShareResult, + handleViewReport, + handleVisit, + resultData, + } = useResultPage(tone); + const config = resultViewConfig[tone]; + + if (!resultData) { + return null; + } + + return ( + + ); +} diff --git a/src/pages/Result/api/createResultPageFetcher.ts b/src/pages/Result/api/createResultPageFetcher.ts new file mode 100644 index 0000000..81003b9 --- /dev/null +++ b/src/pages/Result/api/createResultPageFetcher.ts @@ -0,0 +1,57 @@ +import { + ensureScanDetail, + resolveRequestedUrlFromSearch, +} from '@/shared/lib/scan-session/ensureScanDetail'; +import { getScanSessionSnapshot } from '@/shared/store/scanSessionStore'; +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import { toResultPageData } from '../lib/toResultPageData'; + +import type { ResultPageData } from '../types/resultPage.types'; + +type ResultPageFetcher = { + fetchResultPageData: () => Promise; + getInitialResultPageData: () => ResultPageData | null; +}; + +function hasSessionResult(session: ScanSessionSnapshot): boolean { + return Boolean( + session.analysisDetail || + session.finalResult || + session.scanResponse || + session.historySelection, + ); +} + +export function createResultPageFetcher(tone: ResultTone): ResultPageFetcher { + async function fetchResultPageData(): Promise { + const session = getScanSessionSnapshot(); + const hasRecoverableUrl = Boolean( + session.decodedUrl || session.historySelection?.url || resolveRequestedUrlFromSearch(), + ); + + if (!session.analysisDetail && hasRecoverableUrl) { + const detailSession = await ensureScanDetail(); + return toResultPageData(detailSession, tone); + } + + if (hasSessionResult(session)) { + return toResultPageData(session, tone); + } + + throw new Error('SCAN_SESSION_REQUIRED'); + } + + function getInitialResultPageData(): ResultPageData | null { + const session = getScanSessionSnapshot(); + + if (hasSessionResult(session)) { + return toResultPageData(session, tone); + } + + return null; + } + + return { fetchResultPageData, getInitialResultPageData }; +} diff --git a/src/pages/Result/hooks/useResultPage.ts b/src/pages/Result/hooks/useResultPage.ts new file mode 100644 index 0000000..4892dc5 --- /dev/null +++ b/src/pages/Result/hooks/useResultPage.ts @@ -0,0 +1,100 @@ +import { App } from 'antd'; +import { useCallback } from 'react'; + +import { openExternalLink } from '@/shared/lib/browser/openExternalLink'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import { useResultPageBase } from './useResultPageBase'; +import { createResultPageFetcher } from '../api/createResultPageFetcher'; + +import type { ResultPageData } from '../types/resultPage.types'; + +const resultPageFetcherByTone = { + critical: createResultPageFetcher('critical'), + safe: createResultPageFetcher('safe'), + warning: createResultPageFetcher('warning'), +} as const; + +const loadErrorMessageByTone: Record = { + critical: '위험 결과를 불러오지 못했습니다.', + safe: '안전 결과를 불러오지 못했습니다.', + warning: '주의 결과를 불러오지 못했습니다.', +}; + +type UseResultPageReturn = { + handleReport: () => void; + handleRescan: () => void; + handleShareResult: () => Promise; + handleViewReport: () => void; + handleVisit: () => void; + resultData: ResultPageData | null; +}; + +export function useResultPage(tone: ResultTone): UseResultPageReturn { + const { message, modal } = App.useApp(); + const resultPageFetcher = resultPageFetcherByTone[tone]; + const { handleRescan, handleShareResult, handleViewReport, resultData } = useResultPageBase({ + fetchPageData: resultPageFetcher.fetchResultPageData, + getInitialPageData: resultPageFetcher.getInitialResultPageData, + loadErrorMessage: loadErrorMessageByTone[tone], + }); + + const handleVisit = useCallback(() => { + if (!resultData) { + return; + } + + if (tone === 'critical') { + modal.error({ + centered: true, + content: '이 URL은 악성 위협이 높아 접속을 차단했습니다.', + okText: '확인', + title: '접속 차단', + }); + return; + } + + if (tone === 'warning') { + modal.confirm({ + cancelText: '취소', + centered: true, + content: '주의가 필요한 사이트입니다. 그래도 방문하시겠습니까?', + okText: '방문하기', + title: '주의 사이트 방문', + onOk: () => { + openExternalLink(resultData.visitUrl); + }, + }); + return; + } + + openExternalLink(resultData.visitUrl); + }, [modal, resultData, tone]); + + const handleReport = useCallback(() => { + if (tone !== 'critical') { + return; + } + + modal.confirm({ + cancelText: '취소', + centered: true, + content: '해당 URL을 위험 사이트로 신고하시겠습니까?', + okText: '신고하기', + okType: 'danger', + title: '위험 URL 신고', + onOk: () => { + message.success('신고가 접수되었습니다. 빠르게 확인하겠습니다.'); + }, + }); + }, [message, modal, tone]); + + return { + handleReport, + handleRescan, + handleShareResult, + handleViewReport, + handleVisit, + resultData, + }; +} diff --git a/src/pages/Result/hooks/useResultPageBase.ts b/src/pages/Result/hooks/useResultPageBase.ts new file mode 100644 index 0000000..fda0e9e --- /dev/null +++ b/src/pages/Result/hooks/useResultPageBase.ts @@ -0,0 +1,60 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useCallback } from 'react'; + +import { shareCurrentPage } from '@/shared/lib/browser/shareCurrentPage'; +import { useSessionGuardedPageData } from '@/shared/lib/page/useSessionGuardedPageData'; +import { useScanProgressStore } from '@/shared/store/scanProgressStore'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; + +type UseResultPageBaseOptions = { + fetchPageData: () => Promise; + getInitialPageData: () => T | null; + loadErrorMessage: string; +}; + +type UseResultPageBaseReturn = { + handleRescan: () => void; + handleShareResult: () => Promise; + handleViewReport: () => void; + resultData: T | null; +}; + +export function useResultPageBase({ + fetchPageData, + getInitialPageData, + loadErrorMessage, +}: UseResultPageBaseOptions): UseResultPageBaseReturn { + const navigate = useNavigate(); + const clearScanSession = useScanSessionStore((state) => state.clearSession); + const resetScanProgress = useScanProgressStore((state) => state.reset); + const { data: resultData } = useSessionGuardedPageData({ + fetchPageData, + getInitialPageData, + loadErrorMessage, + logMessage: 'Failed to load result page data.', + }); + + const handleShareResult = useCallback(async () => { + await shareCurrentPage({ + text: 'Veri-Q 분석 결과를 확인해 보세요.', + title: 'Veri-Q 분석 결과', + }); + }, []); + + const handleRescan = useCallback(() => { + clearScanSession(); + resetScanProgress(); + void navigate({ to: '/' }); + }, [clearScanSession, navigate, resetScanProgress]); + + const handleViewReport = useCallback(() => { + void navigate({ to: '/report' }); + }, [navigate]); + + return { + handleRescan, + handleShareResult, + handleViewReport, + resultData, + }; +} diff --git a/src/pages/Result/index.ts b/src/pages/Result/index.ts new file mode 100644 index 0000000..2947b83 --- /dev/null +++ b/src/pages/Result/index.ts @@ -0,0 +1 @@ +export { default } from './ResultPage'; diff --git a/src/pages/Result/lib/toResultPageData.test.ts b/src/pages/Result/lib/toResultPageData.test.ts new file mode 100644 index 0000000..e7ff4c5 --- /dev/null +++ b/src/pages/Result/lib/toResultPageData.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import { toResultPageData } from './toResultPageData'; + +describe('toResultPageData', () => { + it('maps certificate metadata into result page site meta', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + originalUrl: 'https://qr.generatorqr.com/1SpwYoKyl', + score: 23, + serverInfo: { + certificate: { + issuer: null, + valid: false, + validFrom: null, + validTo: null, + }, + }, + }, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const resultPageData = toResultPageData(session, 'safe'); + + expect(resultPageData.siteMeta).toBe('SSL 인증서 유효하지 않음 · 발급자 정보 없음'); + }); +}); diff --git a/src/pages/Result/lib/toResultPageData.ts b/src/pages/Result/lib/toResultPageData.ts new file mode 100644 index 0000000..7201303 --- /dev/null +++ b/src/pages/Result/lib/toResultPageData.ts @@ -0,0 +1,164 @@ +import { + pickBoolean, + pickSourceNumber, + pickSourceRecord, + pickSourceString, +} from '@/shared/api/responseAccess/payloadAccess'; +import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import type { ResultPageData } from '../types/resultPage.types'; + +const trustScoreFallbackByTone: Record = { + critical: 82, + safe: 18, + warning: 45, +}; + +const missingCertificateLabel = 'SSL 인증서 정보 없음'; + +function clampTrustScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} + +function formatDateOnlyLabel(rawDate: string | null): string | null { + if (!rawDate) { + return null; + } + + const parsedDate = new Date(rawDate); + + if (Number.isNaN(parsedDate.getTime())) { + return rawDate; + } + + const dateParts = new Intl.DateTimeFormat('ko-KR', { + day: '2-digit', + month: '2-digit', + timeZone: 'Asia/Seoul', + year: 'numeric', + }).formatToParts(parsedDate); + const year = dateParts.find((part) => part.type === 'year')?.value; + const month = dateParts.find((part) => part.type === 'month')?.value; + const day = dateParts.find((part) => part.type === 'day')?.value; + + if (!year || !month || !day) { + return rawDate; + } + + return `${year}.${month}.${day}`; +} + +function resolveCertificateRecord(sources: unknown[]): Record | null { + const serverInfoRecord = pickSourceRecord(sources, ['serverInfo', 'server_info']); + + return pickSourceRecord([serverInfoRecord, ...sources], ['certificate']); +} + +function resolveCertificateStatusText(certificateRecord: Record | null): string { + const isValid = pickBoolean(certificateRecord, ['valid', 'isValid', 'is_valid']); + + if (isValid === null) { + return missingCertificateLabel; + } + + return isValid ? 'SSL 인증서 유효함' : 'SSL 인증서 유효하지 않음'; +} + +function resolveCertificateIssuerText( + certificateRecord: Record | null, +): string | null { + if (!certificateRecord) { + return null; + } + + const issuer = pickSourceString([certificateRecord], ['issuer', 'certificateIssuer']); + + return issuer ? `발급자 ${issuer}` : '발급자 정보 없음'; +} + +function resolveCertificateValidityText( + certificateRecord: Record | null, +): string | null { + const validFrom = formatDateOnlyLabel( + pickSourceString([certificateRecord], ['validFrom', 'valid_from']), + ); + const validTo = formatDateOnlyLabel( + pickSourceString([certificateRecord], ['validTo', 'valid_to']), + ); + + if (!validFrom && !validTo) { + return null; + } + + return `유효 기간 ${validFrom ?? '정보 없음'} - ${validTo ?? '정보 없음'}`; +} + +function buildSiteMeta(sources: unknown[]): string { + const certificateRecord = resolveCertificateRecord(sources); + const metaParts = [ + resolveCertificateStatusText(certificateRecord), + resolveCertificateIssuerText(certificateRecord), + resolveCertificateValidityText(certificateRecord), + ].filter((metaPart): metaPart is string => Boolean(metaPart)); + + return metaParts.join(' · '); +} + +export function toResultPageData( + session: ScanSessionSnapshot, + fallbackTone: ResultTone, +): ResultPageData { + const sources = [ + session.analysisDetail, + session.finalResult, + session.scanResponse, + session.historySelection, + ]; + + const resolvedOriginalUrl = + pickSourceString(sources, [ + 'originalUrl', + 'original_url', + 'sourceUrl', + 'source_url', + 'scannedUrl', + 'scanned_url', + 'decodedUrl', + 'decoded_url', + 'url', + ]) ?? + session.decodedUrl ?? + session.historySelection?.url ?? + ''; + + const redirectRecord = pickSourceRecord(sources, ['redirect']); + const resolvedFinalUrl = + pickSourceString([redirectRecord], ['finalUrl', 'final_url']) ?? + pickSourceString(sources, [ + 'finalUrl', + 'final_url', + 'destinationUrl', + 'destination_url', + 'visitUrl', + 'visit_url', + ]) ?? + resolvedOriginalUrl; + + const resolvedPreviewUrl = + pickSourceString(sources, ['previewUrl', 'preview_url']) ?? resolvedFinalUrl; + const resolvedTone = resolveResultToneFromSources(sources, session.riskLevel) ?? fallbackTone; + const resolvedTrustScore = + pickSourceNumber(sources, ['trustScore', 'trust_score', 'score']) ?? + trustScoreFallbackByTone[resolvedTone]; + + return { + previewUrl: resolvedPreviewUrl, + siteMeta: buildSiteMeta(sources), + siteName: resolvedOriginalUrl, + siteUrl: resolvedFinalUrl, + trustScore: clampTrustScore(resolvedTrustScore), + visitUrl: resolvedFinalUrl, + }; +} diff --git a/src/pages/Result/types/resultPage.types.ts b/src/pages/Result/types/resultPage.types.ts new file mode 100644 index 0000000..03d092c --- /dev/null +++ b/src/pages/Result/types/resultPage.types.ts @@ -0,0 +1,8 @@ +export type ResultPageData = { + previewUrl: string; + siteMeta: string; + siteName: string; + siteUrl: string; + trustScore: number; + visitUrl: string; +}; diff --git a/src/pages/Result/ui/ResultStatusPage.tsx b/src/pages/Result/ui/ResultStatusPage.tsx new file mode 100644 index 0000000..619a444 --- /dev/null +++ b/src/pages/Result/ui/ResultStatusPage.tsx @@ -0,0 +1,99 @@ +import type { ComponentProps, ReactNode } from 'react'; + +import { + ReportToggleButton, + ResultActionButtons, + RiskLevelCard, + SafeSiteCard, + TrustScoreCard, +} from '@/shared/component'; +import { qrIconByTone } from '@/shared/icon/resultIcons'; +import type { ResultTone } from '@/shared/types/resultTone'; +import AppHeader from '@/shared/ui/app-header'; +import * as styles from '@/shared/ui/resultPage/resultPage.css'; + +type RiskLevelCardOptions = ComponentProps; +type SafeSiteCardOptions = Pick< + ComponentProps, + 'badgeLabel' | 'siteMeta' | 'statusLabel' | 'tone' | 'visitLabel' +>; +type ResultActionOptions = Pick< + ComponentProps, + 'onReportClick' | 'reportLabel' +>; + +type ResultStatusPageData = { + siteMeta: string; + siteName: string; + siteUrl: string; + trustScore: number; +}; + +type ResultStatusPageProps = { + actionButtons?: ResultActionOptions; + hero: ReactNode; + onRescanClick: () => void; + onShareClick: ComponentProps['onShareClick']; + onViewReportClick: () => void; + onVisitClick: () => void; + resultData: ResultStatusPageData; + riskLevelCard?: RiskLevelCardOptions; + siteCard?: SafeSiteCardOptions; + tone: ResultTone; +}; + +const SECURITY_ENGINE_VERSION = 'v2.4.1'; + +export default function ResultStatusPage({ + actionButtons, + hero, + onRescanClick, + onShareClick, + onViewReportClick, + onVisitClick, + resultData, + riskLevelCard, + siteCard, + tone, +}: ResultStatusPageProps) { + const currentYear = new Date().getFullYear(); + + return ( +
+ + +
+
+ {hero} + +
+ + +
+ + + + + + + +

+ {`Veri-Q Security Engine ${SECURITY_ENGINE_VERSION} (c) ${currentYear}. All rights reserved.`} +

+
+
+
+ ); +} diff --git a/src/pages/ResultCritical/ResultCriticalPage.tsx b/src/pages/ResultCritical/ResultCriticalPage.tsx deleted file mode 100644 index e8b970a..0000000 --- a/src/pages/ResultCritical/ResultCriticalPage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - ReportToggleButton, - ResultActionButtons, - RiskLevelCard, - SafeSiteCard, - TrustScoreCard, -} from '@/shared/component'; -import { qrIconByTone } from '@/shared/icon/resultIcons'; -import AppHeader from '@/shared/ui/app-header'; - -import { useResultCriticalPage } from './hooks/useResultCriticalPage'; -import * as styles from './styles/resultCriticalPage.css'; -import ResultCriticalHero from './ui/ResultCriticalHero'; -import ResultCriticalReportPanel from './ui/ResultCriticalReportPanel'; - -export default function ResultCriticalPage() { - const { - handleBlockAccess, - handleReport, - handleRescan, - handleShareResult, - handleToggleReport, - isReportOpen, - resultCriticalData, - } = useResultCriticalPage(); - - return ( -
- - -
-
- - -
- - -
- - - - - - - - {isReportOpen ? : null} - -

- Veri-Q Security Engine v2.4.1 (c) 2024. All rights reserved. -

-
-
-
- ); -} diff --git a/src/pages/ResultCritical/api/fetchResultCriticalPageData.ts b/src/pages/ResultCritical/api/fetchResultCriticalPageData.ts deleted file mode 100644 index 3d553a0..0000000 --- a/src/pages/ResultCritical/api/fetchResultCriticalPageData.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ResultCriticalPageData } from '../types/resultCriticalPage.types'; - -export const mockResultCriticalPageData: ResultCriticalPageData = { - previewUrl: 'https://www.example-critical-site.com/preview', - siteName: 'Example Critical Site', - siteUrl: 'https://www.example-critical-site.com', - trustScore: 24, - visitUrl: 'https://www.example-critical-site.com', -}; - -export async function fetchResultCriticalPageData(): Promise { - // TODO: Replace this mock return with real API integration. - return Promise.resolve(mockResultCriticalPageData); -} - -export function getInitialResultCriticalPageData(): ResultCriticalPageData { - return mockResultCriticalPageData; -} diff --git a/src/pages/ResultCritical/hooks/useResultCriticalPage.ts b/src/pages/ResultCritical/hooks/useResultCriticalPage.ts deleted file mode 100644 index d986552..0000000 --- a/src/pages/ResultCritical/hooks/useResultCriticalPage.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useNavigate } from '@tanstack/react-router'; -import { App } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; - -import { shareCurrentPage } from '@/shared/lib/browser/shareCurrentPage'; - -import { - fetchResultCriticalPageData, - getInitialResultCriticalPageData, -} from '../api/fetchResultCriticalPageData'; - -import type { ResultCriticalPageData } from '../types/resultCriticalPage.types'; - -type UseResultCriticalPageReturn = { - handleBlockAccess: () => void; - handleReport: () => void; - handleRescan: () => void; - handleShareResult: () => Promise; - handleToggleReport: () => void; - isReportOpen: boolean; - resultCriticalData: ResultCriticalPageData; -}; - -export function useResultCriticalPage(): UseResultCriticalPageReturn { - const { message, modal } = App.useApp(); - const navigate = useNavigate(); - const [resultCriticalData, setResultCriticalData] = useState( - getInitialResultCriticalPageData, - ); - const [isReportOpen, setIsReportOpen] = useState(false); - - useEffect(() => { - let isMounted = true; - - const loadResultCriticalData = async () => { - const response = await fetchResultCriticalPageData(); - - if (isMounted) { - setResultCriticalData(response); - } - }; - - void loadResultCriticalData(); - - return () => { - isMounted = false; - }; - }, []); - - const handleBlockAccess = useCallback(() => { - modal.error({ - centered: true, - content: '이 URL은 악성 위험이 높아 접속이 차단되었습니다.', - okText: '확인', - title: '접속 차단', - }); - }, [modal]); - - const handleReport = useCallback(() => { - modal.confirm({ - cancelText: '취소', - centered: true, - content: '해당 URL을 위험 사이트로 신고하시겠습니까?', - okText: '신고하기', - okType: 'danger', - title: '위험 URL 신고', - onOk: () => { - message.success('신고가 접수되었습니다. 빠르게 확인하겠습니다.'); - }, - }); - }, [message, modal]); - - const handleShareResult = useCallback(async () => { - await shareCurrentPage({ - text: 'Veri-Q 분석 결과를 확인해 보세요.', - title: 'Veri-Q 분석 결과', - }); - }, []); - - const handleRescan = useCallback(() => { - void navigate({ to: '/' }); - }, [navigate]); - - const handleToggleReport = useCallback(() => { - setIsReportOpen((prev) => !prev); - }, []); - - return { - handleBlockAccess, - handleReport, - handleRescan, - handleShareResult, - handleToggleReport, - isReportOpen, - resultCriticalData, - }; -} diff --git a/src/pages/ResultCritical/index.ts b/src/pages/ResultCritical/index.ts deleted file mode 100644 index a727e06..0000000 --- a/src/pages/ResultCritical/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ResultCriticalPage'; diff --git a/src/pages/ResultCritical/styles/resultCriticalPage.css.ts b/src/pages/ResultCritical/styles/resultCriticalPage.css.ts deleted file mode 100644 index 4c841ca..0000000 --- a/src/pages/ResultCritical/styles/resultCriticalPage.css.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -import { vars } from '@/vars.css'; - -const bpTablet = 'screen and (min-width: 768px)'; -const bpDesktop = 'screen and (min-width: 1200px)'; - -export const page = style({ - minHeight: '100vh', - backgroundColor: vars.colors.white, -}); - -export const shell = style({ - boxSizing: 'border-box', - maxWidth: '1120px', - width: '100%', - margin: '0 auto', - padding: `0 ${vars.spacing.md} ${vars.spacing.lg}`, - '@media': { - [bpTablet]: { - padding: `0 ${vars.spacing.lg} ${vars.spacing.xl}`, - }, - }, -}); - -export const content = style({ - width: '100%', - maxWidth: '640px', - margin: '0 auto', - display: 'grid', - gap: vars.spacing.lg, - paddingTop: vars.spacing.lg, - '@media': { - [bpDesktop]: { - gap: vars.spacing.xl, - }, - }, -}); - -export const hero = style({ - display: 'grid', - justifyItems: 'center', - textAlign: 'center', - gap: vars.spacing.md, -}); - -export const statusHalo = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 'clamp(216px, 40vw, 270px)', - height: 'clamp(216px, 40vw, 270px)', -}); - -export const statusBadge = style({ - display: 'block', - width: '100%', - height: '100%', - objectFit: 'contain', -}); - -export const heroTitle = style({ - margin: 0, - color: vars.colors.error, - fontSize: 'clamp(30px, 4.4vw, 44px)', - lineHeight: 1.25, - letterSpacing: '-0.02em', -}); - -export const heroDescription = style({ - margin: 0, - color: '#D98282', - fontSize: vars.font.size.md, - lineHeight: 1.6, -}); - -export const metricsGrid = style({ - display: 'grid', - gap: vars.spacing.md, - '@media': { - [bpTablet]: { - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - }, - }, -}); - -export const reportPanel = style({ - padding: vars.spacing.md, - borderRadius: vars.radius.md, - border: `1px solid ${vars.colors.border}`, - backgroundColor: vars.colors.sub, - display: 'grid', - gap: vars.spacing.sm, -}); - -export const reportPanelTitle = style({ - margin: 0, - color: vars.colors.black, - fontSize: vars.font.size.md, - fontWeight: vars.font.weight.semibold, -}); - -export const reportList = style({ - margin: 0, - paddingLeft: vars.spacing.md, - display: 'grid', - gap: vars.spacing.xs, - color: vars.colors.subText, - fontSize: vars.font.size.sm, - lineHeight: 1.5, -}); - -export const footer = style({ - textAlign: 'center', - color: vars.colors.subDark, - fontSize: vars.font.size.xs, - paddingTop: vars.spacing.sm, - '@media': { - [bpDesktop]: { - paddingTop: vars.spacing.md, - }, - }, -}); diff --git a/src/pages/ResultCritical/types/resultCriticalPage.types.ts b/src/pages/ResultCritical/types/resultCriticalPage.types.ts deleted file mode 100644 index 82f2c9a..0000000 --- a/src/pages/ResultCritical/types/resultCriticalPage.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { ResultSafeData } from '@/shared/api/result-safe'; - -export type ResultCriticalPageData = ResultSafeData; diff --git a/src/pages/ResultCritical/ui/ResultCriticalHero.tsx b/src/pages/ResultCritical/ui/ResultCriticalHero.tsx deleted file mode 100644 index add1669..0000000 --- a/src/pages/ResultCritical/ui/ResultCriticalHero.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import ResultHero from '@/shared/ui/resultHero'; - -export default function ResultCriticalHero() { - return ( - - ); -} diff --git a/src/pages/ResultCritical/ui/ResultCriticalReportPanel.tsx b/src/pages/ResultCritical/ui/ResultCriticalReportPanel.tsx deleted file mode 100644 index f69e5f7..0000000 --- a/src/pages/ResultCritical/ui/ResultCriticalReportPanel.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as styles from '../styles/resultCriticalPage.css'; - -export default function ResultCriticalReportPanel() { - return ( -
-

상세 분석 항목

-
    -
  • URL 평판 분석: 위험
  • -
  • SSL 인증서 상태: 불안정 또는 만료 가능
  • -
  • 피싱 의심 키워드 패턴: 다수 감지
  • -
-
- ); -} diff --git a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx index cdefde5..652e796 100644 --- a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx +++ b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx @@ -5,13 +5,13 @@ import { qrIconByTone } from '@/shared/icon/resultIcons'; import { openExternalLink } from '@/shared/lib/browser/openExternalLink'; import AppHeader from '@/shared/ui/app-header'; import ResultHero from '@/shared/ui/resultHero'; +import { resultPageStyles } from '@/shared/ui/resultPage'; -import { nonUrlActionPreviewItems } from './constants/nonUrlActionCatalog'; +import { nonUrlActionPreviewItems } from './constants/nonUrlActionText'; import { useResultNonUrlPage } from './hooks/useResultNonUrlPage'; import { resolveNonUrlActionExecution } from './lib/resolveNonUrlActionExecution'; import * as styles from './styles/resultNonUrlPage.css'; import DetectedNonUrlActionSection from './ui/DetectedNonUrlActionSection'; -import * as warningStyles from '../ResultWarning/styles/resultWarningPage.css'; import type { NonUrlActionType } from './types/resultNonUrlPage.types'; @@ -30,12 +30,26 @@ export default function ResultNonUrlPage() { [selectedPreviewActionType], ); + if (!resultNonUrlPageData) { + return null; + } + const displayedActionType = selectedPreviewItem?.actionType ?? resultNonUrlPageData.detectedActionType; const displayedTargetValue = - selectedPreviewItem?.sampleTargetValue ?? resultNonUrlPageData.targetValue; + displayedActionType === resultNonUrlPageData.detectedActionType + ? resultNonUrlPageData.targetValue + : undefined; + const isExecutable = + displayedActionType === resultNonUrlPageData.detectedActionType && + displayedTargetValue !== undefined && + displayedTargetValue !== null; const handleExecuteAction = () => { + if (!isExecutable) { + return; + } + const executionPlan = resolveNonUrlActionExecution(displayedActionType, displayedTargetValue); setExecutionFeedbackMessage(executionPlan.message); @@ -50,11 +64,11 @@ export default function ResultNonUrlPage() { }; return ( -
+
-
-
+
+
- diff --git a/src/pages/ResultNonUrl/api/fetchResultNonUrlPageData.ts b/src/pages/ResultNonUrl/api/fetchResultNonUrlPageData.ts index 505b98f..36b224c 100644 --- a/src/pages/ResultNonUrl/api/fetchResultNonUrlPageData.ts +++ b/src/pages/ResultNonUrl/api/fetchResultNonUrlPageData.ts @@ -1,17 +1,26 @@ +import { getScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import { toResultNonUrlPageData } from '../lib/toResultNonUrlPageData'; + import type { ResultNonUrlPageData } from '../types/resultNonUrlPage.types'; -export const mockResultNonUrlPageData: ResultNonUrlPageData = { - detectedActionType: 'telSms', - sectionDescription: '백엔드에서 전달한 실행 유형 상세 설명과 주의사항을 표시합니다.', - sectionNumber: '1', - sectionTitle: '탐지된 비 URL 유형 분석', - targetValue: '010-1234-5678', -}; +function hasNonUrlResultSession(): boolean { + const session = getScanSessionSnapshot(); + return Boolean(session.finalResult || session.scanResponse || session.historySelection); +} export async function fetchResultNonUrlPageData(): Promise { - return Promise.resolve({ ...mockResultNonUrlPageData }); + if (hasNonUrlResultSession()) { + return toResultNonUrlPageData(getScanSessionSnapshot()); + } + + throw new Error('SCAN_SESSION_REQUIRED'); } -export function getInitialResultNonUrlPageData(): ResultNonUrlPageData { - return { ...mockResultNonUrlPageData }; +export function getInitialResultNonUrlPageData(): ResultNonUrlPageData | null { + if (!hasNonUrlResultSession()) { + return null; + } + + return toResultNonUrlPageData(getScanSessionSnapshot()); } diff --git a/src/pages/ResultNonUrl/constants/nonUrlActionCatalog.ts b/src/pages/ResultNonUrl/constants/nonUrlActionText.ts similarity index 90% rename from src/pages/ResultNonUrl/constants/nonUrlActionCatalog.ts rename to src/pages/ResultNonUrl/constants/nonUrlActionText.ts index d5f5e2d..086c270 100644 --- a/src/pages/ResultNonUrl/constants/nonUrlActionCatalog.ts +++ b/src/pages/ResultNonUrl/constants/nonUrlActionText.ts @@ -5,24 +5,22 @@ type NonUrlActionCatalogItem = { caution: string; englishLabel: string; previewLabel: string; - sampleTargetValue?: string; title: string; }; -export type NonUrlActionPreviewItem = { +type NonUrlActionPreviewItem = { actionType: NonUrlActionType; label: string; - sampleTargetValue?: string; }; -export type ResolvedNonUrlActionContent = { +type ResolvedNonUrlActionContent = { caution: string; description: string; englishLabel: string; title: string; }; -const nonUrlActionCatalog: Record = { +const nonUrlActionTextMap: Record = { appLaunch: { buildDescription: (targetValue) => targetValue @@ -32,7 +30,6 @@ const nonUrlActionCatalog: Record = { '예상하지 못한 앱 실행은 금융 앱 이동, 인증 화면 호출, 악성 앱 연계 등으로 이어질 수 있으므로 사용자가 의도한 동작인지 먼저 확인해야 합니다.', englishLabel: 'APP LAUNCH INTENT', previewLabel: '앱 실행', - sampleTargetValue: 'KakaoTalk', title: '앱 실행 감지', }, appStore: { @@ -44,7 +41,6 @@ const nonUrlActionCatalog: Record = { '출처가 불명확한 설치 유도는 가짜 앱, 악성 앱, 과도한 권한 요청으로 이어질 수 있으므로 앱 이름과 개발사를 반드시 대조해야 합니다.', englishLabel: 'APP STORE INTENT', previewLabel: '앱 스토어', - sampleTargetValue: 'Veri-Q Secure', title: '앱 스토어 이동 감지', }, bitcoin: { @@ -56,7 +52,6 @@ const nonUrlActionCatalog: Record = { '코인 지갑 주소는 한번 송금하면 되돌리기 어렵습니다. 주소 위변조나 사기 결제 유도 가능성이 있으므로 발신자와 결제 맥락을 먼저 검증해야 합니다.', englishLabel: 'BITCOIN WALLET', previewLabel: '비트코인', - sampleTargetValue: 'bc1q8s7examplewallet9xy2k', title: '비트코인 지갑 감지', }, telSms: { @@ -68,7 +63,6 @@ const nonUrlActionCatalog: Record = { '의도하지 않은 통화나 문자 발신은 프리미엄 요금 번호 연결, 스미싱 링크 응답, 개인정보 노출로 이어질 수 있으므로 번호를 먼저 확인해야 합니다.', englishLabel: 'TEL / SMS ACTION', previewLabel: '전화/문자', - sampleTargetValue: '010-1234-5678', title: '전화·문자 실행 감지', }, unknown: { @@ -89,24 +83,22 @@ const nonUrlActionCatalog: Record = { '낯선 Wi-Fi 연결은 트래픽 가로채기, 피싱 포털, 악성 설정 유도로 이어질 수 있습니다. 네트워크 이름과 제공 주체를 먼저 확인해야 합니다.', englishLabel: 'WIFI CONFIGURATION', previewLabel: 'Wi-Fi', - sampleTargetValue: 'Veri-Q Guest', title: 'Wi-Fi 연결 감지', }, }; export const nonUrlActionPreviewItems: NonUrlActionPreviewItem[] = Object.entries( - nonUrlActionCatalog, + nonUrlActionTextMap, ).map(([actionType, catalogItem]) => ({ actionType: actionType as NonUrlActionType, label: catalogItem.previewLabel, - sampleTargetValue: catalogItem.sampleTargetValue, })); export function resolveNonUrlActionContent( actionType: NonUrlActionType, targetValue?: string, ): ResolvedNonUrlActionContent { - const catalogItem = nonUrlActionCatalog[actionType]; + const catalogItem = nonUrlActionTextMap[actionType]; return { caution: catalogItem.caution, diff --git a/src/pages/ResultNonUrl/hooks/useResultNonUrlPage.ts b/src/pages/ResultNonUrl/hooks/useResultNonUrlPage.ts index 7a8ed49..a405950 100644 --- a/src/pages/ResultNonUrl/hooks/useResultNonUrlPage.ts +++ b/src/pages/ResultNonUrl/hooks/useResultNonUrlPage.ts @@ -1,7 +1,10 @@ import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback } from 'react'; import { shareCurrentPage } from '@/shared/lib/browser/shareCurrentPage'; +import { useSessionGuardedPageData } from '@/shared/lib/page/useSessionGuardedPageData'; +import { useScanProgressStore } from '@/shared/store/scanProgressStore'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; import { fetchResultNonUrlPageData, @@ -13,40 +16,25 @@ import type { ResultNonUrlPageData } from '../types/resultNonUrlPage.types'; type UseResultNonUrlPageReturn = { handleRescan: () => void; handleShareResult: () => Promise; - resultNonUrlPageData: ResultNonUrlPageData; + resultNonUrlPageData: ResultNonUrlPageData | null; }; export function useResultNonUrlPage(): UseResultNonUrlPageReturn { const navigate = useNavigate(); - const [resultNonUrlPageData, setResultNonUrlPageData] = useState( - getInitialResultNonUrlPageData, - ); - - useEffect(() => { - let isMounted = true; - - const loadResultNonUrlPageData = async () => { - try { - const response = await fetchResultNonUrlPageData(); - - if (isMounted) { - setResultNonUrlPageData(response); - } - } catch (error) { - console.error('[ResultNonUrlPage] failed to load page data', error); - } - }; - - void loadResultNonUrlPageData(); - - return () => { - isMounted = false; - }; - }, []); + const clearScanSession = useScanSessionStore((state) => state.clearSession); + const resetScanProgress = useScanProgressStore((state) => state.reset); + const { data: resultNonUrlPageData } = useSessionGuardedPageData({ + fetchPageData: fetchResultNonUrlPageData, + getInitialPageData: getInitialResultNonUrlPageData, + loadErrorMessage: '비 URL 결과를 불러오지 못했습니다.', + logMessage: '[ResultNonUrlPage] failed to load page data', + }); const handleRescan = useCallback(() => { + clearScanSession(); + resetScanProgress(); void navigate({ to: '/' }); - }, [navigate]); + }, [clearScanSession, navigate, resetScanProgress]); const handleShareResult = useCallback(async () => { await shareCurrentPage({ diff --git a/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts b/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts index e8715db..34aa8c6 100644 --- a/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts +++ b/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts @@ -1,6 +1,6 @@ import type { NonUrlActionType } from '../types/resultNonUrlPage.types'; -export type NonUrlActionExecutionPlan = +type NonUrlActionExecutionPlan = | { href: string; kind: 'navigate'; diff --git a/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts b/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts new file mode 100644 index 0000000..9eb7283 --- /dev/null +++ b/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import { toResultNonUrlPageData } from './toResultNonUrlPageData'; + +describe('toResultNonUrlPageData', () => { + it('maps non-web scan payload into non-url result page data', () => { + const session: ScanSessionSnapshot = { + analysisDetail: null, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: false, + riskLevel: null, + scanResponse: { + schemeType: 'TEL', + targetValue: '010-1234-5678', + }, + schemeType: 'TEL', + }; + + const resultNonUrlPageData = toResultNonUrlPageData(session); + + expect(resultNonUrlPageData.detectedActionType).toBe('telSms'); + expect(resultNonUrlPageData.targetValue).toBe('010-1234-5678'); + }); +}); diff --git a/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.ts b/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.ts new file mode 100644 index 0000000..b999dda --- /dev/null +++ b/src/pages/ResultNonUrl/lib/toResultNonUrlPageData.ts @@ -0,0 +1,100 @@ +import { pickSourceString } from '@/shared/api/responseAccess/payloadAccess'; +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import type { NonUrlActionType, ResultNonUrlPageData } from '../types/resultNonUrlPage.types'; + +type NonUrlCopy = Pick; + +const actionMatchers: Array<{ + actionType: NonUrlActionType; + keywords: string[]; +}> = [ + { actionType: 'wifi', keywords: ['wifi'] }, + { actionType: 'bitcoin', keywords: ['bitcoin', 'btc'] }, + { actionType: 'telSms', keywords: ['sms', 'tel'] }, + { actionType: 'appStore', keywords: ['store', 'market'] }, + { actionType: 'appLaunch', keywords: ['app', 'intent'] }, +]; + +const nonUrlCopyByActionType: Record = { + appLaunch: { + sectionDescription: + '특정 앱 실행을 요청하는 QR 코드입니다. 사용자가 예상한 앱이 맞는지 먼저 확인해야 합니다.', + sectionTitle: '탐지된 앱 실행 정보', + }, + appStore: { + sectionDescription: + '앱 스토어 이동이 포함된 QR 코드입니다. 앱 이름과 개발사를 확인하기 전에는 설치를 진행하지 않는 편이 안전합니다.', + sectionTitle: '탐지된 앱 스토어 이동 정보', + }, + bitcoin: { + sectionDescription: + '가상자산 지갑 주소 또는 결제 요청이 포함된 QR 코드입니다. 송금 전 주소와 요청 맥락을 다시 확인해야 합니다.', + sectionTitle: '탐지된 비트코인 지갑 정보', + }, + telSms: { + sectionDescription: + '전화 또는 문자 앱 실행을 유도하는 QR 코드입니다. 의도하지 않은 발신이 발생하지 않도록 번호를 먼저 검토해야 합니다.', + sectionTitle: '탐지된 전화·문자 실행 정보', + }, + unknown: { + sectionDescription: + '정확한 실행 방식을 식별하지 못했습니다. 어떤 동작이 수행되는지 불명확하므로 직접 실행은 피하는 편이 안전합니다.', + sectionTitle: '탐지된 비 URL 실행 정보', + }, + wifi: { + sectionDescription: + 'Wi-Fi 연결 정보를 포함한 QR 코드입니다. 네트워크 제공 주체를 확인한 뒤 연결 여부를 결정해야 합니다.', + sectionTitle: '탐지된 Wi-Fi 연결 정보', + }, +}; + +const targetValueKeys = [ + 'targetValue', + 'target_value', + 'decodedUrl', + 'decoded_url', + 'phoneNumber', + 'phone_number', + 'ssid', + 'appName', + 'app_name', + 'walletAddress', + 'wallet_address', + 'url', +]; + +function resolveNonUrlActionType(rawActionType: string | null): NonUrlActionType { + const normalizedActionType = rawActionType?.trim().toLowerCase() ?? ''; + + if (!normalizedActionType) { + return 'unknown'; + } + + return ( + actionMatchers.find(({ keywords }) => + keywords.some((keyword) => normalizedActionType.includes(keyword)), + )?.actionType ?? 'unknown' + ); +} + +export function toResultNonUrlPageData(session: ScanSessionSnapshot): ResultNonUrlPageData { + const sources = [ + session.analysisDetail, + session.finalResult, + session.scanResponse, + session.historySelection, + ]; + const actionType = resolveNonUrlActionType( + pickSourceString(sources, ['actionType', 'action_type', 'schemeType', 'scheme_type']), + ); + const copy = nonUrlCopyByActionType[actionType]; + + return { + detectedActionType: actionType, + sectionDescription: copy.sectionDescription, + sectionNumber: '1', + sectionTitle: copy.sectionTitle, + targetValue: pickSourceString(sources, targetValueKeys) ?? undefined, + }; +} diff --git a/src/pages/ResultNonUrl/ui/DetectedNonUrlActionSection.tsx b/src/pages/ResultNonUrl/ui/DetectedNonUrlActionSection.tsx index 19ce6ea..eb5dbc6 100644 --- a/src/pages/ResultNonUrl/ui/DetectedNonUrlActionSection.tsx +++ b/src/pages/ResultNonUrl/ui/DetectedNonUrlActionSection.tsx @@ -1,4 +1,4 @@ -import { resolveNonUrlActionContent } from '../constants/nonUrlActionCatalog'; +import { resolveNonUrlActionContent } from '../constants/nonUrlActionText'; import * as styles from '../styles/resultNonUrlPage.css'; import type { NonUrlActionType } from '../types/resultNonUrlPage.types'; diff --git a/src/pages/ResultSafe/ResultSafePage.tsx b/src/pages/ResultSafe/ResultSafePage.tsx deleted file mode 100644 index 38967a2..0000000 --- a/src/pages/ResultSafe/ResultSafePage.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { - ReportToggleButton, - ResultActionButtons, - RiskLevelCard, - SafeSiteCard, - TrustScoreCard, -} from '@/shared/component'; -import { qrIconByTone } from '@/shared/icon/resultIcons'; -import AppHeader from '@/shared/ui/app-header'; - -import { useResultSafePage } from './hooks/useResultSafePage'; -import * as styles from './styles/resultSafePage.css'; -import ResultSafeHero from './ui/ResultSafeHero'; -import ResultSafeReportPanel from './ui/ResultSafeReportPanel'; - -export default function ResultSafePage() { - const { - handleOpenVisitSite, - handleRescan, - handleShareResult, - handleToggleReport, - isReportOpen, - resultSafeData, - } = useResultSafePage(); - - return ( -
- - -
-
- - -
- - -
- - - - - - - - {isReportOpen ? : null} - -

- Veri-Q Security Engine v2.4.1 (c) 2024. All rights reserved. -

-
-
-
- ); -} diff --git a/src/pages/ResultSafe/api/fetchResultSafePageData.ts b/src/pages/ResultSafe/api/fetchResultSafePageData.ts deleted file mode 100644 index 87bef4e..0000000 --- a/src/pages/ResultSafe/api/fetchResultSafePageData.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { fetchResultSafeData, mockResultSafeData } from '@/shared/api/result-safe'; - -import type { ResultSafePageData } from '../types/resultSafePage.types'; - -export async function fetchResultSafePageData(): Promise { - return fetchResultSafeData(); -} - -export function getInitialResultSafePageData(): ResultSafePageData { - return mockResultSafeData; -} diff --git a/src/pages/ResultSafe/hooks/useResultSafePage.ts b/src/pages/ResultSafe/hooks/useResultSafePage.ts deleted file mode 100644 index 00410ee..0000000 --- a/src/pages/ResultSafe/hooks/useResultSafePage.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useEffect, useState } from 'react'; - -import { openExternalLink } from '@/shared/lib/browser/openExternalLink'; -import { shareCurrentPage } from '@/shared/lib/browser/shareCurrentPage'; - -import { - fetchResultSafePageData, - getInitialResultSafePageData, -} from '../api/fetchResultSafePageData'; - -import type { ResultSafePageData } from '../types/resultSafePage.types'; - -type UseResultSafePageReturn = { - handleOpenVisitSite: () => void; - handleRescan: () => void; - handleShareResult: () => Promise; - handleToggleReport: () => void; - isReportOpen: boolean; - resultSafeData: ResultSafePageData; -}; - -export function useResultSafePage(): UseResultSafePageReturn { - const navigate = useNavigate(); - const [resultSafeData, setResultSafeData] = useState( - getInitialResultSafePageData, - ); - const [isReportOpen, setIsReportOpen] = useState(false); - - useEffect(() => { - let isMounted = true; - - const loadResultSafeData = async () => { - const response = await fetchResultSafePageData(); - - if (isMounted) { - setResultSafeData(response); - } - }; - - void loadResultSafeData(); - - return () => { - isMounted = false; - }; - }, []); - - const handleOpenVisitSite = useCallback(() => { - openExternalLink(resultSafeData.visitUrl); - }, [resultSafeData.visitUrl]); - - const handleShareResult = useCallback(async () => { - await shareCurrentPage({ - text: 'Veri-Q 분석 결과를 확인해 보세요.', - title: 'Veri-Q 분석 결과', - }); - }, []); - - const handleRescan = useCallback(() => { - void navigate({ to: '/' }); - }, [navigate]); - - const handleToggleReport = useCallback(() => { - setIsReportOpen((prev) => !prev); - }, []); - - return { - handleOpenVisitSite, - handleRescan, - handleShareResult, - handleToggleReport, - isReportOpen, - resultSafeData, - }; -} diff --git a/src/pages/ResultSafe/index.ts b/src/pages/ResultSafe/index.ts deleted file mode 100644 index 195b4f7..0000000 --- a/src/pages/ResultSafe/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ResultSafePage'; diff --git a/src/pages/ResultSafe/styles/resultSafePage.css.ts b/src/pages/ResultSafe/styles/resultSafePage.css.ts deleted file mode 100644 index bfb03dd..0000000 --- a/src/pages/ResultSafe/styles/resultSafePage.css.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -import { vars } from '@/vars.css'; - -const bpTablet = 'screen and (min-width: 768px)'; -const bpDesktop = 'screen and (min-width: 1200px)'; - -export const page = style({ - minHeight: '100vh', - backgroundColor: vars.colors.white, -}); - -export const shell = style({ - boxSizing: 'border-box', - maxWidth: '1120px', - width: '100%', - margin: '0 auto', - padding: `0 ${vars.spacing.md} ${vars.spacing.lg}`, - '@media': { - [bpTablet]: { - padding: `0 ${vars.spacing.lg} ${vars.spacing.xl}`, - }, - }, -}); - -export const content = style({ - width: '100%', - maxWidth: '640px', - margin: '0 auto', - display: 'grid', - gap: vars.spacing.lg, - paddingTop: vars.spacing.lg, - '@media': { - [bpDesktop]: { - gap: vars.spacing.xl, - }, - }, -}); - -export const hero = style({ - display: 'grid', - justifyItems: 'center', - textAlign: 'center', - gap: vars.spacing.md, -}); - -export const statusHalo = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 'clamp(216px, 40vw, 270px)', - height: 'clamp(216px, 40vw, 270px)', -}); - -export const statusBadge = style({ - display: 'block', - width: '100%', - height: '100%', - objectFit: 'contain', -}); - -export const heroTitle = style({ - margin: 0, - color: vars.colors.black, - fontSize: 'clamp(32px, 5vw, 48px)', - lineHeight: 1.15, - letterSpacing: '-0.02em', -}); - -export const heroDescription = style({ - margin: 0, - color: vars.colors.subText, - fontSize: vars.font.size.md, - lineHeight: 1.6, -}); - -export const metricsGrid = style({ - display: 'grid', - gap: vars.spacing.md, - '@media': { - [bpTablet]: { - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - }, - }, -}); - -export const reportPanel = style({ - padding: vars.spacing.md, - borderRadius: vars.radius.md, - border: `1px solid ${vars.colors.border}`, - backgroundColor: vars.colors.sub, - display: 'grid', - gap: vars.spacing.sm, -}); - -export const reportPanelTitle = style({ - margin: 0, - color: vars.colors.black, - fontSize: vars.font.size.md, - fontWeight: vars.font.weight.semibold, -}); - -export const reportList = style({ - margin: 0, - paddingLeft: vars.spacing.md, - display: 'grid', - gap: vars.spacing.xs, - color: vars.colors.subText, - fontSize: vars.font.size.sm, - lineHeight: 1.5, -}); - -export const footer = style({ - textAlign: 'center', - color: vars.colors.subDark, - fontSize: vars.font.size.xs, - paddingTop: vars.spacing.sm, - '@media': { - [bpDesktop]: { - paddingTop: vars.spacing.md, - }, - }, -}); diff --git a/src/pages/ResultSafe/types/resultSafePage.types.ts b/src/pages/ResultSafe/types/resultSafePage.types.ts deleted file mode 100644 index 2ce4c80..0000000 --- a/src/pages/ResultSafe/types/resultSafePage.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { ResultSafeData } from '@/shared/api/result-safe'; - -export type ResultSafePageData = ResultSafeData; diff --git a/src/pages/ResultSafe/ui/ResultSafeHero.tsx b/src/pages/ResultSafe/ui/ResultSafeHero.tsx deleted file mode 100644 index 02841e8..0000000 --- a/src/pages/ResultSafe/ui/ResultSafeHero.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import ResultHero from '@/shared/ui/resultHero'; - -export default function ResultSafeHero() { - return ( - - 안심하세요! -
- 안전한 사이트입니다 - - } - tone="safe" - /> - ); -} diff --git a/src/pages/ResultSafe/ui/ResultSafeReportPanel.tsx b/src/pages/ResultSafe/ui/ResultSafeReportPanel.tsx deleted file mode 100644 index f4dd49f..0000000 --- a/src/pages/ResultSafe/ui/ResultSafeReportPanel.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as styles from '../styles/resultSafePage.css'; - -export default function ResultSafeReportPanel() { - return ( -
-

상세 분석 항목

-
    -
  • URL 평판 분석: 안전
  • -
  • SSL 인증서 상태: 유효
  • -
  • 피싱 키워드 패턴: 미검출
  • -
-
- ); -} diff --git a/src/pages/ResultWarning/ResultWarningPage.tsx b/src/pages/ResultWarning/ResultWarningPage.tsx deleted file mode 100644 index 8acd44e..0000000 --- a/src/pages/ResultWarning/ResultWarningPage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { - ReportToggleButton, - ResultActionButtons, - RiskLevelCard, - SafeSiteCard, - TrustScoreCard, -} from '@/shared/component'; -import { qrIconByTone } from '@/shared/icon/resultIcons'; -import AppHeader from '@/shared/ui/app-header'; - -import { useResultWarningPage } from './hooks/useResultWarningPage'; -import * as styles from './styles/resultWarningPage.css'; -import ResultWarningHero from './ui/ResultWarningHero'; -import ResultWarningReportPanel from './ui/ResultWarningReportPanel'; - -export default function ResultWarningPage() { - const { - handleOpenVisitSite, - handleRescan, - handleShareResult, - handleToggleReport, - isReportOpen, - resultWarningData, - } = useResultWarningPage(); - - return ( -
- - -
-
- - -
- - -
- - - - - - - - {isReportOpen ? : null} - -

- Veri-Q Security Engine v2.4.1 (c) 2024. All rights reserved. -

-
-
-
- ); -} diff --git a/src/pages/ResultWarning/api/fetchResultWarningPageData.ts b/src/pages/ResultWarning/api/fetchResultWarningPageData.ts deleted file mode 100644 index 64bb46e..0000000 --- a/src/pages/ResultWarning/api/fetchResultWarningPageData.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ResultWarningPageData } from '../types/resultWarningPage.types'; - -export const mockResultWarningPageData: ResultWarningPageData = { - previewUrl: 'https://www.example-warning-site.com/preview', - siteName: 'Example Warning Site', - siteUrl: 'https://www.example-warning-site.com', - trustScore: 68, - visitUrl: 'https://www.example-warning-site.com', -}; - -export async function fetchResultWarningPageData(): Promise { - // TODO: Replace this mock return with real API integration. - return Promise.resolve(mockResultWarningPageData); -} - -export function getInitialResultWarningPageData(): ResultWarningPageData { - return mockResultWarningPageData; -} diff --git a/src/pages/ResultWarning/hooks/useResultWarningPage.ts b/src/pages/ResultWarning/hooks/useResultWarningPage.ts deleted file mode 100644 index b86a87a..0000000 --- a/src/pages/ResultWarning/hooks/useResultWarningPage.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useNavigate } from '@tanstack/react-router'; -import { App } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; - -import { openExternalLink } from '@/shared/lib/browser/openExternalLink'; -import { shareCurrentPage } from '@/shared/lib/browser/shareCurrentPage'; - -import { - fetchResultWarningPageData, - getInitialResultWarningPageData, -} from '../api/fetchResultWarningPageData'; - -import type { ResultWarningPageData } from '../types/resultWarningPage.types'; - -type UseResultWarningPageReturn = { - handleOpenVisitSite: () => void; - handleRescan: () => void; - handleShareResult: () => Promise; - handleToggleReport: () => void; - isReportOpen: boolean; - resultWarningData: ResultWarningPageData; -}; - -export function useResultWarningPage(): UseResultWarningPageReturn { - const { modal } = App.useApp(); - const navigate = useNavigate(); - const [resultWarningData, setResultWarningData] = useState( - getInitialResultWarningPageData, - ); - const [isReportOpen, setIsReportOpen] = useState(false); - - useEffect(() => { - let isMounted = true; - - const loadResultWarningData = async () => { - const response = await fetchResultWarningPageData(); - - if (isMounted) { - setResultWarningData(response); - } - }; - - void loadResultWarningData(); - - return () => { - isMounted = false; - }; - }, []); - - const handleOpenVisitSite = useCallback(() => { - modal.confirm({ - cancelText: '취소', - centered: true, - content: '주의가 필요한 사이트입니다. 그래도 방문하시겠습니까?', - okText: '방문하기', - title: '주의 사이트 방문', - onOk: () => { - openExternalLink(resultWarningData.visitUrl); - }, - }); - }, [modal, resultWarningData.visitUrl]); - - const handleShareResult = useCallback(async () => { - await shareCurrentPage({ - text: 'Veri-Q 분석 결과를 확인해 보세요.', - title: 'Veri-Q 분석 결과', - }); - }, []); - - const handleRescan = useCallback(() => { - void navigate({ to: '/' }); - }, [navigate]); - - const handleToggleReport = useCallback(() => { - setIsReportOpen((prev) => !prev); - }, []); - - return { - handleOpenVisitSite, - handleRescan, - handleShareResult, - handleToggleReport, - isReportOpen, - resultWarningData, - }; -} diff --git a/src/pages/ResultWarning/index.ts b/src/pages/ResultWarning/index.ts deleted file mode 100644 index 9ea0d70..0000000 --- a/src/pages/ResultWarning/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ResultWarningPage'; diff --git a/src/pages/ResultWarning/styles/resultWarningPage.css.ts b/src/pages/ResultWarning/styles/resultWarningPage.css.ts deleted file mode 100644 index 7419257..0000000 --- a/src/pages/ResultWarning/styles/resultWarningPage.css.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -import { vars } from '@/vars.css'; - -const bpTablet = 'screen and (min-width: 768px)'; -const bpDesktop = 'screen and (min-width: 1200px)'; - -export const page = style({ - minHeight: '100vh', - backgroundColor: vars.colors.white, -}); - -export const shell = style({ - boxSizing: 'border-box', - maxWidth: '1120px', - width: '100%', - margin: '0 auto', - padding: `0 ${vars.spacing.md} ${vars.spacing.lg}`, - '@media': { - [bpTablet]: { - padding: `0 ${vars.spacing.lg} ${vars.spacing.xl}`, - }, - }, -}); - -export const content = style({ - width: '100%', - maxWidth: '640px', - margin: '0 auto', - display: 'grid', - gap: vars.spacing.lg, - paddingTop: vars.spacing.lg, - '@media': { - [bpDesktop]: { - gap: vars.spacing.xl, - }, - }, -}); - -export const hero = style({ - display: 'grid', - justifyItems: 'center', - textAlign: 'center', - gap: vars.spacing.md, -}); - -export const statusHalo = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 'clamp(216px, 40vw, 270px)', - height: 'clamp(216px, 40vw, 270px)', -}); - -export const statusBadge = style({ - display: 'block', - width: '100%', - height: '100%', - objectFit: 'contain', -}); - -export const heroTitle = style({ - margin: 0, - color: vars.colors.black, - fontSize: 'clamp(32px, 5vw, 48px)', - lineHeight: 1.15, - letterSpacing: '-0.02em', -}); - -export const heroDescription = style({ - margin: 0, - color: '#C8B95A', - fontSize: vars.font.size.md, - lineHeight: 1.6, -}); - -export const metricsGrid = style({ - display: 'grid', - gap: vars.spacing.md, - '@media': { - [bpTablet]: { - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - }, - }, -}); - -export const reportPanel = style({ - padding: vars.spacing.md, - borderRadius: vars.radius.md, - border: `1px solid ${vars.colors.border}`, - backgroundColor: vars.colors.sub, - display: 'grid', - gap: vars.spacing.sm, -}); - -export const reportPanelTitle = style({ - margin: 0, - color: vars.colors.black, - fontSize: vars.font.size.md, - fontWeight: vars.font.weight.semibold, -}); - -export const reportList = style({ - margin: 0, - paddingLeft: vars.spacing.md, - display: 'grid', - gap: vars.spacing.xs, - color: vars.colors.subText, - fontSize: vars.font.size.sm, - lineHeight: 1.5, -}); - -export const footer = style({ - textAlign: 'center', - color: vars.colors.subDark, - fontSize: vars.font.size.xs, - paddingTop: vars.spacing.sm, - '@media': { - [bpDesktop]: { - paddingTop: vars.spacing.md, - }, - }, -}); diff --git a/src/pages/ResultWarning/types/resultWarningPage.types.ts b/src/pages/ResultWarning/types/resultWarningPage.types.ts deleted file mode 100644 index b6dd532..0000000 --- a/src/pages/ResultWarning/types/resultWarningPage.types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { ResultSafeData } from '@/shared/api/result-safe'; - -export type ResultWarningPageData = ResultSafeData; diff --git a/src/pages/ResultWarning/ui/ResultWarningHero.tsx b/src/pages/ResultWarning/ui/ResultWarningHero.tsx deleted file mode 100644 index 7551ac4..0000000 --- a/src/pages/ResultWarning/ui/ResultWarningHero.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import ResultHero from '@/shared/ui/resultHero'; - -export default function ResultWarningHero() { - return ( - - 주의하세요! -
- 주의가 필요한 사이트입니다 - - } - tone="warning" - /> - ); -} diff --git a/src/pages/ResultWarning/ui/ResultWarningReportPanel.tsx b/src/pages/ResultWarning/ui/ResultWarningReportPanel.tsx deleted file mode 100644 index 39f4a0a..0000000 --- a/src/pages/ResultWarning/ui/ResultWarningReportPanel.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as styles from '../styles/resultWarningPage.css'; - -export default function ResultWarningReportPanel() { - return ( -
-

상세 분석 항목

-
    -
  • URL 평판 분석: 주의
  • -
  • SSL 인증서 상태: 유효
  • -
  • 피싱 의심 키워드 패턴: 일부 감지
  • -
-
- ); -} diff --git a/src/pages/ScanHistory/ScanHistoryPage.tsx b/src/pages/ScanHistory/ScanHistoryPage.tsx deleted file mode 100644 index e18c466..0000000 --- a/src/pages/ScanHistory/ScanHistoryPage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useScanHistoryPage } from './hooks/useScanHistoryPage'; - -export default function ScanHistoryPage() { - const { scanHistoryPageData } = useScanHistoryPage(); - - return ( -
-

스캔 이력 목록

-

최근 스캔 내역과 상태를 기준으로 백엔드 연동 전 mock 데이터를 보여줍니다.

- -
    - {scanHistoryPageData.items.map((item) => ( -
  • - {item.title} -

    {item.url}

    -

    분석 시각: {item.scannedAt}

    -

    상태: {item.status}

    -
  • - ))} -
-
- ); -} diff --git a/src/pages/ScanHistory/api/fetchScanHistoryPageData.ts b/src/pages/ScanHistory/api/fetchScanHistoryPageData.ts deleted file mode 100644 index ad5fddf..0000000 --- a/src/pages/ScanHistory/api/fetchScanHistoryPageData.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ScanHistoryPageData } from '../types/scanHistoryPage.types'; - -export const mockScanHistoryPageData: ScanHistoryPageData = { - items: [ - { - id: 'scan-1', - scannedAt: '2026-03-14 13:40', - status: 'safe', - title: '공식 이벤트 안내 페이지', - url: 'https://www.example-safe-site.com', - }, - { - id: 'scan-2', - scannedAt: '2026-03-14 12:10', - status: 'warning', - title: '주의가 필요한 리다이렉트 페이지', - url: 'https://www.example-warning-site.com', - }, - { - id: 'scan-3', - scannedAt: '2026-03-14 09:55', - status: 'critical', - title: '악성 코드 의심 페이지', - url: 'https://www.example-critical-site.com', - }, - ], -}; - -export async function fetchScanHistoryPageData(): Promise { - return Promise.resolve(mockScanHistoryPageData); -} - -export function getInitialScanHistoryPageData(): ScanHistoryPageData { - return mockScanHistoryPageData; -} diff --git a/src/pages/ScanHistory/hooks/useScanHistoryPage.ts b/src/pages/ScanHistory/hooks/useScanHistoryPage.ts deleted file mode 100644 index 4f7eaf3..0000000 --- a/src/pages/ScanHistory/hooks/useScanHistoryPage.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { - fetchScanHistoryPageData, - getInitialScanHistoryPageData, -} from '../api/fetchScanHistoryPageData'; - -import type { ScanHistoryPageData } from '../types/scanHistoryPage.types'; - -type UseScanHistoryPageReturn = { - scanHistoryPageData: ScanHistoryPageData; -}; - -export function useScanHistoryPage(): UseScanHistoryPageReturn { - const [scanHistoryPageData, setScanHistoryPageData] = useState( - getInitialScanHistoryPageData, - ); - - useEffect(() => { - let isMounted = true; - - const loadScanHistoryPageData = async () => { - const response = await fetchScanHistoryPageData(); - - if (isMounted) { - setScanHistoryPageData(response); - } - }; - - void loadScanHistoryPageData(); - - return () => { - isMounted = false; - }; - }, []); - - return { - scanHistoryPageData, - }; -} diff --git a/src/pages/ScanHistory/index.ts b/src/pages/ScanHistory/index.ts deleted file mode 100644 index 0c11a0b..0000000 --- a/src/pages/ScanHistory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ScanHistoryPage'; diff --git a/src/pages/ScanList/ScanListPage.tsx b/src/pages/ScanList/ScanListPage.tsx index ebe8fba..b5309ac 100644 --- a/src/pages/ScanList/ScanListPage.tsx +++ b/src/pages/ScanList/ScanListPage.tsx @@ -8,10 +8,11 @@ import * as styles from './styles/scanListPage.css'; import type { ScanListStatus } from './types/scanListPage.types'; -const resultRouteByStatus: Record< - ScanListStatus, - '/result/safe' | '/result/warning' | '/result/critical' -> = { +type ResultRoute = '/result/critical' | '/result/non-url' | '/result/safe' | '/result/warning'; + +const nonUrlResultRoute = '/result/non-url'; + +const resultRouteByStatus: Record = { safe: '/result/safe', warning: '/result/warning', critical: '/result/critical', @@ -90,8 +91,12 @@ function StatusBadgeIcon({ tone }: { tone: ScanListStatus }) { ); } +function isWebScanListItem(item: { isUrl: boolean | null; schemeType: string | null }): boolean { + return item.isUrl !== false && item.schemeType?.trim().toUpperCase() === 'WEB'; +} + export default function ScanListPage() { - const { scanListPageData, scanListUuid } = useScanListPage(); + const { handleSelectScanResult, scanListPageData, scanListUuid } = useScanListPage(); return (
@@ -109,42 +114,52 @@ export default function ScanListPage() {
연결된 UUID에 대한 스캔 이력이 없습니다.
) : (
    - {scanListPageData.items.map((item) => ( -
  • - - - - {statusLabelByTone[item.status]} - + {scanListPageData.items.map((item) => { + const resultRoute = isWebScanListItem(item) + ? resultRouteByStatus[item.status] + : nonUrlResultRoute; -
    + { + handleSelectScanResult(item); + }} + search={resultRoute === nonUrlResultRoute ? {} : { url: item.url }} + to={resultRoute} > - -
    - -
    -

    {item.url}

    -

    -

    -
    - -
  • - ))} + {statusLabelByTone[item.status]} + + +
    + +
    + +
    +

    {item.url}

    +

    + + {item.scannedAt} +

    +
    + + + ); + })}
)}
diff --git a/src/pages/ScanList/api/fetchScanListPageData.ts b/src/pages/ScanList/api/fetchScanListPageData.ts index cd6372c..4e81ff6 100644 --- a/src/pages/ScanList/api/fetchScanListPageData.ts +++ b/src/pages/ScanList/api/fetchScanListPageData.ts @@ -1,120 +1,5 @@ -import type { ScanListPageData } from '../types/scanListPage.types'; - -export const defaultScanListUuid = '550e8400-e29b-41d4-a716-446655440000'; -const maxRecentScanListItemCount = 5; - -function resolveScannedAtTimestamp(scannedAt: string) { - const normalizedScannedAt = scannedAt.replace(/\./g, '-'); - const parsedTimestamp = Date.parse(normalizedScannedAt); - - if (Number.isNaN(parsedTimestamp)) { - return 0; - } - - return parsedTimestamp; -} - -function buildRecentScanListPageData(pageData: ScanListPageData): ScanListPageData { - const recentItems = [...pageData.items] - .sort((previousItem, nextItem) => { - return ( - resolveScannedAtTimestamp(nextItem.scannedAt) - - resolveScannedAtTimestamp(previousItem.scannedAt) - ); - }) - .slice(0, maxRecentScanListItemCount); - - return { - ...pageData, - items: recentItems, - }; -} - -const mockScanListPageDataByUuid: Record = { - [defaultScanListUuid]: { - uuid: defaultScanListUuid, - items: [ - { - id: '0d6db9a1-4a87-4a62-bc54-8f8af98eb6c1', - scannedAt: '2026.01.13', - status: 'safe', - url: 'https://www.naver.com', - }, - { - id: '5d695db5-bd3f-4d57-a88c-ef8c7b8038f0', - scannedAt: '2025.12.15', - status: 'safe', - url: 'https://www.google.com', - }, - { - id: 'c97a4156-48f6-47a4-b7c2-2dcd7256190e', - scannedAt: '2025.11.23', - status: 'critical', - url: 'https://www.youtube.com', - }, - { - id: '3c6baf44-1f4b-4fb1-835a-7365fe5f16b8', - scannedAt: '2025.10.23', - status: 'warning', - url: 'https://www.daum.net', - }, - { - id: '8ea1aa3d-af6d-4f28-bad4-aa1b1f58094d', - scannedAt: '2025.10.12', - status: 'warning', - url: 'https://www.apple.com', - }, - { - id: '20d9c8f4-b94d-4878-8bc8-6d0a7df65b4c', - scannedAt: '2025.09.18', - status: 'safe', - url: 'https://www.microsoft.com', - }, - ], - }, - '17b8a3a8-4ae1-4b2c-8a61-9f5f3fce3d77': { - uuid: '17b8a3a8-4ae1-4b2c-8a61-9f5f3fce3d77', - items: [ - { - id: '0328f6ae-f146-4862-886a-1e5f4c5c908d', - scannedAt: '2026.02.21', - status: 'safe', - url: 'https://openai.com', - }, - { - id: '947d0d5f-d87c-4816-a835-12eb3245c135', - scannedAt: '2026.02.17', - status: 'warning', - url: 'https://example-warning-site.com', - }, - { - id: '82c0cb52-c9a6-47a5-8bd6-bf5c66843115', - scannedAt: '2026.02.02', - status: 'critical', - url: 'https://example-critical-site.com', - }, - ], - }, -}; - -export async function fetchScanListPageData(uuid: string): Promise { - const pageData = mockScanListPageDataByUuid[uuid]; - - if (pageData) { - return Promise.resolve(buildRecentScanListPageData(pageData)); - } - - return Promise.resolve({ - items: [], - uuid, - }); -} - -export function getInitialScanListPageData(uuid: string): ScanListPageData { - return buildRecentScanListPageData( - mockScanListPageDataByUuid[uuid] ?? { - items: [], - uuid, - }, - ); -} +export { + fetchScanHistoryData as fetchScanListPageData, + fetchRecentScanHistoryData as fetchRecentScanListPageData, + getInitialScanHistoryData as getInitialScanListPageData, +} from '@/features/scan-history/api/fetchScanHistoryData'; diff --git a/src/pages/ScanList/hooks/useScanListPage.ts b/src/pages/ScanList/hooks/useScanListPage.ts index 33ec335..1228128 100644 --- a/src/pages/ScanList/hooks/useScanListPage.ts +++ b/src/pages/ScanList/hooks/useScanListPage.ts @@ -1,66 +1,35 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; -import { - defaultScanListUuid, - fetchScanListPageData, - getInitialScanListPageData, -} from '../api/fetchScanListPageData'; +import { useGuestStore } from '@/shared/store/guestStore'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; -import type { ScanListPageData } from '../types/scanListPage.types'; +import { fetchScanListPageData, getInitialScanListPageData } from '../api/fetchScanListPageData'; + +import type { ScanListItem, ScanListPageData } from '../types/scanListPage.types'; type UseScanListPageReturn = { + handleSelectScanResult: (item: ScanListItem) => void; scanListPageData: ScanListPageData; scanListUuid: string; }; -function isUuid(value: string) { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); -} - -function resolveInitialScanListUuid() { - if (typeof window === 'undefined') { - return defaultScanListUuid; - } - - const uuidParam = new URLSearchParams(window.location.search).get('uuid')?.trim(); - - if (uuidParam && isUuid(uuidParam)) { - return uuidParam; - } - - return defaultScanListUuid; -} - -function updateUuidSearchParam(uuid: string) { - if (typeof window === 'undefined') { - return; - } - - const searchParams = new URLSearchParams(window.location.search); - searchParams.set('uuid', uuid); - - const nextSearch = searchParams.toString(); - const nextUrl = nextSearch - ? `${window.location.pathname}?${nextSearch}` - : window.location.pathname; - - window.history.replaceState(null, '', nextUrl); -} - export function useScanListPage(): UseScanListPageReturn { - const [scanListUuid] = useState(resolveInitialScanListUuid); + const guestUuid = useGuestStore((state) => state.guestUuid); + const setHistorySelection = useScanSessionStore((state) => state.setHistorySelection); const [scanListPageData, setScanListPageData] = useState(() => - getInitialScanListPageData(scanListUuid), + getInitialScanListPageData(guestUuid ?? ''), ); useEffect(() => { - updateUuidSearchParam(scanListUuid); + useGuestStore.getState().ensureGuestUuid(); + }, []); + useEffect(() => { let isMounted = true; const loadScanListPageData = async () => { try { - const response = await fetchScanListPageData(scanListUuid); + const response = await fetchScanListPageData(); if (isMounted) { setScanListPageData(response); @@ -69,7 +38,7 @@ export function useScanListPage(): UseScanListPageReturn { console.error('Failed to load scan list page data.', error); if (isMounted) { - setScanListPageData(getInitialScanListPageData(scanListUuid)); + setScanListPageData(getInitialScanListPageData(guestUuid ?? '')); } } }; @@ -79,10 +48,24 @@ export function useScanListPage(): UseScanListPageReturn { return () => { isMounted = false; }; - }, [scanListUuid]); + }, [guestUuid]); + + const handleSelectScanResult = useCallback( + (item: ScanListItem) => { + setHistorySelection({ + isUrl: item.isUrl, + riskLevel: item.status, + scannedAt: item.scannedAt, + schemeType: item.schemeType, + url: item.url, + }); + }, + [setHistorySelection], + ); return { + handleSelectScanResult, scanListPageData, - scanListUuid, + scanListUuid: scanListPageData.uuid || guestUuid || '', }; } diff --git a/src/pages/ScanList/types/scanListPage.types.ts b/src/pages/ScanList/types/scanListPage.types.ts index 1acee7b..e134e9a 100644 --- a/src/pages/ScanList/types/scanListPage.types.ts +++ b/src/pages/ScanList/types/scanListPage.types.ts @@ -1,13 +1,5 @@ -export type ScanListStatus = 'safe' | 'warning' | 'critical'; - -export type ScanListItem = { - id: string; - scannedAt: string; - status: ScanListStatus; - url: string; -}; - -export type ScanListPageData = { - items: ScanListItem[]; - uuid: string; -}; +export type { + ScanHistoryData as ScanListPageData, + ScanHistoryItem as ScanListItem, + ScanHistoryStatus as ScanListStatus, +} from '@/features/scan-history/types/scanHistory.types'; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index df84126..23ea89e 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -1,19 +1,28 @@ import { Outlet, createRootRoute, createRoute, createRouter } from '@tanstack/react-router'; - -import CaptchaPage from '@/pages/Captcha'; -import HomePage from '@/pages/Home'; -import LoadingPage from '@/pages/Loading'; -import QRScanPage from '@/pages/QRScan'; -import ReportPage from '@/pages/Report'; -import ResultCriticalPage from '@/pages/ResultCritical'; -import ResultNonUrlPage from '@/pages/ResultNonUrl'; -import ResultSafePage from '@/pages/ResultSafe'; -import ResultWarningPage from '@/pages/ResultWarning'; -import ScanHistoryPage from '@/pages/ScanHistory'; -import ScanListPage from '@/pages/ScanList'; +import { Suspense, lazy } from 'react'; + +const CaptchaPage = lazy(() => import('@/pages/Captcha')); +const LoadingPage = lazy(() => import('@/pages/Loading')); +const QRScanPage = lazy(() => import('@/pages/QRScan')); +const ReportPage = lazy(() => import('@/pages/Report')); +const ResultPage = lazy(() => import('@/pages/Result')); +const ResultNonUrlPage = lazy(() => import('@/pages/ResultNonUrl')); +const ScanListPage = lazy(() => import('@/pages/ScanList')); + +function RouteLoader() { + return ( +
+ Loading... +
+ ); +} function RootLayout() { - return ; + return ( + }> + + + ); } const rootRoute = createRootRoute({ @@ -23,7 +32,7 @@ const rootRoute = createRootRoute({ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: HomePage, + component: QRScanPage, }); const captchaRoute = createRoute({ @@ -53,7 +62,7 @@ const reportRoute = createRoute({ const scanHistoryRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scan-history', - component: ScanHistoryPage, + component: ScanListPage, }); const scanListRoute = createRoute({ @@ -65,7 +74,7 @@ const scanListRoute = createRoute({ const resultCriticalRoute = createRoute({ getParentRoute: () => rootRoute, path: '/result/critical', - component: ResultCriticalPage, + component: () => , }); const resultNonUrlRoute = createRoute({ @@ -77,13 +86,13 @@ const resultNonUrlRoute = createRoute({ const resultSafeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/result/safe', - component: ResultSafePage, + component: () => , }); const resultWarningRoute = createRoute({ getParentRoute: () => rootRoute, path: '/result/warning', - component: ResultWarningPage, + component: () => , }); const routeTree = rootRoute.addChildren([ diff --git a/src/shared/api/apiConfig.ts b/src/shared/api/apiConfig.ts new file mode 100644 index 0000000..aaa72fa --- /dev/null +++ b/src/shared/api/apiConfig.ts @@ -0,0 +1,57 @@ +const DEFAULT_API_TIMEOUT_MS = 10_000; +const DEFAULT_UPLOAD_TIMEOUT_MS = 60_000; +const DEFAULT_SSE_RECONNECT_MAX = 3; +const DEV_BE1_PROXY_BASE_URL = '/be1'; +const DEV_BE3_PROXY_BASE_URL = '/be3'; + +function parsePositiveInteger(rawValue: string | undefined, fallback: number): number { + const parsedValue = Number(trimEnvValue(rawValue)); + + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + return fallback; + } + + return parsedValue; +} + +function trimEnvValue(rawValue: string | undefined): string { + return (rawValue ?? '').trim(); +} + +function requireProdEnvValue(rawValue: string | undefined, variableName: string): string { + const trimmedValue = trimEnvValue(rawValue); + + if (!trimmedValue) { + throw new Error(`${variableName} is required for production builds.`); + } + + return trimmedValue; +} + +export function getBe1BaseUrl(): string { + if (import.meta.env.DEV) { + return DEV_BE1_PROXY_BASE_URL; + } + + return requireProdEnvValue(import.meta.env.VITE_BE1_BASE_URL, 'VITE_BE1_BASE_URL'); +} + +export function getBe3BaseUrl(): string { + if (import.meta.env.DEV) { + return DEV_BE3_PROXY_BASE_URL; + } + + return requireProdEnvValue(import.meta.env.VITE_BE3_BASE_URL, 'VITE_BE3_BASE_URL'); +} + +export function getApiTimeoutMs(): number { + return parsePositiveInteger(import.meta.env.VITE_API_TIMEOUT_MS, DEFAULT_API_TIMEOUT_MS); +} + +export function getUploadTimeoutMs(): number { + return parsePositiveInteger(import.meta.env.VITE_UPLOAD_TIMEOUT_MS, DEFAULT_UPLOAD_TIMEOUT_MS); +} + +export function getSseReconnectMax(): number { + return parsePositiveInteger(import.meta.env.VITE_SSE_RECONNECT_MAX, DEFAULT_SSE_RECONNECT_MAX); +} diff --git a/src/shared/api/axios/be1.ts b/src/shared/api/axios/be1.ts new file mode 100644 index 0000000..a2c7030 --- /dev/null +++ b/src/shared/api/axios/be1.ts @@ -0,0 +1,8 @@ +import { getApiTimeoutMs, getBe1BaseUrl } from '@/shared/api/apiConfig'; + +import { createAxios } from './createAxios'; + +export const axiosBe1 = createAxios({ + baseURL: getBe1BaseUrl(), + timeout: getApiTimeoutMs(), +}); diff --git a/src/shared/api/axios/be3.ts b/src/shared/api/axios/be3.ts new file mode 100644 index 0000000..0d45b22 --- /dev/null +++ b/src/shared/api/axios/be3.ts @@ -0,0 +1,8 @@ +import { getApiTimeoutMs, getBe3BaseUrl } from '@/shared/api/apiConfig'; + +import { createAxios } from './createAxios'; + +export const axiosBe3 = createAxios({ + baseURL: getBe3BaseUrl(), + timeout: getApiTimeoutMs(), +}); diff --git a/src/shared/api/axios/createAxios.ts b/src/shared/api/axios/createAxios.ts new file mode 100644 index 0000000..37d2728 --- /dev/null +++ b/src/shared/api/axios/createAxios.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; + +import { toApiError } from '@/shared/api/errors/apiError'; +import { readGuestUuidFromStorage, useGuestStore } from '@/shared/store/guestStore'; + +type CreateAxiosOptions = { + baseURL: string; + timeout: number; +}; + +export function createAxios({ baseURL, timeout }: CreateAxiosOptions) { + const instance = axios.create({ + baseURL: baseURL || undefined, + timeout, + }); + + instance.interceptors.request.use((config) => { + if (typeof window !== 'undefined') { + useGuestStore.getState().ensureGuestUuid(); + } + + const guestUuid = readGuestUuidFromStorage(); + + if (guestUuid) { + config.headers.set('guest_uuid', guestUuid); + } + + return config; + }); + + instance.interceptors.response.use( + (response) => response, + (error) => { + throw toApiError(error); + }, + ); + + return instance; +} diff --git a/src/shared/api/axios/index.ts b/src/shared/api/axios/index.ts new file mode 100644 index 0000000..4667988 --- /dev/null +++ b/src/shared/api/axios/index.ts @@ -0,0 +1,2 @@ +export { axiosBe1 } from './be1'; +export { axiosBe3 } from './be3'; diff --git a/src/shared/api/endpoints.ts b/src/shared/api/endpoints.ts new file mode 100644 index 0000000..4b02d68 --- /dev/null +++ b/src/shared/api/endpoints.ts @@ -0,0 +1,7 @@ +export const apiEndpoints = { + captchaVerify: '/api/v1/auth/captcha/verify', + scan: '/api/v1/scan', + scanDetail: '/api/v1/scan/detail', + scanHistory: '/api/v1/scan/history', + scanSubscribe: '/api/v1/scan/subscribe', +} as const; diff --git a/src/shared/api/errors/apiError.ts b/src/shared/api/errors/apiError.ts new file mode 100644 index 0000000..1190587 --- /dev/null +++ b/src/shared/api/errors/apiError.ts @@ -0,0 +1,75 @@ +import axios, { AxiosError } from 'axios'; + +type ApiErrorOptions = { + cause?: unknown; + code?: string; + data?: unknown; + message: string; + statusCode?: number; +}; + +export class ApiError extends Error { + code?: string; + data?: unknown; + statusCode?: number; + + constructor({ cause, code, data, message, statusCode }: ApiErrorOptions) { + super(message, cause ? { cause } : undefined); + this.name = 'ApiError'; + this.code = code; + this.data = data; + this.statusCode = statusCode; + } +} + +function resolveAxiosErrorMessage(error: AxiosError): string { + const responseData = error.response?.data; + + if (responseData && typeof responseData === 'object') { + const dataMessage = 'message' in responseData ? responseData.message : null; + + if (typeof dataMessage === 'string' && dataMessage.trim().length > 0) { + return dataMessage.trim(); + } + } + + if (typeof error.message === 'string' && error.message.trim().length > 0) { + return error.message; + } + + return 'API request failed.'; +} + +export function toApiError(error: unknown): ApiError { + if (error instanceof ApiError) { + return error; + } + + if (axios.isAxiosError(error)) { + return new ApiError({ + cause: error, + code: error.code, + data: error.response?.data, + message: resolveAxiosErrorMessage(error), + statusCode: error.response?.status, + }); + } + + if (error instanceof Error) { + const message = error.message.trim() || 'Unknown API error.'; + + return new ApiError({ + cause: error, + message, + }); + } + + return new ApiError({ + cause: error, + message: 'Unknown API error.', + }); +} + +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError; +} diff --git a/src/shared/api/fetchScanDetail.ts b/src/shared/api/fetchScanDetail.ts new file mode 100644 index 0000000..7a7b271 --- /dev/null +++ b/src/shared/api/fetchScanDetail.ts @@ -0,0 +1,32 @@ +import { axiosBe3 } from '@/shared/api/axios'; +import { apiEndpoints } from '@/shared/api/endpoints'; +import { ApiError, toApiError } from '@/shared/api/errors/apiError'; +import type { BackendAnalysisDetailResponse } from '@/shared/api/types'; + +const MAX_SCAN_DETAIL_QUERY_URL_LENGTH = 2_000; + +export async function fetchScanDetail(url: string): Promise { + if (url.length > MAX_SCAN_DETAIL_QUERY_URL_LENGTH) { + throw new ApiError({ + message: 'Scan detail URL is too long to request safely.', + }); + } + + try { + const response = await axiosBe3.get(apiEndpoints.scanDetail, { + params: { + url, + }, + }); + + return response.data; + } catch (error) { + const apiError = toApiError(error); + + throw new ApiError({ + code: apiError.code, + message: apiError.message, + statusCode: apiError.statusCode, + }); + } +} diff --git a/src/shared/api/responseAccess/payloadAccess.test.ts b/src/shared/api/responseAccess/payloadAccess.test.ts new file mode 100644 index 0000000..ec5b928 --- /dev/null +++ b/src/shared/api/responseAccess/payloadAccess.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + getCandidateRecords, + pickBoolean, + pickNumber, + pickSourceRecord, + pickSourceString, + pickSourceStringArray, + pickString, +} from './payloadAccess'; + +describe('payloadAccess', () => { + it('finds records nested behind multiple response envelopes', () => { + expect( + pickString( + { + data: { + result: { + serverInfo: { + status: 'success', + }, + }, + }, + }, + ['status'], + ), + ).toBe('success'); + }); + + it('does not loop forever on self-referential response envelopes', () => { + const source: Record = { + status: 'success', + }; + source.data = source; + + expect(getCandidateRecords(source)).toEqual([source]); + }); + + it('trims strings and rejects whitespace-only strings', () => { + expect(pickString({ message: ' ready ' }, ['message'])).toBe('ready'); + expect(pickString({ message: ' ' }, ['message'])).toBeNull(); + expect(pickString({ detail: 'fallback', message: 123 }, ['message', 'detail'])).toBe( + 'fallback', + ); + }); + + it('parses only strict finite numeric values', () => { + expect(pickNumber({ score: '12abc' }, ['score'])).toBeNull(); + expect(pickNumber({ score: '12.5' }, ['score'])).toBe(12.5); + expect(pickNumber({ score: '-12' }, ['score'])).toBe(-12); + expect(pickNumber({ score: ' 12 ' }, ['score'])).toBe(12); + expect(pickNumber({ score: '1e3' }, ['score'])).toBeNull(); + expect(pickNumber({ score: '0' }, ['score'])).toBe(0); + expect(pickNumber({ score: Number.NaN }, ['score'])).toBeNull(); + expect(pickNumber({ score: Number.POSITIVE_INFINITY }, ['score'])).toBeNull(); + expect(pickNumber({ score: '12abc', trustScore: '42' }, ['score', 'trustScore'])).toBe(42); + }); + + it('parses boolean strings and rejects unrelated values', () => { + expect(pickBoolean({ isUrl: true }, ['isUrl'])).toBe(true); + expect(pickBoolean({ isUrl: 'true' }, ['isUrl'])).toBe(true); + expect(pickBoolean({ isUrl: 'false' }, ['isUrl'])).toBe(false); + expect(pickBoolean({ isUrl: 'yes' }, ['isUrl'])).toBeNull(); + expect(pickBoolean({ isUrl: 1 }, ['isUrl'])).toBeNull(); + expect(pickBoolean({ isUrl: 'yes', is_url: ' TRUE ' }, ['isUrl', 'is_url'])).toBe(true); + }); + + it('uses the first source that contains a matching string', () => { + expect(pickSourceString([null, { message: 'first' }, { message: 'second' }], ['message'])).toBe( + 'first', + ); + expect(pickSourceString([null, { message: ' ' }], ['message'])).toBeNull(); + }); + + it('uses the first source that contains a matching record', () => { + const record = { finalUrl: 'https://example.com' }; + + expect(pickSourceRecord([null, { redirect: record }], ['redirect'])).toBe(record); + expect(pickSourceRecord([null, { redirect: null }], ['redirect'])).toBeNull(); + }); + + it('keeps string arrays as arrays and only splits strings when a delimiter is supplied', () => { + expect(pickSourceStringArray([{ threats: [' phishing ', ''] }], ['threats'])).toEqual([ + 'phishing', + ]); + expect(pickSourceStringArray([{ threats: 'wallet,address' }], ['threats'])).toEqual([ + 'wallet,address', + ]); + expect(pickSourceStringArray([{ threats: 'wallet,address' }], ['threats'], ',')).toEqual([ + 'wallet', + 'address', + ]); + }); +}); diff --git a/src/shared/api/responseAccess/payloadAccess.ts b/src/shared/api/responseAccess/payloadAccess.ts new file mode 100644 index 0000000..a0be195 --- /dev/null +++ b/src/shared/api/responseAccess/payloadAccess.ts @@ -0,0 +1,256 @@ +type UnknownRecord = Record; + +const nestedRecordKeys = [ + 'analysis', + 'certificate', + 'data', + 'detail', + 'externalApi', + 'external_api', + 'final', + 'finalResult', + 'historyItem', + 'https', + 'internalDb', + 'internal_db', + 'item', + 'ml', + 'payload', + 'redirect', + 'response', + 'result', + 'scan', + 'scanDetail', + 'scanResponse', + 'scanResult', + 'scoring', + 'serverInfo', + 'server_info', + 'shortUrl', + 'short_url', +] as const; + +function pushUniqueRecord(records: UnknownRecord[], candidate: UnknownRecord | null) { + if (!candidate || records.includes(candidate)) { + return; + } + + records.push(candidate); +} + +export function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function asRecord(value: unknown): UnknownRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as UnknownRecord; +} + +export function getCandidateRecords(source: unknown): UnknownRecord[] { + const rootRecord = asRecord(source); + + if (!rootRecord) { + return []; + } + + const records: UnknownRecord[] = [rootRecord]; + + for (let recordIndex = 0; recordIndex < records.length; recordIndex += 1) { + const record = records[recordIndex]; + + for (const nestedKey of nestedRecordKeys) { + pushUniqueRecord(records, asRecord(record[nestedKey])); + } + } + + return records; +} + +export function pickUnknown(source: unknown, keys: string[]): unknown { + const records = getCandidateRecords(source); + + for (const record of records) { + for (const key of keys) { + const value = record[key]; + + if (value !== undefined && value !== null) { + return value; + } + } + } + + return null; +} + +function pickTypedValue( + source: unknown, + keys: string[], + parseValue: (value: unknown) => T | null, +): T | null { + const records = getCandidateRecords(source); + + for (const record of records) { + for (const key of keys) { + const value = record[key]; + + if (value === undefined || value === null) { + continue; + } + + const parsedValue = parseValue(value); + + if (parsedValue !== null) { + return parsedValue; + } + } + } + + return null; +} + +export function pickString(source: unknown, keys: string[]): string | null { + return pickTypedValue(source, keys, (value) => { + if (typeof value !== 'string') { + return null; + } + + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : null; + }); +} + +function parseNumberValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value !== 'string') { + return null; + } + + const trimmedValue = value.trim(); + + if (!/^[+-]?\d+(?:\.\d+)?$/u.test(trimmedValue)) { + return null; + } + + const parsedValue = Number(trimmedValue); + return Number.isFinite(parsedValue) ? parsedValue : null; +} + +export function pickNumber(source: unknown, keys: string[]): number | null { + return pickTypedValue(source, keys, parseNumberValue); +} + +function parseBooleanValue(value: unknown): boolean | null { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalizedValue = value.trim().toLowerCase(); + + if (normalizedValue === 'true') { + return true; + } + + if (normalizedValue === 'false') { + return false; + } + } + + return null; +} + +export function pickBoolean(source: unknown, keys: string[]): boolean | null { + return pickTypedValue(source, keys, parseBooleanValue); +} + +export function pickRecord(source: unknown, keys: string[]): UnknownRecord | null { + return asRecord(pickUnknown(source, keys)); +} + +function pickStringArray( + source: unknown, + keys: string[], + splitDelimiter?: string | null, +): string[] { + const value = pickUnknown(source, keys); + + if (Array.isArray(value)) { + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + if (typeof value === 'string') { + if (splitDelimiter) { + return value + .split(splitDelimiter) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + } + + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? [trimmedValue] : []; + } + + return []; +} + +export function pickSourceString(sources: unknown[], keys: string[]): string | null { + for (const source of sources) { + const value = pickString(source, keys); + + if (value) { + return value; + } + } + + return null; +} + +export function pickSourceNumber(sources: unknown[], keys: string[]): number | null { + for (const source of sources) { + const value = pickNumber(source, keys); + + if (value !== null) { + return value; + } + } + + return null; +} + +export function pickSourceRecord(sources: unknown[], keys: string[]): UnknownRecord | null { + for (const source of sources) { + const value = pickRecord(source, keys); + + if (value) { + return value; + } + } + + return null; +} + +export function pickSourceStringArray( + sources: unknown[], + keys: string[], + splitDelimiter?: string | null, +): string[] { + for (const source of sources) { + const value = pickStringArray(source, keys, splitDelimiter); + + if (value.length > 0) { + return value; + } + } + + return []; +} diff --git a/src/shared/api/result-safe/fetchResultSafeData.ts b/src/shared/api/result-safe/fetchResultSafeData.ts deleted file mode 100644 index e46796b..0000000 --- a/src/shared/api/result-safe/fetchResultSafeData.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type ResultSafeData = { - previewUrl: string; - siteName: string; - siteUrl: string; - trustScore: number; - visitUrl: string; -}; - -export const mockResultSafeData: ResultSafeData = { - previewUrl: 'https://www.example-safe-site.com/preview', - siteName: 'Example Safe Site', - siteUrl: 'https://www.example-safe-site.com', - trustScore: 90, - visitUrl: 'https://www.example-safe-site.com', -}; - -export async function fetchResultSafeData(): Promise { - // TODO: Replace this mock return with real API integration. - return Promise.resolve(mockResultSafeData); -} diff --git a/src/shared/api/result-safe/index.ts b/src/shared/api/result-safe/index.ts deleted file mode 100644 index 62eaf2d..0000000 --- a/src/shared/api/result-safe/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { fetchResultSafeData, mockResultSafeData } from './fetchResultSafeData'; -export type { ResultSafeData } from './fetchResultSafeData'; diff --git a/src/shared/api/risk/normalizeRiskLevel.test.ts b/src/shared/api/risk/normalizeRiskLevel.test.ts new file mode 100644 index 0000000..298afc9 --- /dev/null +++ b/src/shared/api/risk/normalizeRiskLevel.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeRiskLevel } from './normalizeRiskLevel'; + +describe('normalizeRiskLevel', () => { + it('maps common backend labels to ui tones', () => { + expect(normalizeRiskLevel('SAFE')).toBe('safe'); + expect(normalizeRiskLevel('medium')).toBe('warning'); + expect(normalizeRiskLevel('HIGH')).toBe('critical'); + expect(normalizeRiskLevel('danger')).toBe('critical'); + }); + + it('returns null for empty or unknown values', () => { + expect(normalizeRiskLevel('')).toBeNull(); + expect(normalizeRiskLevel('unmapped')).toBeNull(); + expect(normalizeRiskLevel(null)).toBeNull(); + }); +}); diff --git a/src/shared/api/risk/normalizeRiskLevel.ts b/src/shared/api/risk/normalizeRiskLevel.ts new file mode 100644 index 0000000..504aebb --- /dev/null +++ b/src/shared/api/risk/normalizeRiskLevel.ts @@ -0,0 +1,28 @@ +import type { ResultTone } from '@/shared/types/resultTone'; + +const normalizedRiskLevelMap: Record = { + clean: 'safe', + critical: 'critical', + danger: 'critical', + high: 'critical', + low: 'safe', + malicious: 'critical', + medium: 'warning', + normal: 'safe', + safe: 'safe', + suspicious: 'warning', + unsafe: 'critical', + warning: 'warning', +}; + +export function normalizeRiskLevel(rawRiskLevel: string | null | undefined): ResultTone | null { + if (!rawRiskLevel) { + return null; + } + + const normalizedKey = rawRiskLevel + .trim() + .toLowerCase() + .replace(/[\s_-]/g, ''); + return normalizedRiskLevelMap[normalizedKey] ?? null; +} diff --git a/src/shared/api/risk/resolveResultTone.test.ts b/src/shared/api/risk/resolveResultTone.test.ts new file mode 100644 index 0000000..607642d --- /dev/null +++ b/src/shared/api/risk/resolveResultTone.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveResultTone, resolveResultToneFromSources } from './resolveResultTone'; + +describe('resolveResultTone', () => { + it('uses score before riskLevel when both are present', () => { + expect(resolveResultTone('critical', 23)).toBe('safe'); + expect(resolveResultTone('safe', 86)).toBe('critical'); + }); + + it('maps score ranges into result tones', () => { + expect(resolveResultTone(null, 29)).toBe('safe'); + expect(resolveResultTone(null, 30)).toBe('warning'); + expect(resolveResultTone(null, 59)).toBe('warning'); + expect(resolveResultTone(null, 60)).toBe('critical'); + }); + + it('falls back to riskLevel when score is missing', () => { + expect(resolveResultTone('HIGH', null)).toBe('critical'); + }); + + it('reads nested score values from backend detail payloads', () => { + expect( + resolveResultToneFromSources([ + { + ml: { + score: 61, + }, + riskLevel: 'safe', + }, + ]), + ).toBe('critical'); + }); +}); diff --git a/src/shared/api/risk/resolveResultTone.ts b/src/shared/api/risk/resolveResultTone.ts new file mode 100644 index 0000000..db9f171 --- /dev/null +++ b/src/shared/api/risk/resolveResultTone.ts @@ -0,0 +1,66 @@ +import type { ResultTone } from '@/shared/types/resultTone'; + +import { normalizeRiskLevel } from './normalizeRiskLevel'; +import { + pickNumber, + pickSourceNumber, + pickSourceString, + pickString, +} from '../responseAccess/payloadAccess'; + +const SAFE_MAX_SCORE = 29; +const WARNING_MAX_SCORE = 59; + +function normalizeScore(rawScore: number | null | undefined): number | null { + if (rawScore === null || rawScore === undefined || !Number.isFinite(rawScore)) { + return null; + } + + return Math.max(0, Math.min(100, Math.round(rawScore))); +} + +function resolveResultToneFromScore(rawScore: number | null | undefined): ResultTone | null { + const score = normalizeScore(rawScore); + + if (score === null) { + return null; + } + + if (score <= SAFE_MAX_SCORE) { + return 'safe'; + } + + if (score <= WARNING_MAX_SCORE) { + return 'warning'; + } + + return 'critical'; +} + +export function resolveResultTone( + rawRiskLevel: string | null | undefined, + rawScore: number | null | undefined, +): ResultTone | null { + return resolveResultToneFromScore(rawScore) ?? normalizeRiskLevel(rawRiskLevel); +} + +export function resolveResultToneFromSource(source: unknown): ResultTone | null { + return resolveResultTone( + pickString(source, ['riskLevel', 'risk_level', 'status', 'result']), + pickNumber(source, ['trustScore', 'trust_score', 'score']), + ); +} + +export function resolveResultToneFromSources( + sources: unknown[], + fallbackTone?: ResultTone | null, +): ResultTone | null { + return ( + resolveResultTone( + pickSourceString(sources, ['riskLevel', 'risk_level', 'status', 'result']), + pickSourceNumber(sources, ['trustScore', 'trust_score', 'score']), + ) ?? + fallbackTone ?? + null + ); +} diff --git a/src/shared/api/sse/sseStepMapper.ts b/src/shared/api/sse/sseStepMapper.ts new file mode 100644 index 0000000..fdc3646 --- /dev/null +++ b/src/shared/api/sse/sseStepMapper.ts @@ -0,0 +1,124 @@ +export const mappedLoadingStepOrder = [ + 'decode', + 'shortUrlCheck', + 'urlNormalize', + 'redirect', + 'internalDb', + 'externalApi', + 'ruleAnalysis', + 'aiAnalysis', + 'riskScore', + 'report', + 'completed', +] as const; + +export type MappedLoadingStepId = (typeof mappedLoadingStepOrder)[number]; + +const mappedLoadingStepMap: Record = { + ai: 'aiAnalysis', + aianalysis: 'aiAnalysis', + analysis: 'aiAnalysis', + analysisdone: 'report', + analysisreport: 'report', + apicheck: 'externalApi', + buildreport: 'report', + certificate: 'report', + completed: 'completed', + complete: 'completed', + database: 'internalDb', + databasecheck: 'internalDb', + db: 'internalDb', + dbcheck: 'internalDb', + decode: 'decode', + decoding: 'decode', + dns: 'report', + done: 'completed', + external: 'externalApi', + externalapi: 'externalApi', + externalcheck: 'externalApi', + externalverification: 'externalApi', + finish: 'completed', + finished: 'completed', + final: 'completed', + finalize: 'completed', + finalresult: 'completed', + https: 'report', + httpscheck: 'report', + internal: 'internalDb', + internaldb: 'internalDb', + linkexpand: 'redirect', + ml: 'aiAnalysis', + mlanalysis: 'aiAnalysis', + modelinference: 'aiAnalysis', + normalize: 'urlNormalize', + normalizeurl: 'urlNormalize', + parameteranalysis: 'ruleAnalysis', + parameterrule: 'ruleAnalysis', + pipelineinit: 'decode', + qrcode: 'decode', + qrdecode: 'decode', + qrparse: 'decode', + reputation: 'externalApi', + reputationcheck: 'externalApi', + redirect: 'redirect', + redirectanalysis: 'redirect', + redirectcheck: 'redirect', + redirecttrace: 'redirect', + report: 'report', + reportgeneration: 'report', + reportgenerate: 'report', + rule: 'ruleAnalysis', + ruleanalysis: 'ruleAnalysis', + rulebased: 'ruleAnalysis', + rulebasedanalysis: 'ruleAnalysis', + result: 'completed', + riskanalysis: 'riskScore', + riskcalculation: 'riskScore', + riskscore: 'riskScore', + score: 'riskScore', + scorecalculation: 'riskScore', + scoring: 'riskScore', + server: 'report', + serverinfo: 'report', + safebrowsing: 'externalApi', + shortlinkcheck: 'shortUrlCheck', + shorturlanalysis: 'shortUrlCheck', + ssl: 'report', + test: 'decode', + threatintel: 'externalApi', + shorturl: 'shortUrlCheck', + shorturlcheck: 'shortUrlCheck', + status: 'completed', + urldecode: 'decode', + urlnormalize: 'urlNormalize', + urlnormalization: 'urlNormalize', + urlscan: 'decode', + whois: 'report', +}; + +function normalizeStepKey(rawStepId: string): string { + return rawStepId + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +} + +export function mapSseStepId(rawStepId: string | null | undefined): MappedLoadingStepId | null { + if (!rawStepId) { + return null; + } + + return mappedLoadingStepMap[normalizeStepKey(rawStepId)] ?? null; +} + +export function mapSseStepIds(rawStepIds: unknown): MappedLoadingStepId[] { + if (!Array.isArray(rawStepIds)) { + return []; + } + + return rawStepIds + .map((rawStepId) => { + return typeof rawStepId === 'string' ? mapSseStepId(rawStepId) : null; + }) + .filter((stepId): stepId is MappedLoadingStepId => stepId !== null); +} diff --git a/src/shared/api/types/analysisResponse.ts b/src/shared/api/types/analysisResponse.ts new file mode 100644 index 0000000..0c63a7c --- /dev/null +++ b/src/shared/api/types/analysisResponse.ts @@ -0,0 +1,33 @@ +export interface BackendAnalysisDetailResponse extends Record { + isUrl?: boolean | null; + is_url?: boolean | null; + riskLevel?: string | null; + risk_level?: string | null; + scanId?: string | null; + scan_id?: string | null; + schemeType?: string | null; + scheme_type?: string | null; + score?: number | string | null; + status?: string | null; + trustScore?: number | string | null; + trust_score?: number | string | null; +} + +export interface BackendHistoryItemResponse extends Record { + id?: string | null; + isUrl?: boolean | null; + is_url?: boolean | null; + riskLevel?: string | null; + risk_level?: string | null; + scanId?: string | null; + scan_id?: string | null; + scannedAt?: string | null; + scanned_at?: string | null; + schemeType?: string | null; + scheme_type?: string | null; + score?: number | string | null; + status?: string | null; + typeInfo?: string | null; + type_info?: string | null; + url?: string | null; +} diff --git a/src/shared/api/types/index.ts b/src/shared/api/types/index.ts new file mode 100644 index 0000000..22164cd --- /dev/null +++ b/src/shared/api/types/index.ts @@ -0,0 +1,4 @@ +export type { BackendAnalysisDetailResponse, BackendHistoryItemResponse } from './analysisResponse'; +export type { BackendScanResponse } from './scanResponse'; +export type { BackendSseFinalPayload } from './sseEvents'; +export { parseSseMessage } from './sseEvents'; diff --git a/src/shared/api/types/scanResponse.ts b/src/shared/api/types/scanResponse.ts new file mode 100644 index 0000000..1dbb331 --- /dev/null +++ b/src/shared/api/types/scanResponse.ts @@ -0,0 +1,10 @@ +export type BackendScanResponse = Record & { + analysisTime?: string | null; + decodedUrl?: string | null; + isUrl?: boolean | null; + is_url?: boolean | null; + riskLevel?: string | null; + risk_level?: string | null; + schemeType?: string | null; + scheme_type?: string | null; +}; diff --git a/src/shared/api/types/sseEvents.test.ts b/src/shared/api/types/sseEvents.test.ts new file mode 100644 index 0000000..e2b4a2e --- /dev/null +++ b/src/shared/api/types/sseEvents.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSseMessage } from './sseEvents'; + +describe('parseSseMessage', () => { + it('treats default message events with progress fields as progress messages', () => { + const payload = { + guestUuid: 'guest-1', + message: 'Decoding QR image', + status: 'IN_PROGRESS', + step: 'decode', + }; + const parsedMessage = parseSseMessage(JSON.stringify(payload), 'message'); + + expect(parsedMessage.type).toBe('progress'); + expect(parsedMessage.payload).toMatchObject({ + guestUuid: 'guest-1', + message: 'Decoding QR image', + status: 'IN_PROGRESS', + step: 'decode', + }); + }); + + it('treats explicit complete events as final even when progress fields are present', () => { + const payload = { + message: 'Scoring completed', + status: 'COMPLETED', + step: 'SCORING', + }; + const parsedMessage = parseSseMessage(JSON.stringify(payload), 'COMPLETE'); + + expect(parsedMessage.type).toBe('final'); + expect(parsedMessage.payload).toMatchObject({ + message: 'Scoring completed', + status: 'COMPLETED', + step: 'SCORING', + }); + }); + + it('treats completed status payloads as final before progress heuristics', () => { + const payload = { + message: 'done', + status: 'COMPLETED', + step: 'UNKNOWN_BACKEND_STEP', + }; + const parsedMessage = parseSseMessage(JSON.stringify(payload), 'message'); + + expect(parsedMessage.type).toBe('final'); + expect(parsedMessage.payload).toMatchObject({ + message: 'done', + status: 'COMPLETED', + step: 'UNKNOWN_BACKEND_STEP', + }); + }); + + it('treats default message events with final analysis fields as final messages', () => { + const payload = { + originalUrl: 'https://example.com', + redirect: false, + riskLevel: 'LOW', + score: 92, + }; + const parsedMessage = parseSseMessage(JSON.stringify(payload), 'message'); + + expect(parsedMessage.type).toBe('final'); + expect(parsedMessage.payload).toMatchObject({ + originalUrl: 'https://example.com', + redirect: false, + riskLevel: 'LOW', + score: 92, + }); + }); + + it('treats failed status payloads as error messages', () => { + const payload = { + message: 'analysis failed', + status: 'FAILED', + }; + const parsedMessage = parseSseMessage(JSON.stringify(payload), 'message'); + + expect(parsedMessage.type).toBe('error'); + expect(parsedMessage.payload).toMatchObject({ + message: 'analysis failed', + status: 'FAILED', + }); + }); +}); diff --git a/src/shared/api/types/sseEvents.ts b/src/shared/api/types/sseEvents.ts new file mode 100644 index 0000000..7d5e87c --- /dev/null +++ b/src/shared/api/types/sseEvents.ts @@ -0,0 +1,162 @@ +type SseMessageType = 'error' | 'final' | 'message' | 'progress'; + +export type BackendSseFinalPayload = Record; + +type ParsedSseMessage = { + payload: unknown; + rawData: string; + type: SseMessageType; +}; + +function parseJsonValue(rawData: string): unknown { + const trimmedData = rawData.trim(); + + if (!trimmedData) { + return null; + } + + if ( + (trimmedData.startsWith('{') && trimmedData.endsWith('}')) || + (trimmedData.startsWith('[') && trimmedData.endsWith(']')) + ) { + try { + return JSON.parse(trimmedData); + } catch { + return trimmedData; + } + } + + return trimmedData; +} + +function asRecord(payload: unknown): Record | null { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return null; + } + + return payload as Record; +} + +function hasAnyDefinedKey(payload: Record, keys: string[]): boolean { + return keys.some((key) => payload[key] !== undefined && payload[key] !== null); +} + +function resolveMessageType(eventType: string | undefined, payload: unknown): SseMessageType { + const normalizedEventType = (eventType ?? '').trim().toLowerCase(); + const payloadRecord = asRecord(payload); + const payloadType = + payloadRecord && typeof payloadRecord.type === 'string' + ? payloadRecord.type.trim().toLowerCase() + : ''; + const payloadStatus = + payloadRecord && typeof payloadRecord.status === 'string' + ? payloadRecord.status.trim().toLowerCase() + : ''; + const candidateType = normalizedEventType || payloadType; + + if (candidateType === 'error' || candidateType === 'failed' || candidateType === 'failure') { + return 'error'; + } + + if (payloadStatus === 'error' || payloadStatus === 'failed' || payloadStatus === 'failure') { + return 'error'; + } + + if ( + payloadStatus === 'final' || + payloadStatus === 'result' || + payloadStatus === 'complete' || + payloadStatus === 'completed' || + payloadStatus === 'done' + ) { + return 'final'; + } + + if ( + candidateType === 'final' || + candidateType === 'result' || + candidateType === 'complete' || + candidateType === 'completed' || + candidateType === 'done' + ) { + return 'final'; + } + + if ( + payloadRecord && + hasAnyDefinedKey(payloadRecord, [ + 'analysisTime', + 'analysis_time', + 'externalApi', + 'external_api', + 'https', + 'internalDb', + 'internal_db', + 'ml', + 'originalUrl', + 'original_url', + 'redirect', + 'riskLevel', + 'risk_level', + 'score', + 'scoring', + 'serverInfo', + 'server_info', + 'shortUrl', + 'short_url', + ]) + ) { + return 'final'; + } + + if ( + payloadRecord && + hasAnyDefinedKey(payloadRecord, [ + 'currentStepId', + 'current_step_id', + 'step', + 'stepId', + 'step_id', + ]) + ) { + return 'progress'; + } + + if ( + candidateType === 'progress' || + candidateType === 'status' || + candidateType === 'step' || + candidateType === 'update' + ) { + return 'progress'; + } + + if ( + payloadRecord && + hasAnyDefinedKey(payloadRecord, [ + 'guestUuid', + 'guest_uuid', + 'message', + 'percent', + 'progress', + 'step', + 'stepId', + 'step_id', + 'status', + ]) + ) { + return 'progress'; + } + + return 'message'; +} + +export function parseSseMessage(rawData: string, eventType?: string): ParsedSseMessage { + const payload = parseJsonValue(rawData); + + return { + payload, + rawData, + type: resolveMessageType(eventType, payload), + }; +} diff --git a/src/shared/component/safeSiteCard/SafeSiteCard.tsx b/src/shared/component/safeSiteCard/SafeSiteCard.tsx index 87fe53a..29684f9 100644 --- a/src/shared/component/safeSiteCard/SafeSiteCard.tsx +++ b/src/shared/component/safeSiteCard/SafeSiteCard.tsx @@ -17,7 +17,7 @@ type SafeSiteCardProps = { export default function SafeSiteCard({ badgeLabel = 'OK', onVisitClick, - siteMeta = 'SSL 인증서 유효함 · 도메인 생성일 2018.05.12', + siteMeta = 'SSL 인증서 정보 없음', siteName, siteUrl, statusLabel = 'OFFICIAL', @@ -34,7 +34,7 @@ export default function SafeSiteCard({
-
+

{siteName}

{statusLabel} diff --git a/src/shared/component/safeSiteCard/safeSiteCard.css.ts b/src/shared/component/safeSiteCard/safeSiteCard.css.ts index e8ea279..93bfdb6 100644 --- a/src/shared/component/safeSiteCard/safeSiteCard.css.ts +++ b/src/shared/component/safeSiteCard/safeSiteCard.css.ts @@ -3,12 +3,15 @@ import { style, styleVariants } from '@vanilla-extract/css'; import { vars } from '@/vars.css'; export const card = style({ + boxSizing: 'border-box', display: 'grid', gap: vars.spacing.md, + width: '100%', padding: vars.spacing.md, borderRadius: vars.radius.xl, border: `1px solid ${vars.colors.border}`, backgroundColor: vars.colors.white, + overflow: 'hidden', }); export const info = style({ @@ -16,6 +19,7 @@ export const info = style({ gridTemplateColumns: 'auto minmax(0, 1fr)', gap: vars.spacing.md, alignItems: 'center', + minWidth: 0, }); export const iconFrame = style({ @@ -68,6 +72,11 @@ export const titleRow = style({ flexWrap: 'wrap', gap: vars.spacing.sm, marginBottom: vars.spacing.xs, + minWidth: 0, +}); + +export const textBlock = style({ + minWidth: 0, }); export const title = style({ @@ -75,6 +84,10 @@ export const title = style({ color: vars.colors.black, fontSize: vars.font.size.xl, fontWeight: vars.font.weight.bold, + lineHeight: 1.3, + maxWidth: '100%', + overflowWrap: 'anywhere', + wordBreak: 'break-word', }); export const official = style({ @@ -97,7 +110,7 @@ export const officialTone = styleVariants({ }, warning: { backgroundColor: 'rgba(242, 223, 13, 0.2)', - color: 'var.colors.warning', + color: vars.colors.warning, }, critical: { backgroundColor: 'rgba(242, 13, 13, 0.12)', @@ -109,12 +122,17 @@ export const siteUrl = style({ margin: `0 0 ${vars.spacing.xs}`, color: vars.colors.subText, fontSize: vars.font.size.sm, + lineHeight: 1.45, + overflowWrap: 'anywhere', + wordBreak: 'break-all', }); export const siteMeta = style({ margin: 0, color: vars.colors.subDark, fontSize: vars.font.size.xs, + lineHeight: 1.45, + overflowWrap: 'anywhere', }); export const visitButton = style({ diff --git a/src/shared/component/trustScoreCard/TrustScoreCard.tsx b/src/shared/component/trustScoreCard/TrustScoreCard.tsx index 87d06a9..4b0298c 100644 --- a/src/shared/component/trustScoreCard/TrustScoreCard.tsx +++ b/src/shared/component/trustScoreCard/TrustScoreCard.tsx @@ -14,7 +14,7 @@ export default function TrustScoreCard({ score, tone = 'safe' }: TrustScoreCardP return (
- 신뢰 점수 + 위험 점수 diff --git a/src/shared/icon/actionIcons.ts b/src/shared/icon/actionIcons.ts index 49ca841..5d87c6d 100644 --- a/src/shared/icon/actionIcons.ts +++ b/src/shared/icon/actionIcons.ts @@ -1,6 +1,4 @@ -export { default as detailAnalysisIcon } from './action/detailAnalysis.svg'; export { default as internetPreviewIcon } from './action/internetPreview.svg'; -export { default as previewIcon } from './action/preview.svg'; export { default as shareResultIcon } from './action/shareResult.svg'; export { default as viewDetailsIcon } from './action/viewDetails.svg'; export { default as visitSiteIcon } from './action/visitSite.svg'; diff --git a/src/shared/lib/feedback/showApiError.ts b/src/shared/lib/feedback/showApiError.ts new file mode 100644 index 0000000..5f8a90d --- /dev/null +++ b/src/shared/lib/feedback/showApiError.ts @@ -0,0 +1,15 @@ +import { toApiError } from '@/shared/api/errors/apiError'; + +type MessageApiLike = { + error: (content: string) => void; +}; + +export function showApiError( + messageApi: MessageApiLike, + error: unknown, + fallbackMessage = '요청 처리 중 오류가 발생했습니다.', +): void { + const apiError = toApiError(error); + const message = apiError.message?.trim() || fallbackMessage; + messageApi.error(message); +} diff --git a/src/shared/lib/page/useSessionGuardedPageData.ts b/src/shared/lib/page/useSessionGuardedPageData.ts new file mode 100644 index 0000000..3ba6d24 --- /dev/null +++ b/src/shared/lib/page/useSessionGuardedPageData.ts @@ -0,0 +1,78 @@ +import { useNavigate } from '@tanstack/react-router'; +import { App } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { showApiError } from '@/shared/lib/feedback/showApiError'; + +type UseSessionGuardedPageDataOptions = { + fetchPageData: () => Promise; + getInitialPageData: () => T | null; + loadErrorMessage: string; + logMessage: string; +}; + +type UseSessionGuardedPageDataReturn = { + data: T | null; + reload: () => Promise; +}; + +/** + * Loads page data that requires an active scan session. + * Pass stable fetch/getInitial callbacks when possible; fetchPageData is read via ref to avoid reload loops. + */ +export function useSessionGuardedPageData({ + fetchPageData, + getInitialPageData, + loadErrorMessage, + logMessage, +}: UseSessionGuardedPageDataOptions): UseSessionGuardedPageDataReturn { + const { message } = App.useApp(); + const navigate = useNavigate(); + const fetchPageDataRef = useRef(fetchPageData); + const isMountedRef = useRef(false); + const [data, setData] = useState(getInitialPageData); + + useEffect(() => { + fetchPageDataRef.current = fetchPageData; + }, [fetchPageData]); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + const reload = useCallback(async () => { + try { + const response = await fetchPageDataRef.current(); + + if (isMountedRef.current) { + setData(response); + } + } catch (error) { + if (!isMountedRef.current) { + return; + } + + console.error(logMessage, error); + + if (error instanceof Error && error.message === 'SCAN_SESSION_REQUIRED') { + void navigate({ to: '/' }); + return; + } + + showApiError(message, error, loadErrorMessage); + } + }, [loadErrorMessage, logMessage, message, navigate]); + + useEffect(() => { + void reload(); + }, [reload]); + + return { + data, + reload, + }; +} diff --git a/src/shared/lib/pdf/exportElementToPdf.ts b/src/shared/lib/pdf/exportElementToPdf.ts deleted file mode 100644 index 54260e3..0000000 --- a/src/shared/lib/pdf/exportElementToPdf.ts +++ /dev/null @@ -1,84 +0,0 @@ -type ExportElementToPdfOptions = { - backgroundColor?: string; - element: HTMLElement; - fileName?: string; - marginMm?: number; -}; - -const DEFAULT_FILE_NAME = 'report.pdf'; -const DEFAULT_BACKGROUND_COLOR = '#FFFFFF'; -const DEFAULT_MARGIN_MM = 8; - -export async function exportElementToPdf(options: ExportElementToPdfOptions): Promise { - const { - backgroundColor = DEFAULT_BACKGROUND_COLOR, - element, - fileName = DEFAULT_FILE_NAME, - marginMm = DEFAULT_MARGIN_MM, - } = options; - - if (!element) { - throw new Error('PDF export target is missing.'); - } - - const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ - import('html2canvas'), - import('jspdf'), - ]); - - const canvas = await html2canvas(element, { - backgroundColor, - scale: Math.min(window.devicePixelRatio || 1, 2), - useCORS: true, - windowHeight: element.scrollHeight, - windowWidth: element.scrollWidth, - }); - - const pdf = new jsPDF({ - compress: true, - format: 'a4', - orientation: 'portrait', - unit: 'mm', - }); - - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const printableWidth = pageWidth - marginMm * 2; - const printableHeight = pageHeight - marginMm * 2; - - const renderedHeight = (canvas.height * printableWidth) / canvas.width; - const imageData = canvas.toDataURL('image/png'); - - let remainingHeight = renderedHeight; - let offsetY = marginMm; - - pdf.addImage( - imageData, - 'PNG', - marginMm, - offsetY, - printableWidth, - renderedHeight, - undefined, - 'FAST', - ); - remainingHeight -= printableHeight; - - while (remainingHeight > 0) { - pdf.addPage(); - offsetY = marginMm - (renderedHeight - remainingHeight); - pdf.addImage( - imageData, - 'PNG', - marginMm, - offsetY, - printableWidth, - renderedHeight, - undefined, - 'FAST', - ); - remainingHeight -= printableHeight; - } - - pdf.save(fileName); -} diff --git a/src/shared/lib/scan-session/ensureScanDetail.ts b/src/shared/lib/scan-session/ensureScanDetail.ts new file mode 100644 index 0000000..bee372f --- /dev/null +++ b/src/shared/lib/scan-session/ensureScanDetail.ts @@ -0,0 +1,30 @@ +import { fetchScanDetail } from '@/shared/api/fetchScanDetail'; +import { getScanSessionSnapshot, useScanSessionStore } from '@/shared/store/scanSessionStore'; + +export function resolveRequestedUrlFromSearch(): string | null { + if (typeof window === 'undefined') { + return null; + } + + const requestedUrl = new URLSearchParams(window.location.search).get('url')?.trim(); + return requestedUrl && requestedUrl.length > 0 ? requestedUrl : null; +} + +export async function ensureScanDetail(): Promise> { + const session = getScanSessionSnapshot(); + + if (session.analysisDetail) { + return session; + } + + const requestedUrl = + session.decodedUrl ?? session.historySelection?.url ?? resolveRequestedUrlFromSearch(); + + if (!requestedUrl) { + throw new Error('SCAN_SESSION_REQUIRED'); + } + + const detail = await fetchScanDetail(requestedUrl); + useScanSessionStore.getState().setAnalysisDetail(detail); + return getScanSessionSnapshot(); +} diff --git a/src/shared/lib/sse/useScanSubscription.ts b/src/shared/lib/sse/useScanSubscription.ts new file mode 100644 index 0000000..8e1d15d --- /dev/null +++ b/src/shared/lib/sse/useScanSubscription.ts @@ -0,0 +1,244 @@ +import { useEffect, useRef } from 'react'; + +import { getBe3BaseUrl, getSseReconnectMax } from '@/shared/api/apiConfig'; +import { apiEndpoints } from '@/shared/api/endpoints'; +import { parseSseMessage } from '@/shared/api/types'; + +type UseScanSubscriptionParams = { + enabled: boolean; + guestUuid: string | null; + onError?: (errorMessage: string) => void; + onFinal?: (payload: Record) => void; + onProgress?: (payload: Record) => void; +}; + +const INACTIVITY_TIMEOUT_MS = 120_000; +const RECONNECT_DELAY_MS = 1_000; +const STABLE_CONNECTION_MS = 5_000; +const sseEventNames = [ + 'INIT', + 'COMPLETE', + 'complete', + 'completed', + 'error', + 'final', + 'progress', + 'result', +] as const; + +function normalizeFinalPayload(payload: unknown): Record | null { + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + return payload as Record; + } + + if (typeof payload === 'string') { + const normalizedUrl = payload.trim(); + + if (!normalizedUrl) { + return null; + } + + return { + decodedUrl: normalizedUrl, + destinationUrl: normalizedUrl, + finalUrl: normalizedUrl, + originalUrl: normalizedUrl, + }; + } + + return null; +} + +function buildSubscriptionUrl(guestUuid: string): string { + const baseUrl = getBe3BaseUrl(); + const searchParams = new URLSearchParams({ + guest_uuid: guestUuid, + }); + + if (!baseUrl) { + return `${apiEndpoints.scanSubscribe}?${searchParams.toString()}`; + } + + if (baseUrl.startsWith('/')) { + return `${baseUrl}${apiEndpoints.scanSubscribe}?${searchParams.toString()}`; + } + + const resolvedUrl = new URL(apiEndpoints.scanSubscribe, `${baseUrl}/`); + resolvedUrl.search = searchParams.toString(); + return resolvedUrl.toString(); +} + +export function useScanSubscription({ + enabled, + guestUuid, + onError, + onFinal, + onProgress, +}: UseScanSubscriptionParams): void { + const callbacksRef = useRef({ + onError, + onFinal, + onProgress, + }); + + callbacksRef.current = { + onError, + onFinal, + onProgress, + }; + + useEffect(() => { + if (!enabled || !guestUuid) { + return; + } + + let reconnectAttempts = 0; + let reconnectTimerId: number | null = null; + let inactivityTimerId: number | null = null; + let stabilityTimerId: number | null = null; + let isDisposed = false; + let eventSource: EventSource | null = null; + + const clearStabilityTimer = () => { + if (stabilityTimerId !== null) { + window.clearTimeout(stabilityTimerId); + stabilityTimerId = null; + } + }; + + const clearTimers = () => { + if (reconnectTimerId !== null) { + window.clearTimeout(reconnectTimerId); + reconnectTimerId = null; + } + + if (inactivityTimerId !== null) { + window.clearTimeout(inactivityTimerId); + inactivityTimerId = null; + } + + clearStabilityTimer(); + }; + + const closeEventSource = () => { + if (!eventSource) { + return; + } + + eventSource.close(); + eventSource = null; + }; + + const resetInactivityTimer = () => { + if (inactivityTimerId !== null) { + window.clearTimeout(inactivityTimerId); + } + + inactivityTimerId = window.setTimeout(() => { + scheduleReconnect('실시간 분석 연결이 지연되어 다시 연결합니다.'); + }, INACTIVITY_TIMEOUT_MS); + }; + + const handleParsedMessage = (rawData: string, eventType?: string) => { + const parsedMessage = parseSseMessage(rawData, eventType); + resetInactivityTimer(); + + if ( + parsedMessage.type === 'progress' && + parsedMessage.payload && + typeof parsedMessage.payload === 'object' + ) { + callbacksRef.current.onProgress?.(parsedMessage.payload as Record); + return; + } + + if (parsedMessage.type === 'final') { + const finalPayload = normalizeFinalPayload(parsedMessage.payload); + + if (finalPayload) { + callbacksRef.current.onFinal?.(finalPayload); + } + + return; + } + + if (parsedMessage.type === 'error') { + callbacksRef.current.onError?.( + typeof parsedMessage.payload === 'string' + ? parsedMessage.payload + : '실시간 분석 중 오류가 발생했습니다.', + ); + } + }; + + const scheduleReconnect = (errorMessage: string) => { + if (isDisposed) { + return; + } + + clearStabilityTimer(); + closeEventSource(); + + if (reconnectAttempts >= getSseReconnectMax()) { + callbacksRef.current.onError?.(errorMessage); + clearTimers(); + return; + } + + reconnectAttempts += 1; + reconnectTimerId = window.setTimeout(() => { + connect(); + }, RECONNECT_DELAY_MS * reconnectAttempts); + }; + + const connect = () => { + if (isDisposed) { + return; + } + + closeEventSource(); + clearTimers(); + + const nextEventSource = new EventSource(buildSubscriptionUrl(guestUuid)); + eventSource = nextEventSource; + + nextEventSource.onopen = () => { + resetInactivityTimer(); + clearStabilityTimer(); + stabilityTimerId = window.setTimeout(() => { + reconnectAttempts = 0; + stabilityTimerId = null; + }, STABLE_CONNECTION_MS); + }; + + nextEventSource.onmessage = (event) => { + handleParsedMessage(event.data, 'message'); + }; + + nextEventSource.onerror = () => { + clearStabilityTimer(); + scheduleReconnect('실시간 분석 연결이 종료되었습니다. 다시 시도해 주세요.'); + }; + + for (const eventName of sseEventNames) { + nextEventSource.addEventListener(eventName, (event) => { + if (!(event instanceof MessageEvent)) { + return; + } + + handleParsedMessage(event.data, eventName); + }); + } + + resetInactivityTimer(); + }; + + connect(); + + return () => { + isDisposed = true; + clearTimers(); + closeEventSource(); + }; + }, [enabled, guestUuid]); +} diff --git a/src/shared/store/guestStore.ts b/src/shared/store/guestStore.ts new file mode 100644 index 0000000..9473ee6 --- /dev/null +++ b/src/shared/store/guestStore.ts @@ -0,0 +1,109 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +const guestUuidStorageKey = 'veriq.guestUuid'; + +type GuestStoreState = { + ensureGuestUuid: () => string; + guestUuid: string | null; + setGuestUuid: (guestUuid: string) => void; +}; + +function createGuestUuid(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20, + )}-${hex.slice(20)}`; + } + + throw new Error('Secure random UUID generation is not supported in this environment.'); +} + +export function readGuestUuidFromStorage(): string | null { + if (typeof window === 'undefined') { + return null; + } + + let rawValue: string | null = null; + + try { + rawValue = window.localStorage.getItem(guestUuidStorageKey); + } catch { + return null; + } + + if (!rawValue) { + return null; + } + + try { + const parsedValue = JSON.parse(rawValue) as { + guestUuid?: unknown; + state?: { + guestUuid?: unknown; + }; + }; + const candidateValue = + typeof parsedValue?.state?.guestUuid === 'string' + ? parsedValue.state.guestUuid + : parsedValue?.guestUuid; + + if (typeof candidateValue === 'string' && candidateValue.trim().length > 0) { + return candidateValue.trim(); + } + } catch { + const trimmedValue = rawValue.trim(); + + if (trimmedValue.length > 0) { + return trimmedValue; + } + } + + return null; +} + +export const useGuestStore = create()( + persist( + (set, get) => ({ + ensureGuestUuid: () => { + const persistedGuestUuid = readGuestUuidFromStorage(); + + if (persistedGuestUuid) { + if (get().guestUuid !== persistedGuestUuid) { + set({ guestUuid: persistedGuestUuid }); + } + + return persistedGuestUuid; + } + + const nextGuestUuid = createGuestUuid(); + set({ guestUuid: nextGuestUuid }); + return nextGuestUuid; + }, + guestUuid: null, + setGuestUuid: (guestUuid) => { + set({ guestUuid }); + }, + }), + { + name: guestUuidStorageKey, + partialize: (state) => ({ + guestUuid: state.guestUuid, + }), + storage: createJSONStorage(() => localStorage), + }, + ), +); diff --git a/src/shared/store/scanProgressStore.test.ts b/src/shared/store/scanProgressStore.test.ts new file mode 100644 index 0000000..b12b8d2 --- /dev/null +++ b/src/shared/store/scanProgressStore.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { useScanProgressStore } from './scanProgressStore'; + +describe('scanProgressStore', () => { + afterEach(() => { + useScanProgressStore.getState().reset(); + }); + + it('maps backend pipeline step names into loading state', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + message: '파이프라인 진입 성공', + status: 'IN_PROGRESS', + step: 'TEST', + }); + useScanProgressStore.getState().updateFromProgressEvent({ + message: '단축 URL 여부 확인이 완료되었습니다.', + status: 'COMPLETED', + step: 'SHORT_URL_CHECK', + }); + useScanProgressStore.getState().updateFromProgressEvent({ + message: 'URL 정규화가 완료되었습니다.', + status: 'COMPLETED', + step: 'URL_NORMALIZE', + }); + useScanProgressStore.getState().updateFromProgressEvent({ + message: '내부 DB에서 기존 분석 이력을 조회하고 있습니다.', + status: 'IN_PROGRESS', + step: 'DB_CHECK', + }); + + const state = useScanProgressStore.getState(); + + expect(state.status).toBe('active'); + expect(state.backendMessage).toBe('내부 DB에서 기존 분석 이력을 조회하고 있습니다.'); + expect(state.backendStatus).toBe('in_progress'); + expect(state.backendStep).toBe('DB_CHECK'); + expect(state.currentStepId).toBe('internalDb'); + expect(state.completedStepIds).toEqual(['decode', 'shortUrlCheck', 'urlNormalize']); + expect(state.completedStepIds).not.toContain('redirect'); + expect(state.percent).toBeGreaterThan(0); + }); + + it('marks the current step done only when backend status is completed', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + status: 'IN_PROGRESS', + step: 'RULE_ANALYSIS', + }); + + expect(useScanProgressStore.getState().completedStepIds).not.toContain('ruleAnalysis'); + + useScanProgressStore.getState().updateFromProgressEvent({ + status: 'COMPLETED', + step: 'RULE_ANALYSIS', + }); + + expect(useScanProgressStore.getState().completedStepIds).toContain('ruleAnalysis'); + }); + + it('marks the flow completed when the backend sends a terminal step', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + status: 'COMPLETED', + step: 'completed', + }); + + const state = useScanProgressStore.getState(); + + expect(state.status).toBe('completed'); + expect(state.currentStepId).toBe('completed'); + expect(state.completedStepIds.at(-1)).toBe('completed'); + expect(state.percent).toBe(100); + }); + + it('marks every mapped step done when detail polling completes the flow', () => { + useScanProgressStore.getState().setCompleted(); + + const state = useScanProgressStore.getState(); + + expect(state.status).toBe('completed'); + expect(state.completedStepIds).toContain('decode'); + expect(state.completedStepIds).toContain('shortUrlCheck'); + expect(state.completedStepIds).toContain('urlNormalize'); + expect(state.completedStepIds).toContain('completed'); + }); + + it('clears stale progress when starting a new connection', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + percent: 80, + status: 'IN_PROGRESS', + step: 'DB_CHECK', + }); + + useScanProgressStore.getState().setConnecting(); + + const state = useScanProgressStore.getState(); + + expect(state.status).toBe('connecting'); + expect(state.percent).toBe(0); + expect(state.currentStepId).toBeNull(); + expect(state.completedStepIds).toEqual([]); + expect(state.errorMessage).toBeNull(); + }); + + it('clears stale errors when a retry completes', () => { + useScanProgressStore.getState().setError('temporary failure'); + useScanProgressStore.getState().setCompleted(); + + expect(useScanProgressStore.getState().errorMessage).toBeNull(); + }); + + it('clears stale progress when an error is set', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + percent: 70, + status: 'IN_PROGRESS', + step: 'DB_CHECK', + }); + + useScanProgressStore.getState().setError('network failure'); + + const state = useScanProgressStore.getState(); + + expect(state.status).toBe('error'); + expect(state.backendMessage).toBe('network failure'); + expect(state.backendStatus).toBe('error'); + expect(state.percent).toBe(0); + expect(state.currentStepId).toBeNull(); + expect(state.completedStepIds).toEqual([]); + }); + + it('marks the flow completed when backend sends only a terminal status', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + status: 'COMPLETED', + }); + + const state = useScanProgressStore.getState(); + + expect(state.status).toBe('completed'); + expect(state.currentStepId).toBe('completed'); + expect(state.percent).toBe(100); + }); + + it('keeps unknown in-progress steps as raw metadata and completes on terminal status', () => { + useScanProgressStore.getState().updateFromProgressEvent({ + message: 'new step started', + status: 'IN_PROGRESS', + step: 'NEW_BACKEND_STEP', + }); + + let state = useScanProgressStore.getState(); + + expect(state.status).toBe('active'); + expect(state.backendStep).toBe('NEW_BACKEND_STEP'); + expect(state.backendStatus).toBe('in_progress'); + expect(state.backendMessage).toBe('new step started'); + expect(state.currentStepId).toBeNull(); + expect(state.completedStepIds).toEqual([]); + + useScanProgressStore.getState().updateFromProgressEvent({ + message: 'new step completed', + status: 'COMPLETED', + step: 'NEW_BACKEND_STEP', + }); + + state = useScanProgressStore.getState(); + + expect(state.status).toBe('completed'); + expect(state.backendStep).toBe('NEW_BACKEND_STEP'); + expect(state.backendStatus).toBe('completed'); + expect(state.backendMessage).toBe('new step completed'); + expect(state.currentStepId).toBe('completed'); + expect(state.completedStepIds.at(-1)).toBe('completed'); + expect(state.percent).toBe(100); + }); +}); diff --git a/src/shared/store/scanProgressStore.ts b/src/shared/store/scanProgressStore.ts new file mode 100644 index 0000000..248f02b --- /dev/null +++ b/src/shared/store/scanProgressStore.ts @@ -0,0 +1,264 @@ +import { create } from 'zustand'; + +import { pickNumber, pickString, pickUnknown } from '@/shared/api/responseAccess/payloadAccess'; +import { + mapSseStepId, + mapSseStepIds, + mappedLoadingStepOrder, + type MappedLoadingStepId, +} from '@/shared/api/sse/sseStepMapper'; + +type ScanProgressStatus = 'active' | 'completed' | 'connecting' | 'error' | 'idle'; + +type ScanProgressState = { + backendMessage: string | null; + backendStatus: string | null; + backendStep: string | null; + completedStepIds: MappedLoadingStepId[]; + currentStepId: MappedLoadingStepId | null; + errorMessage: string | null; + percent: number; + reset: () => void; + setCompleted: () => void; + setConnecting: () => void; + setError: (errorMessage: string) => void; + status: ScanProgressStatus; + updateFromProgressEvent: (payload: unknown) => void; +}; + +const initialState = { + backendMessage: null as string | null, + backendStatus: null as string | null, + backendStep: null as string | null, + completedStepIds: [] as MappedLoadingStepId[], + currentStepId: null as MappedLoadingStepId | null, + errorMessage: null as string | null, + percent: 0, + status: 'idle' as ScanProgressStatus, +}; + +function clampPercent(value: number | null): number { + if (value === null) { + return 0; + } + + return Math.max(0, Math.min(100, Math.round(value))); +} + +function normalizeStatus(rawStatus: string | null): string { + return rawStatus?.trim().toLowerCase() ?? ''; +} + +function isCompletedStatus(status: string): boolean { + return ( + status === 'complete' || + status === 'completed' || + status === 'done' || + status === 'finish' || + status === 'finished' || + status === 'success' || + status === 'succeeded' + ); +} + +function isErrorStatus(status: string): boolean { + return status === 'error' || status === 'failed' || status === 'failure' || status === 'fail'; +} + +function sortStepIds(stepIds: Iterable): MappedLoadingStepId[] { + return Array.from(new Set(stepIds)).sort((left, right) => { + return mappedLoadingStepOrder.indexOf(left) - mappedLoadingStepOrder.indexOf(right); + }); +} + +function deriveCompletedStepIds({ + currentStepId, + explicitCompletedStepIds, + previousCompletedStepIds, + previousCurrentStepId, + status, +}: { + currentStepId: MappedLoadingStepId | null; + explicitCompletedStepIds: MappedLoadingStepId[]; + previousCompletedStepIds: MappedLoadingStepId[]; + previousCurrentStepId: MappedLoadingStepId | null; + status: string; +}): MappedLoadingStepId[] { + const completedStepIds = new Set(previousCompletedStepIds); + + for (const completedStepId of explicitCompletedStepIds) { + completedStepIds.add(completedStepId); + } + + if ( + previousCurrentStepId && + previousCurrentStepId !== currentStepId && + currentStepId && + !isErrorStatus(status) + ) { + completedStepIds.add(previousCurrentStepId); + } + + if (currentStepId && isCompletedStatus(status)) { + completedStepIds.add(currentStepId); + } + + if (currentStepId === 'completed' || (!currentStepId && isCompletedStatus(status))) { + for (const stepId of mappedLoadingStepOrder) { + completedStepIds.add(stepId); + } + } + + return sortStepIds(completedStepIds); +} + +function derivePercent({ + completedStepIds, + currentStepId, + explicitPercent, + previousPercent, +}: { + completedStepIds: MappedLoadingStepId[]; + currentStepId: MappedLoadingStepId | null; + explicitPercent: number; + previousPercent: number; +}): number { + if (explicitPercent > 0) { + return Math.max(previousPercent, explicitPercent); + } + + if (completedStepIds.includes('completed') || currentStepId === 'completed') { + return 100; + } + + const progressStepOrder = mappedLoadingStepOrder.filter((stepId) => stepId !== 'completed'); + const stepCount = progressStepOrder.length; + + if (stepCount <= 0) { + return previousPercent; + } + + const completedCount = completedStepIds.filter((stepId) => stepId !== 'completed').length; + const currentStepIndex = currentStepId ? progressStepOrder.indexOf(currentStepId) : -1; + const currentStepIsCompleted = currentStepId !== null && completedStepIds.includes(currentStepId); + const countBasedUnits = completedCount + (currentStepId && !currentStepIsCompleted ? 0.5 : 0); + const countBasedPercent = clampPercent((countBasedUnits / stepCount) * 100); + const indexBasedPercent = + currentStepIndex >= 0 + ? clampPercent(((currentStepIndex + (currentStepIsCompleted ? 1 : 0.5)) / stepCount) * 100) + : 0; + const nextPercent = Math.max(countBasedPercent, indexBasedPercent); + + return Math.max(previousPercent, Math.min(nextPercent, 99)); +} + +export const useScanProgressStore = create((set) => ({ + ...initialState, + reset: () => { + set(initialState); + }, + setCompleted: () => { + set({ + backendMessage: '분석이 완료되었습니다.', + backendStatus: 'completed', + backendStep: 'completed', + completedStepIds: [...mappedLoadingStepOrder], + currentStepId: 'completed', + errorMessage: null, + percent: 100, + status: 'completed', + }); + }, + setConnecting: () => { + set({ + backendMessage: null, + backendStatus: null, + backendStep: null, + completedStepIds: [], + currentStepId: null, + errorMessage: null, + percent: 0, + status: 'connecting', + }); + }, + setError: (errorMessage) => { + set({ + backendMessage: errorMessage, + backendStatus: 'error', + backendStep: null, + completedStepIds: [], + currentStepId: null, + errorMessage, + percent: 0, + status: 'error', + }); + }, + status: 'idle', + updateFromProgressEvent: (payload) => { + const backendStep = pickString(payload, [ + 'currentStepId', + 'current_step_id', + 'step', + 'stepId', + 'step_id', + ]); + const currentStepId = mapSseStepId(backendStep); + const explicitCompletedStepIds = mapSseStepIds( + pickUnknown(payload, ['completedStepIds', 'completed_step_ids', 'completedSteps']), + ); + const percent = clampPercent( + pickNumber(payload, ['percent', 'progress', 'progressPercent', 'progress_percent']), + ); + const status = normalizeStatus(pickString(payload, ['status', 'state'])); + const backendMessage = pickString(payload, ['message', 'description', 'detail']); + + set((state) => { + let nextCurrentStepId = currentStepId ?? state.currentStepId; + const progressStatus = status; + + if (isCompletedStatus(status) && currentStepId === null) { + nextCurrentStepId = 'completed'; + } + + const completedStepIds = deriveCompletedStepIds({ + currentStepId: nextCurrentStepId, + explicitCompletedStepIds, + previousCompletedStepIds: state.completedStepIds, + previousCurrentStepId: state.currentStepId, + status: progressStatus, + }); + const nextPercent = derivePercent({ + completedStepIds, + currentStepId: nextCurrentStepId, + explicitPercent: percent, + previousPercent: state.percent, + }); + const isCompleted = + completedStepIds.includes('completed') || nextCurrentStepId === 'completed'; + + if (isErrorStatus(progressStatus)) { + return { + backendMessage, + backendStatus: status || state.backendStatus, + backendStep: backendStep ?? state.backendStep, + completedStepIds, + currentStepId: nextCurrentStepId, + errorMessage: backendMessage ?? '분석 중 오류가 발생했습니다.', + percent: nextPercent, + status: 'error', + }; + } + + return { + backendMessage: backendMessage ?? state.backendMessage, + backendStatus: status || state.backendStatus, + backendStep: backendStep ?? state.backendStep, + completedStepIds, + currentStepId: isCompleted ? 'completed' : nextCurrentStepId, + errorMessage: null, + percent: isCompleted ? 100 : nextPercent, + status: isCompleted ? 'completed' : 'active', + }; + }); + }, +})); diff --git a/src/shared/store/scanSessionStore.test.ts b/src/shared/store/scanSessionStore.test.ts new file mode 100644 index 0000000..e736137 --- /dev/null +++ b/src/shared/store/scanSessionStore.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { useScanSessionStore } from './scanSessionStore'; + +describe('scanSessionStore', () => { + afterEach(() => { + useScanSessionStore.getState().clearSession(); + }); + + it('uses typeInfo as decodedUrl for web scan responses', () => { + useScanSessionStore.getState().setScanResponse({ + guestUuid: 'guest-1', + schemeType: 'WEB', + typeInfo: 'https://example.com/path', + }); + + const state = useScanSessionStore.getState(); + + expect(state.decodedUrl).toBe('https://example.com/path'); + expect(state.schemeType).toBe('WEB'); + expect(state.isUrl).toBe(true); + }); + + it('uses finalUrl and score from analysis detail payloads', () => { + useScanSessionStore.getState().setAnalysisDetail({ + originalUrl: 'https://short.example/a', + redirect: { + finalUrl: 'https://final.example/path', + }, + riskLevel: 'safe', + score: 83, + }); + + const state = useScanSessionStore.getState(); + + expect(state.decodedUrl).toBe('https://final.example/path'); + expect(state.riskLevel).toBe('critical'); + expect(state.isUrl).toBe(true); + }); + + it('updates URL classification from analysis detail payloads', () => { + useScanSessionStore.getState().setAnalysisDetail({ + scheme_type: 'SMS', + targetValue: '01012345678', + }); + + const state = useScanSessionStore.getState(); + + expect(state.decodedUrl).toBe('01012345678'); + expect(state.schemeType).toBe('SMS'); + expect(state.isUrl).toBe(false); + }); +}); diff --git a/src/shared/store/scanSessionStore.ts b/src/shared/store/scanSessionStore.ts new file mode 100644 index 0000000..e10e2c5 --- /dev/null +++ b/src/shared/store/scanSessionStore.ts @@ -0,0 +1,266 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'; + +import { pickBoolean, pickString } from '@/shared/api/responseAccess/payloadAccess'; +import { resolveResultToneFromSource } from '@/shared/api/risk/resolveResultTone'; +import type { + BackendAnalysisDetailResponse, + BackendScanResponse, + BackendSseFinalPayload, +} from '@/shared/api/types'; +import type { ResultTone } from '@/shared/types/resultTone'; + +export type ScanHistorySelection = { + isUrl: boolean | null; + riskLevel: ResultTone | null; + scannedAt: string | null; + schemeType: string | null; + url: string; +}; + +export type ScanSessionSnapshot = { + analysisDetail: BackendAnalysisDetailResponse | null; + decodedUrl: string | null; + finalResult: BackendSseFinalPayload | null; + historySelection: ScanHistorySelection | null; + isUrl: boolean | null; + riskLevel: ResultTone | null; + scanResponse: BackendScanResponse | null; + schemeType: string | null; +}; + +type ScanSessionState = ScanSessionSnapshot & { + clearSession: () => void; + resetForNewScan: () => void; + setAnalysisDetail: (detail: BackendAnalysisDetailResponse) => void; + setFinalResult: (finalResult: BackendSseFinalPayload) => void; + setHistorySelection: (historySelection: ScanHistorySelection) => void; + setScanResponse: (scanResponse: BackendScanResponse) => void; +}; + +const scanSessionStorageKey = 'veriq.scanSession'; + +const noopStorage: StateStorage = { + getItem: (_key) => null, + removeItem: (_key) => {}, + setItem: (_key, _value) => {}, +}; + +function createScanSessionStorage(): StateStorage { + if (typeof window === 'undefined') { + return noopStorage; + } + + try { + return window.localStorage ?? noopStorage; + } catch { + return noopStorage; + } +} + +const initialState: ScanSessionSnapshot = { + analysisDetail: null, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: null, + riskLevel: null, + scanResponse: null, + schemeType: null, +}; + +function mergePersistedLightSession( + persistedState: unknown, + currentState: ScanSessionState, +): ScanSessionState { + if (!persistedState || typeof persistedState !== 'object' || Array.isArray(persistedState)) { + return currentState; + } + + const persisted = persistedState as Partial; + + return { + ...currentState, + decodedUrl: typeof persisted.decodedUrl === 'string' ? persisted.decodedUrl : null, + historySelection: persisted.historySelection ?? null, + isUrl: typeof persisted.isUrl === 'boolean' ? persisted.isUrl : null, + riskLevel: persisted.riskLevel ?? null, + schemeType: typeof persisted.schemeType === 'string' ? persisted.schemeType : null, + }; +} + +function resolveDecodedUrl(source: unknown): string | null { + return pickString(source, [ + 'decodedUrl', + 'decoded_url', + 'destinationUrl', + 'destination_url', + 'finalUrl', + 'final_url', + 'scannedUrl', + 'scanned_url', + 'typeInfo', + 'type_info', + 'targetValue', + 'target_value', + 'url', + ]); +} + +function resolveSchemeType(source: unknown, decodedUrl: string | null): string | null { + const explicitSchemeType = pickString(source, ['schemeType', 'scheme_type']); + + if (explicitSchemeType) { + return explicitSchemeType; + } + + if (!decodedUrl) { + return null; + } + + if (/^https?:\/\//iu.test(decodedUrl)) { + return 'WEB'; + } + + return 'NON_WEB'; +} + +function resolveIsUrl( + source: unknown, + decodedUrl: string | null, + schemeType: string | null, +): boolean | null { + const explicitIsUrl = pickBoolean(source, ['isUrl', 'is_url']); + + if (explicitIsUrl !== null) { + return explicitIsUrl; + } + + if (schemeType) { + return schemeType.trim().toUpperCase() === 'WEB'; + } + + if (decodedUrl) { + return /^https?:\/\//iu.test(decodedUrl); + } + + return null; +} + +function resolveRiskLevel(source: unknown): ResultTone | null { + return resolveResultToneFromSource(source); +} + +export const useScanSessionStore = create()( + persist( + (set) => ({ + ...initialState, + clearSession: () => { + set(initialState); + }, + resetForNewScan: () => { + set({ + analysisDetail: null, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: null, + riskLevel: null, + scanResponse: null, + schemeType: null, + }); + }, + setAnalysisDetail: (analysisDetail) => { + const decodedUrl = resolveDecodedUrl(analysisDetail); + + set((state) => { + const nextDecodedUrl = decodedUrl ?? state.decodedUrl; + const schemeType = resolveSchemeType(analysisDetail, nextDecodedUrl); + const nextSchemeType = schemeType ?? state.schemeType; + + return { + analysisDetail, + decodedUrl: nextDecodedUrl, + isUrl: resolveIsUrl(analysisDetail, nextDecodedUrl, nextSchemeType) ?? state.isUrl, + riskLevel: resolveRiskLevel(analysisDetail) ?? state.riskLevel, + schemeType: nextSchemeType, + }; + }); + }, + setFinalResult: (finalResult) => { + const decodedUrl = resolveDecodedUrl(finalResult); + const schemeType = resolveSchemeType(finalResult, decodedUrl); + + set((state) => ({ + decodedUrl: decodedUrl ?? state.decodedUrl, + finalResult, + historySelection: state.historySelection, + isUrl: + resolveIsUrl( + finalResult, + decodedUrl ?? state.decodedUrl, + schemeType ?? state.schemeType, + ) ?? state.isUrl, + riskLevel: resolveRiskLevel(finalResult) ?? state.riskLevel, + schemeType: schemeType ?? state.schemeType, + })); + }, + setHistorySelection: (historySelection) => { + const schemeType = resolveSchemeType(historySelection, historySelection.url); + + set({ + analysisDetail: null, + decodedUrl: historySelection.url, + finalResult: null, + historySelection, + isUrl: resolveIsUrl(historySelection, historySelection.url, schemeType), + riskLevel: historySelection.riskLevel, + scanResponse: null, + schemeType, + }); + }, + setScanResponse: (scanResponse) => { + const decodedUrl = resolveDecodedUrl(scanResponse); + const schemeType = resolveSchemeType(scanResponse, decodedUrl); + + set({ + analysisDetail: null, + decodedUrl, + finalResult: null, + historySelection: null, + isUrl: resolveIsUrl(scanResponse, decodedUrl, schemeType), + riskLevel: resolveRiskLevel(scanResponse), + scanResponse, + schemeType, + }); + }, + }), + { + name: scanSessionStorageKey, + partialize: (state) => ({ + decodedUrl: state.decodedUrl, + historySelection: state.historySelection, + isUrl: state.isUrl, + riskLevel: state.riskLevel, + schemeType: state.schemeType, + }), + merge: mergePersistedLightSession, + storage: createJSONStorage(createScanSessionStorage), + }, + ), +); + +export function getScanSessionSnapshot(): ScanSessionSnapshot { + const state = useScanSessionStore.getState(); + + return { + analysisDetail: state.analysisDetail, + decodedUrl: state.decodedUrl, + finalResult: state.finalResult, + historySelection: state.historySelection, + isUrl: state.isUrl, + riskLevel: state.riskLevel, + scanResponse: state.scanResponse, + schemeType: state.schemeType, + }; +} diff --git a/src/shared/ui/resultPage/index.ts b/src/shared/ui/resultPage/index.ts new file mode 100644 index 0000000..de95ba7 --- /dev/null +++ b/src/shared/ui/resultPage/index.ts @@ -0,0 +1 @@ +export * as resultPageStyles from './resultPage.css'; diff --git a/src/shared/ui/resultPage/resultPage.css.ts b/src/shared/ui/resultPage/resultPage.css.ts new file mode 100644 index 0000000..0d79c20 --- /dev/null +++ b/src/shared/ui/resultPage/resultPage.css.ts @@ -0,0 +1,60 @@ +import { style } from '@vanilla-extract/css'; + +import { vars } from '@/vars.css'; + +const bpTablet = 'screen and (min-width: 768px)'; +const bpDesktop = 'screen and (min-width: 1200px)'; + +export const page = style({ + minHeight: '100vh', + backgroundColor: vars.colors.white, +}); + +export const shell = style({ + boxSizing: 'border-box', + maxWidth: '1120px', + width: '100%', + margin: '0 auto', + padding: `0 ${vars.spacing.md} ${vars.spacing.lg}`, + '@media': { + [bpTablet]: { + padding: `0 ${vars.spacing.lg} ${vars.spacing.xl}`, + }, + }, +}); + +export const content = style({ + width: '100%', + maxWidth: '640px', + margin: '0 auto', + display: 'grid', + gap: vars.spacing.lg, + paddingTop: vars.spacing.lg, + '@media': { + [bpDesktop]: { + gap: vars.spacing.xl, + }, + }, +}); + +export const metricsGrid = style({ + display: 'grid', + gap: vars.spacing.md, + '@media': { + [bpTablet]: { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + }, +}); + +export const footer = style({ + textAlign: 'center', + color: vars.colors.subDark, + fontSize: vars.font.size.xs, + paddingTop: vars.spacing.sm, + '@media': { + [bpDesktop]: { + paddingTop: vars.spacing.md, + }, + }, +}); diff --git a/src/shared/ui/status-action-button/StatusActionButton.tsx b/src/shared/ui/status-action-button/StatusActionButton.tsx deleted file mode 100644 index 749c1f4..0000000 --- a/src/shared/ui/status-action-button/StatusActionButton.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { ButtonHTMLAttributes, ReactNode } from 'react'; - -import * as styles from './statusActionButton.css'; - -export type StatusTone = 'safe' | 'warning' | 'critical'; - -type StatusActionButtonProps = { - children: ReactNode; - iconSrc?: string; - tone: StatusTone; -} & Omit, 'children'>; - -export default function StatusActionButton({ - children, - className, - iconSrc, - tone, - type = 'button', - ...buttonProps -}: StatusActionButtonProps) { - const buttonClassName = className - ? `${styles.root} ${styles.filledTone[tone]} ${className}` - : `${styles.root} ${styles.filledTone[tone]}`; - - return ( - - ); -} diff --git a/src/shared/ui/status-action-button/index.ts b/src/shared/ui/status-action-button/index.ts deleted file mode 100644 index 3d93430..0000000 --- a/src/shared/ui/status-action-button/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './StatusActionButton'; -export type { StatusTone } from './StatusActionButton'; diff --git a/src/shared/ui/status-action-button/statusActionButton.css.ts b/src/shared/ui/status-action-button/statusActionButton.css.ts deleted file mode 100644 index 3afb7d5..0000000 --- a/src/shared/ui/status-action-button/statusActionButton.css.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { style, styleVariants } from '@vanilla-extract/css'; - -import { vars } from '@/vars.css'; - -export const root = style({ - width: '100%', - minWidth: '0', - height: '52px', - padding: `0 ${vars.spacing.md}`, - border: 'none', - borderRadius: '999px', - fontSize: vars.font.size.xl, - fontWeight: vars.font.weight.semibold, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: vars.spacing.sm, -}); - -export const filledTone = styleVariants({ - safe: { - backgroundColor: '#11D483', - color: vars.colors.white, - }, - warning: { - backgroundColor: vars.colors.warning, - color: vars.colors.black, - }, - critical: { - backgroundColor: vars.colors.error, - color: vars.colors.white, - }, -}); - -export const iconWrap = style({ - display: 'inline-flex', - width: '14px', - height: '14px', - flexShrink: 0, -}); - -export const iconImage = style({ - width: '100%', - height: '100%', - objectFit: 'contain', -}); diff --git a/vite.config.ts b/vite.config.ts index 38ec345..4d6e19b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,15 +3,74 @@ import { fileURLToPath } from 'node:url'; import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; const dirname = path.dirname(fileURLToPath(import.meta.url)); -export default defineConfig({ - plugins: [react(), vanillaExtractPlugin()], - resolve: { - alias: { - '@': path.resolve(dirname, 'src'), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const be1ProxyTarget = env.VITE_BE1_BASE_URL?.trim() || 'http://localhost:8081'; + const be3ProxyTarget = env.VITE_BE3_BASE_URL?.trim() || 'http://localhost:8083'; + + return { + build: { + rollupOptions: { + output: { + manualChunks(id) { + const normalizedId = id.replaceAll(path.sep, '/'); + + if (!normalizedId.includes('/node_modules/')) { + return; + } + + if ( + normalizedId.includes('/antd/') || + normalizedId.includes('/@ant-design/') || + normalizedId.includes('/rc-') + ) { + return 'antd'; + } + + if ( + normalizedId.includes('/react/') || + normalizedId.includes('/react-dom/') || + normalizedId.includes('/scheduler/') + ) { + return 'react'; + } + + if (normalizedId.includes('/@tanstack/')) { + return 'router'; + } + + if (normalizedId.includes('/zustand/')) { + return 'state'; + } + + return; + }, + }, + }, + }, + plugins: [react(), vanillaExtractPlugin()], + resolve: { + alias: { + '@': path.resolve(dirname, 'src'), + }, + }, + server: { + proxy: { + '/be1': { + changeOrigin: true, + rewrite: (requestPath) => requestPath.replace(/^\/be1/u, ''), + target: be1ProxyTarget, + }, + '/be3': { + changeOrigin: true, + rewrite: (requestPath) => requestPath.replace(/^\/be3/u, ''), + target: be3ProxyTarget, + }, + }, }, - }, + }; });