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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
194 changes: 154 additions & 40 deletions apps/app/components/ShareBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ShareFailureReasonT, string> = {
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 (
Expand All @@ -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<SheetStateT>({ 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로 링크와 상품 설명이 함께 오는 경우가 많음
Expand All @@ -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;
}

Expand All @@ -45,28 +69,33 @@ 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();

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 (
<SheetContainer>
<View style={styles.handle} />

<Text allowFontScaling={false} style={styles.title}>
위시템을 담고 있어요
위시를 담고 있어요
</Text>

<View style={styles.imageContainer}>
Expand All @@ -80,41 +109,83 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) {
</SheetContainer>
);

if (sheetStatus === 'error')
if (sheetState.status === 'failure') {
const { reason, retryable } = sheetState;
const isLoginRequired = LOGIN_REQUIRED_REASONS.includes(reason);
/** 토큰 자체가 없으면 실패가 아니라 로그인 유도 화면 (세션 만료는 실패 화면 + 로그인 버튼) */
const isLoginPrompt = reason === 'unauthenticated';
Comment on lines +114 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

sessionExpired에도 로그인 유도 화면을 표시하세요.

LOGIN_REQUIRED_REASONSsessionExpired를 로그인 유도 사유로 분류합니다. 그러나 Line 114는 unauthenticated만 로그인 유도 화면으로 처리합니다. 따라서 세션 만료는 일반 실패 제목과 오류 이미지를 표시합니다. isLoginPrompt 조건에 isLoginRequired를 사용하세요.

수정 예시
-    const isLoginPrompt = reason === 'unauthenticated';
+    const isLoginPrompt = isLoginRequired;

Also applies to: 123-136

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/components/ShareBottomSheet.tsx` around lines 112 - 114, Update the
isLoginPrompt condition in ShareBottomSheet to use isLoginRequired, so both
unauthenticated and sessionExpired reasons display the login prompt instead of
the generic failure state. Preserve the existing LOGIN_REQUIRED_REASONS
classification and downstream rendering behavior.

const canRetry = retryable && retryCount < MAX_RETRY_COUNT;

return (
<SheetContainer onDimPress={() => close()}>
<View style={styles.handle} />

<Text allowFontScaling={false} style={styles.title}>
위시템을 저장하지 못했어요
</Text>

<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/share-bottom-sheet/basket.png')}
style={styles.image}
/>

<Image
source={require('@/assets/images/share-bottom-sheet/icon-error.png')}
style={styles.icon}
/>
<View style={styles.titleGroup}>
<Text allowFontScaling={false} style={styles.title}>
{isLoginPrompt ? '위시를 담으려면 로그인 해주세요' : '위시를 저장하지 못했어요'}
</Text>
{FAILURE_DESCRIPTION[reason] ? (
<Text allowFontScaling={false} style={styles.description}>
{FAILURE_DESCRIPTION[reason]}
</Text>
) : null}
</View>

<Pressable style={styles.button} onPress={() => close()}>
<Text allowFontScaling={false} style={styles.buttonText}>
확인
</Text>
</Pressable>
{isLoginPrompt ? (
<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/share-bottom-sheet/login-tag.png')}
style={styles.loginImage}
/>
</View>
) : (
<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/share-bottom-sheet/basket.png')}
style={styles.image}
/>

<Image
source={require('@/assets/images/share-bottom-sheet/icon-error.png')}
style={styles.icon}
/>
</View>
)}

{isLoginRequired || canRetry ? (
<View style={styles.buttonRow}>
<Pressable style={[styles.button, styles.buttonSecondary]} onPress={() => close()}>
<Text allowFontScaling={false} style={styles.buttonSecondaryText}>
{isLoginRequired ? '나중에 할게요' : '확인'}
</Text>
</Pressable>

<Pressable
style={[styles.button, styles.buttonPrimary]}
onPress={isLoginRequired ? handleOpenLogin : handleRetry}
>
<Text allowFontScaling={false} style={styles.buttonText}>
{isLoginRequired ? '로그인하기' : '다시 시도'}
</Text>
</Pressable>
</View>
) : (
<Pressable style={[styles.button, styles.buttonFull]} onPress={() => close()}>
<Text allowFontScaling={false} style={styles.buttonText}>
확인
</Text>
</Pressable>
)}
</SheetContainer>
);
}

return (
<SheetContainer onDimPress={() => close()}>
<View style={styles.handle} />

<Text allowFontScaling={false} style={styles.title}>
위시템을 저장했어요
위시를 저장 했어요
</Text>

<View style={styles.imageContainer}>
Expand All @@ -129,9 +200,9 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) {
/>
</View>

<Pressable style={styles.button} onPress={handleOpenWishlist}>
<Pressable style={[styles.button, styles.buttonFull]} onPress={handleOpenWishlist}>
<Text allowFontScaling={false} style={styles.buttonText}>
위시템 보러가기
위시 보러가기
</Text>
</Pressable>
</SheetContainer>
Expand Down Expand Up @@ -215,16 +286,36 @@ 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,
height: 105,
marginTop: 52,
marginBottom: 45,
},
/** 시안 태그 실측 117.6x142.3 — 169px 영역 안에 여백 13 을 두고 들어간다 */
loginImage: {
width: 118,
height: 143,
marginVertical: 13,
},
imageContainer: {
position: 'relative',
},
Expand All @@ -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',
},

Expand Down
52 changes: 45 additions & 7 deletions apps/app/utils/postWishLinkFromShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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`, {
Expand All @@ -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<string | null> => {
try {
const body = (await response.json()) as { code?: string | null };
return body.code ?? null;
} catch {
return null;
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Share Extension에서 링크로 위시 등록 */
export const postWishLinkFromShare = async (
productUrl: string
): Promise<PostWishLinkFromShareResultT> => {
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 };
Comment on lines 56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

토큰 저장소 예외를 실패 결과로 변환하세요.

Line 56-57의 토큰 조회는 try 밖에서 실행됩니다. 저장소 조회가 reject되면 postWishLinkFromShare가 결과를 반환하지 않고 reject됩니다. apps/app/components/ShareBottomSheet.tsxregisterWish는 이 예외를 처리하지 않으므로 바텀시트가 실패 상태로 전환되지 않습니다.

토큰 조회를 예외 처리 범위에 포함하세요. 저장소 오류를 적절한 ShareFailureReasonTretryable 정책으로 매핑하세요. 실제 비로그인과 저장소 장애를 unauthenticated로 합치지 마세요.

apps/app/utils/tokenStorage.ts:30-36의 비동기 조회와 apps/app/components/ShareBottomSheet.tsx:67-77의 호출 계약에 근거합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/utils/postWishLinkFromShare.ts` around lines 56 - 59, Include the
TokenStorage.getAccessToken and getRefreshToken calls within the try/catch in
postWishLinkFromShare so rejected storage reads return a failure result instead
of propagating. Map storage errors to the appropriate distinct
ShareFailureReasonT and retryable policy, preserving unauthenticated only for a
missing access token and the existing registerWish contract in ShareBottomSheet.


try {
/** 위시 등록 시도 */
Expand All @@ -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 };
}

/** 토큰 갱신 후 토큰 저장 */
Expand All @@ -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 };
Comment on lines 95 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

네트워크 예외와 내부 오류를 구분하세요.

Line 95-96의 catch는 네트워크 예외뿐 아니라 refreshResponse.json(), TokenStorage.setTokens(), TokenStorage.clearTokens()의 실패도 잡습니다. 잘못된 갱신 응답이나 저장소 오류가 networkretryable: true로 변환됩니다.

전송 오류만 이 경로에서 처리하세요. JSON 파싱 오류와 저장소 오류는 각각의 실패 사유와 재시도 정책으로 분류하세요.

PR 목표의 실패 사유 구분 요구사항에 근거합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/app/utils/postWishLinkFromShare.ts` around lines 95 - 96, Update the
error handling in postWishLinkFromShare so only the request’s network/transport
failure returns reason "network" with retryable true. Handle
refreshResponse.json() parsing failures and
TokenStorage.setTokens()/clearTokens() failures separately, preserving their
appropriate failure reasons and retry policies instead of converting them to
network errors.

}
};
Loading