diff --git a/.env.example b/.env.example index e97236d..408e04d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Google reCAPTCHA Enterprise checkbox site key +# Google reCAPTCHA Enterprise site key VITE_RECAPTCHA_SITE_KEY= # Browser-facing backend base paths. In Vercel these route through vercel.json rewrites. diff --git a/scripts/setupEnv.mjs b/scripts/setupEnv.mjs index 29c71a9..4478298 100644 --- a/scripts/setupEnv.mjs +++ b/scripts/setupEnv.mjs @@ -14,7 +14,7 @@ const serverEnvLocalPath = path.join(rootDir, '.env.server.local'); const OWNER_READ_WRITE_MODE = 0o600; const defaultEnvContent = [ - '# Google reCAPTCHA Enterprise checkbox site key (required when provider is googleRecaptchaEnterprise)', + '# Google reCAPTCHA Enterprise site key (required when provider is googleRecaptchaEnterprise)', 'VITE_RECAPTCHA_SITE_KEY=', '', '# Backend base URLs. Provide deployed values via .env.local or secret management.', diff --git a/src/pages/Captcha/hooks/useCaptchaPage.ts b/src/pages/Captcha/hooks/useCaptchaPage.ts index 3a70925..1a3e2a9 100644 --- a/src/pages/Captcha/hooks/useCaptchaPage.ts +++ b/src/pages/Captcha/hooks/useCaptchaPage.ts @@ -18,7 +18,7 @@ export function useCaptchaPage(): UseCaptchaPageReturn { const [feedbackMessage, setFeedbackMessage] = useState(null); const [isVerifying, setIsVerifying] = useState(false); const recaptchaSiteKey = (import.meta.env.VITE_RECAPTCHA_SITE_KEY ?? '').trim(); - const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0; + const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0 && !isVerifying; const handleCaptchaTokenChange = useCallback((nextToken: string | null) => { setToken(nextToken); @@ -27,13 +27,14 @@ export function useCaptchaPage(): UseCaptchaPageReturn { const handleSubmit = useCallback(async () => { setFeedbackMessage(null); - const trimmedToken = token?.trim() ?? ''; if (recaptchaSiteKey.length === 0) { - setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다. `.env.local`을 확인해 주세요.'); + setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다.'); return; } + const trimmedToken = token?.trim() ?? ''; + if (!trimmedToken) { setFeedbackMessage('캡차를 먼저 완료해 주세요.'); return; @@ -49,7 +50,7 @@ export function useCaptchaPage(): UseCaptchaPageReturn { return; } - setFeedbackMessage('캡차 검증이 완료되었습니다. 스캔 화면으로 이동합니다.'); + setFeedbackMessage('캡차 검증이 완료되었습니다.'); void navigate({ to: '/qr-scan' }); } finally { setIsVerifying(false); diff --git a/src/pages/Captcha/lib/recaptchaEnterprise.ts b/src/pages/Captcha/lib/recaptchaEnterprise.ts new file mode 100644 index 0000000..4ccd8ab --- /dev/null +++ b/src/pages/Captcha/lib/recaptchaEnterprise.ts @@ -0,0 +1,254 @@ +type RenderCaptchaOptions = { + onError: () => void; + onExpired: () => void; + onToken: (token: string) => void; + signal?: AbortSignal; + siteKey: string; +}; + +type GrecaptchaEnterprise = { + ready: (callback: () => void) => void; + render: ( + container: HTMLElement, + parameters: { + callback: (token: string) => void; + 'error-callback': () => void; + 'expired-callback': () => void; + sitekey: string; + theme?: 'light' | 'dark'; + }, + ) => number; + reset?: (widgetId: number) => void; +}; + +declare global { + interface Window { + grecaptcha?: { + enterprise?: GrecaptchaEnterprise; + }; + } +} + +let enterpriseScriptPromise: Promise | null = null; +const enterpriseScriptSources = [ + 'https://www.google.com/recaptcha/enterprise.js?render=explicit', + 'https://www.recaptcha.net/recaptcha/enterprise.js?render=explicit', +] as const; +const enterpriseApiReadyTimeoutMs = 5_000; +const enterpriseApiReadyCheckIntervalMs = 50; + +function getEnterpriseApi(): GrecaptchaEnterprise | null { + return window.grecaptcha?.enterprise ?? null; +} + +function isEnterpriseApiReady(): boolean { + return typeof window.grecaptcha?.enterprise?.render === 'function'; +} + +function waitForEnterpriseApiReady(timeoutMs = enterpriseApiReadyTimeoutMs): Promise { + if (isEnterpriseApiReady()) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + + const checkReady = () => { + if (isEnterpriseApiReady()) { + resolve(); + return; + } + + if (Date.now() - startedAt >= timeoutMs) { + reject(new Error('Enterprise API is not ready after script load.')); + return; + } + + window.setTimeout(checkReady, enterpriseApiReadyCheckIntervalMs); + }; + + checkReady(); + }); +} + +function buildScriptSelector(sourceUrl: string): string { + return `script[data-recaptcha-enterprise-src="${sourceUrl}"]`; +} + +function waitForScriptReady(script: HTMLScriptElement): Promise { + return new Promise((resolve, reject) => { + const onLoad = () => { + void waitForEnterpriseApiReady() + .then(() => { + cleanup(); + resolve(); + }) + .catch((error) => { + cleanup(); + reject(error); + }); + }; + + const onError = () => { + cleanup(); + reject(new Error('Failed to load reCAPTCHA Enterprise script.')); + }; + + const cleanup = () => { + script.removeEventListener('load', onLoad); + script.removeEventListener('error', onError); + }; + + script.addEventListener('load', onLoad); + script.addEventListener('error', onError); + + if (script.dataset.loadStatus === 'loaded' || isEnterpriseApiReady()) { + onLoad(); + return; + } + + if (script.dataset.loadStatus === 'error') { + onError(); + } + }); +} + +async function loadEnterpriseScriptBySource(sourceUrl: string): Promise { + const existingScript = document.querySelector(buildScriptSelector(sourceUrl)); + + if (existingScript) { + if (existingScript.dataset.loadStatus === 'loaded' || isEnterpriseApiReady()) { + await waitForEnterpriseApiReady(); + return; + } + + if (existingScript.dataset.loadStatus === 'error') { + throw new Error('Failed to load reCAPTCHA Enterprise script.'); + } + + await waitForScriptReady(existingScript); + return; + } + + const script = document.createElement('script'); + script.async = true; + script.defer = true; + script.dataset.loadStatus = 'loading'; + script.dataset.recaptchaEnterprise = 'true'; + script.dataset.recaptchaEnterpriseSrc = sourceUrl; + script.src = sourceUrl; + + const readyPromise = waitForScriptReady(script); + + script.onload = () => { + script.dataset.loadStatus = 'loaded'; + }; + + script.onerror = () => { + script.dataset.loadStatus = 'error'; + }; + + document.head.appendChild(script); + await readyPromise; +} + +export function loadRecaptchaEnterprise(): Promise { + if (isEnterpriseApiReady()) { + return Promise.resolve(); + } + + if (enterpriseScriptPromise) { + return enterpriseScriptPromise; + } + + const scriptPromise = (async () => { + let lastError: unknown = null; + + for (const sourceUrl of enterpriseScriptSources) { + try { + await loadEnterpriseScriptBySource(sourceUrl); + + if (isEnterpriseApiReady()) { + return; + } + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) { + throw lastError; + } + + throw new Error('Failed to load reCAPTCHA Enterprise script.'); + })().catch((error) => { + enterpriseScriptPromise = null; + throw error; + }); + + enterpriseScriptPromise = scriptPromise; + return scriptPromise; +} + +export async function renderRecaptchaEnterprise( + container: HTMLElement, + { onError, onExpired, onToken, signal, siteKey }: RenderCaptchaOptions, +): Promise { + if (signal?.aborted) { + throw new Error('RECAPTCHA_RENDER_ABORTED'); + } + + await loadRecaptchaEnterprise(); + + const enterprise = getEnterpriseApi(); + + if (!enterprise) { + throw new Error('reCAPTCHA Enterprise API is unavailable.'); + } + + return new Promise((resolve, reject) => { + const abortError = new Error('RECAPTCHA_RENDER_ABORTED'); + const onAbort = () => { + reject(abortError); + }; + + if (signal?.aborted) { + reject(abortError); + return; + } + + signal?.addEventListener('abort', onAbort, { once: true }); + + const detachAbortListener = () => { + signal?.removeEventListener('abort', onAbort); + }; + + enterprise.ready(() => { + if (signal?.aborted) { + detachAbortListener(); + reject(abortError); + return; + } + + try { + const widgetId = enterprise.render(container, { + callback: onToken, + 'error-callback': onError, + 'expired-callback': onExpired, + sitekey: siteKey, + theme: 'light', + }); + + detachAbortListener(); + resolve(widgetId); + } catch (error) { + detachAbortListener(); + reject(error); + } + }); + }); +} + +export function resetRecaptchaEnterprise(widgetId: number): void { + window.grecaptcha?.enterprise?.reset?.(widgetId); +} diff --git a/src/pages/Captcha/styles/captchaPage.css.ts b/src/pages/Captcha/styles/captchaPage.css.ts index 0f3e4ae..bbeccaf 100644 --- a/src/pages/Captcha/styles/captchaPage.css.ts +++ b/src/pages/Captcha/styles/captchaPage.css.ts @@ -70,9 +70,18 @@ export const widgetFrame = style({ export const enterpriseBox = style({ display: 'flex', + flexDirection: 'column', justifyContent: 'center', alignItems: 'center', minHeight: '78px', + gap: vars.spacing.xs, +}); + +export const captchaError = style({ + margin: 0, + color: '#B42318', + fontSize: vars.font.size.xs, + lineHeight: 1.4, }); export const captchaHint = style({ diff --git a/src/pages/Captcha/ui/CaptchaWidget.tsx b/src/pages/Captcha/ui/CaptchaWidget.tsx index ee45efe..2d2a3d9 100644 --- a/src/pages/Captcha/ui/CaptchaWidget.tsx +++ b/src/pages/Captcha/ui/CaptchaWidget.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { renderRecaptchaEnterprise, resetRecaptchaEnterprise } from '../lib/recaptchaEnterprise'; import * as styles from '../styles/captchaPage.css'; type CaptchaWidgetProps = { @@ -7,194 +8,70 @@ type CaptchaWidgetProps = { recaptchaSiteKey: string; }; -type GrecaptchaEnterprise = { - ready: (callback: () => void) => void; - render: ( - container: HTMLElement, - parameters: { - callback: (token: string) => void; - 'error-callback': () => void; - 'expired-callback': () => void; - sitekey: string; - theme?: 'light' | 'dark'; - }, - ) => number; - reset?: (widgetId: number) => void; -}; - -declare global { - interface Window { - grecaptcha?: { - enterprise?: GrecaptchaEnterprise; - }; - } -} - -let enterpriseScriptPromise: Promise | null = null; - -function isEnterpriseApiReady() { - return typeof window.grecaptcha?.enterprise?.render === 'function'; -} - -function loadEnterpriseScript(): Promise { - if (isEnterpriseApiReady()) { - return Promise.resolve(); - } - - if (enterpriseScriptPromise) { - return enterpriseScriptPromise; - } - - const scriptPromise = new Promise((resolve, reject) => { - const existingScript = document.querySelector( - 'script[data-recaptcha-enterprise="true"]', - ); - - const onLoad = () => { - if (isEnterpriseApiReady()) { - resolve(); - return; - } - - reject(new Error('Enterprise API is not ready after script load')); - }; - - const onError = () => { - reject(new Error('Failed to load reCAPTCHA Enterprise script')); - }; - - if (existingScript) { - const loadStatus = existingScript.dataset.loadStatus; - - if (loadStatus === 'loaded' || isEnterpriseApiReady()) { - onLoad(); - return; - } - - if (loadStatus === 'error') { - onError(); - return; - } - - existingScript.addEventListener('load', onLoad, { once: true }); - existingScript.addEventListener('error', onError, { once: true }); - return; - } - - const script = document.createElement('script'); - script.async = true; - script.defer = true; - script.dataset.loadStatus = 'loading'; - script.dataset.recaptchaEnterprise = 'true'; - script.src = 'https://www.google.com/recaptcha/enterprise.js?render=explicit'; - - script.onload = () => { - script.dataset.loadStatus = 'loaded'; - onLoad(); - }; - - script.onerror = () => { - script.dataset.loadStatus = 'error'; - onError(); - }; - - document.head.appendChild(script); - }).catch((error) => { - enterpriseScriptPromise = null; - throw error; - }); - - enterpriseScriptPromise = scriptPromise; - return scriptPromise; -} - export default function CaptchaWidget({ onTokenChange, recaptchaSiteKey }: CaptchaWidgetProps) { const widgetContainerRef = useRef(null); + const [loadErrorMessage, setLoadErrorMessage] = useState(null); useEffect(() => { const container = widgetContainerRef.current; - let cancelled = false; + let disposed = false; let widgetId: number | null = null; + const renderAbortController = new AbortController(); if (!container || !recaptchaSiteKey) { return; } container.innerHTML = ''; + setLoadErrorMessage(null); - loadEnterpriseScript() - .then(() => { - if (cancelled || widgetContainerRef.current !== container) { - return; + renderRecaptchaEnterprise(container, { + onError: () => { + if (!disposed) { + onTokenChange(null); } - - const enterprise = window.grecaptcha?.enterprise; - - if (!enterprise) { - if (cancelled) { - return; - } - + }, + onExpired: () => { + if (!disposed) { onTokenChange(null); + } + }, + onToken: (token) => { + if (!disposed) { + onTokenChange(token); + } + }, + signal: renderAbortController.signal, + siteKey: recaptchaSiteKey, + }) + .then((nextWidgetId) => { + if (disposed) { + resetRecaptchaEnterprise(nextWidgetId); return; } - enterprise.ready(() => { - 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); - } - }); + widgetId = nextWidgetId; }) - .catch(() => { - if (cancelled) { + .catch((error) => { + if (disposed) { return; } + console.error('Failed to render reCAPTCHA Enterprise widget.', error); onTokenChange(null); + setLoadErrorMessage( + error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'reCAPTCHA widget failed to load.', + ); }); return () => { - cancelled = true; + disposed = true; + renderAbortController.abort(); if (widgetId !== null) { - window.grecaptcha?.enterprise?.reset?.(widgetId); + resetRecaptchaEnterprise(widgetId); } container.innerHTML = ''; @@ -204,6 +81,7 @@ export default function CaptchaWidget({ onTokenChange, recaptchaSiteKey }: Captc return (
+ {loadErrorMessage ?

{loadErrorMessage}

: null}
); } diff --git a/src/pages/Result/ResultPage.tsx b/src/pages/Result/ResultPage.tsx index 3013533..ee3a3bb 100644 --- a/src/pages/Result/ResultPage.tsx +++ b/src/pages/Result/ResultPage.tsx @@ -1,6 +1,7 @@ import type { ResultTone } from '@/shared/types/resultTone'; import ResultHero from '@/shared/ui/resultHero'; +import { DETAIL_UNAVAILABLE_MESSAGE } from './api/createResultPageFetcher'; import { useResultPage } from './hooks/useResultPage'; import ResultStatusPage from './ui/ResultStatusPage'; @@ -15,13 +16,13 @@ const resultViewConfig = { }, hero: ( ), riskLevelCard: { - description: '피싱 의심 패턴이 강하게 감지됨', + description: '실시간 위협 신호가 강하게 감지됨', levelText: '위험', }, siteCard: { @@ -37,7 +38,7 @@ const resultViewConfig = { description="Veri-Q 분석 결과, 해당 QR 코드는 검증된 안전한 웹사이트로 연결됩니다." title={ <> - 안심하세요! + 안심하세요
안전한 사이트입니다 @@ -55,7 +56,7 @@ const resultViewConfig = { description="Veri-Q 분석 결과, 해당 QR 코드는 주의가 필요한 웹사이트로 분류되었습니다." title={ <> - 주의하세요! + 주의하세요
주의가 필요한 사이트입니다 @@ -64,16 +65,43 @@ const resultViewConfig = { /> ), riskLevelCard: { - description: '의심 패턴 일부 감지됨', + description: '의심 신호 일부 감지됨', levelText: '주의', }, siteCard: { badgeLabel: '!', - visitLabel: '주의하여 사이트 방문하기', + visitLabel: '주의하며 사이트 방문하기', }, }, } as const; +const detailUnavailableViewConfig = { + hero: ( + + 아직 분석 결과를 +
+ 가져오지 못했습니다 + + } + tone="warning" + /> + ), + riskLevelCard: { + description: '스캔 결과 상세 데이터가 준비되지 않았습니다.', + levelText: '분석 대기', + tone: 'warning' as const, + }, + siteCard: { + badgeLabel: '?', + statusLabel: '분석 대기', + tone: 'warning' as const, + visitLabel: '다시 검사하기', + }, +} as const; + export default function ResultPage({ tone }: ResultPageProps) { const { handleReport, @@ -84,6 +112,8 @@ export default function ResultPage({ tone }: ResultPageProps) { resultData, } = useResultPage(tone); const config = resultViewConfig[tone]; + const isDetailUnavailable = resultData?.detailUnavailable === true; + const resolvedTone: ResultTone = isDetailUnavailable ? 'warning' : tone; if (!resultData) { return null; @@ -92,22 +122,26 @@ export default function ResultPage({ tone }: ResultPageProps) { return ( ); } diff --git a/src/pages/Result/api/createResultPageFetcher.test.ts b/src/pages/Result/api/createResultPageFetcher.test.ts new file mode 100644 index 0000000..5958af2 --- /dev/null +++ b/src/pages/Result/api/createResultPageFetcher.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '@/shared/api/errors/apiError'; +import { useScanSessionStore } from '@/shared/store/scanSessionStore'; + +import { createResultPageFetcher, DETAIL_UNAVAILABLE_MESSAGE } from './createResultPageFetcher'; + +vi.mock('@/shared/lib/scan-session/ensureScanDetail', () => { + return { + ensureScanDetail: vi.fn(), + resolveRequestedUrlFromSearch: vi.fn(() => null), + }; +}); + +describe('createResultPageFetcher', () => { + beforeEach(() => { + useScanSessionStore.getState().clearSession(); + vi.resetAllMocks(); + }); + + it('falls back to session result data when scan detail is not found yet', async () => { + const { ensureScanDetail } = await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(ensureScanDetail).mockRejectedValue( + new ApiError({ + message: 'Request failed with status code 404', + statusCode: 404, + }), + ); + + useScanSessionStore.getState().setFinalResult({ + finalUrl: 'http://naver-login-check.xyz', + originalUrl: 'http://naver-login-check.xyz', + riskLevel: 'safe', + score: 23, + }); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).resolves.toMatchObject({ + siteName: 'http://naver-login-check.xyz', + siteUrl: 'http://naver-login-check.xyz', + trustScore: 23, + }); + }); + + it('returns detail-unavailable data when scan detail is missing and only URL query is available', async () => { + const { ensureScanDetail, resolveRequestedUrlFromSearch } = + await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(ensureScanDetail).mockRejectedValue( + new ApiError({ + message: 'Request failed with status code 404', + statusCode: 404, + }), + ); + vi.mocked(resolveRequestedUrlFromSearch).mockReturnValue('https://www.daum.net/'); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).resolves.toMatchObject({ + detailUnavailable: true, + siteMeta: DETAIL_UNAVAILABLE_MESSAGE, + siteName: 'https://www.daum.net/', + siteUrl: 'https://www.daum.net/', + trustScore: 0, + }); + }); + + it('propagates non-404 API errors from scan detail request', async () => { + const { ensureScanDetail } = await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(ensureScanDetail).mockRejectedValue( + new ApiError({ + message: 'Request failed with status code 500', + statusCode: 500, + }), + ); + + useScanSessionStore.getState().setHistorySelection({ + isUrl: true, + riskLevel: 'safe', + scannedAt: null, + schemeType: 'WEB', + url: 'https://www.daum.net/', + }); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).rejects.toThrow('Request failed with status code 500'); + }); + + it('throws SCAN_SESSION_REQUIRED when there is no session data and no recoverable URL', async () => { + const { ensureScanDetail, resolveRequestedUrlFromSearch } = + await import('@/shared/lib/scan-session/ensureScanDetail'); + vi.mocked(resolveRequestedUrlFromSearch).mockReturnValue(null); + + const { fetchResultPageData } = createResultPageFetcher('safe'); + + await expect(fetchResultPageData()).rejects.toThrow('SCAN_SESSION_REQUIRED'); + expect(ensureScanDetail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/Result/api/createResultPageFetcher.ts b/src/pages/Result/api/createResultPageFetcher.ts index 81003b9..c6bc123 100644 --- a/src/pages/Result/api/createResultPageFetcher.ts +++ b/src/pages/Result/api/createResultPageFetcher.ts @@ -1,3 +1,4 @@ +import { isApiError } from '@/shared/api/errors/apiError'; import { ensureScanDetail, resolveRequestedUrlFromSearch, @@ -15,6 +16,9 @@ type ResultPageFetcher = { getInitialResultPageData: () => ResultPageData | null; }; +export const DETAIL_UNAVAILABLE_MESSAGE = + '상세 분석 데이터를 찾지 못했습니다. 잠시 후 다시 시도하거나 다시 검사해 주세요.'; + function hasSessionResult(session: ScanSessionSnapshot): boolean { return Boolean( session.analysisDetail || @@ -24,16 +28,42 @@ function hasSessionResult(session: ScanSessionSnapshot): boolean { ); } +function resolveRecoverableUrl(session: ScanSessionSnapshot): string | null { + return session.decodedUrl ?? session.historySelection?.url ?? resolveRequestedUrlFromSearch(); +} + +function createDetailUnavailableResultPageData(url: string): ResultPageData { + return { + detailUnavailable: true, + previewUrl: url, + siteMeta: DETAIL_UNAVAILABLE_MESSAGE, + siteName: url, + siteUrl: url, + trustScore: 0, + visitUrl: url, + }; +} + export function createResultPageFetcher(tone: ResultTone): ResultPageFetcher { async function fetchResultPageData(): Promise { const session = getScanSessionSnapshot(); - const hasRecoverableUrl = Boolean( - session.decodedUrl || session.historySelection?.url || resolveRequestedUrlFromSearch(), - ); + const recoverableUrl = resolveRecoverableUrl(session); + + if (!session.analysisDetail && recoverableUrl) { + try { + const detailSession = await ensureScanDetail(); + return toResultPageData(detailSession, tone); + } catch (error) { + if (isApiError(error) && error.statusCode === 404) { + if (hasSessionResult(session)) { + return toResultPageData(session, tone); + } + + return createDetailUnavailableResultPageData(recoverableUrl); + } - if (!session.analysisDetail && hasRecoverableUrl) { - const detailSession = await ensureScanDetail(); - return toResultPageData(detailSession, tone); + throw error; + } } if (hasSessionResult(session)) { diff --git a/src/pages/Result/lib/toResultPageData.ts b/src/pages/Result/lib/toResultPageData.ts index 7201303..17f5304 100644 --- a/src/pages/Result/lib/toResultPageData.ts +++ b/src/pages/Result/lib/toResultPageData.ts @@ -154,6 +154,7 @@ export function toResultPageData( trustScoreFallbackByTone[resolvedTone]; return { + detailUnavailable: false, previewUrl: resolvedPreviewUrl, siteMeta: buildSiteMeta(sources), siteName: resolvedOriginalUrl, diff --git a/src/pages/Result/types/resultPage.types.ts b/src/pages/Result/types/resultPage.types.ts index 03d092c..f1ca30b 100644 --- a/src/pages/Result/types/resultPage.types.ts +++ b/src/pages/Result/types/resultPage.types.ts @@ -1,4 +1,5 @@ export type ResultPageData = { + detailUnavailable?: boolean; previewUrl: string; siteMeta: string; siteName: string; diff --git a/src/pages/Result/ui/ResultStatusPage.tsx b/src/pages/Result/ui/ResultStatusPage.tsx index 619a444..da11057 100644 --- a/src/pages/Result/ui/ResultStatusPage.tsx +++ b/src/pages/Result/ui/ResultStatusPage.tsx @@ -39,6 +39,8 @@ type ResultStatusPageProps = { resultData: ResultStatusPageData; riskLevelCard?: RiskLevelCardOptions; siteCard?: SafeSiteCardOptions; + showMetrics?: boolean; + showReportToggle?: boolean; tone: ResultTone; }; @@ -54,6 +56,8 @@ export default function ResultStatusPage({ resultData, riskLevelCard, siteCard, + showMetrics = true, + showReportToggle = true, tone, }: ResultStatusPageProps) { const currentYear = new Date().getFullYear(); @@ -66,10 +70,12 @@ export default function ResultStatusPage({
{hero} -
- - -
+ {showMetrics ? ( +
+ + +
+ ) : null} - + {showReportToggle ? ( + + ) : null}

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