diff --git a/app.json b/app.json index 84a864b..a4116c6 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "모아동", "slug": "moadong-app", - "version": "1.5.2", + "version": "1.6.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "moadongapp", @@ -10,7 +10,7 @@ "newArchEnabled": true, "ios": { "supportsTablet": false, - "buildNumber": "15", + "buildNumber": "16", "googleServicesFile": "./GoogleService-Info.plist", "bundleIdentifier": "com.moadong.moadong", "associatedDomains": [ @@ -26,7 +26,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 15, + "versionCode": 16, "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx deleted file mode 100644 index bb7a7c8..0000000 --- a/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import MenuIcon from "@/assets/icons/ic-menu.svg"; -import HomeIcon from "@/assets/icons/ic_home.svg"; -import { HapticTab } from "@/components/haptic-tab"; -import { USER_EVENT } from "@/constants/eventname"; -import { Colors } from "@/constants/theme"; -import { useColorScheme } from "@/hooks/use-color-scheme"; -import { useMixpanelTrack } from "@/hooks/use-mixpanel-track"; -import { Tabs } from "expo-router"; -import React, { useEffect } from "react"; -import { Image } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; - -export default function TabLayout() { - const colorScheme = useColorScheme(); - const trackEvent = useMixpanelTrack(); - const insets = useSafeAreaInsets(); - const TAB_BAR_BASE_HEIGHT = 56; - const TAB_BAR_BASE_PADDING_VERTICAL = 6; - - useEffect(() => { - console.log('[StartupTiming] tabsMounted', Date.now()); - }, []); - - const handleTabPress = (tabName: string) => { - trackEvent(USER_EVENT.BOTTOM_TAB_CLICKED, { - tab: tabName, - url: `app://moadong/(tabs)/${tabName}`, - }); - }; - - return ( - - ( - - ), - }} - listeners={{ - tabPress: () => handleTabPress("home"), - }} - /> - ( - - ), - }} - listeners={{ - tabPress: () => handleTabPress("explore"), - }} - /> - ( - - ), - }} - listeners={{ - tabPress: () => handleTabPress("more"), - }} - /> - - ); -} diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx deleted file mode 100644 index 9417ca4..0000000 --- a/app/(tabs)/explore.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import SubscribeScreen from '@/ui/subscribe'; - -export default SubscribeScreen; diff --git a/app/(tabs)/index.tsx.backup b/app/(tabs)/index.tsx.backup deleted file mode 100644 index d4269c3..0000000 --- a/app/(tabs)/index.tsx.backup +++ /dev/null @@ -1,3 +0,0 @@ -import HomeScreen from '@/ui/home'; - -export default HomeScreen; diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx deleted file mode 100644 index 30d66b1..0000000 --- a/app/(tabs)/more.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import MoadongIcon from '@/assets/icons/ic-moadong.svg'; -import { MoaText } from '@/components/moa-text'; -import { PAGE_VIEW_EVENT, USER_EVENT } from '@/constants/eventname'; -import { useMixpanelTrack } from '@/hooks/use-mixpanel-track'; -import { useTrackScreenView } from '@/hooks/use-track-screen-view'; -import { Ionicons } from '@expo/vector-icons'; -import Constants from 'expo-constants'; -import { useRouter } from 'expo-router'; -import React from 'react'; -import { TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import styled from 'styled-components/native'; - -interface MenuItem { - id: string; - title: string; - icon: keyof typeof Ionicons.glyphMap; - route: string; -} - -const menuItems: MenuItem[] = [ - { - id: 'introduce', - title: '서비스 소개', - icon: 'information-circle-outline', - route: '/webview/introduce', - }, - { - id: 'club-union', - title: '총 동아리 연합회', - icon: 'people-outline', - route: '/webview/club-union', - }, - { - id: 'privacy-policy', - title: '개인정보 처리방침', - icon: 'document-text-outline', - route: '/webview/privacy-policy', - }, -]; - -export default function MoreScreen() { - const insets = useSafeAreaInsets(); - const router = useRouter(); - const trackEvent = useMixpanelTrack(); - - useTrackScreenView(PAGE_VIEW_EVENT.MORE_PAGE); - - const appVersion = - Constants.expoConfig?.version ?? - Constants.nativeAppVersion ?? - Constants.manifest?.version ?? - '알 수 없음'; - - const handleMenuPress = (item: MenuItem) => { - trackEvent(USER_EVENT.MORE_MENU_CLICKED, { - menu: item.title, - url: `app://moadong${item.route}`, - }); - - router.push(item.route as any); - }; - - return ( - -
- 더보기 -
- - - {menuItems.map((item) => ( - handleMenuPress(item)} - activeOpacity={0.7} - > - - - - - {item.title} - - - - ))} - - - - - - 앱 버전 - - {appVersion} - - -
- ); -} - -// Styled Components -const Container = styled(View)` - flex: 1; - background-color: #fff; -`; - -const Header = styled.View` - padding-horizontal: 16px; - padding-vertical: 16px; - border-bottom-width: 1px; - border-bottom-color: #F0F0F0; -`; - -const HeaderTitle = styled(MoaText)` - color: #111111; -`; - -const MenuList = styled.View` - padding-top: 8px; - flex: 1; -`; - -const MenuItem = styled(TouchableOpacity)` - flex-direction: row; - align-items: center; - justify-content: space-between; - padding-horizontal: 16px; - padding-vertical: 16px; - border-bottom-width: 1px; - border-bottom-color: #F5F5F5; -`; - -const MenuItemContent = styled.View` - flex-direction: row; - align-items: center; - flex: 1; -`; - -const IconContainer = styled.View` - width: 40px; - height: 40px; - border-radius: 20px; - background-color: #FFECE5; - justify-content: center; - align-items: center; - margin-right: 12px; -`; - -const MenuItemText = styled(MoaText)` - color: #111111; - font-size: 16px; -`; - -const VersionItem = styled.View` - flex-direction: row; - align-items: center; - justify-content: space-between; - padding-horizontal: 16px; - padding-vertical: 16px; - border-bottom-width: 1px; - border-bottom-color: #ffffff; -`; - -const VersionValue = styled(MoaText)` - color: #888888; -`; diff --git a/app/_layout.tsx b/app/_layout.tsx index f6a5b2d..c367db6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -32,7 +32,7 @@ SplashScreen.preventAutoHideAsync().catch(() => { }); export const unstable_settings = { - anchor: '(tabs)', + anchor: 'index', }; type BootstrapStatus = 'idle' | 'running' | 'success' | 'failed'; @@ -57,15 +57,15 @@ function RootLayoutContent() { const [bootstrapResult, setBootstrapResult] = useState(null); const bootstrapStatusRef = useRef('idle'); const nativeSplashHiddenRef = useRef(false); - const { isSettled: homeWebViewPreloadSettled, status: homeWebViewPreloadStatus } = + const { isSettled: homeWebViewPreloadSettled } = useHomeWebViewPreloadContext(); + const initialPathnameRef = useRef(pathname); const bootstrapSucceeded = bootstrapStatus === 'success'; - const shouldWaitForHomeWebView = pathname === '/'; const shouldBlockSplash = forceUpdateRequired || !bootstrapSucceeded || - (shouldWaitForHomeWebView && !homeWebViewPreloadSettled); + (initialPathnameRef.current === '/' && !homeWebViewPreloadSettled); // 강제 업데이트가 필요한 경우(또는 체크 전)에는 FCM 권한 프롬프트/핸들러 설정이 뜨지 않도록 비활성화 useFcm(forceUpdateChecked && !forceUpdateRequired && bootstrapSucceeded); @@ -204,7 +204,7 @@ function RootLayoutContent() { forceUpdateRequired, bootstrapStatus, pathname, - homeWebViewPreloadStatus, + homeWebViewPreloadSettled, }); return; } @@ -213,7 +213,7 @@ function RootLayoutContent() { forceUpdateRequired, bootstrapStatus, pathname, - homeWebViewPreloadStatus, + homeWebViewPreloadSettled, shouldBlockSplash, ]); @@ -236,7 +236,7 @@ function RootLayoutContent() { bootstrapStatus, forceUpdateRequired, pathname, - homeWebViewPreloadStatus, + homeWebViewPreloadSettled, }); } @@ -252,7 +252,7 @@ function RootLayoutContent() { - + diff --git a/app/(tabs)/index.tsx b/app/index.tsx similarity index 95% rename from app/(tabs)/index.tsx rename to app/index.tsx index b35027c..d18fd03 100644 --- a/app/(tabs)/index.tsx +++ b/app/index.tsx @@ -4,7 +4,7 @@ import React, { Suspense, lazy, useCallback, useState } from 'react'; const LazyHomeScreen = lazy(() => import('@/ui/home/home-screen')); -export default function HomeTab() { +export default function Home() { const [webViewFailed, setWebViewFailed] = useState(false); const { markFailed } = useHomeWebViewPreloadContext(); diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index e2bfed3..6cf9104 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -83,15 +83,15 @@ export default function WebViewScreen() { if (router.canGoBack()) { router.back(); } else { - router.push("/(tabs)/more"); + router.push("/"); } }; - const handleNavigateWebview = (slug: string) => { + const handleNavigateWebview = (slug: string, clubId?: string) => { if (slug.startsWith('club/')) { - const clubId = slug.slice('club/'.length); - if (!clubId) return; - router.push({ pathname: '/club/[id]', params: { id: clubId } }); + const slugId = slug.slice('club/'.length); + if (!slugId) return; + router.push({ pathname: '/club/[id]', params: { id: slugId, clubId } }); } else { router.push({ pathname: '/webview/[slug]', params: { slug } }); } @@ -174,6 +174,7 @@ export default function WebViewScreen() { domStorageEnabled={true} /> + ); } diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 8ebc0ce..845feb8 100644 --- a/hooks/use-webview-message-handler.ts +++ b/hooks/use-webview-message-handler.ts @@ -6,7 +6,7 @@ interface UseWebViewMessageHandlerOptions { // 뒤로가기 요청 시 호출 onNavigateBack?: () => void; // 웹뷰 내 화면 이동 요청 시 호출 - onNavigateWebview?: (slug: string) => void; + onNavigateWebview?: (slug: string, clubId?: string) => void; // 알림 구독 요청 시 호출 onSubscribe?: (clubId: string, clubName?: string) => Promise | void; // 알림 구독 해제 요청 시 호출 @@ -36,7 +36,7 @@ export const useWebViewMessageHandler = ({ break; case WebViewMessageTypes.NAVIGATE_WEBVIEW: if (message.payload?.slug) { - onNavigateWebview?.(message.payload.slug); + onNavigateWebview?.(message.payload.slug, message.payload.clubId); } break; case WebViewMessageTypes.NOTIFICATION_SUBSCRIBE: diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index d5e513c..98d23bc 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 2QMK9GBWN6; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -430,7 +430,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.6.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 2QMK9GBWN6; INFOPLIST_FILE = app/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -462,7 +462,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.6.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/app/Info.plist b/ios/app/Info.plist index aed8a97..71a5bfa 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.5.2 + 1.6.0 CFBundleSignature ???? CFBundleURLTypes @@ -39,7 +39,7 @@ CFBundleVersion - 15 + 16 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/package-lock.json b/package-lock.json index ac21e6a..168f572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4887,9 +4887,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4904,9 +4901,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4921,9 +4915,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4938,9 +4929,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4955,9 +4943,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4972,9 +4957,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4989,9 +4971,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5006,9 +4985,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10314,9 +10290,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10337,9 +10310,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10360,9 +10330,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10383,9 +10350,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/types/webview-message.types.ts b/types/webview-message.types.ts index 0d917d4..1bf740e 100644 --- a/types/webview-message.types.ts +++ b/types/webview-message.types.ts @@ -13,7 +13,7 @@ export const WebViewMessageTypes = { export type WebViewMessage = | { type: 'NAVIGATE_BACK' } - | { type: 'NAVIGATE_WEBVIEW'; payload: { slug: string } } + | { type: 'NAVIGATE_WEBVIEW'; payload: { slug: string; clubId?: string } } | { type: 'NOTIFICATION_SUBSCRIBE'; payload: { clubId: string; clubName?: string } } | { type: 'NOTIFICATION_UNSUBSCRIBE'; payload: { clubId: string } } | { type: 'SHARE'; payload: { title: string; text: string; url: string } } diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index fb39dd0..7f66ef1 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -18,7 +18,7 @@ import styled from "styled-components/native"; export default function ClubWebViewScreen() { const router = useRouter(); - const { id, name } = useLocalSearchParams<{ id?: string; name?: string }>(); + const { id, name, clubId: objectId } = useLocalSearchParams<{ id?: string; name?: string; clubId?: string }>(); const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); const [showPermissionDialog, setShowPermissionDialog] = useState(false); @@ -38,15 +38,17 @@ export default function ClubWebViewScreen() { const baseUrl = `${cleanUrl}/webview/club/${id}`; let url = appendSessionId(baseUrl, sessionId); - if (id && isSubscribed(id)) { + const lookupId = typeof objectId === 'string' ? objectId : id; + if (lookupId && isSubscribed(lookupId)) { url += `&is_subscribed=true`; } return url; - }, [id, webviewUrl, sessionId, isSubscribed]); + }, [id, objectId, webviewUrl, sessionId, isSubscribed]); const subscribed = useMemo(() => { - return id ? isSubscribed(id) : false; - }, [id, isSubscribed]); + const lookupId = typeof objectId === 'string' ? objectId : id; + return lookupId ? isSubscribed(lookupId) : false; + }, [id, objectId, isSubscribed]); const userAgent = getWebViewUserAgent(); @@ -72,7 +74,7 @@ export default function ClubWebViewScreen() { if (router.canGoBack()) { router.back(); } else { - router.push("/(tabs)"); + router.push("/"); } }; diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 9872e68..fd26ad0 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,12 +2,18 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; +import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { ActivityIndicator, View } from 'react-native'; +import { ActivityIndicator, BackHandler, Platform, Share, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import { + WebView, + WebViewMessageEvent, + WebViewNavigation, +} from 'react-native-webview'; +import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; import styled from 'styled-components/native'; const BASE_URL = `${(process.env.EXPO_PUBLIC_WEBVIEW_URL || 'https://moadong.com').replace(/\/$/, '')}/webview/main`; @@ -21,6 +27,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const insets = useSafeAreaInsets(); const router = useRouter(); const webViewRef = useRef(null); + const canGoBackRef = useRef(false); const loadFailedRef = useRef(false); const [loaded, setLoaded] = useState(false); @@ -77,11 +84,16 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { break; } + case 'NAVIGATE_BACK': + webViewRef.current?.goBack(); + break; + case 'NAVIGATE_WEBVIEW': + if (!loaded) break; if (payload.slug?.startsWith('club/')) { - const clubId = payload.slug.slice('club/'.length); - if (!clubId) break; - router.push({ pathname: '/club/[id]', params: { id: clubId } }); + const slugId = payload.slug.slice('club/'.length); + if (!slugId) break; + router.push({ pathname: '/club/[id]', params: { id: slugId, clubId: payload.clubId } }); } else if (payload.slug?.startsWith('promotions/')) { router.push({ pathname: '/webview/[slug]', params: { slug: 'promotions', path: `/${payload.slug}`, hideHeader: 'true' } }); } else { @@ -92,6 +104,17 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { case 'OPEN_EXTERNAL_URL': await WebBrowser.openBrowserAsync(payload.url); break; + + case 'SHARE': + await Share.share({ title: payload.title, message: payload.text, url: payload.url }); + break; + + case 'REQUEST_APP_VERSION': + sendMessage({ + type: 'APP_VERSION', + payload: { version: Constants.expoConfig?.version ?? 'unknown' }, + }); + break; } } catch { // 파싱 실패 무시 @@ -116,6 +139,49 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { onError(); }, [markFailed, onError]); + const handleNavigationStateChange = useCallback( + (navState: WebViewNavigation) => { + canGoBackRef.current = navState.canGoBack; + }, + [], + ); + + const handleShouldStartLoadWithRequest = useCallback( + (request: ShouldStartLoadRequest) => { + const baseOrigin = (process.env.EXPO_PUBLIC_WEBVIEW_URL ?? 'https://moadong.com').replace(/\/$/, ''); + if (request.url.startsWith('http') && !request.url.startsWith(baseOrigin)) { + // iOS: navigationType === 'click' 은 사용자가 직접 링크를 탭한 경우만 해당 + // 초기 로드·서버 리다이렉트는 'other' 이므로 인터셉트하지 않음 + // Android: navigationType이 항상 'other'이므로 loaded 상태로 구분 + const isUserInitiated = Platform.OS === 'ios' + ? request.navigationType === 'click' + : loaded; + if (isUserInitiated) { + router.push({ pathname: '/webview/[slug]', params: { slug: 'external', url: request.url } }); + return false; + } + return true; + } + return true; + }, + [router, loaded], + ); + + // Android 하드웨어 뒤로가기: 웹뷰 히스토리가 있으면 웹뷰 back, 없으면 기본 동작(종료) + useEffect(() => { + const subscription = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + if (canGoBackRef.current) { + webViewRef.current?.goBack(); + return true; + } + return false; + }, + ); + return () => subscription.remove(); + }, []); + return ( {!loaded && ( @@ -131,11 +197,14 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { userAgent={USER_AGENT} onMessage={handleMessage} onLoadEnd={handleLoadEnd} + onNavigationStateChange={handleNavigationStateChange} + onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest} onError={handleError} onHttpError={handleError} javaScriptEnabled domStorageEnabled pullToRefreshEnabled + allowsBackForwardNavigationGestures /> )} diff --git a/ui/subscribe/components/empty-state.tsx b/ui/subscribe/components/empty-state.tsx deleted file mode 100644 index 27926c3..0000000 --- a/ui/subscribe/components/empty-state.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * 구독 빈 상태 컴포넌트 - */ - -import { MoaImage } from '@/components/moa-image'; -import { MoaText } from '@/components/moa-text'; -import { USER_EVENT } from '@/constants/eventname'; -import { MainColors } from '@/constants/theme'; -import { useMixpanelTrack } from '@/hooks/use-mixpanel-track'; -import { useRouter } from 'expo-router'; -import React from 'react'; -import { TouchableOpacity } from 'react-native'; -import styled from 'styled-components/native'; - -/** - * 구독한 동아리가 없을 때 표시되는 컴포넌트 - */ -export function EmptyState() { - const router = useRouter(); - const trackEvent = useMixpanelTrack(); - - const handleGoHome = () => { - trackEvent(USER_EVENT.GO_HOME_BUTTON_CLICKED, { - from: 'subscribe_empty', - url: 'app://moadong/(tabs)/home', - }); - - router.push('/(tabs)'); - }; - - return ( - - - - - - 구독한 동아리가 없어요 - - - 관심있는 동아리를 구독하고{'\n'}새로운 모집 및 활동 소식을 받아보세요 - - - - 홈으로 가기 - - - ); -} - -// Styled Components -const Container = styled.View` - flex: 1; - justify-content: center; - align-items: center; - padding: 40px; - background-color: #fff; -`; - -const IconContainer = styled.View` - margin-bottom: 24px; -`; - -const Title = styled(MoaText)` - color: #111111; - margin-bottom: 12px; - text-align: center; -`; - -const Description = styled(MoaText)` - color: #989898; - text-align: center; - margin-bottom: 32px; - line-height: 24px; -`; - -const HomeButton = styled(TouchableOpacity)` - background-color: ${MainColors.main}; - padding-horizontal: 32px; - padding-vertical: 14px; - border-radius: 12px; -`; - -const ButtonText = styled(MoaText)` - color: #FFFFFF; -`; diff --git a/ui/subscribe/components/index.ts b/ui/subscribe/components/index.ts deleted file mode 100644 index 0984669..0000000 --- a/ui/subscribe/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 구독 화면 컴포넌트 Export - */ - -export * from './empty-state'; -export * from './subscribed-club-list'; - diff --git a/ui/subscribe/components/subscribed-club-list.tsx b/ui/subscribe/components/subscribed-club-list.tsx deleted file mode 100644 index a2eb835..0000000 --- a/ui/subscribe/components/subscribed-club-list.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 구독한 동아리 목록 컴포넌트 - */ - -import { Club } from '@/types/club.types'; -import { ClubCard } from '@/ui/home/components/club-card'; -import React, { RefObject } from 'react'; -import { ActivityIndicator, FlatList, RefreshControl } from 'react-native'; -import styled from 'styled-components/native'; - -/** - * 구독한 동아리 목록 Props - */ -interface SubscribedClubListProps { - clubs: Club[]; - loading: boolean; - onRefresh: () => void; - onClubPress: (club: Club) => void; - isSubscribed: (clubId: string) => boolean; - onSubscribeToggle: (club: Club) => Promise; - listRef?: RefObject>; -} - -/** - * 구독한 동아리 목록을 표시하는 컴포넌트 - */ -export function SubscribedClubList({ - clubs, - loading, - onRefresh, - onClubPress, - isSubscribed, - onSubscribeToggle, - listRef, -}: SubscribedClubListProps) { - const renderItem = ({ item }: { item: Club }) => ( - onSubscribeToggle(item)} - /> - ); - - const keyExtractor = (item: Club) => item.id; - - const renderListHeader = () => ( - - 총 {clubs.length}개의 동아리를 구독 중입니다 - - ); - - return ( - - } - showsVerticalScrollIndicator={false} - ListFooterComponent={ - loading ? ( - - - - ) : null - } - /> - ); -} - -// Styled Components -const HeaderContainer = styled.View` - padding-horizontal: 16px; - padding-vertical: 16px; -`; - -const CountText = styled.Text` - font-size: 14px; - color: #666666; - font-weight: 500; -`; - -const LoadingContainer = styled.View` - padding: 20px; - align-items: center; -`; - diff --git a/ui/subscribe/hook/index.ts b/ui/subscribe/hook/index.ts deleted file mode 100644 index 2e2fcb7..0000000 --- a/ui/subscribe/hook/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * 구독 화면 훅 Export - */ - -export * from './use-subscribe-screen'; - diff --git a/ui/subscribe/hook/use-subscribe-screen.ts b/ui/subscribe/hook/use-subscribe-screen.ts deleted file mode 100644 index 9c9cd45..0000000 --- a/ui/subscribe/hook/use-subscribe-screen.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 구독 화면 로직 커스텀 훅 - */ - -import { Club } from '@/types/club.types'; -import { useClubs } from '@/ui/home/hook/use-clubs'; -import { useSubscribedClubs } from '@/ui/home/hook/use-subscribed-clubs'; -import { useCallback, useMemo } from 'react'; - -/** - * 구독 화면 훅 반환 값 - */ -interface UseSubscribeScreenReturn { - subscribedClubs: Club[]; - loading: boolean; - error: string | null; - refetch: () => void; - isSubscribed: (clubId: string) => boolean; - toggleSubscribe: (clubId: string) => Promise<{ needsPermission: boolean }>; -} - -/** - * 구독 화면 데이터를 관리하는 훅 - * - * @example - * ```typescript - * const { - * subscribedClubs, - * loading, - * error, - * refetch, - * isSubscribed, - * toggleSubscribe, - * } = useSubscribeScreen(); - * ``` - */ -export function useSubscribeScreen(): UseSubscribeScreenReturn { - // 구독 동아리 ID 목록 가져오기 - const { - subscribedClubIds, - isSubscribed, - toggleSubscribe, - } = useSubscribedClubs(); - - // 모든 동아리 데이터 가져오기 - const { - clubs, - loading, - error, - refetch, - } = useClubs({ - initialCategory: undefined, // 전체 카테고리 - initialType: 'central', // 중앙동아리 - autoFetch: true, - }); - - /** - * 구독한 동아리만 필터링 - */ - const subscribedClubs = useMemo(() => { - return clubs.filter(club => subscribedClubIds.includes(club.id)); - }, [clubs, subscribedClubIds]); - - /** - * 새로고침 핸들러 - */ - const handleRefetch = useCallback(() => { - refetch(); - }, [refetch]); - - return { - subscribedClubs, - loading, - error, - refetch: handleRefetch, - isSubscribed, - toggleSubscribe, - }; -} - diff --git a/ui/subscribe/index.ts b/ui/subscribe/index.ts deleted file mode 100644 index 6a55f08..0000000 --- a/ui/subscribe/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * 구독 화면 Export - */ - -export * from './subscribe-screen'; -export { default } from './subscribe-screen'; - diff --git a/ui/subscribe/subscribe-screen.tsx b/ui/subscribe/subscribe-screen.tsx deleted file mode 100644 index dc40b57..0000000 --- a/ui/subscribe/subscribe-screen.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/** - * 구독 화면 컴포넌트 - */ - -import { MoaText } from '@/components/moa-text'; -import { PermissionDialog } from '@/components/permission-dialog'; -import { PAGE_VIEW_EVENT, USER_EVENT } from '@/constants/eventname'; -import { useMixpanelTrack } from '@/hooks/use-mixpanel-track'; -import { useTrackScreenView } from '@/hooks/use-track-screen-view'; -import { Club } from '@/types/club.types'; -import { useRouter } from 'expo-router'; -import React, { RefObject, useCallback, useRef, useState } from 'react'; -import { ActivityIndicator, FlatList, TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import styled from 'styled-components/native'; -import { EmptyState, SubscribedClubList } from './components'; -import { useSubscribeScreen } from './hook'; - -/** - * 구독 화면 메인 컴포넌트 - */ -export function SubscribeScreen() { - const insets = useSafeAreaInsets(); - const router = useRouter(); - const listRef = useRef | null>(null); - const [showPermissionDialog, setShowPermissionDialog] = useState(false); - - useTrackScreenView(PAGE_VIEW_EVENT.SUBSCRIBE_PAGE); - const trackEvent = useMixpanelTrack(); - - // 구독 화면 데이터 및 로직 - const { - subscribedClubs, - loading, - error, - refetch, - isSubscribed, - toggleSubscribe, - } = useSubscribeScreen(); - - /** - * 동아리 카드 클릭 핸들러 - */ - const handleClubPress = useCallback((club: Club) => { - if (!club?.id) { - return; - } - - trackEvent(USER_EVENT.CLUB_CARD_CLICKED, { - clubName: club.name, - category: club.category, - from: 'subscribe', - url: 'app://moadong/(tabs)/subscribe', - }); - - router.push({ - pathname: '/club/[id]', - params: { id: club.id, name: club.name } - }); - }, [router, trackEvent]); - - /** - * 구독 토글 핸들러 - */ - const handleSubscribeToggle = useCallback(async (club: Club) => { - const wasSubscribed = isSubscribed(club.id); - - trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { - clubName: club.name, - subscribed: !wasSubscribed, - from: 'subscribe', - url: 'app://moadong/(tabs)/subscribe', - }); - - const result = await toggleSubscribe(club.id); - if (result.needsPermission) { - setShowPermissionDialog(true); - } - }, [toggleSubscribe, isSubscribed, trackEvent]); - - /** - * 로딩 중 표시 - */ - if (loading && subscribedClubs.length === 0) { - return ( - -
- 구독 -
- - - -
- ); - } - - /** - * 에러 발생 시 표시 - */ - if (error && subscribedClubs.length === 0) { - return ( - -
- 구독 -
- - 구독한 동아리 목록을 불러오지 못했어요. - - 재시도 - - -
- ); - } - - /** - * 구독한 동아리가 없을 때 - */ - if (subscribedClubs.length === 0) { - return ( - -
- 구독 -
- -
- ); - } - - /** - * 구독한 동아리 목록 표시 - */ - return ( - -
- 구독 -
- >} - /> - - {/* 알림 권한 다이얼로그 */} - setShowPermissionDialog(false)} - /> -
- ); -} - -// Styled Components -const Container = styled(View)` - flex: 1; - background-color: #fff; -`; - -const Header = styled.View` - padding-horizontal: 16px; - padding-vertical: 16px; - border-bottom-width: 1px; - border-bottom-color: #F0F0F0; -`; - -const HeaderTitle = styled(MoaText)` - color: #111111; -`; - -const LoadingContainer = styled.View` - flex: 1; - justify-content: center; - align-items: center; -`; - -const ErrorContainer = styled.View` - flex: 1; - justify-content: center; - align-items: center; - padding: 24px; -`; - -const ErrorTitle = styled(MoaText)` - color: #3A3A3A; - text-align: center; - margin-bottom: 16px; - font-size: 18px; -`; - -const RetryButton = styled(TouchableOpacity)` - background-color: #FF5414; - padding-horizontal: 28px; - padding-vertical: 14px; - border-radius: 8px; -`; - -const RetryButtonText = styled(MoaText)` - color: #fff; - font-size: 16px; -`; - -export default SubscribeScreen;