diff --git a/package.json b/package.json index b0d4bc62..ce81d44b 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@tanstack/react-query": "^5.66.9", "@tosspayments/widget-sdk-react-native": "^1.3.5", "@types/react-native-vector-icons": "^6.4.18", - "@ummgoban/shared": "^0.0.6-nightly.20250808.a3cf0f6", + "@ummgoban/shared": "^0.0.6-nightly.20250812.4b293fc", "axios": "^1.7.4", "dayjs": "^1.11.13", "react": "18.3.1", @@ -61,6 +61,7 @@ "react-native-splash-screen": "^3.3.0", "react-native-svg": "^15.8.0", "react-native-vector-icons": "^10.1.0", + "react-native-webview": "^13.15.0", "tosspayments-react-native-webview": "^1.0.0", "zustand": "^5.0.3" }, diff --git a/react-native-config.d.ts b/react-native-config.d.ts index e283d578..d2424138 100644 --- a/react-native-config.d.ts +++ b/react-native-config.d.ts @@ -10,6 +10,7 @@ declare module 'react-native-config' { NAVER_CONSUMER_SECRET_KEY: string; TOSS_CLIENT_KEY: string; TOSS_CUSTOMER_KEY: string; + WEBVIEW_URL: string; } export const Config: NativeConfig; diff --git a/src/apis/auth/query.ts b/src/apis/auth/query.ts index fd9458b1..08df6fee 100644 --- a/src/apis/auth/query.ts +++ b/src/apis/auth/query.ts @@ -22,6 +22,15 @@ import { } from './model'; import CustomError from '../CustomError'; +import {getStorage} from '@/utils/storage'; +import {SessionType} from '@ummgoban/shared'; + +export const useSession = () => + useQuery({ + queryKey: ['session'], + queryFn: async (): Promise => + await getStorage('session'), + }); export const useProfileQuery = () => useQuery({ diff --git a/src/components/common/Appbar/AppbarOptions.tsx b/src/components/common/Appbar/AppbarOptions.tsx index 2f96335a..84542fb0 100644 --- a/src/components/common/Appbar/AppbarOptions.tsx +++ b/src/components/common/Appbar/AppbarOptions.tsx @@ -1,14 +1,10 @@ -import React from 'react'; - import {StackNavigationOptions} from '@react-navigation/stack'; -import ChevronLeft from '@/assets/icons/chevron-left.svg'; +import {BackIcon} from './BackIcon'; export const defaultOptions: StackNavigationOptions = { headerShown: true, headerTitleAlign: 'left' as const, headerBackTitleVisible: false, - headerBackImage: ({tintColor}) => ( - - ), + headerLeft: BackIcon, }; diff --git a/src/components/common/Appbar/BackIcon.tsx b/src/components/common/Appbar/BackIcon.tsx new file mode 100644 index 00000000..5891e7d3 --- /dev/null +++ b/src/components/common/Appbar/BackIcon.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import styled from '@emotion/native'; + +import {useWebViewHistoryStore} from '@/webview'; + +import ChevronLeft from '@/assets/icons/chevron-left.svg'; +import {RootStackParamList} from '@/types/StackNavigationType'; +import {useNavigation} from '@react-navigation/native'; +import { + StackNavigationOptions, + StackNavigationProp, +} from '@react-navigation/stack'; + +const S = { + Container: styled.View` + display: flex; + align-items: center; + justify-content: center; + + width: 48px; + height: 48px; + padding: 8px; + `, + Icon: styled(ChevronLeft)` + width: 24px; + height: 24px; + `, +}; + +export const BackIcon: StackNavigationOptions['headerLeft'] = ({ + tintColor, + canGoBack, +}) => { + const navigation = useNavigation>(); + const {history, setHistory} = useWebViewHistoryStore(); + + if (!canGoBack && !history) { + return undefined; + } + + return ( + + { + setHistory(undefined); + if (history) { + navigation.navigate(history.screen, { + screen: history.screen, + params: history.params, + webview: { + uri: history.webUri, + }, + }); + return; + } + navigation.goBack(); + }} + /> + + ); +}; diff --git a/src/components/common/CartNavigatorIcon.tsx b/src/components/common/Appbar/CartNavigatorIcon.tsx similarity index 100% rename from src/components/common/CartNavigatorIcon.tsx rename to src/components/common/Appbar/CartNavigatorIcon.tsx diff --git a/src/components/common/HeaderIcon.style.tsx b/src/components/common/Appbar/HeaderIcon.style.tsx similarity index 100% rename from src/components/common/HeaderIcon.style.tsx rename to src/components/common/Appbar/HeaderIcon.style.tsx diff --git a/src/components/common/SettingsNavigatorIcon.tsx b/src/components/common/Appbar/SettingsNavigatorIcon.tsx similarity index 100% rename from src/components/common/SettingsNavigatorIcon.tsx rename to src/components/common/Appbar/SettingsNavigatorIcon.tsx diff --git a/src/components/common/Appbar/index.ts b/src/components/common/Appbar/index.ts new file mode 100644 index 00000000..91996804 --- /dev/null +++ b/src/components/common/Appbar/index.ts @@ -0,0 +1,5 @@ +import CartNavigatorIcon from './CartNavigatorIcon'; +import SettingsNavigatorIcon from './SettingsNavigatorIcon'; +import HeaderTitle from './HeaderTitle'; + +export {CartNavigatorIcon, SettingsNavigatorIcon, HeaderTitle}; diff --git a/src/components/map/MyLocationMap.tsx b/src/components/map/MyLocationMap.tsx index 7d87d645..0c322a95 100644 --- a/src/components/map/MyLocationMap.tsx +++ b/src/components/map/MyLocationMap.tsx @@ -6,6 +6,7 @@ import {StackNavigationProp} from '@react-navigation/stack'; import {RootStackParamList} from '@/types/StackNavigationType'; import S from './MyLocationMap.style'; +import {routeToDetail} from '@/navigation/navigator'; const MyLocationMap = ({ cords, @@ -20,10 +21,7 @@ const MyLocationMap = ({ const navigation = useNavigation>(); const handleMarkerClick = (marketId: number) => { if (marketId !== -1) { - navigation.navigate('Detail', { - screen: 'MarketDetail', - params: {marketId}, - }); + routeToDetail(navigation, marketId); } }; return ( diff --git a/src/components/marketDetailPage/Menu.style.tsx b/src/components/marketDetailPage/Menu.style.tsx index e74875dd..2fde6987 100644 --- a/src/components/marketDetailPage/Menu.style.tsx +++ b/src/components/marketDetailPage/Menu.style.tsx @@ -36,21 +36,17 @@ const MenuInfoWrapper = styled.View` const MenuoriginPrice = styled.Text` color: var(--gray-gray5, #979797); - font-size: 14px; - font-weight: 400; + ${({theme}) => theme.fonts.body2} text-decoration-line: line-through; `; const MenuDiscountPrice = styled.Text` color: #2d3e39; - font-size: 16px; - - font-weight: 700; + ${({theme}) => theme.fonts.body2} `; const MenuStockCount = styled.Text` - color: green; - font-size: 16px; - font-weight: 700; + color: ${props => props.theme.colors.tertiary}; + ${({theme}) => theme.fonts.body2} `; const MenuBoxRight = styled.View` display: flex; diff --git a/src/components/marketDetailPage/Menu.tsx b/src/components/marketDetailPage/Menu.tsx index ea9bea8e..6fd8895a 100644 --- a/src/components/marketDetailPage/Menu.tsx +++ b/src/components/marketDetailPage/Menu.tsx @@ -81,15 +81,15 @@ const Menu = ({product, initCount, onCountChange, isCart}: Props) => { {product.originPrice !== product.discountPrice ? ( - {`정가: ${product.originPrice.toLocaleString()}원`} + {product.originPrice.toLocaleString()}원 - {`할인가: ${product.discountPrice.toLocaleString()}원`} + {product.discountPrice.toLocaleString()}원 ) : ( - {`정가: ${product.originPrice.toLocaleString()}원`} + {product.originPrice.toLocaleString()}원 )} @@ -98,8 +98,6 @@ const Menu = ({product, initCount, onCountChange, isCart}: Props) => { ) : ( {`현재 재고가 없어요`} )} - - {`${!isCart ? '' : `수량: ${product.count}`}`} {isCart && ( diff --git a/src/components/orderDetail/OrderDescription.tsx b/src/components/orderDetail/OrderDescription.tsx index bdc7ef55..07d85b98 100644 --- a/src/components/orderDetail/OrderDescription.tsx +++ b/src/components/orderDetail/OrderDescription.tsx @@ -9,6 +9,7 @@ import {to6DigitHash} from '@ummgoban/shared'; import {DetailStackParamList} from '@/types/StackNavigationType'; import S from './OrderDescription.style'; +import {routeToDetail} from '@/navigation/navigator'; type Props = { id: string; @@ -48,9 +49,7 @@ const OrderCustomerInfo = ({id, navigation, orderDetail}: Props) => { {orderStatusText} - navigation.navigate('MarketDetail', {marketId: orderDetail.marketId}) - }> + onPress={() => routeToDetail(navigation, orderDetail.marketId)}> {orderDetail.marketName} {orderDetail.address} diff --git a/src/navigation/DetailNavigator.tsx b/src/navigation/DetailNavigator.tsx index bb67be7d..3e1cd8d6 100644 --- a/src/navigation/DetailNavigator.tsx +++ b/src/navigation/DetailNavigator.tsx @@ -6,7 +6,7 @@ import React from 'react'; import {defaultOptions} from '@/components/common/Appbar/AppbarOptions'; import HeaderTitle from '@/components/common/Appbar/HeaderTitle'; -import CartIcon from '@/components/common/CartNavigatorIcon'; +import CartIcon from '@/components/common/Appbar/CartNavigatorIcon'; import MarketDetailScreen from '@/screens/MarketDetailScreen'; import OrderDetailScreen from '@/screens/OrderDetailScreen'; diff --git a/src/navigation/FeedNavigator.tsx b/src/navigation/FeedNavigator.tsx index 11698505..6c41e9dc 100644 --- a/src/navigation/FeedNavigator.tsx +++ b/src/navigation/FeedNavigator.tsx @@ -5,7 +5,7 @@ import { import React from 'react'; import HeaderTitle from '@/components/common/Appbar/HeaderTitle'; -import CartIcon from '@/components/common/CartNavigatorIcon'; +import CartIcon from '@/components/common/Appbar/CartNavigatorIcon'; import {defaultOptions} from '@/components/common/Appbar/AppbarOptions'; import MapScreen from '@/screens/MapScreen'; diff --git a/src/navigation/HomeNavigator.tsx b/src/navigation/HomeNavigator.tsx index ae8bf3c1..7e2e4723 100644 --- a/src/navigation/HomeNavigator.tsx +++ b/src/navigation/HomeNavigator.tsx @@ -6,8 +6,8 @@ import { import React from 'react'; import HeaderTitle from '@/components/common/Appbar/HeaderTitle'; -import CartIcon from '@/components/common/CartNavigatorIcon'; -import SettingsIcon from '@/components/common/SettingsNavigatorIcon'; +import CartIcon from '@/components/common/Appbar/CartNavigatorIcon'; +import SettingsIcon from '@/components/common/Appbar/SettingsNavigatorIcon'; import {TabBar} from '@components/common'; import OrderHistoryScreen from '@/screens/OrderHistoryScreen'; diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 96139d85..c56e0ca0 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -6,11 +6,18 @@ import RegisterNavigator from './RegisterNavigator'; import DetailNavigator from './DetailNavigator'; import CartNavigator from './CartNavigator'; import MyPageNavigator from './MyPageNavigator'; +import withWebViewGate from './withWebViewGate'; import {RootStackParamList} from '@/types/StackNavigationType'; const Stack = createStackNavigator(); +const HomeWithGate = withWebViewGate(HomeNavigator); +const RegisterWithGate = withWebViewGate(RegisterNavigator); +const DetailWithGate = withWebViewGate(DetailNavigator); +const CartWithGate = withWebViewGate(CartNavigator); +const MyPageWithGate = withWebViewGate(MyPageNavigator); + const AppNavigator = () => { /** omit top, because of `@react-navigation/stack` appbar containing top safe area */ // const {left, right, bottom} = useSafeAreaInsets(); @@ -19,11 +26,11 @@ const AppNavigator = () => { - - - - - + + + + + ); }; diff --git a/src/navigation/navigator.ts b/src/navigation/navigator.ts new file mode 100644 index 00000000..fa1aaeb4 --- /dev/null +++ b/src/navigation/navigator.ts @@ -0,0 +1,32 @@ +import {StackNavigationProp} from '@react-navigation/stack'; + +import { + DetailStackParamList, + FeedStackParamList, + HomeStackParamList, + MyPageStackParamList, + RegisterStackParamList, + RootStackParamList, +} from '@/types/StackNavigationType'; +import Config from 'react-native-config'; + +export function routeToDetail( + navigation: StackNavigationProp< + | RootStackParamList + | HomeStackParamList + | FeedStackParamList + | RegisterStackParamList + | DetailStackParamList + | MyPageStackParamList + >, + marketId: number, +) { + navigation.navigate('Detail', { + screen: 'MarketDetail', + params: {marketId}, + webview: { + uri: `${Config.WEBVIEW_URL}/market/${marketId}`, + title: `맘찬픽 가게`, + }, + }); +} diff --git a/src/navigation/withWebViewGate.tsx b/src/navigation/withWebViewGate.tsx new file mode 100644 index 00000000..d6249279 --- /dev/null +++ b/src/navigation/withWebViewGate.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import {WebViewScreen} from '@/webview/WebViewScreen'; + +type RouteWithWeb = {params: {webview?: {uri: string; title?: string}}}; + +const withWebViewGate =

( + NavigatorComponent: React.ComponentType

, +) => { + return function WebViewGateComponent(props: P) { + const webview = props.route?.params?.webview; + + if (webview?.uri) { + return ; + } + return ; + }; +}; + +export default withWebViewGate; diff --git a/src/screens/CustomerReviewScreen/index.tsx b/src/screens/CustomerReviewScreen/index.tsx index e65313bf..2b7996f1 100644 --- a/src/screens/CustomerReviewScreen/index.tsx +++ b/src/screens/CustomerReviewScreen/index.tsx @@ -8,6 +8,7 @@ import S from './CustomerReviewScreen.style'; import {CustomerReviewCard} from '@/components/common/customerReview'; import {ActivityIndicator} from 'react-native-paper'; import EmptyComponent from '@/components/common/EmptyComponent'; +import {routeToDetail} from '@/navigation/navigator'; type CustomerReviewScreenProps = StackScreenProps< MyPageStackParamList, @@ -49,12 +50,7 @@ const CustomerReviewScreen = ({ } const navigateMarketDetail = (marketId: number) => { - navigation.navigate('Detail', { - screen: 'MarketDetail', - params: { - marketId: marketId, - }, - }); + routeToDetail(navigation, marketId); }; if (reviews.length === 0) { diff --git a/src/screens/FeedScreen/index.tsx b/src/screens/FeedScreen/index.tsx index c5185239..0699f75a 100644 --- a/src/screens/FeedScreen/index.tsx +++ b/src/screens/FeedScreen/index.tsx @@ -13,11 +13,13 @@ import {useMarketList} from '@/apis/markets'; import FeedBottomFloatingButton from '@/components/common/FeedBottomFloatingButton'; import {Market} from '@/components/feedPage'; -import usePullDownRefresh from '@/hooks/usePullDownRefresh'; import useGPSLocation from '@/hooks/useGPSLocation'; +import usePullDownRefresh from '@/hooks/usePullDownRefresh'; import {RootStackParamList} from '@/types/StackNavigationType'; +import {routeToDetail} from '@/navigation/navigator'; + import S from './Feed.style'; type Props = { @@ -36,10 +38,7 @@ const FeedScreen = ({navigation}: Props) => { const marketList = data ? data.pages.flatMap(page => page.markets) : []; const onPressStore = (marketId: number) => { - navigation.navigate('Detail', { - screen: 'MarketDetail', - params: {marketId}, - }); + routeToDetail(navigation, marketId); }; const navigateMap = () => { diff --git a/src/screens/MarketDetailScreen/MarketDetailPage.tsx b/src/screens/MarketDetailScreen/MarketDetailPage.tsx index 0b947f45..4f5533b8 100644 --- a/src/screens/MarketDetailScreen/MarketDetailPage.tsx +++ b/src/screens/MarketDetailScreen/MarketDetailPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import {useNavigation} from '@react-navigation/native'; import {StackNavigationProp} from '@react-navigation/stack'; @@ -10,6 +10,7 @@ import MarketOpenHourModal from '@/components/marketDetailPage/OpenHoursModal'; import S from './MarketDetail.style'; import {useCart, useScroll, useMarketDetail} from './hooks'; import {MarketInfo, ProductList, CartButton} from './components'; +import {routeToDetail} from '@/navigation/navigator'; const MarketDetailPage = ({ name, @@ -27,7 +28,16 @@ const MarketDetailPage = ({ averageRating, reviewNum, }: Omit) => { + //////////////////////////// + // 웹뷰로 모두 대체 // + //////////////////////////// + const navigation = useNavigation>(); + + useEffect(() => { + routeToDetail(navigation, id); + }, [navigation, id]); + const {profile} = useProfile(); // 커스텀 훅 사용 diff --git a/src/screens/MarketReviewScreen/index.tsx b/src/screens/MarketReviewScreen/index.tsx index 691185ec..64023cf2 100644 --- a/src/screens/MarketReviewScreen/index.tsx +++ b/src/screens/MarketReviewScreen/index.tsx @@ -1,17 +1,25 @@ import React, {useCallback} from 'react'; import {View, FlatList, RefreshControl} from 'react-native'; -import {StackScreenProps} from '@react-navigation/stack'; -import {DetailStackParamList} from '@/types/StackNavigationType'; +import {StackNavigationProp, StackScreenProps} from '@react-navigation/stack'; +import { + DetailStackParamList, + RootStackParamList, +} from '@/types/StackNavigationType'; import {useReadReviewListForMarket} from '@/apis/review'; import usePullDownRefresh from '@/hooks/usePullDownRefresh'; import S from './MarketReviewScreen.style'; import MarketReviewCard from '@/components/marketReview/MarketReviewCard'; import {ActivityIndicator} from 'react-native-paper'; +import EmptyComponent from '@/components/common/EmptyComponent'; +import {useNavigation} from '@react-navigation/native'; +import {routeToDetail} from '@/navigation/navigator'; + type MarketReviewScreenProps = StackScreenProps< DetailStackParamList, 'MarketReview' >; const MarketReviewScreen = ({route}: MarketReviewScreenProps) => { + const navigation = useNavigation>(); const { data: reviewList, refetch, @@ -41,6 +49,16 @@ const MarketReviewScreen = ({route}: MarketReviewScreenProps) => { ); } + if (reviews.length === 0) { + return ( + routeToDetail(navigation, route.params.marketId)} + buttonText="주문하러 가기" + /> + ); + } + return ( { const {data: historyList, refetch} = useOrderHistoryQuery(); @@ -60,12 +61,7 @@ const OrderHistoryScreen = () => { }> - navigation.navigate('Detail', { - screen: 'MarketDetail', - params: {marketId: marketId}, - }) - } + onPressMarket={marketId => routeToDetail(navigation, marketId)} /> ); diff --git a/src/screens/ShoppingCartScreen/ShoppingCartPage.tsx b/src/screens/ShoppingCartScreen/ShoppingCartPage.tsx index cb8249c2..a9602465 100644 --- a/src/screens/ShoppingCartScreen/ShoppingCartPage.tsx +++ b/src/screens/ShoppingCartScreen/ShoppingCartPage.tsx @@ -1,14 +1,19 @@ -import React, {useMemo, useState} from 'react'; -import {Alert} from 'react-native'; import {StackNavigationProp} from '@react-navigation/stack'; -import {BucketType} from '@/types/Bucket'; -import PaymentSummary from '@/components/orderPage/PaymentSummary'; -import S from './ShoppingCartPage.style'; +import React, {useMemo} from 'react'; +import {Alert} from 'react-native'; + +import {useUpdateBucket} from '@/apis/buckets'; import MarketInfo from '@/components/CartPage/MarketInfo'; -import {RootStackParamList} from '@/types/StackNavigationType'; -import {Menu} from '@/components/marketDetailPage'; import {BottomButton} from '@/components/common'; -import {useUpdateBucket} from '@/apis/buckets'; +import {Menu} from '@/components/marketDetailPage'; +import PaymentSummary from '@/components/orderPage/PaymentSummary'; +import {queryClient} from '@/context/ReactQueryProvider'; +import {routeToDetail} from '@/navigation/navigator'; + +import {BucketType} from '@/types/Bucket'; +import {RootStackParamList} from '@/types/StackNavigationType'; + +import S from './ShoppingCartPage.style'; type Props = { navigation: StackNavigationProp; @@ -17,10 +22,8 @@ type Props = { const ShoppingCartPage = ({navigation, cartData}: Props) => { const {mutate: updateBucketProduct} = useUpdateBucket(); - const [marketData, setMarketData] = useState(cartData); - const market = marketData?.market ?? cartData.market; - const products = marketData?.products ?? cartData.products; + const {market, products} = cartData; const {originPrice, discountPrice} = useMemo(() => { return products.reduce( @@ -42,10 +45,7 @@ const ShoppingCartPage = ({navigation, cartData}: Props) => { }, [market.closeAt]); const onPressStore = () => { - navigation.navigate('Detail', { - screen: 'MarketDetail', - params: {marketId: cartData.market.id}, - }); + routeToDetail(navigation, cartData.market.id); }; const onPressPayment = () => { @@ -60,7 +60,7 @@ const ShoppingCartPage = ({navigation, cartData}: Props) => { {productId, count}, { onSuccess: data => { - setMarketData(data); + queryClient.setQueryData(['bucketList'], data); }, onError: () => { Alert.alert('네트워크 오류입니다. 다시 시도해주세요.'); diff --git a/src/screens/SubscribeScreen/index.tsx b/src/screens/SubscribeScreen/index.tsx index a594175c..39e05f33 100644 --- a/src/screens/SubscribeScreen/index.tsx +++ b/src/screens/SubscribeScreen/index.tsx @@ -17,6 +17,7 @@ import SubscribeMarketCard from '@/components/subscribePage/SubscribeMarketCard' import {RootStackParamList} from '@/types/StackNavigationType'; import S from './SubscribeScreen.style'; +import {routeToDetail} from '@/navigation/navigator'; type Props = { navigation: StackNavigationProp; @@ -48,10 +49,7 @@ const SubscribeScreen = ({navigation}: Props) => { }); const onPressStore = (marketId: number) => { - navigation.navigate('Detail', { - screen: 'MarketDetail', - params: {marketId}, - }); + routeToDetail(navigation, marketId); }; const handleEndReached = () => { diff --git a/src/types/StackNavigationType.ts b/src/types/StackNavigationType.ts index 3124585f..0762cf24 100644 --- a/src/types/StackNavigationType.ts +++ b/src/types/StackNavigationType.ts @@ -2,9 +2,13 @@ import {ParamListBase} from '@react-navigation/native'; import {OrderType} from './OrderType'; import {ProductType} from '@ummgoban/shared/lib'; -type StackParamType = { +export type StackParamType = { screen?: keyof T; params?: T[keyof T]; + webview?: { + uri: string; + title?: string; + }; }; export interface HomeStackParamList extends ParamListBase { diff --git a/src/webview/WebViewScreen.tsx b/src/webview/WebViewScreen.tsx new file mode 100644 index 00000000..15463908 --- /dev/null +++ b/src/webview/WebViewScreen.tsx @@ -0,0 +1,148 @@ +import {useFocusEffect, useNavigation} from '@react-navigation/native'; +import {StackNavigationProp} from '@react-navigation/stack'; +import React, {useCallback, useState} from 'react'; +import {BackHandler, Linking, Platform} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {WebView, WebViewMessageEvent} from 'react-native-webview'; + +import {useSession} from '@/apis/auth'; +import type {RootStackParamList} from '@/types/StackNavigationType'; + +import {useWebRefStore, useWebViewHistoryStore} from './store'; +import type {ReceiveMessagePayloadType} from './types/receive-message.type'; +import { + isNativeGoBackPayload, + isNativeNavigationPayload, + isPlainPayload, + isUnknownPayload, +} from './utils'; + +import pkg from '../../package.json'; + +const injectedBefore = ` + (function() { + // RN에서 보낸 데이터를 Web이 받을 함수 + // -> 커스텀 이벤트로 앱 전역에 전달 + window.__fromRN = function(data) { + try { + window.dispatchEvent(new CustomEvent('APP_MESSAGE', { detail: data })); + } catch (e) { + console.error('APP_MESSAGE error', e); + } + }; + // 리스너가 없을 때 도착하는 메시지 대비 큐도 가능(선택사항) + window.__fromRNQueue = []; + const orig = window.__fromRN; + window.__fromRN = function(data) { + if (window.__hasAppMessageListener) return orig(data); + window.__fromRNQueue.push(data); + }; + window.addEventListener('APP_MESSAGE_LISTENER_READY', function() { + window.__hasAppMessageListener = true; + while (window.__fromRNQueue.length) { + orig(window.__fromRNQueue.shift()); + } + }); + })(); + true; + `; + +type Props = { + uri: string; + title?: string; +}; + +export const WebViewScreen = ({uri}: Props) => { + const {webRef, sendToWeb} = useWebRefStore(); + const {setHistory} = useWebViewHistoryStore(); + + const {data: session} = useSession(); + + const [canGoBack, setCanGoBack] = useState(false); + const {top, left, right, bottom} = useSafeAreaInsets(); + + const navigation = useNavigation>(); + + useFocusEffect( + useCallback(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + if (canGoBack) { + webRef.current?.goBack(); + return true; + } + return false; + }); + return () => sub.remove(); + }, [canGoBack, webRef]), + ); + + const onMessage = (e: WebViewMessageEvent) => { + try { + console.info('Web to RN message', e.nativeEvent.data); + const msg: ReceiveMessagePayloadType = JSON.parse(e.nativeEvent.data); + + if (isNativeNavigationPayload(msg)) { + setHistory(msg.payload.callbackState); + navigation.navigate(msg.payload.screen, msg.payload.params); + } else if (isNativeGoBackPayload(msg)) { + navigation.goBack(); + } else if (isPlainPayload(msg)) { + } else if (isUnknownPayload(msg)) { + } + } catch (err) { + console.error(`[WEBVIEW] ${err}`); + } + }; + + const sendInit = () => { + sendToWeb({ + type: 'INIT', + payload: { + platform: Platform.OS, + version: pkg.version, + ts: Date.now(), + }, + }); + sendToWeb({ + type: 'SAFE_AREA_INSETS', + payload: { + top, + left, + right, + bottom, + }, + }); + sendToWeb({ + type: 'AUTHORIZATION', + payload: { + accessToken: session?.accessToken, + refreshToken: session?.refreshToken, + }, + }); + }; + + return ( + setCanGoBack(s.canGoBack)} + onMessage={onMessage} + // 필요 시 스킴 필터/외부 링크 분리 + onShouldStartLoadWithRequest={req => { + if (!/^https?:/.test(req.url)) { + Linking.openURL(req.url); + return false; + } + return true; + }} + injectedJavaScriptBeforeContentLoaded={injectedBefore} + onLoadEnd={sendInit} + /> + ); +}; diff --git a/src/webview/index.ts b/src/webview/index.ts new file mode 100644 index 00000000..cbdaca2a --- /dev/null +++ b/src/webview/index.ts @@ -0,0 +1,4 @@ +export * from './store'; +export * from './utils'; + +export {WebViewScreen} from './WebViewScreen'; diff --git a/src/webview/store/index.ts b/src/webview/store/index.ts new file mode 100644 index 00000000..a4d9a6ca --- /dev/null +++ b/src/webview/store/index.ts @@ -0,0 +1,2 @@ +export * from './useWebRef.store'; +export * from './useWebViewHistory.store'; diff --git a/src/webview/store/useWebRef.store.ts b/src/webview/store/useWebRef.store.ts new file mode 100644 index 00000000..bae47047 --- /dev/null +++ b/src/webview/store/useWebRef.store.ts @@ -0,0 +1,25 @@ +import React from 'react'; +import WebView from 'react-native-webview'; +import {create} from 'zustand'; +import {PostMessageMethodType} from '../types/post-message.type'; + +interface WebRefStore { + webRef: React.RefObject; +} + +const webRefStore = create(() => ({ + webRef: React.createRef(), +})); + +export const useWebRefStore = () => { + const webRef = webRefStore(state => state.webRef); + + const sendToWeb = (payload: {type: PostMessageMethodType; payload: any}) => { + webRef.current?.injectJavaScript(` + window.__fromRN && window.__fromRN(${JSON.stringify(payload)}); + true; + `); + }; + + return {webRef, sendToWeb}; +}; diff --git a/src/webview/store/useWebViewHistory.store.ts b/src/webview/store/useWebViewHistory.store.ts new file mode 100644 index 00000000..0c059ed8 --- /dev/null +++ b/src/webview/store/useWebViewHistory.store.ts @@ -0,0 +1,21 @@ +import {create} from 'zustand'; +import {ReceiveMessageNativeNavigationPayload} from '../types/receive-message.type'; + +interface WebViewHistoryStore { + history: ReceiveMessageNativeNavigationPayload['payload']['callbackState']; + setHistory: ( + history: ReceiveMessageNativeNavigationPayload['payload']['callbackState'], + ) => void; +} + +const webviewHistoryStore = create(set => ({ + history: undefined, + setHistory: history => set({history}), +})); + +export const useWebViewHistoryStore = () => { + const history = webviewHistoryStore(state => state.history); + const setHistory = webviewHistoryStore(state => state.setHistory); + + return {history, setHistory}; +}; diff --git a/src/webview/types/post-message.type.ts b/src/webview/types/post-message.type.ts new file mode 100644 index 00000000..1433cf9f --- /dev/null +++ b/src/webview/types/post-message.type.ts @@ -0,0 +1,79 @@ +export type PostMessageMethodType = + | 'INIT' + | 'SAFE_AREA_INSETS' + | 'WEB_NAVIGATION' + | 'NATIVE_HISTORY' + | 'AUTHORIZATION'; + +export interface PostMessagePayloadType { + type: PostMessageMethodType; + payload?: object; +} + +export interface PostMessageSafeAreaInsetsPayload + extends PostMessagePayloadType { + type: 'SAFE_AREA_INSETS'; + payload: { + top: number; + bottom: number; + left: number; + right: number; + }; +} + +export interface PostMessageInitPayload extends PostMessagePayloadType { + type: 'INIT'; + payload: { + /** + * platform + */ + platform: 'ios' | 'android'; + /** + * app package version + */ + version: string; + /** + * UTC (ms) + */ + ts: number; + }; +} + +export interface PostMessageNavigationPayload extends PostMessagePayloadType { + type: 'WEB_NAVIGATION'; + payload: { + screen: string; + params?: object; + }; +} + +export interface PostMessageNativeHistoryPayload + extends PostMessagePayloadType { + type: 'NATIVE_HISTORY'; + payload: { + screen: string; + params?: object; + }; +} + +export interface PostMessageAuthorizationPayload + extends PostMessagePayloadType { + type: 'AUTHORIZATION'; + payload: { + accessToken: string; + refreshToken: string; + }; +} + +export type PostMessagePayload = + T extends 'SAFE_AREA_INSETS' + ? PostMessageSafeAreaInsetsPayload + : T extends 'INIT' + ? PostMessageInitPayload + : T extends 'WEB_NAVIGATION' + ? PostMessageNavigationPayload + : T extends 'NATIVE_HISTORY' + ? PostMessageNativeHistoryPayload + : T extends 'AUTHORIZATION' + ? PostMessageAuthorizationPayload + : never; diff --git a/src/webview/types/receive-message.type.ts b/src/webview/types/receive-message.type.ts new file mode 100644 index 00000000..d9e5f8bf --- /dev/null +++ b/src/webview/types/receive-message.type.ts @@ -0,0 +1,61 @@ +export type ReceiveMessageMethodType = + | 'NATIVE_NAVIGATION' + | 'NATIVE_GO_BACK' + | 'AUTHORIZATION' + | 'PLAIN' + | 'UNKNOWN'; + +export interface ReceiveMessagePayloadType { + type: ReceiveMessageMethodType; + payload?: object; +} + +/** + * Use React Native Stack Navigation + * - Navigation + */ +export interface ReceiveMessageNativeNavigationPayload + extends ReceiveMessagePayloadType { + type: 'NATIVE_NAVIGATION'; + payload: { + screen: string; + params?: object; + callbackState?: { + screen: string; + params?: object; + webUri: string; + }; + }; +} + +/** + * Use React Native Stack Navigation + * - Go Back Navigation + */ +export interface ReceiveMessageNativeGoBackPayload + extends ReceiveMessagePayloadType { + type: 'NATIVE_GO_BACK'; +} + +export interface ReceiveMessageAuthorizedPayload + extends ReceiveMessagePayloadType { + type: 'AUTHORIZATION'; +} + +export interface ReceiveMessagePlainPayload extends ReceiveMessagePayloadType { + type: 'PLAIN'; + payload: { + message: string; + }; +} + +export type ReceiveMessagePayload = + T extends 'NATIVE_NAVIGATION' + ? ReceiveMessageNativeNavigationPayload + : T extends 'NATIVE_GO_BACK' + ? ReceiveMessageNativeGoBackPayload + : T extends 'AUTHORIZATION' + ? ReceiveMessageAuthorizedPayload + : T extends 'PLAIN' + ? ReceiveMessagePlainPayload + : never; diff --git a/src/webview/utils/index.ts b/src/webview/utils/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/webview/utils/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/webview/utils/utils.ts b/src/webview/utils/utils.ts new file mode 100644 index 00000000..90ca4b7e --- /dev/null +++ b/src/webview/utils/utils.ts @@ -0,0 +1,19 @@ +import type { + ReceiveMessagePayload, + ReceiveMessagePayloadType, +} from '../types/receive-message.type'; + +export const isNativeNavigationPayload = ( + msg: ReceiveMessagePayloadType, +): msg is ReceiveMessagePayload<'NATIVE_NAVIGATION'> => + msg.type === 'NATIVE_NAVIGATION'; +export const isNativeGoBackPayload = ( + msg: ReceiveMessagePayloadType, +): msg is ReceiveMessagePayload<'NATIVE_GO_BACK'> => + msg.type === 'NATIVE_GO_BACK'; +export const isPlainPayload = ( + msg: ReceiveMessagePayloadType, +): msg is ReceiveMessagePayload<'PLAIN'> => msg.type === 'PLAIN'; +export const isUnknownPayload = ( + msg: ReceiveMessagePayloadType, +): msg is ReceiveMessagePayload<'UNKNOWN'> => msg.type === 'UNKNOWN'; diff --git a/yarn.lock b/yarn.lock index c6f3dab8..26e1963f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4453,14 +4453,14 @@ __metadata: languageName: node linkType: hard -"@ummgoban/shared@npm:^0.0.6-nightly.20250808.a3cf0f6": - version: 0.0.6-nightly.20250808.f18a523 - resolution: "@ummgoban/shared@npm:0.0.6-nightly.20250808.f18a523" +"@ummgoban/shared@npm:^0.0.6-nightly.20250812.4b293fc": + version: 0.0.6-nightly.20250812.4b293fc + resolution: "@ummgoban/shared@npm:0.0.6-nightly.20250812.4b293fc" peerDependencies: axios: ">=1.7.4" react: ">=18.0.0" react-native: ">=0.79.0" - checksum: e7b2817c8e85c7c963997ddd03490c809faf3bd313919902a4236f62bd34c17c619ac1967c6095ebe2cf305689120ef9625b1ad232a9576fd2b6cbfec0000455 + checksum: 6fd95dbf093a56a9cd921b190510abc696c491ace24fa2e6dfe7b269ce63130b259871ad4d9b35174bcc398320743d428cc4e38426ce1878bd9f5d9636264492 languageName: node linkType: hard @@ -4502,7 +4502,7 @@ __metadata: "@types/react": ^18.2.6 "@types/react-native-vector-icons": ^6.4.18 "@types/react-test-renderer": ^18.0.0 - "@ummgoban/shared": ^0.0.6-nightly.20250808.a3cf0f6 + "@ummgoban/shared": ^0.0.6-nightly.20250812.4b293fc axios: ^1.7.4 babel-jest: ^29.6.3 babel-plugin-module-resolver: ^5.0.2 @@ -4531,6 +4531,7 @@ __metadata: react-native-svg: ^15.8.0 react-native-svg-transformer: ^1.5.0 react-native-vector-icons: ^10.1.0 + react-native-webview: ^13.15.0 react-test-renderer: 18.3.1 tosspayments-react-native-webview: ^1.0.0 typescript: 5.0.4 @@ -10576,6 +10577,19 @@ __metadata: languageName: node linkType: hard +"react-native-webview@npm:^13.15.0": + version: 13.15.0 + resolution: "react-native-webview@npm:13.15.0" + dependencies: + escape-string-regexp: ^4.0.0 + invariant: 2.2.4 + peerDependencies: + react: "*" + react-native: "*" + checksum: 1c843206108358faef5cd8e25834de729a90319c3fa3f765923536fdd99b86b07a3e850b89ebe6503725a022dd9dddd5de76ef6e102d290cc5935dcf9c60198d + languageName: node + linkType: hard + "react-native@npm:0.75.2": version: 0.75.2 resolution: "react-native@npm:0.75.2"