diff --git a/package.json b/package.json index 1137524..9dfb609 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "prettier": "^3.8.1", "secretlint": "^11.3.0", "typescript": "^5.9.3", - "vite": "^7.3.1", + "vite": "^7.3.2", "vitest": "^3.2.4" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 883822b..81182bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,10 +55,10 @@ importers: version: 8.56.1(eslint@9.39.4)(typescript@5.9.3) '@vanilla-extract/vite-plugin': specifier: ^5.1.0 - version: 5.1.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 5.1.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.1.4(vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 5.1.4(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: ^9.39.1 version: 9.39.4 @@ -84,8 +84,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + specifier: ^7.3.2 + version: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) @@ -4466,10 +4466,10 @@ packages: engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } hasBin: true - vite@7.3.1: + vite@7.3.2: resolution: { - integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==, + integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==, } engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true @@ -5641,7 +5641,7 @@ snapshots: dependencies: '@vanilla-extract/css': 1.18.0 '@vanilla-extract/integration': 8.0.7 - vite: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vite-node: 3.2.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' @@ -5693,11 +5693,11 @@ snapshots: '@vanilla-extract/private@1.0.9': {} - '@vanilla-extract/vite-plugin@5.1.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': + '@vanilla-extract/vite-plugin@5.1.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': dependencies: '@vanilla-extract/compiler': 0.3.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) '@vanilla-extract/integration': 8.0.7 - vite: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -5713,7 +5713,7 @@ snapshots: - tsx - yaml - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitejs/plugin-react@5.1.4(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5721,7 +5721,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -5733,13 +5733,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -7537,7 +7537,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -7552,7 +7552,7 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.4) @@ -7571,7 +7571,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7589,7 +7589,7 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) vite-node: 3.2.4(@types/node@24.12.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/src/pages/QRScan/QRScanPage.tsx b/src/pages/QRScan/QRScanPage.tsx index 7dcf07b..ca93eff 100644 --- a/src/pages/QRScan/QRScanPage.tsx +++ b/src/pages/QRScan/QRScanPage.tsx @@ -1,8 +1,10 @@ -import { Link } from '@tanstack/react-router'; +import { Link, useNavigate } 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'; @@ -49,33 +51,79 @@ function ViewGlyphIcon() { ); } +function HistoryPrevIcon() { + return ( + + ); +} + +function HistoryNextIcon() { + return ( + + ); +} + +const resultRouteByStatus: Record< + ScanListStatus, + '/result/safe' | '/result/warning' | '/result/critical' +> = { + safe: '/result/safe', + warning: '/result/warning', + critical: '/result/critical', +}; + +const statusLabelByTone: Record = { + safe: '안전', + warning: '주의', + critical: '위험', +}; + +const statusSummaryByTone: Record = { + safe: '안전으로 판정된 스캔 이력입니다.', + warning: '주의가 필요한 스캔 이력입니다.', + critical: '위험으로 분류된 스캔 이력입니다.', +}; + export default function QRScanPage() { + const navigate = useNavigate(); const { cameraStatus, cameraStatusText, - captureRecord, + canNavigateHistory, fileInputRef, handleCapturePhoto, handleGalleryFileChange, handleOpenGallery, + handleShowNextHistoryItem, + handleShowPreviousHistoryItem, isCapturing, isFlashVisible, + recentScanItem, videoRef, } = useQRScanPage(); - const recentActivity = captureRecord ?? { - badgeLabel: 'LIVE', - capturedAt: - cameraStatus === 'ready' ? '실시간 프리뷰 활성화됨' : '카메라 연결을 확인해 주세요.', - headline: - cameraStatus === 'ready' - ? '후면 카메라 화면이 실시간으로 표시되고 있습니다.' - : '후면 카메라 연결 후 현재 화면을 촬영할 수 있습니다.', - photoUrl: null, - summary: - cameraStatus === 'ready' - ? '초록색 버튼을 누르면 현재 후면 카메라 프레임이 촬영됩니다.' - : cameraStatusText, + const handleOpenRecentScanResult = () => { + if (!recentScanItem) { + return; + } + + void navigate({ to: resultRouteByStatus[recentScanItem.status] }); }; return ( @@ -147,7 +195,9 @@ export default function QRScanPage() {

QR 스캔하기

-

안전한 스캔을 위해 QR을 프레임 안에 맞춰 주세요

+

+ 안전한 스캔을 위해 QR 코드를 프레임 안에 맞춰 주세요. +

{cameraStatusText}

@@ -188,7 +238,7 @@ export default function QRScanPage() { />

- 라이브 카메라 프리뷰가 준비되면 초록색 버튼으로 현재 화면을 바로 촬영할 수 있습니다. + 라이브 카메라 미리보기가 준비되면 초록색 버튼으로 현재 화면을 바로 촬영할 수 있습니다.

@@ -197,32 +247,71 @@ export default function QRScanPage() {

최근 스캔 기록

- + 전체보기
- - - + -

{recentActivity.capturedAt}

-

{recentActivity.headline}

-

{recentActivity.summary}

- - {recentActivity.photoUrl ? ( -
- 최근 촬영 또는 업로드한 이미지 미리보기 + {recentScanItem ? ( + + ) : ( +
+

스캔 이력이 없습니다.

+

+ 최근 스캔 이력이 생기면 이곳에 가장 최신 항목이 표시됩니다. +

+

전체보기에서 스캔 목록을 확인할 수 있습니다.

- ) : null} + )} + +
diff --git a/src/pages/QRScan/hooks/useQRScanPage.ts b/src/pages/QRScan/hooks/useQRScanPage.ts index bc0b75c..34074a1 100644 --- a/src/pages/QRScan/hooks/useQRScanPage.ts +++ b/src/pages/QRScan/hooks/useQRScanPage.ts @@ -3,6 +3,13 @@ import type { ChangeEvent, RefObject } from 'react'; import { App } from 'antd'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { + defaultScanListUuid, + fetchScanListPageData, + getInitialScanListPageData, +} from '@/pages/ScanList/api/fetchScanListPageData'; +import type { ScanListItem } from '@/pages/ScanList/types/scanListPage.types'; + type CameraStatus = 'loading' | 'ready' | 'error'; type CaptureRecord = { @@ -17,12 +24,16 @@ type UseQRScanPageReturn = { cameraStatus: CameraStatus; cameraStatusText: string; captureRecord: CaptureRecord | null; + canNavigateHistory: boolean; fileInputRef: RefObject; handleCapturePhoto: () => Promise; handleGalleryFileChange: (event: ChangeEvent) => void; + handleShowNextHistoryItem: () => void; handleOpenGallery: () => void; + handleShowPreviousHistoryItem: () => void; isCapturing: boolean; isFlashVisible: boolean; + recentScanItem: ScanListItem | null; videoRef: RefObject; }; @@ -118,6 +129,12 @@ 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 [currentHistoryIndex, setCurrentHistoryIndex] = useState(0); + const recentScanItem = scanHistoryItems[currentHistoryIndex] ?? null; + const canNavigateHistory = scanHistoryItems.length > 1; const triggerCaptureFlash = useCallback(() => { if (flashTimerRef.current) { @@ -213,6 +230,68 @@ export function useQRScanPage(): UseQRScanPageReturn { }; }, [startCamera]); + useEffect(() => { + let isMounted = true; + + const loadRecentScanItems = async () => { + try { + const response = await fetchScanListPageData(defaultScanListUuid); + + if (isMounted) { + setScanHistoryItems(response.items); + setCurrentHistoryIndex((previousIndex) => { + if (response.items.length === 0) { + return 0; + } + + return Math.min(previousIndex, response.items.length - 1); + }); + } + } catch (error) { + console.error('Failed to load latest scan list items.', error); + + if (isMounted) { + const fallbackItems = getInitialScanListPageData(defaultScanListUuid).items; + + setScanHistoryItems(fallbackItems); + setCurrentHistoryIndex((previousIndex) => { + if (fallbackItems.length === 0) { + return 0; + } + + return Math.min(previousIndex, fallbackItems.length - 1); + }); + } + } + }; + + void loadRecentScanItems(); + + return () => { + isMounted = false; + }; + }, []); + + const handleShowPreviousHistoryItem = useCallback(() => { + if (scanHistoryItems.length <= 1) { + return; + } + + setCurrentHistoryIndex((previousIndex) => { + return (previousIndex - 1 + scanHistoryItems.length) % scanHistoryItems.length; + }); + }, [scanHistoryItems.length]); + + const handleShowNextHistoryItem = useCallback(() => { + if (scanHistoryItems.length <= 1) { + return; + } + + setCurrentHistoryIndex((previousIndex) => { + return (previousIndex + 1) % scanHistoryItems.length; + }); + }, [scanHistoryItems.length]); + const handleCapturePhoto = useCallback(async () => { if (cameraStatus !== 'ready') { await startCamera(); @@ -301,12 +380,16 @@ export function useQRScanPage(): UseQRScanPageReturn { cameraStatus, cameraStatusText, captureRecord, + canNavigateHistory, fileInputRef, handleCapturePhoto, handleGalleryFileChange, + handleShowNextHistoryItem, handleOpenGallery, + handleShowPreviousHistoryItem, isCapturing, isFlashVisible, + recentScanItem, videoRef, }; } diff --git a/src/pages/QRScan/styles/qrScanPage.css.ts b/src/pages/QRScan/styles/qrScanPage.css.ts index 6b772eb..8168462 100644 --- a/src/pages/QRScan/styles/qrScanPage.css.ts +++ b/src/pages/QRScan/styles/qrScanPage.css.ts @@ -3,6 +3,7 @@ import { keyframes, style, styleVariants } from '@vanilla-extract/css'; import { vars } from '@/vars.css'; const bpTablet = 'screen and (min-width: 768px)'; +const historyBadgeMinWidth = '86px'; const scanLineMotion = keyframes({ '0%': { @@ -412,18 +413,107 @@ export const viewAllLink = style({ export const historyCard = style({ position: 'relative', display: 'grid', - gap: vars.spacing.sm, - padding: '18px 16px 16px', + gridTemplateColumns: '40px minmax(0, 1fr) 40px', + alignItems: 'center', + gap: vars.spacing.xs, + padding: '18px 10px 16px', borderRadius: '16px', backgroundColor: '#eef2f6', boxShadow: 'inset 0 0 0 1px rgba(148, 163, 184, 0.08)', }); +export const historyCardInteractive = style({ + cursor: 'pointer', + transition: 'transform 0.18s ease, box-shadow 0.18s ease', + selectors: { + '&:hover': { + transform: 'translateY(-1px)', + boxShadow: 'inset 0 0 0 1px rgba(148, 163, 184, 0.08), 0 12px 24px rgba(15, 23, 42, 0.08)', + }, + '&:focus-visible': { + outline: `3px solid ${vars.colors.mainLightHover}`, + outlineOffset: '3px', + }, + }, +}); + +export const historyOpenButton = style({ + position: 'relative', + width: '100%', + minWidth: 0, + display: 'block', + alignSelf: 'stretch', + padding: 0, + border: 'none', + borderRadius: '12px', + background: 'transparent', + textAlign: 'left', +}); + +export const historyNavButton = style({ + width: '34px', + height: '34px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + borderRadius: '999px', + backgroundColor: 'rgba(255, 255, 255, 0.92)', + color: '#475569', + boxShadow: '0 8px 18px rgba(15, 23, 42, 0.08)', + cursor: 'pointer', + transition: + 'transform 160ms ease, background-color 160ms ease, color 160ms ease, opacity 160ms ease', + selectors: { + '&:hover': { + transform: 'translateY(-1px)', + backgroundColor: vars.colors.white, + color: vars.colors.black, + }, + '&:focus-visible': { + outline: `2px solid ${vars.colors.mainLightHover}`, + outlineOffset: '2px', + }, + '&:disabled, &:disabled:hover': { + opacity: 0.45, + backgroundColor: vars.colors.sub, + color: vars.colors.subDark, + cursor: 'not-allowed', + transform: 'none', + }, + }, +}); + +export const historyNavButtonLeft = style({ + justifySelf: 'start', +}); + +export const historyNavButtonRight = style({ + justifySelf: 'end', +}); + +export const historyNavIcon = style({ + width: '16px', + height: '16px', + display: 'inline-flex', + flexShrink: 0, +}); + +export const historyContent = style({ + minWidth: 0, + display: 'grid', + gap: vars.spacing.sm, + overflow: 'hidden', + paddingTop: '4px', + paddingLeft: '12px', + paddingRight: historyBadgeMinWidth, +}); + export const historyBadge = style({ position: 'absolute', top: 0, right: 0, - minWidth: '86px', + minWidth: historyBadgeMinWidth, height: '28px', display: 'inline-flex', alignItems: 'center', @@ -438,6 +528,21 @@ export const historyBadge = style({ letterSpacing: '0.18em', }); +export const historyBadgeTone = styleVariants({ + critical: { + backgroundColor: '#ff6b65', + color: vars.colors.white, + }, + safe: { + backgroundColor: vars.colors.success, + color: vars.colors.white, + }, + warning: { + backgroundColor: '#ffd84b', + color: '#3f3500', + }, +}); + export const historyBadgeIcon = style({ width: '12px', height: '12px', @@ -457,6 +562,9 @@ export const historyHeadline = style({ fontSize: vars.font.size.md, fontWeight: vars.font.weight.bold, lineHeight: 1.45, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', wordBreak: 'keep-all', }); @@ -468,6 +576,18 @@ export const historyStatus = style({ lineHeight: 1.4, }); +export const historyStatusTone = styleVariants({ + critical: { + color: vars.colors.error, + }, + safe: { + color: vars.colors.success, + }, + warning: { + color: vars.colors.warning, + }, +}); + export const historyThumbnail = style({ overflow: 'hidden', borderRadius: '12px', diff --git a/src/pages/ScanList/ScanListPage.tsx b/src/pages/ScanList/ScanListPage.tsx index 1b873d8..ebe8fba 100644 --- a/src/pages/ScanList/ScanListPage.tsx +++ b/src/pages/ScanList/ScanListPage.tsx @@ -1,3 +1,5 @@ +import { Link } from '@tanstack/react-router'; + import { qrIconByTone } from '@/shared/icon/resultIcons'; import AppHeader from '@/shared/ui/app-header'; @@ -6,6 +8,15 @@ import * as styles from './styles/scanListPage.css'; import type { ScanListStatus } from './types/scanListPage.types'; +const resultRouteByStatus: Record< + ScanListStatus, + '/result/safe' | '/result/warning' | '/result/critical' +> = { + safe: '/result/safe', + warning: '/result/warning', + critical: '/result/critical', +}; + const statusLabelByTone: Record = { safe: '안전', warning: '주의', @@ -99,32 +110,39 @@ export default function ScanListPage() { ) : (
    {scanListPageData.items.map((item) => ( -
  • - -
  • + + + + {statusLabelByTone[item.status]} - {statusLabelByTone[item.status]} - -
    - -
    +
    + +
    -
    -

    {item.url}

    -

    - - {item.scannedAt} -

    -
    +
    +

    {item.url}

    +

    + + {item.scannedAt} +

    +
    +
  • ))}
diff --git a/src/pages/ScanList/styles/scanListPage.css.ts b/src/pages/ScanList/styles/scanListPage.css.ts index b85d08e..95f36fa 100644 --- a/src/pages/ScanList/styles/scanListPage.css.ts +++ b/src/pages/ScanList/styles/scanListPage.css.ts @@ -77,6 +77,20 @@ export const card = style({ borderRadius: '30px', boxShadow: '0 12px 30px rgba(15, 23, 42, 0.07)', border: '1px solid rgba(15, 23, 42, 0.04)', + color: 'inherit', + cursor: 'pointer', + textDecoration: 'none', + transition: 'transform 0.18s ease, box-shadow 0.18s ease', + selectors: { + '&:hover': { + boxShadow: '0 16px 34px rgba(15, 23, 42, 0.1)', + transform: 'translateY(-2px)', + }, + '&:focus-visible': { + outline: '3px solid rgba(59, 130, 246, 0.28)', + outlineOffset: '4px', + }, + }, }); export const cardTone = styleVariants({ diff --git a/src/shared/component/safeSiteCard/safeSiteCard.css.ts b/src/shared/component/safeSiteCard/safeSiteCard.css.ts index d59e975..e8ea279 100644 --- a/src/shared/component/safeSiteCard/safeSiteCard.css.ts +++ b/src/shared/component/safeSiteCard/safeSiteCard.css.ts @@ -97,7 +97,7 @@ export const officialTone = styleVariants({ }, warning: { backgroundColor: 'rgba(242, 223, 13, 0.2)', - color: '#8A7800', + color: 'var.colors.warning', }, critical: { backgroundColor: 'rgba(242, 13, 13, 0.12)',