Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/native/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +71,7 @@ export default function App() {
<BubbleQuestionPressQueueWiring />
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ToastSafeAreaBridge />
{isReady && (
<NavigationContainer
ref={navigationRef}
Expand Down
12 changes: 10 additions & 2 deletions apps/native/src/apis/controller/student/me/putAllowPush.ts
Original file line number Diff line number Diff line change
@@ -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('알림 설정 변경에 실패했습니다.');
Expand Down
7 changes: 7 additions & 0 deletions apps/native/src/constants/termsUrls.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
sterdsterd marked this conversation as resolved.

export type TermsKey = keyof typeof TERMS_URLS;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 19 additions & 2 deletions apps/native/src/features/student/menu/screens/MenuScreen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,6 +22,9 @@ import {
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<NativeStackNavigationProp<MenuStackParamList>>();
const signOut = useAuthStore((state) => state.signOut);
Expand Down Expand Up @@ -49,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 (
Expand Down Expand Up @@ -96,7 +113,7 @@ const MenuScreen = () => {
icon={Headset}
title='고객센터'
onPress={() => {
showToast('info', '고객센터 준비 중입니다.');
void handleSupportPress();
}}
/>
<MenuListItem
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -59,28 +58,25 @@ const NotificationSettingsScreen = () => {

const [_pushEnabled, setPushEnabled] = useState<boolean | undefined>();
const [_serviceNotification, setServiceNotification] = useState<boolean | undefined>();
const [_qnaNotification, setQnaNotification] = useState<boolean | undefined>();
const [_eventNotification, setEventNotification] = useState<boolean | undefined>();

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);
}, []);

Expand All @@ -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]
);
Expand All @@ -110,7 +109,6 @@ const NotificationSettingsScreen = () => {
if (
!!pushSettingData.isAllowPush ||
!!pushSettingData.isAllowServicePush ||
!!pushSettingData.isAllowQnaPush ||
!!pushSettingData.isAllowMarketingPush
) {
hasInitializedSubTogglesRef.current = true;
Expand All @@ -133,8 +131,8 @@ const NotificationSettingsScreen = () => {
? {
isAllowPush: true,
isAllowServicePush: true,
isAllowQnaPush: true,
isAllowMarketingPush: true,
isAllowQnaPush: false,
isAllowMarketingPush: false,
}
: {
...previousSettings,
Expand Down Expand Up @@ -193,13 +191,12 @@ const NotificationSettingsScreen = () => {
isAllowPush: newValue,
};

// 최초 푸시 ON 시점에만 하위 3개를 모두 ON 처리
// 최초 푸시 ON 시점에는 서비스 알림만 기본 ON 처리 (QnA/마케팅 제외)
if (newValue && !hasInitializedSubTogglesRef.current) {
nextSettings = {
...nextSettings,
isAllowServicePush: true,
isAllowQnaPush: true,
isAllowMarketingPush: true,
isAllowMarketingPush: false,
};
hasInitializedSubTogglesRef.current = true;
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -269,13 +256,7 @@ const NotificationSettingsScreen = () => {
disabled={!pushEnabled}
/>

<SettingsToggleItem
title='QnA 채팅 알림'
description='출제진 피드백, 선생님 답변 알림 등'
value={qnaNotification}
onValueChange={handleQnaNotificationChange}
disabled={!pushEnabled}
/>
{/* TODO: QnA 정식 출시 시 해제 — MAT-746 PR-3 */}

<SettingsToggleItem
title='이벤트/업데이트 알림'
Expand Down
10 changes: 7 additions & 3 deletions apps/native/src/features/student/menu/screens/TermsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React from 'react';
import { Text, ScrollView } from 'react-native';
import { ChevronRight } from 'lucide-react-native';
import * as WebBrowser from 'expo-web-browser';

import { AnimatedPressable, ContentInset } from '@components/common';
import { colors } from '@theme/tokens';
import { TERMS_URLS, type TermsKey } from '@constants/termsUrls';

import { ScreenLayout } from '../components';

interface TermsItem {
id: string;
id: TermsKey;
title: string;
content?: string;
}

const TERMS_LIST: TermsItem[] = [
Expand All @@ -20,7 +21,10 @@ const TERMS_LIST: TermsItem[] = [
];

const TermsScreen = () => {
const handleTermPress = (term: TermsItem) => {};
const handleTermPress = (term: TermsItem) => {
const url = TERMS_URLS[term.id];
if (url) void WebBrowser.openBrowserAsync(url);
};

return (
<ScreenLayout title='서비스 약관'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OnboardingStackParamList>();
Expand All @@ -26,7 +27,8 @@ const OnboardingScreen = () => {
if (status === 'idle') markStarted();
}, [status, markStarted]);

const initialRouteName = currentStep === 'Welcome' ? 'Welcome' : 'Grade';
const initialRouteName =
currentStep === 'Welcome' || currentStep === 'PushConsent' ? currentStep : 'Grade';

return (
<Stack.Navigator
Expand All @@ -36,6 +38,7 @@ const OnboardingScreen = () => {
<Stack.Screen name='MathSubject' component={MathSubjectStep} />
<Stack.Screen name='School' component={SchoolStep} />
<Stack.Screen name='MockExam' component={MockExamStep} />
<Stack.Screen name='PushConsent' component={PushConsentStep} />
<Stack.Screen name='Welcome' component={WelcomeStep} />
</Stack.Navigator>
);
Expand Down
Loading
Loading