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 0000000..96fcf37 Binary files /dev/null and b/assets/images/favicon.png differ 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 0000000..bacf3b9 Binary files /dev/null and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/terms.png b/docs/screenshots/terms.png new file mode 100644 index 0000000..dd5aede Binary files /dev/null and b/docs/screenshots/terms.png differ diff --git a/docs/terms-consent.md b/docs/terms-consent.md new file mode 100644 index 0000000..b7aa4fb --- /dev/null +++ b/docs/terms-consent.md @@ -0,0 +1,24 @@ +# T-02 약관 동의 구현 + +- `/terms`: 실제 가입 흐름의 약관 화면. API 미확정 상태로 서버 요청 없이 메모리의 동의 상태를 반영하고 `/profile`로 이동합니다. +- 동의 전 다음 버튼은 비활성화됩니다. 개별 동의와 전체 동의를 양방향으로 동기화합니다. +- 공통 `ActionButton`, `Loading`, 색상·간격·타이포그래피 토큰을 재사용합니다. +- 약관 원문은 제공되지 않아 보기 버튼에서 미등록 안내를 표시합니다. 실제 법적 동의 및 저장 구현이 완료된 상태는 아닙니다. +- `TermsScreen.onSave`는 확정된 API를 연결하기 위한 비동기 경계입니다. 입력은 화면 내부 모델이며 백엔드 요청 형식이 아닙니다. 약관 버전·동의 시각 등 요청 규격은 명세 확정 후 연결합니다. +- 저장 실패 시 선택 상태를 유지하고 오류 안내 및 다시 시도 버튼을 표시합니다. 중복 요청을 막고 화면 종료 시 AbortSignal로 취소를 전달합니다. + +## 개발 중 미리보기 + +개발 서버에서만 접근할 수 있으며 production에서는 `/terms`로 이동합니다. + +- `/dev/terms`: 정상 선택 및 저장 성공 (650ms 후 프로필 이동) +- `/dev/terms?scenario=loading`: 전체 선택 후 시작. 다음 버튼을 누르면 3초간 공통 로딩 표시 후 성공 +- `/dev/terms?scenario=error`: 전체 선택 후 시작. 첫 제출은 실패, 다시 시도는 성공 + +대기와 실패는 미리보기 전용이며 실제 가입 화면에는 인위적인 네트워크 지연을 추가하지 않았습니다. + +## 확인할 사항 + +- 정상/저장 중/오류 SVG 기준 구현. OS 상태바는 실제 시스템 표시를 사용하고 정적 시각·배터리를 그리지 않습니다. +- 미선택 상태 CTA는 필수 동의 차단을 위해 공통 비활성 스타일을 사용합니다. +- 약관 원문과 동의 저장 API는 후속 연결이 필요합니다. diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..9cf5ab6 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,37 @@ - \ 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>;