From 2f166ba62ce51a7946c176f0b731a724af74c47d Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 20 May 2026 02:34:21 +0900 Subject: [PATCH 1/7] fix(native): toast respect safe area insets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iPhone 14 Pro/Dynamic Island 등 notched 기기에서 토스트가 status bar에 잘리는 문제 해결. - showToast: 모듈 레벨 latched topOffset 도입 (ToastSafeAreaBridge가 SafeAreaProvider 내부에서 inset 추적) - useToast hook 추가 (호출 컴포넌트가 Provider 안에 있을 때 사용 가능) - App.tsx: SafeAreaProvider 직속에 ToastSafeAreaBridge mount --- apps/native/App.tsx | 6 ++- .../scrap/components/Notification/Toast.tsx | 43 ++++++++++++++++++- .../scrap/components/Notification/index.ts | 2 +- apps/native/tsconfig.json | 2 + 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 50c0ad3b6..ffa046374 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -15,7 +15,10 @@ import { colors } from '@theme/tokens'; import '@/app/providers/global.css'; import '@/app/providers/api'; import { useLoadAssets, useDeepLinkHandler, useOTAUpdate } from '@hooks'; -import { toastConfig } from '@/features/student/scrap/components/Notification/Toast'; +import { + toastConfig, + ToastSafeAreaBridge, +} from '@/features/student/scrap/components/Notification/Toast'; import { PointingFeedbackQueueWiring } from '@/features/student/problem/services/PointingFeedbackQueueWiring'; import { BubbleQuestionPressQueueWiring } from '@features/student/problem/services/BubbleQuestionPressQueueWiring'; import { env } from '@utils'; @@ -68,6 +71,7 @@ export default function App() { + {isReady && ( { Toast.show({ - type: type, + type, text1: message, - topOffset: 30, // 위쪽 위치 조정 + topOffset: latchedTopOffset, visibilityTime: 3000, }); }; +/** + * Mount once at app root inside SafeAreaProvider to keep `showToast` topOffset + * in sync with the current safe-area inset. Without it `showToast` falls back + * to a sensible default (30) and may clip on notched devices. + */ +export const ToastSafeAreaBridge = () => { + const insets = useSafeAreaInsets(); + useEffect(() => { + latchedTopOffset = insets.top + TOAST_TOP_PADDING; + }, [insets.top]); + return null; +}; + +/** + * Hook variant for callers that already render inside SafeAreaProvider — + * avoids module-level latching latency. + */ +export const useToast = () => { + const insets = useSafeAreaInsets(); + return useCallback( + (type: string, message: string) => { + Toast.show({ + type, + text1: message, + topOffset: insets.top + TOAST_TOP_PADDING, + visibilityTime: 3000, + }); + }, + [insets.top] + ); +}; + export const toastConfig: ToastConfig = { success: (props) => ( Date: Wed, 20 May 2026 02:34:32 +0900 Subject: [PATCH 2/7] feat(native): connect terms, privacy, support menu links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 학생앱 심사 정책 정합성: - src/constants/termsUrls.ts: 약관/개인정보/마케팅 Notion URL을 단일 source로 추출 - tsconfig.json: @constants 별칭 추가 - SignupTermsScreen: TERMS_URLS을 새 상수에서 import (구 in-file 상수 제거) - TermsScreen(메뉴): handleTermPress 활성화 — in-app WebBrowser로 Notion 문서 오픈 - MenuScreen: 고객센터 진입 시 mailto:develop@math-pointer.com 메일 컴포저 (학생앱 문의 prefilled) --- apps/native/src/constants/termsUrls.ts | 7 +++++++ .../features/auth/signup/screens/SignupTermsScreen.tsx | 7 +------ .../src/features/student/menu/screens/MenuScreen.tsx | 5 ++--- .../src/features/student/menu/screens/TermsScreen.tsx | 10 +++++++--- 4 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 apps/native/src/constants/termsUrls.ts diff --git a/apps/native/src/constants/termsUrls.ts b/apps/native/src/constants/termsUrls.ts new file mode 100644 index 000000000..680068410 --- /dev/null +++ b/apps/native/src/constants/termsUrls.ts @@ -0,0 +1,7 @@ +export const TERMS_URLS = { + service: 'https://www.notion.so/2b4fa6e6a8fe80119c13d84d794a63a3?source=copy_link', + privacy: 'https://www.notion.so/2b4fa6e6a8fe8031ac2aef3009552575?source=copy_link', + marketing: 'https://www.notion.so/2b4fa6e6a8fe80359e8bf33c870967d9?source=copy_link', +} as const; + +export type TermsKey = keyof typeof TERMS_URLS; diff --git a/apps/native/src/features/auth/signup/screens/SignupTermsScreen.tsx b/apps/native/src/features/auth/signup/screens/SignupTermsScreen.tsx index cb061f0b6..07498171d 100644 --- a/apps/native/src/features/auth/signup/screens/SignupTermsScreen.tsx +++ b/apps/native/src/features/auth/signup/screens/SignupTermsScreen.tsx @@ -10,12 +10,7 @@ import { useAuthStore } from '@stores'; import { useSignupStore } from '@features/auth/signup/store/useSignupStore'; import type { AuthStackParamList } from '@navigation/auth/AuthNavigator'; import { OnboardingLayout } from '@features/student/onboarding/components'; - -const TERMS_URLS = { - service: 'https://www.notion.so/2b4fa6e6a8fe80119c13d84d794a63a3?source=copy_link', - privacy: 'https://www.notion.so/2b4fa6e6a8fe8031ac2aef3009552575?source=copy_link', - marketing: 'https://www.notion.so/2b4fa6e6a8fe80359e8bf33c870967d9?source=copy_link', -} as const; +import { TERMS_URLS } from '@constants/termsUrls'; type AgreementState = { age: boolean; diff --git a/apps/native/src/features/student/menu/screens/MenuScreen.tsx b/apps/native/src/features/student/menu/screens/MenuScreen.tsx index 37b245c8a..7c305a7f0 100644 --- a/apps/native/src/features/student/menu/screens/MenuScreen.tsx +++ b/apps/native/src/features/student/menu/screens/MenuScreen.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react'; -import { ScrollView, Text, View } from 'react-native'; +import { Linking, ScrollView, Text, View } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useQueryClient } from '@tanstack/react-query'; @@ -20,7 +20,6 @@ import { MenuSection, } from '../components'; import { ConfirmationModal } from '../../scrap/components/Dialog'; -import { showToast } from '../../scrap/components/Notification'; const MenuScreen = () => { const navigation = useNavigation>(); @@ -96,7 +95,7 @@ const MenuScreen = () => { icon={Headset} title='고객센터' onPress={() => { - showToast('info', '고객센터 준비 중입니다.'); + void Linking.openURL('mailto:develop@math-pointer.com?subject=학생앱 문의'); }} /> { - const handleTermPress = (term: TermsItem) => {}; + const handleTermPress = (term: TermsItem) => { + const url = TERMS_URLS[term.id]; + if (url) void WebBrowser.openBrowserAsync(url); + }; return ( From d879a2d9eca1b32e6150ccc117b8e77d29f9af8b Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 20 May 2026 02:34:43 +0900 Subject: [PATCH 3/7] fix(native): notification settings hide qna toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QnA 채팅 알림은 정식 출시 전까지 비활성: - putAllowPush.ts: QNA_PUSH_DISABLED 상수 + sanitizePushSettings 헬퍼 추출. usePutAllowPush의 mutationFn에서 자동으로 isAllowQnaPush:false 동봉 - NotificationSettingsScreen: QnA 토글 UI/state/handler 제거(주석 자리만 유지), 자동 push-on 분기 및 모든 PUT 호출에서 isAllowQnaPush:false 강제 --- .../controller/student/me/putAllowPush.ts | 12 +++- .../screens/NotificationSettingsScreen.tsx | 55 ++++++------------- 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/apps/native/src/apis/controller/student/me/putAllowPush.ts b/apps/native/src/apis/controller/student/me/putAllowPush.ts index 3ed511110..56040fe29 100644 --- a/apps/native/src/apis/controller/student/me/putAllowPush.ts +++ b/apps/native/src/apis/controller/student/me/putAllowPush.ts @@ -1,17 +1,25 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { type components } from '@schema'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client, TanstackQueryClient } from '@apis/client'; type UpdatePushSettingsRequest = components['schemas']['StudentPushDTO.UpdateSettingsRequest']; +// MAT-746 PR-3: QnA 채팅 알림은 정식 출시 전까지 비활성. PUT body에 항상 false 동봉. +export const QNA_PUSH_DISABLED = true; + +export const sanitizePushSettings = ( + settings: UpdatePushSettingsRequest +): UpdatePushSettingsRequest => + QNA_PUSH_DISABLED ? { ...settings, isAllowQnaPush: false } : settings; + const usePutAllowPush = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data: UpdatePushSettingsRequest) => { const response = await client.PUT('/api/student/me/push/settings', { - body: data, + body: sanitizePushSettings(data), }); if (response.error || !response.data) { throw new Error('알림 설정 변경에 실패했습니다.'); diff --git a/apps/native/src/features/student/menu/screens/NotificationSettingsScreen.tsx b/apps/native/src/features/student/menu/screens/NotificationSettingsScreen.tsx index 381508ce2..033e80ff1 100644 --- a/apps/native/src/features/student/menu/screens/NotificationSettingsScreen.tsx +++ b/apps/native/src/features/student/menu/screens/NotificationSettingsScreen.tsx @@ -45,7 +45,6 @@ type SaveSettingsOptions = { const hasSameSettings = (a: PushSettingsPayload, b: PushSettingsPayload): boolean => a.isAllowPush === b.isAllowPush && a.isAllowServicePush === b.isAllowServicePush && - a.isAllowQnaPush === b.isAllowQnaPush && a.isAllowMarketingPush === b.isAllowMarketingPush; const NotificationSettingsScreen = () => { @@ -59,28 +58,25 @@ const NotificationSettingsScreen = () => { const [_pushEnabled, setPushEnabled] = useState(); const [_serviceNotification, setServiceNotification] = useState(); - const [_qnaNotification, setQnaNotification] = useState(); const [_eventNotification, setEventNotification] = useState(); const pushEnabled = _pushEnabled ?? pushSettingData?.isAllowPush ?? false; const serviceNotification = _serviceNotification ?? pushSettingData?.isAllowServicePush ?? false; - const qnaNotification = _qnaNotification ?? pushSettingData?.isAllowQnaPush ?? false; const eventNotification = _eventNotification ?? pushSettingData?.isAllowMarketingPush ?? false; const getCurrentSettings = useCallback( (): PushSettingsPayload => ({ isAllowPush: pushEnabled, isAllowServicePush: serviceNotification, - isAllowQnaPush: qnaNotification, + isAllowQnaPush: false, isAllowMarketingPush: eventNotification, }), - [pushEnabled, serviceNotification, qnaNotification, eventNotification] + [pushEnabled, serviceNotification, eventNotification] ); const applyLocalSettings = useCallback((settings: PushSettingsPayload) => { setPushEnabled(settings.isAllowPush); setServiceNotification(settings.isAllowServicePush); - setQnaNotification(settings.isAllowQnaPush); setEventNotification(settings.isAllowMarketingPush); }, []); @@ -90,16 +86,19 @@ const NotificationSettingsScreen = () => { previousSettings: PushSettingsPayload, options?: SaveSettingsOptions ) => { - updatePushSettings(nextSettings, { - onSuccess: () => { - showToast('success', '알림 설정이 변경되었습니다.'); - options?.onSuccess?.(); - }, - onError: () => { - applyLocalSettings(previousSettings); - showToast('error', '알림 설정 변경에 실패했습니다. 다시 시도해주세요.'); - }, - }); + updatePushSettings( + { ...nextSettings, isAllowQnaPush: false }, + { + onSuccess: () => { + showToast('success', '알림 설정이 변경되었습니다.'); + options?.onSuccess?.(); + }, + onError: () => { + applyLocalSettings(previousSettings); + showToast('error', '알림 설정 변경에 실패했습니다. 다시 시도해주세요.'); + }, + } + ); }, [updatePushSettings, applyLocalSettings] ); @@ -110,7 +109,6 @@ const NotificationSettingsScreen = () => { if ( !!pushSettingData.isAllowPush || !!pushSettingData.isAllowServicePush || - !!pushSettingData.isAllowQnaPush || !!pushSettingData.isAllowMarketingPush ) { hasInitializedSubTogglesRef.current = true; @@ -133,7 +131,7 @@ const NotificationSettingsScreen = () => { ? { isAllowPush: true, isAllowServicePush: true, - isAllowQnaPush: true, + isAllowQnaPush: false, isAllowMarketingPush: true, } : { @@ -193,12 +191,11 @@ const NotificationSettingsScreen = () => { isAllowPush: newValue, }; - // 최초 푸시 ON 시점에만 하위 3개를 모두 ON 처리 + // 최초 푸시 ON 시점에만 하위 토글을 모두 ON 처리 (QnA 제외) if (newValue && !hasInitializedSubTogglesRef.current) { nextSettings = { ...nextSettings, isAllowServicePush: true, - isAllowQnaPush: true, isAllowMarketingPush: true, }; hasInitializedSubTogglesRef.current = true; @@ -229,16 +226,6 @@ const NotificationSettingsScreen = () => { saveSettings(nextSettings, previousSettings); }; - const handleQnaNotificationChange = (newValue: boolean) => { - const previousSettings = getCurrentSettings(); - const nextSettings = { - ...previousSettings, - isAllowQnaPush: newValue, - }; - applyLocalSettings(nextSettings); - saveSettings(nextSettings, previousSettings); - }; - const handleEventNotificationChange = (newValue: boolean) => { const previousSettings = getCurrentSettings(); const nextSettings = { @@ -269,13 +256,7 @@ const NotificationSettingsScreen = () => { disabled={!pushEnabled} /> - + {/* TODO: QnA 정식 출시 시 해제 — MAT-746 PR-3 */} Date: Wed, 20 May 2026 02:35:01 +0900 Subject: [PATCH 4/7] feat(native): add push consent step in onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 학생앱 심사 권한 요청 타이밍 정합 — onboarding 마지막 단계 직전에 PushConsentStep 신설: - PushConsentStep: 3-phase UI (request → tune → denied) · Phase request: "알림 허용" CTA → messaging().requestPermission() · Phase tune: 서비스(default ON)/이벤트 마케팅(default OFF) 토글 2개, "완료" 시 PUT /api/student/me/push/settings + FCM 토큰 등록 · Phase denied: OS 권한 거부 시 "설정 앱으로 이동" CTA (Linking.openSettings) · "다음에 받기" 선택 시 서버 호출 0, FCM 토큰 등록 0 - OnboardingStackParamList에 PushConsent route 추가, OnboardingScreen에 등록 - useFinishOnboarding: register 성공 후 Welcome 대신 PushConsent로 reset - useOnboardingResume: PushConsent는 Welcome과 동일하게 early-return --- .../onboarding/hooks/useFinishOnboarding.ts | 4 +- .../onboarding/hooks/useOnboardingResume.ts | 2 +- .../onboarding/screens/OnboardingScreen.tsx | 5 +- .../screens/steps/PushConsentStep.tsx | 182 ++++++++++++++++++ .../student/onboarding/screens/types.ts | 1 + 5 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx diff --git a/apps/native/src/features/student/onboarding/hooks/useFinishOnboarding.ts b/apps/native/src/features/student/onboarding/hooks/useFinishOnboarding.ts index 09d025fc1..3181d00bd 100644 --- a/apps/native/src/features/student/onboarding/hooks/useFinishOnboarding.ts +++ b/apps/native/src/features/student/onboarding/hooks/useFinishOnboarding.ts @@ -105,11 +105,11 @@ const useFinishOnboarding = (args?: FinishArgs) => { } } - setCurrentStep('Welcome'); + setCurrentStep('PushConsent'); navigation.dispatch( CommonActions.reset({ index: 0, - routes: [{ name: 'Welcome' }], + routes: [{ name: 'PushConsent' }], }) ); return { ok: true }; diff --git a/apps/native/src/features/student/onboarding/hooks/useOnboardingResume.ts b/apps/native/src/features/student/onboarding/hooks/useOnboardingResume.ts index 1c01c8ab5..b960e3018 100644 --- a/apps/native/src/features/student/onboarding/hooks/useOnboardingResume.ts +++ b/apps/native/src/features/student/onboarding/hooks/useOnboardingResume.ts @@ -27,7 +27,7 @@ const useOnboardingResume = () => { if (restoredRef.current) return; if (status !== 'in-progress') return; const resumeStep = resumeStepRef.current; - if (resumeStep === 'Grade' || resumeStep === 'Welcome') return; + if (resumeStep === 'Grade' || resumeStep === 'PushConsent' || resumeStep === 'Welcome') return; if (currentTypeStatus !== 'resolved' && currentTypeStatus !== 'error') return; const hasActiveMockExam = diff --git a/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx b/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx index 0b7acf663..979cf8a26 100644 --- a/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx +++ b/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx @@ -9,6 +9,7 @@ import GradeStep from './steps/GradeStep'; import MathSubjectStep from './steps/MathSubjectStep'; import SchoolStep from './steps/SchoolStep'; import MockExamStep from './steps/MockExamStep'; +import PushConsentStep from './steps/PushConsentStep'; import WelcomeStep from './steps/WelcomeStep'; const Stack = createNativeStackNavigator(); @@ -26,7 +27,8 @@ const OnboardingScreen = () => { if (status === 'idle') markStarted(); }, [status, markStarted]); - const initialRouteName = currentStep === 'Welcome' ? 'Welcome' : 'Grade'; + const initialRouteName = + currentStep === 'Welcome' || currentStep === 'PushConsent' ? 'Welcome' : 'Grade'; return ( { + ); diff --git a/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx b/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx new file mode 100644 index 000000000..f205cb0b4 --- /dev/null +++ b/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Linking, Platform, Text, View } from 'react-native'; +import { CommonActions } from '@react-navigation/native'; +import messaging from '@react-native-firebase/messaging'; + +import { client } from '@apis/client'; +import { sanitizePushSettings } from '@apis/controller/student/me/putAllowPush'; +import { postPushToken } from '@apis/controller/student/me'; + +import { OnboardingLayout } from '../../components'; +import { SettingsToggleItem } from '../../../menu/components'; +import { useOnboardingStore } from '../../store/useOnboardingStore'; +import type { OnboardingScreenProps } from '../types'; + +type Phase = 'request' | 'tune' | 'denied'; + +const checkOsNotificationPermission = async (): Promise => { + const status = await messaging().hasPermission(); + return ( + status === messaging.AuthorizationStatus.AUTHORIZED || + status === messaging.AuthorizationStatus.PROVISIONAL + ); +}; + +const requestOsNotificationPermission = async (): Promise => { + const status = await messaging().requestPermission(); + return ( + status === messaging.AuthorizationStatus.AUTHORIZED || + status === messaging.AuthorizationStatus.PROVISIONAL + ); +}; + +const PushConsentStep = ({ navigation }: OnboardingScreenProps<'PushConsent'>) => { + const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); + + const [phase, setPhase] = useState('request'); + const [isProcessing, setIsProcessing] = useState(false); + const [servicePush, setServicePush] = useState(true); + const [marketingPush, setMarketingPush] = useState(false); + + useEffect(() => { + setCurrentStep('PushConsent'); + }, [setCurrentStep]); + + const goToWelcome = useCallback(() => { + setCurrentStep('Welcome'); + navigation.dispatch( + CommonActions.reset({ + index: 0, + routes: [{ name: 'Welcome' }], + }) + ); + }, [navigation, setCurrentStep]); + + const handleAllow = useCallback(async () => { + if (isProcessing) return; + setIsProcessing(true); + try { + const alreadyGranted = await checkOsNotificationPermission(); + const granted = alreadyGranted || (await requestOsNotificationPermission()); + + if (!granted) { + setPhase('denied'); + return; + } + setPhase('tune'); + } catch (error) { + console.error('[PushConsentStep] permission request failed:', error); + setPhase('denied'); + } finally { + setIsProcessing(false); + } + }, [isProcessing]); + + const handleSkip = useCallback(() => { + goToWelcome(); + }, [goToWelcome]); + + const handleOpenSettings = useCallback(() => { + void Linking.openSettings(); + }, []); + + const handleConfirm = useCallback(async () => { + if (isProcessing) return; + setIsProcessing(true); + try { + await client.PUT('/api/student/me/push/settings', { + body: sanitizePushSettings({ + isAllowPush: true, + isAllowServicePush: servicePush, + isAllowMarketingPush: marketingPush, + }), + }); + + try { + const token = await messaging().getToken(); + if (token) await postPushToken(token); + } catch (tokenError) { + console.warn('[PushConsentStep] FCM token registration failed:', tokenError); + } + } catch (error) { + console.error('[PushConsentStep] save settings failed:', error); + } finally { + setIsProcessing(false); + goToWelcome(); + } + }, [goToWelcome, isProcessing, marketingPush, servicePush]); + + if (phase === 'tune') { + return ( + + + + + + + + ); + } + + if (phase === 'denied') { + return ( + + + + 허용 후 이 화면으로 돌아오면 알림 수신 설정을 마칠 수 있어요. + + + + ); + } + + return ( + + + + 출제진/선생님 답변, 새 문제집 출시 등 학습에 도움이 되는 소식을 알려드려요. + + + + ); +}; + +export default PushConsentStep; diff --git a/apps/native/src/features/student/onboarding/screens/types.ts b/apps/native/src/features/student/onboarding/screens/types.ts index 8eb636873..6978d4ce4 100644 --- a/apps/native/src/features/student/onboarding/screens/types.ts +++ b/apps/native/src/features/student/onboarding/screens/types.ts @@ -5,6 +5,7 @@ export type OnboardingStackParamList = { MathSubject: undefined; School: undefined; MockExam: undefined; + PushConsent: undefined; Welcome: undefined; }; From 1003f52380f5ea36eecaf162bf315579dca444ac Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 20 May 2026 04:57:30 +0900 Subject: [PATCH 5/7] style(native): satisfy prettier tailwind class order in PushConsentStep --- .../student/onboarding/screens/steps/PushConsentStep.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx b/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx index f205cb0b4..74fa98e2a 100644 --- a/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx +++ b/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx @@ -150,7 +150,7 @@ const PushConsentStep = ({ navigation }: OnboardingScreenProps<'PushConsent'>) = onSkip={handleSkip} showBackButton={false}> - + 허용 후 이 화면으로 돌아오면 알림 수신 설정을 마칠 수 있어요. @@ -171,7 +171,7 @@ const PushConsentStep = ({ navigation }: OnboardingScreenProps<'PushConsent'>) = skipDisabled={isProcessing} showBackButton={false}> - + 출제진/선생님 답변, 새 문제집 출시 등 학습에 도움이 되는 소식을 알려드려요. From 313c7c0bc7e92afbd72713a14e6ad160d7f6fa54 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 20 May 2026 05:41:12 +0900 Subject: [PATCH 6/7] fix(native): keep PushConsent route on resume + clarify token register intent --- .../features/student/onboarding/screens/OnboardingScreen.tsx | 2 +- .../student/onboarding/screens/steps/PushConsentStep.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx b/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx index 979cf8a26..b1964e7ae 100644 --- a/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx +++ b/apps/native/src/features/student/onboarding/screens/OnboardingScreen.tsx @@ -28,7 +28,7 @@ const OnboardingScreen = () => { }, [status, markStarted]); const initialRouteName = - currentStep === 'Welcome' || currentStep === 'PushConsent' ? 'Welcome' : 'Grade'; + currentStep === 'Welcome' || currentStep === 'PushConsent' ? currentStep : 'Grade'; return ( ) = }), }); + // 사용자가 명시적으로 동의한 직후 즉시 토큰 등록. + // StudentNavigator 마운트 시 useFcmToken hook이 다시 한 번 등록을 시도하지만, + // 같은 endpoint(idempotent) + 동일 device token이라 race 무해. try { const token = await messaging().getToken(); if (token) await postPushToken(token); From 9c230fd3217fc98df15b4ebc0e979d96674afac4 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Wed, 20 May 2026 06:13:22 +0900 Subject: [PATCH 7/7] fix(native): address push consent review feedback --- .../student/menu/screens/MenuScreen.tsx | 20 +++++++++- .../screens/NotificationSettingsScreen.tsx | 6 +-- .../screens/steps/PushConsentStep.tsx | 40 +++++++++++++++++-- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/apps/native/src/features/student/menu/screens/MenuScreen.tsx b/apps/native/src/features/student/menu/screens/MenuScreen.tsx index 7c305a7f0..ae3f8ba61 100644 --- a/apps/native/src/features/student/menu/screens/MenuScreen.tsx +++ b/apps/native/src/features/student/menu/screens/MenuScreen.tsx @@ -20,6 +20,10 @@ import { MenuSection, } from '../components'; import { ConfirmationModal } from '../../scrap/components/Dialog'; +import { showToast } from '../../scrap/components/Notification'; + +const SUPPORT_EMAIL = 'develop@math-pointer.com'; +const SUPPORT_MAILTO_URL = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent('학생앱 문의')}`; const MenuScreen = () => { const navigation = useNavigation>(); @@ -48,6 +52,20 @@ const MenuScreen = () => { } }, [signOut]); + const handleSupportPress = useCallback(async () => { + try { + const canOpenMail = await Linking.canOpenURL(SUPPORT_MAILTO_URL); + if (!canOpenMail) { + showToast('error', '메일 앱을 열 수 없습니다.'); + return; + } + await Linking.openURL(SUPPORT_MAILTO_URL); + } catch (error) { + console.error('Failed to open support email', error); + showToast('error', '메일 앱을 열 수 없습니다.'); + } + }, []); + const isTablet = useIsTablet(); return ( @@ -95,7 +113,7 @@ const MenuScreen = () => { icon={Headset} title='고객센터' onPress={() => { - void Linking.openURL('mailto:develop@math-pointer.com?subject=학생앱 문의'); + void handleSupportPress(); }} /> { isAllowPush: true, isAllowServicePush: true, isAllowQnaPush: false, - isAllowMarketingPush: true, + isAllowMarketingPush: false, } : { ...previousSettings, @@ -191,12 +191,12 @@ const NotificationSettingsScreen = () => { isAllowPush: newValue, }; - // 최초 푸시 ON 시점에만 하위 토글을 모두 ON 처리 (QnA 제외) + // 최초 푸시 ON 시점에는 서비스 알림만 기본 ON 처리 (QnA/마케팅 제외) if (newValue && !hasInitializedSubTogglesRef.current) { nextSettings = { ...nextSettings, isAllowServicePush: true, - isAllowMarketingPush: true, + isAllowMarketingPush: false, }; hasInitializedSubTogglesRef.current = true; } diff --git a/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx b/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx index 4059340e4..a36b6d185 100644 --- a/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx +++ b/apps/native/src/features/student/onboarding/screens/steps/PushConsentStep.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; -import { Linking, Platform, Text, View } from 'react-native'; +import { AppState, Linking, Platform, Text, View } from 'react-native'; import { CommonActions } from '@react-navigation/native'; import messaging from '@react-native-firebase/messaging'; import { client } from '@apis/client'; import { sanitizePushSettings } from '@apis/controller/student/me/putAllowPush'; import { postPushToken } from '@apis/controller/student/me'; +import { showToast } from '@features/student/scrap/components/Notification'; import { OnboardingLayout } from '../../components'; import { SettingsToggleItem } from '../../../menu/components'; @@ -80,11 +81,30 @@ const PushConsentStep = ({ navigation }: OnboardingScreenProps<'PushConsent'>) = void Linking.openSettings(); }, []); + useEffect(() => { + if (phase !== 'denied') return; + + const syncPermission = async () => { + const granted = await checkOsNotificationPermission(); + if (granted) setPhase('tune'); + }; + + const subscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'active') { + void syncPermission(); + } + }); + + void syncPermission(); + + return () => subscription.remove(); + }, [phase]); + const handleConfirm = useCallback(async () => { if (isProcessing) return; setIsProcessing(true); try { - await client.PUT('/api/student/me/push/settings', { + const { error } = await client.PUT('/api/student/me/push/settings', { body: sanitizePushSettings({ isAllowPush: true, isAllowServicePush: servicePush, @@ -92,20 +112,32 @@ const PushConsentStep = ({ navigation }: OnboardingScreenProps<'PushConsent'>) = }), }); + if (error) { + console.warn('[PushConsentStep] save settings returned error:', error); + showToast('error', '알림 설정 저장에 실패했습니다. 다시 시도해주세요.'); + return; + } + // 사용자가 명시적으로 동의한 직후 즉시 토큰 등록. // StudentNavigator 마운트 시 useFcmToken hook이 다시 한 번 등록을 시도하지만, // 같은 endpoint(idempotent) + 동일 device token이라 race 무해. try { const token = await messaging().getToken(); - if (token) await postPushToken(token); + if (token) { + const { error: tokenError } = await postPushToken(token); + if (tokenError) { + console.warn('[PushConsentStep] FCM token registration returned error:', tokenError); + } + } } catch (tokenError) { console.warn('[PushConsentStep] FCM token registration failed:', tokenError); } + goToWelcome(); } catch (error) { console.error('[PushConsentStep] save settings failed:', error); + showToast('error', '알림 설정 저장에 실패했습니다. 다시 시도해주세요.'); } finally { setIsProcessing(false); - goToWelcome(); } }, [goToWelcome, isProcessing, marketingPush, servicePush]);