Skip to content

UiProfileScreen props (내 정보 탭)

화면 픽스처(앱 저장소): src/react/ui/mobile/fixtures/ProfileScreen.fixture.ts, src/react/ui/mobile/fixtures/CustomExercisesScreen.fixture.ts

모바일 하단 탭 "프로필" — 프로필 카드 + 로그인 연결 + 데이터 도구 + 로그아웃. 순수 프레젠테이션(모달·입력 상태만 로컬). 화면: src/react/ui/mobile/screens/ProfileScreen.tsx (ESM named export UiProfileScreen, Vite 체제에서 window 노출 없음). 2026-08-19 실코드 기준 재작성 — 구판(2026-06-10)은 ../archive/profile-screen-props.md.

통합 시점 동작

2026-09-08 사용자 승인으로 새 서버 기능까지 통합한다. 아래 원자 저장 계약이 초기 디자인 전달 문구의 개별 콜백 저장 제안보다 우선한다.

원자 저장 계약

  • onSaveBody({ sex?, heightCm?, weightKg? })profileController.saveProfileBodyBarbelicApi.updateProfileSettingsprofile port → profile domain/repository → update_profile_settings_v1(payload) 한 요청으로 저장한다. 신체 모달은 성별과 지표를 별도 요청으로 나누지 않는다. saveProfileBodysaveProfileTrainingStyle은 appController render context에서 mobileApp으로 전달한다.
  • 변경하지 않은 키는 요청에서 생략한다. sex: null은 선택 해제이며 누락과 다르다. 성별만 바꾸는 데 미설정 키·체중을 요구하지 않는다. 키 100~250cm, 체중 20~400kg, 성별 m/f/null을 UI·codec·서버가 검증한다. 키·체중 null은 허용하지 않는다. 체중이 있으면 컨테이너가 고정 업무 날짜 todayDate를 넣고 서버는 유효한 YYYY-MM-DD를 요구한다.
  • RPC는 본인 auth.uid()만 수정한다. 다른 owner 인자는 없고 허용되지 않은 필드·빈 요청은 거부한다. anon 실행 권한은 없으며 세션 없는 호출은 42501이다. 입력 오류는 22023이며, 입력·프로필·측정치 저장 중 하나라도 실패하면 트랜잭션 전체가 롤백된다.
  • 체중 저장은 같은 날짜의 기존 수동 측정치를 우선하며, 없으면 최신 측정치의 골격근량·체지방량·체지방률을 보존한다. 같은 날짜의 수동 측정치는 기존 unique key로 갱신해 중복을 만들지 않는다. 기존 메모·다른 날짜 측정치·다른 사용자·프로필의 이름/목표/온보딩 상태는 보존한다.
  • 응답은 { profile: { id, sex, height_cm, primary_training_style }, profile_workspace }이다. codec은 모양과 값을 검증하고 controller는 요청 소유자와 응답 id가 일치할 때만 반영한다. 계정 전환 뒤 늦은 응답은 폐기한다. 성별·체중·운동 타입 변경 후 기존 기준표를 재적재한다. 재적재 실패는 이미 완료된 저장을 실패로 되돌리지 않는다.
  • UI는 저장 중 입력·닫기·중복 제출을 막고 응답을 기다린다. 실패/null/false이면 모달과 입력을 유지한다. 성공 후에만 닫는다. 기존 onSaveSex(m/f) 호출은 기존 API와 호환되며 onSaveSex(null)은 새 원자 저장 API로 선택을 해제한다.
  • onLogoutAll은 기존 signOutAllDevices에 연결한다. 로그인·이름·아이디·사진·내보내기·차단·계정 삭제는 기존 callback을 유지한다.
  • 마이그레이션: 20260913100100_profile_design_settings.sql. 회귀: tests/react/profileDesignIntegration.test.mjs, supabase/tests/database/profile_design_settings.test.sql.

2026-09-08 레이아웃 정리 (props 계약 불변)

  • 상단 = 전 탭 공용 워드마크 행(UiBrandRow) + 좌측 큰 제목 "내 정보"(.mb-lead.pf-title) — 구 .appbar h1 폐지. 페이지 골격 prt-page pf-page.
  • 히어로: 아바타 84 + 우하단 카메라 배지(탭 = 사진 파일 선택, profile.photoFile; onChangePhoto 없으면 배지 미렌더) · 이름 · @아이디.
  • 모든 행 = "제목 · 회색 값 · ›" 단일 문법, 캡션 3그룹(드로어 문법): PROFILE 이름(값, profile.name.edit → 기존 모달) · 아이디(값, profile.handle.edit → 기존 모달) — hook mobileProfileEditCard, 콜백 없는 행 미렌더 · 신체 정보 행(값 = "여성 · 165cm · 58kg" 또는 미설정, hook mobileProfileSex, onSaveSex·onSaveBody 둘 다 없으면 disabled) → 모달: 성별 세그 + "성별 선택하지 않음" 체크(→ onSaveSex(null)) + 키/체중 입력 + 취소/저장, 검증 profileBodySettingsError · 운동 타입 행(2026-09-15 #1633 신설) — 값 = 현재 운동 타입명(미설정이면 안내), hook mobileProfileTrainingStyle, 탭 = 운동 타입 선택 화면 스택 항목(tool="trainingStyle")을 연다(구 드로어 "직업 변경" 행 이동). onOpenTrainingStyle 없으면 행 미렌더 · ACCOUNT 로그인 연결 행(값 = 연결된 provider 이름, 로딩 시 "확인 중…") → 모달(hook mobileSocialIdentityConnections, provider 행 + 연결됨/연결(.btn.btn-outline.sm) profile.linkIdentity, 오류는 모달 안 안내) + 데이터 내보내기/가져오기 + 차단한 사용자 · SUPPORT 이용약관 · 개인정보 처리방침 · 문의(행 전체 탭 = profile.contactCopy, hook mobileSafetyCard).
  • ACCOUNT 끝 "계정 관리 ›" 행(onDeleteAccount 있을 때) → 모달(hook mobileProfileAccount, 미등록) 세부 메뉴 = 모든 기기에서 로그아웃 행(onLogoutAll, action profile.logoutAll) · 계정 삭제 행(.pf-delUiAccountDeleteSection 진입 버튼만 행 모양으로 재스킨 — 2단 확인 플로우·마커 불변). 로그아웃 = 리스트 아래 독립 .btn.btn-outline.block(hook mobileLogoutButton).
  • 행 탭 결과는 전부 모달(이름·아이디 입력 모달 / 성별·로그인 연결 선택 모달) — 화면 내부 UI 상태 — 컨테이너 상태·마커·action·field·prop 전부 동일, 배선 변경 0.
prop형태설명
profile{ name, short, meta?, photo? }프로필 카드. photo 있으면 이미지, 없으면 short 이니셜 아바타. meta 예: "N회 운동 · 가입 N일"(없으면 이메일 폴백). 컨테이너는 공통 profile 형태 전체를 넘기며 이 화면은 이 4개 필드만 소비
socialIdentitiesreadonly [{ provider, label, connected, enabled }]로그인 연결 카드의 provider 행들(SocialIdentityConnection, src/react/types/auth.ts). connected → "연결됨" 칩(data-lg-state="connected"), 아니면 연결 버튼(enabled=false면 disabled)
socialIdentitiesLoadingboolean카드 서브텍스트를 "연결 상태 확인 중…"으로 전환
socialIdentityErrorstring있으면 카드 마지막 행에 안내(role="status" aria-live="polite")
pendingSocialIdentityProviderSocialAuthProvider | ""해당 provider 버튼 라벨 "연결 중…" + 모든 연결 버튼 disabled
onLinkSocialIdentity(provider)연결 버튼 탭
onExport()"데이터 내보내기" 행 탭
onChangePhoto?(file)2026-09-02 신설 — "프로필 사진 변경하기" 행(파일 선택 accept="image/*", data-lg-field="profile.photoFile") → 선택된 File 하나 전달. 업로드·리사이즈·재서명·profile.photo 갱신은 컨테이너. prop 없으면 행 미렌더
onSaveName?(name)2026-09-02 신설 — "이름 변경하기" 행 → 이름 변경 모달(profileNameModal, data-lg-field="profile.name", 20자) 저장 시 trim된 이름 전달(빈 값 불가). prop 없으면 행 미렌더
onSaveHandle?(handle)기존 콜백 그대로 — 2026-09-02부터 인라인 폼 대신 "아이디 변경하기" 행 → 모달(profileHandleModal, @ 접두 입력) 저장 시 @ 제거·trim된 값 전달. prop 없으면 행 미렌더
(표시)2026-09-02: 행 부제(설명문) 전부 제거, 로그인 연결 provider 행 = 브랜드 아이콘 배지(kakao/apple/google, 그 외 첫 글자)
(표시)프로필 카드는 아바타 · name · @handle만 표시 — meta는 2026-09-02부터 미표시(prop 유지)
onImport?(file)컨테이너 미배선(2026-08-19 현재) — prop이 없으면 가져오기 행 자체를 렌더하지 않는다. 배선 시 선택된 File 하나를 전달
onLogout()하단 로그아웃 버튼
onDeleteAccount?()() => Promise<{ ok?: boolean }>2026-08-25 계정 삭제(#663) — 로그아웃 아래 UiAccountDeleteSection(../lib/AccountDeleteFlow.tsx, 자체 상태) 진입 버튼 + 2단 확인(안내 목록·내보내기 힌트 → 확인 문구 계정 삭제 입력). 문구 일치 시에만 실행하며 ok:true면 앱이 로그아웃 상태로 전환(다이얼로그는 트리째 소멸), 아니면 인라인 실패 안내 후 재시도. prop 없으면 섹션 미렌더. 공유 카피: src/react/ui/shared/accountDeletionCopy.ts
onSaveHandle(handle)2026-08-23 아이디(@handle) 카드 — 프로필 카드 아래 .card.flush 한 장: 행 제목 "아이디", 부제(현재 @handle 또는 안내), input.input(name=handle, defaultValue=현재 값) + "저장" 버튼의 무상태 <form>. 제출 시 입력값 원문을 넘기고 정규화·유일성·문구는 컨테이너(profileController.saveProfileHandle → 토스트)가 맡는다. profile.handle이 바뀌면 입력이 리셋된다(key)
onSaveBody?(changes){ sex?: "m" | "f" | null; heightCm?: number; weightKg?: number }신체 정보에서 변경된 필드만 한 번에 저장한다. 위 원자 저장 계약이 정본이다. 표시값은 profile.bodyMetrics.heightCm/.weight. field profile.heightCm·profile.weightKg, action profile.body.save
onOpenTrainingStyle?()2026-09-15 신설(이슈 #1633) — PROFILE 그룹 "운동 타입 · 현재 운동 타입명 · ›" 행 탭. 컨테이너(MobileProfileBinding)가 운동 타입 선택 화면 스택 항목을 연다. prop 없으면 행 미렌더
onLogoutAll?()2026-09-08 신설 — 계정 관리 모달 "모든 기기에서 로그아웃" 행. 있으면 계정 관리 행 노출(onDeleteAccount와 OR). 컨테이너가 전 세션 revoke + 로컬 로그아웃 처리
onSaveSex(sex)"m" | "f" | null (2026-09-08: null = 선택하지 않음)2026-09-02 성별 카드(이슈 #1142) — 아이디 카드 아래 .card.flush 한 장(hook mobileProfileSex): 행 제목 "성별", 부제(설정됨: "종목 등급(상위 %) 기준표를 고르는 데 쓰여요" / 미설정: "아직 없어요 — 등급이 남성 기준으로 매겨지고 있어요"), 온보딩 신체 단계의 .ob-pun 토글(남성/여성, role=radio). 현재 값(profile.sex)과 다른 쪽을 탭하면 즉시 저장 — 저장·토스트·등급 기준표 재적재는 컨테이너(profileController.saveProfileSex)가 맡는다. prop 없으면 버튼 disabled
  • 마커: hook mobileProfileView(view dashboard.profile) / mobileSocialIdentityConnections / mobileProfileSex / mobileExportButton / mobileLogoutButton / importFile(field profile.importFile) / mobileImportButton / accountDeleteButton / accountDeleteDialog. action profile.export / profile.import / profile.linkIdentity(+data-lg-option-value=provider) / profile.logout / profile.deleteAccount(+.cancel/.continue/.confirm, field profile.deleteAccount.phrase). 연결 행에 data-provider 부가. 전부 designContract.ts 등록 완료.
  • Codex 배선(mobileApp.tsx): profile=lgProfilePropsFromRemote 결과, social 4종 + linkSocialIdentitysocialIdentityController, onExport=exportUiData, onLogout=signOut, onDeleteAccount=deleteAccount(profileController — 임퍼서네이션 차단 → /api/account/delete → 로컬 정리), onImport 미전달.
  • 공통 profile 형태(lgProfilePropsFromRemote 반환, 데스크톱 계약과 공유): { name, handle, short, meta, photo, annualVolumeGoalKg, bodyMetrics{ weight, muscleMass, bodyFat, updatedAt }, wodupImportMeta, inbodyImportMeta }.

2026-08-25 안전·약관·문의 (UGC 안전 v1, 이슈 #670 — App Store 1.2)

prop형태설명
blockedUsers{ status:'loading'|'ready'|'error', items:[{ id, name, handle, photo, short, blockedAt }], error, unblockingId } | null차단 목록 시트 상태(moderationStore.blockedList 정본). null이면 시트 미렌더
onOpenBlockedUsers()"차단한 사용자" 행(profile.blockedUsers) 탭 → 스토어가 목록을 읽고 시트를 연다
onCloseBlockedUsers()시트 닫기(스크림 탭·X)
onUnblockUser(id)행 "차단 해제"(profile.unblock) — 해제해도 팔로우는 복구되지 않는다(D1, 행 부제에 명시)
contactEmailstring문의 행의 현재 이메일 주소(SUPPORT_COPY.contactEmail/legal/service-info.html의 문의 주소와 동일). 과거 동의 대상 문서 원문에 남은 연락처를 현재 값으로 덮어쓰지 않는다.
onCopyContact()문의 행 "복사"(profile.contactCopy) — 클립보드 복사(불가 시 주소 토스트)
onOpenLegal(kind)kind: 'terms'|'privacy'이용약관(profile.terms)·개인정보 처리방침(profile.privacy) 행 — 인앱 문서 뷰어 오버레이(LegalDocumentOverlay, 히스토리 레이어 legal)로 현재 버전 문서를 연다. 웹뷰를 문서 페이지로 이동시키지 않는다(이슈 #742 — 셸에서 복귀가 전체 재부팅·부팅 스플래시였음)
  • 카드(hook mobileSafetyCard)는 데이터 도구 카드와 로그아웃 사이. 차단 목록 시트는 hook profileBlockedUsers (.sheet-scrim/.sheet.rc-sheet.pf-blk, 상태는 전부 스토어 소유 — 화면은 여전히 무상태 hooks 없음).
  • 배선(mobileApp.tsx): blockedUsers=moderation.blockedList, 콜백 4종=moderationController, contactEmail=SUPPORT_COPY.contactEmail, onCopyContact=copySupportContact, onOpenLegal=openLegalDocumentPage(appController).

커스텀 종목 관리 = 독립 화면 분리 (2026-08-27)

  • UiProfileScreen에서 커스텀 종목 props 제거: customExercises, customExercisesLoading, customExercisePendingId, onArchiveCustomExercise, onRestoreCustomExercise — 내 정보 화면은 더 이상 이 UI를 갖지 않는다.
  • 신설 UiCustomExercisesScreen(같은 파일 named export, 무상태): props = customExercises · customExercisesLoading? · customExercisePendingId? · onArchiveCustomExercise(id) · onRestoreCustomExercise(id) · onBack(). PR 도구와 같은 스택 화면(이슈 #1393 Phase 5, D1=a) — 연 탭 스택 위 슬라이드, 워드마크 행 좌측 뒤로 버튼 = onBack(스택 뒤로).
  • 진입 = 메뉴 드로어 ACCOUNT 그룹 "커스텀 종목 관리"(NavDrawerProps.onCustomExercises, 선택 배선 — 미공급 시 항목 비노출).
  • 마커: mobileCustomExerciseManager 훅이 내 정보 카드 → 새 화면 루트로 이동. mobileCustomExerciseRow·보관/복원 액션 마커는 불변. 드로어 항목 액션 마커는 미부착(제안명 profile.customExercises.open — Codex 등록 후 부착).
  • CSS: styles/screens/profile.css 신설(@layer screen) — 컨테이너 CSS 로드 목록에 편입 필요.

A11 resource 연결 (2026-09-10)

프로필 값은 profile resource를 props로 투영한다. 기존 데스크톱 인입 행에 진행 상태와 확인 중지/재확인 동작을 연결한다. 파일 선택 busy는 binding이 제공하며 화면이 서버 잡 상태나 성공을 추정하지 않는다. 소유·무효화 계약을 따른다.