From 0e485464fd6c9b17c5ec8393972d8a5063e16268 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 29 May 2026 23:28:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=EB=90=9C=20URL?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=B2=B4=ED=81=AC=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/saved-links.ts | 16 ++++++++ app/(tabs)/(home)/add-link.tsx | 70 +++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/api/saved-links.ts b/api/saved-links.ts index 91425fd..0a5e2a9 100644 --- a/api/saved-links.ts +++ b/api/saved-links.ts @@ -54,6 +54,10 @@ export type SavedLinkTitleUpdateResponse = { title: string; }; +export type SavedLinkUrlCheckResponse = { + exists: boolean; +}; + type SavedLinkRequestOptions = Pick; export function createSavedLink( @@ -80,6 +84,18 @@ export function fetchSavedLinks( ); } +export function checkSavedLinkUrl( + getToken: ClerkTokenGetter, + url: string, + options: SavedLinkRequestOptions = {}, +) { + return authenticatedApiRequest( + getToken, + `/api/v1/saved-links/check?url=${encodeURIComponent(url)}`, + options, + ); +} + export function deleteSavedLink(getToken: ClerkTokenGetter, id: number) { return authenticatedApiRequest( getToken, diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index b8250f1..7724f57 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useAuth } from '@clerk/expo'; import { useFocusEffect } from '@react-navigation/native'; import { KeyboardAvoidingView, @@ -12,6 +13,8 @@ import { } from 'react-native'; import { Stack, router, useLocalSearchParams } from 'expo-router'; +import { ApiError } from '@/api/api-client'; +import { checkSavedLinkUrl } from '@/api/saved-links'; import { ScanButton } from '@/components/ui/scan-button'; import { Colors, Typography } from '@/constants/theme'; import { useGuardedPress } from '@/utils/press-guard'; @@ -29,6 +32,7 @@ function getSharedUrlParam(value: string | string[] | undefined): string { } export default function AddLinkScreen() { + const { getToken, isLoaded, isSignedIn } = useAuth(); const { sharedUrl } = useLocalSearchParams<{ sharedUrl?: string }>(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const initialSharedUrl = getSharedUrlParam(sharedUrl); @@ -37,6 +41,11 @@ export default function AddLinkScreen() { const isCompact = windowWidth < COMPACT_WIDTH || windowHeight <= SHORT_SCREEN_HEIGHT; const [isNavigating, setIsNavigating] = useState(false); const isNavigatingRef = useRef(false); + const getTokenRef = useRef(getToken); + + useEffect(() => { + getTokenRef.current = getToken; + }, [getToken]); useFocusEffect( useCallback(() => { @@ -56,7 +65,7 @@ export default function AddLinkScreen() { setError(''); }, [sharedUrl]); - const handleScan = () => { + const handleScan = async () => { if (isNavigatingRef.current) { return; } @@ -76,10 +85,36 @@ export default function AddLinkScreen() { return; } + if (!isLoaded) { + setError('로그인 상태를 확인하고 있습니다. 잠시 후 다시 시도해주세요.'); + return; + } + + if (!isSignedIn) { + setError('로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'); + return; + } + setError(''); isNavigatingRef.current = true; setIsNavigating(true); - router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); + + try { + const response = await checkSavedLinkUrl(() => getTokenRef.current(), normalizedUrl); + + if (response.exists) { + setError('이미 저장된 링크입니다.'); + isNavigatingRef.current = false; + setIsNavigating(false); + return; + } + + router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); + } catch (error) { + setError(getSavedLinkUrlCheckErrorMessage(error)); + isNavigatingRef.current = false; + setIsNavigating(false); + } }; const handleChangeUrl = (value: string) => { @@ -88,7 +123,7 @@ export default function AddLinkScreen() { }; const hasError = error.length > 0; - const scanDisabled = !url.trim() || isNavigating; + const scanDisabled = !url.trim() || isNavigating || !isLoaded; const guardedClearUrl = useGuardedPress(() => { setUrl(''); setError(''); @@ -141,7 +176,9 @@ export default function AddLinkScreen() { autoCorrect={false} keyboardType="url" returnKeyType="search" - onSubmitEditing={handleScan} + onSubmitEditing={() => { + void handleScan(); + }} /> {url.length > 0 && ( )} - + { + void handleScan(); + }} + disabled={scanDisabled} + /> {/* 에러 메시지 */} @@ -173,6 +215,24 @@ export default function AddLinkScreen() { ); } +function getSavedLinkUrlCheckErrorMessage(error: unknown) { + if (error instanceof ApiError) { + if (error.status === 401 || error.status === 403) { + return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + } + + return error.message || '저장된 링크 확인에 실패했습니다. 잠시 후 다시 시도해주세요.'; + } + + if (error instanceof Error) { + if (error.message === 'Missing Clerk session token') { + return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; + } + } + + return '저장된 링크 확인에 실패했습니다. 잠시 후 다시 시도해주세요.'; +} + const styles = StyleSheet.create({ safeArea: { flex: 1, From 23457f64693e351da3746901a1061cc8266827c6 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sun, 31 May 2026 19:59:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20URL=20=EC=A4=91=EB=B3=B5=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=9D=91=EB=8B=B5=20=EC=B5=9C=EC=8B=A0=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/add-link.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index 7724f57..1a836ce 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -41,6 +41,7 @@ export default function AddLinkScreen() { const isCompact = windowWidth < COMPACT_WIDTH || windowHeight <= SHORT_SCREEN_HEIGHT; const [isNavigating, setIsNavigating] = useState(false); const isNavigatingRef = useRef(false); + const urlRef = useRef(initialSharedUrl); const getTokenRef = useRef(getToken); useEffect(() => { @@ -61,6 +62,7 @@ export default function AddLinkScreen() { return; } + urlRef.current = nextSharedUrl; setUrl(nextSharedUrl); setError(''); }, [sharedUrl]); @@ -101,6 +103,13 @@ export default function AddLinkScreen() { try { const response = await checkSavedLinkUrl(() => getTokenRef.current(), normalizedUrl); + const currentNormalizedUrl = normalizeHttpUrlInput(urlRef.current.trim()); + + if (currentNormalizedUrl !== normalizedUrl) { + isNavigatingRef.current = false; + setIsNavigating(false); + return; + } if (response.exists) { setError('이미 저장된 링크입니다.'); @@ -118,6 +127,7 @@ export default function AddLinkScreen() { }; const handleChangeUrl = (value: string) => { + urlRef.current = value; setUrl(value); if (error) setError(''); }; @@ -125,6 +135,7 @@ export default function AddLinkScreen() { const hasError = error.length > 0; const scanDisabled = !url.trim() || isNavigating || !isLoaded; const guardedClearUrl = useGuardedPress(() => { + urlRef.current = ''; setUrl(''); setError(''); }, { lockMs: 250 });