diff --git a/hooks/index.ts b/hooks/index.ts index af560c4..82191f5 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -3,4 +3,5 @@ export { useFcm } from './use-fcm'; export { useMixpanelTrack } from './use-mixpanel-track'; export { useThemeColor } from './use-theme-color'; export { useTrackScreenView } from './use-track-screen-view'; +export { useWebViewMessageHandler } from './use-webview-message-handler'; diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts new file mode 100644 index 0000000..dbfa87a --- /dev/null +++ b/hooks/use-webview-message-handler.ts @@ -0,0 +1,57 @@ +import { WebViewMessage, WebViewMessageEvent, WebViewMessageTypes } from '@/types/webview-message.types'; +import { useCallback } from 'react'; + +interface UseWebViewMessageHandlerOptions { + // 뒤로가기 요청 시 호출 + onNavigateBack?: () => void; + // 알림 구독 요청 시 호출 + onSubscribe?: (clubId: string, clubName?: string) => Promise | void; + // 알림 구독 해제 요청 시 호출 + onUnsubscribe?: (clubId: string) => Promise | void; + // 공유하기 요청 시 호출 + onShare?: (payload: { title: string; text: string; url: string }) => Promise | void; +} + +// WebView 메시지를 처리하는 Hook +export const useWebViewMessageHandler = ({ + onNavigateBack, + onSubscribe, + onUnsubscribe, + onShare, +}: UseWebViewMessageHandlerOptions) => { + const handleMessage = useCallback((event: WebViewMessageEvent) => { + try { + const data = event.nativeEvent.data; + if (!data) return; + + const message: WebViewMessage = JSON.parse(data); + + switch (message.type) { + case WebViewMessageTypes.NAVIGATE_BACK: + onNavigateBack?.(); + break; + case WebViewMessageTypes.NOTIFICATION_SUBSCRIBE: + if (message.payload?.clubId) { + onSubscribe?.(message.payload.clubId, message.payload.clubName); + } + break; + case WebViewMessageTypes.NOTIFICATION_UNSUBSCRIBE: + if (message.payload?.clubId) { + onUnsubscribe?.(message.payload.clubId); + } + break; + case WebViewMessageTypes.SHARE: + if (message.payload) { + onShare?.(message.payload); + } + break; + default: + console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); + } + } catch (error) { + console.error('[WebViewHandler] 메시지 파싱 오류:', error); + } + }, [onNavigateBack, onSubscribe, onUnsubscribe, onShare]); + + return { handleMessage }; +}; diff --git a/types/webview-message.types.ts b/types/webview-message.types.ts new file mode 100644 index 0000000..6ffe7d9 --- /dev/null +++ b/types/webview-message.types.ts @@ -0,0 +1,23 @@ + +//WebView 메시지 타입 상수 +export const WebViewMessageTypes = { + NAVIGATE_BACK: 'NAVIGATE_BACK', + NOTIFICATION_SUBSCRIBE: 'NOTIFICATION_SUBSCRIBE', + NOTIFICATION_UNSUBSCRIBE: 'NOTIFICATION_UNSUBSCRIBE', + SHARE: 'SHARE', +} as const; + +// WebView 메시지 Discriminated Union 타입 + +export type WebViewMessage = + | { type: 'NAVIGATE_BACK' } + | { type: 'NOTIFICATION_SUBSCRIBE'; payload: { clubId: string; clubName?: string } } + | { type: 'NOTIFICATION_UNSUBSCRIBE'; payload: { clubId: string } } + | { type: 'SHARE'; payload: { title: string; text: string; url: string } }; + +// WebView 메시지 이벤트 타입 (react-native-webview) +export interface WebViewMessageEvent { + nativeEvent: { + data: string; + }; +} diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index da48b5e..af39a52 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -1,28 +1,25 @@ -import { MoaImage } from "@/components/moa-image"; -import { MoaText } from "@/components/moa-text"; -import { PermissionDialog } from "@/components/permission-dialog"; -import { USER_EVENT } from "@/constants/eventname"; -import { useMixpanelContext } from "@/contexts"; -import { useSubscribedClubsContext } from "@/contexts/subscribed-clubs-context"; -import { useMixpanelTrack } from "@/hooks"; -import { Ionicons } from "@expo/vector-icons"; -import Constants from "expo-constants"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { useMemo, useState } from "react"; -import { - ActivityIndicator, - Platform, - Share, - TouchableOpacity, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { WebView, WebViewMessageEvent } from "react-native-webview"; -import styled from "styled-components/native"; +import { MoaImage } from '@/components/moa-image'; +import { MoaText } from '@/components/moa-text'; +import { PermissionDialog } from '@/components/permission-dialog'; +import { USER_EVENT } from '@/constants/eventname'; +import { useMixpanelContext } from '@/contexts'; +import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; +import { useMixpanelTrack, useWebViewMessageHandler } from '@/hooks'; +import { Ionicons } from '@expo/vector-icons'; +import Constants from 'expo-constants'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, Platform, Share, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { WebView } from 'react-native-webview'; +import styled from 'styled-components/native'; export default function ClubWebViewScreen() { const router = useRouter(); const { id, name } = useLocalSearchParams<{ id?: string; name?: string }>(); const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); const [showPermissionDialog, setShowPermissionDialog] = useState(false); const { isSubscribed, toggleSubscribe } = useSubscribedClubsContext(); const { sessionId } = useMixpanelContext(); @@ -37,12 +34,18 @@ export default function ClubWebViewScreen() { const cleanUrl = webviewUrl?.replace(/\/$/, "") || ""; const baseUrl = `${cleanUrl}/club/${id}`; - + + const params = new URLSearchParams(); if (sessionId) { - return `${baseUrl}?session_id=${encodeURIComponent(sessionId)}`; + params.append('session_id', sessionId); + } + if (id && isSubscribed(id)) { + params.append('is_subscribed', 'true'); } - return baseUrl; - }, [id, webviewUrl, sessionId]); + + const queryString = params.toString(); + return queryString ? `${baseUrl}?${queryString}` : baseUrl; + }, [id, webviewUrl, sessionId, isSubscribed]); const subscribed = useMemo(() => { return id ? isSubscribed(id) : false; @@ -62,6 +65,11 @@ export default function ClubWebViewScreen() { }, 200); }; + const handleError = () => { + setHasError(true); + setIsLoading(false); + }; + const handleBack = () => { trackEvent(USER_EVENT.BACK_BUTTON_CLICKED, { from: "club_detail", @@ -94,41 +102,69 @@ export default function ClubWebViewScreen() { } }; - const handleMessage = async (event: WebViewMessageEvent) => { - try { - const data = JSON.parse(event.nativeEvent.data); - if (data.type === "SHARE") { - const { title, text, url } = data.payload; - await Share.share({ - title, - message: text, - url, - }); + // WebView 메시지 핸들러 + const { handleMessage } = useWebViewMessageHandler({ + onNavigateBack: handleBack, + onSubscribe: async (targetId: string, clubName?: string) => { + trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { + clubName: clubName || name, + subscribed: true, + from: 'club_detail', + url: 'app://moadong/club', + }); + + // 이미 구독 중이면 무시 + if (isSubscribed(targetId)) return; + + const result = await toggleSubscribe(targetId); + if (result.needsPermission) { + setShowPermissionDialog(true); } - } catch (e) { - // Ignore errors - } - }; + }, + onUnsubscribe: async (targetId: string) => { + // 구독 중이 아니면 무시 + if (!isSubscribed(targetId)) return; + + trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { + clubName: name, + subscribed: false, + from: 'club_detail', + url: 'app://moadong/club', + }); + + await toggleSubscribe(targetId); + }, + onShare: async ({ title, text, url }: { title: string; text: string; url: string }) => { + await Share.share({ + title, + message: text, + url, + }); + }, + }); return ( - -
- - - - 동아리 상세 - - - -
+ + + {hasError && ( +
+ + + + 동아리 상세 + + + +
+ )} {isLoading && (