From b039065d8142c73d944eb28b0dc96c6b74f9f3cb Mon Sep 17 00:00:00 2001 From: sumin0423 Date: Thu, 12 Nov 2026 00:33:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.json | 3 +- assets/images/favicon.png | Bin 0 -> 4148 bytes assets/images/favicon.svg | 37 ++ docs/profile-input.md | 20 ++ docs/screenshots/profile.png | Bin 0 -> 20690 bytes docs/screenshots/terms.png | Bin 0 -> 21418 bytes docs/terms-consent.md | 24 ++ public/favicon.svg | 38 +- src/app/dev/profile.tsx | 38 ++ src/app/dev/terms.tsx | 32 ++ src/app/index.tsx | 7 +- src/app/profile.tsx | 4 +- src/app/settings.tsx | 2 +- src/app/terms.tsx | 6 +- src/features/onboarding/OnboardingApp.tsx | 419 ++++------------------ src/features/profile/ProfileScreen.tsx | 230 ++++++++++++ src/features/profile/profile.ts | 8 + src/features/terms/TermsCheckbox.tsx | 55 +++ src/features/terms/TermsScreen.tsx | 275 ++++++++++++++ src/features/terms/terms.ts | 11 + 20 files changed, 848 insertions(+), 361 deletions(-) create mode 100644 assets/images/favicon.png create mode 100644 assets/images/favicon.svg create mode 100644 docs/profile-input.md create mode 100644 docs/screenshots/profile.png create mode 100644 docs/screenshots/terms.png create mode 100644 docs/terms-consent.md create mode 100644 src/app/dev/profile.tsx create mode 100644 src/app/dev/terms.tsx create mode 100644 src/features/profile/ProfileScreen.tsx create mode 100644 src/features/profile/profile.ts create mode 100644 src/features/terms/TermsCheckbox.tsx create mode 100644 src/features/terms/TermsScreen.tsx create mode 100644 src/features/terms/terms.ts diff --git a/app.json b/app.json index 9da53e7..e81c5ce 100644 --- a/app.json +++ b/app.json @@ -9,7 +9,8 @@ "plugins": ["expo-router"], "web": { "bundler": "metro", - "output": "single" + "output": "single", + "favicon": "./assets/images/favicon.png" }, "ios": { "supportsTablet": true diff --git a/assets/images/favicon.png b/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..96fcf372db25b29bc73feb6d80042548f8ef3743 GIT binary patch literal 4148 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 literal 0 HcmV?d00001 diff --git a/assets/images/favicon.svg b/assets/images/favicon.svg new file mode 100644 index 0000000..9cf5ab6 --- /dev/null +++ b/assets/images/favicon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/profile-input.md b/docs/profile-input.md new file mode 100644 index 0000000..511e727 --- /dev/null +++ b/docs/profile-input.md @@ -0,0 +1,20 @@ +# T-03 프로필 입력 + +새 T-03 SVG 기준으로 프로필 화면을 분리했습니다. + +- 공통 TextInput, Chip, ActionButton, Loading 및 디자인 토큰 재사용 +- 이름: 앞뒤 공백 제거 기준 2~10자, 최대 입력 길이 10자. 빈 값·한 글자·공백만 입력 시 완료 비활성화 +- 과목: 선택 사항, 복수 선택 및 해제 가능. 390px 시안에서는 한 줄 배치, 작은 화면에서는 줄바꿈 +- 완료: 로컬 상태를 반영하고 기존 흐름대로 `/home` 이동 +- 설정의 수정: 기존 값 표시, 저장 후 설정으로 복귀 +- 저장 중: 공통 로딩 및 버튼 점 표시, 입력·과목 선택·중복 제출 차단 +- 저장 실패: 입력 유지 및 재시도 안내. 완료 버튼으로 재시도 +- API 미확정이므로 실제 서버 저장은 연결하지 않았습니다. `onSave`로 추후 확정된 API를 연결합니다. + +## 개발 미리보기 + +- `/dev/profile`: 빈 입력 및 검증 +- `/dev/profile?scenario=loading`: 김나연·영어 선택. 완료 시 3초간 저장 중 표시 후 홈 이동 +- `/dev/profile?scenario=error`: 첫 저장 실패 후 완료 버튼 재시도 시 성공 + +시나리오의 지연·오류는 개발 경로에만 있으며 production은 실제 프로필 경로로 전환됩니다. diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png new file mode 100644 index 0000000000000000000000000000000000000000..bacf3b9e79dbd496dacd829e9fc1e0a29981a058 GIT binary patch literal 20690 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 Y0IT;tLEN4CL;wH)07*qoM6N<$g7xNnod5s; literal 0 HcmV?d00001 diff --git a/docs/screenshots/terms.png b/docs/screenshots/terms.png new file mode 100644 index 0000000000000000000000000000000000000000..dd5aedeaf64710e155b5b9edaaa93a7ffbafbc95 GIT binary patch literal 21418 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 qsUDb3{{P?daf2_A!3N4M%nT3rXT9Ia(s2tY!r \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/dev/profile.tsx b/src/app/dev/profile.tsx new file mode 100644 index 0000000..43111d2 --- /dev/null +++ b/src/app/dev/profile.tsx @@ -0,0 +1,38 @@ +import { useRef } from 'react'; +import { Redirect, useLocalSearchParams } from 'expo-router'; +import ProfileScreen from '@/features/profile/ProfileScreen'; +import type { SaveProfile } from '@/features/profile/profile'; +export default function ProfilePreview() { + const { scenario } = useLocalSearchParams<{ scenario?: string }>(); + const attempts = useRef(0); + if (!__DEV__) return ; + const save: SaveProfile = async (_, signal) => { + const attempt = ++attempts.current; + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + signal.removeEventListener('abort', abort); + resolve(); + }, + scenario === 'loading' ? 3000 : 650, + ); + function abort() { + clearTimeout(timer); + reject(new Error('Aborted')); + } + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + if (scenario === 'error' && attempt === 1) throw new Error('Preview save failure'); + }; + return ( + + ); +} diff --git a/src/app/dev/terms.tsx b/src/app/dev/terms.tsx new file mode 100644 index 0000000..fa5f7d9 --- /dev/null +++ b/src/app/dev/terms.tsx @@ -0,0 +1,32 @@ +import { useRef } from 'react'; +import { Redirect, useLocalSearchParams } from 'expo-router'; +import TermsScreen from '@/features/terms/TermsScreen'; +import type { SaveTerms } from '@/features/terms/terms'; + +export default function TermsPreview() { + const { scenario } = useLocalSearchParams<{ scenario?: string }>(); + const attempts = useRef(0); + if (!__DEV__) return ; + const save: SaveTerms = async (_, signal) => { + const attempt = ++attempts.current; + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + signal.removeEventListener('abort', abort); + resolve(); + }, + scenario === 'loading' ? 3000 : 650, + ); + function abort() { + clearTimeout(timer); + reject(new Error('Aborted')); + } + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + if (scenario === 'error' && attempt === 1) throw new Error('Preview save failure'); + }; + return ( + + ); +} diff --git a/src/app/index.tsx b/src/app/index.tsx index 3b495c4..4ed5e42 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,4 +1,5 @@ -import OnboardingApp from '@/features/onboarding/OnboardingApp'; -export default function Screen() { - return ; +import { Redirect } from 'expo-router'; + +export default function Index() { + return ; } diff --git a/src/app/profile.tsx b/src/app/profile.tsx index e672e91..3e5fc3f 100644 --- a/src/app/profile.tsx +++ b/src/app/profile.tsx @@ -1,8 +1,8 @@ -import OnboardingApp from '@/features/onboarding/OnboardingApp'; +import ProfileScreen from '@/features/profile/ProfileScreen'; import { Redirect } from 'expo-router'; import { useOnboarding } from '@/features/onboarding/OnboardingProvider'; export default function Screen() { const { agreed, editing, profile } = useOnboarding(); if (!agreed.every(Boolean) && !(editing && profile.name)) return ; - return ; + return ; } diff --git a/src/app/settings.tsx b/src/app/settings.tsx index 357d372..3ef0a08 100644 --- a/src/app/settings.tsx +++ b/src/app/settings.tsx @@ -4,5 +4,5 @@ import { useOnboarding } from '@/features/onboarding/OnboardingProvider'; export default function Screen() { const { profile } = useOnboarding(); if (!profile.name) return ; - return ; + return ; } diff --git a/src/app/terms.tsx b/src/app/terms.tsx index 73d1f32..a57b9bb 100644 --- a/src/app/terms.tsx +++ b/src/app/terms.tsx @@ -1,4 +1,2 @@ -import OnboardingApp from '@/features/onboarding/OnboardingApp'; -export default function Screen() { - return ; -} +import TermsScreen from '@/features/terms/TermsScreen'; +export default TermsScreen; diff --git a/src/features/onboarding/OnboardingApp.tsx b/src/features/onboarding/OnboardingApp.tsx index 7e668c3..4f31ce8 100644 --- a/src/features/onboarding/OnboardingApp.tsx +++ b/src/features/onboarding/OnboardingApp.tsx @@ -1,10 +1,5 @@ -import emailImage from '../../../assets/images/email.svg'; -import googleImage from '../../../assets/images/google.svg'; -import kakaoImage from '../../../assets/images/kakao.svg'; -import logoImage from '../../../assets/images/logo.svg'; import { useState } from 'react'; import { useRouter } from 'expo-router'; -import { Image } from 'expo-image'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useOnboarding } from './OnboardingProvider'; import { @@ -13,49 +8,27 @@ import { ScrollView, StyleSheet, Text, - TextInput, View, KeyboardAvoidingView, Platform, } from 'react-native'; -type Screen = 'welcome' | 'terms' | 'profile' | 'settings'; -const subjects = ['국어', '영어', '수학', '과학', '사회', '기타']; -const terms = ['이용약관', '개인정보 수집 · 이용', '학부모 · 학생 연락처 처리']; const ink = '#343845'; -export default function OnboardingApp({ screen }: { screen: Screen }) { +export default function OnboardingApp() { const router = useRouter(); - const { - agreed, - setAgreed, - name, - setName, - selected, - setSelected, - profile, - setProfile, - editing, - setEditing, - } = useOnboarding(); - const setScreen = (next: Screen) => router.push(next === 'welcome' ? '/' : `/${next}`); + const { setAgreed, setName, setSelected, profile, setProfile, setEditing } = useOnboarding(); const [dialog, setDialog] = useState<{ title: string; body: string; action?: 'logout' | 'delete'; } | null>(null); - const allAgreed = agreed.every(Boolean); - const validName = name.trim().length >= 1 && name.trim().length <= 20; const openTerms = (title: string) => setDialog({ title, body: '약관 원문이 아직 제공되지 않았습니다. 현재 화면은 UI 확인용 데모이며 실제 가입이나 개인정보 수집은 이루어지지 않습니다. 정식 서비스에서는 확정된 약관을 연결해야 합니다.', }); - const start = () => { - setEditing(false); - setScreen('terms'); - }; const reset = () => { setName(''); setSelected([]); @@ -67,7 +40,7 @@ export default function OnboardingApp({ screen }: { screen: Screen }) { }; const back = () => { if (router.canGoBack()) router.back(); - else router.replace(screen === 'profile' && !editing ? '/terms' : '/'); + else router.replace('/'); }; const primary = (label: string, disabled: boolean, onPress: () => void) => ( - {screen === 'welcome' ? ( - - - - - 선생님은 수업만, 나머지는 수담이 - - - - - setDialog({ - title: '카카오 로그인', - body: '카카오 로그인은 서버 연동 전입니다. 데모로 화면 흐름을 확인할 수 있어요.', - }) - } - style={({ pressed }) => [s.loginButton, s.kakao, pressed && s.pressed]} - > - - 카카오로 시작하기 - - - setDialog({ - title: '구글 로그인', - body: '구글 로그인은 서버 연동 전입니다. 데모로 화면 흐름을 확인할 수 있어요.', - }) - } - style={({ pressed }) => [s.loginButton, pressed && s.pressed]} - > - - 구글로 시작하기 - - [s.loginButton, pressed && s.pressed]} - > - - 이메일로 가입하기 - - + <> + + + ← + + + 설정 + + + + { + <> + + + {profile.name} 선생님 + + {profile.subjects.join(' · ') || '과목 미설정'} + + + { + setName(profile.name); + setSelected(profile.subjects); + setEditing(true); + router.push('/profile'); + }} + > + 수정 + + + {['이용약관', '개인정보 처리방침'].map((term) => ( + openTerms(term)} + style={s.settingRow} + > + {term} + › + + ))} + setDialog({ - title: '이메일 로그인', - body: '이메일 인증 및 로그인 API는 아직 연결되지 않았습니다.', + title: '로그아웃', + body: '데모 프로필을 지우고 시작 화면으로 돌아갈까요?', + action: 'logout', }) } > - 이메일로 로그인 + 로그아웃 - | setDialog({ - title: '비밀번호 찾기', - body: '비밀번호 재설정은 이메일 발송 API 연동 후 이용할 수 있습니다.', + title: '회원 탈퇴', + body: '실제 계정은 생성되지 않았습니다. 이 화면에서 입력한 데모 프로필을 삭제할까요?', + action: 'delete', }) } > - 비밀번호 찾기 + 회원 탈퇴 - - + + } - ) : ( - <> - - - ← - - - {screen === 'terms' ? '약관 동의' : screen === 'profile' ? '프로필' : '설정'} - - - - {screen === 'terms' && ( - <> - 수담을 쓰려면 동의가 필요해요 - setAgreed(agreed.map(() => !allAgreed))} - style={[s.termRow, s.allRow]} - > - - {allAgreed ? '✓' : ''} - - 전체 동의 - - {terms.map((term, i) => ( - - setAgreed(agreed.map((v, j) => (i === j ? !v : v)))} - style={s.termChoice} - > - - {agreed[i] ? '✓' : ''} - - [필수] {term} - - openTerms(term)} - style={s.viewTerm} - > - 보기 - - - ))} - - )} - {screen === 'profile' && ( - <> - 학부모님께 보일 이름을 알려 주세요 - - 이름 (필수) - - - 과목 (선택, 여러 개) - - {subjects.map((subject) => ( - - setSelected( - selected.includes(subject) - ? selected.filter((v) => v !== subject) - : [...selected, subject], - ) - } - style={[s.chip, selected.includes(subject) && s.selectedChip]} - > - - {subject} - - - ))} - - - )} - {screen === 'settings' && ( - <> - - - {profile.name} 선생님 - - {profile.subjects.join(' · ') || '과목 미설정'} - - - { - setName(profile.name); - setSelected(profile.subjects); - setEditing(true); - setScreen('profile'); - }} - > - 수정 - - - {['이용약관', '개인정보 처리방침'].map((term) => ( - openTerms(term)} - style={s.settingRow} - > - {term} - › - - ))} - - - setDialog({ - title: '로그아웃', - body: '데모 프로필을 지우고 시작 화면으로 돌아갈까요?', - action: 'logout', - }) - } - > - 로그아웃 - - - setDialog({ - title: '회원 탈퇴', - body: '실제 계정은 생성되지 않았습니다. 이 화면에서 입력한 데모 프로필을 삭제할까요?', - action: 'delete', - }) - } - > - 회원 탈퇴 - - - )} - - {screen === 'terms' && ( - - {primary('다음', !allAgreed, () => setScreen('profile'))} - - )} - {screen === 'profile' && ( - - {primary(editing ? '저장' : '완료', !validName, () => { - setProfile({ name: name.trim(), subjects: selected }); - if (editing) { - if (router.canGoBack()) router.back(); - else router.replace('/settings'); - return; - } - if (router.canDismiss()) router.dismissAll(); - router.replace('/home'); - })} - - )} - - )} + {primary('확인', false, reset)} ) : ( - <> - {screen === 'welcome' && - (dialog?.title === '카카오 로그인' || dialog?.title === '구글 로그인') && ( - { - setDialog(null); - start(); - }} - style={s.demoButton} - > - 데모로 계속하기 - - )} - {primary('닫기', false, () => setDialog(null))} - + <>{primary('닫기', false, () => setDialog(null))} )} @@ -403,25 +186,6 @@ const s = StyleSheet.create({ flex: 1, paddingTop: Platform.OS === 'web' ? 44 : 0, }, - welcome: { flexGrow: 1, paddingHorizontal: 28, justifyContent: 'space-between' }, - brand: { alignItems: 'center', paddingTop: 189 }, - logo: { width: 230, height: 51, marginBottom: 16 }, - tagline: { marginTop: 8, fontSize: 14, fontWeight: '600', color: '#151515' }, - blue: { color: '#1557ff' }, - loginActions: { paddingBottom: 64, gap: 10, marginTop: 100 }, - loginButton: { - height: 58, - borderRadius: 8, - borderWidth: 1, - borderColor: '#d2d5dc', - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - }, - kakao: { backgroundColor: '#ffe500', borderColor: '#ffe500' }, - loginText: { color: ink, fontSize: 16, fontWeight: '600', marginLeft: 56 }, - provider: { position: 'absolute', left: 78, width: 28, height: 28 }, - links: { flexDirection: 'row', justifyContent: 'center', paddingTop: 14 }, smallLink: { color: '#9a9fac', fontSize: 12 }, header: { height: 54, alignItems: 'center', justifyContent: 'center' }, headerTitle: { fontSize: 16, color: ink, fontWeight: '700' }, @@ -436,25 +200,6 @@ const s = StyleSheet.create({ backText: { fontSize: 28, color: '#454954' }, content: { flex: 1 }, contentInner: { paddingHorizontal: 24, paddingTop: 16, paddingBottom: 24 }, - heading: { color: ink, fontSize: 18, fontWeight: '700', marginBottom: 22, letterSpacing: -0.5 }, - termRow: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 16 }, - allRow: { paddingBottom: 18, marginBottom: 10, borderBottomWidth: 1, borderColor: '#d2d5dc' }, - termChoice: { flex: 1, flexDirection: 'row', gap: 16, alignItems: 'center', minHeight: 44 }, - termText: { fontSize: 14, fontWeight: '600', color: '#242424' }, - checkbox: { - width: 17, - height: 17, - borderWidth: 1, - borderColor: '#e0e5f1', - borderRadius: 4, - alignItems: 'center', - justifyContent: 'center', - }, - checked: { backgroundColor: '#219bff', borderColor: '#219bff' }, - checkMark: { fontSize: 12, color: '#fff', fontWeight: '700' }, - viewTerm: { minHeight: 44, minWidth: 32, justifyContent: 'center', alignItems: 'flex-end' }, - underlined: { fontSize: 12, color: '#9a9fac', textDecorationLine: 'underline' }, - footer: { paddingHorizontal: 24, paddingBottom: 38, paddingTop: 16 }, primary: { height: 56, borderRadius: 4, @@ -466,29 +211,6 @@ const s = StyleSheet.create({ disabled: { backgroundColor: '#d6d8dc' }, disabledText: { color: '#949aa4' }, pressed: { opacity: 0.7 }, - label: { color: ink, fontSize: 12, marginBottom: 12 }, - input: { - height: 60, - borderWidth: 1, - borderColor: '#d2d5dc', - borderRadius: 4, - paddingHorizontal: 16, - fontSize: 15, - color: ink, - backgroundColor: '#fcfcff', - }, - subjectLabel: { fontSize: 13, color: ink, marginTop: 18, marginBottom: 10, marginLeft: 8 }, - chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, width: 200, paddingLeft: 4 }, - chip: { - paddingHorizontal: 17, - height: 36, - borderRadius: 20, - backgroundColor: '#e9ebf4', - justifyContent: 'center', - }, - chipText: { fontSize: 14, color: ink }, - selectedChip: { backgroundColor: '#219bff' }, - selectedChipText: { color: '#fff', fontWeight: '600' }, profileRow: { borderBottomWidth: 1, borderColor: '#d2d5dc', @@ -523,5 +245,4 @@ const s = StyleSheet.create({ dialogActions: { flexDirection: 'row', gap: 12 }, cancel: { flex: 1, alignItems: 'center', justifyContent: 'center' }, flex: { flex: 1 }, - demoButton: { alignItems: 'center', padding: 16, marginBottom: 8 }, }); diff --git a/src/features/profile/ProfileScreen.tsx b/src/features/profile/ProfileScreen.tsx new file mode 100644 index 0000000..6c9d6fb --- /dev/null +++ b/src/features/profile/ProfileScreen.tsx @@ -0,0 +1,230 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Keyboard, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { ActionButton } from '@/components/ActionButton'; +import { Chip } from '@/components/Chip'; +import { Loading } from '@/components/Loading'; +import { TextInput } from '@/components/TextInput'; +import { useOnboarding } from '@/features/onboarding/OnboardingProvider'; +import { semantic } from '@/styles/colors'; +import { gap, grid, lineWeight, padding } from '@/styles/measurement'; +import { typography } from '@/styles/typography'; +import { isValidProfileName, profileSubjects } from './profile'; +import type { ProfileValue, SaveProfile } from './profile'; + +type Props = { onSave?: SaveProfile; initialProfile?: ProfileValue }; +export default function ProfileScreen({ onSave, initialProfile }: Props) { + const router = useRouter(); + const { name, setName, selected, setSelected, setProfile, editing } = useOnboarding(); + const [draftName, setDraftName] = useState(initialProfile?.name ?? name); + const [draftSubjects, setDraftSubjects] = useState(initialProfile?.subjects ?? selected); + const [touched, setTouched] = useState(false); + const [saving, setSaving] = useState(false); + const [failed, setFailed] = useState(false); + const inFlight = useRef(false); + const mounted = useRef(true); + const controller = useRef(null); + const validName = isValidProfileName(draftName); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + controller.current?.abort(); + }; + }, []); + + async function submit() { + if (!validName || inFlight.current) return; + inFlight.current = true; + setFailed(false); + Keyboard.dismiss(); + const request = new AbortController(); + controller.current = request; + const value = { name: draftName.trim(), subjects: [...draftSubjects] }; + try { + if (onSave) { + setSaving(true); + await onSave(value, request.signal); + } + if (!mounted.current || request.signal.aborted) return; + // API 미확정: 기본 동작은 메모리 상태 반영만 수행합니다. + setName(value.name); + setSelected(value.subjects); + setProfile(value); + if (editing) { + if (router.canGoBack()) router.back(); + else router.replace('/settings'); + } else { + if (router.canDismiss()) router.dismissAll(); + router.replace('/home'); + } + } catch { + if (mounted.current && !request.signal.aborted) setFailed(true); + } finally { + inFlight.current = false; + if (mounted.current) setSaving(false); + } + } + + function back() { + if (inFlight.current) return; + Keyboard.dismiss(); + setName(draftName); + setSelected([...draftSubjects]); + if (router.canGoBack()) router.back(); + else router.replace(editing ? '/settings' : '/terms'); + } + + return ( + + + + + + + + + + + 프로필 + + + + + 학부모님께 보일 이름을 알려 주세요 + + 이름* + { + setDraftName(value); + setFailed(false); + }} + onSubmitEditing={() => { + if (!validName) setTouched(true); + else void submit(); + }} + errorMessage={ + touched && draftName.length > 0 && !validName + ? '이름은 2~10자로 입력해 주세요.' + : undefined + } + /> + 과목 (선택, 여러 개) + + {profileSubjects.map((subject) => ( + { + setFailed(false); + setDraftSubjects((current) => + current.includes(subject) + ? current.filter((item) => item !== subject) + : [...current, subject], + ); + }} + /> + ))} + + + + {failed && ( + + 연결이 불안정해요. 다시 시도해 주세요. + + )} + void submit()} + /> + + + + + ); +} +const s = StyleSheet.create({ + safeArea: { flex: 1, alignItems: 'center', backgroundColor: semantic.surface.input }, + phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 }, + header: { height: 54, alignItems: 'center', justifyContent: 'center' }, + headerTitle: { ...typography.body[1].semiBold, color: semantic.text.default.strong }, + back: { + position: 'absolute', + left: 12, + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + }, + backIcon: { width: 16, height: 16, justifyContent: 'center' }, + arrowHead: { + position: 'absolute', + left: 1, + width: 10, + height: 10, + borderLeftWidth: lineWeight.semiBold, + borderBottomWidth: lineWeight.semiBold, + borderColor: semantic.icon.primary, + transform: [{ rotate: '45deg' }], + }, + arrowLine: { height: lineWeight.semiBold, width: 16, backgroundColor: semantic.icon.primary }, + scroll: { flex: 1 }, + content: { paddingHorizontal: grid.margin, paddingTop: padding[20], paddingBottom: padding[24] }, + title: { + ...typography.heading[1].semiBold, + color: semantic.text.default.strong, + marginBottom: gap[20], + }, + nameLabel: { + ...typography.body[5].medium, + color: semantic.text.default.normal, + marginLeft: padding[8], + marginBottom: gap[8], + }, + subjectLabel: { + ...typography.body[5].medium, + color: semantic.text.default.normal, + marginTop: gap[16], + marginBottom: gap[8], + marginLeft: padding[4], + }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: gap[8] }, + footer: { paddingHorizontal: grid.margin, paddingTop: padding[16], paddingBottom: 56 }, + error: { marginBottom: gap[8] }, + errorText: { ...typography.caption[1].regular, color: semantic.text.feedback.warning }, +}); diff --git a/src/features/profile/profile.ts b/src/features/profile/profile.ts new file mode 100644 index 0000000..c2efb4c --- /dev/null +++ b/src/features/profile/profile.ts @@ -0,0 +1,8 @@ +export const profileSubjects = ['국어', '영어', '수학', '과학', '사회', '기타'] as const; +export type ProfileValue = { name: string; subjects: string[] }; +/** 화면 모델입니다. API 확정 후 요청 형식으로 변환합니다. */ +export type SaveProfile = (profile: ProfileValue, signal: AbortSignal) => Promise; +export function isValidProfileName(value: string) { + const length = value.trim().length; + return length >= 2 && length <= 10; +} diff --git a/src/features/terms/TermsCheckbox.tsx b/src/features/terms/TermsCheckbox.tsx new file mode 100644 index 0000000..bbda579 --- /dev/null +++ b/src/features/terms/TermsCheckbox.tsx @@ -0,0 +1,55 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { semantic } from '@/styles/colors'; +import { gap, lineWeight, radius } from '@/styles/measurement'; +import { typography } from '@/styles/typography'; + +type Props = { label: string; checked: boolean; disabled: boolean; onPress: () => void }; +export function TermsCheckbox({ label, checked, disabled, onPress }: Props) { + return ( + [s.row, pressed && s.pressed]} + > + + {checked && } + + {label} + + ); +} +const s = StyleSheet.create({ + row: { + flex: 1, + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + gap: gap[8], + paddingLeft: 2, + }, + pressed: { opacity: 0.7 }, + box: { + width: 16, + height: 16, + borderRadius: radius.small, + borderWidth: lineWeight.regular, + borderColor: semantic.border.default, + alignItems: 'center', + justifyContent: 'center', + }, + checked: { backgroundColor: semantic.icon.brand, borderColor: semantic.icon.brand }, + check: { + width: 8, + height: 5, + borderLeftWidth: 1.5, + borderBottomWidth: 1.5, + borderColor: semantic.text.default.inverse, + transform: [{ rotate: '-45deg' }], + marginTop: -2, + }, + label: { ...typography.body[3].regular, color: semantic.text.default.normal, flexShrink: 1 }, +}); diff --git a/src/features/terms/TermsScreen.tsx b/src/features/terms/TermsScreen.tsx new file mode 100644 index 0000000..654a275 --- /dev/null +++ b/src/features/terms/TermsScreen.tsx @@ -0,0 +1,275 @@ +import { useEffect, useRef, useState } from 'react'; +import { Modal, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { ActionButton } from '@/components/ActionButton'; +import { Loading } from '@/components/Loading'; +import { useOnboarding } from '@/features/onboarding/OnboardingProvider'; +import { semantic } from '@/styles/colors'; +import { gap, grid, lineWeight, padding, radius } from '@/styles/measurement'; +import { typography } from '@/styles/typography'; +import { TermsCheckbox } from './TermsCheckbox'; +import { termsItems } from './terms'; +import type { SaveTerms, TermId, TermsDocuments } from './terms'; + +type Props = { + /** API 확정 전에는 생략합니다. 이 경우 동의를 메모리에 반영하고 이동할 뿐 서버에 저장하지 않습니다. */ + onSave?: SaveTerms; + documents?: TermsDocuments; + initialChecked?: boolean; +}; + +export default function TermsScreen({ onSave, documents, initialChecked = false }: Props) { + const router = useRouter(); + const { agreed, setAgreed } = useOnboarding(); + const [draft, setDraft] = useState(() => + termsItems.map((_, i) => initialChecked || agreed[i] || false), + ); + const [saving, setSaving] = useState(false); + const [failed, setFailed] = useState(false); + const [documentId, setDocumentId] = useState(null); + const inFlight = useRef(false); + const mounted = useRef(true); + const controller = useRef(null); + const allChecked = draft.every(Boolean); + const document = termsItems.find((item) => item.id === documentId); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + controller.current?.abort(); + }; + }, []); + + function toggle(index?: number) { + if (inFlight.current) return; + setFailed(false); + setDraft((current) => + index === undefined + ? current.map(() => !current.every(Boolean)) + : current.map((value, i) => (i === index ? !value : value)), + ); + } + + async function submit() { + if (!allChecked || inFlight.current) return; + inFlight.current = true; + setFailed(false); + const request = new AbortController(); + controller.current = request; + const selection = { service: draft[0], privacy: draft[1], contacts: draft[2] }; + try { + if (onSave) { + setSaving(true); + await onSave(selection, request.signal); + } + if (!mounted.current || request.signal.aborted) return; + setAgreed([...draft]); + router.push('/profile'); + } catch { + if (mounted.current && !request.signal.aborted) setFailed(true); + } finally { + inFlight.current = false; + if (mounted.current) setSaving(false); + } + } + + function back() { + if (inFlight.current) return; + // 뒤로 이동 후 다시 진입해도 선택은 유지합니다. 서버 저장 완료를 뜻하지 않습니다. + setAgreed([...draft]); + if (router.canGoBack()) router.back(); + } + + return ( + + + + {router.canGoBack() && ( + + + + )} + + 약관 동의 + + + + + 수담을 쓰려면 동의가 필요해요 + + + toggle()} + /> + + {termsItems.map((item, index) => ( + + toggle(index)} + /> + setDocumentId(item.id)} + style={s.viewButton} + > + 보기 + + + ))} + + + void submit()} + /> + + {failed && ( + + 연결이 불안정해요 + void submit()} + style={s.retry} + > + 다시 시도 + + + )} + + + + + setDocumentId(null)} + > + + + + {document?.label} + + + + {(documentId && documents?.[documentId]) || + '약관 원문이 아직 등록되지 않았어요. 현재 화면은 구현 확인용이며, 실제 가입 전에는 확정된 약관 원문을 연결해야 해요.'} + + + setDocumentId(null)} /> + + + + + ); +} + +const s = StyleSheet.create({ + safeArea: { flex: 1, backgroundColor: semantic.surface.input, alignItems: 'center' }, + phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 }, + header: { height: 72, alignItems: 'center', justifyContent: 'center' }, + headerTitle: { ...typography.body[1].semiBold, color: semantic.text.default.strong }, + back: { + position: 'absolute', + left: 12, + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + }, + arrow: { + width: 12, + height: 12, + borderLeftWidth: lineWeight.semiBold, + borderBottomWidth: lineWeight.semiBold, + borderColor: semantic.icon.primary, + transform: [{ rotate: '45deg' }], + }, + scroll: { flex: 1 }, + content: { paddingHorizontal: grid.margin, paddingBottom: padding[24] }, + title: { + ...typography.heading[1].semiBold, + color: semantic.text.default.strong, + marginBottom: gap[8], + }, + allRow: { + minHeight: 48, + borderBottomWidth: lineWeight.regular, + borderColor: semantic.border.default, + marginBottom: gap[8], + }, + termRow: { minHeight: 42, flexDirection: 'row', alignItems: 'center' }, + viewButton: { + minWidth: 44, + minHeight: 42, + justifyContent: 'center', + alignItems: 'flex-end', + paddingRight: padding[4], + }, + viewText: { + ...typography.body[4].regular, + color: semantic.text.default.weak, + textDecorationLine: 'underline', + }, + footer: { paddingHorizontal: grid.margin, paddingTop: padding[16] }, + feedbackSlot: { + height: 56, + alignItems: 'center', + justifyContent: 'flex-end', + paddingBottom: padding[4], + }, + toast: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: semantic.surface.inverse, + borderRadius: radius.full, + paddingLeft: padding[12], + gap: gap[8], + }, + toastText: { ...typography.caption[1].regular, color: semantic.text.default.inverse }, + retry: { minHeight: 44, justifyContent: 'center', paddingRight: padding[12] }, + retryText: { + ...typography.caption[1].semiBold, + color: semantic.text.default.inverse, + textDecorationLine: 'underline', + }, + modalBackdrop: { + flex: 1, + backgroundColor: '#00000066', + alignItems: 'center', + justifyContent: 'center', + padding: padding[24], + }, + documentDialog: { + width: '100%', + maxWidth: 390, + maxHeight: '80%', + backgroundColor: semantic.surface.input, + borderRadius: radius.large, + padding: padding[24], + gap: gap[16], + }, + documentTitle: { ...typography.heading[1].semiBold, color: semantic.text.default.strong }, + documentScroll: { flexShrink: 1 }, + documentBody: { paddingBottom: padding[8] }, + documentText: { ...typography.body[3].regular, color: semantic.text.default.normal }, +}); diff --git a/src/features/terms/terms.ts b/src/features/terms/terms.ts new file mode 100644 index 0000000..562780d --- /dev/null +++ b/src/features/terms/terms.ts @@ -0,0 +1,11 @@ +export const termsItems = [ + { id: 'service', label: '이용약관' }, + { id: 'privacy', label: '개인정보 수집·이용' }, + { id: 'contacts', label: '학부모·학생 연락처 처리' }, +] as const; + +export type TermId = (typeof termsItems)[number]['id']; +/** 화면 내부 모델입니다. 백엔드 요청 형식은 API 확정 후 변환합니다. */ +export type TermsConsent = Record; +export type SaveTerms = (consent: TermsConsent, signal: AbortSignal) => Promise; +export type TermsDocuments = Partial>;