diff --git a/apps/app/assets/images/share-bottom-sheet/login-tag.png b/apps/app/assets/images/share-bottom-sheet/login-tag.png new file mode 100644 index 00000000..1e1e40fe Binary files /dev/null and b/apps/app/assets/images/share-bottom-sheet/login-tag.png differ diff --git a/apps/app/components/ShareBottomSheet.tsx b/apps/app/components/ShareBottomSheet.tsx index 10d01b8c..8d5274bd 100644 --- a/apps/app/components/ShareBottomSheet.tsx +++ b/apps/app/components/ShareBottomSheet.tsx @@ -4,7 +4,26 @@ import { type ReactNode, useEffect, useState } from 'react'; import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { postWishLinkFromShare } from '@/utils/postWishLinkFromShare'; +import { type ShareFailureReasonT, postWishLinkFromShare } from '@/utils/postWishLinkFromShare'; + +/** 실패 사유별 서브 문구. 서버 카탈로그와 별개로 시안 문구를 그대로 쓴다. */ +const FAILURE_DESCRIPTION: Record = { + unauthenticated: '', + sessionExpired: '로그인이 만료됐어요', + network: '네트워크 연결을 확인해주세요', + server: '일시적인 오류가 발생했어요', +}; + +/** 로그인으로 유도할 사유 — 재시도해도 결과가 같다 */ +const LOGIN_REQUIRED_REASONS: ShareFailureReasonT[] = ['unauthenticated', 'sessionExpired']; + +/** 재시도는 1회까지. 또 실패하면 확인 버튼만 남긴다. */ +const MAX_RETRY_COUNT = 1; + +type SheetStateT = + | { status: 'loading' } + | { status: 'success' } + | { status: 'failure'; reason: ShareFailureReasonT; retryable: boolean }; export default function ShareBottomSheet(props: ShareExtensionProps) { return ( @@ -15,13 +34,17 @@ export default function ShareBottomSheet(props: ShareExtensionProps) { } function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { - const [sheetStatus, setSheetStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [sheetState, setSheetState] = useState({ status: 'loading' }); + const [retryCount, setRetryCount] = useState(0); - const handleOpenWishlist = () => { - /** openHostApp path 규칙: `/{path}?{query}` — `web=...`만 넘기면 `/web=...` 라우트로 해석됨 */ - openHostApp(`/?web=${encodeURIComponent('/archive/wish')}`); + /** openHostApp path 규칙: `/{path}?{query}` — `web=...`만 넘기면 `/web=...` 라우트로 해석됨 */ + const openHostAppAt = (webPath: string) => { + openHostApp(`/?web=${encodeURIComponent(webPath)}`); }; + const handleOpenWishlist = () => openHostAppAt('/archive/wish'); + const handleOpenLogin = () => openHostAppAt('/login'); + useEffect(() => { /** * NOTE: 웹은 url, 앱은 text로 링크와 상품 설명이 함께 오는 경우가 많음 @@ -33,8 +56,9 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { const urlFromText = text?.match(/https?:\/\/[^\s]+/)?.[0]?.replace(/[),.]+$/, ''); const productUrl = url ?? urlFromText; + /** 링크를 못 찾은 경우 — 공유된 내용이 그대로라 다시 눌러도 같다 */ if (!productUrl) { - setSheetStatus('error'); + setSheetState({ status: 'failure', reason: 'server', retryable: false }); return; } @@ -45,12 +69,11 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { if (!isMounted) return; - if (result.ok) { - setSheetStatus('success'); - return; - } - - setSheetStatus('error'); + setSheetState( + result.ok + ? { status: 'success' } + : { status: 'failure', reason: result.reason, retryable: result.retryable } + ); }; void registerWish(); @@ -58,15 +81,21 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { return () => { isMounted = false; }; - }, [url, text]); + // retryCount 가 바뀌면 같은 링크로 다시 등록을 시도한다 + }, [url, text, retryCount]); - if (sheetStatus === 'loading') + const handleRetry = () => { + setSheetState({ status: 'loading' }); + setRetryCount(count => count + 1); + }; + + if (sheetState.status === 'loading') return ( - 위시템을 담고 있어요 + 위시를 담고 있어요 @@ -80,41 +109,83 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { ); - if (sheetStatus === 'error') + if (sheetState.status === 'failure') { + const { reason, retryable } = sheetState; + const isLoginRequired = LOGIN_REQUIRED_REASONS.includes(reason); + /** 토큰 자체가 없으면 실패가 아니라 로그인 유도 화면 (세션 만료는 실패 화면 + 로그인 버튼) */ + const isLoginPrompt = reason === 'unauthenticated'; + const canRetry = retryable && retryCount < MAX_RETRY_COUNT; + return ( close()}> - - 위시템을 저장하지 못했어요 - - - - - - + + + {isLoginPrompt ? '위시를 담으려면 로그인 해주세요' : '위시를 저장하지 못했어요'} + + {FAILURE_DESCRIPTION[reason] ? ( + + {FAILURE_DESCRIPTION[reason]} + + ) : null} - close()}> - - 확인 - - + {isLoginPrompt ? ( + + + + ) : ( + + + + + + )} + + {isLoginRequired || canRetry ? ( + + close()}> + + {isLoginRequired ? '나중에 할게요' : '확인'} + + + + + + {isLoginRequired ? '로그인하기' : '다시 시도'} + + + + ) : ( + close()}> + + 확인 + + + )} ); + } return ( close()}> - 위시템을 저장했어요 + 위시를 저장 했어요 @@ -129,9 +200,9 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { /> - + - 위시템 보러가기 + 위시 보러가기 @@ -215,9 +286,23 @@ const styles = StyleSheet.create({ borderRadius: 24, marginBottom: 20, }, + titleGroup: { + alignItems: 'center', + gap: 4, + }, title: { fontSize: 20, + lineHeight: 28, fontWeight: 'bold', + color: '#2D3037', + textAlign: 'center', + }, + description: { + fontSize: 16, + lineHeight: 22, + fontWeight: '500', + color: '#686F7E', + textAlign: 'center', }, image: { width: 145, @@ -225,6 +310,12 @@ const styles = StyleSheet.create({ marginTop: 52, marginBottom: 45, }, + /** 시안 태그 실측 117.6x142.3 — 169px 영역 안에 여백 13 을 두고 들어간다 */ + loginImage: { + width: 118, + height: 143, + marginVertical: 13, + }, imageContainer: { position: 'relative', }, @@ -244,17 +335,40 @@ const styles = StyleSheet.create({ backgroundColor: '#F4F4F6', }, - button: { + /** 시안 버튼 폭 175/176 + gap 12 — 기기 폭에 맞춰 균등 분할한다 */ + buttonRow: { + flexDirection: 'row', + gap: 12, width: '100%', + }, + button: { height: 54, - backgroundColor: '#191B1F', borderRadius: 12, alignItems: 'center', justifyContent: 'center', }, + buttonFull: { + width: '100%', + backgroundColor: '#191B1F', + }, + buttonPrimary: { + flex: 1, + backgroundColor: '#191B1F', + }, + buttonSecondary: { + flex: 1, + backgroundColor: '#E9E9ED', + }, buttonText: { color: '#FFFFFF', fontSize: 16, + lineHeight: 22, + fontWeight: '600', + }, + buttonSecondaryText: { + color: '#686F7E', + fontSize: 16, + lineHeight: 22, fontWeight: '600', }, diff --git a/apps/app/utils/postWishLinkFromShare.ts b/apps/app/utils/postWishLinkFromShare.ts index f42c99da..040d40f0 100644 --- a/apps/app/utils/postWishLinkFromShare.ts +++ b/apps/app/utils/postWishLinkFromShare.ts @@ -2,7 +2,28 @@ import { postTokenRefresh } from '@/apis/postTokenRefresh'; import { TokenStorage } from './tokenStorage'; -type PostWishLinkFromShareResultT = { ok: true } | { ok: false; message: string }; +/** + * 실패 사유. 문구는 시트가 정하고 여기서는 사유만 식별한다. + * - `unauthenticated` 토큰 없음 → 로그인 유도 + * - `sessionExpired` refresh 실패 → 로그인 유도 + * - `network` 네트워크 예외 → 재시도 가능 + * - `server` 서버 오류 → 재시도 가능 + */ +export type ShareFailureReasonT = 'unauthenticated' | 'sessionExpired' | 'network' | 'server'; + +export type PostWishLinkFromShareResultT = + | { ok: true } + | { + ok: false; + reason: ShareFailureReasonT; + /** + * 다시 시도할 가치가 있는지. 같은 reason 이라도 갈린다 — + * 서버 5xx 는 재시도 가능하지만 설정 누락은 몇 번을 눌러도 같다. + */ + retryable: boolean; + /** 서버가 내려준 에러 코드 (로컬 판단 실패면 없음) */ + code?: string; + }; const postWishLink = async (productUrl: string, accessToken: string) => fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/v1/wishlists`, { @@ -15,17 +36,27 @@ const postWishLink = async (productUrl: string, accessToken: string) => body: JSON.stringify({ url: productUrl }), }); +/** 실패 응답 body 의 code 만 뽑는다. 파싱 실패는 무시 — 사유는 status 로 이미 갈렸다. */ +const readErrorCode = async (response: Response): Promise => { + try { + const body = (await response.json()) as { code?: string | null }; + return body.code ?? null; + } catch { + return null; + } +}; + /** Share Extension에서 링크로 위시 등록 */ export const postWishLinkFromShare = async ( productUrl: string ): Promise => { - if (!process.env.EXPO_PUBLIC_API_URL) - return { ok: false, message: 'API 주소가 설정되지 않았어요' }; + /** 빌드 설정 누락 — 다시 눌러도 결과가 같다 */ + if (!process.env.EXPO_PUBLIC_API_URL) return { ok: false, reason: 'server', retryable: false }; const accessToken = await TokenStorage.getAccessToken(); const refreshToken = await TokenStorage.getRefreshToken(); - if (!accessToken) return { ok: false, message: '로그인이 필요해요' }; + if (!accessToken) return { ok: false, reason: 'unauthenticated', retryable: false }; try { /** 위시 등록 시도 */ @@ -38,7 +69,7 @@ export const postWishLinkFromShare = async ( if (!refreshResponse.ok) { /** 죽은 토큰으로 재시도가 반복되지 않도록 정리 */ if (refreshResponse.status === 401) await TokenStorage.clearTokens(); - return { ok: false, message: '로그인이 만료됐어요' }; + return { ok: false, reason: 'sessionExpired', retryable: false }; } /** 토큰 갱신 후 토큰 저장 */ @@ -51,10 +82,17 @@ export const postWishLinkFromShare = async ( postWishResponse = await postWishLink(productUrl, refreshBody.data.accessToken); } - if (!postWishResponse.ok) return { ok: false, message: '요청 처리 중 오류가 발생했습니다.' }; + if (!postWishResponse.ok) { + const code = await readErrorCode(postWishResponse); + /** refreshToken 이 없어 갱신조차 못 한 401 도 세션 만료로 본다 */ + const reason: ShareFailureReasonT = + postWishResponse.status === 401 ? 'sessionExpired' : 'server'; + + return { ok: false, reason, retryable: reason === 'server', ...(code ? { code } : {}) }; + } return { ok: true }; } catch { - return { ok: false, message: '네트워크 오류가 발생했어요' }; + return { ok: false, reason: 'network', retryable: true }; } };