Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions api/analyses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
authenticatedApiRequest,
type ApiRequestOptions,
type ClerkTokenGetter,
} from '@/api/api-client';

export type AnalysisStatus = 'queued' | 'succeeded' | 'failed';
export type AnalysisVerdict = 'safe' | 'caution' | 'danger';

export type AnalysisReason = {
code: string;
stage: number;
weight: number;
message: string;
};

export type AnalysisResponse = {
analysisId: string;
status: AnalysisStatus;
originalUrl?: string;
finalUrl?: string;
verdict?: AnalysisVerdict;
score?: number;
summary?: string;
reasons?: AnalysisReason[];
analyzedAt?: string;
elapsedMs?: number;
errorCode?: string;
errorStage?: number;
errorMessage?: string;
};

type AnalysisRequestOptions = Pick<ApiRequestOptions, 'signal'>;

export function requestAnalysis(
getToken: ClerkTokenGetter,
url: string,
options: AnalysisRequestOptions = {},
) {
return authenticatedApiRequest<AnalysisResponse>(getToken, '/api/v1/analyses', {
...options,
method: 'POST',
body: { url },
});
}

export function fetchAnalysis(
getToken: ClerkTokenGetter,
analysisId: string,
options: AnalysisRequestOptions = {},
) {
return authenticatedApiRequest<AnalysisResponse>(
getToken,
`/api/v1/analyses/${encodeURIComponent(analysisId)}`,
options,
);
}
82 changes: 78 additions & 4 deletions app/(tabs)/(home)/scan-result-block.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,66 @@
import { useEffect } from 'react';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ResultStatusIcon } from '@/components/ui/result-status-icon';
import { ScanResultReason } from '@/components/ui/scan-result-reason';
import { getMockScanResultReason } from '@/constants/scan-result-reasons';
import { Colors, Typography } from '@/constants/theme';
import { useAnalysisResult } from '@/hooks/use-analysis-result';
import {
getAnalysisDisplayUrl,
getAnalysisReasonText,
getAnalysisResultPath,
getRouteParam,
} from '@/utils/analysis-result-display';

export default function ScanResultBlockScreen() {
const { url } = useLocalSearchParams<{ url: string }>();
// TODO: 테스트용 mock 판정 이유입니다. 백엔드 reason 응답 연동 시 제거합니다.
const reason = getMockScanResultReason('danger');
const {
analysisId: analysisIdParam,
url: urlParam,
} = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>();
const analysisId = getRouteParam(analysisIdParam);
const url = getRouteParam(urlParam);
const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId);
const displayUrl = getAnalysisDisplayUrl(analysis, url);
const reason = getAnalysisReasonText(analysis, getMockScanResultReason('danger'));
const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'danger');
const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading);

useEffect(() => {
if (!analysis?.verdict || analysis.verdict === 'danger') {
return;
}

router.replace({
pathname: getAnalysisResultPath(analysis.verdict),
params: {
url: analysis.originalUrl ?? url ?? '',
analysisId: analysis.analysisId,
verdict: analysis.verdict,
},
});
}, [analysis, url]);

if (isVerifyingAnalysis || shouldRedirectToVerdict) {
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: '寃€??寃곌낵',
headerBackTitle: '',
headerStyle: { backgroundColor: Colors.brand.background },
headerTitleStyle: { ...Typography.title, color: Colors.brand.text },
headerTintColor: Colors.brand.text,
headerShadowVisible: false,
}}
/>
<View style={styles.loadingContainer}>
<Text style={styles.statusText}>遺꾩꽍 寃곌낵瑜?遺덈윭?ㅻ뒗 以묒엯?덈떎.</Text>
</View>
</>
);
}

return (
<>
Expand Down Expand Up @@ -36,13 +88,16 @@ export default function ScanResultBlockScreen() {
{/* 결과 텍스트 */}
<Text style={styles.resultTitle}>차단된 위험 링크입니다.</Text>

{isLoading && <Text style={styles.statusText}>분석 결과를 불러오는 중입니다.</Text>}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}

<ScanResultReason reason={reason} style={styles.reasonCard} />

{/* 검사 대상 카드 */}
<View style={styles.card}>
<Text style={styles.cardLabel}>검사 대상</Text>
<Text style={styles.cardUrl} numberOfLines={1} ellipsizeMode="tail">
{url}
{displayUrl}
</Text>
</View>

Expand Down Expand Up @@ -88,6 +143,25 @@ const styles = StyleSheet.create({
reasonCard: {
marginBottom: 24,
},
statusText: {
...Typography.caption,
color: Colors.brand.textSecondary,
textAlign: 'center',
marginBottom: 10,
},
errorText: {
...Typography.caption,
color: Colors.brand.textWarning,
textAlign: 'center',
marginBottom: 10,
},
loadingContainer: {
flex: 1,
backgroundColor: Colors.brand.background,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
},
card: {
width: '100%',
backgroundColor: Colors.brand.surface,
Expand Down
122 changes: 104 additions & 18 deletions app/(tabs)/(home)/scan-result-caution.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { router, Stack, useLocalSearchParams } from 'expo-router';
import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ResultStatusIcon } from '@/components/ui/result-status-icon';
Expand All @@ -7,29 +7,64 @@ import { getMockScanResultReason } from '@/constants/scan-result-reasons';
import { Colors, Typography } from '@/constants/theme';
import { useSavedLinks } from '@/context/saved-links-context';
import { LinkSaveModal } from '@/components/ui/link-save-modal';
import { useAnalysisResult } from '@/hooks/use-analysis-result';
import {
getAnalysisDisplayUrl,
getAnalysisFinalUrl,
getAnalysisReasonText,
getAnalysisResultPath,
getRouteParam,
getSiteName,
} from '@/utils/analysis-result-display';

export default function ScanResultCautionScreen() {
const { url } = useLocalSearchParams<{ url: string }>();
const {
analysisId: analysisIdParam,
url: urlParam,
} = useLocalSearchParams<{ analysisId?: string | string[]; url?: string | string[] }>();
const { addLink } = useSavedLinks();
// TODO: 테스트용 mock 판정 이유입니다. 백엔드 reason 응답 연동 시 제거합니다.
const reason = getMockScanResultReason('caution');
const analysisId = getRouteParam(analysisIdParam);
const url = getRouteParam(urlParam);
const { analysis, isLoading, errorMessage } = useAnalysisResult(analysisId);
const displayUrl = getAnalysisDisplayUrl(analysis, url);
const finalUrl = getAnalysisFinalUrl(analysis, displayUrl);
const reason = getAnalysisReasonText(analysis, getMockScanResultReason('caution'));
const [saveModalVisible, setSaveModalVisible] = useState(false);
const shouldRedirectToVerdict = Boolean(analysis?.verdict && analysis.verdict !== 'caution');
const isVerifyingAnalysis = Boolean(analysisId) && !errorMessage && (!analysis?.verdict || isLoading);
const canSave = displayUrl.trim().length > 0 && !isLoading && !shouldRedirectToVerdict;

useEffect(() => {
if (!analysis?.verdict || analysis.verdict === 'caution') {
return;
}

router.replace({
pathname: getAnalysisResultPath(analysis.verdict),
params: {
url: analysis.originalUrl ?? url ?? '',
analysisId: analysis.analysisId,
verdict: analysis.verdict,
},
});
}, [analysis, url]);

const handleSave = (title: string) => {
const resolvedUrl = url ?? '';
if (!canSave) {
return;
}

// TODO: POST /api/v1/saved-links { analysisId } 호출 후 응답으로 교체
addLink({
id: Date.now(),
analysisId: `mock-${Date.now()}`,
analysisId: analysis?.analysisId ?? analysisId ?? `mock-${Date.now()}`,
categoryId: null,
originalUrl: resolvedUrl,
finalUrl: resolvedUrl || null,
originalUrl: displayUrl,
finalUrl: finalUrl || null,
title,
description: '저장된 링크입니다.',
siteName: (() => {
try { return new URL(resolvedUrl).hostname; } catch { return '알 수 없음'; }
})(),
verdict: 'caution',
description: analysis?.summary ?? '저장된 링크입니다.',
siteName: getSiteName(finalUrl || displayUrl),
verdict: analysis?.verdict ?? 'caution',
isBookmarked: false,
createdAt: new Date().toISOString(),
});
Expand All @@ -38,15 +73,36 @@ export default function ScanResultCautionScreen() {
};

const handleOpenUrl = async () => {
if (url) {
if (finalUrl) {
try {
await Linking.openURL(url);
await Linking.openURL(finalUrl);
} catch {
// URL을 열 수 없는 경우 무시
}
}
};

if (isVerifyingAnalysis || shouldRedirectToVerdict) {
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: '寃€??寃곌낵',
headerBackTitle: '',
headerStyle: { backgroundColor: Colors.brand.background },
headerTitleStyle: { ...Typography.title, color: Colors.brand.text },
headerTintColor: Colors.brand.text,
headerShadowVisible: false,
}}
/>
<View style={styles.loadingContainer}>
<Text style={styles.statusText}>遺꾩꽍 寃곌낵瑜?遺덈윭?ㅻ뒗 以묒엯?덈떎.</Text>
</View>
</>
);
}

return (
<>
<Stack.Screen
Expand All @@ -73,19 +129,27 @@ export default function ScanResultCautionScreen() {
{/* 결과 텍스트 */}
<Text style={styles.resultTitle}>주의가 필요한 링크입니다.</Text>

{isLoading && <Text style={styles.statusText}>분석 결과를 불러오는 중입니다.</Text>}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}

<ScanResultReason reason={reason} style={styles.reasonCard} />

{/* 검사 대상 카드 */}
<View style={styles.card}>
<Text style={styles.cardLabel}>검사 대상</Text>
<Text style={styles.cardUrl} numberOfLines={1} ellipsizeMode="tail">
{url}
{displayUrl}
</Text>
</View>

{/* 버튼 영역 */}
<View style={styles.buttonArea}>
<TouchableOpacity style={styles.cautionButton} onPress={() => setSaveModalVisible(true)} activeOpacity={0.8}>
<TouchableOpacity
style={[styles.cautionButton, !canSave && styles.disabledButton]}
onPress={() => setSaveModalVisible(true)}
activeOpacity={0.8}
disabled={!canSave}
>
<Text style={styles.cautionButtonText}>주의 후 저장</Text>
</TouchableOpacity>

Expand All @@ -97,7 +161,7 @@ export default function ScanResultCautionScreen() {

<LinkSaveModal
visible={saveModalVisible}
url={url ?? ''}
url={displayUrl}
onCancel={() => setSaveModalVisible(false)}
onSave={handleSave}
/>
Expand Down Expand Up @@ -132,6 +196,25 @@ const styles = StyleSheet.create({
reasonCard: {
marginBottom: 24,
},
statusText: {
...Typography.caption,
color: Colors.brand.textSecondary,
textAlign: 'center',
marginBottom: 10,
},
errorText: {
...Typography.caption,
color: Colors.brand.textWarning,
textAlign: 'center',
marginBottom: 10,
},
loadingContainer: {
flex: 1,
backgroundColor: Colors.brand.background,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
},
card: {
width: '100%',
backgroundColor: Colors.brand.surface,
Expand Down Expand Up @@ -165,6 +248,9 @@ const styles = StyleSheet.create({
...Typography.section,
color: Colors.brand.text,
},
disabledButton: {
opacity: 0.45,
},
secondaryButton: {
width: '100%',
height: 56,
Expand Down
Loading
Loading