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
119 changes: 87 additions & 32 deletions frontend/src/hooks/useNavigator.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { useNavigate } from 'react-router-dom';
import { renderHook, RenderHookResult } from '@testing-library/react';
import useNavigator from '@/hooks/useNavigator';
import isInAppWebView from '@/utils/isInAppWebView';
import {
requestNavigateWebview,
requestOpenExternalUrl,
} from '@/utils/webviewBridge';

jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
}));
jest.mock('@/utils/isInAppWebView');
jest.mock('@/utils/webviewBridge', () => ({
requestNavigateWebview: jest.fn(),
requestOpenExternalUrl: jest.fn(),
}));

const mockIsInAppWebView = isInAppWebView as jest.Mock;
const mockRequestNavigateWebview = requestNavigateWebview as jest.Mock;
const mockRequestOpenExternalUrl = requestOpenExternalUrl as jest.Mock;

describe('useNavigator - 사용자가 링크를 클릭했을 때', () => {
const mockNavigate = jest.fn();
Expand All @@ -14,13 +28,14 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => {
beforeEach(() => {
jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
mockIsInAppWebView.mockReturnValue(false);

Object.defineProperty(window, 'location', {
writable: true,
value: { href: '' },
});
window.open = jest.fn();

// given
handleLink = renderHook(() => useNavigator());
});

Expand All @@ -33,19 +48,15 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => {

describe('링크가 비어있으면', () => {
it('아무 페이지로도 이동하지 않는다', () => {
// When
handleLink.result.current('');

// Then
expect(mockNavigate).not.toHaveBeenCalled();
expect(window.location.href).toBe('');
});

it('공백만 있는 링크도 이동하지 않는다', () => {
// When
handleLink.result.current(' ');

// Then
expect(mockNavigate).not.toHaveBeenCalled();
expect(window.location.href).toBe('');
});
Expand All @@ -58,47 +69,91 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => {
['vbscript', 'vbscript:msgbox("XSS")'],
['대문자 javascript', 'JAVASCRIPT:alert("XSS")'],
])('%s 프로토콜 링크는 차단된다', (_, maliciousUrl) => {
// When
handleLink.result.current(maliciousUrl);

// Then
expect(mockNavigate).not.toHaveBeenCalled();
expect(window.location.href).toBe('');
});
});

describe('외부 사이트 링크를 클릭하면', () => {
it.each([
['https', 'https://example.com'],
['http', 'http://example.com'],
['App Store (itms-apps)', 'itms-apps://itunes.apple.com/app/123456'],
])('%s 링크는 해당 사이트로 이동한다', (_, externalUrl) => {
// When
handleLink.result.current(externalUrl);

// Then
expect(window.location.href).toBe(externalUrl);
expect(mockNavigate).not.toHaveBeenCalled();
describe('일반 웹에서', () => {
describe('외부 링크를 클릭하면', () => {
it.each([
['https', 'https://example.com'],
['http', 'http://example.com'],
['itms-apps', 'itms-apps://itunes.apple.com/app/123456'],
])('%s 링크는 window.location.href로 이동한다', (_, externalUrl) => {
handleLink.result.current(externalUrl);

expect(window.location.href).toBe(externalUrl);
expect(mockNavigate).not.toHaveBeenCalled();
});
});

describe('내부 경로를 클릭하면', () => {
it('React Router로 이동한다', () => {
handleLink.result.current('/introduce');

expect(mockNavigate).toHaveBeenCalledWith('/introduce');
expect(window.location.href).toBe('');
});

it('상대 경로도 React Router로 이동한다', () => {
handleLink.result.current('about');

expect(mockNavigate).toHaveBeenCalledWith('about');
expect(window.location.href).toBe('');
});
});
});

describe('내부 페이지 링크를 클릭하면', () => {
it('소개 페이지로 이동할 수 있다', () => {
// When
handleLink.result.current('/introduce');
describe('웹뷰에서', () => {
beforeEach(() => {
mockIsInAppWebView.mockReturnValue(true);
handleLink = renderHook(() => useNavigator());
});

// Then
expect(mockNavigate).toHaveBeenCalledWith('/introduce');
expect(window.location.href).toBe('');
describe('외부 링크를 클릭하면', () => {
it('http/https 링크는 requestOpenExternalUrl로 앱에 위임한다', () => {
mockRequestOpenExternalUrl.mockReturnValue(true);

handleLink.result.current('https://example.com');

expect(mockRequestOpenExternalUrl).toHaveBeenCalledWith(
'https://example.com',
);
expect(window.location.href).toBe('');
});

it('itms-apps:// 링크는 requestOpenExternalUrl이 false를 반환하면 window.open으로 폴백한다', () => {
mockRequestOpenExternalUrl.mockReturnValue(false);

handleLink.result.current('itms-apps://itunes.apple.com/app/123456');

expect(mockRequestOpenExternalUrl).toHaveBeenCalled();
expect(window.open).toHaveBeenCalledWith(
'itms-apps://itunes.apple.com/app/123456',
);
});
});

it('상대 경로로도 이동할 수 있다', () => {
// When
handleLink.result.current('about');
describe('내부 경로를 클릭하면', () => {
it('requestNavigateWebview로 앱에 위임한다', () => {
handleLink.result.current('/promotions/123');

// Then
expect(mockNavigate).toHaveBeenCalledWith('about');
expect(window.location.href).toBe('');
expect(mockRequestNavigateWebview).toHaveBeenCalledWith(
'promotions/123',
);
expect(mockNavigate).not.toHaveBeenCalled();
});

it('leading slash를 제거한 slug로 전달한다', () => {
handleLink.result.current('/festival-introduction');

expect(mockRequestNavigateWebview).toHaveBeenCalledWith(
'festival-introduction',
);
});
});
});
});
39 changes: 18 additions & 21 deletions frontend/src/hooks/useNavigator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import isInAppWebView from '@/utils/isInAppWebView';
import { requestOpenExternalUrl } from '@/utils/webviewBridge';
import {
requestNavigateWebview,
requestOpenExternalUrl,
} from '@/utils/webviewBridge';

const toSlug = (path: string) => path.replace(/^\//, '');

const useNavigator = () => {
const navigate = useNavigate();
Expand All @@ -10,28 +15,20 @@ const useNavigator = () => {
(url: string) => {
const trimmedUrl = url?.trim();
if (!trimmedUrl) return;
if (/^(javascript|data|vbscript):/i.test(trimmedUrl)) return;

const inWebview = isInAppWebView();
const isExternal = /^(https?|itms-apps):\/\//.test(trimmedUrl);

const isDangerousProtocol = /^(javascript|data|vbscript):/i.test(
trimmedUrl,
);
if (isDangerousProtocol) return;

const isExternalUrl = /^(https?|itms-apps):\/\//.test(trimmedUrl);

if (isExternalUrl) {
// 웹뷰에서 window.location.href로 외부 URL을 열면 WebView 자체가 이동해버리므로 앱에 위임.
// requestOpenExternalUrl은 http/https만 허용하므로, itms-apps:// 등 비표준 스킴은 false를
// 반환 → window.open으로 폴백해 OS가 처리하도록 위임
if (isInAppWebView()) {
if (!requestOpenExternalUrl(trimmedUrl)) {
window.open(trimmedUrl);
}
} else {
window.location.href = trimmedUrl;
}
} else {
navigate(trimmedUrl);
if (isExternal) {
if (inWebview && !requestOpenExternalUrl(trimmedUrl))
window.open(trimmedUrl);
else if (!inWebview) window.location.href = trimmedUrl;
Comment on lines +20 to +26

@coderabbitai coderabbitai Bot May 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

외부 URL 스킴 판별은 대소문자를 무시해야 합니다.

지금 정규식은 HTTPS://...ITMS-APPS://...를 내부 경로로 분류합니다. 그러면 일반 웹에서는 React Router로, 웹뷰에서는 NAVIGATE_WEBVIEW로 잘못 보내지게 됩니다.

수정 예시
-      const isExternal = /^(https?|itms-apps):\/\//.test(trimmedUrl);
+      const isExternal = /^(https?|itms-apps):\/\//i.test(trimmedUrl);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const inWebview = isInAppWebView();
const isExternal = /^(https?|itms-apps):\/\//.test(trimmedUrl);
const isDangerousProtocol = /^(javascript|data|vbscript):/i.test(
trimmedUrl,
);
if (isDangerousProtocol) return;
const isExternalUrl = /^(https?|itms-apps):\/\//.test(trimmedUrl);
if (isExternalUrl) {
// 웹뷰에서 window.location.href로 외부 URL을 열면 WebView 자체가 이동해버리므로 앱에 위임.
// requestOpenExternalUrl은 http/https만 허용하므로, itms-apps:// 등 비표준 스킴은 false를
// 반환 → window.open으로 폴백해 OS가 처리하도록 위임
if (isInAppWebView()) {
if (!requestOpenExternalUrl(trimmedUrl)) {
window.open(trimmedUrl);
}
} else {
window.location.href = trimmedUrl;
}
} else {
navigate(trimmedUrl);
if (isExternal) {
if (inWebview && !requestOpenExternalUrl(trimmedUrl)) window.open(trimmedUrl);
else if (!inWebview) window.location.href = trimmedUrl;
const inWebview = isInAppWebView();
const isExternal = /^(https?|itms-apps):\/\//i.test(trimmedUrl);
if (isExternal) {
if (inWebview && !requestOpenExternalUrl(trimmedUrl)) window.open(trimmedUrl);
else if (!inWebview) window.location.href = trimmedUrl;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useNavigator.ts` around lines 20 - 25, The isExternal
detection in useNavigator.ts uses /^(https?|itms-apps):\/\// which is
case-sensitive and misclassifies schemes like "HTTPS://" or "ITMS-APPS://";
update the isExternal check (the regex or the input) to be case-insensitive
(e.g., add the i flag to the regex or normalize trimmedUrl to lowercase before
testing) so variables inWebview and isExternal behave correctly for all-cased
schemes and navigation branches (window.open / window.location.href /
requestOpenExternalUrl).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

타당하지 않습니다. 적용 불필요합니다.

URL 스킴은 RFC 3986 기준으로 대소문자를 구분하지 않지만, 실제로 HTTPS:// 같은 대문자 URL을 입력하는 경우는 없습니다. 브라우저, 앱, 백엔드 모두 소문자 스킴으로 URL을 생성하고, 사용자가 직접 입력하는 경우도 거의 없습니다.

이미 위의 위험 프로토콜 차단 정규식(line 18)에는 i 플래그가 있는 것도 실제 XSS 공격 벡터를 막기 위한 것이고, 외부 URL 판별은 그런 위협이 없습니다. 과잉 방어입니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@oesnuj, 말씀하신 내용 이해했습니다. 실제 환경에서 대문자 스킴이 사용되는 경우가 없고, 위험 프로토콜 차단과는 위협 모델이 다르다는 점에서 타당한 판단입니다. 제안을 철회하겠습니다.

return;
}

if (inWebview) requestNavigateWebview(toSlug(trimmedUrl));
else navigate(trimmedUrl);
Comment on lines +30 to +31

@coderabbitai coderabbitai Bot May 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

웹뷰 브리지 실패 시 내부 링크가 무반응이 됩니다.

requestNavigateWebview()는 브리지 전송 실패 시 false를 돌려주는데, 현재는 그 값을 무시하고 있습니다. postMessage가 실패하면 프로모션/페스티벌 이동이 그대로 죽으니 웹 라우팅으로라도 폴백하는 편이 안전합니다.

수정 예시
-      if (inWebview) requestNavigateWebview(toSlug(trimmedUrl));
-      else navigate(trimmedUrl);
+      if (inWebview) {
+        const delegated = requestNavigateWebview(toSlug(trimmedUrl));
+        if (!delegated) navigate(trimmedUrl);
+        return;
+      }
+
+      navigate(trimmedUrl);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useNavigator.ts` around lines 29 - 30, The webview bridge
call requestNavigateWebview(toSlug(trimmedUrl)) can return false on failure but
its result is ignored; update the logic in the useNavigator hook so that you
capture the return value and, if it is false (or otherwise indicates a failure),
call navigate(trimmedUrl) as a fallback; ensure this change targets the branch
that currently does if (inWebview) requestNavigateWebview(...) else
navigate(...), replacing the simple call with a conditional that falls back to
navigate when requestNavigateWebview fails.

@oesnuj oesnuj May 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

requestNavigateWebview는 이미 inWebview === true인 브랜치 안에서 호출됩니다.

이 함수가 false를 반환하는 유일한 경우는 isInAppWebView()가 false일 때인데, 해당 브랜치에 진입했다는 것 자체가 그 조건을 이미 통과했음을 의미합니다.
브릿지 전송 실패 시 navigate(trimmedUrl)로 폴백해도 웹뷰 환경에서 React Router 이동은 앱 네이티브 스택에 화면이 쌓이지 않는 근본 문제를 그대로 가집니다.

의미 없는 폴백을 추가하는 것은 오히려 잘못된 동작을 정상처럼 보이게 만듭니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and display the webviewBridge utility
fd -i "webviewBridge" --type f

Length of output: 36


🏁 Script executed:

#!/bin/bash
cat frontend/src/utils/webviewBridge.ts

Length of output: 4110


@oesnuj, 피드백 감사합니다. 코드를 확인했습니다.

첫 번째 주장에 대해 한 가지 짚고 싶은 점이 있습니다. postMessageToApptry/catch 블록을 보면, window.ReactNativeWebView?.postMessage() 호출 자체가 예외를 던지는 경우에도 false를 반환하는 경로가 존재합니다. 즉, isInAppWebView() === true인 브랜치 안에서도 이론상 false 반환은 가능합니다.

그러나 두 번째 주장이 결정적입니다. postMessage가 실패한 상황에서 navigate(trimmedUrl)로 폴백하더라도, 웹뷰 환경에서 React Router 이동은 앱 네이티브 스택에 화면을 쌓지 못한다는 근본 문제가 그대로 남습니다. 결과적으로 해당 폴백은 오류를 정상 동작처럼 보이게 만드는 오해의 소지가 있으며, 실질적인 UX 개선도 없습니다.

지적하신 내용이 맞습니다. 리뷰 코멘트를 철회하겠습니다.


✏️ Learnings added
Learnt from: oesnuj
Repo: Moadong/moadong PR: 1507
File: frontend/src/hooks/useNavigator.ts:30-31
Timestamp: 2026-05-03T09:41:11.455Z
Learning: In `frontend/src/hooks/useNavigator.ts`, the `requestNavigateWebview` call is intentionally inside the `if (inWebview)` guard. A React Router `navigate()` fallback on bridge failure is not meaningful because React Router navigation inside an in-app webview does not push screens onto the native app stack — that is the exact problem the webview bridge is designed to solve. Adding such a fallback would make broken behavior appear normal.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

},
[navigate],
);
Expand Down
19 changes: 4 additions & 15 deletions frontend/src/pages/MainPage/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,13 @@ import { useGetBanners } from '@/hooks/Queries/useBanner';
import useDevice from '@/hooks/useDevice';
import useNavigator from '@/hooks/useNavigator';
import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink';
import isInAppWebView from '@/utils/isInAppWebView';
import {
requestNavigateWebview,
requestOpenExternalUrl,
} from '@/utils/webviewBridge';
import * as Styled from './Banner.styles';
import BANNERS from './bannerData';

interface BannerProps {
isWebview?: boolean;
}

const inAppWebView = isInAppWebView();

const Banner = ({ isWebview = false }: BannerProps) => {
const { isMobile } = useDevice();
const handleLink = useNavigator();
Expand Down Expand Up @@ -69,12 +62,8 @@ const Banner = ({ isWebview = false }: BannerProps) => {
linkTo: url,
});

if (inAppWebView) {
if (url === WEBVIEW_LINK_TARGET.CLUB_FESTIVAL) {
requestNavigateWebview('festival-introduction');
return;
}
requestOpenExternalUrl(url);
if (url === WEBVIEW_LINK_TARGET.CLUB_FESTIVAL) {
handleLink('/festival-introduction');
return;
}

Expand Down Expand Up @@ -140,13 +129,13 @@ const Banner = ({ isWebview = false }: BannerProps) => {
</SwiperSlide>
))}
</Swiper>
{(isMobile || inAppWebView) && (
{(isMobile || isWebview) && (
<Styled.NumericPagination>
{currentIndex + 1} / {displayBanners?.length ?? 0}
</Styled.NumericPagination>
)}

{!isMobile && !inAppWebView && (
{!isMobile && !isWebview && (
<Styled.DotPagination>
{displayBanners?.map((_, index) => (
<Styled.Dot key={index} active={currentIndex === index} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import { USER_EVENT } from '@/constants/eventName';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import useNavigator from '@/hooks/useNavigator';
import isInAppWebView from '@/utils/isInAppWebView';
import { requestNavigateWebview } from '@/utils/webviewBridge';
import ArrowButton from '../PromotionArrowButton/PromotionArrowButton';
Expand All @@ -12,7 +12,7 @@ interface Props {
}

const PromotionClubCTA = ({ clubId, clubName }: Props) => {
const navigate = useNavigate();
const handleLink = useNavigator();
const trackEvent = useMixpanelTrack();

const handleNavigate = () => {
Expand All @@ -21,10 +21,11 @@ const PromotionClubCTA = ({ clubId, clubName }: Props) => {
club_name: clubName,
});

// 웹뷰는 club/id 기반 slug, 일반 웹은 clubName 기반 경로 사용
if (isInAppWebView()) {
requestNavigateWebview(`club/${clubId}`);
} else {
navigate(`/clubDetail/@${encodeURIComponent(clubName)}`);
handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`);
Comment on lines +24 to +28

@coderabbitai coderabbitai Bot May 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CTA도 웹뷰 브리지 실패 시 그대로 죽습니다.

여기서는 requestNavigateWebview('club/...')의 반환값을 확인하지 않아서, 브리지 전송이 실패하면 버튼 클릭이 무반응이 됩니다. 실패 시에는 웹 경로로 폴백해 두는 게 안전합니다.

수정 예시
-    if (isInAppWebView()) {
-      requestNavigateWebview(`club/${clubId}`);
-    } else {
-      handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`);
-    }
+    if (isInAppWebView() && requestNavigateWebview(`club/${clubId}`)) {
+      return;
+    }
+
+    handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 웹뷰는 club/id 기반 slug, 일반 웹은 clubName 기반 경로 사용
if (isInAppWebView()) {
requestNavigateWebview(`club/${clubId}`);
} else {
navigate(`/clubDetail/@${encodeURIComponent(clubName)}`);
handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`);
// 웹뷰는 club/id 기반 slug, 일반 웹은 clubName 기반 경로 사용
if (isInAppWebView() && requestNavigateWebview(`club/${clubId}`)) {
return;
}
handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx`
around lines 24 - 28, The CTA currently calls
requestNavigateWebview(`club/${clubId}`) without checking its result, so if the
webview bridge call fails the click does nothing; update the click handling
around isInAppWebView() to await/inspect requestNavigateWebview's return (or
catch its rejection) and on failure fall back to calling
handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`); locate this logic in
PromotionClubCTA (symbols: isInAppWebView, requestNavigateWebview, handleLink,
clubId, clubName) and ensure the fallback runs when the bridge returns a falsy
value or throws.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

실패 시 handleLink('/clubDetail/@clubname')으로 폴백하면 useNavigator 내부에서 다시 inWebview === true 브랜치로 진입해 requestNavigateWebview를 재호출하는 무한루프가 발생합니다.
브릿지가 실패한 환경에서 동일한 브릿지 경로를 다시 타는 폴백은 성립하지 않습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@oesnuj, 지적이 정확합니다. /clubDetail/@clubName은 외부 URL(http/https/itms-apps)이 아니라 내부 경로이기 때문에, handleLink 내부에서 isInAppWebView() === true 브랜치로 다시 진입해 requestNavigateWebview를 재호출하는 순환이 발생합니다. 제 제안이 잘못되었습니다.

현재 구조(isInAppWebView() → requestNavigateWebview, 그 외 → handleLink)는 올바른 패턴이며, 웹뷰 브리지 실패 시 동일 브리지 경로로의 폴백은 의미가 없습니다. 제 코멘트는 철회합니다.

(∩`-´)⊃━☆゚.*・。゚


✏️ Learnings added
Learnt from: oesnuj
Repo: Moadong/moadong PR: 1507
File: frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx:24-28
Timestamp: 2026-05-03T09:40:26.989Z
Learning: In `frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx`, the `PromotionClubCTA` component intentionally keeps a manual `isInAppWebView()` guard rather than delegating to `handleLink` (from `useNavigator`) for the webview branch. This is because `handleLink` internally re-checks `isInAppWebView()` and would re-enter the webview branch for internal paths, causing an infinite loop if used as a fallback after a bridge failure. The pattern `if (isInAppWebView()) { requestNavigateWebview(...) } else { handleLink(...) }` is correct and intentional here.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import { USER_EVENT } from '@/constants/eventName';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import useNavigator from '@/hooks/useNavigator';
import { getDDay } from '@/pages/PromotionPage/utils/getDday';
import { PromotionArticle } from '@/types/promotion';
import CardMeta from './CardMeta/CardMeta';
Expand All @@ -14,7 +14,7 @@ interface PromotionCardProps {

const PromotionCard = ({ article }: PromotionCardProps) => {
const trackEvent = useMixpanelTrack();
const navigateToPromotionDetail = useNavigate();
const handleLink = useNavigator();
const dday = getDDay(article.eventStartDate, article.eventEndDate);

const handleCardClick = () => {
Expand All @@ -24,15 +24,15 @@ const PromotionCard = ({ article }: PromotionCardProps) => {
source: 'promotion-card',
});

navigateToPromotionDetail('/festival-introduction');
handleLink('/festival-introduction');
return;
}

trackEvent(USER_EVENT.PROMOTION_CARD_CLICKED, {
clubId: article.clubId,
});

navigateToPromotionDetail(`/promotions/${article.id}`);
handleLink(`/promotions/${article.id}`);
};

const imageUrl = article.images?.[0];
Expand Down
Loading