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/assets/images/record-success.svg b/assets/images/record-success.svg new file mode 100644 index 0000000..920316f --- /dev/null +++ b/assets/images/record-success.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/session-logo.svg b/assets/images/session-logo.svg new file mode 100644 index 0000000..45e58a9 --- /dev/null +++ b/assets/images/session-logo.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/session-success.svg b/assets/images/session-success.svg new file mode 100644 index 0000000..df87fb5 --- /dev/null +++ b/assets/images/session-success.svg @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/makeup-coordination.md b/docs/makeup-coordination.md new file mode 100644 index 0000000..196fe8b --- /dev/null +++ b/docs/makeup-coordination.md @@ -0,0 +1,42 @@ +# T-12 휴강 등록·보강 조율 + +첨부 안내에 따라 임시 일정 홈은 구현하지 않았습니다. 독립 미리보기의 빈 배경 위에 바텀시트만 렌더링합니다. + +## 동작 + +- 휴강하고 보강 날짜 받기: 휴강 등록 콜백 성공 후 후보 선택 단계로 이동 +- 이미 휴강한 T-10 완료 화면의 보강 조율하기: 후보 단계로 바로 이동 +- 후보 1은 기본 항목으로 삭제 버튼이 없고, 후보 2·3에만 삭제 버튼 표시 +- 최대 3개, 추가·삭제 후 번호 및 CTA 개수 갱신 +- 날짜 필드 터치 후 달력 선택, 시간 선택(30분 단위), 유효한 날짜·시간 검사 및 중복 후보 차단 +- 날짜·시간이 빠지거나 수신 동의가 없으면 공유 비활성화 +- 저장·공유 처리 중에는 입력·추가·삭제·취소·배경 닫기 차단 +- 실패 시 후보와 입력 유지 및 재시도 +- 시간만 바꾸기는 날짜를 유지하는 별도 시간 변경 콜백 +- 달력/시간 선택을 취소하면 기존 후보 입력 유지 + +## API 연결 경계 + +`MakeupSheet`에 `MakeupLesson`과 `MakeupActions`를 전달합니다. API·카카오 SDK·실제 수신자 선택/전송은 미확정으로 연결하지 않았습니다. `previewActions.ts`만 지연·실패 예시를 생성합니다. + +카카오 공유 창 열림과 실제 전송 완료는 구분합니다. 공유 결과는 opened/cancelled/unavailable로 처리하며 미리보기는 실제 메시지를 보냈다고 표시하지 않습니다. 실제 서버가 휴강 등록을 완료한 후 후보 선택을 취소해도 이미 등록된 휴강을 자동으로 취소하지 않습니다. + +후보 날짜의 과거 허용 여부·수업 전 보강 가능 여부·시간 간격은 API/기획 확인이 필요합니다. 현재 날짜의 유효성과 중복만 검사하며 임의로 과거 날짜를 제한하지 않습니다. + +## 개발 경로 + +- `/dev/makeup-coordination`: 최초 바텀시트 +- `?scenario=loading`: 휴강 등록 처리 중 3초 대기 +- `?scenario=error`: 첫 처리 실패 후 재시도 성공 +- `?step=candidates`: 빈 후보 1개로 시작 +- `?step=candidates&count=1`: 유효한 후보 1개 +- `?step=candidates&count=2`: 후보 2개, 후보 2에만 삭제 버튼 +- `?step=candidates&count=3`: 최대 3개 +- `?step=candidates&count=1&scenario=error`: 첫 공유 실패 후 재시도 +- `?step=candidates&count=1&scenario=share-cancelled`: 공유 취소 안내 +- `?step=candidates&count=1&consent=no`: 수신 동의 없음 +- `/dev/session-complete`의 보강 조율하기에서도 연결됨 + +개발 전용 진입점은 production에서 홈으로 이동합니다. 실제 일정 화면의 이벤트 연결은 담당 화면에서 콜백을 연결해야 합니다. + +날짜 필드 안의 별도 달력 아이콘은 표시하지 않습니다. 날짜 필드 전체를 누르면 달력이 열리고, 시간 필드는 시간 선택이 열립니다. 일정 홈은 담당 화면에서 시트를 호출하는 방식으로 연결합니다. 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/makeup-action.png b/docs/screenshots/makeup-action.png new file mode 100644 index 0000000..dca8998 Binary files /dev/null and b/docs/screenshots/makeup-action.png differ diff --git a/docs/screenshots/makeup-one.png b/docs/screenshots/makeup-one.png new file mode 100644 index 0000000..56bf851 Binary files /dev/null and b/docs/screenshots/makeup-one.png differ diff --git a/docs/screenshots/makeup-three.png b/docs/screenshots/makeup-three.png new file mode 100644 index 0000000..6ee47a0 Binary files /dev/null and b/docs/screenshots/makeup-three.png differ 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/session-record.md b/docs/session-record.md new file mode 100644 index 0000000..f2e93f4 --- /dev/null +++ b/docs/session-record.md @@ -0,0 +1,48 @@ +# T-10 회차 기록 · 휴강 시트 · 휴강 완료 + +## 구현 범위 + +제공된 회차 기록 6개, 휴강 시트 2개, 휴강 완료 2개 SVG를 기준으로 구현했습니다. 홈·과외 생성 등 다른 담당 화면은 수정하지 않았습니다. + +- `SessionRecordScreen`: 교재별 진도, 직접 입력, 새 교재 등록·삭제, 이해도, 지난 숙제 수행, 다음 숙제, 관찰 한줄 +- 교재 진도(또는 안 함), 이해도, 지난 숙제 수행이 필수입니다. 등록 중인 교재가 있으면 저장을 막습니다. +- 지난 진도가 없는 교재는 제안 칩 대신 직접 입력을 표시합니다. 교재가 하나도 없는 경우 등록 폼으로 시작합니다. +- 새 교재는 이름과 진도 단위를 입력해 등록하며 시작 진도는 선택 사항입니다. API 확정 전 화면 입력 규칙입니다. +- 저장 중 입력·선택·중복 제출 차단, 실패 시 전체 입력 유지 및 다시 저장 +- `CancellationSheet`: 사유 단일 선택, 배경 터치·기기 뒤로 가기로 닫기, 저장 중 닫기 차단, 실패 후 재시도 +- `CancellationComplete`: 저장 결과의 인정 회차·사유 표시, 학부모 수신 동의가 없으면 보강 조율 버튼 미노출 +- `ActionButton`, `Chip`, `TextInput`, `Loading` 재사용. 균등 폭 선택 그룹과 유동 폭 진도 막대는 별도 구현했습니다. 공통 SelectButton·ProgressBar는 고정 너비라 이 화면 배치와 맞지 않습니다. + +## API와 다른 화면 연결 + +실제 API·저장·알림 발송·보강 일정 생성은 연결하지 않았습니다. 서비스에서 사용할 때 `LessonContext`, `onSaveRecord`, `onSaveCancellation`, `onHome`, `onCoordinate`를 전달합니다. 저장 콜백은 필수이며 암묵적으로 성공을 반환하지 않습니다. `CancellationResult`의 회차 인정·누적 회차는 백엔드 응답을 연결하도록 구분했습니다. + +화면 모델은 백엔드 계약이 아닙니다. `preview.tsx`만 확인용 학생·교재·날짜와 지연/실패를 생성합니다. 회차 인정 예시는 SVG 문구에 따라 학생 당일 취소 + 인정 정책인 경우만 증가합니다. 실제 정책은 백엔드 확인이 필요합니다. 시안의 숫자는 예시이므로 완료 화면의 숫자는 전달받은 결과를 표시합니다. + +## 개발 전용 확인 경로 + +- `/dev/session-record`: 첫 수업, 시안의 선택 예시 +- `/dev/session-record?variant=blank`: 필수 항목 미선택, 저장 비활성 +- `/dev/session-record?variant=empty`: 교재·지난 진도 없음, 직접 등록/입력 +- `/dev/session-record?variant=later`: 2회차 +- `/dev/session-record?variant=direct`: 다음 숙제 직접 입력 +- `/dev/session-record?scenario=loading`: 저장 시 3초 로딩 +- `/dev/session-record?scenario=error`: 첫 저장 실패, 다시 저장 성공 +- `/dev/session-cancellation`: 사유 미선택 시트 +- `/dev/session-cancellation?scenario=loading`: 사유 선택 후 저장 중 +- `/dev/session-cancellation?policy=count`: 학생 당일 취소 시 회차 인정 예시 +- `/dev/session-cancellation?scenario=error`: 첫 저장 실패 후 재시도 +- `/dev/session-complete`: 수신 동의 있는 완료 화면 +- `/dev/session-complete?consent=no`: 학부모 수신 동의 전 +- `/dev/session-complete?policy=count&reason=sameDay`: 회차 인정 예시 + +모든 개발 경로는 production에서 `/home`으로 이동합니다. 실제 저장 성공·메시지 전송을 검증하는 기능이 아닙니다. 새 기능은 로그인·홈 흐름에 연결하지 않았습니다. + +## 기록 완료 화면 v4 + +- 수업 저장 성공 시 팝업 대신 RecordComplete로 이동합니다. 실패 시에는 입력 화면을 유지합니다. +- `/dev/session-record-complete`: 4회차 완료·인정 8/8·정산 준비 완료 예시를 바로 확인합니다. +- 완료 회차·학생명·숙제는 전달된 데이터로 표시하며 정산 준비 여부는 저장 결과의 reportReady를 따릅니다. +- 다음 숙제가 없으면 학생용 카카오 공유 버튼을 비활성화합니다. 정산이 준비되지 않으면 정산 리포트 버튼을 비활성화합니다. +- 카카오 SDK·공유 템플릿·홈 알림 저장·정산 리포트 생성은 미연결입니다. 미리보기 버튼은 미연동 안내만 표시하며 실제 공유·청구를 수행하지 않습니다. +- 실제 서비스 연결 시 onShare 및 onReport 콜백을 전달해야 합니다. 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/makeup-coordination.tsx b/src/app/dev/makeup-coordination.tsx new file mode 100644 index 0000000..942a4d6 --- /dev/null +++ b/src/app/dev/makeup-coordination.tsx @@ -0,0 +1,2 @@ +import { MakeupPreview } from '@/features/makeup/preview'; +export default MakeupPreview; 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/session-cancellation.tsx b/src/app/dev/session-cancellation.tsx new file mode 100644 index 0000000..009f0e7 --- /dev/null +++ b/src/app/dev/session-cancellation.tsx @@ -0,0 +1,4 @@ +import { SessionPreview } from '@/features/session-record/preview'; +export default function Screen() { + return ; +} diff --git a/src/app/dev/session-complete.tsx b/src/app/dev/session-complete.tsx new file mode 100644 index 0000000..127b829 --- /dev/null +++ b/src/app/dev/session-complete.tsx @@ -0,0 +1,4 @@ +import { SessionPreview } from '@/features/session-record/preview'; +export default function Screen() { + return ; +} diff --git a/src/app/dev/session-record-complete.tsx b/src/app/dev/session-record-complete.tsx new file mode 100644 index 0000000..9a70a09 --- /dev/null +++ b/src/app/dev/session-record-complete.tsx @@ -0,0 +1,4 @@ +import { SessionPreview } from '@/features/session-record/preview'; +export default function Screen() { + return ; +} diff --git a/src/app/dev/session-record.tsx b/src/app/dev/session-record.tsx new file mode 100644 index 0000000..7d24d20 --- /dev/null +++ b/src/app/dev/session-record.tsx @@ -0,0 +1,4 @@ +import { SessionPreview } from '@/features/session-record/preview'; +export default function Screen() { + 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/components/ActionButton.tsx b/src/components/ActionButton.tsx index 0d68ad9..73c0456 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -22,8 +22,12 @@ export type ActionButtonProps = Omit & { /** L: 가로 꽉 참, M: 96, S: 72 */ size?: ActionButtonSize; variant?: ActionButtonVariant; + /** primary 버튼의 기본 파란색 농도. strong은 진한 파란색을 사용합니다. */ + tone?: 'default' | 'strong'; /** true면 글씨 대신 로딩 점을 보여 주고 누를 수 없게 합니다. */ loading?: boolean; + /** 필수 입력 안내 CTA처럼 비활성이어도 파란 배경·흰 글씨를 유지하는 경우에 사용합니다. */ + disabledAppearance?: 'muted' | 'brand'; }; const variants = { @@ -51,15 +55,22 @@ export function ActionButton({ label, size = 'L', variant = 'primary', + tone = 'default', disabled, loading = false, + disabledAppearance = 'muted', ...props }: ActionButtonProps) { const v = variants[variant]; const background = (pressed: boolean) => { - if (disabled) return v.disabledBackground; + if (disabled) + return disabledAppearance === 'brand' ? semantic.action.pressed : v.disabledBackground; if (loading) return v.loadingBackground; - return pressed ? v.pressedBackground : v.background; + return pressed + ? v.pressedBackground + : variant === 'primary' && tone === 'strong' + ? semantic.action.pressed + : v.background; }; return ( @@ -75,7 +86,19 @@ export function ActionButton({ {loading ? ( ) : ( - + {label} )} diff --git a/src/features/makeup/DateTimePicker.tsx b/src/features/makeup/DateTimePicker.tsx new file mode 100644 index 0000000..6c8994c --- /dev/null +++ b/src/features/makeup/DateTimePicker.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { Pressable, ScrollView, Text, View } from 'react-native'; +import { ActionButton } from '@/components/ActionButton'; +import { normalizeDate, timeLabel, times } from './dateTime'; +import { s } from './styles'; +export function CalendarPicker({ + value, + fallback, + onChange, + onCancel, +}: { + value: string; + fallback: string; + onChange: (date: string) => void; + onCancel: () => void; +}) { + const initial = (normalizeDate(value) ?? normalizeDate(fallback) ?? '2026-01-01') + .split('-') + .map(Number); + const [month, setMonth] = useState({ year: initial[0], month: initial[1] }); + const start = new Date(Date.UTC(month.year, month.month - 1, 1)).getUTCDay(); + const count = new Date(Date.UTC(month.year, month.month, 0)).getUTCDate(); + function move(delta: number) { + const d = new Date(Date.UTC(month.year, month.month - 1 + delta, 1)); + if (d.getUTCFullYear() < 1900 || d.getUTCFullYear() > 2100) return; + setMonth({ year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 }); + } + return ( + <> + + 보강 날짜 선택 + + + move(-1)} + style={s.monthButton} + > + ‹ + + + {month.year}년 {month.month}월 + + move(1)} + style={s.monthButton} + > + › + + + + {['일', '월', '화', '수', '목', '금', '토'].map((day) => ( + + {day} + + ))} + + {Array.from({ length: Math.ceil((start + count) / 7) }, (_, row) => ( + + {Array.from({ length: 7 }, (_, col) => { + const day = row * 7 + col - start + 1; + const date = `${month.year}-${String(month.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + return day < 1 || day > count ? ( + + ) : ( + onChange(date)} + style={[s.day, normalizeDate(value) === date && s.selectedDay]} + > + + {day} + + + ); + })} + + ))} + + + + + ); +} +export function TimePicker({ + value, + onChange, + onCancel, +}: { + value: string; + onChange: (time: string) => void; + onCancel: () => void; +}) { + return ( + <> + + 시간 선택 + + + {times.map((time) => ( + onChange(time)} + style={[s.timeOption, value === time && s.selectedTime]} + > + {timeLabel(time)} + + ))} + + + + + + ); +} diff --git a/src/features/makeup/MakeupSheet.tsx b/src/features/makeup/MakeupSheet.tsx new file mode 100644 index 0000000..565c057 --- /dev/null +++ b/src/features/makeup/MakeupSheet.tsx @@ -0,0 +1,342 @@ +import { useRef, useState } from 'react'; +import { Keyboard, Modal, Pressable, ScrollView, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActionButton } from '@/components/ActionButton'; +import { useSubmission } from '@/features/session-record/useSubmission'; +import { CalendarPicker, TimePicker } from './DateTimePicker'; +import { candidateError, normalizeDate, timeLabel, validTime } from './dateTime'; +import { s } from './styles'; +import type { MakeupActions, MakeupCandidate, MakeupLesson } from './types'; +type Step = 'action' | 'candidates' | 'reschedule'; +type Picker = { kind: 'date' | 'time'; id: string } | { kind: 'reschedule-time' }; +type Props = { + lesson: MakeupLesson; + actions: MakeupActions; + onClose: () => void; + initialStep?: Step; + initialCandidates?: MakeupCandidate[]; +}; +export function MakeupSheet({ + lesson, + actions, + onClose, + initialStep = 'action', + initialCandidates, +}: Props) { + const [step, setStep] = useState(initialStep); + const [candidates, setCandidates] = useState(() => + initialCandidates?.slice(0, 3).length + ? initialCandidates.slice(0, 3) + : [{ id: 'candidate-1', date: '', time: '' }], + ); + const [picker, setPicker] = useState(null); + const [newTime, setNewTime] = useState(lesson.time); + const [notice, setNotice] = useState(null); + const [registered, setRegistered] = useState(Boolean(lesson.alreadyCancelled)); + const nextId = useRef(4); + const submit = useSubmission(); + const insets = useSafeAreaInsets(); + const error = candidateError(candidates); + const mayShare = !error && lesson.parentConsented; + function update(id: string, patch: Partial) { + if (submit.pending) return; + submit.clearError(); + setCandidates((current) => current.map((c) => (c.id === id ? { ...c, ...patch } : c))); + } + function close() { + if (submit.pending) return; + Keyboard.dismiss(); + if (picker) setPicker(null); + else onClose(); + } + function register() { + Keyboard.dismiss(); + if (registered) { + setStep('candidates'); + return; + } + void submit.run( + (signal) => actions.registerCancellation(lesson.id, signal), + () => { + setRegistered(true); + setStep('candidates'); + }, + ); + } + function share() { + if (!mayShare || submit.pending) return; + Keyboard.dismiss(); + const snapshot = candidates.map((c) => ({ ...c, date: normalizeDate(c.date)! })); + void submit.run( + (signal) => actions.shareCandidates(lesson.id, snapshot, signal), + (result) => + setNotice( + result === 'opened' + ? '카카오 공유 창을 열었어요. 실제 전송 여부는 카카오톡에서 확인해 주세요.' + : result === 'cancelled' + ? '공유를 취소했어요. 보강 후보는 그대로 유지했어요.' + : '카카오 공유는 아직 연결되지 않았어요. 선택한 후보를 확인했으며 실제 메시지는 보내지 않았습니다.', + ), + ); + } + function reschedule() { + if (!validTime(newTime) || newTime === lesson.time || submit.pending) return; + void submit.run( + (signal) => actions.rescheduleTime(lesson.id, newTime, signal), + () => + setNotice('시간 변경 요청을 처리했어요. 확인용 화면에서는 실제 일정이 변경되지 않습니다.'), + ); + } + const target = picker && 'id' in picker ? candidates.find((c) => c.id === picker.id) : undefined; + return ( + + + + + + {notice ? ( + <> + + 처리 안내 + + + {notice} + + setNotice(null)} /> + + ) : picker ? ( + <> + {picker.kind === 'date' && target ? ( + { + update(target.id, { date: date.replaceAll('-', '.') }); + setPicker(null); + }} + onCancel={() => setPicker(null)} + /> + ) : ( + { + if (picker.kind === 'reschedule-time') setNewTime(time); + else if (target) update(target.id, { time }); + setPicker(null); + }} + onCancel={() => setPicker(null)} + /> + )} + + ) : ( + <> + + {step === 'action' ? ( + <> + + {lesson.dateLabel} {lesson.studentName} 수업 + + + 휴강하면 보강 날짜를 학부모님께 물어볼 수 있어요. + + + + ) : step === 'candidates' ? ( + <> + + 보강 후보 날짜 고르기 + + + {lesson.dateLabel} {lesson.studentName} 수업을 휴강해요. 가능한 날짜를 1~3개 + 고르면 학부모님이 그중 하나를 고르고, 안 맞으면 다른 날짜를 요청해요. + + {candidates.map((candidate, index) => ( + + + + 보강 후보 {index + 1} + {index === 0 ? '*' : ''} + + {index > 0 && ( + { + submit.clearError(); + + setCandidates((current) => + current.filter((c) => c.id !== candidate.id), + ); + }} + style={s.remove} + > + × + + )} + + + + { + Keyboard.dismiss(); + setPicker({ kind: 'date', id: candidate.id }); + }} + style={s.dateButton} + > + + {candidate.date || '날짜 선택'} + + + + { + Keyboard.dismiss(); + setPicker({ kind: 'time', id: candidate.id }); + }} + style={s.time} + > + {timeLabel(candidate.time)} + ⌃ + + + + ))} + {candidates.length < 3 && ( + { + submit.clearError(); + const id = `candidate-${nextId.current++}`; + setCandidates((current) => + current.length < 3 ? [...current, { id, date: '', time: '' }] : current, + ); + }} + style={s.add} + > + 후보 추가 ({candidates.length}/3) + + )} + {error && candidates.every((c) => c.date && c.time) && ( + + {error} + + )} + {!lesson.parentConsented && ( + + 학부모 수신 동의 후 보강 후보를 공유할 수 있어요. + + )} + + ) : ( + <> + + 수업 시간만 바꾸기 + + + {lesson.dateLabel} {lesson.studentName} 수업 날짜는 유지하고 시간만 변경해요. + + setPicker({ kind: 'reschedule-time' })} + style={s.time} + > + {timeLabel(newTime)} + ⌃ + + + )} + + {submit.error && ( + + 처리하지 못했어요. 입력값은 유지했으니 다시 시도해 주세요. + + )} + + {step === 'action' ? ( + <> + + { + submit.clearError(); + setStep('reschedule'); + }} + /> + + ) : step === 'candidates' ? ( + <> + + + + ) : ( + <> + + { + submit.clearError(); + setStep('action'); + }} + /> + + )} + + + )} + + + + ); +} diff --git a/src/features/makeup/dateTime.ts b/src/features/makeup/dateTime.ts new file mode 100644 index 0000000..239bb55 --- /dev/null +++ b/src/features/makeup/dateTime.ts @@ -0,0 +1,39 @@ +import type { MakeupCandidate } from './types'; +export function normalizeDate(value: string): string | null { + const match = /^(\d{4})[.-](\d{1,2})[.-](\d{1,2})$/.exec(value.trim()); + if (!match) return null; + const year = Number(match[1]), + month = Number(match[2]), + day = Number(match[3]); + if ( + year < 1900 || + year > 2100 || + month < 1 || + month > 12 || + day < 1 || + day > new Date(Date.UTC(year, month, 0)).getUTCDate() + ) + return null; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} +export function validTime(value: string) { + return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value); +} +export function timeLabel(value: string) { + if (!validTime(value)) return '시간 선택'; + const [hour, minute] = value.split(':').map(Number); + return `${hour < 12 ? '오전' : '오후'} ${hour % 12 || 12}시${minute ? ` ${minute}분` : ''}`; +} +export function candidateError(candidates: MakeupCandidate[]) { + if (candidates.length < 1 || candidates.length > 3) + return '보강 후보는 1~3개까지 선택할 수 있어요.'; + if (candidates.some((c) => !normalizeDate(c.date) || !validTime(c.time))) + return '날짜와 시간을 골라주세요.'; + const keys = candidates.map((c) => `${normalizeDate(c.date)} ${c.time}`); + if (new Set(keys).size !== keys.length) return '같은 날짜와 시간이 중복되지 않게 골라주세요.'; + return ''; +} +export const times = Array.from( + { length: 48 }, + (_, i) => `${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 ? '30' : '00'}`, +); diff --git a/src/features/makeup/preview.tsx b/src/features/makeup/preview.tsx new file mode 100644 index 0000000..c6b21d5 --- /dev/null +++ b/src/features/makeup/preview.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { Redirect, useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { View } from 'react-native'; +import { ActionButton } from '@/components/ActionButton'; +import { MakeupSheet } from './MakeupSheet'; +import { createPreviewActions } from './previewActions'; +import { s } from './styles'; +export function MakeupPreview() { + const params = useLocalSearchParams<{ + step?: string; + count?: string; + scenario?: string; + consent?: string; + }>(); + const [visible, setVisible] = useState(true); + const [actions] = useState(() => createPreviewActions(params.scenario)); + if (!__DEV__) return ; + const count = Math.min(3, Math.max(1, Number(params.count) || 1)); + return ( + + + setVisible(true)} /> + + {visible && ( + ({ + id: `candidate-${i + 1}`, + date: `2026.10.${22 + i}`, + time: '20:00', + })) + : undefined + } + onClose={() => setVisible(false)} + /> + )} + + ); +} diff --git a/src/features/makeup/previewActions.ts b/src/features/makeup/previewActions.ts new file mode 100644 index 0000000..542fe32 --- /dev/null +++ b/src/features/makeup/previewActions.ts @@ -0,0 +1,30 @@ +import type { MakeupActions } from './types'; +export function createPreviewActions(scenario?: string): MakeupActions { + let attempt = 0; + async function work(signal: AbortSignal) { + 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 failure'); + } + return { + registerCancellation: async (_, signal) => work(signal), + shareCandidates: async (_, __, signal) => { + await work(signal); + return scenario === 'share-cancelled' ? 'cancelled' : 'unavailable'; + }, + rescheduleTime: async (_, __, signal) => work(signal), + }; +} diff --git a/src/features/makeup/styles.ts b/src/features/makeup/styles.ts new file mode 100644 index 0000000..16d5c3c --- /dev/null +++ b/src/features/makeup/styles.ts @@ -0,0 +1,128 @@ +import { StyleSheet } from 'react-native'; +import { semantic as c } from '@/styles/colors'; +import { typography as t } from '@/styles/typography'; +import { radius, padding, gap } from '@/styles/measurement'; +export const s = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: '#2D303D80', + justifyContent: 'flex-end', + alignItems: 'center', + }, + dismiss: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }, + sheet: { + width: '100%', + maxWidth: 390, + maxHeight: '92%', + backgroundColor: c.surface.subtle, + borderTopLeftRadius: radius.large, + borderTopRightRadius: radius.large, + paddingHorizontal: padding[24], + }, + handle: { + width: 56, + height: 4, + backgroundColor: c.border.default, + borderRadius: radius.full, + alignSelf: 'center', + marginTop: gap[12], + marginBottom: gap[24], + }, + heading: { ...t.heading[1].semiBold, color: c.text.default.strong, marginBottom: gap[8] }, + description: { ...t.body[4].regular, color: c.text.default.weak, marginBottom: gap[16] }, + content: { paddingBottom: padding[12] }, + actions: { gap: gap[8], paddingTop: padding[12] }, + actionSpacing: { height: 32 }, + candidate: { marginBottom: gap[12] }, + candidateHeading: { + minHeight: 28, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: gap[4], + }, + label: { ...t.body[5].medium, color: c.text.default.normal, marginLeft: padding[8] }, + remove: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + removeText: { fontSize: 24, color: c.icon.primary, fontWeight: '300' }, + row: { + flexDirection: 'row', + gap: gap[8], + alignItems: 'flex-start', + width: '100%', + maxWidth: 316, + }, + dateColumn: { flex: 1 }, + time: { + width: 96, + minHeight: 52, + borderWidth: 1, + borderColor: c.border.default, + borderRadius: radius.small, + paddingHorizontal: padding[8], + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: gap[8], + backgroundColor: c.surface.input, + }, + timeText: { ...t.body[5].medium, color: c.text.default.normal }, + caret: { color: c.icon.secondary, fontSize: 12 }, + dateButton: { + minHeight: 52, + borderWidth: 1, + borderColor: c.border.default, + borderRadius: radius.small, + backgroundColor: c.surface.input, + justifyContent: 'center', + paddingHorizontal: padding[12], + }, + placeholder: { color: c.text.default.weak }, + add: { + minHeight: 44, + justifyContent: 'center', + alignSelf: 'flex-start', + paddingLeft: padding[8], + marginBottom: gap[16], + }, + addText: { ...t.body[4].semiBold, color: c.text.brand.assistive }, + error: { ...t.caption[1].regular, color: c.text.feedback.warning, marginBottom: gap[8] }, + monthHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: gap[16], + }, + monthText: { ...t.body[2].semiBold, color: c.text.default.normal }, + monthButton: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + monthArrow: { fontSize: 24, color: c.icon.primary }, + week: { flexDirection: 'row' }, + day: { + width: '14.285714%', + height: 44, + justifyContent: 'center', + alignItems: 'center', + borderRadius: radius.small, + }, + dayText: { ...t.body[4].regular, color: c.text.default.normal }, + selectedDay: { backgroundColor: c.action.default }, + selectedDayText: { color: c.text.default.inverse }, + weekText: { ...t.caption[1].medium, color: c.text.default.weak }, + timeOption: { + minHeight: 44, + paddingHorizontal: padding[16], + justifyContent: 'center', + borderRadius: radius.small, + }, + selectedTime: { backgroundColor: c.surface.focused }, + timeOptionText: { ...t.body[3].medium, color: c.text.default.normal }, + timeList: { maxHeight: 300 }, + preview: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: padding[24], + backgroundColor: c.surface.input, + }, + previewButton: { width: '100%', maxWidth: 342 }, + notice: { ...t.body[3].regular, color: c.text.default.normal, marginBottom: gap[16] }, +}); diff --git a/src/features/makeup/types.ts b/src/features/makeup/types.ts new file mode 100644 index 0000000..63421ee --- /dev/null +++ b/src/features/makeup/types.ts @@ -0,0 +1,21 @@ +export type MakeupCandidate = { id: string; date: string; time: string }; +export type MakeupLesson = { + id: string; + studentName: string; + dateLabel: string; + date: string; + time: string; + parentConsented: boolean; + alreadyCancelled?: boolean; +}; +export type CandidateShareResult = 'opened' | 'cancelled' | 'unavailable'; +/** 화면 내부 모델입니다. 실제 API 계약은 확정 후 변환합니다. */ +export type MakeupActions = { + registerCancellation: (lessonId: string, signal: AbortSignal) => Promise; + shareCandidates: ( + lessonId: string, + candidates: MakeupCandidate[], + signal: AbortSignal, + ) => Promise; + rescheduleTime: (lessonId: string, time: string, signal: AbortSignal) => Promise; +}; 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/session-record/BookEditor.tsx b/src/features/session-record/BookEditor.tsx new file mode 100644 index 0000000..0ca7e4b --- /dev/null +++ b/src/features/session-record/BookEditor.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { Chip } from '@/components/Chip'; +import { TextInput } from '@/components/TextInput'; +import { s } from './styles'; +import type { Textbook } from './types'; +const units = ['Unit', '단원', '페이지', '회', '직접'] as const; +type Props = { + number: number; + disabled: boolean; + onRegister: (book: Omit) => void; + onCancel: () => void; +}; +export function BookEditor({ number, disabled, onRegister, onCancel }: Props) { + const [name, setName] = useState(''); + const [unit, setUnit] = useState(''); + const [customUnit, setCustomUnit] = useState(''); + const [start, setStart] = useState(''); + const [error, setError] = useState(false); + const unitName = unit === '직접' ? customUnit.trim() : unit; + function register() { + if (disabled) return; + if (!name.trim() || !unitName) { + setError(true); + return; + } + onRegister({ + name: name.trim(), + unit: unitName, + previousProgress: start.trim() || undefined, + suggestions: [], + }); + } + return ( + + + 교재 {number} + + + 취소 + + + 등록하기 + + + + + + 교재명* + + + + 진도 단위 + + {units.map((v) => ( + setUnit(v)} + /> + ))} + + + { + setUnit('직접'); + setCustomUnit(v); + }} + editable={!disabled} + maxLength={20} + placeholder="텍스트를 입력해주세요" + /> + + + 시작 진도(선택) + + + {error && ( + + 교재명과 진도 단위를 입력해 주세요. + + )} + + + ); +} diff --git a/src/features/session-record/CancellationComplete.tsx b/src/features/session-record/CancellationComplete.tsx new file mode 100644 index 0000000..387201c --- /dev/null +++ b/src/features/session-record/CancellationComplete.tsx @@ -0,0 +1,109 @@ +import { Image } from 'expo-image'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ActionButton } from '@/components/ActionButton'; +import { semantic as c } from '@/styles/colors'; +import { typography as t } from '@/styles/typography'; +import { s } from './styles'; +import type { CancellationResult } from './types'; +import logo from '../../../assets/images/session-logo.svg'; +import success from '../../../assets/images/session-success.svg'; +type Props = { + result: CancellationResult; + teacherLabel: string; + onHome: () => void; + onCoordinate: () => void; +}; +export function CancellationComplete({ result, teacherLabel, onHome, onCoordinate }: Props) { + return ( + + + + + + + {teacherLabel} + + + + + + 휴강을 기록했어요 + + {result.reason} + + {result.counted ? '회차로 인정돼요' : '회차로 인정되지 않아요'} + + + + + ✓ + + 인정 회차는 {result.counted ? '반영되어' : '그대로'} {result.completedCount}/ + {result.totalCount} + + + + + ✓ + + {result.parentConsented + ? '보강 날짜를 학부모님께 물어볼 수 있어요' + : '보강 날짜 조율은 수신 동의 후 가능해요'} + + + + + + {!result.parentConsented && ( + 학부모 연락처 동의 후 보강 날짜를 물어볼 수 있어요. + )} + + {result.parentConsented && ( + + )} + + + + ); +} +const v = StyleSheet.create({ + content: { paddingHorizontal: 24, paddingBottom: 24 }, + brandRow: { + height: 72, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 12, + }, + logo: { width: 136, height: 40 }, + teacher: { + backgroundColor: c.surface.brand, + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 8, + flexShrink: 1, + }, + teacherText: { ...t.caption[1].medium, color: c.text.brand.assistive }, + hero: { alignItems: 'center', paddingTop: 26 }, + success: { width: 80, height: 80, marginBottom: 12 }, + title: { ...t.body[1].semiBold, color: c.text.default.neutral, marginBottom: 24 }, + reason: { ...t.body[3].semiBold, color: c.text.brand.assistive, marginBottom: 4 }, + subtitle: { ...t.body[4].regular, color: c.text.default.weak }, + summary: { + marginTop: 72, + paddingHorizontal: 12, + backgroundColor: c.surface.subtle, + borderRadius: 8, + }, + summaryRow: { minHeight: 56, flexDirection: 'row', gap: 16, alignItems: 'center' }, + summaryText: { ...t.body[3].semiBold, color: c.text.brand.assistive, flex: 1 }, + tick: { color: c.action.default, fontSize: 18 }, + rule: { height: 1, backgroundColor: c.border.default }, + consentHint: { + ...t.body[4].regular, + color: c.text.default.weak, + textAlign: 'center', + marginBottom: 8, + }, +}); diff --git a/src/features/session-record/CancellationSheet.tsx b/src/features/session-record/CancellationSheet.tsx new file mode 100644 index 0000000..f4e0daf --- /dev/null +++ b/src/features/session-record/CancellationSheet.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { Modal, Pressable, ScrollView, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ActionButton } from '@/components/ActionButton'; +import { Loading } from '@/components/Loading'; +import { Choices } from './Choices'; +import { s } from './styles'; +import { useSubmission } from './useSubmission'; +import type { + CancellationReason, + CancellationResult, + LessonContext, + SaveCancellation, +} from './types'; +type Props = { + lesson: LessonContext; + onClose: () => void; + onSave: SaveCancellation; + onComplete: (result: CancellationResult) => void; +}; +export function CancellationSheet({ lesson, onClose, onSave, onComplete }: Props) { + const [reason, setReason] = useState(null); + const submit = useSubmission(); + const insets = useSafeAreaInsets(); + function save() { + if (!reason || submit.pending) return; + const input = { lessonId: lesson.lessonId, reason }; + void submit.run((signal) => onSave(input, signal), onComplete); + } + return ( + <> + { + if (!submit.pending) onClose(); + }} + > + + + + + + + 휴강으로 기록할까요? + + + 회차로 인정되는 경우는 '학생 당일 취소'이면서 휴강 정책이 '인정'일 때뿐이에요.{' '} + {lesson.studentName} 학생의 정책은{' '} + {lesson.sameDayCancellationCounts ? '인정' : '미인정'}입니다. + + + + + + {submit.error && ( + + + 저장하지 못했어요. 사유를 유지했으니 다시 시도해 주세요. + + + )} + + + + + + + + ); +} diff --git a/src/features/session-record/Choices.tsx b/src/features/session-record/Choices.tsx new file mode 100644 index 0000000..997bc68 --- /dev/null +++ b/src/features/session-record/Choices.tsx @@ -0,0 +1,30 @@ +import { Pressable, Text, View } from 'react-native'; +import { s } from './styles'; +type Props = { + label: string; + options: readonly T[]; + value: T | null; + disabled?: boolean; + onChange: (value: T) => void; +}; +/** 세 칸/두 칸 균등 폭 선택. 고정 최소 너비인 공통 SelectButton과 달리 좁은 화면에서도 줄 안에 맞춥니다. */ +export function Choices({ label, options, value, disabled, onChange }: Props) { + return ( + + {options.map((option) => ( + onChange(option)} + style={[s.choice, value === option && s.selectedChoice]} + > + {option} + + ))} + + ); +} diff --git a/src/features/session-record/RecordComplete.tsx b/src/features/session-record/RecordComplete.tsx new file mode 100644 index 0000000..b891e99 --- /dev/null +++ b/src/features/session-record/RecordComplete.tsx @@ -0,0 +1,169 @@ +import { Image } from 'expo-image'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ActionButton } from '@/components/ActionButton'; +import { semantic as c } from '@/styles/colors'; +import { typography as t } from '@/styles/typography'; +import { s } from './styles'; +import success from '../../../assets/images/record-success.svg'; +import type { LessonContext, RecordShareTarget, SessionRecord, SessionRecordResult } from './types'; +type Props = { + lesson: LessonContext; + record: SessionRecord; + result: SessionRecordResult; + onShare: (target: RecordShareTarget) => void; + onReport: () => void; + onHome: () => void; +}; +export function RecordComplete({ lesson, record, result, onShare, onReport, onHome }: Props) { + const hasHomework = Boolean(record.nextHomework.trim()); + return ( + + + + + + + {lesson.sessionNumber}회차 기록 완료 + + + 카톡으로 보내기 + + + {lesson.studentName} 학생 오늘 근황 + {lesson.studentName} 어머님 + + onShare('parent-summary')} + /> + + + + 다음 숙제 + + {hasHomework ? `${lesson.studentName} 학생` : '등록된 다음 숙제가 없어요'} + + + onShare('student-homework')} + /> + + + ⓘ + + 카카오톡 공유 창에서 받는 사람을 고르면 보내져요.{'\n'}안 보내면 홈 알림에 남아요. + + + 완료 내역 + + + ✓ + 학부모님께 보낼 오늘 근황 카드를 만들었어요 + + + ✓ + + {hasHomework + ? `학생에게 보낼 숙제 알림 카드를 만들었어요\n· ${record.nextHomework}` + : '다음 숙제는 등록하지 않았어요'} + + + {result.reportReady ? ( + + ✓ + + 정산 {result.totalCount}회차가 채워져 정산 리포트와 교육비 안내를 만들었어요 + + + ) : ( + + ✓ + + 인정 회차 {result.completedCount}/{result.totalCount}를 기록했어요 + + + )} + + + + + + + + + + ); +} +const v = StyleSheet.create({ + content: { paddingHorizontal: 24, paddingBottom: 52, flexGrow: 1 }, + hero: { alignItems: 'center', paddingTop: 36, marginBottom: 28 }, + success: { width: 90, height: 90, marginBottom: 16 }, + title: { ...t.body[1].semiBold, color: c.text.brand.assistive }, + sectionLabel: { + ...t.body[5].medium, + color: c.text.default.normal, + marginLeft: 8, + marginBottom: 12, + }, + shareCard: { + minHeight: 82, + backgroundColor: c.surface.subtle, + borderRadius: 8, + padding: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + marginBottom: 4, + }, + shareDescription: { flex: 1, gap: 4 }, + cardTitle: { ...t.body[3].semiBold, color: c.text.default.normal }, + recipient: { ...t.body[5].regular, color: c.text.default.normal }, + info: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + padding: 12, + backgroundColor: c.surface.brand, + borderWidth: 1, + borderColor: c.border.focused, + borderRadius: 4, + marginTop: 8, + marginBottom: 20, + }, + infoIcon: { color: c.text.brand.assistive, fontSize: 14 }, + infoText: { ...t.caption[1].regular, color: c.text.brand.assistive, flex: 1 }, + detailsLabel: { + ...t.body[5].medium, + color: c.text.default.normal, + marginLeft: 8, + marginBottom: 12, + }, + details: { backgroundColor: c.surface.subtle, paddingHorizontal: 12, borderRadius: 8 }, + detailRow: { + minHeight: 56, + paddingHorizontal: 4, + paddingVertical: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 16, + borderBottomWidth: 1, + borderColor: c.border.default, + }, + lastRow: { borderBottomWidth: 0 }, + tick: { fontSize: 18, color: c.action.default }, + detailText: { ...t.body[4].semiBold, color: c.text.brand.assistive, flex: 1 }, + spacer: { flexGrow: 1, minHeight: 40 }, + actions: { gap: 8 }, +}); diff --git a/src/features/session-record/SessionRecordScreen.tsx b/src/features/session-record/SessionRecordScreen.tsx new file mode 100644 index 0000000..284c205 --- /dev/null +++ b/src/features/session-record/SessionRecordScreen.tsx @@ -0,0 +1,436 @@ +import { useRef, useState } from 'react'; +import { + Keyboard, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + Text, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ActionButton } from '@/components/ActionButton'; +import { Chip } from '@/components/Chip'; +import { Loading } from '@/components/Loading'; +import { TextInput } from '@/components/TextInput'; +import { BookEditor } from './BookEditor'; +import { Choices } from './Choices'; +import { CancellationSheet } from './CancellationSheet'; +import { CancellationComplete } from './CancellationComplete'; +import { RecordComplete } from './RecordComplete'; +import { s } from './styles'; +import { useSubmission } from './useSubmission'; +import type { + BookProgress, + CancellationResult, + HomeworkStatus, + LessonContext, + SaveCancellation, + SaveRecord, + SessionRecordResult, + RecordShareTarget, + SessionRecord, + Textbook, + Understanding, +} from './types'; + +type Props = { + lesson: LessonContext; + onSaveRecord: SaveRecord; + onSaveCancellation: SaveCancellation; + onBack: () => void; + onHome: () => void; + onCoordinate: () => void; + onShare: (target: RecordShareTarget) => void; + onReport: () => void; + initialFilled?: boolean; + initialDirect?: boolean; + initialSheet?: boolean; + initialCompletion?: CancellationResult; + onRecorded?: (record: SessionRecord) => void; +}; +export function SessionRecordScreen({ + lesson, + onSaveRecord, + onSaveCancellation, + onBack, + onHome, + onCoordinate, + onShare, + onReport, + initialFilled = false, + initialDirect = false, + initialSheet = false, + initialCompletion, + onRecorded, +}: Props) { + const [books, setBooks] = useState(lesson.textbooks); + const [progress, setProgress] = useState>(() => + initialFilled + ? Object.fromEntries( + lesson.textbooks.map((book, i) => [book.id, book.suggestions[i === 1 ? 1 : 0] ?? '']), + ) + : {}, + ); + const [customProgress, setCustomProgress] = useState>({}); + const [understanding, setUnderstanding] = useState( + initialFilled ? '중' : null, + ); + const selectedUnderstanding = understanding; + const [homework, setHomework] = useState(initialFilled ? '일부' : null); + const [nextHomework, setNextHomework] = useState(initialDirect ? '직접' : ''); + const [customHomework, setCustomHomework] = useState(''); + const [observation, setObservation] = useState(''); + const [editingBook, setEditingBook] = useState(lesson.textbooks.length === 0); + const [sheet, setSheet] = useState(initialSheet); + const [completion, setCompletion] = useState(initialCompletion); + const [saved, setSaved] = useState<{ record: SessionRecord; result: SessionRecordResult } | null>( + null, + ); + const [discard, setDiscard] = useState(false); + const nextBookId = useRef(0); + const submit = useSubmission(); + const validProgress = + books.length > 0 && + books.every( + (book) => + Boolean(progress[book.id]) && + (progress[book.id] !== '직접' || Boolean(customProgress[book.id]?.trim())), + ); + const canSave = + validProgress && selectedUnderstanding !== null && homework !== null && !editingBook; + const dirty = + Object.values(progress).some(Boolean) || + selectedUnderstanding !== null || + homework !== null || + Boolean(observation) || + Boolean(nextHomework) || + books.length !== lesson.textbooks.length || + editingBook; + function save() { + if (!canSave || submit.pending || !selectedUnderstanding || !homework) return; + Keyboard.dismiss(); + const entries: BookProgress[] = books.map((book) => ({ + book: { ...book, suggestions: [...book.suggestions] }, + value: progress[book.id] === '직접' ? customProgress[book.id].trim() : progress[book.id], + skipped: progress[book.id] === '안 함', + })); + const record: SessionRecord = { + lessonId: lesson.lessonId, + progress: entries, + understanding: selectedUnderstanding, + previousHomework: homework, + nextHomework: + nextHomework === '직접' + ? customHomework.trim() + : nextHomework === '없음' + ? '' + : nextHomework, + observation: observation.trim(), + }; + void submit.run( + (signal) => onSaveRecord(record, signal), + (result) => { + setSaved({ record, result }); + onRecorded?.(record); + }, + ); + } + function register(book: Omit) { + const id = `local-book-${++nextBookId.current}`; + setBooks((current) => [...current, { ...book, id }]); + setEditingBook(false); + submit.clearError(); + } + function leave() { + if (submit.pending) return; + if (dirty) setDiscard(true); + else onBack(); + } + if (saved) + return ( + + ); + if (completion) + return ( + + ); + return ( + + + + + ← + + + 수업 기록 + + { + Keyboard.dismiss(); + setSheet(true); + }} + style={s.leave} + > + 휴강 + + + + {lesson.studentName} 학생 + + {lesson.dateLabel}, {lesson.sessionNumber}회차 + + + {Array.from({ length: lesson.totalCount }, (_, i) => ( + + ))} + + + 기록하면 정산{' '} + + {lesson.sessionNumber}/{lesson.totalCount} + + 회차가 돼요 + + + 교재별 진도 + {books.map((book) => ( + + + {book.name} + {book.previousProgress && ( + 지난 {book.previousProgress} + )} + + {book.previousProgress && book.suggestions.length > 0 && ( + + {[ + ...book.suggestions, + ...(book.unit === 'Unit' ? ['안 함', '직접'] : ['직접', '안 함']), + ].map((value) => ( + { + submit.clearError(); + setProgress((current) => ({ + ...current, + [book.id]: current[book.id] === value ? '' : value, + })); + }} + /> + ))} + + )} + {(!book.previousProgress || + book.suggestions.length === 0 || + progress[book.id] === '직접') && ( + { + submit.clearError(); + setCustomProgress((current) => ({ ...current, [book.id]: value })); + setProgress((current) => ({ ...current, [book.id]: '직접' })); + }} + /> + )} + {book.id.startsWith('local-book-') && ( + setBooks((current) => current.filter((v) => v.id !== book.id))} + > + 교재 삭제 + + )} + + ))} + {editingBook && ( + setEditingBook(false)} + /> + )} + setEditingBook(true)} + style={s.add} + > + + + 교재 추가 + + + + 이해도 + { + submit.clearError(); + setUnderstanding(value); + }} + /> + + + 지난 숙제 수행 + { + submit.clearError(); + setHomework(value); + }} + /> + + + 다음 숙제 + + {[...lesson.homeworkSuggestions, '없음', '직접'].map((value) => ( + setNextHomework((current) => (current === value ? '' : value))} + /> + ))} + + {nextHomework === '직접' && ( + + 다음 숙제 직접 입력 (선택) + + + )} + + + 관찰 한줄 (선택) + + + + + {submit.error ? ( + + 다시 시도해주세요 + + ) : ( + 진도·이해도·지난 숙제를 고르면 저장할 수 있어요 + )} + + + + + {sheet && ( + setSheet(false)} + onSave={onSaveCancellation} + onComplete={(result) => { + setSheet(false); + setCompletion(result); + }} + /> + )} + { + setDiscard(false); + }} + > + + + + 입력 중인 기록을 나갈까요? + + 입력한 내용은 저장되지 않아요. + {discard && ( + setDiscard(false)} + /> + )} + { + setDiscard(false); + onBack(); + }} + /> + + + + + ); +} diff --git a/src/features/session-record/preview.tsx b/src/features/session-record/preview.tsx new file mode 100644 index 0000000..7f95ce8 --- /dev/null +++ b/src/features/session-record/preview.tsx @@ -0,0 +1,197 @@ +import { useRef, useState } from 'react'; +import { Redirect, useLocalSearchParams, useRouter } from 'expo-router'; +import { Modal, Text, View } from 'react-native'; +import { ActionButton } from '@/components/ActionButton'; +import { SessionRecordScreen } from './SessionRecordScreen'; +import { RecordComplete } from './RecordComplete'; +import { MakeupSheet } from '@/features/makeup/MakeupSheet'; +import { createPreviewActions } from '@/features/makeup/previewActions'; +import { s } from './styles'; +import type { + CancellationReason, + CancellationResult, + LessonContext, + SaveCancellation, + SaveRecord, +} from './types'; + +function wait(ms: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, ms); + function abort() { + clearTimeout(timer); + reject(new Error('Aborted')); + } + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); +} +const reasons: Record = { + sameDay: '학생 당일 취소', + advance: '학생 사전 취소', + teacher: '선생님 사정', + holiday: '공휴일', +}; +export function SessionPreview({ + screen = 'record', +}: { + screen?: 'record' | 'sheet' | 'complete' | 'record-complete'; +}) { + const params = useLocalSearchParams<{ + variant?: string; + scenario?: string; + consent?: string; + policy?: string; + reason?: string; + }>(); + const router = useRouter(); + const [notice, setNotice] = useState< + 'coordinate' | 'parent-summary' | 'student-homework' | 'report' | null + >(null); + const [makeupOpen, setMakeupOpen] = useState(false); + const [makeupActions] = useState(() => createPreviewActions(params.scenario)); + const recordAttempts = useRef(0); + const cancelAttempts = useRef(0); + if (!__DEV__) return ; + const variant = params.variant ?? 'first'; + const empty = variant === 'empty'; + const lesson: LessonContext = { + lessonId: 'preview-lesson', + studentName: screen === 'record-complete' ? '윤서아' : '박도윤', + dateLabel: '10월 14일 화요일', + sessionNumber: + screen === 'record-complete' ? 4 : variant === 'later' || variant === 'direct' ? 2 : 1, + completedCount: + screen === 'record-complete' ? 7 : variant === 'later' || variant === 'direct' ? 1 : 0, + totalCount: screen === 'complete' || screen === 'record-complete' ? 8 : 5, + sameDayCancellationCounts: params.policy === 'count', + parentConsented: params.consent !== 'no', + teacherLabel: 'OOO 선생님 · 영어', + textbooks: empty + ? [] + : [ + { + id: 'book-english', + name: '수능특강 영어', + unit: '강', + previousProgress: '6강', + suggestions: ['7강', '7-8강', '8-9강'], + }, + { + id: 'book-grammar', + name: 'Grammar Zone 기본', + unit: 'Unit', + previousProgress: 'Unit 16', + suggestions: ['Unit 17', '17-18', '18-19'], + }, + ], + homeworkSuggestions: ['2단원 예습', '워크북 42~45p'], + }; + function result(reason: CancellationReason): CancellationResult { + const counted = reason === '학생 당일 취소' && lesson.sameDayCancellationCounts; + return { + reason, + counted, + completedCount: Math.min(lesson.completedCount + (counted ? 1 : 0), lesson.totalCount), + totalCount: lesson.totalCount, + parentConsented: lesson.parentConsented, + }; + } + const saveRecord: SaveRecord = async (_, signal) => { + await wait(params.scenario === 'loading' ? 3000 : 650, signal); + if (params.scenario === 'error' && ++recordAttempts.current === 1) + throw new Error('Preview save failure'); + const count = Math.min(lesson.completedCount + 1, lesson.totalCount); + return { + completedCount: count, + totalCount: lesson.totalCount, + reportReady: count >= lesson.totalCount, + }; + }; + const saveCancellation: SaveCancellation = async (input, signal) => { + await wait(params.scenario === 'loading' ? 3000 : 650, signal); + if (params.scenario === 'error' && ++cancelAttempts.current === 1) + throw new Error('Preview save failure'); + return result(input.reason); + }; + return ( + <> + {screen === 'record-complete' ? ( + setNotice('report')} + onHome={() => router.replace('/home')} + /> + ) : ( + router.replace('/home')} + onHome={() => router.replace('/home')} + onCoordinate={() => setMakeupOpen(true)} + onShare={setNotice} + onReport={() => setNotice('report')} + initialFilled={variant !== 'blank' && !empty} + initialDirect={variant === 'direct'} + initialSheet={screen === 'sheet'} + initialCompletion={ + screen === 'complete' + ? result(reasons[params.reason ?? 'teacher'] ?? '선생님 사정') + : undefined + } + /> + )} + {makeupOpen && ( + setMakeupOpen(false)} + /> + )} + setNotice(null)}> + + + + {notice === 'report' + ? '정산 리포트' + : notice === 'coordinate' + ? '보강 조율하기' + : '카카오 공유'} + + + {notice === 'coordinate' + ? '보강 날짜 조율 화면은 아직 연결되지 않았어요. 학부모에게 메시지를 전송하지 않았습니다.' + : notice === 'report' + ? '정산 리포트 화면은 아직 연결되지 않았어요. 정산·교육비 청구는 실행하지 않았습니다.' + : `카카오 공유는 아직 연결되지 않았어요. ${notice === 'parent-summary' ? '학부모용 오늘 근황' : '학생용 다음 숙제'} 카드 미리보기이며 실제 메시지는 전송하지 않았습니다.`} + + setNotice(null)} /> + + + + + ); +} diff --git a/src/features/session-record/styles.ts b/src/features/session-record/styles.ts new file mode 100644 index 0000000..6176efb --- /dev/null +++ b/src/features/session-record/styles.ts @@ -0,0 +1,171 @@ +import { StyleSheet, Platform } from 'react-native'; +import { semantic as c } from '@/styles/colors'; +import { padding, grid, radius, gap } from '@/styles/measurement'; +import { typography as t } from '@/styles/typography'; +export const s = StyleSheet.create({ + safe: { flex: 1, alignItems: 'center', backgroundColor: c.surface.input }, + phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 }, + header: { height: 72, alignItems: 'center', justifyContent: 'center' }, + headerTitle: { ...t.body[1].semiBold, color: c.text.default.strong }, + back: { + position: 'absolute', + left: padding[12], + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + backText: { color: c.icon.primary, fontSize: 26 }, + leave: { position: 'absolute', right: grid.margin, minHeight: 44, justifyContent: 'center' }, + link: { ...t.body[4].semiBold, color: c.text.brand.assistive }, + scroll: { flex: 1 }, + content: { paddingHorizontal: grid.margin, paddingBottom: padding[24] }, + student: { + ...t.body[4].semiBold, + color: c.text.default.neutral, + marginBottom: gap[4], + marginLeft: padding[4], + }, + lessonTitle: { ...t.heading[1].bold, color: c.text.default.strong, marginLeft: padding[4] }, + blue: { color: c.text.brand.assistive }, + track: { + flexDirection: 'row', + gap: gap[2], + marginTop: gap[20], + marginBottom: gap[12], + paddingHorizontal: padding[4], + }, + segment: { height: 6, flex: 1, borderRadius: radius.full, backgroundColor: c.surface.disabled }, + filledSegment: { backgroundColor: c.action.default }, + progressText: { + ...t.caption[1].medium, + color: c.text.default.normal, + marginLeft: padding[4], + marginBottom: gap[24], + }, + label: { + ...t.body[5].medium, + color: c.text.default.normal, + marginBottom: gap[8], + marginLeft: padding[8], + }, + section: { marginBottom: gap[24] }, + card: { + backgroundColor: c.surface.subtle, + borderRadius: radius.large, + padding: padding[12], + marginBottom: gap[12], + gap: gap[12], + }, + bookHeading: { flexDirection: 'row', flexWrap: 'wrap', gap: gap[12], alignItems: 'center' }, + bookName: { ...t.body[4].semiBold, color: c.text.default.neutral, flexShrink: 1 }, + previous: { ...t.body[4].regular, color: c.text.default.weak }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: gap[8] }, + add: { + minHeight: 56, + borderRadius: radius.medium, + backgroundColor: c.surface.default, + borderWidth: 1, + borderColor: c.border.default, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: gap[8], + }, + addText: { ...t.body[3].regular, color: c.text.default.weak }, + plus: { fontSize: 24, color: c.icon.secondary }, + editor: { + backgroundColor: c.surface.subtle, + borderRadius: radius.large, + marginBottom: gap[12], + overflow: 'hidden', + }, + editorHeader: { + padding: padding[16], + minHeight: 60, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderBottomWidth: 1, + borderColor: c.border.subtle, + }, + editorBody: { padding: padding[16], gap: gap[16] }, + editorActions: { flexDirection: 'row', gap: gap[16], alignItems: 'center' }, + row: { flexDirection: 'row', gap: gap[4] }, + choice: { + flex: 1, + minHeight: 46, + borderRadius: radius.medium, + borderWidth: 1, + borderColor: c.border.default, + backgroundColor: c.surface.input, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: padding[8], + paddingVertical: padding[8], + }, + selectedChoice: { borderColor: c.border.focused, backgroundColor: c.surface.focused }, + choiceText: { ...t.body[3].semiBold, color: c.text.default.weak, textAlign: 'center' }, + selectedText: { color: c.text.default.strong }, + footer: { + paddingHorizontal: grid.margin, + paddingTop: padding[8], + paddingBottom: Platform.OS === 'web' ? 56 : 24, + backgroundColor: c.surface.input, + gap: gap[12], + }, + hint: { ...t.caption[1].medium, color: c.text.default.weak, textAlign: 'center' }, + warning: { ...t.caption[1].regular, color: c.text.feedback.warning }, + backdrop: { + flex: 1, + backgroundColor: '#2D303D80', + alignItems: 'center', + justifyContent: 'flex-end', + }, + sheet: { + width: '100%', + maxWidth: 390, + maxHeight: '92%', + backgroundColor: c.surface.subtle, + borderTopLeftRadius: radius.large, + borderTopRightRadius: radius.large, + paddingHorizontal: grid.margin, + paddingBottom: 56, + }, + handle: { + width: 56, + height: 4, + borderRadius: radius.full, + backgroundColor: c.border.default, + alignSelf: 'center', + marginTop: gap[12], + marginBottom: gap[24], + }, + sheetTitle: { ...t.heading[1].semiBold, color: c.text.default.strong, marginBottom: gap[8] }, + description: { ...t.body[4].regular, color: c.text.default.weak, marginBottom: gap[16] }, + reasonGrid: { gap: gap[4], marginBottom: gap[32] }, + toast: { + backgroundColor: c.surface.inverse, + borderRadius: radius.full, + alignSelf: 'center', + paddingHorizontal: padding[12], + paddingVertical: padding[8], + }, + toastText: { ...t.caption[1].regular, color: c.text.default.inverse }, + confirmBackdrop: { + flex: 1, + backgroundColor: '#00000066', + justifyContent: 'center', + alignItems: 'center', + padding: padding[24], + }, + confirmCard: { + width: '100%', + maxWidth: 342, + borderRadius: radius.large, + backgroundColor: c.surface.input, + padding: padding[24], + gap: gap[16], + }, + confirmTitle: { ...t.heading[1].semiBold, color: c.text.default.strong }, +}); diff --git a/src/features/session-record/types.ts b/src/features/session-record/types.ts new file mode 100644 index 0000000..602809a --- /dev/null +++ b/src/features/session-record/types.ts @@ -0,0 +1,55 @@ +export type Understanding = '상' | '중' | '하'; +export type HomeworkStatus = '완료' | '일부' | '안함'; +export type CancellationReason = '학생 당일 취소' | '학생 사전 취소' | '선생님 사정' | '공휴일'; +export type Textbook = { + id: string; + name: string; + unit: string; + previousProgress?: string; + suggestions: string[]; +}; +export type LessonContext = { + lessonId: string; + studentName: string; + dateLabel: string; + sessionNumber: number; + completedCount: number; + totalCount: number; + sameDayCancellationCounts: boolean; + parentConsented: boolean; + teacherLabel: string; + textbooks: Textbook[]; + homeworkSuggestions: string[]; +}; +/** 화면 내부 모델이며 확정된 API 요청·응답 형식이 아닙니다. */ +export type BookProgress = { book: Textbook; value: string; skipped: boolean }; +export type SessionRecord = { + lessonId: string; + progress: BookProgress[]; + understanding: Understanding; + previousHomework: HomeworkStatus; + nextHomework: string; + observation: string; +}; +export type CancellationInput = { lessonId: string; reason: CancellationReason }; +export type CancellationResult = { + reason: CancellationReason; + counted: boolean; + completedCount: number; + totalCount: number; + parentConsented: boolean; +}; +export type SessionRecordResult = { + completedCount: number; + totalCount: number; + reportReady: boolean; +}; +export type RecordShareTarget = 'parent-summary' | 'student-homework'; +export type SaveRecord = ( + record: SessionRecord, + signal: AbortSignal, +) => Promise; +export type SaveCancellation = ( + input: CancellationInput, + signal: AbortSignal, +) => Promise; diff --git a/src/features/session-record/useSubmission.ts b/src/features/session-record/useSubmission.ts new file mode 100644 index 0000000..590f5c4 --- /dev/null +++ b/src/features/session-record/useSubmission.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +/** 중복 요청, 화면 종료 후 상태 반영을 막는 저장 경계입니다. */ +export function useSubmission() { + const [pending, setPending] = useState(false); + const [error, setError] = useState(false); + const active = useRef(true); + const locked = useRef(false); + const controller = useRef(null); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + controller.current?.abort(); + }; + }, []); + const run = useCallback( + async (save: (signal: AbortSignal) => Promise, success: (value: T) => void) => { + if (locked.current) return; + locked.current = true; + setPending(true); + setError(false); + const request = new AbortController(); + controller.current = request; + try { + const value = await save(request.signal); + if (active.current && !request.signal.aborted) success(value); + } catch { + if (active.current && !request.signal.aborted) setError(true); + } finally { + locked.current = false; + if (active.current) setPending(false); + } + }, + [], + ); + return { pending, error, clearError: () => setError(false), run }; +} 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>;