Compare commits
91 Commits
2ac568a4ab
...
6df34a0eb7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6df34a0eb7 | |||
| 9b013f1843 | |||
| 14d7165ffe | |||
| d193973eb5 | |||
| f580ab8a72 | |||
| 167e64534f | |||
| 3a06881634 | |||
| bdbcf3c3f1 | |||
| 99146fb08b | |||
| 3204609f3d | |||
| fd1b7123e2 | |||
| cd91ff9ae5 | |||
| aff1a007b2 | |||
| f21129fc5d | |||
| 13a94ef42d | |||
| 2afbe3ce7a | |||
| 5026138ad9 | |||
| 4bbee36e1e | |||
| 1d2ce85cfd | |||
| 6194c19f3b | |||
| abc1525fe2 | |||
| 38abc1e0c7 | |||
| f4910238a0 | |||
| ec941f3cde | |||
| 627bd82706 | |||
| b91fdbcb67 | |||
| fb2729193f | |||
| c63ddc4e98 | |||
| 6b25a18d5a | |||
| e16a182499 | |||
| 8f4a69fc77 | |||
| 81e969c116 | |||
| 16d620ee4a | |||
| 721212ec1f | |||
| c6e342e93d | |||
| 38a9d1e762 | |||
| acfa8f4f48 | |||
| 3471c96972 | |||
| 56385ec2eb | |||
| 3c5154178d | |||
| 728330bf74 | |||
| 3aba789c97 | |||
| 1b01ceaa8b | |||
| 6b70d07e3c | |||
| 6a0710d023 | |||
| cbeeb38413 | |||
| 0f01ecd8a1 | |||
| b3853c98d2 | |||
| 6bf3336aec | |||
| de95505d2f | |||
| 5d3a5ac8ac | |||
| c8b00905cd | |||
| fe908ec415 | |||
| 445ef54528 | |||
| dc97a78fdd | |||
| 679601d201 | |||
| 74e44fff69 | |||
| 0b8c207fe2 | |||
| 729afe0cbf | |||
| 278fc11135 | |||
| b0fe2887c6 | |||
| 425943cf89 | |||
| 9abe868db6 | |||
| caf53f0b68 | |||
| cc3eafb2fa | |||
| 4421e776b2 | |||
| fe67597320 | |||
| a27cce9a67 | |||
| b4ed94cf1b | |||
| bc08a049b6 | |||
| 6154bd54a8 | |||
| a1424a4794 | |||
| 88bb4f40b8 | |||
| abdde2a8ae | |||
| 2506dd53a7 | |||
| 698c124ade | |||
| 972be117cb | |||
| 35f1dfb92d | |||
| 7867bd39ca | |||
| 4717bb3a1a | |||
| 9811134d8a | |||
| c47f60163d | |||
| 1717f335f0 | |||
| 92a509ebb6 | |||
| 1c55f74132 | |||
| 986b9ba94b | |||
| cceaa6bd82 | |||
| 675014166a | |||
| 8184915cb1 | |||
| d18d9b2bb9 | |||
| 09b02f4168 |
@@ -1,22 +1,202 @@
|
|||||||
# 90. Current State
|
# 90. Current State
|
||||||
|
|
||||||
Last Updated: 2026-03-05
|
Last Updated: 2026-03-16
|
||||||
|
|
||||||
## DONE
|
## DONE
|
||||||
|
|
||||||
|
- `/app` session gate 제거:
|
||||||
|
- `/app`은 더 이상 running / paused / takeover UI를 보여주지 않는다
|
||||||
|
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다
|
||||||
|
- no-session일 때만 atmosphere entry shell이 열린다
|
||||||
|
- `/app` Atmosphere Entry Shell 1차 구현:
|
||||||
|
- no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다
|
||||||
|
- `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다
|
||||||
|
- atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다
|
||||||
|
- 입력한 duration은 raw `focusDurationMinutes`로 server에 전달된다
|
||||||
|
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
||||||
|
- `/app` Atmosphere Entry Shell visual premium polish:
|
||||||
|
- no-session shell을 `decision rail + selected atmosphere stage + curated atmosphere library` 구조로 다시 짰다
|
||||||
|
- 좌측은 입력과 시작 결정을 담당하고, 우측은 선택한 atmosphere의 immersive preview를 크게 보여준다
|
||||||
|
- review entry는 start stage 아래 quiet dock로 내려 main CTA와 경쟁하지 않게 정리했다
|
||||||
|
|
||||||
|
- current session direct start 차단:
|
||||||
|
- silent abandon을 막기 위해 server `startSession()`은 current session 존재 시 direct start를 거절한다
|
||||||
|
- `/app` 기존 single-goal commitment gate는 legacy로 내려갔다:
|
||||||
|
- 2-step `goal -> ritual` flow를 제거하고, current session이 있으면 `Resume` UI를 우선 노출하도록 정리했다
|
||||||
|
- 현재 source-of-truth는 `goal + duration + atmosphere` 중심의 새 entry shell spec이다
|
||||||
|
- `/space` Refocus System slice 1 구현:
|
||||||
|
- HUD recovery layer를 `paused / refocus / next-beat / complete` 단일 overlay 상태로 정리
|
||||||
|
- pause 직후 바로 편집 시트를 열지 않고, 작은 recovery prompt를 먼저 노출
|
||||||
|
- `한 조각 다시 잡기`로 refocus에 들어가고, paused 상태에서는 `적용하고 이어가기`로 바로 resume 연결
|
||||||
|
- microStep 완료 후 `다음 한 조각이 있나요?` next-beat prompt로만 이어지게 정리
|
||||||
|
- 한 번에 하나의 recovery tray만 열리도록 hierarchy를 고정
|
||||||
|
- `/space` Refocus System slice 2 구현:
|
||||||
|
- pause prompt의 `이대로 이어가기`가 실제 resume 동작으로 연결
|
||||||
|
- goal complete tray에 `여기서 마무리하기` 경로 추가
|
||||||
|
- 현재 세션을 다음 목표 입력 없이도 정상 완료 처리할 수 있게 연결
|
||||||
|
- goal complete / rest / next-goal의 세 분기가 UI와 동작 모두에서 분리됨
|
||||||
|
- `/space` Refocus System slice 3 구현:
|
||||||
|
- goal complete tray가 초기부터 input form을 강요하지 않도록 progressive disclosure 구조로 변경
|
||||||
|
- `여기서 마무리하기 / 잠시 비우기 / 다음 목표 이어가기`를 먼저 제안하고, 다음 목표 입력은 선택 시에만 펼쳐지게 정리
|
||||||
|
- next-beat prompt에 현재 goal 문맥을 함께 보여주도록 보강
|
||||||
|
- `/space` Refocus System slice 4 구현:
|
||||||
|
- pause / next-beat / complete / refocus tray의 glass material, hairline, spacing을 공통 규칙으로 정리
|
||||||
|
- 선택 액션을 단순 inline 링크에서 quiet option row로 바꿔 recovery decision hierarchy를 선명하게 만듦
|
||||||
|
- `goal complete`의 세 분기를 같은 tray 안의 선택 행으로 통합해 planner/form 느낌을 약화
|
||||||
|
- `refocus` form field와 footer action 톤을 다른 recovery tray와 같은 제품군으로 맞춤
|
||||||
|
- `/space` Away / Return Recovery slice 구현:
|
||||||
|
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 AwayCandidate 감지 추가
|
||||||
|
- 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둠
|
||||||
|
- 돌아왔을 때 focus가 아직 running이면 `Return` tray에서 `이어서 하기 / 한 조각 다시 잡기`를 제안
|
||||||
|
- 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray를 먼저 띄움
|
||||||
|
- 이 경우 `쉬기 이어가기 / 다음 목표 이어가기 / 한 조각 다시 잡기`를 선택할 수 있음
|
||||||
|
- `다음 목표 이어가기`는 `Goal Complete` next view로 바로 연결됨
|
||||||
|
- `/space` Pause tray premium polish:
|
||||||
|
- tray 폭과 열림 높이를 키워 긴 한국어 카피가 잘리지 않게 조정
|
||||||
|
- eyebrow / title / body의 typography hierarchy와 line-height를 재정렬
|
||||||
|
- option row spacing, radius, chevron 위치를 보정해 급조된 버튼 묶음 느낌을 완화
|
||||||
|
- `/space` Pause / Break / Return tone 분리 1차 구현:
|
||||||
|
- `Return(focus)`와 `Return(break)`가 같은 tray처럼 보이지 않도록 break tray에 emerald tint release tone 도입
|
||||||
|
- `Goal Complete`의 `잠시 비우기` 선택도 같은 break 계열 material로 연결
|
||||||
|
- timer HUD는 break phase에서 더 가벼운 emerald 계열 glass로 보정해 focus/pause와 구분되게 조정
|
||||||
|
- `/space` Pause / Break / Return copy + interaction polish:
|
||||||
|
- `Pause`는 `멈춘 이유` 대신 `다시 시작할 한 줄`을 중심으로 카피를 다시 정리
|
||||||
|
- `Return(focus)`는 `멈춘 자리에서 이어가기`, `Return(break)`는 `쉬기 이어가기 / 다음 블록 이어가기` 중심으로 재서술
|
||||||
|
- `Goal Complete`는 `다음 블록 이어가기 / 잠시 비우기 / 여기서 마무리하기` 순의 선택 tray를 먼저 보여주고, 다음 블록 입력은 이후 단계에서만 열리게 정리
|
||||||
|
- choice/next view의 헤더와 설명도 각각 다른 감정 상태에 맞춰 분리
|
||||||
|
- `/space` Pause / Break / Return motion polish 1차 구현:
|
||||||
|
- `Pause` tray는 빠르게 다시 붙잡는 recovery reveal로 조정
|
||||||
|
- `Return(focus)`는 짧은 re-entry settle motion으로, `Return(break)`는 더 느슨한 release reveal로 분리
|
||||||
|
- `Goal Complete`도 같은 recovery family 안에서 가장 느린 closure motion을 가지도록 조정
|
||||||
|
- `/space` goal closure CTA matrix 정렬:
|
||||||
|
- `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정
|
||||||
|
- base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가
|
||||||
|
- 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리
|
||||||
|
- `/space` timer completion flow 재정의:
|
||||||
|
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다
|
||||||
|
- timer가 `00:00`에 도달하면 same-session modal이 자동으로 열리고, `완료하고 종료하기 / 10분 더` 두 경로만 제안한다
|
||||||
|
- `10분 더`를 누르면 현재 focus phase에 10분이 추가되어 바로 running으로 돌아가고, 다시 시간이 끝나면 같은 modal이 다시 열린다
|
||||||
|
- server와 web 모두 `extend-phase` 계약 기준으로 동작한다
|
||||||
|
- `10분 더`는 남은 시간만이 아니라 `focusDurationSeconds`도 함께 늘려 이후 결과 모달과 review의 집중 시간이 맞도록 정리됐다
|
||||||
|
- `/space` completion result modal 추가:
|
||||||
|
- `timer-complete -> 완료하고 종료하기`와 `End Session -> 여기서 마무리하기`는 즉시 `/app`으로 가지 않고 중앙 결과 모달을 먼저 연다
|
||||||
|
- 결과 모달에는 `집중한 시간`, `완료한 목표`, `이번 세션 thought capsule`이 표시된다
|
||||||
|
- 결과 모달이 떠 있는 동안 `/space` 자동 `/app` redirect는 막히고, `확인하고 돌아가기`에서만 `/app`으로 이동한다
|
||||||
|
- current-session thought capsule 서버 복원:
|
||||||
|
- `/space` thought는 public `focusSessionId` 없이 auth 기반으로 현재 세션에 서버가 내부 연결한다
|
||||||
|
- `/space` 진입 시 `GET /api/v1/focus-sessions/current/thoughts`로 현재 세션 thought 목록을 복원한다
|
||||||
|
- 브라우저를 껐다 켜도 current session이 살아 있으면 같은 thought 목록을 다시 읽을 수 있다
|
||||||
|
- `/space` intent HUD collapsed / expanded 재설계:
|
||||||
|
- 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경
|
||||||
|
- hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨
|
||||||
|
- recovery tray(`pause / return / next-beat / complete / refocus`)가 열릴 때는 base card가 강제로 collapsed 상태를 유지하도록 정리
|
||||||
|
- expanded rail은 outside click으로 접히지만, recovery tray는 outside click으로 닫히지 않고 명시적 액션으로만 닫힘
|
||||||
|
- rail 클릭은 expand/collapse만 담당하고, refocus는 expanded 상태의 `수정` 액션으로만 진입
|
||||||
|
|
||||||
|
- Focus Entry Surface / Execution Surface 재정의:
|
||||||
|
- `/app`을 planning home이 아니라 hero-first focus entry surface로 재구성
|
||||||
|
- 상단 카피를 `Planning Home` 톤에서 `지금 시작할 첫 블록` 진입 톤으로 교체
|
||||||
|
- 메인 hero에 one-line goal input + 단일 primary CTA `지금 시작`만 남기고, 첫 진입의 주 행동을 고정
|
||||||
|
- empty state에서는 starter draft를 자동 주입하지 않고 placeholder 입력으로 시작한다
|
||||||
|
- suggestion chip으로 draft를 빠르게 교체할 수 있게 하고, 직접 타이핑 시에는 ad-hoc start를 허용
|
||||||
|
- 이후 `/app`은 current session이 있으면 `Resume`, 없으면 single-goal direct start만 남기는 commitment gate로 더 줄였다
|
||||||
|
- block CRUD, preview row, list-first 구조는 메인 진입 경로에서 제거했다
|
||||||
|
- `/space`는 planning overview 없이 goal/scene/sound/timer + HUD 실행 화면으로 정리
|
||||||
|
- focus-plan / focus-session 서버 계약 연결:
|
||||||
|
- `GET /api/v1/focus-plan/today`
|
||||||
|
- `POST /api/v1/focus-plan/items`
|
||||||
|
- `PATCH /api/v1/focus-plan/items/:id`
|
||||||
|
- `DELETE /api/v1/focus-plan/items/:id`
|
||||||
|
- `POST /api/v1/focus-plan/items/:id/complete`
|
||||||
|
- `POST /api/v1/focus-sessions/current/advance-goal`
|
||||||
|
- frontend에서 plan item id와 today plan 응답을 backend contract 기준으로 정규화
|
||||||
|
- `GET /today`는 current 이후 pending item 전체를 `nextItems`로 반환
|
||||||
|
- 목표 완료 후 다음 목표 즉시 실행 흐름 구현:
|
||||||
|
- GoalCompleteSheet에서 다음 목표를 입력하면 현재 세션 완료 + 새 planning item 생성 + 다음 세션 즉시 시작
|
||||||
|
- 같은 scene / sound / timer를 유지한 채 `/space` focus 화면에서 그대로 이어감
|
||||||
|
- 실패 시 시트를 닫지 않고 HUD 토스트로 에러만 노출
|
||||||
|
- `/stats` factual summary 정리:
|
||||||
|
- weekly insight / quiet accountability mock 제거
|
||||||
|
- today / last7Days / trend만 남기고 factual card 구조로 단순화
|
||||||
|
- Calm Session OS 유료화 축 구현:
|
||||||
|
- Free는 기본 시작, Pro는 더 잘 이어가기를 파는 구조로 재정의
|
||||||
|
- old `Scene Packs / Sound Packs / Profiles` 중심 copy를 `Daily Focus Plan / Rituals / Weekly Review` 중심으로 교체
|
||||||
|
- `/app` route를 focus entry surface로 복구:
|
||||||
|
- `/app` route가 `/space` redirect 대신 `FocusDashboardWidget`을 렌더링
|
||||||
|
- current/next summary card와 list-first 구조를 제거했다
|
||||||
|
- 현재는 `Resume` gate가 구현되어 있고, no-session entry shell은 다음 slice에서 `goal + duration + atmosphere` 구조로 교체될 예정이다
|
||||||
|
- 플랜 tier 공유 store 추가:
|
||||||
|
- `entities/plan/model/usePlanTier.ts` 추가
|
||||||
|
- localStorage 기반 Free/Pro 상태를 `/app`, `/space`, `/stats`, dock paywall에서 공통 사용
|
||||||
|
- Session OS 도메인 mock 추가:
|
||||||
|
- `FocusPlanItem`
|
||||||
|
- `SessionTemplate`
|
||||||
|
- `SessionOutcome`
|
||||||
|
- `WeeklyReview`
|
||||||
|
- `AsyncCheckIn`
|
||||||
|
- `entities/session/model/focusSystem.ts`에 mock/헬퍼 집약
|
||||||
|
- `/space` planning overview 제거:
|
||||||
|
- setup drawer에서 Daily Plan / Ritual Library 진입 섹션 제거
|
||||||
|
- `/app`에서 넘긴 goal + `planItemId`를 받아 execution-only surface로 집중
|
||||||
|
- `/stats` factual summary 정착:
|
||||||
|
- factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환
|
||||||
|
- `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영
|
||||||
|
- 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용
|
||||||
|
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다
|
||||||
|
- `/stats` immersive review stage polish:
|
||||||
|
- `/stats`를 밝은 대시보드 카드 반복 화면에서 dark immersive review stage로 재구성했다
|
||||||
|
- 중앙 hero summary, snapshot signal board, start/recovery/completion observatory panel, carry-forward closure stage를 같은 glass family로 통일했다
|
||||||
|
- carry-forward ritual에 맞는 atmosphere 배경을 얇게 투영하되, `/app`의 entry ritual을 복제하지 않고 통계 화면 고유의 observatory 톤으로 분리했다
|
||||||
|
- `/app -> /stats` primary entry의 1차 연결:
|
||||||
|
- current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다
|
||||||
|
- current session이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다
|
||||||
|
- `/stats -> /app` handoff의 2차 연결:
|
||||||
|
- `/stats` 마지막 CTA는 `/app?review=weekly&carryHint=...&entryAtmosphereId=forest-draft&entryDurationMinutes=50`으로 연결된다
|
||||||
|
- `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다
|
||||||
|
- goal과 duration은 자동 입력하지 않고, 방향만 가볍게 제안한다
|
||||||
|
- Pro personalized handoff 3차 연결:
|
||||||
|
- Pro에서는 `/stats` carry-forward 섹션에 추천 ritual을 함께 보여준다
|
||||||
|
- `/stats` 마지막 CTA 카피가 generic start가 아니라 `가장 잘 맞은 atmosphere로 /app 돌아가기`로 바뀐다
|
||||||
|
- `/app` teaser와 review return hint도 Pro에서 더 구체적인 next-session handoff 톤으로 표시된다
|
||||||
|
- `Weekly Review` recovery의 서버 연결:
|
||||||
|
- server `focus-summary` 응답에 `recovery`가 추가됐다
|
||||||
|
- `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다
|
||||||
|
- 현재는 `away recovery` 이벤트 스키마가 없어 partial/limited 상태로 남긴다
|
||||||
|
- paywall / plan / landing 메시지 재정렬:
|
||||||
|
- paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성
|
||||||
|
- landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
||||||
|
- Teams는 후순위 준비중 톤으로 약화
|
||||||
|
- Gemini 분리본 재점검:
|
||||||
|
- `SpaceWorkspaceWidget`, `SpaceToolsDockWidget`, `admin/page.tsx`, `shared/i18n/ko.ts` 분리 상태를 다시 확인
|
||||||
|
- 현재 기준 500줄 초과 파일 없음
|
||||||
|
- 최대 파일은 `src/widgets/admin-console/ui/AdminDashboardView.tsx` 482줄
|
||||||
|
- `/admin` 업로드 콘솔 회귀 복구:
|
||||||
|
- widget 분리 이후 로그인 후 placeholder만 보이던 상태 제거
|
||||||
|
- `AdminConsoleWidget`은 조합만 담당하고, 실제 scene/sound 업로드 UI는 `AdminDashboardView`로 분리 복원
|
||||||
|
- 인증 전역 상태 위치 정리:
|
||||||
|
- `src/store/useAuthStore.ts` 제거
|
||||||
|
- `entities/auth/model/useAuthStore.ts`로 이동해 auth feature가 루트 store를 직접 참조하지 않도록 정리
|
||||||
|
- auth 타입 참조 정리:
|
||||||
|
- `features/admin/api/adminApi.ts`가 `features/auth/types` 대신 `entities/auth`를 직접 사용
|
||||||
|
- `features/auth/types/index.ts`는 중복 선언을 제거하고 `entities/auth` 재export만 담당
|
||||||
|
- `/space` stage 배경 overscan 보정:
|
||||||
|
- pan 애니메이션 중 가장자리 빈틈이 드러나지 않도록 stage background layer를 `-inset-8`로 확장
|
||||||
|
- `/space` 배경 asset 해석 안정화:
|
||||||
|
- media manifest scene key를 scene alias까지 정규화해 `green-forest`와 `forest`를 동일 asset으로 해석
|
||||||
|
- scene/sound asset에 `source(fallback|remote)` 메타를 추가해 실제 fallback 사용 여부를 구분 가능하게 정리
|
||||||
|
- remote manifest load 실패 시 error 상태를 노출하고, `/space`에서 manifest 실패/scene fallback 사용을 진단 로그로 남기도록 보강
|
||||||
- Focus 피드백 채널 단일화:
|
- Focus 피드백 채널 단일화:
|
||||||
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
|
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
|
||||||
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
|
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
|
||||||
- Free 코어 루프 개방:
|
- Free 코어 루프 개방:
|
||||||
- Quick Controls Time의 `90/20` 잠금을 제거
|
|
||||||
- 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능
|
- 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능
|
||||||
- Pro 가치 재배치:
|
- Pro 가치 재배치:
|
||||||
- Pro 잠금 대상을 `Scene Packs / Sound Packs / Profiles`로 재정의
|
- Pro 잠금 대상을 `Daily Focus Plan / Rituals / Weekly Review`로 재정의
|
||||||
- 기본 Scene/Time/Sound는 잠금 없이 선택 중심으로 정리
|
- 기본 Scene/Time/Sound는 잠금 없이 선택 중심으로 정리
|
||||||
- Control Center UI 재구성:
|
- Control Center UI 재구성:
|
||||||
- Scene/Time/Sound 중심 구조 유지
|
- Scene/Time/Sound 중심 구조 유지
|
||||||
- 추천 조합을 정보 1줄로 축소(비인터랙션)
|
- 추천 조합을 정보 1줄로 축소(비인터랙션)
|
||||||
- 하단에 Packs/Profiles 요약 카드(작은 🔒 배지) 추가
|
- 하단에 Session OS 요약 카드(작은 🔒 배지) 추가
|
||||||
- Paywall 의도 기반 트리거 적용:
|
- Paywall 의도 기반 트리거 적용:
|
||||||
- 잠금 카드 클릭 시에만 Paywall Sheet 오픈
|
- 잠금 카드 클릭 시에만 Paywall Sheet 오픈
|
||||||
- Plan Pill(NORMAL) 클릭은 즉시 결제창 대신 상태 안내만 표시
|
- Plan Pill(NORMAL) 클릭은 즉시 결제창 대신 상태 안내만 표시
|
||||||
@@ -34,11 +214,11 @@ Last Updated: 2026-03-05
|
|||||||
- 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김) 추가
|
- 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김) 추가
|
||||||
- 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영 유지
|
- 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영 유지
|
||||||
- `/space` Scene 기반 자동 추천 적용:
|
- `/space` Scene 기반 자동 추천 적용:
|
||||||
- `RoomTheme`에 `recommendedSoundPresetId`, `recommendedTimerPresetId` 필드 추가
|
- `RoomTheme`에 `recommendedSoundPresetId` 필드 추가
|
||||||
- 첫 진입/시작 시 Scene 추천 타이머/사운드가 자동 반영되도록 초기화 로직 정리
|
- 첫 진입/시작 시 Scene 추천 사운드와 atmosphere 기반 duration이 자동 반영되도록 초기화 로직 정리
|
||||||
- Scene 변경 시 `override.sound/timer`가 `false`인 항목만 자동 동기화
|
- Scene 변경 시 `override.sound/duration`가 `false`인 항목만 자동 동기화
|
||||||
- `/space` 사용자 override 존중 규칙 도입:
|
- `/space` 사용자 override 존중 규칙 도입:
|
||||||
- `override.sound`, `override.timer` UI 상태 추가
|
- `override.sound`, `override.duration` UI 상태 추가
|
||||||
- 사용자가 직접 고른 항목은 이후 Scene 변경에도 자동 덮어쓰기되지 않도록 반영
|
- 사용자가 직접 고른 항목은 이후 Scene 변경에도 자동 덮어쓰기되지 않도록 반영
|
||||||
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원 지원
|
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원 지원
|
||||||
- `Control Center`를 Scene/Time 중심으로 단순화:
|
- `Control Center`를 Scene/Time 중심으로 단순화:
|
||||||
@@ -48,18 +228,18 @@ Last Updated: 2026-03-05
|
|||||||
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
|
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
|
||||||
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
|
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
|
||||||
- 세션 상태 더미 저장/복원 추가:
|
- 세션 상태 더미 저장/복원 추가:
|
||||||
- `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장
|
- `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`를 localStorage에 저장
|
||||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
- 복원 우선순위: 저장 상태 > 사용자 기본 설정 > atmosphere 추천
|
||||||
- `/space` 진입 Resume CTA 추가:
|
- `/space` 진입 Resume CTA 추가:
|
||||||
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
|
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
|
||||||
- `이어서 시작`: 저장 목표로 즉시 Focus 진입
|
- `이어서 시작`: 저장 목표로 즉시 Focus 진입
|
||||||
- `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환
|
- `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환
|
||||||
- 세션 복구 운영 문서 추가:
|
- 세션 복구 운영 문서 추가:
|
||||||
- `docs/06_commit_convention.md`
|
- `docs/foundation/06_commit_convention.md`
|
||||||
- `docs/07_session_recovery.md`
|
- `docs/ops/07_session_recovery.md`
|
||||||
- 워크플로우 토큰 절약 모드 추가:
|
- 워크플로우 토큰 절약 모드 추가:
|
||||||
- `docs/context_core.md` 신설
|
- `docs/context_core.md` 신설
|
||||||
- `docs/workFlow.md`를 기본 3문서 + 조건부 로드로 변경
|
- `docs/ops/workFlow.md`를 기본 3문서 + 조건부 로드로 변경
|
||||||
- 워크플로우 기본 로드를 2파일로 축소:
|
- 워크플로우 기본 로드를 2파일로 축소:
|
||||||
- `docs/work.md`
|
- `docs/work.md`
|
||||||
- `docs/session_brief.md`
|
- `docs/session_brief.md`
|
||||||
@@ -153,12 +333,22 @@ Last Updated: 2026-03-05
|
|||||||
|
|
||||||
## NEXT
|
## NEXT
|
||||||
|
|
||||||
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
|
1. `/app` atmosphere entry shell 구현
|
||||||
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리
|
2. custom duration contract 정리
|
||||||
3. Stage 가독성/모션/레이어 폴리시 최종 통일
|
3. weekly review dock 위치 재설계
|
||||||
|
|
||||||
## RISKS
|
## RISKS
|
||||||
|
|
||||||
|
- Free/Pro 제한은 클라이언트 local tier 기준이므로 서버에서 직접 막지 않는다
|
||||||
|
- Free/Pro gating은 localStorage mock tier 기반이라 실제 구독 상태와 연결되지 않았다
|
||||||
|
- `advance-goal`은 atomic endpoint 기준으로 동작하지만, 네트워크 실패 시 사용자는 현재 시트에서 재시도해야 한다
|
||||||
|
- Session OS 도메인은 mock 기반이므로 실제 저장/복구 API 없이도 화면만 먼저 완성된 상태다
|
||||||
|
- empty state에서 CTA는 살아 있지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다
|
||||||
|
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다
|
||||||
|
- `/admin` 업로드 콘솔은 구조 복구가 끝났지만, 실제 파일 업로드 경로는 브라우저 수동 검증 전까지 확정할 수 없다
|
||||||
|
- stage background overscan으로 좁은 화면에서 배경 crop이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다
|
||||||
|
- remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다
|
||||||
|
- alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다
|
||||||
- `npm run build`는 네트워크 제한 시 Google Font fetch 실패 가능
|
- `npm run build`는 네트워크 제한 시 Google Font fetch 실패 가능
|
||||||
- localStorage 포맷 변경 시 이전 세션 저장값과의 호환성 이슈 가능
|
- localStorage 포맷 변경 시 이전 세션 저장값과의 호환성 이슈 가능
|
||||||
- Scene 추천값과 실제 사용자 선호가 어긋나면 자동 적용 체감 품질이 낮아질 수 있음
|
- Scene 추천값과 실제 사용자 선호가 어긋나면 자동 적용 체감 품질이 낮아질 수 있음
|
||||||
@@ -182,6 +372,41 @@ Last Updated: 2026-03-05
|
|||||||
|
|
||||||
## CHANGED FILES
|
## CHANGED FILES
|
||||||
|
|
||||||
|
- (이번 구조 재점검)
|
||||||
|
- `src/widgets/admin-console/ui/AdminConsoleWidget.tsx`
|
||||||
|
- `src/widgets/admin-console/ui/AdminDashboardView.tsx`
|
||||||
|
- `src/features/admin/api/adminApi.ts`
|
||||||
|
- `src/features/auth/types/index.ts`
|
||||||
|
- `src/entities/auth/index.ts`
|
||||||
|
- `src/entities/auth/model/useAuthStore.ts`
|
||||||
|
- `src/features/auth/hooks/useSocialLogin.ts`
|
||||||
|
- `src/features/auth/components/AuthRedirectButton.tsx`
|
||||||
|
- `docs/foundation/02_arch_fsd_rules.md`
|
||||||
|
- `docs/work.md`
|
||||||
|
- `docs/90_current_state.md`
|
||||||
|
- `docs/session_brief.md`
|
||||||
|
- (이번 세션)
|
||||||
|
- `src/app/(app)/app/page.tsx`
|
||||||
|
- `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`
|
||||||
|
- `src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx`
|
||||||
|
- `src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx`
|
||||||
|
- `src/entities/focus-plan/model/useFocusPlan.ts`
|
||||||
|
- `src/widgets/focus-dashboard/index.ts`
|
||||||
|
- `src/entities/plan/model/usePlanTier.ts`
|
||||||
|
- `src/entities/session/model/focusSystem.ts`
|
||||||
|
- `src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx`
|
||||||
|
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||||
|
- `src/widgets/stats-overview/ui/StatsOverviewWidget.tsx`
|
||||||
|
- `src/shared/i18n/messages/core.ts`
|
||||||
|
- `src/shared/i18n/messages/app.ts`
|
||||||
|
- `src/shared/i18n/messages/product.ts`
|
||||||
|
- `src/shared/i18n/messages/space.ts`
|
||||||
|
- `src/entities/media/model/types.ts`
|
||||||
|
- `src/entities/media/model/resolveMediaAsset.ts`
|
||||||
|
- `src/entities/media/model/useMediaCatalog.ts`
|
||||||
|
- `docs/work.md`
|
||||||
|
- `docs/90_current_state.md`
|
||||||
|
- `docs/session_brief.md`
|
||||||
- (최근 workflow 반영)
|
- (최근 workflow 반영)
|
||||||
- `src/widgets/space-workspace/ui/FocusTopToast.tsx`
|
- `src/widgets/space-workspace/ui/FocusTopToast.tsx`
|
||||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||||
@@ -199,11 +424,11 @@ Last Updated: 2026-03-05
|
|||||||
- `src/entities/room/model/types.ts`
|
- `src/entities/room/model/types.ts`
|
||||||
- `src/entities/room/model/rooms.ts`
|
- `src/entities/room/model/rooms.ts`
|
||||||
- `src/widgets/space-tools-dock/model/applyQuickPack.ts` (삭제)
|
- `src/widgets/space-tools-dock/model/applyQuickPack.ts` (삭제)
|
||||||
- `docs/06_commit_convention.md`
|
- `docs/foundation/06_commit_convention.md`
|
||||||
- `docs/07_session_recovery.md`
|
- `docs/ops/07_session_recovery.md`
|
||||||
- `docs/context_core.md`
|
- `docs/context_core.md`
|
||||||
- `docs/session_brief.md`
|
- `docs/session_brief.md`
|
||||||
- `docs/workFlow.md`
|
- `docs/ops/workFlow.md`
|
||||||
- `docs/README.md`
|
- `docs/README.md`
|
||||||
- `.gitmessage-session.txt`
|
- `.gitmessage-session.txt`
|
||||||
- `scripts/session/recover-context.sh`
|
- `scripts/session/recover-context.sh`
|
||||||
|
|||||||
145
docs/README.md
145
docs/README.md
@@ -1,28 +1,133 @@
|
|||||||
# Docs Index
|
# Docs Index
|
||||||
|
|
||||||
Codex CLI가 중간에 끊겨도 같은 품질로 작업을 이어가기 위한 운영 문서 모음입니다.
|
이 문서는 `web/docs`의 **메인 인덱스**다.
|
||||||
|
문서를 찾을 때는 항상 여기서 시작한다.
|
||||||
|
|
||||||
## 우선 읽기 순서
|
문서 상태는 3가지로 본다.
|
||||||
|
|
||||||
1. [work.md](./work.md)
|
- `source-of-truth`: 현재 기획/구현의 기준 문서
|
||||||
2. [session_brief.md](./session_brief.md)
|
- `ops`: 작업 운영, 핸드오프, 세션 복구용 문서
|
||||||
3. [90_current_state.md](./90_current_state.md)
|
- `legacy`: 과거 맥락을 이해할 때만 보는 참고 문서
|
||||||
4. [context_core.md](./context_core.md)
|
|
||||||
|
|
||||||
## 추가 실무 가이드
|
## 가장 먼저 읽기
|
||||||
|
|
||||||
- [04_coding_rules.md](./04_coding_rules.md)
|
새 세션이나 새 에이전트가 들어오면 이 순서로 읽는다.
|
||||||
- [05_handoff_checklist.md](./05_handoff_checklist.md)
|
|
||||||
- [06_commit_convention.md](./06_commit_convention.md)
|
1. [work.md](./work.md) `source-of-truth`
|
||||||
- [07_session_recovery.md](./07_session_recovery.md)
|
2. [session_brief.md](./session_brief.md) `source-of-truth`
|
||||||
|
3. [90_current_state.md](./90_current_state.md) `source-of-truth`
|
||||||
|
4. [context_core.md](./context_core.md) `ops`
|
||||||
|
5. 이 문서에서 지금 다루는 화면 섹션 확인
|
||||||
|
|
||||||
|
## 제품 전체 기획
|
||||||
|
|
||||||
|
화면 하나가 아니라 제품 전체 방향을 볼 때 읽는다.
|
||||||
|
|
||||||
|
- [00_project_brief.md](./foundation/00_project_brief.md) `source-of-truth`
|
||||||
|
- 프로젝트 목적, 범위, 현재 제품 성격
|
||||||
|
- [12_core_loop_execution_roadmap.md](./product/12_core_loop_execution_roadmap.md) `source-of-truth`
|
||||||
|
- 어떤 순서로 구현할지 정리한 로드맵
|
||||||
|
- [16_product_alignment_audit_plan.md](./product/16_product_alignment_audit_plan.md) `source-of-truth`
|
||||||
|
- 기획-구현 불일치 점검 계획
|
||||||
|
- [17_product_alignment_findings.md](./product/17_product_alignment_findings.md) `source-of-truth`
|
||||||
|
- 실제 불일치 ledger
|
||||||
|
- [08_app_reframe_strategy.md](./screens/app/archive/08_app_reframe_strategy.md) `legacy`
|
||||||
|
- 초반 `/app` 방향 논의용 아이디어 문서
|
||||||
|
|
||||||
|
## 화면별 기획 문서
|
||||||
|
|
||||||
|
### `/app`
|
||||||
|
|
||||||
|
`/app` 진입 경험, paused gate, review entry를 볼 때 읽는다.
|
||||||
|
|
||||||
|
- [screens/app/README.md](./screens/app/README.md) `source-of-truth`
|
||||||
|
- `/app` current/archive 인덱스
|
||||||
|
- [19_app_atmosphere_entry_spec.md](./screens/app/current/19_app_atmosphere_entry_spec.md) `source-of-truth`
|
||||||
|
- `goal + duration + atmosphere` 중심의 새 `/app` 설계
|
||||||
|
|
||||||
|
### `/space`
|
||||||
|
|
||||||
|
`/space` HUD, refocus, break/return, goal card를 볼 때 읽는다.
|
||||||
|
|
||||||
|
- [screens/space/README.md](./screens/space/README.md) `source-of-truth`
|
||||||
|
- `/space` current/archive 인덱스
|
||||||
|
- [13_space_intent_card_collapsed_expanded_spec.md](./screens/space/current/13_space_intent_card_collapsed_expanded_spec.md) `source-of-truth`
|
||||||
|
- 좌상단 목표 카드 구조
|
||||||
|
|
||||||
|
### `/stats`
|
||||||
|
|
||||||
|
review 구조와 BM 연결을 볼 때 읽는다.
|
||||||
|
|
||||||
|
- [screens/stats/README.md](./screens/stats/README.md) `source-of-truth`
|
||||||
|
- `/stats` current/archive 인덱스
|
||||||
|
- [14_weekly_review_reframe_spec.md](./screens/stats/current/14_weekly_review_reframe_spec.md) `source-of-truth`
|
||||||
|
- weekly review를 행동 시스템으로 재정의한 문서
|
||||||
|
|
||||||
|
## 플로우 문서
|
||||||
|
|
||||||
|
두 화면 이상을 가로지르는 문서는 별도로 관리한다.
|
||||||
|
|
||||||
|
- [flows/README.md](./flows/README.md) `source-of-truth`
|
||||||
|
- [10_refocus_system_spec.md](./flows/current/10_refocus_system_spec.md) `source-of-truth`
|
||||||
|
- [11_away_return_recovery_spec.md](./flows/current/11_away_return_recovery_spec.md) `source-of-truth`
|
||||||
|
- [15_app_stats_entry_flow_spec.md](./flows/current/15_app_stats_entry_flow_spec.md) `source-of-truth`
|
||||||
|
- [18_paused_session_reentry_spec.md](./flows/current/18_paused_session_reentry_spec.md) `source-of-truth`
|
||||||
|
|
||||||
|
## 개발 시 참고 문서
|
||||||
|
|
||||||
|
구현 규칙이나 구조를 볼 때 읽는다.
|
||||||
|
|
||||||
|
- [08_premium_uiux_guideline.md](./foundation/08_premium_uiux_guideline.md) `source-of-truth`
|
||||||
|
- 세계 최고급(LifeAt, Portal 수준) UI/UX 톤앤매너, 글래스모피즘, 모션 등 프리미엄 디자인 절대 원칙
|
||||||
|
- 중요: UI 작업 시 AI가 새 view를 임의로 그리지 않고, 사용자가 만든 현재 view를 source of truth로 재사용/확장해야 한다
|
||||||
|
- [01_ui_guidelines.md](./foundation/01_ui_guidelines.md) `source-of-truth`
|
||||||
|
- UI 톤, CTA 위계, premium 품질 기준 (일반 가이드)
|
||||||
|
- [02_arch_fsd_rules.md](./foundation/02_arch_fsd_rules.md) `source-of-truth`
|
||||||
|
- FSD/레이어 구조 규칙
|
||||||
|
- [03_routes_map.md](./foundation/03_routes_map.md) `source-of-truth`
|
||||||
|
- 라우트와 주요 화면 진입점
|
||||||
|
- [04_coding_rules.md](./foundation/04_coding_rules.md) `source-of-truth`
|
||||||
|
- 코드 작성 규칙
|
||||||
|
- [06_commit_convention.md](./foundation/06_commit_convention.md) `source-of-truth`
|
||||||
|
- 커밋 규칙
|
||||||
|
|
||||||
|
## 운영 / 핸드오프 문서
|
||||||
|
|
||||||
|
작업 재개, 세션 복구, handoff용 문서다.
|
||||||
|
|
||||||
|
- [05_handoff_checklist.md](./ops/05_handoff_checklist.md) `ops`
|
||||||
|
- [07_session_recovery.md](./ops/07_session_recovery.md) `ops`
|
||||||
|
- [context_core.md](./context_core.md) `ops`
|
||||||
|
- [workFlow.md](./ops/workFlow.md) `ops`
|
||||||
|
- [work.template.md](./work.template.md) `ops`
|
||||||
|
|
||||||
|
## 실시간 상태 문서
|
||||||
|
|
||||||
|
지금 무엇을 하고 있고, 다음 작업이 무엇인지 볼 때 읽는다.
|
||||||
|
|
||||||
|
- [work.md](./work.md) `source-of-truth`
|
||||||
|
- [session_brief.md](./session_brief.md) `source-of-truth`
|
||||||
|
- [90_current_state.md](./90_current_state.md) `source-of-truth`
|
||||||
|
|
||||||
|
## 빠른 선택 가이드
|
||||||
|
|
||||||
|
- `/app`을 수정한다:
|
||||||
|
- [screens/app/README.md](./screens/app/README.md)
|
||||||
|
- [19_app_atmosphere_entry_spec.md](./screens/app/current/19_app_atmosphere_entry_spec.md)
|
||||||
|
- [18_paused_session_reentry_spec.md](./flows/current/18_paused_session_reentry_spec.md)
|
||||||
|
- [15_app_stats_entry_flow_spec.md](./flows/current/15_app_stats_entry_flow_spec.md)
|
||||||
|
- `/space` recovery를 수정한다:
|
||||||
|
- [screens/space/README.md](./screens/space/README.md)
|
||||||
|
- [10_refocus_system_spec.md](./flows/current/10_refocus_system_spec.md)
|
||||||
|
- [11_away_return_recovery_spec.md](./flows/current/11_away_return_recovery_spec.md)
|
||||||
|
- [13_space_intent_card_collapsed_expanded_spec.md](./screens/space/current/13_space_intent_card_collapsed_expanded_spec.md)
|
||||||
|
- [08_premium_uiux_guideline.md](./foundation/08_premium_uiux_guideline.md)
|
||||||
|
- 현재 사용자가 만든 `/space` view를 그대로 재사용/확장하고, 새 shell을 임의로 만들지 않는다
|
||||||
|
- `/stats`를 수정한다:
|
||||||
|
- [screens/stats/README.md](./screens/stats/README.md)
|
||||||
|
- [14_weekly_review_reframe_spec.md](./screens/stats/current/14_weekly_review_reframe_spec.md)
|
||||||
|
- [15_app_stats_entry_flow_spec.md](./flows/current/15_app_stats_entry_flow_spec.md)
|
||||||
|
- 지금 다음 작업이 뭔지 본다:
|
||||||
- [work.md](./work.md)
|
- [work.md](./work.md)
|
||||||
- [workFlow.md](./workFlow.md)
|
|
||||||
- [session_brief.md](./session_brief.md)
|
- [session_brief.md](./session_brief.md)
|
||||||
|
- [90_current_state.md](./90_current_state.md)
|
||||||
## 운영 원칙
|
|
||||||
|
|
||||||
- 구현 범위는 항상 UI 목업 + 더미 데이터 + 토스트 수준으로 유지한다.
|
|
||||||
- `page.tsx`는 조합만 담당하고 비즈니스 로직은 `features/widgets/entities`로 이동한다.
|
|
||||||
- 작업 종료 시 `90_current_state.md`를 반드시 업데이트한다.
|
|
||||||
- 세션 복구는 `npm run session:recover`로 시작한다.
|
|
||||||
- `workFlow.md` 실행 시 기본은 `work + session_brief` 2파일만 로드한다.
|
|
||||||
|
|||||||
@@ -1,43 +1,56 @@
|
|||||||
# Context Core (Token-Saving)
|
# Context Core
|
||||||
|
|
||||||
세션 시작 시 가장 먼저 읽는 핵심 요약본이다.
|
세션 시작 시 빠르게 읽는 **초단기 핵심 요약본**이다.
|
||||||
|
|
||||||
## 제품/범위
|
전체 문서 구조는 [README.md](./README.md)에서 본다.
|
||||||
|
|
||||||
- VibeRoom Web은 UI 목업 중심 프로젝트다.
|
## 제품 핵심
|
||||||
- 실제 기능(타이머 카운트다운, 오디오 엔진, 서버/DB)은 구현하지 않는다.
|
|
||||||
- 더미 데이터 + 토스트 + 화면 상호작용 중심으로 구현한다.
|
|
||||||
|
|
||||||
## FSD 핵심 규칙
|
- VibeRoom Web은 ADHD와 프리랜서를 위한 premium focus service다.
|
||||||
|
- 핵심 루프는 `commit -> enter -> immerse -> pause/return -> complete -> review`다.
|
||||||
|
- `/app`은 planning dashboard가 아니라 **entry stage**다.
|
||||||
|
- `/space`는 dashboard가 아니라 **execution sanctuary**다.
|
||||||
|
- `/stats`는 summary page가 아니라 **다음 세션 성공률을 높이는 review**다.
|
||||||
|
|
||||||
- `app/page.tsx`는 조합만 담당한다.
|
## 현재 화면 기준
|
||||||
- 비즈니스 로직은 `features` 또는 `entities`로 이동한다.
|
|
||||||
- UI 상태(토글/선택)만 최소 허용한다.
|
### `/app`
|
||||||
- 파일이 500줄 이상이면 분리한다.
|
|
||||||
- import 방향:
|
- current session이 `running`이면 `/space`가 우선이다.
|
||||||
|
- current session이 `paused`이면 `/app` resume gate가 우선이다.
|
||||||
|
- no-session 상태의 source-of-truth는 `goal + duration + atmosphere` entry shell이다.
|
||||||
|
- `microStep`은 `/app`에서 입력하지 않는다.
|
||||||
|
|
||||||
|
### `/space`
|
||||||
|
|
||||||
|
- goal card는 collapsed / expanded rail 구조다.
|
||||||
|
- `pause`, `return`, `next beat`, `goal complete`는 한 번에 하나만 열린다.
|
||||||
|
- active session에서는 closure 경로가 항상 남아 있어야 한다.
|
||||||
|
|
||||||
|
### `/stats`
|
||||||
|
|
||||||
|
- review는 `snapshot / start quality / recovery quality / completion quality / carry forward` 구조다.
|
||||||
|
- primary entry는 `/app`, secondary entry는 `/space` complete 이후 setup이다.
|
||||||
|
|
||||||
|
## 구현 규칙 요약
|
||||||
|
|
||||||
|
- `page.tsx`는 조합만 담당한다.
|
||||||
|
- 레이어 규칙:
|
||||||
- `app -> widgets/features/entities/shared`
|
- `app -> widgets/features/entities/shared`
|
||||||
- `widgets -> features/entities/shared`
|
- `widgets -> features/entities/shared`
|
||||||
- `features -> entities/shared`
|
- `features -> entities/shared`
|
||||||
- `entities -> shared`
|
- `entities -> shared`
|
||||||
- `shared -> shared/external`
|
- UI는 premium ambient focus 톤을 유지한다.
|
||||||
|
- 한 화면에는 primary CTA를 1개만 둔다.
|
||||||
|
|
||||||
## UI 핵심 규칙
|
## 작업 시작 순서
|
||||||
|
|
||||||
- 톤: 감성/저자극, 과한 대비/강조 금지
|
1. [work.md](./work.md)
|
||||||
- CTA 위계: Primary 1개 중심, Secondary/Tertiary는 무게 낮게
|
2. [session_brief.md](./session_brief.md)
|
||||||
- 모바일은 접근성 우선, 데스크톱은 과한 풀폭 버튼 지양
|
3. [90_current_state.md](./90_current_state.md)
|
||||||
|
4. 지금 건드릴 화면의 source-of-truth spec
|
||||||
|
|
||||||
## 커밋 규칙 (요약)
|
## 주의
|
||||||
|
|
||||||
- 주제별 1커밋
|
- 오래된 `/app` single-goal gate 문서는 legacy다.
|
||||||
- 한국어 Conventional Commit
|
- 문서가 충돌하면 [README.md](./README.md)의 `source-of-truth` 분류가 우선이다.
|
||||||
- 본문 형식:
|
|
||||||
- `맥락`
|
|
||||||
- `변경사항`
|
|
||||||
- `검증`
|
|
||||||
- `세션-상태 / 세션-다음 / 세션-리스크`
|
|
||||||
|
|
||||||
## 세션 복구 규칙
|
|
||||||
|
|
||||||
- 시작: `npm run session:recover`
|
|
||||||
- 상태판: `docs/90_current_state.md` 기준으로 다음 작업 결정
|
|
||||||
|
|||||||
20
docs/flows/README.md
Normal file
20
docs/flows/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Flow Docs
|
||||||
|
|
||||||
|
여기는 특정 화면 하나에 속하지 않고, 두 화면 이상을 가로지르는 플로우 문서를 둔다.
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- `/app -> /stats -> /app`
|
||||||
|
- paused session re-entry
|
||||||
|
- refocus / away-return recovery
|
||||||
|
|
||||||
|
## current
|
||||||
|
|
||||||
|
- [10_refocus_system_spec.md](./current/10_refocus_system_spec.md)
|
||||||
|
- [11_away_return_recovery_spec.md](./current/11_away_return_recovery_spec.md)
|
||||||
|
- [15_app_stats_entry_flow_spec.md](./current/15_app_stats_entry_flow_spec.md)
|
||||||
|
- [18_paused_session_reentry_spec.md](./current/18_paused_session_reentry_spec.md)
|
||||||
|
|
||||||
|
## archive
|
||||||
|
|
||||||
|
- 현재 없음
|
||||||
487
docs/flows/current/10_refocus_system_spec.md
Normal file
487
docs/flows/current/10_refocus_system_spec.md
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
# 10. Refocus System Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-15
|
||||||
|
|
||||||
|
이 문서는 VibeRoom의 `Refocus System`을 제품 대표 경험으로 설계하기 위한 상세 기준 문서다.
|
||||||
|
|
||||||
|
관련 상위 문서:
|
||||||
|
|
||||||
|
- `../../product_principles.md`
|
||||||
|
- `../../current_context.md`
|
||||||
|
- `../app/19_app_atmosphere_entry_spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 Refocus가 핵심인가
|
||||||
|
|
||||||
|
VibeRoom의 차별점은 `더 오래 계획하게 만드는 것`이 아니라,
|
||||||
|
`흔들린 뒤에도 다시 집중 위에 올라타게 만드는 것`이어야 한다.
|
||||||
|
|
||||||
|
ADHD 성향 사용자와 프리랜서에게 진짜 어려운 순간은 대개 아래 셋 중 하나다.
|
||||||
|
|
||||||
|
- 시작 직전
|
||||||
|
- 잠깐 멈췄다가 다시 붙잡아야 할 때
|
||||||
|
- 한 조각을 끝냈는데 다음 동작이 흐려졌을 때
|
||||||
|
|
||||||
|
`/app`이 시작 마찰을 줄이는 화면이라면,
|
||||||
|
`Refocus System`은 **세션 도중 무너진 흐름을 다시 복구하는 핵심 시스템**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 레퍼런스에서 가져올 것 / 버릴 것
|
||||||
|
|
||||||
|
이 문서는 2026-03-14 기준 공식 사이트를 기준으로 아래 레퍼런스를 참고했다.
|
||||||
|
|
||||||
|
- [Portal](https://portal.app/)
|
||||||
|
- [LifeAt Pricing](https://lifeat.io/pricing)
|
||||||
|
- [Focusmate Pricing](https://www.focusmate.com/pricing/)
|
||||||
|
- [Focusmate Getting Started](https://support.focusmate.com/en/articles/9110188-getting-started)
|
||||||
|
- [Focusmate Focus Now](https://support.focusmate.com/en/articles/9994509-focus-now)
|
||||||
|
|
||||||
|
### Portal에서 가져올 것
|
||||||
|
|
||||||
|
- 배경이 주인공이고 인터페이스는 조용히 뒤로 물러나야 한다
|
||||||
|
- 감각 품질 자체가 제품 가치가 된다
|
||||||
|
- “아름답다”가 장식이 아니라 사용 지속 이유가 된다
|
||||||
|
|
||||||
|
### Focusmate에서 가져올 것
|
||||||
|
|
||||||
|
- 행동을 시작시키는 명확한 구조
|
||||||
|
- 사용자가 망설이지 않게 하는 단일 흐름
|
||||||
|
- `지금 바로 들어간다`는 즉시성
|
||||||
|
|
||||||
|
### LifeAt에서 가져올 것
|
||||||
|
|
||||||
|
- 가볍게 시작할 수 있는 친화성
|
||||||
|
- 공간과 집중을 연결하는 감성
|
||||||
|
|
||||||
|
### LifeAt에서 가져오면 안 되는 것
|
||||||
|
|
||||||
|
- planner / todo / calendar 중심 구조
|
||||||
|
- 목표를 많이 다루게 하는 흐름
|
||||||
|
- “집중 앱”보다 “정리 앱”처럼 읽히는 경험
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 한 줄 정의
|
||||||
|
|
||||||
|
> Refocus는 사용자가 흔들렸을 때 죄책감 없이 다시 한 조각 위에 올라타게 만드는 조용한 복귀 의식이다.
|
||||||
|
|
||||||
|
중요한 점:
|
||||||
|
|
||||||
|
- `수정 기능`이 아니다
|
||||||
|
- `체크리스트`가 아니다
|
||||||
|
- `왜 못 했는지 묻는 평가 시스템`이 아니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 제품 목표
|
||||||
|
|
||||||
|
Refocus System은 아래 4가지를 만족해야 한다.
|
||||||
|
|
||||||
|
1. 사용자가 세션을 포기하지 않게 한다
|
||||||
|
2. pause 이후 복귀 마찰을 줄인다
|
||||||
|
3. microStep을 다시 잡아 실행을 재개하게 한다
|
||||||
|
4. UI가 배경과 몰입을 방해하지 않는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 시스템 원칙
|
||||||
|
|
||||||
|
### 1. Refocus는 한 번에 한 질문만 한다
|
||||||
|
|
||||||
|
질문을 여러 개 던지면 planner처럼 느껴진다.
|
||||||
|
항상 다음 하나만 물어야 한다.
|
||||||
|
|
||||||
|
- 계속할까?
|
||||||
|
- 지금 할 한 조각은 무엇일까?
|
||||||
|
- 이 목표를 끝낼까?
|
||||||
|
|
||||||
|
### 2. 사용자를 평가하지 않는다
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- 왜 못 했어요?
|
||||||
|
- 얼마나 산만했나요?
|
||||||
|
- 다시 집중하세요
|
||||||
|
|
||||||
|
허용:
|
||||||
|
|
||||||
|
- 다음 한 조각이 있나요?
|
||||||
|
- 지금 다시 시작할 수 있게 한 줄만 남겨볼까요?
|
||||||
|
- 여기까지로 충분한가요?
|
||||||
|
|
||||||
|
### 3. goal은 1개, microStep도 1개
|
||||||
|
|
||||||
|
Refocus는 절대 리스트 관리 UI가 되면 안 된다.
|
||||||
|
|
||||||
|
- multi-step 금지
|
||||||
|
- queue 금지
|
||||||
|
- subtask list 금지
|
||||||
|
|
||||||
|
### 4. 배경은 항상 주인공이다
|
||||||
|
|
||||||
|
Refocus UI는 강한 모달이 아니라 **배경 위에 잠깐 생겼다가 사라지는 얇은 recovery layer**여야 한다.
|
||||||
|
|
||||||
|
### 5. Premium은 절제에서 온다
|
||||||
|
|
||||||
|
좋은 Refocus는 화려한 카드나 모션이 아니라 아래에서 온다.
|
||||||
|
|
||||||
|
- 올바른 정보 밀도
|
||||||
|
- 1개의 명확한 행동
|
||||||
|
- 자연스러운 등장과 퇴장
|
||||||
|
- 배경과 어우러지는 재질감
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Refocus가 필요한 순간들
|
||||||
|
|
||||||
|
Refocus는 아래 4개 진입점으로 고정한다.
|
||||||
|
|
||||||
|
### A. Pause 직후
|
||||||
|
|
||||||
|
사용자가 세션을 멈춘 뒤 다시 돌아와야 하는 순간.
|
||||||
|
|
||||||
|
### B. MicroStep 완료 직후
|
||||||
|
|
||||||
|
현재 한 조각은 끝났지만 다음 행동이 아직 흐린 순간.
|
||||||
|
|
||||||
|
### C. 사용자가 의도를 직접 수정하고 싶을 때
|
||||||
|
|
||||||
|
goal이나 microStep을 다시 정리하고 싶은 수동 진입.
|
||||||
|
|
||||||
|
### D. 세션에서 멀어졌다가 복귀했을 때
|
||||||
|
|
||||||
|
탭을 바꾸거나 잠깐 이탈한 후 다시 `/space`로 돌아온 상황.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 화면 상태 모델
|
||||||
|
|
||||||
|
Refocus는 아래 5개 상태만 가진다.
|
||||||
|
|
||||||
|
1. `Focused`
|
||||||
|
2. `Paused`
|
||||||
|
3. `Refocus`
|
||||||
|
4. `Next Beat`
|
||||||
|
5. `Complete`
|
||||||
|
|
||||||
|
한 번에 활성화되는 확장 상태는 하나만 허용한다.
|
||||||
|
|
||||||
|
- `Refocus`
|
||||||
|
- `Next Beat`
|
||||||
|
- `Complete`
|
||||||
|
|
||||||
|
이 셋은 동시에 보이면 안 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 상태별 상세 UX
|
||||||
|
|
||||||
|
### CTA Matrix
|
||||||
|
|
||||||
|
Refocus family에서 중요한 원칙은 하나다.
|
||||||
|
|
||||||
|
> 세션이 아직 살아 있는 상태라면, 사용자는 언제든 `계속 / 다시 잡기 / 마무리` 중 하나로 갈 수 있어야 한다.
|
||||||
|
|
||||||
|
상태별 CTA는 아래처럼 고정한다.
|
||||||
|
|
||||||
|
| 상태 | 보여야 하는 주 행동 | goal complete 접근 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Focused` | `수정`, `microStep 완료`, `이번 목표 완료`, timer `pause/reset` | base intent card에서 직접 노출 |
|
||||||
|
| `Paused` | `한 조각 다시 잡기`, `바로 이어가기` | pause tray의 low-emphasis `여기서 마무리하기` |
|
||||||
|
| `Return (focus)` | `멈춘 자리에서 이어가기`, `한 조각 다시 잡기` | return tray의 low-emphasis `여기서 마무리하기` |
|
||||||
|
| `Break` | break 유지, 다음 블록으로 이어가기, goal closure | base intent card에서 직접 노출 |
|
||||||
|
| `Return (break)` | `쉬기 이어가기`, `다음 블록 이어가기`, `한 조각 다시 잡기` | return tray의 low-emphasis `여기서 마무리하기` |
|
||||||
|
| `Next Beat` | `목표만 두고 계속하기`, `다음 단계 적기` | next-beat tray의 low-emphasis `이 목표는 여기서 마무리하기` |
|
||||||
|
|
||||||
|
`Refocus` 편집 시트 자체는 편집에만 집중하고, complete 액션은 별도 decision tray나 base intent card에서 수행한다.
|
||||||
|
|
||||||
|
### 8.1 Focused
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- 사용자가 UI를 거의 의식하지 않고 일하는 상태
|
||||||
|
|
||||||
|
보여야 하는 것:
|
||||||
|
|
||||||
|
- goal
|
||||||
|
- optional microStep
|
||||||
|
- timer HUD
|
||||||
|
- minimal controls
|
||||||
|
|
||||||
|
보이면 안 되는 것:
|
||||||
|
|
||||||
|
- 질문
|
||||||
|
- 복구 유도 문구
|
||||||
|
- task-like affordance
|
||||||
|
|
||||||
|
### 8.2 Paused
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- 세션이 끊긴 느낌이 아니라 잠시 호흡을 고르는 느낌
|
||||||
|
|
||||||
|
행동:
|
||||||
|
|
||||||
|
- 바로 큰 sheet를 띄우지 않는다
|
||||||
|
- HUD 상태가 paused로 바뀌고,
|
||||||
|
- `다시 시작할 준비가 되면 한 조각만 다시 잡는다`는 감각을 준다
|
||||||
|
|
||||||
|
### 8.3 Refocus
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- 목표를 버리지 않고 다시 시작점을 만든다
|
||||||
|
|
||||||
|
UI:
|
||||||
|
|
||||||
|
- anchored tray 또는 compact sheet
|
||||||
|
- 필드 2개
|
||||||
|
- 이번 세션 목표
|
||||||
|
- 지금 할 한 조각
|
||||||
|
- CTA 2개
|
||||||
|
- 적용
|
||||||
|
- 취소
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- `다시 방향 잡기`는 메인 액션이 아니라 recovery action이어야 한다
|
||||||
|
- button cluster처럼 보이면 안 된다
|
||||||
|
|
||||||
|
### 8.4 Next Beat
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- microStep 완료 후 checklist가 아니라 “다음 한 조각”으로 연결
|
||||||
|
|
||||||
|
질문:
|
||||||
|
|
||||||
|
> 다음 한 조각이 있나요?
|
||||||
|
|
||||||
|
행동:
|
||||||
|
|
||||||
|
- `한 조각 정하기`
|
||||||
|
- `없이 계속`
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- 다음 step list 펼치기
|
||||||
|
- 여러 개 제안
|
||||||
|
- 정리형 UI
|
||||||
|
|
||||||
|
### 8.5 Complete
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- 목표가 끝났을 때 닫음과 다음 시작을 모두 부드럽게 연결
|
||||||
|
|
||||||
|
질문:
|
||||||
|
|
||||||
|
- 여기까지 끝났나요?
|
||||||
|
|
||||||
|
행동:
|
||||||
|
|
||||||
|
- 여기서 마무리하기
|
||||||
|
- 다음 블록 이어가기
|
||||||
|
- 잠시 비우기
|
||||||
|
|
||||||
|
완료는 celebration보다 **closure quality**가 중요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 상세 플로우
|
||||||
|
|
||||||
|
### Flow A. Pause -> Refocus -> Resume
|
||||||
|
|
||||||
|
1. 사용자가 pause 한다
|
||||||
|
2. UI는 즉시 평가하지 않는다
|
||||||
|
3. 사용자가 resume을 누르거나 intent를 누르면 Refocus로 들어갈 수 있다
|
||||||
|
4. 사용자는 goal / microStep 중 필요한 것만 조정한다
|
||||||
|
5. `적용 후 이어가기` 또는 `적용` 뒤 resume
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- pause 이후 바로 복귀하는 것이 아니라,
|
||||||
|
- 한 번 숨을 고르고 다시 붙잡게 만든다
|
||||||
|
|
||||||
|
### Flow B. MicroStep Complete -> Next Beat
|
||||||
|
|
||||||
|
1. 사용자가 microStep completion mark를 누른다
|
||||||
|
2. 기존 microStep은 조용히 처리된다
|
||||||
|
3. `다음 한 조각이 있나요?`가 나타난다
|
||||||
|
4. 사용자는
|
||||||
|
- 새 microStep을 적거나
|
||||||
|
- 없이 계속 간다
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- planner처럼 다음 목록을 만드는 것이 아니라
|
||||||
|
- 지금 하나만 다시 붙잡게 한다
|
||||||
|
|
||||||
|
### Flow C. Manual Refocus
|
||||||
|
|
||||||
|
1. 사용자가 goal 영역을 눌러 의도 수정 진입
|
||||||
|
2. goal / microStep 수정
|
||||||
|
3. 적용
|
||||||
|
4. 조용히 복귀
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- 세션을 깨지 않는 범위에서 intent만 조정
|
||||||
|
|
||||||
|
### Flow D. Goal Complete -> Next Goal or Close
|
||||||
|
|
||||||
|
1. 사용자가 goal complete 진입
|
||||||
|
2. 현재 목표를 닫을지, 다음 목표로 이어갈지 선택
|
||||||
|
3. 다음 목표는 여전히 1개만 다룬다
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- checklist가 아니라 clean transition
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. UI 구조
|
||||||
|
|
||||||
|
### 레이어 구조
|
||||||
|
|
||||||
|
#### Layer 1. Background
|
||||||
|
|
||||||
|
- 배경 이미지/영상
|
||||||
|
- 주인공
|
||||||
|
|
||||||
|
#### Layer 2. Core HUD
|
||||||
|
|
||||||
|
- timer
|
||||||
|
- sound / scene controls
|
||||||
|
- intent card
|
||||||
|
|
||||||
|
#### Layer 3. Recovery Layer
|
||||||
|
|
||||||
|
- refocus tray
|
||||||
|
- next beat prompt
|
||||||
|
- complete tray
|
||||||
|
|
||||||
|
Recovery Layer는 Core HUD와 같은 material family를 가져야 한다.
|
||||||
|
다만 더 조용하고 얇아야 한다.
|
||||||
|
|
||||||
|
### Material 방향
|
||||||
|
|
||||||
|
- iOS 계열의 refined glass 참고
|
||||||
|
- 강한 탁도보다 `투명 + blur + 얕은 경계`를 우선
|
||||||
|
- glow, heavy shadow, thick border 금지
|
||||||
|
- chip 남발 금지
|
||||||
|
|
||||||
|
### 모션 방향
|
||||||
|
|
||||||
|
- 빠르게 튀어나오지 않는다
|
||||||
|
- appear 220~280ms
|
||||||
|
- disappear 180~220ms
|
||||||
|
- scale보다는 opacity + position shift 위주
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 카피라이팅 방향
|
||||||
|
|
||||||
|
### tone
|
||||||
|
|
||||||
|
- 짧다
|
||||||
|
- 저압력이다
|
||||||
|
- 평가하지 않는다
|
||||||
|
- 다시 시작할 수 있게 한다
|
||||||
|
|
||||||
|
### 좋은 예
|
||||||
|
|
||||||
|
- `지금 할 한 조각`
|
||||||
|
- `다음 한 조각이 있나요?`
|
||||||
|
- `한 줄만 다듬고 다시 시작해요.`
|
||||||
|
- `여기까지로 충분한가요?`
|
||||||
|
|
||||||
|
### 피해야 할 예
|
||||||
|
|
||||||
|
- `할 일`
|
||||||
|
- `리스트`
|
||||||
|
- `관리`
|
||||||
|
- `다음 단계들`
|
||||||
|
- `왜 못 했나요?`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Free / Pro에서의 역할
|
||||||
|
|
||||||
|
### Free
|
||||||
|
|
||||||
|
- 기본 refocus 진입
|
||||||
|
- goal / microStep 재설정
|
||||||
|
- next beat prompt
|
||||||
|
- goal complete 기본 흐름
|
||||||
|
|
||||||
|
### Pro
|
||||||
|
|
||||||
|
- 더 정교한 refocus guidance
|
||||||
|
- 세션 패턴 기반 microStep 제안
|
||||||
|
- 어떤 ritual에서 복귀율이 높은지 review에 반영
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
Pro는 `더 많은 목표`가 아니라 `더 높은 복귀 성공률`을 파는 쪽으로 가야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 성공 지표
|
||||||
|
|
||||||
|
Refocus System은 아래 지표로 판단한다.
|
||||||
|
|
||||||
|
- Pause 후 Resume 비율
|
||||||
|
- Pause 후 Refocus 진입 비율
|
||||||
|
- Refocus 후 2분 이상 유지 비율
|
||||||
|
- MicroStep 완료 후 Next Beat 입력 비율
|
||||||
|
- Goal complete 후 다음 세션 전환 비율
|
||||||
|
- 세션 중 abandon 비율 감소
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 구현 우선순위
|
||||||
|
|
||||||
|
### Slice 1
|
||||||
|
|
||||||
|
- pause 이후 Refocus 진입 구조 정리
|
||||||
|
- goal / microStep 수정 tray 정교화
|
||||||
|
- 한 번에 하나의 overlay만 뜨도록 상태 정리
|
||||||
|
|
||||||
|
### Slice 2
|
||||||
|
|
||||||
|
- microStep 완료 -> Next Beat 흐름 정리
|
||||||
|
- checklist 느낌 제거
|
||||||
|
|
||||||
|
### Slice 3
|
||||||
|
|
||||||
|
- goal complete tray의 완성도 향상
|
||||||
|
- closure / next goal / break 분기 정리
|
||||||
|
|
||||||
|
### Slice 4
|
||||||
|
|
||||||
|
- Pro용 adaptive refocus 기획
|
||||||
|
- review와 refocus 연결
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 절대 피해야 할 방향
|
||||||
|
|
||||||
|
- pause를 실패처럼 느끼게 하는 UX
|
||||||
|
- checklist UI
|
||||||
|
- multi-step planner화
|
||||||
|
- 과한 그래프 / 통계 immediate 노출
|
||||||
|
- 배경보다 더 앞에 나오는 recovery UI
|
||||||
|
- action chip 남발
|
||||||
|
- 한 번에 여러 질문을 던지는 sheet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 이 문서를 기준으로 다음 구현에서 꼭 지켜야 할 것
|
||||||
|
|
||||||
|
- Refocus는 `편집 기능`이 아니라 `recovery ritual`처럼 느껴져야 한다
|
||||||
|
- 사용자는 한 번에 하나의 행동만 선택해야 한다
|
||||||
|
- 배경은 절대 희생시키지 않는다
|
||||||
|
- premium quality는 더 많은 glass가 아니라 더 적은 friction에서 나온다
|
||||||
441
docs/flows/current/11_away_return_recovery_spec.md
Normal file
441
docs/flows/current/11_away_return_recovery_spec.md
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
# 11. Away / Return Recovery Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-14
|
||||||
|
|
||||||
|
이 문서는 사용자가 `pause`를 누르지 않고 그냥 자리를 떠난 경우를 VibeRoom이 어떻게 감지하고, 어떻게 맞이하고, 어떻게 다시 복귀시킬지를 정의하는 상세 기준 문서다.
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `../../product_principles.md`
|
||||||
|
- `../../current_context.md`
|
||||||
|
- `./10_refocus_system_spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 이 기획이 중요한가
|
||||||
|
|
||||||
|
ADHD 성향 사용자와 프리랜서는 자주 아래처럼 이탈한다.
|
||||||
|
|
||||||
|
- `pause`를 누를 생각도 못 한 채 자리에서 일어남
|
||||||
|
- 잠깐 딴 탭을 보다가 세션 흐름을 잃음
|
||||||
|
- focus가 끝났는지도 모른 채 돌아옴
|
||||||
|
|
||||||
|
이 상황을 제품이 다루지 않으면 아래 문제가 생긴다.
|
||||||
|
|
||||||
|
- Refocus가 “사용자가 pause를 눌렀을 때만 작동하는 기능”으로 축소된다
|
||||||
|
- 세션이 실제 흐름보다 더 기계적으로 느껴진다
|
||||||
|
- `pause`와 `break`가 같은 “멈춘 상태”처럼 읽힌다
|
||||||
|
- 돌아온 사용자를 잘못된 상태로 맞이하게 된다
|
||||||
|
|
||||||
|
VibeRoom은 감시 앱이 되어서는 안 되지만,
|
||||||
|
**돌아왔을 때의 복귀 품질**은 반드시 설계해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
|
> Away / Return은 사용자의 무의식적 이탈을 실패로 다루지 않고, 돌아왔을 때 가장 자연스럽게 다시 몰입 위에 올려놓는 복귀 레이어다.
|
||||||
|
|
||||||
|
중요한 점:
|
||||||
|
|
||||||
|
- 사용자를 통제하거나 막는 기능이 아니다
|
||||||
|
- “왜 떠났는가”를 추궁하지 않는다
|
||||||
|
- 핵심은 detection보다 **return UX**다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 이 시스템이 해결해야 하는 문제
|
||||||
|
|
||||||
|
### 문제 A. 사용자는 `pause`를 누르지 않는다
|
||||||
|
|
||||||
|
사용자는 실제로는 쉬고 싶어서 일어난 것이어도,
|
||||||
|
제품 안에서는 아무 액션도 하지 않은 채 세션이 흘러간다.
|
||||||
|
|
||||||
|
### 문제 B. focus가 끝났는데 break처럼 보인다
|
||||||
|
|
||||||
|
사용자가 없던 사이 focus가 끝났다면, 돌아왔을 때 단순 `Break`로 맞이하면 안 된다.
|
||||||
|
그건 제품이 사용자의 실제 맥락을 오해한 것이다.
|
||||||
|
|
||||||
|
### 문제 C. pause와 break가 겹쳐 보인다
|
||||||
|
|
||||||
|
현재 감정적으로는 이렇게 읽히기 쉽다.
|
||||||
|
|
||||||
|
- `Pause`: 내가 멈춤
|
||||||
|
- `Break`: focus가 끝나서 쉬는 중
|
||||||
|
|
||||||
|
둘은 의미가 다른데, UI와 상태가 충분히 분리되지 않으면 같은 “멈춤”처럼 느껴진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 핵심 원칙
|
||||||
|
|
||||||
|
### 1. 감시는 하지 않는다
|
||||||
|
|
||||||
|
아래는 금지한다.
|
||||||
|
|
||||||
|
- webcam / face tracking
|
||||||
|
- 키보드/마우스 무입력만으로 강제 이탈 판정
|
||||||
|
- 사용자를 혼내는 알림
|
||||||
|
- “집중하지 않았네요” 류의 카피
|
||||||
|
|
||||||
|
### 2. 확실한 신호만 사용한다
|
||||||
|
|
||||||
|
웹에서 “자리 비움”은 정확히 알 수 없다.
|
||||||
|
따라서 v1은 아래처럼 비교적 강한 신호만 쓴다.
|
||||||
|
|
||||||
|
- `visibilitychange`
|
||||||
|
- `pagehide`
|
||||||
|
- 브라우저/기기 sleep 이후 큰 시간 점프
|
||||||
|
- 창 복귀 시점
|
||||||
|
|
||||||
|
### 3. detection보다 return이 중요하다
|
||||||
|
|
||||||
|
중요한 것은 “네가 떠났다는 걸 알아냈다”가 아니라,
|
||||||
|
**“돌아온 지금 무엇을 제안할 것인가”**다.
|
||||||
|
|
||||||
|
### 4. Pause와 Break는 명확히 다르게 느껴져야 한다
|
||||||
|
|
||||||
|
- `Pause`는 recovery tone
|
||||||
|
- `Break`는 release / reset tone
|
||||||
|
- `Return`은 re-entry tone
|
||||||
|
|
||||||
|
### 5. focus ended while away는 Break가 아니라 Return이다
|
||||||
|
|
||||||
|
사용자가 없는 동안 focus가 끝났다면,
|
||||||
|
돌아온 순간의 경험은 `쉬는 중`이 아니라 `복귀 결정`이어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 상태 모델
|
||||||
|
|
||||||
|
VibeRoom의 세션 상태를 제품적으로는 아래처럼 다룬다.
|
||||||
|
|
||||||
|
### Core States
|
||||||
|
|
||||||
|
- `Focus`
|
||||||
|
- `Pause`
|
||||||
|
- `Break`
|
||||||
|
|
||||||
|
### Recovery States
|
||||||
|
|
||||||
|
- `AwayCandidate`
|
||||||
|
- `Return`
|
||||||
|
|
||||||
|
### 의미
|
||||||
|
|
||||||
|
#### Focus
|
||||||
|
|
||||||
|
사용자가 현재 세션 안에서 일하고 있음
|
||||||
|
|
||||||
|
#### Pause
|
||||||
|
|
||||||
|
사용자가 의도적으로 멈춤
|
||||||
|
|
||||||
|
#### Break
|
||||||
|
|
||||||
|
focus 블록을 끝내고 의도적으로 쉬는 상태
|
||||||
|
|
||||||
|
#### AwayCandidate
|
||||||
|
|
||||||
|
사용자가 실제로 자리를 떴을 가능성이 높은 내부 판단 상태
|
||||||
|
|
||||||
|
#### Return
|
||||||
|
|
||||||
|
이탈 후 다시 돌아온 순간, 제품이 복귀를 제안하는 상태
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 웹에서의 감지 전략
|
||||||
|
|
||||||
|
### v1에서 사용하는 신호
|
||||||
|
|
||||||
|
#### 1. visibility hidden
|
||||||
|
|
||||||
|
- 사용자가 탭을 떠남
|
||||||
|
- 브라우저가 background로 감
|
||||||
|
|
||||||
|
#### 2. pagehide / tab background
|
||||||
|
|
||||||
|
- 페이지가 전환되거나 숨겨짐
|
||||||
|
|
||||||
|
#### 3. sleep / wake 시간 점프
|
||||||
|
|
||||||
|
- 사용자가 기기를 잠그거나 화면이 꺼졌다가 돌아왔을 가능성
|
||||||
|
- `Date.now()` 기준 큰 delta로 감지
|
||||||
|
|
||||||
|
### v1에서 사용하지 않는 신호
|
||||||
|
|
||||||
|
#### blur / focus 단독
|
||||||
|
|
||||||
|
너무 오탐이 많다.
|
||||||
|
|
||||||
|
#### 무입력 시간만으로 Away 판단
|
||||||
|
|
||||||
|
읽고 생각하는 사용자를 잘못 판정할 수 있다.
|
||||||
|
|
||||||
|
#### pointer / keyboard tracking 강제화
|
||||||
|
|
||||||
|
감시처럼 느껴질 수 있어 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 판단 규칙
|
||||||
|
|
||||||
|
### Rule 1. focus 중 hidden/wake gap 발생
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- `Focus` 중
|
||||||
|
- `visibility hidden` 또는 `sleep/wake delta` 발생
|
||||||
|
|
||||||
|
처리:
|
||||||
|
|
||||||
|
- 내부적으로 `AwayCandidate`
|
||||||
|
|
||||||
|
### Rule 2. 돌아왔을 때 focus가 아직 안 끝남
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 사용자가 복귀
|
||||||
|
- 남은 focus 시간이 남아 있음
|
||||||
|
|
||||||
|
처리:
|
||||||
|
|
||||||
|
- `Return` tray 노출
|
||||||
|
- 질문:
|
||||||
|
- `이어서 할까요?`
|
||||||
|
- `한 조각 다시 잡을까요?`
|
||||||
|
|
||||||
|
### Rule 3. 돌아왔을 때 focus가 이미 끝남
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 사용자가 복귀
|
||||||
|
- focus phase는 끝났음
|
||||||
|
- 하지만 사용자는 그 종료를 경험하지 못했음
|
||||||
|
|
||||||
|
처리:
|
||||||
|
|
||||||
|
- `Break` 직접 진입 금지
|
||||||
|
- `Return` tray 노출
|
||||||
|
- 질문:
|
||||||
|
- `자리를 비운 사이 이 블록이 끝났어요. 지금 어떻게 이어갈까요?`
|
||||||
|
|
||||||
|
행동:
|
||||||
|
|
||||||
|
- `지금부터 쉬기`
|
||||||
|
- `다음 목표로 이어가기`
|
||||||
|
- `한 조각 다시 잡기`
|
||||||
|
|
||||||
|
### Rule 4. pause 상태에서 복귀
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 이미 사용자가 직접 `Pause`한 상태
|
||||||
|
|
||||||
|
처리:
|
||||||
|
|
||||||
|
- 이건 Away보다 Pause 흐름이 우선
|
||||||
|
- 기존 pause recovery prompt 유지
|
||||||
|
|
||||||
|
즉, `Away / Return`은 manual pause를 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Return UX 설계
|
||||||
|
|
||||||
|
### Return의 목적
|
||||||
|
|
||||||
|
- 죄책감 없이 다시 올려놓기
|
||||||
|
- `지금 어떤 상태인지`를 간단히 알려주기
|
||||||
|
- 선택지를 2~3개만 제시하기
|
||||||
|
|
||||||
|
### Return의 기본 구조
|
||||||
|
|
||||||
|
- eyebrow: `다시 돌아왔어요`
|
||||||
|
- 짧은 현재 맥락 설명
|
||||||
|
- primary action 1개
|
||||||
|
- secondary action 1개
|
||||||
|
- tertiary는 최대 1개
|
||||||
|
|
||||||
|
### Case A. focus still running
|
||||||
|
|
||||||
|
카피 예:
|
||||||
|
|
||||||
|
- 제목: `이어서 갈까요?`
|
||||||
|
- 설명: `흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.`
|
||||||
|
|
||||||
|
행동:
|
||||||
|
|
||||||
|
- primary: `이어서 하기`
|
||||||
|
- secondary: `한 조각 다시 잡기`
|
||||||
|
|
||||||
|
### Case B. focus ended while away
|
||||||
|
|
||||||
|
카피 예:
|
||||||
|
|
||||||
|
- 제목: `자리를 비운 사이 이 블록이 끝났어요.`
|
||||||
|
- 설명: `지금부터 쉬거나, 다음으로 이어갈 수 있어요.`
|
||||||
|
|
||||||
|
행동:
|
||||||
|
|
||||||
|
- primary: `지금부터 쉬기`
|
||||||
|
- secondary: `다음 목표 이어가기`
|
||||||
|
- tertiary: `한 조각 다시 잡기`
|
||||||
|
|
||||||
|
### Case C. break ended while away
|
||||||
|
|
||||||
|
이건 v1에서는 단순화한다.
|
||||||
|
|
||||||
|
- break가 끝났고 사용자가 돌아왔다면
|
||||||
|
- `다음 블록으로 이어갈까요?` 정도의 단순 return prompt만 둔다
|
||||||
|
- 이 상태는 후속 slice에서 다룬다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Pause / Break / Return의 차이
|
||||||
|
|
||||||
|
### Pause
|
||||||
|
|
||||||
|
- 사용자가 직접 멈춘 상태
|
||||||
|
- 톤: recovery
|
||||||
|
- 질문:
|
||||||
|
- `한 조각 다시 잡기`
|
||||||
|
- `이대로 이어가기`
|
||||||
|
|
||||||
|
### Break
|
||||||
|
|
||||||
|
- 사용자가 focus 블록을 끝낸 뒤 쉬는 상태
|
||||||
|
- 톤: release / reset
|
||||||
|
- 질문:
|
||||||
|
- `쉬기 계속`
|
||||||
|
- `다음으로 가기`
|
||||||
|
|
||||||
|
### Return
|
||||||
|
|
||||||
|
- 사용자가 떠났다가 돌아온 상태
|
||||||
|
- 톤: re-entry
|
||||||
|
- 질문:
|
||||||
|
- `지금 어디서 다시 시작할까?`
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
|
||||||
|
`Pause`와 `Return`은 비슷하지만 다르다.
|
||||||
|
|
||||||
|
- Pause는 사용자가 멈춘 것
|
||||||
|
- Return은 제품이 사용자의 이탈을 감지하고 다시 맞이하는 것
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. UI / Layer 구조
|
||||||
|
|
||||||
|
### Layer 원칙
|
||||||
|
|
||||||
|
- Away / Return도 기존 recovery family 안에 들어간다
|
||||||
|
- 한 번에 하나의 recovery layer만 열 수 있다
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
|
||||||
|
1. `Complete`
|
||||||
|
2. `Return`
|
||||||
|
3. `Pause`
|
||||||
|
4. `Refocus`
|
||||||
|
5. `Next Beat`
|
||||||
|
|
||||||
|
### 이유
|
||||||
|
|
||||||
|
- Complete는 가장 명시적이고 종료 의미가 강함
|
||||||
|
- Return은 환경 변화에 대한 복귀 진입점
|
||||||
|
- Pause는 사용자의 수동 멈춤
|
||||||
|
- Refocus와 Next Beat는 더 세부적인 recovery 단계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 카피 원칙
|
||||||
|
|
||||||
|
### 좋은 톤
|
||||||
|
|
||||||
|
- `다시 돌아왔어요`
|
||||||
|
- `흐름은 그대로 남아 있어요`
|
||||||
|
- `한 조각만 다시 잡을까요?`
|
||||||
|
- `지금부터 쉬거나, 다음으로 이어갈 수 있어요`
|
||||||
|
|
||||||
|
### 금지 톤
|
||||||
|
|
||||||
|
- `집중을 놓쳤네요`
|
||||||
|
- `왜 자리를 비우셨나요`
|
||||||
|
- `세션이 중단되었습니다`
|
||||||
|
- `복귀하세요`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 구현 우선순위
|
||||||
|
|
||||||
|
### Slice A. Spec 정리
|
||||||
|
|
||||||
|
- 상태 모델 확정
|
||||||
|
- detection 전략 확정
|
||||||
|
- Return UX 문구 확정
|
||||||
|
|
||||||
|
### Slice B. Detection 도입
|
||||||
|
|
||||||
|
- `visibilitychange`
|
||||||
|
- `pagehide`
|
||||||
|
- sleep/wake delta
|
||||||
|
|
||||||
|
### Slice C. Return Tray 구현
|
||||||
|
|
||||||
|
- focus still running case
|
||||||
|
- focus ended while away case
|
||||||
|
|
||||||
|
### Slice D. Pause / Break 분리 강화
|
||||||
|
|
||||||
|
- copy
|
||||||
|
- visual tone
|
||||||
|
- action hierarchy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 측정 지표
|
||||||
|
|
||||||
|
- hidden -> return 이후 resume 비율
|
||||||
|
- away detected 후 abandon 비율
|
||||||
|
- focus ended while away 후 다음 행동 선택률
|
||||||
|
- return tray에서 primary 선택 비율
|
||||||
|
- return 후 2분 이상 유지 비율
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 이번 기획이 기존 계획과 어떻게 연결되는가
|
||||||
|
|
||||||
|
이 문서는 `중간에 끼어든 기획`이 맞지만, 방향을 흐리는 끼어듦이 아니다.
|
||||||
|
오히려 `Refocus System`을 완성하기 위해 필요한 핵심 보강이다.
|
||||||
|
|
||||||
|
따라서 순서는 이렇게 재정렬한다.
|
||||||
|
|
||||||
|
1. `Refocus System` 구현
|
||||||
|
2. `Away / Return Recovery` 구현
|
||||||
|
3. 그 다음에 originally planned visual polish
|
||||||
|
4. 그 다음에 Break 품질과 Review 확장
|
||||||
|
|
||||||
|
즉, 원래 예정했던 다음 기획은 사라지지 않는다.
|
||||||
|
**Away / Return이 먼저 들어오고, visual polish와 break refinement가 그 뒤로 밀리는 것**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 절대 피해야 할 방향
|
||||||
|
|
||||||
|
- 감시처럼 느껴지는 detection
|
||||||
|
- 무입력 시간을 벌점처럼 해석하는 것
|
||||||
|
- hidden -> 자동 pause 강제
|
||||||
|
- 돌아왔을 때 죄책감 유발 카피
|
||||||
|
- focus ended while away인데 그냥 break로 보내는 것
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 다음 구현에서 꼭 지켜야 할 것
|
||||||
|
|
||||||
|
- detection은 조심스럽게, return UX는 분명하게
|
||||||
|
- pause와 return을 섞지 말 것
|
||||||
|
- break는 reward / reset tone으로 유지할 것
|
||||||
|
- away는 “실패”가 아니라 “복귀가 필요한 순간”으로 다룰 것
|
||||||
439
docs/flows/current/15_app_stats_entry_flow_spec.md
Normal file
439
docs/flows/current/15_app_stats_entry_flow_spec.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# 15. `/app -> /stats -> /app` Weekly Review Entry Flow Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-15
|
||||||
|
|
||||||
|
이 문서는 VibeRoom의 `Weekly Review`를 **어디서, 왜, 어떤 타이밍에 열어야 하는지**를 정의하는 진입 플로우 문서다.
|
||||||
|
|
||||||
|
핵심 목적은 하나다.
|
||||||
|
|
||||||
|
> `/stats`를 고립된 summary page가 아니라, `/app`의 다음 세션 시작 성공률을 높여주는 review ritual로 코어 루프 안에 넣는다.
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `../../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
|
- `../../product/12_core_loop_execution_roadmap.md`
|
||||||
|
- `../../screens/stats/current/14_weekly_review_reframe_spec.md`
|
||||||
|
- `../../product_principles.md`
|
||||||
|
- `../../current_context.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 문제 정의
|
||||||
|
|
||||||
|
현재 구현 기준에서 `/stats`는 route는 존재하지만,
|
||||||
|
제품 루프 안에서의 진입 이유가 약하다.
|
||||||
|
|
||||||
|
현재 문제:
|
||||||
|
|
||||||
|
- `/stats`는 사실상 고립된 페이지다
|
||||||
|
- 사용자가 왜 지금 review를 봐야 하는지 맥락이 없다
|
||||||
|
- `/space`에서 바로 review로 튀면 execution 흐름을 끊는다
|
||||||
|
- `/app`과 `/stats`가 연결되지 않아, review가 다음 세션 시작으로 이어지지 않는다
|
||||||
|
|
||||||
|
즉, 지금 `/stats`는 “있을 수는 있는 페이지”지만
|
||||||
|
**사용자가 자연스럽게 보게 되는 페이지는 아니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
|
> Weekly Review의 primary flow는 `/app`에서 review teaser를 보고, `/stats`에서 지난 7일의 집중 리듬을 짧게 정리한 뒤, 다시 `/app`으로 돌아와 다음 세션을 시작하는 흐름이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 제품 원칙
|
||||||
|
|
||||||
|
### 1. `/stats`의 primary entry는 `/app`이다
|
||||||
|
|
||||||
|
- review는 “지금 일하는 중에 보는 것”이 아니라
|
||||||
|
- “다음 세션을 시작하기 직전에 확인하는 것”이다
|
||||||
|
|
||||||
|
### 2. `/space`는 review의 메인 진입점이 아니다
|
||||||
|
|
||||||
|
- `/space`는 execution surface다
|
||||||
|
- review는 boundary ritual이어야 한다
|
||||||
|
- `/space`에서는 teaser만 허용하고, full review로의 강한 유도는 금지한다
|
||||||
|
|
||||||
|
### 3. review는 다음 행동으로 연결되어야 한다
|
||||||
|
|
||||||
|
- `/stats`는 읽고 끝나는 페이지가 아니다
|
||||||
|
- 마지막 CTA는 반드시 `/app`의 next session start와 연결돼야 한다
|
||||||
|
|
||||||
|
### 4. review 진입은 “보면 좋음”이 아니라 “지금 보면 의미 있음”이어야 한다
|
||||||
|
|
||||||
|
- 무조건 항상 띄우지 않는다
|
||||||
|
- 데이터가 충분하고, 지금 보는 것이 실제로 의미 있을 때만 노출한다
|
||||||
|
|
||||||
|
### 5. review는 planning이 아니라 focus optimization이다
|
||||||
|
|
||||||
|
- 할 일 정리로 이어지면 실패
|
||||||
|
- 다음 세션의 시작 조건을 더 잘 맞추는 흐름이어야 한다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 왜 `/app`이 primary entry인가
|
||||||
|
|
||||||
|
`/app`은 commitment gate다.
|
||||||
|
즉 사용자가 “지금 한 가지를 붙잡고 들어가겠다”는 결정을 내리는 곳이다.
|
||||||
|
|
||||||
|
review가 가장 유의미한 순간도 바로 여기다.
|
||||||
|
|
||||||
|
왜냐하면:
|
||||||
|
|
||||||
|
- review를 보고 바로 다음 세션을 시작할 수 있다
|
||||||
|
- `/space`보다 맥락이 느슨해서 회고가 자연스럽다
|
||||||
|
- `/stats`를 planner page처럼 쓰지 않고 entry ritual로 흡수할 수 있다
|
||||||
|
|
||||||
|
반대로 `/space`는:
|
||||||
|
|
||||||
|
- 이미 실행 중인 화면이고
|
||||||
|
- review가 들어오면 흐름을 끊고
|
||||||
|
- 제품을 dashboard처럼 보이게 만들 수 있다
|
||||||
|
|
||||||
|
결론:
|
||||||
|
|
||||||
|
- `/app`은 review를 여는 문
|
||||||
|
- `/stats`는 주간 review desk
|
||||||
|
- `/app`으로 돌아와 다시 시작
|
||||||
|
|
||||||
|
이 삼각 구조가 가장 맞다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 사용자 플로우
|
||||||
|
|
||||||
|
### Primary Flow
|
||||||
|
|
||||||
|
`/app -> /stats -> /app`
|
||||||
|
|
||||||
|
1. 사용자가 `/app`에 들어온다
|
||||||
|
2. entry stage 바깥의 quiet review dock에 `weekly review entry`가 보인다
|
||||||
|
3. 사용자가 teaser를 누른다
|
||||||
|
4. `/stats`로 이동한다
|
||||||
|
5. review를 본다
|
||||||
|
6. 하단 CTA `이 흐름으로 다음 세션 시작`
|
||||||
|
7. `/app`으로 돌아온다
|
||||||
|
8. review에서 이어온 ritual/context를 가지고 바로 start한다
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
|
||||||
|
- review는 `/app`을 대체하지 않는다
|
||||||
|
- review는 `/app`을 보조한다
|
||||||
|
- review를 보고 다시 `/app`으로 돌아오는 구조가 중요하다
|
||||||
|
|
||||||
|
### Secondary Flow
|
||||||
|
|
||||||
|
`/space complete -> teaser -> /stats`
|
||||||
|
|
||||||
|
1. 사용자가 `/space`에서 세션을 마친다
|
||||||
|
2. complete flow가 끝난다
|
||||||
|
3. 조건이 충족되면 작은 review teaser만 보여준다
|
||||||
|
4. 사용자가 원할 때만 `/stats`로 간다
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- `/space`에서는 full review를 메인 행동으로 밀지 않는다
|
||||||
|
- complete tray를 review 유도 UI로 바꾸지 않는다
|
||||||
|
|
||||||
|
### Tertiary Flow
|
||||||
|
|
||||||
|
`profile / secondary nav -> /stats`
|
||||||
|
|
||||||
|
- 다시 review를 보고 싶은 사용자를 위한 명시적 진입
|
||||||
|
- 메인 CTA는 아니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `/app`에서 review teaser를 언제 보여줄 것인가
|
||||||
|
|
||||||
|
항상 보여주면 안 된다.
|
||||||
|
“지금 봐도 의미 없음” 상태에서는 review가 노이즈가 된다.
|
||||||
|
|
||||||
|
### 노출 조건
|
||||||
|
|
||||||
|
기본 규칙:
|
||||||
|
|
||||||
|
- 최근 7일 started session >= 3
|
||||||
|
|
||||||
|
추가 강화 조건:
|
||||||
|
|
||||||
|
- completed session >= 2
|
||||||
|
또는
|
||||||
|
- returned after pause >= 1
|
||||||
|
|
||||||
|
즉,
|
||||||
|
|
||||||
|
- 데이터가 너무 적으면 teaser를 숨긴다
|
||||||
|
- 최소한 “이번 주 리듬”이라고 부를 수 있을 만큼 쌓였을 때만 연다
|
||||||
|
|
||||||
|
### 노출하지 않는 경우
|
||||||
|
|
||||||
|
- 첫 1~2회 사용
|
||||||
|
- 세션 기록이 거의 없는 주
|
||||||
|
- current session이 있고 review 데이터도 거의 없는 상황
|
||||||
|
|
||||||
|
이 경우:
|
||||||
|
|
||||||
|
- `/app` no-session 상태에서는 goal/duration/atmosphere entry stage에 집중
|
||||||
|
- current session이 있으면 `/app` 대신 `/space`로 이동하므로, `/app` review dock은 no-session entry shell에서만 다룬다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `/app` review dock 설계
|
||||||
|
|
||||||
|
### 위치
|
||||||
|
|
||||||
|
추천 위치:
|
||||||
|
|
||||||
|
- desktop에서는 entry stage 우측 또는 우상단 quiet dock
|
||||||
|
- mobile에서는 entry stage 아래의 얇은 secondary panel
|
||||||
|
|
||||||
|
금지:
|
||||||
|
|
||||||
|
- entry stage보다 위
|
||||||
|
- entry CTA와 같은 무게
|
||||||
|
- card 3개 중 하나처럼 보이게 배치
|
||||||
|
|
||||||
|
### 형태
|
||||||
|
|
||||||
|
- low-emphasis secondary panel
|
||||||
|
- 작고 조용한 one-liner + secondary CTA
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- `이번 주엔 오전 시작에서 가장 오래 이어졌어요`
|
||||||
|
- `pause 뒤 복귀 3회 · review 보기`
|
||||||
|
|
||||||
|
### CTA
|
||||||
|
|
||||||
|
- `주간 review 보기`
|
||||||
|
|
||||||
|
보조 문구:
|
||||||
|
|
||||||
|
- `다음 세션 전에 가볍게 보고 갈 수 있어요`
|
||||||
|
|
||||||
|
### UX 원칙
|
||||||
|
|
||||||
|
- teaser는 insight를 다 말하지 않는다
|
||||||
|
- review를 보고 싶게 만들되, entry stage를 가리지 않는다
|
||||||
|
- start CTA보다 절대 커 보이면 안 된다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `/stats` 내부 플로우
|
||||||
|
|
||||||
|
### 진입 직후
|
||||||
|
|
||||||
|
- 이번 주 snapshot hero
|
||||||
|
- start / recovery / completion
|
||||||
|
- carry forward
|
||||||
|
|
||||||
|
### 마지막 CTA
|
||||||
|
|
||||||
|
review의 마지막 CTA는 아래 중 하나여야 한다.
|
||||||
|
|
||||||
|
- `이 흐름으로 다음 세션 시작`
|
||||||
|
- `이 조합으로 /app 돌아가기`
|
||||||
|
|
||||||
|
추천:
|
||||||
|
|
||||||
|
- CTA를 `/app?review=weekly&entryAtmosphereId=...&entryDurationMinutes=...` 같은 방식으로 연결
|
||||||
|
- 최소한 query 또는 client state로 ritual/context를 넘긴다
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- `/stats`에서 바로 `/space`로 가지 않는다
|
||||||
|
- `/app`을 다시 거치게 해서 commitment gate를 유지한다
|
||||||
|
|
||||||
|
왜냐하면:
|
||||||
|
|
||||||
|
- review 후에도 goal은 다시 내가 정해야 한다
|
||||||
|
- VibeRoom의 핵심은 single-goal commitment이지, review에서 바로 auto-start가 아니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `/app` 복귀 후 UX
|
||||||
|
|
||||||
|
### 복귀 상태
|
||||||
|
|
||||||
|
`/stats`에서 온 경우 `/app`은 plain 상태가 아니라
|
||||||
|
review-aware state가 된다.
|
||||||
|
|
||||||
|
보여줄 것:
|
||||||
|
|
||||||
|
- “방금 본 review 기준 추천” 한 줄
|
||||||
|
- review carry-forward hint
|
||||||
|
- optional atmosphere / ritual hint
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- `이번 주에 가장 잘 맞았던 흐름 · Forest Draft · 50분`
|
||||||
|
- `이번엔 시작을 더 작게 잡아보세요`
|
||||||
|
|
||||||
|
### 유지할 것
|
||||||
|
|
||||||
|
- goal은 여전히 사용자가 직접 입력
|
||||||
|
- goal과 duration은 여전히 사용자가 직접 입력
|
||||||
|
|
||||||
|
자동으로 하지 말 것:
|
||||||
|
|
||||||
|
- goal 자동 채우기
|
||||||
|
- 바로 `/space`로 보냄
|
||||||
|
- todo/list를 다시 불러옴
|
||||||
|
|
||||||
|
즉 review는:
|
||||||
|
|
||||||
|
- 방향을 준다
|
||||||
|
- 하지만 결정을 대신하진 않는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Free / Pro 차이
|
||||||
|
|
||||||
|
### Free
|
||||||
|
|
||||||
|
- `/app` teaser는 1개만
|
||||||
|
- `/stats`에서는 이번 주 snapshot + start / recovery / completion 핵심까지만
|
||||||
|
- return CTA는 generic
|
||||||
|
- `이 흐름으로 다음 세션 시작`
|
||||||
|
|
||||||
|
### Pro
|
||||||
|
|
||||||
|
- teaser가 더 구체적일 수 있다
|
||||||
|
- `Forest Draft · 50분에서 pause 뒤 복귀율이 가장 높았어요`
|
||||||
|
- `/stats` 마지막 CTA가 더 개인화된다
|
||||||
|
- `가장 잘 맞은 atmosphere로 /app 돌아가기`
|
||||||
|
- `/app` 복귀 후 ritual prefill / carry-forward hint가 더 정교하다
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
|
||||||
|
- Pro는 더 많은 review가 아니라
|
||||||
|
- 더 정확한 next-session handoff를 판다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. UI/UX 원칙
|
||||||
|
|
||||||
|
### `/app` review dock
|
||||||
|
|
||||||
|
- entry stage보다 작음
|
||||||
|
- 한 줄 summary
|
||||||
|
- secondary CTA
|
||||||
|
- glass/card를 크게 쓰지 않음
|
||||||
|
|
||||||
|
### `/stats`
|
||||||
|
|
||||||
|
- calm review desk
|
||||||
|
- 읽고 이해하고, 마지막에 행동 하나를 고르는 구조
|
||||||
|
|
||||||
|
### `/app` return state
|
||||||
|
|
||||||
|
- 다시 시작하는 감각 유지
|
||||||
|
- review를 보고 왔다고 해서 `/app`이 dashboard처럼 바뀌면 안 된다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 구현 순서
|
||||||
|
|
||||||
|
### Slice 1. `/app` weekly review dock 추가
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- teaser 노출 조건
|
||||||
|
- teaser UI
|
||||||
|
- `/stats` deep link
|
||||||
|
|
||||||
|
완료 기준:
|
||||||
|
|
||||||
|
- `/app`에서 review로 들어가는 primary path가 생긴다
|
||||||
|
|
||||||
|
### Slice 2. `/stats -> /app` return CTA 연결
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- `/stats` 마지막 CTA
|
||||||
|
- query/state handoff
|
||||||
|
- `/app` review-aware state
|
||||||
|
|
||||||
|
완료 기준:
|
||||||
|
|
||||||
|
- review가 읽고 끝나는 페이지가 아니라 다음 세션으로 이어진다
|
||||||
|
|
||||||
|
### Slice 3. Pro personalized handoff
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- best ritual carry-forward
|
||||||
|
- review origin state 유지
|
||||||
|
- CTA copy personalization
|
||||||
|
|
||||||
|
완료 기준:
|
||||||
|
|
||||||
|
- Pro가 더 자연스럽게 다음 세션으로 이어진다
|
||||||
|
|
||||||
|
### Slice 4. `/space` secondary teaser
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- complete 이후 작은 review teaser
|
||||||
|
- full review 강제 이동 금지
|
||||||
|
|
||||||
|
완료 기준:
|
||||||
|
|
||||||
|
- `/space`에서도 review가 존재하지만 execution을 방해하지 않는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 데이터 / 인터페이스 제안
|
||||||
|
|
||||||
|
### `/app` teaser용 데이터
|
||||||
|
|
||||||
|
새 hook 또는 기존 `useFocusStats` 확장:
|
||||||
|
|
||||||
|
- `hasEnoughWeeklyData`
|
||||||
|
- `weeklyTeaserLine`
|
||||||
|
- `reviewHref`
|
||||||
|
|
||||||
|
### `/stats -> /app` handoff
|
||||||
|
|
||||||
|
추천 query:
|
||||||
|
|
||||||
|
- `review=weekly`
|
||||||
|
- `entryAtmosphereId=forest-draft`
|
||||||
|
- `entryDurationMinutes=50`
|
||||||
|
- `carryHint=start-smaller`
|
||||||
|
|
||||||
|
주의:
|
||||||
|
|
||||||
|
- query는 가볍게
|
||||||
|
- 민감한 분석 전체를 싣지 않는다
|
||||||
|
|
||||||
|
### `/app` 복귀 상태
|
||||||
|
|
||||||
|
로컬 상태:
|
||||||
|
|
||||||
|
- `reviewSource`
|
||||||
|
- `suggestedEntryAtmosphereId`
|
||||||
|
- `suggestedEntryDurationMinutes`
|
||||||
|
- `carryForwardHint`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 완료 기준
|
||||||
|
|
||||||
|
- `/stats`의 primary entry가 `/app`에 생긴다
|
||||||
|
- `/space`는 review의 secondary entry만 가진다
|
||||||
|
- review를 본 뒤 사용자가 다시 `/app`으로 돌아와 다음 세션을 시작할 수 있다
|
||||||
|
- review가 planner나 dashboard가 아니라 next-session ritual처럼 읽힌다
|
||||||
|
- Free / Pro의 handoff 차이가 분명하다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 절대 피해야 할 방향
|
||||||
|
|
||||||
|
- `/space`에서 review로 강하게 끌어가는 것
|
||||||
|
- review를 top nav의 메인 기능처럼 밀어올리는 것
|
||||||
|
- `/stats`에서 바로 `/space` auto-start
|
||||||
|
- review 결과로 goal을 자동 생성하는 것
|
||||||
|
- review teaser가 `/app` hero보다 더 커 보이게 하는 것
|
||||||
|
- planner/todo 회고처럼 보이게 만드는 것
|
||||||
156
docs/flows/current/18_paused_session_reentry_spec.md
Normal file
156
docs/flows/current/18_paused_session_reentry_spec.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# 18. Session Routing Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-16
|
||||||
|
|
||||||
|
이 문서는 VibeRoom에서 `/app`과 `/space`의 역할을 어떻게 나눌지 정의한다.
|
||||||
|
|
||||||
|
핵심 원칙은 하나다.
|
||||||
|
|
||||||
|
> current session이 있으면 사용자를 `/app`에 세워두지 않고 바로 `/space`로 보낸다.
|
||||||
|
> current session이 없을 때만 `/app`에서 새 entry를 만든다.
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `../../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
|
- `../../screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`
|
||||||
|
- `./15_app_stats_entry_flow_spec.md`
|
||||||
|
- `../../product/12_core_loop_execution_roadmap.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 바꾸는가
|
||||||
|
|
||||||
|
이전 구조는 `/app`이
|
||||||
|
|
||||||
|
- no-session entry shell
|
||||||
|
- paused resume gate
|
||||||
|
- takeover decision
|
||||||
|
- review secondary entry
|
||||||
|
|
||||||
|
를 모두 안고 있었다.
|
||||||
|
|
||||||
|
문제:
|
||||||
|
|
||||||
|
- `/app`의 정체성이 흐려진다
|
||||||
|
- 사용자는 `들어가는 화면`인지 `멈춘 세션을 처리하는 화면`인지 헷갈린다
|
||||||
|
- `/space`의 recovery UX와 `/app`의 resume UX가 중복된다
|
||||||
|
|
||||||
|
따라서 `/app`은 다시 단순해져야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
|
### `/app`
|
||||||
|
|
||||||
|
새 session을 시작하기 위한 atmosphere entry surface
|
||||||
|
|
||||||
|
### `/space`
|
||||||
|
|
||||||
|
이미 존재하는 session을 계속 다루는 execution surface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 라우팅 규칙
|
||||||
|
|
||||||
|
### Rule A. current session이 있으면 `/space`
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- `currentSession` 존재
|
||||||
|
- `state = running` 또는 `paused`
|
||||||
|
- `phase = focus` 또는 `break`
|
||||||
|
|
||||||
|
처리:
|
||||||
|
|
||||||
|
- `/app` 진입 시 즉시 `/space`
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- session이 살아 있는 동안 사용자의 일은 이미 시작된 상태다
|
||||||
|
- `/app`이 끼어들면 execution surface와 decision surface가 섞인다
|
||||||
|
|
||||||
|
### Rule B. current session이 없으면 `/app`
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- current session 없음
|
||||||
|
|
||||||
|
처리:
|
||||||
|
|
||||||
|
- `/app` no-session entry shell 노출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `/app`의 역할
|
||||||
|
|
||||||
|
`/app`은 아래 3가지 결정만 받는다.
|
||||||
|
|
||||||
|
1. goal
|
||||||
|
2. duration
|
||||||
|
3. atmosphere
|
||||||
|
|
||||||
|
포함하지 않음:
|
||||||
|
|
||||||
|
- paused resume gate
|
||||||
|
- takeover sheet
|
||||||
|
- current session review entry
|
||||||
|
- running / paused 상태별 CTA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `/space`의 역할
|
||||||
|
|
||||||
|
current session이 있는 동안의 모든 판단은 `/space`에서 이뤄진다.
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- 이어가기
|
||||||
|
- 잠시 멈춤
|
||||||
|
- 다시 붙잡기
|
||||||
|
- 다음 단계 정하기
|
||||||
|
- 여기서 마무리하기
|
||||||
|
|
||||||
|
즉 paused session도 `/space` 안에서 다시 다룬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Weekly Review entry 규칙
|
||||||
|
|
||||||
|
### `/app`
|
||||||
|
|
||||||
|
- current session이 없을 때만 quiet secondary entry로 노출
|
||||||
|
|
||||||
|
### `/space`
|
||||||
|
|
||||||
|
- complete 이후 setup 상태에서만 secondary teaser 허용
|
||||||
|
|
||||||
|
current session이 살아 있는 동안 `/app`에서 review를 여는 flow는 current가 아니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 규칙
|
||||||
|
|
||||||
|
1. `/app`은 current session fetch 후 session이 있으면 바로 `/space` redirect
|
||||||
|
2. `/app` render tree에는 paused gate / takeover sheet를 남기지 않는다
|
||||||
|
3. `/space`는 paused session이라고 `/app`으로 되돌리지 않는다
|
||||||
|
4. `/space` recovery는 pause / return / break / complete 안에서 닫는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 하지 말아야 할 것
|
||||||
|
|
||||||
|
- paused session만 `/app`에 남기기
|
||||||
|
- `/app -> /space -> 다시 resume/start` 이중 결정
|
||||||
|
- `/app`에서 current session goal을 편집하기
|
||||||
|
- current session이 있는데 `/app`에서 새 entry를 겹쳐 띄우기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. QA 포인트
|
||||||
|
|
||||||
|
1. current session 없음 -> `/app` entry shell
|
||||||
|
2. current session running -> `/app` 진입 즉시 `/space`
|
||||||
|
3. current session paused -> `/app` 진입 즉시 `/space`
|
||||||
|
4. `/space`에서 pause 후 화면이 `/app`으로 튀지 않음
|
||||||
|
5. `/space` complete 후 no-session이 되면 다시 `/app` entry shell 접근 가능
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
## 프로젝트 목적
|
## 프로젝트 목적
|
||||||
|
|
||||||
VibeRoom Web은 몰입 공간 경험을 빠르게 실험하기 위한 프론트엔드 목업 프로젝트다.
|
VibeRoom Web은 ADHD와 프리랜서를 위한 premium focus service의 웹 제품을 설계하고 구현하는 프로젝트다.
|
||||||
핵심 목표는 실제 기능 완성보다 UX 흐름, 화면 구조, 상호작용 톤을 안정적으로 검증하는 것이다.
|
핵심 목표는 UX 흐름, 화면 구조, 상호작용 톤, core loop 계약을 실제 제품 품질로 안정적으로 끌어올리는 것이다.
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
- Next.js (App Router)
|
- Next.js (App Router)
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- TailwindCSS
|
- TailwindCSS
|
||||||
- 상태: React state + 일부 Zustand
|
- 상태: React state + local storage + 일부 shared store
|
||||||
|
|
||||||
## 유지보수 역할 정의
|
## 유지보수 역할 정의
|
||||||
|
|
||||||
@@ -19,22 +19,22 @@ VibeRoom Web은 몰입 공간 경험을 빠르게 실험하기 위한 프론트
|
|||||||
- FSD 구조를 지키며 화면/기능을 지속적으로 리팩터링한다.
|
- FSD 구조를 지키며 화면/기능을 지속적으로 리팩터링한다.
|
||||||
- View 계층을 조합 중심으로 유지하고 로직이 새지 않게 막는다.
|
- View 계층을 조합 중심으로 유지하고 로직이 새지 않게 막는다.
|
||||||
- 감성/저자극 톤을 유지하며 과한 강조 UI를 억제한다.
|
- 감성/저자극 톤을 유지하며 과한 강조 UI를 억제한다.
|
||||||
- 실제 서비스 로직은 구현하지 않고, 더미 데이터와 토스트로 흐름만 검증한다.
|
- mock UI만 만드는 데 그치지 않고, core focus/session/review 흐름은 실제 계약과 연결한다.
|
||||||
|
|
||||||
## 범위와 비범위
|
## 범위와 비범위
|
||||||
|
|
||||||
범위:
|
범위:
|
||||||
|
|
||||||
- 라우트/위젯/피처 단위 UI 개선
|
- 라우트/위젯/피처 단위 UI 개선
|
||||||
- 더미 데이터 기반 상태 표현
|
- 더미 데이터 + 실제 계약 혼합 상태 표현
|
||||||
- 모달, 토글, 탭, 선택, 토스트
|
- 모달, 토글, 탭, 선택, 토스트
|
||||||
|
|
||||||
비범위:
|
비범위:
|
||||||
|
|
||||||
- 실시간 인원수/presence 정확도 보장
|
- 실시간 인원수/presence 정확도 보장
|
||||||
- 타이머 카운트다운 실제 동작
|
- 오디오 DSP 수준의 고급 엔진
|
||||||
- 오디오 재생 엔진
|
- 운영 observability 완성
|
||||||
- 서버/DB/API 연동 완성
|
- 모든 주변 기능의 production hardening
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ src/
|
|||||||
features/ # 사용자 액션/유즈케이스 단위
|
features/ # 사용자 액션/유즈케이스 단위
|
||||||
entities/ # 도메인 타입/더미 데이터
|
entities/ # 도메인 타입/더미 데이터
|
||||||
shared/ # 공용 UI/유틸
|
shared/ # 공용 UI/유틸
|
||||||
store/ # 전역 상태(필요 최소)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 핵심 규칙
|
## 핵심 규칙
|
||||||
@@ -19,6 +18,7 @@ src/
|
|||||||
3. UI 상태(토글/선택)만 컴포넌트 내부에서 최소 허용한다.
|
3. UI 상태(토글/선택)만 컴포넌트 내부에서 최소 허용한다.
|
||||||
4. 파일 길이 500줄 이상이면 즉시 분리한다.
|
4. 파일 길이 500줄 이상이면 즉시 분리한다.
|
||||||
5. 하위 레이어가 상위 레이어를 import하지 않는다.
|
5. 하위 레이어가 상위 레이어를 import하지 않는다.
|
||||||
|
6. 전역 상태가 필요하면 먼저 해당 도메인 slice의 `model/` 안에 둔다.
|
||||||
|
|
||||||
## Import 방향 규칙
|
## Import 방향 규칙
|
||||||
|
|
||||||
@@ -33,12 +33,14 @@ src/
|
|||||||
- `features` -> 다른 `features` 직접 참조 (강한 결합 유발)
|
- `features` -> 다른 `features` 직접 참조 (강한 결합 유발)
|
||||||
- `shared` -> `entities/features/widgets/app` 참조
|
- `shared` -> `entities/features/widgets/app` 참조
|
||||||
- `page.tsx`에 도메인 로직/세부 UI 구현 누적
|
- `page.tsx`에 도메인 로직/세부 UI 구현 누적
|
||||||
|
- 루트 전역 저장소를 관성적으로 추가하는 것
|
||||||
|
|
||||||
## 구현 정책 (이 프로젝트 전용)
|
## 구현 정책 (이 프로젝트 전용)
|
||||||
|
|
||||||
- 실제 타이머/오디오/서버/DB 기능은 구현하지 않는다.
|
- 실제 타이머/오디오/서버/DB 기능은 구현하지 않는다.
|
||||||
- 기능 트리거는 토스트 또는 더미 상태 전환으로 표현한다.
|
- 기능 트리거는 토스트 또는 더미 상태 전환으로 표현한다.
|
||||||
- 도메인 표시는 `entities` 데이터에서 읽고 뷰 하드코딩을 지양한다.
|
- 도메인 표시는 `entities` 데이터에서 읽고 뷰 하드코딩을 지양한다.
|
||||||
|
- 인증/세션 같은 전역 상태도 가능하면 해당 도메인 `entities/*/model` 안에서 관리한다.
|
||||||
|
|
||||||
## 파일 분리 기준
|
## 파일 분리 기준
|
||||||
|
|
||||||
@@ -14,21 +14,24 @@
|
|||||||
### `/app` (허브)
|
### `/app` (허브)
|
||||||
|
|
||||||
- Page: `src/app/(app)/app/page.tsx`
|
- Page: `src/app/(app)/app/page.tsx`
|
||||||
- Core Widget: `src/widgets/app-hub/ui/AppHubWidget.tsx`
|
- Core Widget: `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`
|
||||||
- 주요 구성:
|
- 주요 구성:
|
||||||
- `StartRitualWidget`
|
- `FocusDashboardWidget`
|
||||||
- `RoomsGalleryWidget`
|
- paused `Resume Gate`
|
||||||
- `CustomEntryWidget`
|
- no-session `Atmosphere Entry Shell` (기획 기준, 구현 예정)
|
||||||
- 데이터 소스:
|
- 데이터 소스:
|
||||||
- room 목록: `entities/room`
|
- current session: `features/focus-session`
|
||||||
- 목표/타이머/사운드 프리셋: `entities/session`
|
- weekly review: `features/stats`
|
||||||
|
- atmosphere 선택 데이터: entry slice 구현 예정
|
||||||
|
|
||||||
### `/space` (집중 화면)
|
### `/space` (집중 화면)
|
||||||
|
|
||||||
- Page: `src/app/(app)/space/page.tsx`
|
- Page: `src/app/(app)/space/page.tsx`
|
||||||
- Core Widget: `src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx`
|
- Core Widget: `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||||
- 주요 구성:
|
- 주요 구성:
|
||||||
- `SpaceTimerHudWidget`
|
- `SpaceTimerHudWidget`
|
||||||
|
- `SpaceFocusHudWidget`
|
||||||
|
- `SpaceSetupDrawerWidget`
|
||||||
- `SpaceToolsDockWidget`
|
- `SpaceToolsDockWidget`
|
||||||
- `features/restart-30s` (HUD 내 조합)
|
- `features/restart-30s` (HUD 내 조합)
|
||||||
|
|
||||||
@@ -38,9 +41,11 @@
|
|||||||
- `sound`: 사운드 preset id
|
- `sound`: 사운드 preset id
|
||||||
- `timer`: 타이머 라벨
|
- `timer`: 타이머 라벨
|
||||||
- `goal`: 목표 한 줄 (선택)
|
- `goal`: 목표 한 줄 (선택)
|
||||||
|
- `resume`: `continue | refocus`
|
||||||
|
|
||||||
## 변경 시 체크포인트
|
## 변경 시 체크포인트
|
||||||
|
|
||||||
- 라우팅 변경 시 `/app -> /space` 진입 흐름이 깨지지 않는지 확인
|
- 라우팅 변경 시 `/app -> /space` 진입 흐름이 깨지지 않는지 확인
|
||||||
|
- `running -> /space`, `paused -> /app` 재진입 정책 유지
|
||||||
- query param 기본값 처리 유지
|
- query param 기본값 처리 유지
|
||||||
- page 파일에 로직 누수 여부 확인
|
- page 파일에 로직 누수 여부 확인
|
||||||
136
docs/foundation/08_premium_uiux_guideline.md
Normal file
136
docs/foundation/08_premium_uiux_guideline.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Premium Immersive UI/UX Guidelines (VibeRoom Core)
|
||||||
|
|
||||||
|
이 문서는 VibeRoom 프로젝트가 LifeAt, Portal, Focusmate 등 **세계 최고급의 프리미엄 UI/UX 경험**을 일관되게 유지하기 위해 작성된 절대적인 디자인 헌장입니다.
|
||||||
|
어떤 에이전트, 어떤 개발자가 코드를 작성하든 화면을 추가하거나 수정할 때 이 가이드라인을 반드시 숙지하고 엄격하게 준수해야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 핵심 철학 (Core Philosophy)
|
||||||
|
|
||||||
|
### 1-0. View Reuse First (사용자 뷰 우선 재사용)
|
||||||
|
- **절대 원칙:** 사용자가 직접 다듬어 둔 현재 화면의 view, spacing, motion, hierarchy는 곧 source of truth입니다.
|
||||||
|
- AI 에이전트나 개발자는 UI 작업 시 새로운 shell, 새로운 visual language, 새로운 레이아웃을 임의로 다시 그리면 안 됩니다.
|
||||||
|
- 기능 추가/상태 분기 변경이 필요해도 **기존 view를 재사용하고, 그 내부에 stage/state만 확장**하는 방식으로 처리합니다.
|
||||||
|
- "더 좋아 보인다", "더 premium해 보인다"는 이유만으로 현재 사용 중인 view를 갈아엎는 것은 금지합니다.
|
||||||
|
- 새 화면이 필요할 때도 먼저 기존 화면의 shell, typography, spacing, animation을 복제/재사용할 수 있는지부터 검토합니다.
|
||||||
|
- 특히 `/space`처럼 사용자가 직접 premium tone을 반복적으로 조정한 화면에서는, **현재 구현된 컴포넌트가 디자인 시스템 그 자체**라고 보고 수정 범위를 최소화해야 합니다.
|
||||||
|
|
||||||
|
### 1-1. 무대 우선주의 (Stage-first & Immersive)
|
||||||
|
- **절대 원칙:** 사용자가 선택한 **배경(Atmosphere/Scene) 자체가 곧 앱의 정체성이자 무대**입니다.
|
||||||
|
- 배경을 가리거나 시야를 방해하는 거대한 대시보드 형태의 레이아웃(Split-screen, 거대한 Solid Card Grid)은 절대 금지합니다.
|
||||||
|
- UI는 무대 위에 떠 있는 얇고 투명한 유리 조각(Glass)처럼 존재해야 하며, 화면의 여백(White Space)을 극대화하여 공간감을 제공해야 합니다.
|
||||||
|
|
||||||
|
### 1-2. 중앙 집중의 의식 (Minimal Central Ritual)
|
||||||
|
- 사용자가 수행해야 할 가장 중요한 단 하나의 핵심 액션(예: "무엇에 집중할 것인가?")은 **항상 화면의 정중앙에 거대하고 우아하게 배치**합니다.
|
||||||
|
- 텍스트 입력창은 투명하게(`bg-transparent`), 폰트는 크고 얇게(`text-4xl font-light tracking-tight`) 유지하여 단순한 '입력'이 아닌 '의식(Ritual)'처럼 느껴지게 합니다.
|
||||||
|
|
||||||
|
### 1-3. 압도적인 글래스모피즘 (Premium Glassmorphism)
|
||||||
|
- 단순히 투명도를 낮추는 것이 아닙니다. 뒤의 빛과 배경이 은은하게 굴절되는 진짜 유리의 질감을 구현해야 합니다.
|
||||||
|
- **필수 속성:** `backdrop-blur-xl` 또는 `backdrop-blur-2xl`을 반드시 사용합니다.
|
||||||
|
- **테두리와 명암:** 투박한 solid border 대신, `border-white/5` ~ `border-white/10` 수준의 매우 얇고 투명한 테두리를 사용합니다. 깊이감을 위해 다중 그림자(`shadow-2xl` 등)와 미세한 그라데이션(`bg-[linear-gradient(...)]`)을 조합합니다.
|
||||||
|
|
||||||
|
### 1-4. 무대와 대기실의 분리 (Stage vs. Lobby Separation)
|
||||||
|
- **세션(Space) 외의 화면(`/app`, `/stats`, `/settings` 등)은 실제 집중 배경(Atmosphere)을 그대로 띄우지 않습니다.**
|
||||||
|
- 대기실(Lobby) 역할을 하는 화면에 너무 구체적인 풍경이나 영상이 띄워져 있으면, 사용자가 이미 집중 세션에 들어왔다고 착각하거나 인지적 피로감을 느낄 수 있습니다.
|
||||||
|
- **해결책:** 대기실 화면의 배경은 집중할 때 볼 풍경을 블러 처리(`blur-3xl`)하거나, 매우 어둡고 깊이 있는 추상적 그라데이션(예: `bg-black`에 은은한 틴트)으로 처리하여 **"아직 무대에 오르기 전(또는 내려온 후)"** 이라는 심리적 분리감을 명확히 주어야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 레이아웃 & 컴포넌트 배치 원칙 (Layout & Placement)
|
||||||
|
|
||||||
|
### 2-1. 절대 위치(Absolute) 사용의 엄격한 제한
|
||||||
|
- UI 요소가 창 크기 조절(Resizing) 시 중앙의 핵심 컨텐츠(Main Ritual)를 가리거나 겹치는 현상(Overlap)은 치명적인 결함입니다.
|
||||||
|
- **해결책:** 좌/우측에 둥둥 떠 있는(Floating) 위젯 형태를 구현할 때, 단순히 `absolute top-x left-y`로 띄워두지 마십시오. 창이 작아져도 겹치지 않게 하려면 중앙 컨테이너의 흐름(Inline Flex) 내부에 배치하거나, 화면 크기에 따른 철저한 미디어 쿼리 제어를 통해 **어떤 해상도에서도 메인 텍스트 영역을 침범하지 않도록 보장**해야 합니다.
|
||||||
|
|
||||||
|
### 2-2. 보조 위젯의 극단적 미니멀리즘 (Subtle Accessories)
|
||||||
|
- 메인 액션이 아닌 모든 정보(예: Weekly Review, Error Message, 힌트 등)는 **크기를 최소화하고 시각적 대비를 낮춥니다.**
|
||||||
|
- 정보 텍스트는 `text-[12px]` 또는 `text-[13px]`, 라벨이나 뱃지는 `text-[9px] ~ text-[10px]`에 두꺼운 자간(`tracking-[0.25em]`)과 대문자(`uppercase`) 조합을 사용하여 명품 브랜드의 타이포그래피처럼 디자인합니다.
|
||||||
|
- 예: 투박한 'Weekly Review' 카드 ❌ -> 한 줄의 세련된 'Smart Hint Pill' 형태 ⭕
|
||||||
|
|
||||||
|
### 2-3. 가장자리 도킹 (Edge Docking)
|
||||||
|
- 선택형 리스트(Atmosphere 선택 등)는 화면을 덮는 Grid 대신 **화면 최하단에 스와이프 가능한 가로 독(Carousel Dock)** 형태로 배치합니다.
|
||||||
|
- 독 내부의 아이템이 확대(Scale)되거나 애니메이션 될 때, 스크롤 컨테이너의 영역이 좁아 카드가 잘려 보이는 현상(Clipping)이 발생하지 않도록 **컨테이너 자체에 충분한 상하 여백(`py-8` 등)**을 확보해야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 애니메이션 및 상호작용 (Motion & Interaction)
|
||||||
|
|
||||||
|
### 3-1. 부드럽고 웅장한 진입 (Stately Entrance)
|
||||||
|
- 뚝 떨어지거나 딱딱하게 나타나는 화면 전환은 금지합니다.
|
||||||
|
- 프리미엄 서비스 특유의 '서서히 떠오르는' 모션을 위해 커스텀 Keyframe(`fade-in-up`, `fade-in`)과 섬세한 이징 커브(`cubic-bezier(0.16, 1, 0.3, 1)`)를 적용합니다.
|
||||||
|
- `animation-delay`를 활용하여 헤더 -> 중앙 텍스트 -> 하단 독 순서로 물결치듯 순차적으로 나타나는 시퀀스를 구성합니다.
|
||||||
|
|
||||||
|
### 3-2. Hover 및 포커스 상태 (Fluid Feedback)
|
||||||
|
- 카드 호버 시 단순히 색만 변하는 것이 아니라, 부드러운 스케일 업(`hover:scale-105 ~ 110`)과 함께 그림자가 깊어지고(`hover:shadow-2xl`) 내부 텍스트 및 오버레이의 명도가 미세하게 조절되어야 합니다.
|
||||||
|
- 버튼의 경우 누를 때 미세하게 작아지는 햅틱 피드백(`active:scale-[0.98]`)을 적용하여 쫀득한 터치감을 줍니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 타이포그래피 및 카피 (Typography & Copy)
|
||||||
|
|
||||||
|
### 4-1. 소음 줄이기 (Reduce Visual Noise)
|
||||||
|
- 중요하지 않은 부가 설명(Helper Text)은 `text-white/40` ~ `text-white/60` 정도로 투명도를 과감히 낮춰 시야에서 멀어지게 합니다.
|
||||||
|
- 강렬한 Primary Color(파란색, 빨간색 등)는 에러나 꼭 필요한 CTA에만 극도로 제한적으로 사용하고, 기본적으로는 **무채색(흰색, 검은색)의 투명도 조절만으로 위계를 표현**합니다.
|
||||||
|
|
||||||
|
### 4-2. 프리미엄 카피라이팅 (Tone & Manner)
|
||||||
|
- 기능적인 설명보다 감성적이고 몰입을 돕는 문구를 사용합니다.
|
||||||
|
- "시간을 입력하세요" ❌ -> "What will you focus on?" / "의식을 시작합니다" ⭕
|
||||||
|
- 업그레이드 등 상업적 CTA도 "결제하기"보다 작고 섬세한 캡슐 버튼(`Upgrade →`)으로 디자인하여 브랜드의 우아함을 지킵니다.
|
||||||
|
|
||||||
|
## 5. AI 에이전트를 위한 Tailwind CSS 프롬프팅 & 코드 패턴 (Crucial for AI)
|
||||||
|
|
||||||
|
AI 에이전트(Codex, Cursor, Cline 등)는 "프리미엄하게 만들어줘"라는 추상적인 지시를 이해하지 못하고 평범한 UI(`bg-gray-100 rounded-md` 등)를 생성하는 경향이 있습니다. AI에게 작업을 지시할 때는 아래의 **명확한 Tailwind 유틸리티 패턴(Snippet)** 을 그대로 복사해서 사용하라고 지시해야 합니다.
|
||||||
|
|
||||||
|
### 5-1. Floating Smart Pill (보조 정보 위젯)
|
||||||
|
AI가 투박한 Card를 만들지 못하게 하고, 이 코드를 복사하라고 지시하세요.
|
||||||
|
```tsx
|
||||||
|
// DO: 아주 작고 은은하게 떠 있는 스마트 필
|
||||||
|
<div className="inline-flex items-center gap-3 rounded-full border border-white/5 bg-white/5 py-2 pl-4 pr-3 backdrop-blur-md transition-all hover:bg-white/10 hover:border-white/10">
|
||||||
|
<span className="flex h-1.5 w-1.5 rounded-full bg-white/40 group-hover:bg-white/60 transition-colors" />
|
||||||
|
<span className="text-[13px] font-medium text-white/70 group-hover:text-white/90">
|
||||||
|
내용 텍스트
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// DON'T: AI가 자주 실수하는 투박한 솔리드 카드
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow-md">내용 텍스트</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-2. 메인 입력창 (Ritual Input)
|
||||||
|
AI가 흔한 폼 인풋(`border rounded px-4`)을 만들지 못하게 하세요.
|
||||||
|
```tsx
|
||||||
|
// DO: 거대하고 얇고 투명한 의식적 텍스트
|
||||||
|
<input
|
||||||
|
className="w-full bg-transparent text-center text-4xl font-light tracking-tight text-white outline-none placeholder:text-white/20 md:text-5xl lg:text-[4.5rem] lg:leading-[1.1]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// DON'T: 일반적인 대시보드 폼
|
||||||
|
<input className="border border-gray-300 rounded px-4 py-2 w-full" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-3. 프리미엄 버튼 (Primary Action)
|
||||||
|
```tsx
|
||||||
|
// DO: 빛나는 투명 테두리와 쫀득한 햅틱 모션을 가진 버튼
|
||||||
|
<button className="group relative flex h-16 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 px-12 text-lg font-medium tracking-wide text-white shadow-2xl backdrop-blur-md transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] active:scale-[0.98]">
|
||||||
|
시작하기
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-4. 그라데이션 오버레이 (배경 어둡게 하기)
|
||||||
|
이미지 위에서 글씨가 잘 보이게 하려면 단순 `bg-black/50` 대신 깊이감 있는 그라데이션을 써야 합니다.
|
||||||
|
```tsx
|
||||||
|
// DO: 복합 그라데이션 (중앙은 비우고 테두리만 어둡게)
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] mix-blend-multiply pointer-events-none" />
|
||||||
|
<div className="absolute inset-0 bg-black/10 pointer-events-none" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. QA 체크리스트 (커밋 전 반드시 확인)
|
||||||
|
|
||||||
|
1. **Overlap Check:** 브라우저 창을 최소 크기(모바일/작은 데스크탑)로 줄였을 때 위젯이나 텍스트가 겹치거나 잘리는 곳이 단 한 곳이라도 있는가? (있다면 즉시 수정)
|
||||||
|
2. **Clipping Check:** 리스트 내의 아이템에 마우스를 올렸을 때(hover:scale) 아이템의 상하좌우 테두리가 부모 컨테이너에 의해 잘려 나가는 현상이 없는가?
|
||||||
|
3. **Hierarchy Check:** 화면 내에서 가장 눈에 띄는 것이 "현재 사용자가 해야 할 단 하나의 액션"인가? 부가 정보가 너무 커서 메인 액션을 압도하지 않는가?
|
||||||
|
4. **Motion Check:** 화면 진입 시 모든 요소가 우아하고 부드럽게 등장하는가? 깜빡이거나 투박하게 나타나는 요소는 없는가?
|
||||||
|
5. **Glass Check:** 모든 팝오버, 시트, 위젯이 뒷 배경을 우아하게 투영(backdrop-blur)하고 있으며, 테두리가 지나치게 두껍지 않은가?
|
||||||
|
6. **Reuse Check:** 이번 작업이 기존 사용자가 만든 view를 재사용/확장한 것인가? 새 shell이나 새 visual language를 임의로 만들지 않았는가?
|
||||||
@@ -4,9 +4,13 @@ Codex CLI가 중단되거나 컨텍스트가 초기화된 뒤 재개할 때 사
|
|||||||
|
|
||||||
## 재개 시작 (5분)
|
## 재개 시작 (5분)
|
||||||
|
|
||||||
1. `docs/README.md`에서 우선 읽기 5개 문서를 순서대로 확인
|
1. `docs/README.md`에서 지금 작업할 화면 섹션과 우선 읽기 문서를 확인
|
||||||
2. `git status --short`로 작업 트리 상태 파악
|
2. `git status --short`로 작업 트리 상태 파악
|
||||||
3. `docs/90_current_state.md`의 `NEXT` 1순위부터 착수
|
3. `docs/work.md`와 `docs/90_current_state.md`의 현재 우선순위를 확인
|
||||||
|
4. 화면 작업이면 해당 source-of-truth spec을 추가로 읽는다
|
||||||
|
- `/app` -> `19`, `18`, `15`
|
||||||
|
- `/space` -> `10`, `11`, `13`
|
||||||
|
- `/stats` -> `14`, `15`
|
||||||
|
|
||||||
## 구현 중 체크
|
## 구현 중 체크
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ docs(session): 세션 복구 워크플로우와 커밋 템플릿 추가
|
|||||||
- codex cli 중단 시 작업 맥락 손실을 줄이기 위해
|
- codex cli 중단 시 작업 맥락 손실을 줄이기 위해
|
||||||
|
|
||||||
변경사항:
|
변경사항:
|
||||||
- docs/06_commit_convention.md 추가
|
- docs/foundation/06_commit_convention.md 추가
|
||||||
- docs/07_session_recovery.md 추가
|
- docs/ops/07_session_recovery.md 추가
|
||||||
- scripts/session/recover-context.sh 추가
|
- scripts/session/recover-context.sh 추가
|
||||||
|
|
||||||
검증:
|
검증:
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
### 조건부 로드 (필요할 때만)
|
### 조건부 로드 (필요할 때만)
|
||||||
|
|
||||||
- UI/카피/CTA 변경이 있으면 `docs/01_ui_guidelines.md`
|
- UI/카피/CTA 변경이 있으면 `docs/foundation/01_ui_guidelines.md`
|
||||||
- 구조/FSD/레이어 변경이 있으면 `docs/02_arch_fsd_rules.md`
|
- 구조/FSD/레이어 변경이 있으면 `docs/foundation/02_arch_fsd_rules.md`
|
||||||
- 커밋 직전에 `docs/06_commit_convention.md`
|
- 커밋 직전에 `docs/foundation/06_commit_convention.md`
|
||||||
- 현재 상태 상세가 필요하면 `docs/90_current_state.md`
|
- 현재 상태 상세가 필요하면 `docs/90_current_state.md`
|
||||||
- 핵심 규칙 상세가 필요하면 `docs/context_core.md`
|
- 핵심 규칙 상세가 필요하면 `docs/context_core.md`
|
||||||
- 규칙 충돌/모호함이 있으면 `docs/00_project_brief.md`, `docs/04_coding_rules.md`
|
- 규칙 충돌/모호함이 있으면 `docs/foundation/00_project_brief.md`, `docs/foundation/04_coding_rules.md`
|
||||||
|
|
||||||
## 2) 구현 원칙 (강제)
|
## 2) 구현 원칙 (강제)
|
||||||
|
|
||||||
433
docs/product/12_core_loop_execution_roadmap.md
Normal file
433
docs/product/12_core_loop_execution_roadmap.md
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
# 12. Core Loop Execution Roadmap
|
||||||
|
|
||||||
|
Last Updated: 2026-03-15
|
||||||
|
|
||||||
|
이 문서는 VibeRoom의 핵심 제품 기획을 **어떤 순서로 구현까지 연결할지**를 정의하는 실행 로드맵이다.
|
||||||
|
|
||||||
|
중요한 목적은 두 가지다.
|
||||||
|
|
||||||
|
- 중간에 끼어드는 기획이 전체 방향을 흔들지 않게 한다
|
||||||
|
- 어떤 세션, 어떤 에이전트가 들어와도 **다음으로 무엇을 해야 하는지** 즉시 이해하게 한다
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `../../product_principles.md`
|
||||||
|
- `../../current_context.md`
|
||||||
|
- `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
|
- `../flows/current/10_refocus_system_spec.md`
|
||||||
|
- `../flows/current/11_away_return_recovery_spec.md`
|
||||||
|
- `../flows/current/15_app_stats_entry_flow_spec.md`
|
||||||
|
- `../flows/current/18_paused_session_reentry_spec.md`
|
||||||
|
- `./16_product_alignment_audit_plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 기본 원칙
|
||||||
|
|
||||||
|
VibeRoom은 아래 방식으로 진행한다.
|
||||||
|
|
||||||
|
1. 바뀌면 안 되는 제품 원칙을 먼저 고정한다
|
||||||
|
2. 코어 루프를 정의한다
|
||||||
|
3. 코어 루프를 **slice 단위**로 상세 기획한다
|
||||||
|
4. 각 slice는 `기획 -> 구현 -> 브라우저 QA -> 문서 업데이트`까지 닫고 간다
|
||||||
|
5. 다음 slice는 이전 slice가 제품적으로 안정된 뒤에 들어간다
|
||||||
|
|
||||||
|
즉:
|
||||||
|
|
||||||
|
- `모든 기획을 끝낸 뒤 한 번에 개발`하지 않는다
|
||||||
|
- `생각나는 기능을 바로 구현`하지도 않는다
|
||||||
|
- **큰 방향은 먼저 고정하고, 세부는 vertical slice로 기획 후 바로 구현**한다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재까지 완료된 것
|
||||||
|
|
||||||
|
### 완료 1. Product Principles
|
||||||
|
|
||||||
|
- 루트 문서 `../../product_principles.md`
|
||||||
|
- single-goal, premium focus, anti-to-do 방향 고정
|
||||||
|
|
||||||
|
### 완료 2. `/app` Entry Reframe (Legacy)
|
||||||
|
|
||||||
|
- 문서: historical, superseded by `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
|
- 구현 상태:
|
||||||
|
- `/app`은 한때 single-goal commitment gate로 정리됐음
|
||||||
|
- planner/list-first 구조 제거
|
||||||
|
- current session이 있으면 resume 우선
|
||||||
|
|
||||||
|
### 다음 Phase A. `/app` Atmosphere Entry Redesign
|
||||||
|
|
||||||
|
- 문서: `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
|
- 목적:
|
||||||
|
- `/app` no-session 상태를 `goal + duration + atmosphere` 중심의 premium entry stage로 재설계
|
||||||
|
- scene/sound를 다시 entry 가치로 끌어올리되 planner/dashboard처럼 보이지 않게 유지
|
||||||
|
- 핵심 변화:
|
||||||
|
- microStep 제거
|
||||||
|
- duration을 분 단위 직접 입력으로 전환
|
||||||
|
- `Atmosphere` 12개 그리드 도입
|
||||||
|
- weekly review는 quiet secondary dock로 유지
|
||||||
|
- 상태:
|
||||||
|
- 상세 기획 완료
|
||||||
|
- 구현 전
|
||||||
|
|
||||||
|
### 완료 3. Refocus System 기본 구조
|
||||||
|
|
||||||
|
- 문서: `../flows/current/10_refocus_system_spec.md`
|
||||||
|
- 구현 상태:
|
||||||
|
- pause -> refocus 흐름의 기본 skeleton 존재
|
||||||
|
- next beat / goal complete의 상태 분리 시작
|
||||||
|
|
||||||
|
### 완료 4. Away / Return Recovery 기본 구현
|
||||||
|
|
||||||
|
- 문서: `../flows/current/11_away_return_recovery_spec.md`
|
||||||
|
- 구현 상태:
|
||||||
|
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 detection 연결
|
||||||
|
- focus running 복귀 시 `Return` tray 노출
|
||||||
|
- focus ended while away 시 standard break 대신 `Return` tray 선행
|
||||||
|
|
||||||
|
### 완료 5. Pause / Break / Return 1차 톤 분리
|
||||||
|
|
||||||
|
- 구현 상태:
|
||||||
|
- pause tray 가독성 재정리
|
||||||
|
- `Return(break)`와 `Return(focus)` material 분리 시작
|
||||||
|
- break 관련 선택과 timer HUD를 더 release tone으로 보정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 지금 끼어든 기획의 위치
|
||||||
|
|
||||||
|
### 끼어든 기획
|
||||||
|
|
||||||
|
- `../flows/current/11_away_return_recovery_spec.md`
|
||||||
|
|
||||||
|
이 기획은 원래 흐름을 덮어쓴 것이 아니다.
|
||||||
|
이 문서는 **Refocus System을 실제 사용자 행동에 맞게 완성하기 위해 중간에 추가된 필수 slice**다.
|
||||||
|
|
||||||
|
왜 끼어들었는가:
|
||||||
|
|
||||||
|
- 사용자는 `pause`를 누르지 않고 자리를 뜨는 경우가 많다
|
||||||
|
- 이 케이스를 다루지 않으면 Refocus System이 반쪽짜리가 된다
|
||||||
|
- `pause`와 `break`가 겹쳐 보이는 문제도 여기서 정리해야 한다
|
||||||
|
|
||||||
|
즉, 이 기획은 “옆길”이 아니라 **Refocus System의 확장 파트**다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 현재 기준의 구현 순서
|
||||||
|
|
||||||
|
아래 순서를 공식 순서로 고정한다.
|
||||||
|
|
||||||
|
### Phase 1. `/app` Entry Polish
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 시작 마찰을 최소화
|
||||||
|
- `/app`이 planner가 아니라 commitment gate로 읽히게 고정
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 기획 완료
|
||||||
|
- 구현 완료
|
||||||
|
- 남은 것은 브라우저 QA와 minor polish
|
||||||
|
|
||||||
|
### Phase 2. Refocus System
|
||||||
|
|
||||||
|
문서:
|
||||||
|
|
||||||
|
- `../flows/current/10_refocus_system_spec.md`
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- pause 이후 복귀
|
||||||
|
- microStep 완료 후 다음 한 조각 연결
|
||||||
|
- goal complete의 자연스러운 closure
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 기획 완료
|
||||||
|
- 핵심 구현 완료
|
||||||
|
- 남은 것은 visual polish와 browser QA
|
||||||
|
|
||||||
|
### Phase 3. Away / Return Recovery
|
||||||
|
|
||||||
|
문서:
|
||||||
|
|
||||||
|
- `../flows/current/11_away_return_recovery_spec.md`
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 사용자가 `pause` 없이 떠난 경우를 감지
|
||||||
|
- 돌아왔을 때 Break가 아니라 Return UX로 맞이
|
||||||
|
- `pause`, `break`, `return`의 감정적 의미를 분리
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 기획 완료
|
||||||
|
- 핵심 구현 완료
|
||||||
|
- 남은 것은 오탐/복귀 타이밍 브라우저 QA
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- 이것은 `Refocus System` 다음으로 이어지는 것이 아니라
|
||||||
|
- **Refocus System 구현의 후반부**로 바로 이어진다
|
||||||
|
|
||||||
|
### Phase 4. Complete / Break Separation Polish
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- `goal complete`, `break`, `return`의 의미와 재질감 분리
|
||||||
|
- “쉬는 중”과 “복귀 결정”이 섞이지 않게 정리
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 현재 진행 중
|
||||||
|
- 1차 material/tone 분리 반영 완료
|
||||||
|
- 남은 것은 copy, motion, 선택 위계 미세 조정
|
||||||
|
|
||||||
|
### Phase 5. Weekly Review Reframe
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- total time보다 시작 성공률, 복귀율, 유지 패턴 중심으로 재설계
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 상세 기획 문서 작성 완료
|
||||||
|
- 1차 snapshot 구현 완료
|
||||||
|
- `/app -> /stats -> /app` entry flow 구현 완료
|
||||||
|
- `/space` complete 이후 secondary teaser 구현 완료
|
||||||
|
- pause recovery 집계 연결 완료
|
||||||
|
- 남은 것은 away recovery event schema, ritual fit, Free / Pro gating
|
||||||
|
|
||||||
|
문서:
|
||||||
|
|
||||||
|
- `../screens/stats/current/14_weekly_review_reframe_spec.md`
|
||||||
|
- `../flows/current/15_app_stats_entry_flow_spec.md`
|
||||||
|
|
||||||
|
### Phase 5.5. Session Routing / Paused Re-entry Alignment
|
||||||
|
|
||||||
|
문서:
|
||||||
|
|
||||||
|
- `../flows/current/18_paused_session_reentry_spec.md`
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- `running focus -> /space`
|
||||||
|
- `running break -> /space`
|
||||||
|
- `paused focus -> /app`
|
||||||
|
- explicit continue 이후 `/space` auto-resume
|
||||||
|
- paused session 위의 new start를 takeover flow로만 허용
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 상세 기획 문서 작성 완료
|
||||||
|
- Session Routing Contract 구현 완료
|
||||||
|
- `/app` Paused Resume Gate 구현 완료
|
||||||
|
- `/space` Auto-Resume Handoff 구현 완료
|
||||||
|
- `Paused Session Takeover Flow` 구현 완료
|
||||||
|
- 남은 것은 browser QA와 takeover 문구 polish
|
||||||
|
|
||||||
|
### Phase 6. Premium Ambience System
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- Portal급 감각 품질 확보
|
||||||
|
- 배경, 사운드, 전환, material의 art direction 통일
|
||||||
|
|
||||||
|
상태:
|
||||||
|
|
||||||
|
- 방향만 존재
|
||||||
|
- review 전후 어느 시점에 들어갈지 조정 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. “그 다음에 하려던 기획”은 무엇인가
|
||||||
|
|
||||||
|
Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가지였다.
|
||||||
|
|
||||||
|
1. `Refocus / Next Beat / Goal Complete`의 premium polish
|
||||||
|
2. `Pause`와 `Break`의 감정/구조 분리
|
||||||
|
|
||||||
|
즉, 원래 예정된 기획은 사라진 것이 아니다.
|
||||||
|
단지 순서가 아래처럼 정리된 것이다.
|
||||||
|
|
||||||
|
이전 예상 순서:
|
||||||
|
|
||||||
|
1. Refocus polish
|
||||||
|
2. Break refinement
|
||||||
|
3. Review
|
||||||
|
|
||||||
|
현재 확정 순서:
|
||||||
|
|
||||||
|
1. Refocus polish
|
||||||
|
2. Away / Return Recovery
|
||||||
|
3. Break refinement
|
||||||
|
4. Review
|
||||||
|
|
||||||
|
정리하면:
|
||||||
|
|
||||||
|
- **Away / Return은 중간에 끼어든 임시 아이디어가 아니라**
|
||||||
|
- `Break refinement`보다 먼저 처리해야 하는 선행 조건이다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 다음 세션에서의 실제 작업 순서
|
||||||
|
|
||||||
|
다음 작업은 아래 순서로 진행한다.
|
||||||
|
|
||||||
|
### Step 1. `/space` Refocus visual/behavior polish 마무리
|
||||||
|
|
||||||
|
포함:
|
||||||
|
|
||||||
|
- pause tray
|
||||||
|
- refocus tray
|
||||||
|
- next beat tray
|
||||||
|
- goal complete tray
|
||||||
|
|
||||||
|
완료 기준:
|
||||||
|
|
||||||
|
- recovery flow가 checklist나 planner처럼 보이지 않는다
|
||||||
|
- overlay는 한 번에 하나만 보인다
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
|
||||||
|
- 대부분 구현 완료
|
||||||
|
- 남은 것은 브라우저 기준 visual QA
|
||||||
|
|
||||||
|
### Step 2. Away / Return 구현
|
||||||
|
|
||||||
|
포함:
|
||||||
|
|
||||||
|
- `visibilitychange`
|
||||||
|
- `pagehide`
|
||||||
|
- sleep/wake delta 감지
|
||||||
|
- `Return` tray 추가
|
||||||
|
|
||||||
|
완료 기준:
|
||||||
|
|
||||||
|
- focus ended while away 시 standard break로 바로 가지 않는다
|
||||||
|
- return 상태에서 `지금부터 쉬기 / 이어가기 / 한 조각 다시 잡기`가 가능하다
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
|
||||||
|
- 구현 완료
|
||||||
|
- 남은 것은 브라우저 기준 state transition QA
|
||||||
|
|
||||||
|
### Step 3. Break 분리
|
||||||
|
|
||||||
|
포함:
|
||||||
|
|
||||||
|
- `Pause`, `Break`, `Return` 카피와 material 분리
|
||||||
|
- break를 recovery tone이 아니라 release tone으로 재설계
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
|
||||||
|
- 진행 중
|
||||||
|
- 다음 핵심 작업
|
||||||
|
|
||||||
|
### Step 4. Review 상세 기획
|
||||||
|
|
||||||
|
포함:
|
||||||
|
|
||||||
|
- started
|
||||||
|
- resumed
|
||||||
|
- completed
|
||||||
|
- recovery rate
|
||||||
|
- ritual success correlation
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
|
||||||
|
- 아직 시작 전
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 현재 진행률 한눈에 보기
|
||||||
|
|
||||||
|
### 완료
|
||||||
|
|
||||||
|
- Product Principles
|
||||||
|
- `/app` Entry Reframe
|
||||||
|
- Refocus core implementation
|
||||||
|
- Away / Return core implementation
|
||||||
|
|
||||||
|
### 진행 중
|
||||||
|
|
||||||
|
- `Pause / Break / Return` separation polish
|
||||||
|
- material 1차 분리 반영 완료
|
||||||
|
- copy / CTA hierarchy 2차 분리 반영 완료
|
||||||
|
- motion polish 1차 반영 완료
|
||||||
|
- 남은 것은 browser QA와 motion 미세 조정
|
||||||
|
|
||||||
|
### 다음 대기
|
||||||
|
|
||||||
|
- `Paused Session Re-entry` 구현
|
||||||
|
- `/space` recovery browser QA
|
||||||
|
- Weekly Review ritual fit highlight
|
||||||
|
- Premium Ambience System
|
||||||
|
|
||||||
|
### 방금 완료
|
||||||
|
|
||||||
|
- `Weekly Review Entry Flow` Slice 1
|
||||||
|
- `/app`에 low-emphasis weekly review entry를 추가
|
||||||
|
- 충분한 최근 7일 데이터가 있을 때만 `/stats` primary entry를 노출
|
||||||
|
- `Weekly Review Entry Flow` Slice 2
|
||||||
|
- `/stats` 마지막 CTA가 `/app?review=weekly&carryHint=...` handoff로 연결
|
||||||
|
- `/app`은 review-aware return hint를 먼저 보여주고, goal 입력은 그대로 사용자가 결정한다
|
||||||
|
- `Weekly Review Entry Flow` Slice 3
|
||||||
|
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다
|
||||||
|
- `/stats` 마지막 CTA와 `/app` return hint가 더 구체적인 next-session handoff로 바뀐다
|
||||||
|
- `Weekly Review Entry Flow` Slice 4
|
||||||
|
- `/space`에서 goal complete로 setup 상태로 돌아온 직후에만 secondary review entry가 보인다
|
||||||
|
- full review 강제 이동 없이 작은 후행 경로로 `/stats`를 연다
|
||||||
|
- 다음 구현은 weekly review의 ritual fit highlight 또는 deeper recovery schema다
|
||||||
|
- `Paused Session Re-entry` spec
|
||||||
|
- running / paused / break의 route policy를 한 문서에서 고정했다
|
||||||
|
- `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다
|
||||||
|
- paused session 위의 새 시작은 takeover flow로만 허용한다
|
||||||
|
- `Paused Session Re-entry` Slice 1
|
||||||
|
- `/app`은 running session을 감지하면 hero 대신 즉시 `/space`로 보낸다
|
||||||
|
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다
|
||||||
|
- 다음 구현은 paused resume gate와 auto-resume handoff다
|
||||||
|
- `Paused Session Re-entry` Slice 2-3
|
||||||
|
- `/app` paused 상태에 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 올렸다
|
||||||
|
- explicit continue 이후 `/space`는 자동 resume된다
|
||||||
|
- refocus handoff는 `/space?resume=refocus`로 들어가며, 진입 직후 refocus tray를 연다
|
||||||
|
- 다음 구현은 takeover flow다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 의사결정 규칙
|
||||||
|
|
||||||
|
새 기능이나 새 기획이 들어올 때는 아래 규칙으로 판단한다.
|
||||||
|
|
||||||
|
### 바로 진행해도 되는 경우
|
||||||
|
|
||||||
|
- 기존 코어 루프의 빈칸을 메우는 경우
|
||||||
|
- `start / immerse / refocus / return / complete` 중 하나를 선명하게 하는 경우
|
||||||
|
|
||||||
|
### 뒤로 미뤄야 하는 경우
|
||||||
|
|
||||||
|
- list/planner/task manager 느낌을 강하게 만드는 경우
|
||||||
|
- social, buddy, coworking 쪽으로 제품 무게를 옮기는 경우
|
||||||
|
- 화면에 오브젝트와 설정을 더 많이 추가하는 경우
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 한 줄 결론
|
||||||
|
|
||||||
|
현재 끼어든 `Away / Return` 기획은 옆길이 아니다.
|
||||||
|
|
||||||
|
> 이것은 `Refocus System`을 실제 사용자 행동에 맞게 완성하기 위해 반드시 먼저 처리해야 하는 다음 단계다.
|
||||||
|
|
||||||
|
즉, 다음에 원래 하려던 기획은 사라지지 않았고,
|
||||||
|
순서는 아래처럼 재정렬되었다.
|
||||||
|
|
||||||
|
1. Refocus polish
|
||||||
|
2. Away / Return Recovery
|
||||||
|
3. Break refinement
|
||||||
|
4. Weekly Review
|
||||||
|
|
||||||
|
현재 위치:
|
||||||
|
|
||||||
|
> 코어 루프 기준으로는 `4. Weekly Review`의 entry flow 구현까지 마친 상태이고,
|
||||||
|
> 운영 기준으로는 이제 `Product Alignment Audit`을 통해 core loop 전반의 기획-구현 정합성을 전수 점검하는 단계다.
|
||||||
602
docs/product/16_product_alignment_audit_plan.md
Normal file
602
docs/product/16_product_alignment_audit_plan.md
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
# 16. Product Alignment Audit Plan
|
||||||
|
|
||||||
|
Last Updated: 2026-03-15
|
||||||
|
|
||||||
|
이 문서는 VibeRoom 전체에서 **기획과 구현이 어긋난 부분을 체계적으로 찾아내고, 실제 수정 작업까지 연결하는 실행 계획**이다.
|
||||||
|
|
||||||
|
목표는 단순하다.
|
||||||
|
|
||||||
|
> 우연히 발견한 불일치를 그때그때 고치는 방식에서 벗어나,
|
||||||
|
> 어떤 세션과 어떤 에이전트가 들어와도 같은 기준으로 앱 전체를 점검하고 닫을 수 있는 운영 체계를 만든다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 이 문서가 필요한가
|
||||||
|
|
||||||
|
최근 점검에서 이미 아래 문제가 반복적으로 드러났다.
|
||||||
|
|
||||||
|
- 카피는 `break`를 약속하는데 실제 동작은 `pause`인 경우
|
||||||
|
- spec은 `/app`이 review의 primary entry라고 적혀 있는데 구현에서는 resume 상태에서 review entry가 사라지는 경우
|
||||||
|
- `goal click`, `수정`, `expand/collapse`의 역할 분리가 코드와 문서에서 다르게 남는 경우
|
||||||
|
- `/stats -> /app` handoff가 실제 행동을 바꾸지 않는데 문구는 개인화된 추천을 약속하는 경우
|
||||||
|
|
||||||
|
즉 현재 문제는 “버그가 많다”가 아니라,
|
||||||
|
**기획 / 카피 / 상태 모델 / 실제 코드 / 문서가 동시에 drift할 수 있는 구조**라는 점이다.
|
||||||
|
|
||||||
|
이 audit plan의 목적은 아래 4개다.
|
||||||
|
|
||||||
|
1. source of truth를 고정한다
|
||||||
|
2. route / state / copy / BM claim을 같은 표로 점검한다
|
||||||
|
3. 발견된 불일치를 severity와 수정 단위로 즉시 분류한다
|
||||||
|
4. 수정 후 문서와 QA까지 같은 라운드에서 닫는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Audit의 정의
|
||||||
|
|
||||||
|
이 문서에서 말하는 `alignment audit`는 아래 5개를 동시에 보는 점검이다.
|
||||||
|
|
||||||
|
### 1. Product Alignment
|
||||||
|
|
||||||
|
- 제품 원칙과 실제 기능이 맞는가
|
||||||
|
- single-goal, single-microStep, commitment-first 원칙이 지켜지는가
|
||||||
|
|
||||||
|
### 2. Flow Alignment
|
||||||
|
|
||||||
|
- 사용자가 실제로 밟는 경로가 기획된 유저 플로우와 맞는가
|
||||||
|
- entry / recovery / complete / review의 연결이 끊기지 않는가
|
||||||
|
|
||||||
|
### 3. Copy Alignment
|
||||||
|
|
||||||
|
- 화면 문구가 실제 동작을 과장하거나 왜곡하지 않는가
|
||||||
|
- 초심자도 용어 없이 이해 가능한가
|
||||||
|
|
||||||
|
### 4. State Alignment
|
||||||
|
|
||||||
|
- pause / break / return / complete / resume 같은 상태 의미가 코드와 카피에서 일치하는가
|
||||||
|
|
||||||
|
### 5. Business Alignment
|
||||||
|
|
||||||
|
- Free / Pro 가치 제안이 실제 제품 행동으로 느껴지는가
|
||||||
|
- “된다고 쓰여 있지만 실제로는 안 되는” BM성 약속이 없는가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Source of Truth 우선순위
|
||||||
|
|
||||||
|
Audit 중 충돌이 생기면 아래 순서로 판단한다.
|
||||||
|
|
||||||
|
1. `product_principles.md`
|
||||||
|
2. `/Users/ijeongmin/Desktop/corpi/viberoom/current_context.md`
|
||||||
|
3. route/flow 관련 상세 spec
|
||||||
|
- `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||||
|
- `../flows/current/10_refocus_system_spec.md`
|
||||||
|
- `../flows/current/11_away_return_recovery_spec.md`
|
||||||
|
- `../screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`
|
||||||
|
- `../screens/stats/current/14_weekly_review_reframe_spec.md`
|
||||||
|
- `../flows/current/15_app_stats_entry_flow_spec.md`
|
||||||
|
4. `90_current_state.md`
|
||||||
|
5. 실제 코드
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- 코드가 source of truth가 아니다
|
||||||
|
- 하지만 문서가 낡았고 코드가 더 최근 의도를 반영한다면, 그 차이를 `finding`으로 기록한 뒤 문서를 업데이트한다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Audit 범위
|
||||||
|
|
||||||
|
### 4.1 Route 범위
|
||||||
|
|
||||||
|
반드시 본다.
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `/login`
|
||||||
|
- `/app`
|
||||||
|
- `/space`
|
||||||
|
- `/stats`
|
||||||
|
- `/settings`
|
||||||
|
- `/admin`
|
||||||
|
|
||||||
|
### 4.2 Core Product Flow 범위
|
||||||
|
|
||||||
|
반드시 본다.
|
||||||
|
|
||||||
|
- Landing -> Auth -> `/app`
|
||||||
|
- `/app` new start
|
||||||
|
- `/app` resume
|
||||||
|
- `/space` focus
|
||||||
|
- `/space` pause
|
||||||
|
- `/space` return
|
||||||
|
- `/space` next beat
|
||||||
|
- `/space` goal complete
|
||||||
|
- `/space` complete -> setup
|
||||||
|
- `/app -> /stats -> /app`
|
||||||
|
- paywall / Pro handoff
|
||||||
|
|
||||||
|
### 4.3 Cross-cutting 범위
|
||||||
|
|
||||||
|
반드시 본다.
|
||||||
|
|
||||||
|
- i18n copy
|
||||||
|
- query param handoff
|
||||||
|
- API contract / optimistic UI
|
||||||
|
- plan tier gating
|
||||||
|
- toast / reminder / empty state
|
||||||
|
- docs drift
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Audit 원칙
|
||||||
|
|
||||||
|
### 1. 화면이 아니라 “행동 단위”로 점검한다
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- `Goal Complete` 화면 자체를 보는 게 아니라
|
||||||
|
- `사용자가 목표를 끝냈다고 느끼는 순간 -> 선택 -> 결과 상태` 전체를 본다
|
||||||
|
|
||||||
|
### 2. copy와 behavior를 반드시 함께 본다
|
||||||
|
|
||||||
|
카피만 맞고 동작이 다르면 실패다.
|
||||||
|
동작은 맞는데 카피가 과장되면 그것도 실패다.
|
||||||
|
|
||||||
|
### 3. Free / Pro 약속은 더 엄격하게 본다
|
||||||
|
|
||||||
|
BM에 연결된 카피는 일반 카피보다 더 엄격하게 검증한다.
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- “추천 ritual로 돌아가기”
|
||||||
|
- “방금 끝낸 흐름까지 담아 review”
|
||||||
|
- “주간 review가 다음 세션을 돕는다”
|
||||||
|
|
||||||
|
### 4. 한 라운드에서 `finding -> fix -> docs -> commit`까지 닫는다
|
||||||
|
|
||||||
|
발견만 하고 쌓아두지 않는다.
|
||||||
|
|
||||||
|
### 5. 브라우저 QA 없이 닫지 않는다
|
||||||
|
|
||||||
|
`tsc`, `eslint` 통과는 필수지만 충분조건이 아니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Finding 분류 기준
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
제품 의미가 뒤틀리는 불일치
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- break로 쓰였는데 실제로 break가 아님
|
||||||
|
- primary entry가 없어짐
|
||||||
|
- Pro value를 약속하지만 실제로 작동하지 않음
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
사용자 플로우가 헷갈리거나 약속이 약해지는 불일치
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- 문구는 맞지만 진입점이 숨겨짐
|
||||||
|
- 2단계 flow인데 1단계처럼 보임
|
||||||
|
- recovery 상태가 시각적으로 구분되지 않음
|
||||||
|
|
||||||
|
### P3
|
||||||
|
|
||||||
|
문서 / 카피 / 접근성 / 잔여 코드 드리프트
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- 오래된 spec 표현
|
||||||
|
- dead i18n key
|
||||||
|
- aria-label과 가시 UI가 어긋남
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 실제 Audit 방법
|
||||||
|
|
||||||
|
Audit은 아래 6단계로 진행한다.
|
||||||
|
|
||||||
|
### Phase 0. Baseline Freeze
|
||||||
|
|
||||||
|
산출물:
|
||||||
|
|
||||||
|
- 현재 기준 문서 목록
|
||||||
|
- 현재 route / widget ownership map
|
||||||
|
- 현재 work order
|
||||||
|
|
||||||
|
실행:
|
||||||
|
|
||||||
|
- `current_context.md`
|
||||||
|
- `90_current_state.md`
|
||||||
|
- `./12_core_loop_execution_roadmap.md`
|
||||||
|
- route map / session brief 확인
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 무엇이 기준인지 먼저 잠근다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1. Route & Flow Inventory
|
||||||
|
|
||||||
|
각 route마다 아래를 표로 만든다.
|
||||||
|
|
||||||
|
- route
|
||||||
|
- intended role
|
||||||
|
- main CTA
|
||||||
|
- secondary CTA
|
||||||
|
- entry source
|
||||||
|
- exit target
|
||||||
|
- hidden states
|
||||||
|
- Pro-specific behavior
|
||||||
|
|
||||||
|
산출물:
|
||||||
|
|
||||||
|
- `route-flow matrix`
|
||||||
|
|
||||||
|
점검 질문:
|
||||||
|
|
||||||
|
- 사용자는 이 route에 왜 오는가
|
||||||
|
- 여기서 무엇을 해야 하는가
|
||||||
|
- 다음 화면은 어디인가
|
||||||
|
- 실제 코드가 이 흐름을 유지하는가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2. State Contract Audit
|
||||||
|
|
||||||
|
`/space`와 `/app`의 상태 전이를 표로 만든다.
|
||||||
|
|
||||||
|
필수 상태:
|
||||||
|
|
||||||
|
- setup
|
||||||
|
- focus
|
||||||
|
- paused
|
||||||
|
- return
|
||||||
|
- refocus
|
||||||
|
- next-beat
|
||||||
|
- complete-choice
|
||||||
|
- complete-next
|
||||||
|
- break
|
||||||
|
- review-entry
|
||||||
|
|
||||||
|
각 상태마다 본다.
|
||||||
|
|
||||||
|
- state 이름
|
||||||
|
- 사용자가 이해하는 의미
|
||||||
|
- 실제 trigger
|
||||||
|
- visible UI
|
||||||
|
- exit actions
|
||||||
|
- side effects
|
||||||
|
|
||||||
|
산출물:
|
||||||
|
|
||||||
|
- `state contract matrix`
|
||||||
|
|
||||||
|
핵심 목적:
|
||||||
|
|
||||||
|
- 이름만 있고 실제 의미가 다른 상태를 찾는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3. Copy / Behavior Audit
|
||||||
|
|
||||||
|
각 핵심 CTA에 대해 아래를 점검한다.
|
||||||
|
|
||||||
|
- label
|
||||||
|
- user promise
|
||||||
|
- actual effect
|
||||||
|
- mismatch 여부
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
- `잠시 비우기`
|
||||||
|
- `쉬기 이어가기`
|
||||||
|
- `다음 목표로 바로 시작`
|
||||||
|
- `주간 review 보기`
|
||||||
|
- `추천 ritual과 함께 /app 돌아가기`
|
||||||
|
|
||||||
|
산출물:
|
||||||
|
|
||||||
|
- `copy-behavior mismatch list`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4. Business Model Audit
|
||||||
|
|
||||||
|
Free / Pro를 아래 기준으로 본다.
|
||||||
|
|
||||||
|
- user sees
|
||||||
|
- user clicks
|
||||||
|
- actual unlocked behavior
|
||||||
|
- value perception
|
||||||
|
- overclaim risk
|
||||||
|
|
||||||
|
반드시 보는 곳:
|
||||||
|
|
||||||
|
- paywall copy
|
||||||
|
- `/stats` Pro carry-forward
|
||||||
|
- `/app` teaser / return hint
|
||||||
|
- review insight / ritual recommendation
|
||||||
|
|
||||||
|
산출물:
|
||||||
|
|
||||||
|
- `BM promise audit`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5. Browser Journey Audit
|
||||||
|
|
||||||
|
이 단계는 실제 브라우저에서 한다.
|
||||||
|
|
||||||
|
필수 시나리오:
|
||||||
|
|
||||||
|
1. 로그인 후 `/app` 첫 진입
|
||||||
|
2. `/app`에서 새 goal 시작
|
||||||
|
3. `/app` resume 상태
|
||||||
|
4. `/space` pause -> refocus -> resume
|
||||||
|
5. `/space` away -> return(focus)
|
||||||
|
6. `/space` focus ended while away -> return(break)
|
||||||
|
7. `/space` microStep done -> next beat
|
||||||
|
8. `/space` goal complete choice
|
||||||
|
9. `/space` goal complete next
|
||||||
|
10. `/space` complete -> setup -> review teaser
|
||||||
|
11. `/app` weekly review entry -> `/stats` -> `/app`
|
||||||
|
12. Pro 상태 handoff
|
||||||
|
|
||||||
|
각 시나리오마다 본다.
|
||||||
|
|
||||||
|
- first meaningful paint
|
||||||
|
- primary CTA visibility
|
||||||
|
- copy comprehension
|
||||||
|
- next action clarity
|
||||||
|
- state transition correctness
|
||||||
|
- dismiss behavior
|
||||||
|
- mobile / desktop 차이
|
||||||
|
|
||||||
|
산출물:
|
||||||
|
|
||||||
|
- `browser QA findings`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6. Fix Closure
|
||||||
|
|
||||||
|
각 finding은 아래 단위로 닫는다.
|
||||||
|
|
||||||
|
- finding 요약
|
||||||
|
- severity
|
||||||
|
- affected files
|
||||||
|
- fix approach
|
||||||
|
- docs to update
|
||||||
|
- validation commands
|
||||||
|
- manual QA notes
|
||||||
|
- commit hash
|
||||||
|
|
||||||
|
이 단계까지 끝나야 “수정 완료”다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 실제 실행 순서
|
||||||
|
|
||||||
|
앱 전체 audit은 아래 순서로 진행한다.
|
||||||
|
|
||||||
|
### Slice A. Core Loop Alignment
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- `/app`
|
||||||
|
- `/space`
|
||||||
|
- `/stats`
|
||||||
|
|
||||||
|
우선 점검:
|
||||||
|
|
||||||
|
- entry
|
||||||
|
- recovery
|
||||||
|
- completion
|
||||||
|
- review handoff
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- VibeRoom의 돈 되는 핵심 가치가 여기 있다
|
||||||
|
|
||||||
|
### Slice B. Monetization Alignment
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- paywall
|
||||||
|
- plan tier
|
||||||
|
- Pro copy
|
||||||
|
- Pro actual behavior
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- BM 과장은 제품 신뢰를 가장 빠르게 해친다
|
||||||
|
|
||||||
|
### Slice C. Shell & Supporting Routes
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `/login`
|
||||||
|
- `/settings`
|
||||||
|
- `/admin`
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- 핵심 루프보다 우선순위는 낮지만, 전체 완성도와 신뢰에 영향이 크다
|
||||||
|
|
||||||
|
### Slice D. Docs & Regression Guard
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- `current_context.md`
|
||||||
|
- `90_current_state.md`
|
||||||
|
- detailed specs
|
||||||
|
- lint / dead copy / route map
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- 다음 세션에서 다시 어긋나지 않게 만드는 단계다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 추천 산출물
|
||||||
|
|
||||||
|
이 audit을 실제로 굴릴 때는 아래 3개 파일이 필요하다.
|
||||||
|
|
||||||
|
### 1. 기준 문서
|
||||||
|
|
||||||
|
- 이 문서 `./16_product_alignment_audit_plan.md`
|
||||||
|
|
||||||
|
### 2. 실제 finding ledger
|
||||||
|
|
||||||
|
추천 파일:
|
||||||
|
|
||||||
|
- `./17_product_alignment_findings.md`
|
||||||
|
|
||||||
|
형식:
|
||||||
|
|
||||||
|
- ID
|
||||||
|
- severity
|
||||||
|
- route / flow
|
||||||
|
- product promise
|
||||||
|
- actual behavior
|
||||||
|
- affected files
|
||||||
|
- fix status
|
||||||
|
|
||||||
|
### 3. execution checklist
|
||||||
|
|
||||||
|
추천 위치:
|
||||||
|
|
||||||
|
- `work.md`
|
||||||
|
|
||||||
|
형식:
|
||||||
|
|
||||||
|
- 이번 slice에서 확인할 시나리오
|
||||||
|
- 수정 범위
|
||||||
|
- 제외 범위
|
||||||
|
- 완료 조건
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 이 audit을 어떻게 실행할 것인가
|
||||||
|
|
||||||
|
### 라운드 단위 규칙
|
||||||
|
|
||||||
|
한 라운드는 아래 순서로만 진행한다.
|
||||||
|
|
||||||
|
1. 이번 slice 범위 결정
|
||||||
|
2. static audit
|
||||||
|
3. browser audit
|
||||||
|
4. findings 정리
|
||||||
|
5. fix
|
||||||
|
6. docs update
|
||||||
|
7. validation
|
||||||
|
8. commit
|
||||||
|
|
||||||
|
### 각 라운드 출력 형식
|
||||||
|
|
||||||
|
항상 아래 형식으로 닫는다.
|
||||||
|
|
||||||
|
- 추가
|
||||||
|
- 수정
|
||||||
|
- 삭제
|
||||||
|
- 검증
|
||||||
|
- 커밋
|
||||||
|
|
||||||
|
### 커밋 규칙
|
||||||
|
|
||||||
|
- 한 주제 = 한 커밋
|
||||||
|
- 기획 불일치 정리는 `fix(flow): ...`
|
||||||
|
- 문서 중심 라운드는 `docs(product): ...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 바로 다음 실행 계획
|
||||||
|
|
||||||
|
### 실행 1. `./17_product_alignment_findings.md` 생성
|
||||||
|
|
||||||
|
범위:
|
||||||
|
|
||||||
|
- 현재까지 이미 드러난 core loop mismatch를 먼저 기록
|
||||||
|
|
||||||
|
포함:
|
||||||
|
|
||||||
|
- break semantics
|
||||||
|
- review entry visibility
|
||||||
|
- review teaser overclaim
|
||||||
|
- Pro handoff realism
|
||||||
|
- docs drift
|
||||||
|
|
||||||
|
### 실행 2. Slice A static audit
|
||||||
|
|
||||||
|
대상:
|
||||||
|
|
||||||
|
- `/app`
|
||||||
|
- `/space`
|
||||||
|
- `/stats`
|
||||||
|
|
||||||
|
목표:
|
||||||
|
|
||||||
|
- route-flow matrix
|
||||||
|
- state contract matrix
|
||||||
|
- copy-behavior mismatch list
|
||||||
|
|
||||||
|
### 실행 3. Slice A browser audit
|
||||||
|
|
||||||
|
대상 시나리오:
|
||||||
|
|
||||||
|
1. `/app` first entry
|
||||||
|
2. `/app` resume
|
||||||
|
3. `/app -> /stats -> /app`
|
||||||
|
4. `/space` pause / return / complete
|
||||||
|
5. `/space complete -> setup -> review teaser`
|
||||||
|
|
||||||
|
### 실행 4. findings fix round
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
- P1 먼저
|
||||||
|
- 그 다음 P2
|
||||||
|
- P3는 같은 파일을 만질 때 함께 정리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 완료 기준
|
||||||
|
|
||||||
|
Alignment audit이 제대로 굴러간 상태는 아래와 같다.
|
||||||
|
|
||||||
|
- 중요한 CTA가 실제 동작을 과장하지 않는다
|
||||||
|
- primary / secondary entry가 문서와 코드에서 동일하다
|
||||||
|
- pause / break / return / complete의 의미가 사용자 기준으로도 분리된다
|
||||||
|
- Free / Pro 가치 제안이 실제 행동으로 느껴진다
|
||||||
|
- 다음 세션/에이전트가 문서를 읽고도 현재 제품 상태를 오해하지 않는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 절대 하지 말아야 할 것
|
||||||
|
|
||||||
|
- 브라우저 QA 없이 문서만 보고 “정상”이라고 판단
|
||||||
|
- 카피만 바꾸고 실제 상태 모델은 그대로 두기
|
||||||
|
- BM 문구를 구현보다 앞서 나가게 두기
|
||||||
|
- `current_context.md`를 나중에 한꺼번에 갱신하기
|
||||||
|
- 한 라운드에 너무 많은 route를 동시에 수정하기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 최종 판단
|
||||||
|
|
||||||
|
VibeRoom이 premium product가 되려면,
|
||||||
|
UI refinement만으로는 부족하다.
|
||||||
|
|
||||||
|
정말 중요한 것은:
|
||||||
|
|
||||||
|
> 사용자가 읽는 문구, 누르는 행동, 실제로 일어나는 상태 변화,
|
||||||
|
> 그리고 그걸 설명하는 문서가 모두 같은 제품을 말하고 있어야 한다.
|
||||||
|
|
||||||
|
이 audit plan은 그 정합성을 제품 운영의 기본 프로세스로 만들기 위한 문서다.
|
||||||
132
docs/product/17_product_alignment_findings.md
Normal file
132
docs/product/17_product_alignment_findings.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# 17. Product Alignment Findings
|
||||||
|
|
||||||
|
Last Updated: 2026-03-15
|
||||||
|
|
||||||
|
이 문서는 `./16_product_alignment_audit_plan.md`를 실제로 운영하기 위한 **findings ledger**다.
|
||||||
|
|
||||||
|
목표는 단순하다.
|
||||||
|
|
||||||
|
> VibeRoom의 core loop에서 발견된 기획-구현 불일치를
|
||||||
|
> severity, 실제 영향, 수정 상태 기준으로 누적 관리한다.
|
||||||
|
|
||||||
|
이 파일은 단순 회고 메모가 아니다.
|
||||||
|
|
||||||
|
- 어떤 문제가 있었는지
|
||||||
|
- 무엇을 약속하고 있었는지
|
||||||
|
- 실제로는 어떻게 동작했는지
|
||||||
|
- 어느 파일을 건드렸는지
|
||||||
|
- 지금은 열린 문제인지, 수정됐지만 브라우저 확인이 남았는지
|
||||||
|
|
||||||
|
를 한 번에 보게 만드는 운영 문서다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 상태 정의
|
||||||
|
|
||||||
|
### `open`
|
||||||
|
|
||||||
|
- 아직 수정 전
|
||||||
|
- 다음 작업 라운드에서 다뤄야 함
|
||||||
|
|
||||||
|
### `fixed-awaiting-browser`
|
||||||
|
|
||||||
|
- 코드와 문서는 정리됨
|
||||||
|
- 브라우저 기준 최종 QA가 남아 있음
|
||||||
|
|
||||||
|
### `closed`
|
||||||
|
|
||||||
|
- 코드 / 문서 / 브라우저 확인까지 끝남
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 범위
|
||||||
|
|
||||||
|
이번 ledger v1은 아래 core loop 범위만 다룬다.
|
||||||
|
|
||||||
|
- `/app`
|
||||||
|
- `/space`
|
||||||
|
- `/stats`
|
||||||
|
- review handoff
|
||||||
|
- break / pause / return semantics
|
||||||
|
- Pro personalized handoff
|
||||||
|
|
||||||
|
`/settings`, `/admin`, landing shell의 정합성 감사는 다음 slice로 미룬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Findings
|
||||||
|
|
||||||
|
| ID | Severity | Area | Product Promise | Actual Behavior | Affected Files | Status | Next Action |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| ALN-001 | P1 | `/space` Goal Complete / Break semantics | `잠깐 쉬기`는 블록을 닫고 break로 넘어가는 것처럼 읽힘 | 실제로는 overlay만 닫고 reminder만 예약되어 break 의미가 깨졌음 | `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`, `src/shared/i18n/messages/space.ts` | fixed-awaiting-browser | `잠시 비우기 -> pause + reminder`가 실제 체감상도 맞는지 브라우저 확인 |
|
||||||
|
| ALN-002 | P1 | `/app` Weekly Review primary entry | `/app`이 Weekly Review의 primary entry라고 정의됨 | current session이 있으면 review entry가 완전히 사라져 primary entry가 끊겼음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | fixed-awaiting-browser | resume 상태에서 review entry 발견성과 우선순위 확인 |
|
||||||
|
| ALN-003 | P2 | `/space` secondary review teaser | `방금 끝낸 흐름까지 review에 담아둘까요?`처럼 read-after-write를 약속했음 | 실제로는 generic `/stats`만 열고, 방금 끝낸 흐름을 별도 handoff하지 않았음 | `src/shared/i18n/messages/space.ts`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` | fixed-awaiting-browser | setup drawer teaser가 과장 없이 자연스럽게 읽히는지 확인 |
|
||||||
|
| ALN-004 | P1 | Pro personalized handoff | `/stats`에서 추천 atmosphere/duration으로 돌아간다고 말했음 | 실제로는 `/app` 문구만 바뀌고 start behavior는 기본값 그대로였음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/shared/i18n/messages/app.ts` | fixed-awaiting-browser | `entryAtmosphereId + entryDurationMinutes`가 실제 start 값으로 적용되는지 검증 |
|
||||||
|
| ALN-005 | P2 | `/space` intent card interaction | rail, edit, expand/collapse의 역할이 분리돼야 함 | goal 클릭과 edit 진입이 섞여 예측 가능성이 낮았음 | `src/widgets/space-focus-hud/ui/IntentCapsule.tsx`, `docs/screens/space/current/13_space_intent_card_collapsed_expanded_spec.md` | fixed-awaiting-browser | desktop/mobile에서 expand와 edit 구분이 분명한지 확인 |
|
||||||
|
| ALN-006 | P2 | `/space` Goal Complete 2단계 인지 | 1단계 choice와 2단계 next 입력이 명확히 구분돼야 함 | 사용자 입장에서는 `돌아가기 / 다음 목표로 바로 시작` 화면이 top-level 분기처럼 읽히기 쉬움 | `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/shared/i18n/messages/space.ts` | open | choice view와 next view의 제목, 구조, motion, context label을 더 분리하는 기획 필요 |
|
||||||
|
| ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 |
|
||||||
|
| ALN-008 | P1 | `잠시 비우기`와 `Break`의 제품 의미 | break는 reward/reset, pause는 recovery로 분리돼야 함 | 현재는 카피와 트레이는 개선됐지만, 제품 차원의 최종 정의와 시각 분리까지 완전히 닫히진 않았음 | `docs/flows/current/10_refocus_system_spec.md`, `docs/flows/current/11_away_return_recovery_spec.md`, `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/widgets/space-focus-hud/ui/ReturnPrompt.tsx` | open | `잠시 비우기`, active break, return(break)를 하나의 최종 state model로 재정의 |
|
||||||
|
| ALN-009 | P3 | Spec / current-state drift | 다음 세션 문서가 실제 구현과 맞아야 함 | intent card, goal complete, review entry 관련 오래된 표현이 여러 spec에 남아 있었음 | `docs/flows/current/10_refocus_system_spec.md`, `docs/screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`, `docs/90_current_state.md`, `docs/session_brief.md`, `../../current_context.md` | fixed-awaiting-browser | 이후 라운드부터는 fix와 문서 갱신을 같은 커밋에서 닫는지 점검 |
|
||||||
|
| ALN-010 | P1 | paused session 재진입 정책 | running은 바로 `/space`, paused는 `/app` resume gate, explicit continue 이후에는 자동 resume이어야 함 | Session Routing Contract, paused resume gate, auto-resume handoff, takeover flow까지 구현됐다. 남은 것은 browser QA와 takeover wording polish이다 | `docs/flows/current/18_paused_session_reentry_spec.md`, `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`, `src/features/focus-session/model/useFocusSessionEngine.ts` | fixed-awaiting-browser | paused resume / refocus / takeover 3경로를 브라우저에서 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 요약 판단
|
||||||
|
|
||||||
|
### 현재 이미 수습된 축
|
||||||
|
|
||||||
|
- break/pause 카피와 실제 동작의 큰 충돌
|
||||||
|
- `/app` resume 상태의 review entry 공백
|
||||||
|
- `/space` review teaser 과장 카피
|
||||||
|
- Pro ritual handoff의 실동작 부재
|
||||||
|
- intent card interaction ambiguity 일부
|
||||||
|
|
||||||
|
### 아직 열린 축
|
||||||
|
|
||||||
|
- `Goal Complete` 2단계 구조의 인지 부담
|
||||||
|
- low-data / resume 상태에서 review discoverability
|
||||||
|
- `잠시 비우기`와 진짜 break의 최종 state model
|
||||||
|
- paused session의 route / resume / takeover contract
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 다음 실행 우선순위
|
||||||
|
|
||||||
|
### 1. Static audit continuation
|
||||||
|
|
||||||
|
다음 문서가 필요하다.
|
||||||
|
|
||||||
|
- route-flow matrix
|
||||||
|
- state contract matrix
|
||||||
|
|
||||||
|
즉, 다음 라운드는 `설명 가능한 표`를 만드는 단계다.
|
||||||
|
|
||||||
|
### 2. Browser audit
|
||||||
|
|
||||||
|
반드시 아래 순서로 확인한다.
|
||||||
|
|
||||||
|
1. `/app` no-session entry
|
||||||
|
2. `/app` resume + review entry
|
||||||
|
3. `/app -> /stats -> /app`
|
||||||
|
4. `/space` goal complete choice
|
||||||
|
5. `/space` goal complete next
|
||||||
|
6. `/space` 잠시 비우기
|
||||||
|
7. `/space` return(focus)
|
||||||
|
8. `/space` return(break)
|
||||||
|
9. `/space` complete -> setup -> review teaser
|
||||||
|
|
||||||
|
### 3. 다음 수정 라운드의 우선순위
|
||||||
|
|
||||||
|
1. ALN-010
|
||||||
|
2. ALN-008
|
||||||
|
3. ALN-006
|
||||||
|
4. ALN-007
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 운영 규칙
|
||||||
|
|
||||||
|
- 새로운 mismatch를 찾으면 이 문서에 먼저 기록한다
|
||||||
|
- severity 없이 추가하지 않는다
|
||||||
|
- `fixed-awaiting-browser`는 브라우저 확인 전까지 닫지 않는다
|
||||||
|
- 실제 fix가 끝나면 관련 commit hash를 이 문서에 추가한다
|
||||||
22
docs/screens/app/README.md
Normal file
22
docs/screens/app/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# `/app` Docs
|
||||||
|
|
||||||
|
`/app` 화면 관련 문서를 모아둔 폴더다.
|
||||||
|
|
||||||
|
## current
|
||||||
|
|
||||||
|
지금 `/app` 작업에 직접 영향을 주는 source-of-truth 문서다.
|
||||||
|
|
||||||
|
- [19_app_atmosphere_entry_spec.md](./current/19_app_atmosphere_entry_spec.md)
|
||||||
|
|
||||||
|
## archive
|
||||||
|
|
||||||
|
이전 `/app` 방향이나 구현 완료 후 더 이상 기준 문서가 아닌 자료다.
|
||||||
|
|
||||||
|
- [08_app_reframe_strategy.md](./archive/08_app_reframe_strategy.md)
|
||||||
|
|
||||||
|
## 같이 볼 문서
|
||||||
|
|
||||||
|
`/app`은 화면 자체 문서 외에도 cross-screen flow 영향을 많이 받는다.
|
||||||
|
|
||||||
|
- [../../flows/current/15_app_stats_entry_flow_spec.md](../../flows/current/15_app_stats_entry_flow_spec.md)
|
||||||
|
- [../../flows/current/18_paused_session_reentry_spec.md](../../flows/current/18_paused_session_reentry_spec.md)
|
||||||
61
docs/screens/app/archive/08_app_reframe_strategy.md
Normal file
61
docs/screens/app/archive/08_app_reframe_strategy.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# VibeRoom `/app` 프로덕트 고도화 및 수익화 기획안
|
||||||
|
|
||||||
|
## 1. 문제 정의: 왜 `/app`의 존재 의의가 애매해졌는가?
|
||||||
|
현재 `/app` 화면은 단순히 '목표를 하나 입력하고 `/space`로 넘어가는 정거장' 역할만 수행하고 있습니다.
|
||||||
|
사용자 입장에서는 "어차피 `/space`에서 타이머 돌리고 일할 건데, 굳이 `/app`이라는 별도 화면에서 목표 하나만 달랑 쳐야 하나?"라는 기능적 회의감이 들 수밖에 없습니다. 즉, **경험적 가치(UX Value) 없이 Depth만 하나 늘어난 상태**입니다.
|
||||||
|
|
||||||
|
## 2. 타겟 인사이트: ADHD & 프리랜서의 심리
|
||||||
|
이 서비스의 핵심 타겟은 **ADHD 성향을 가진 사람들과 혼자 일하는 프리랜서**입니다. 이들의 가장 큰 페인포인트는 다음과 같습니다.
|
||||||
|
1. **시작의 두려움 (High Activation Energy):** '무엇을 해야 할지'는 알지만, 첫 단추를 꿰는 것을 극도로 미룹니다.
|
||||||
|
2. **작업 기억 용량 초과 (RAM Overload):** 머릿속에 해야 할 일과 잡념이 뒤엉켜 있어 하나에 집중하지 못합니다.
|
||||||
|
3. **도파민 갈구 (Dopamine Seeking):** 즉각적인 피드백이나 보상이 없으면 쉽게 지루해하고 이탈합니다.
|
||||||
|
|
||||||
|
## 3. 핵심 전략: `/app`을 '단순 입력창'에서 **'몰입을 위한 의식(Ritual)의 공간'**으로 재정의
|
||||||
|
최고의 서비스는 사용자에게 '행동'을 요구하기 전에 '감정'을 만져줍니다. `/app`은 투두리스트(To-do)를 적는 곳이 아닙니다. **복잡한 현실에서 벗어나, 고도의 집중 상태(`/space`)로 들어가기 전 마음을 다잡고 준비 운동을 하는 '감압실(Decompression Chamber)'**이 되어야 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `/app` 공간의 4가지 핵심 기능 기획
|
||||||
|
|
||||||
|
### A. Brain Dump (뇌 비우기) & The One Thing
|
||||||
|
ADHD 유저에게 "지금 할 목표 하나만 적으세요"라고 하면 오히려 압박감을 느낍니다.
|
||||||
|
- **기능:** 머릿속에 맴도는 모든 잡념과 해야 할 일들을 일단 마구잡이로 쏟아내게 합니다(Brain Dump).
|
||||||
|
- **UX:** 쏟아낸 여러 항목 중 **"지금 당장 딱 하나만 쥐고 `/space`로 들어갑시다. 나머지는 저희가 안전하게 보관해 둘게요"**라며 단 하나의 목표(The One Thing)를 선택하게 유도합니다.
|
||||||
|
- **효과:** 머릿속 RAM을 비워줌으로써 인지적 과부하를 해결하고 안도감을 줍니다.
|
||||||
|
|
||||||
|
### B. Micro-Stepping (초소형 첫 단계 쪼개기)
|
||||||
|
"제안서 작성하기"라는 목표는 너무 거대해서 시작을 미루게 만듭니다.
|
||||||
|
- **기능:** 유저가 큰 목표를 선택했을 때, "이 목표를 위해 지금 당장 할 수 있는 5분짜리 행동은 무엇인가요?"라고 묻습니다.
|
||||||
|
- **UX:** "제안서 폴더 열기", "노션 새 페이지 만들기" 수준으로 목표를 극단적으로 잘게 쪼개도록 유도합니다.
|
||||||
|
- **효과:** 시작의 허들(Activation Energy)을 바닥까지 낮춰줍니다.
|
||||||
|
|
||||||
|
### C. Commitment Ritual (몰입 세팅과 선언)
|
||||||
|
`/space`에 들어가서 환경을 바꾸는 것이 아니라, `/app`에서 오늘 나를 도와줄 환경을 선택하고 '입장'하는 것이 하나의 의식이 되어야 합니다.
|
||||||
|
- **기능:** 목표를 정한 후, 오늘 나의 기분에 맞는 **Vibe(배경 씬 + 백색소음)**와 **Pace(뽀모도로 25분 / 딥워크 50분 등)**를 선택합니다.
|
||||||
|
- **UX:** 세팅을 마치고 "집중 모드 진입하기" 버튼을 누를 때, 시각적/청각적인 전환 효과와 함께 비행기가 이륙하듯 `/space`로 부드럽게 빨려 들어가는 연출을 줍니다.
|
||||||
|
|
||||||
|
### D. Momentum & Streak (과거의 작은 성공 시각화)
|
||||||
|
- **기능:** `/app` 화면 한 켠에, 최근에 내가 해냈던 작은 몰입 세션들의 기록(잔디 심기, 자라나는 식물 등)을 시각적이고 감성적으로 보여줍니다.
|
||||||
|
- **효과:** "어제도 해냈으니 오늘도 할 수 있다"는 자기 효능감을 심어주어 도파민을 자극합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 비즈니스 모델(BM): 기꺼이 돈을 내게 만드는 수익화 전략
|
||||||
|
|
||||||
|
유저가 제품에 깊이 몰입하고 나면, 다음과 같은 **PRO 플랜(구독 모델)**을 통해 수익화를 달성할 수 있습니다.
|
||||||
|
|
||||||
|
1. **AI Micro-Stepping Coach (AI 목표 쪼개기 및 코칭)**
|
||||||
|
- **무료:** 유저가 직접 첫 단계를 고민해서 적어야 함.
|
||||||
|
- **PRO:** "기획서 쓰기"라고 치면, AI가 [1. 레퍼런스 3개 찾기, 2. 목차 5개 적기, 3. 서론 쓰기] 등으로 즉각 분해해주고 응원의 메시지를 건넴. (ADHD 유저에게 압도적인 가치 제공)
|
||||||
|
2. **Premium Vibes (독점 씬 & 사운드스케이프)**
|
||||||
|
- **무료:** 기본 자연음(숲, 비투비) 2~3종 제공.
|
||||||
|
- **PRO:** 유명 아티스트가 작곡한 Lo-Fi 집중 비트, 세계 유명 도서관/카페의 3D 공간 음향, 초고화질 동적 배경(비 오는 도쿄 야경 등) 무제한 접근.
|
||||||
|
3. **Deep Focus Analytics (심층 분석 리포트)**
|
||||||
|
- **무료:** 오늘 하루 총 집중 시간(단순 타이머 기록)만 확인 가능.
|
||||||
|
- **PRO:** 웹 서비스의 한계를 극복하기 위해 **세션 종료 후 유저가 직접 평가하는 '집중도 점수(1~5점)'와 Page Visibility API를 활용한 '탭 이탈(딴짓) 횟수 기록'을 결합**합니다. 이를 통해 단순히 켜둔 시간이 아니라 어떤 사운드/환경에서 가장 '질 높은' 집중을 했는지, 요일별 언제가 집중 Peak Time인지 분석하여 나만의 최적화된 루틴을 제안합니다.
|
||||||
|
4. **Calendar & Notion Integration (업무 툴 연동)**
|
||||||
|
- **PRO:** 구글 캘린더의 일정이나 노션의 Task를 `/app`의 Brain Dump로 자동 불러오고, 집중 세션이 끝나면 소요 시간과 함께 자동으로 기록/동기화됨. (프리랜서들의 필수 니즈)
|
||||||
|
- **기술적 실현 가능성 (Feasibility Check):** 웹 서비스 환경에서도 **OAuth 2.0 기반의 Google Calendar API 및 Notion API**를 연동하여 완벽히 구현 가능합니다. 서버에서 유저의 접근 권한 토큰을 관리하여 웹 클라이언트와 외부 서비스 간의 양방향 데이터 동기화를 지원할 수 있습니다.
|
||||||
|
|
||||||
|
## 요약 (Next Action)
|
||||||
|
`/app`은 단순히 데이터를 넘기는 껍데기가 아니라, **유저의 마음을 달래고(Brain Dump), 허들을 낮춰주며(Micro-Stepping), 몰입을 세팅(Ritual)하는 심리적 안전기지**가 되어야 합니다. 이 경험이 탄탄해지면 유저는 VibeRoom을 단순한 타이머가 아닌 '나의 루틴 파트너'로 인식하게 되며, AI 코칭과 프리미엄 환경을 위해 기꺼이 지갑을 열게 될 것입니다.
|
||||||
544
docs/screens/app/current/19_app_atmosphere_entry_spec.md
Normal file
544
docs/screens/app/current/19_app_atmosphere_entry_spec.md
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# 19. `/app` Atmosphere Entry Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-16
|
||||||
|
|
||||||
|
이 문서는 `/app`을 **`goal + duration + atmosphere` 중심의 premium focus entry surface**로 재설계하기 위한 기준 문서다.
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
|
||||||
|
- `Slice 1` no-session shell 구현 완료
|
||||||
|
- `Slice 1-2` visual premium polish 구현 완료
|
||||||
|
- `Custom Duration Contract` 구현 완료
|
||||||
|
- `Weekly Review Dock Reposition` 구현 완료
|
||||||
|
- 다음 slice는 `/space` current-session-only cleanup과 browser QA다
|
||||||
|
|
||||||
|
핵심 정책 변경:
|
||||||
|
|
||||||
|
- `/app`에는 더 이상 running / paused / resume 같은 session gate UI를 두지 않는다
|
||||||
|
- current session이 있으면 상태와 상관없이 바로 `/space`로 보낸다
|
||||||
|
- current session이 없을 때만 `/app` entry shell이 열린다
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `../../../../../product_principles.md`
|
||||||
|
- `../../../../../current_context.md`
|
||||||
|
- `../../stats/current/14_weekly_review_reframe_spec.md`
|
||||||
|
- `../../../flows/current/15_app_stats_entry_flow_spec.md`
|
||||||
|
- `../../../flows/current/18_paused_session_reentry_spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 다시 바꾸는가
|
||||||
|
|
||||||
|
현재 `/app`은 single-goal commitment gate로 정리되어 있지만,
|
||||||
|
사용자가 실제로 “어떤 환경으로 들어갈지”를 고르는 감각 품질이 entry에서 충분히 살아 있지 않다.
|
||||||
|
|
||||||
|
문제:
|
||||||
|
|
||||||
|
- 시작은 빨라졌지만, entry가 아직 너무 추상적이다
|
||||||
|
- `scene / sound / timer`가 제품 핵심 가치인데 `/app`에서는 거의 느껴지지 않는다
|
||||||
|
- 첫 진입에서 VibeRoom만의 premium 감각이 충분히 전달되지 않는다
|
||||||
|
|
||||||
|
따라서 `/app`은 다시 planning home으로 돌아가면 안 되지만,
|
||||||
|
**`goal + duration + atmosphere`를 한 화면에서 직관적으로 결정하는 premium entry stage**로 진화할 필요가 있다.
|
||||||
|
|
||||||
|
핵심은 이것이다.
|
||||||
|
|
||||||
|
> `/app`은 여전히 한 가지 목표만 정하는 화면이어야 하지만,
|
||||||
|
> 그 목표를 어떤 분위기에서 얼마나 붙잡을지까지 함께 결정하는 stage가 되어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
|
`/app`은 사용자가
|
||||||
|
|
||||||
|
1. 이번 세션의 목표를 한 줄로 적고
|
||||||
|
2. 이 목표에 얼마나 걸릴지 직접 입력하고
|
||||||
|
3. 배경 + 사운드가 묶인 하나의 `Atmosphere`를 고른 뒤
|
||||||
|
4. 바로 `/space`로 들어가는
|
||||||
|
|
||||||
|
**premium focus staging screen**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 핵심 제품 원칙
|
||||||
|
|
||||||
|
### 0. `/app`은 session 상태를 보여주지 않는다
|
||||||
|
|
||||||
|
- `/app`은 entry surface다
|
||||||
|
- current session이 있으면 사용자를 붙잡아 두지 않고 바로 `/space`로 이동시킨다
|
||||||
|
- 따라서 `/app` 안의 paused resume gate, takeover sheet, session review entry는 current가 아니다
|
||||||
|
|
||||||
|
### 1. Single Goal First는 유지한다
|
||||||
|
|
||||||
|
- goal은 1개만 입력한다
|
||||||
|
- entry에서 multi-goal / list / planner affordance 금지
|
||||||
|
|
||||||
|
### 2. MicroStep은 `/app`에서 제거한다
|
||||||
|
|
||||||
|
- 최초 entry에서는 microStep을 묻지 않는다
|
||||||
|
- microStep은 `/space` 안의 recovery / refocus에서 다시 잡는다
|
||||||
|
- 이유:
|
||||||
|
- 첫 entry는 시작 마찰을 최소화해야 한다
|
||||||
|
- goal, duration, atmosphere만으로도 결정량이 충분하다
|
||||||
|
|
||||||
|
### 3. Duration은 사용자가 직접 입력한다
|
||||||
|
|
||||||
|
- 기존 preset `25/5`, `50/10`, `90/20` 중심 entry를 버린다
|
||||||
|
- 사용자는 “이 목표를 끝내는 데 얼마나 걸릴지”를 분 단위로 적는다
|
||||||
|
- 예: `70분`
|
||||||
|
|
||||||
|
### 4. 배경 + 사운드는 하나의 오브젝트로 다룬다
|
||||||
|
|
||||||
|
- scene과 sound를 따로 고르게 하지 않는다
|
||||||
|
- entry에서 사용자가 고르는 것은 `Atmosphere` 하나다
|
||||||
|
- 즉 `/app`의 선택 단위는 `scene + sound pair`
|
||||||
|
|
||||||
|
### 5. Review는 entry를 방해하지 않는 secondary ritual이어야 한다
|
||||||
|
|
||||||
|
- `/app`의 주인공은 시작이다
|
||||||
|
- weekly review, achieved goals, “어떤 조합에서 잘 됐는지”는 보이되 start보다 앞서면 안 된다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 용어 정의
|
||||||
|
|
||||||
|
### 추천 명칭: `Atmosphere`
|
||||||
|
|
||||||
|
배경 + 사운드가 결합된 카드의 공식 명칭은 `Atmosphere`로 고정한다.
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- scene, sound, timer보다 더 제품적인 단위로 읽힌다
|
||||||
|
- “배경 카드”보다 premium하고 감성적이다
|
||||||
|
- 나중에 Pro 가치(`saved atmospheres`, `best atmosphere for mornings`)와도 자연스럽게 연결된다
|
||||||
|
|
||||||
|
### UI에서의 노출 방식
|
||||||
|
|
||||||
|
- 섹션 제목: `어떤 분위기에서 들어갈까요?`
|
||||||
|
- 카드 타입 명칭: `Atmosphere`
|
||||||
|
- 예시 카드명:
|
||||||
|
- Rain Window
|
||||||
|
- Quiet Library
|
||||||
|
- Forest Draft
|
||||||
|
- Deep Night Desk
|
||||||
|
|
||||||
|
### 피해야 할 이름
|
||||||
|
|
||||||
|
- `배경 카드`
|
||||||
|
- `사운드 카드`
|
||||||
|
- `조합 카드`
|
||||||
|
- `프리셋`
|
||||||
|
|
||||||
|
이런 이름은 값싸 보이거나 툴처럼 읽힌다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 정보 구조
|
||||||
|
|
||||||
|
`/app`은 아래 3층으로 구성한다.
|
||||||
|
|
||||||
|
### Layer 1. Entry Header
|
||||||
|
|
||||||
|
- VibeRoom wordmark
|
||||||
|
- quiet weekly review entry
|
||||||
|
- plan pill / account
|
||||||
|
|
||||||
|
전제:
|
||||||
|
|
||||||
|
- current session이 있으면 이 화면 자체에 오래 머무르지 않고 `/space`로 넘어간다
|
||||||
|
|
||||||
|
### Layer 2. Primary Start Stage
|
||||||
|
|
||||||
|
- goal input
|
||||||
|
- duration input
|
||||||
|
- start CTA
|
||||||
|
- editorial copy block
|
||||||
|
- selected atmosphere large preview stage
|
||||||
|
|
||||||
|
### Layer 3. Atmosphere Grid
|
||||||
|
|
||||||
|
- 12개의 curated atmosphere card
|
||||||
|
- row당 4개
|
||||||
|
- 첫 구현은 더미 데이터
|
||||||
|
|
||||||
|
핵심은
|
||||||
|
**review / archive / history가 main stage를 먹지 않고, atmosphere grid도 secondary decoration이 아니라 실제 선택 surface로 작동해야 한다**는 점이다.
|
||||||
|
|
||||||
|
### 현재 visual shell 원칙
|
||||||
|
|
||||||
|
- left: decision rail
|
||||||
|
- 큰 제목
|
||||||
|
- goal / duration input
|
||||||
|
- selected atmosphere summary
|
||||||
|
- primary CTA
|
||||||
|
- quiet review dock
|
||||||
|
- right: selected atmosphere stage
|
||||||
|
- immersive preview
|
||||||
|
- sound / scene / best-for meta
|
||||||
|
- bottom: curated atmosphere library
|
||||||
|
- 4열 grid
|
||||||
|
- selected state가 즉시 읽혀야 함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 화면 구성
|
||||||
|
|
||||||
|
### A. Goal Input
|
||||||
|
|
||||||
|
- 가장 위, 가장 크게
|
||||||
|
- 한 줄 입력
|
||||||
|
- placeholder 예시:
|
||||||
|
- `예: 여행 계획 짜기`
|
||||||
|
- `예: 제안서 첫 문단 정리`
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 필수
|
||||||
|
- 한 줄
|
||||||
|
- Enter로 start 가능
|
||||||
|
|
||||||
|
### B. Duration Input
|
||||||
|
|
||||||
|
- goal 바로 아래 또는 옆
|
||||||
|
- 분 단위 숫자 입력
|
||||||
|
- label:
|
||||||
|
- `얼마나 붙잡을까요?`
|
||||||
|
- `예상 시간(분)`
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- 필수
|
||||||
|
- 숫자만
|
||||||
|
- 권장 범위:
|
||||||
|
- 최소 5분
|
||||||
|
- 최대 180분
|
||||||
|
- helper:
|
||||||
|
- `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.`
|
||||||
|
- quick duration suggestion은 허용한다.
|
||||||
|
- 예: `25`, `45`, `70`, `90`
|
||||||
|
- 단, planner처럼 보이지 않도록 아주 조용한 assistive chip이어야 한다
|
||||||
|
|
||||||
|
### C. Atmosphere Grid
|
||||||
|
|
||||||
|
- 12개 dummy card
|
||||||
|
- desktop: 4열 x 3행
|
||||||
|
- tablet: 3열
|
||||||
|
- mobile: 2열
|
||||||
|
|
||||||
|
각 카드 구성:
|
||||||
|
|
||||||
|
- 배경 썸네일
|
||||||
|
- 카드명
|
||||||
|
- sound label
|
||||||
|
- 1줄 description
|
||||||
|
- selected state
|
||||||
|
- hover / focus state
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- `Rain Window` / `Soft Rain`
|
||||||
|
- `Quiet Library` / `Air + Page Rustle`
|
||||||
|
- `Forest Draft` / `Forest Birds`
|
||||||
|
- `Deep Night Desk` / `Low White`
|
||||||
|
|
||||||
|
### D. Start CTA
|
||||||
|
|
||||||
|
- 문구 예시:
|
||||||
|
- `이 분위기로 들어가기`
|
||||||
|
- `지금 시작`
|
||||||
|
|
||||||
|
추천:
|
||||||
|
|
||||||
|
- 기본 CTA는 `이 분위기로 들어가기`
|
||||||
|
- 이유:
|
||||||
|
- goal + duration + atmosphere가 모두 한 번에 묶여 entry action으로 읽힌다
|
||||||
|
|
||||||
|
### E. Selection Rules
|
||||||
|
|
||||||
|
- no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다
|
||||||
|
- review handoff로 들어온 경우에는 handoff `entryAtmosphereId + entryDurationMinutes`를 preselect한다
|
||||||
|
- 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다
|
||||||
|
- 사용자가 duration을 직접 수정한 뒤에는 atmosphere를 바꿔도 duration 값을 덮어쓰지 않는다
|
||||||
|
|
||||||
|
### F. Weekly Review Entry
|
||||||
|
|
||||||
|
이건 main stage 바깥의 quiet secondary placement로 둔다.
|
||||||
|
|
||||||
|
권장 위치:
|
||||||
|
|
||||||
|
- desktop: header 아래 우상단 quiet insight panel
|
||||||
|
- mobile: hero 하단의 얇은 secondary card
|
||||||
|
|
||||||
|
보여줄 것:
|
||||||
|
|
||||||
|
- 이번 주 가장 잘 맞았던 atmosphere
|
||||||
|
- 시작 성공률 또는 복귀율 1줄
|
||||||
|
- CTA: `이번 주 review`
|
||||||
|
|
||||||
|
보이지 말아야 할 것:
|
||||||
|
|
||||||
|
- achieved goals 리스트 전체
|
||||||
|
- 작은 차트 여러 개
|
||||||
|
- dashboard처럼 보이는 summary wall
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Weekly Review / Achieved Goals를 어디에 둘 것인가
|
||||||
|
|
||||||
|
결론:
|
||||||
|
|
||||||
|
> `/app`에는 full review를 두지 않고,
|
||||||
|
> `quiet review dock`만 둔다.
|
||||||
|
> 자세한 achieved goals와 “어떤 조합에서 잘 됐는지”는 `/stats`에서 본다.
|
||||||
|
|
||||||
|
### 왜 `/app`에 다 보여주면 안 되는가
|
||||||
|
|
||||||
|
- 메인 entry의 집중력이 깨진다
|
||||||
|
- goal 입력과 review 해석이 같은 레벨로 보인다
|
||||||
|
- planner/dashboard처럼 읽힌다
|
||||||
|
|
||||||
|
### 추천 구조
|
||||||
|
|
||||||
|
#### `/app`
|
||||||
|
|
||||||
|
- 작은 weekly review dock
|
||||||
|
- 보여주는 정보는 1~2줄만
|
||||||
|
- 예:
|
||||||
|
- `이번 주엔 Rain Window에서 가장 오래 이어졌어요.`
|
||||||
|
- `여행 계획 같은 넓은 목표는 70분보다 45분에서 더 잘 닫혔어요.`
|
||||||
|
|
||||||
|
#### `/stats`
|
||||||
|
|
||||||
|
- achieved goals history
|
||||||
|
- best-performing atmospheres
|
||||||
|
- duration fit
|
||||||
|
- recovery quality
|
||||||
|
|
||||||
|
즉:
|
||||||
|
|
||||||
|
- `/app`은 “review를 여는 문”
|
||||||
|
- `/stats`는 “review desk”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 상태별 유저 플로우
|
||||||
|
|
||||||
|
### Flow A. First Entry / No Session
|
||||||
|
|
||||||
|
1. 사용자가 `/app` 진입
|
||||||
|
2. goal 입력
|
||||||
|
3. duration 입력
|
||||||
|
4. atmosphere 1개 선택
|
||||||
|
5. CTA 클릭
|
||||||
|
6. `/space` 진입
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
|
||||||
|
- microStep 없음
|
||||||
|
- list 없음
|
||||||
|
- wizard 없음
|
||||||
|
|
||||||
|
### Flow B. Running Session Exists
|
||||||
|
|
||||||
|
1. 사용자가 `/app` 진입
|
||||||
|
2. 바로 `/space` redirect
|
||||||
|
|
||||||
|
이유:
|
||||||
|
|
||||||
|
- 이미 실행 중인 세션은 다시 decision gate에 세우지 않는다
|
||||||
|
|
||||||
|
### Flow C. Any Current Session Exists
|
||||||
|
|
||||||
|
1. 사용자가 `/app` 진입
|
||||||
|
2. 바로 `/space` redirect
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- `/app`은 current session을 처리하는 장소가 아니다
|
||||||
|
- resume / pause / break / complete 판단은 `/space` 안에서 이어진다
|
||||||
|
|
||||||
|
### Flow D. Review Entry
|
||||||
|
|
||||||
|
1. 사용자가 `/app`의 quiet review dock 클릭
|
||||||
|
2. `/stats`
|
||||||
|
3. carry-forward CTA
|
||||||
|
4. 다시 `/app`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Visual Direction
|
||||||
|
|
||||||
|
### Primary Stage
|
||||||
|
|
||||||
|
- no-session `/app`의 주인공은 하나의 넓은 start stage다
|
||||||
|
- 이 stage 안에서:
|
||||||
|
- goal
|
||||||
|
- duration
|
||||||
|
- primary CTA
|
||||||
|
가 먼저 읽혀야 한다
|
||||||
|
- stage는 glassmorphism을 쓰더라도 dashboard 카드처럼 보이면 안 된다
|
||||||
|
- typography와 spacing으로 premium하게 보여야 한다
|
||||||
|
|
||||||
|
### Atmosphere Grid
|
||||||
|
|
||||||
|
- 아래 grid는 decoration이 아니라 실제 선택 surface다
|
||||||
|
- card는 서로 다른 장면으로 충분히 구분돼야 한다
|
||||||
|
- selected state는:
|
||||||
|
- 두꺼운 outline보다 얇은 light ring
|
||||||
|
- 미세한 scale
|
||||||
|
- title/sound contrast 상승
|
||||||
|
정도로 표현하는 편이 맞다
|
||||||
|
|
||||||
|
### Review Dock
|
||||||
|
|
||||||
|
- review는 top-right quiet dock 또는 stage 바깥의 조용한 secondary panel로 둔다
|
||||||
|
- 절대 main CTA와 같은 무게가 되면 안 된다
|
||||||
|
- “이번 주 review”는 읽히되, goal 입력을 방해하면 실패다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 구현 상태와 남은 작업
|
||||||
|
|
||||||
|
현재 `/app`의 no-session shell은 이미 구현되어 있고, focus-session API V2도 함께 연결됐다.
|
||||||
|
|
||||||
|
현재 포함된 범위:
|
||||||
|
|
||||||
|
- goal input
|
||||||
|
- duration input
|
||||||
|
- 12개 atmosphere grid
|
||||||
|
- selected atmosphere 기반 background 반영
|
||||||
|
- quiet review dock의 기본 위치
|
||||||
|
- raw `focusDurationMinutes` start payload
|
||||||
|
- review handoff의 `entryAtmosphereId + entryDurationMinutes`
|
||||||
|
|
||||||
|
이번 라운드에서 제외된 것:
|
||||||
|
|
||||||
|
- `/stats` IA 변경
|
||||||
|
- `/space` current-session-only dead path cleanup
|
||||||
|
|
||||||
|
### 현재 계약
|
||||||
|
|
||||||
|
- 사용자는 분 단위 duration을 입력한다
|
||||||
|
- server는 `focusDurationMinutes`를 공식 start 계약으로 받는다
|
||||||
|
- selected atmosphere는 `atmosphereId + sceneId + soundPresetId`로 함께 저장된다
|
||||||
|
- `/space`는 current session 응답의 `focusDurationSeconds`, `atmosphereId`, `sceneId`, `soundPresetId`를 source of truth로 읽는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. UI 방향
|
||||||
|
|
||||||
|
### 톤
|
||||||
|
|
||||||
|
- premium
|
||||||
|
- calm
|
||||||
|
- stage-like
|
||||||
|
- card-heavy dashboard 금지
|
||||||
|
|
||||||
|
### 중요한 시각 원칙
|
||||||
|
|
||||||
|
- 상단 input stage는 “form”보다 “entry surface”처럼 보여야 한다
|
||||||
|
- atmosphere grid는 catalog처럼 보이되, ecommerce처럼 보이면 안 된다
|
||||||
|
- card는 selection object이지 콘텐츠 카드가 아니다
|
||||||
|
- hover, selected, focus state가 매우 명확해야 한다
|
||||||
|
|
||||||
|
### Selected Card
|
||||||
|
|
||||||
|
- 다른 카드보다 1단 더 선명
|
||||||
|
- subtle border glow
|
||||||
|
- sound label이 더 읽히게
|
||||||
|
- 선택되면 CTA 문구가 `이 분위기로 들어가기`와 연결돼야 함
|
||||||
|
|
||||||
|
### Unselected Card
|
||||||
|
|
||||||
|
- 너무 장식적이면 안 된다
|
||||||
|
- 배경 썸네일이 주인공
|
||||||
|
- 텍스트는 간결
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 비즈니스 모델 관점
|
||||||
|
|
||||||
|
이 redesign은 BM에도 직접 연결된다.
|
||||||
|
|
||||||
|
### Free
|
||||||
|
|
||||||
|
- curated atmosphere 12개 제공
|
||||||
|
- goal + custom duration + basic review dock
|
||||||
|
- best atmosphere insight는 1줄만
|
||||||
|
|
||||||
|
### Pro
|
||||||
|
|
||||||
|
- personalized atmosphere recommendations
|
||||||
|
- saved favorite atmospheres
|
||||||
|
- goal type / time-of-day 기반 추천
|
||||||
|
- “이 목표 유형에서 잘 맞았던 조합” 인사이트
|
||||||
|
- duration suggestion
|
||||||
|
- deeper review
|
||||||
|
|
||||||
|
즉 Pro는 “카드를 더 많이 준다”가 아니라
|
||||||
|
**더 잘 맞는 atmosphere를 더 빨리 고르게 해준다**가 되어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 서버 / 데이터 영향
|
||||||
|
|
||||||
|
이번 redesign은 backend contract까지 건드린다.
|
||||||
|
|
||||||
|
### 필요한 변화
|
||||||
|
|
||||||
|
1. `startSession`이 `focusDurationMinutes`를 공식 필드로 받는다
|
||||||
|
2. session에 `focusDurationSeconds`와 `atmosphereId`를 저장한다
|
||||||
|
3. `GET /focus-sessions/current`가 `/space` hydration에 필요한 goal / duration / atmosphere / scene / sound를 모두 반환한다
|
||||||
|
4. `/stats -> /app` handoff는 preset이 아니라 atmosphere / duration 힌트를 사용한다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 구현 순서
|
||||||
|
|
||||||
|
### Slice 1. Atmosphere Model / Dummy Data
|
||||||
|
|
||||||
|
- atmosphere card dummy 12개 정의
|
||||||
|
- 카드명 / scene / sound / thumbnail 연결
|
||||||
|
|
||||||
|
### Slice 2. `/app` Entry Shell Redesign
|
||||||
|
|
||||||
|
- goal input
|
||||||
|
- duration input
|
||||||
|
- 4x3 atmosphere grid
|
||||||
|
- selected card state
|
||||||
|
- start CTA
|
||||||
|
|
||||||
|
### Slice 3. Paused Gate Coexistence
|
||||||
|
|
||||||
|
- current session이 있으면 `/app`을 건너뛰고 `/space`로 보낸다
|
||||||
|
- no-session일 때만 새 shell 노출
|
||||||
|
|
||||||
|
### Slice 4. Custom Duration Contract
|
||||||
|
|
||||||
|
- web -> server `custom minutes`
|
||||||
|
- break duration policy
|
||||||
|
- `/space` timer HUD와 연동
|
||||||
|
|
||||||
|
### Slice 5. Weekly Review Dock
|
||||||
|
|
||||||
|
- `/app`의 quiet secondary review entry를 새 shell에 맞게 재배치
|
||||||
|
- `/stats` entry/handoff 유지
|
||||||
|
|
||||||
|
### Slice 6. Pro Personalization
|
||||||
|
|
||||||
|
- recommended atmosphere
|
||||||
|
- best-fit insight
|
||||||
|
- duration suggestion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 검증 기준
|
||||||
|
|
||||||
|
- 사용자가 `/app`에서 10초 안에 goal + duration + atmosphere를 정하고 들어갈 수 있다
|
||||||
|
- atmosphere grid가 decorator가 아니라 실제 선택 surface로 읽힌다
|
||||||
|
- weekly review가 start보다 앞서지 않는다
|
||||||
|
- `/app`이 planner/dashboard처럼 보이지 않는다
|
||||||
|
- current session이 있을 때는 `/app`에 머무르지 않고 `/space`로 이동한다
|
||||||
|
- `70분` 같은 custom duration이 실제 세션 길이로 반영된다
|
||||||
18
docs/screens/space/README.md
Normal file
18
docs/screens/space/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# `/space` Docs
|
||||||
|
|
||||||
|
`/space` 화면 자체 구조와 시각 규칙 문서를 모아둔 폴더다.
|
||||||
|
|
||||||
|
## current
|
||||||
|
|
||||||
|
- [13_space_intent_card_collapsed_expanded_spec.md](./current/13_space_intent_card_collapsed_expanded_spec.md)
|
||||||
|
|
||||||
|
## archive
|
||||||
|
|
||||||
|
- 현재 없음
|
||||||
|
|
||||||
|
## 같이 볼 문서
|
||||||
|
|
||||||
|
`/space`는 recovery flow 문서와 함께 읽어야 한다.
|
||||||
|
|
||||||
|
- [../../flows/current/10_refocus_system_spec.md](../../flows/current/10_refocus_system_spec.md)
|
||||||
|
- [../../flows/current/11_away_return_recovery_spec.md](../../flows/current/11_away_return_recovery_spec.md)
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
# 13. `/space` Intent Card Collapsed / Expanded Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-15
|
||||||
|
|
||||||
|
이 문서는 `/space` 좌상단 목표 카드의 **collapsed / expanded 구조**를 정의한다.
|
||||||
|
|
||||||
|
목적은 단순하다.
|
||||||
|
|
||||||
|
- goal은 항상 보여야 한다
|
||||||
|
- 하지만 배경을 계속 크게 가리면 안 된다
|
||||||
|
- 평소에는 조용한 `anchor`처럼 남고, 필요할 때만 `card`처럼 확장돼야 한다
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `./10_refocus_system_spec.md`
|
||||||
|
- `./11_away_return_recovery_spec.md`
|
||||||
|
- `../../product/12_core_loop_execution_roadmap.md`
|
||||||
|
- `../../current_context.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 왜 바꾸는가
|
||||||
|
|
||||||
|
현재 구조의 문제는 이것이다.
|
||||||
|
|
||||||
|
- goal, microStep, 액션이 항상 한 카드 안에 모두 보인다
|
||||||
|
- 읽힘은 확보하지만, 배경의 존재감을 과하게 가져간다
|
||||||
|
- `/space`의 주인공이 scene이어야 하는데, 좌상단 카드가 먼저 읽힌다
|
||||||
|
|
||||||
|
즉 문제는 glass material 자체보다 **항상 큰 상태로 떠 있는 면적**이다.
|
||||||
|
|
||||||
|
premium ambient UI에서는:
|
||||||
|
|
||||||
|
- 정보는 항상 남아야 하지만
|
||||||
|
- 존재감은 항상 같을 필요가 없다
|
||||||
|
|
||||||
|
그래서 목표 카드는 `persistent presence`는 유지하고, `persistent emphasis`는 버려야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
|
> `/space`의 목표 카드는 평소에는 얇은 glass rail로 남고, 사용자가 실제로 결정을 내려야 할 때만 확장된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 상태 정의
|
||||||
|
|
||||||
|
Intent Card는 아래 2개 상태만 가진다.
|
||||||
|
|
||||||
|
### 3.1 Collapsed
|
||||||
|
|
||||||
|
기본 상태.
|
||||||
|
|
||||||
|
보이는 것:
|
||||||
|
|
||||||
|
- goal 1줄
|
||||||
|
|
||||||
|
보이지 않는 것:
|
||||||
|
|
||||||
|
- microStep 상세
|
||||||
|
- 완료 액션
|
||||||
|
- recovery footer
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
- 지금 어떤 일 위에 있는지 잃지 않게 하는 기준점
|
||||||
|
|
||||||
|
감정:
|
||||||
|
|
||||||
|
- panel이 아니라 anchor
|
||||||
|
|
||||||
|
### 3.2 Expanded
|
||||||
|
|
||||||
|
필요할 때만 열리는 상태.
|
||||||
|
|
||||||
|
보이는 것:
|
||||||
|
|
||||||
|
- goal
|
||||||
|
- microStep 또는 비어 있을 때의 조용한 helper line
|
||||||
|
- `이번 목표 완료`
|
||||||
|
- microStep 완료 체크
|
||||||
|
|
||||||
|
역할:
|
||||||
|
|
||||||
|
- 지금 이 세션을 어떻게 계속할지 결정하게 하는 짧은 제어 면
|
||||||
|
|
||||||
|
감정:
|
||||||
|
|
||||||
|
- 툴바가 아니라, 잠깐 열리는 soft control surface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 상태 전환 규칙
|
||||||
|
|
||||||
|
### Expanded로 전환되는 경우
|
||||||
|
|
||||||
|
- desktop에서 hover
|
||||||
|
- focus 진입
|
||||||
|
- rail 전체 클릭/tap
|
||||||
|
|
||||||
|
### Collapsed로 돌아가는 경우
|
||||||
|
|
||||||
|
- pointer leave
|
||||||
|
- focus out
|
||||||
|
- expand affordance 재클릭
|
||||||
|
- expanded 상태에서 card 바깥 pointer down
|
||||||
|
- recovery tray가 열릴 때
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- `pause / return / next-beat / complete / refocus` tray가 열려 있는 동안에는 목표 카드는 강제로 collapsed 상태를 유지한다
|
||||||
|
- tray가 이미 의사결정 레이어이기 때문에, base card까지 expanded 상태면 배경을 이중으로 가리게 된다
|
||||||
|
|
||||||
|
### dismissal rule
|
||||||
|
|
||||||
|
- expanded goal card는 `outside click / outside tap`으로 접혀도 된다
|
||||||
|
- 이 동작은 base card의 ephemeral expansion에만 적용한다
|
||||||
|
- `refocus / next-beat / return / complete`처럼 의사결정을 요구하는 tray는 outside click으로 닫히면 안 된다
|
||||||
|
- decision tray는 반드시 명시적 액션으로만 닫는다
|
||||||
|
- `취소`
|
||||||
|
- `적용`
|
||||||
|
- `여기서 마무리하기`
|
||||||
|
- `잠시 비우기`
|
||||||
|
- `다음 블록 이어가기`
|
||||||
|
- 즉, `/space`에서 가볍게 접히는 것은 `expanded rail`뿐이고, 실질적인 state change layer는 dismissible popover로 취급하지 않는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 시각 구조
|
||||||
|
|
||||||
|
### Collapsed rail
|
||||||
|
|
||||||
|
- 폭: 약 `18rem ~ 19rem`
|
||||||
|
- 높이: 약 `48px ~ 52px`
|
||||||
|
- radius: `18px` 전후
|
||||||
|
- glass:
|
||||||
|
- 더 얇고 더 투명
|
||||||
|
- large panel처럼 보이면 안 된다
|
||||||
|
- goal:
|
||||||
|
- 1줄 truncate
|
||||||
|
- medium weight
|
||||||
|
- affordance:
|
||||||
|
- 별도 chevron / dropdown button은 두지 않는다
|
||||||
|
- rail 자체가 expand trigger로 동작한다
|
||||||
|
- desktop에서는 hover로, mobile에서는 tap으로 자연스럽게 열린다
|
||||||
|
|
||||||
|
### Expanded card
|
||||||
|
|
||||||
|
- 폭: 약 `21rem ~ 23rem`
|
||||||
|
- 높이: 내용에 따라 자연스럽게
|
||||||
|
- radius: `22px ~ 24px`
|
||||||
|
- glass:
|
||||||
|
- collapsed보다 한 단계 더 선명한 재질
|
||||||
|
- 하지만 tray만큼 무겁지는 않다
|
||||||
|
- 내부 구조:
|
||||||
|
1. goal row
|
||||||
|
2. microStep row
|
||||||
|
3. quiet footer action
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 정보 구조
|
||||||
|
|
||||||
|
### Goal row
|
||||||
|
|
||||||
|
- 좌측: goal 1줄
|
||||||
|
- 우측: expanded 상태에서만 보이는 `수정` 액션
|
||||||
|
- collapsed 상태에서는 rail 전체가 expand trigger로만 동작한다
|
||||||
|
- expanded 상태에서도 goal 텍스트 자체는 edit trigger가 아니다
|
||||||
|
- refocus는 expanded 상태의 명시적 `수정` 액션으로만 진입한다
|
||||||
|
|
||||||
|
### MicroStep row
|
||||||
|
|
||||||
|
- expanded 상태에서만 노출
|
||||||
|
- 왼쪽: completion glyph
|
||||||
|
- 오른쪽: microStep text
|
||||||
|
- microStep이 없으면:
|
||||||
|
- helper 1줄만 노출
|
||||||
|
- planner-like placeholder 금지
|
||||||
|
|
||||||
|
### Footer action
|
||||||
|
|
||||||
|
- expanded 상태에서만 노출
|
||||||
|
- 우측 정렬된 quiet text action 1개
|
||||||
|
- `이번 목표 완료`
|
||||||
|
- `다시 방향` 상시 버튼은 두지 않는다
|
||||||
|
- refocus는 expanded 상태의 명시적 `수정` 액션으로만 진입한다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. interaction 원칙
|
||||||
|
|
||||||
|
### Goal 수정
|
||||||
|
|
||||||
|
- expanded 상태의 `수정` 액션 클릭 -> refocus
|
||||||
|
- collapsed 상태의 rail 클릭은 절대 refocus를 열지 않는다
|
||||||
|
- 핵심은 `expand`와 `edit`의 역할이 섞이지 않게 하는 것이다
|
||||||
|
|
||||||
|
### microStep 완료
|
||||||
|
|
||||||
|
- expanded 상태에서만 완료 glyph가 노출된다
|
||||||
|
- collapsed 상태에서는 실수 클릭을 막기 위해 숨긴다
|
||||||
|
|
||||||
|
### 목표 완료
|
||||||
|
|
||||||
|
- expanded 상태에서만 footer action 노출
|
||||||
|
- collapsed 상태에서는 보이지 않는다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. motion 원칙
|
||||||
|
|
||||||
|
### Collapsed -> Expanded
|
||||||
|
|
||||||
|
- 넓어짐 + 내부 row fade-in
|
||||||
|
- card가 “튀어나오는” 느낌보다, rail이 조용히 열리는 느낌이어야 한다
|
||||||
|
- duration은 짧고 부드럽게
|
||||||
|
|
||||||
|
### Expanded -> Collapsed
|
||||||
|
|
||||||
|
- 내부 row가 먼저 약해지고
|
||||||
|
- 폭이 줄어든다
|
||||||
|
- 닫힘은 더 조용해야 한다
|
||||||
|
|
||||||
|
### tray open 시
|
||||||
|
|
||||||
|
- intent card는 먼저 collapsed
|
||||||
|
- 그 아래 tray가 열림
|
||||||
|
- 즉 `card 확장`과 `tray 확장`은 동시에 크게 보이면 안 된다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 금지사항
|
||||||
|
|
||||||
|
- 상시 expanded 금지
|
||||||
|
- goal + microStep + footer action 항상 노출 금지
|
||||||
|
- Now chip 재도입 금지
|
||||||
|
- toolbar/pill/button cluster처럼 보이게 만들기 금지
|
||||||
|
- 배경보다 card가 먼저 읽히게 만들기 금지
|
||||||
|
- rail 클릭과 edit 진입을 같은 액션으로 섞기 금지
|
||||||
|
- 별도 chevron/dropdown button을 상시 노출하기 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 구현 기준
|
||||||
|
|
||||||
|
필수 변경 파일:
|
||||||
|
|
||||||
|
- `src/widgets/space-focus-hud/ui/IntentCapsule.tsx`
|
||||||
|
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
|
||||||
|
|
||||||
|
가능한 보조 변경:
|
||||||
|
|
||||||
|
- `src/shared/i18n/messages/space.ts`
|
||||||
|
- `src/widgets/space-focus-hud/ui/overlayStyles.ts`
|
||||||
|
|
||||||
|
구현 포인트:
|
||||||
|
|
||||||
|
- `IntentCapsule` 내부에 local expanded state 추가
|
||||||
|
- hover / focus / toggle button 기반 전환
|
||||||
|
- `showActions === false` 또는 overlay open 상태에서는 강제 collapsed
|
||||||
|
- microStep / footer action은 expanded에서만 렌더
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 완료 기준
|
||||||
|
|
||||||
|
- idle 상태에서 상단 goal UI가 배경을 덜 가린다
|
||||||
|
- expanded 상태에서만 microStep과 완료 액션이 보인다
|
||||||
|
- recovery tray가 열릴 때 base card는 과하게 커져 있지 않다
|
||||||
|
- bright / dark scene 모두에서 읽히지만 scene이 먼저 읽힌다
|
||||||
|
- `/space`가 productivity dashboard처럼 보이지 않는다
|
||||||
17
docs/screens/stats/README.md
Normal file
17
docs/screens/stats/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# `/stats` Docs
|
||||||
|
|
||||||
|
`/stats` 화면의 review 구조 문서를 모아둔 폴더다.
|
||||||
|
|
||||||
|
## current
|
||||||
|
|
||||||
|
- [14_weekly_review_reframe_spec.md](./current/14_weekly_review_reframe_spec.md)
|
||||||
|
|
||||||
|
## archive
|
||||||
|
|
||||||
|
- 현재 없음
|
||||||
|
|
||||||
|
## 같이 볼 문서
|
||||||
|
|
||||||
|
`/stats`는 `/app` 진입/복귀 플로우와 함께 읽어야 한다.
|
||||||
|
|
||||||
|
- [../../flows/current/15_app_stats_entry_flow_spec.md](../../flows/current/15_app_stats_entry_flow_spec.md)
|
||||||
636
docs/screens/stats/current/14_weekly_review_reframe_spec.md
Normal file
636
docs/screens/stats/current/14_weekly_review_reframe_spec.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
# 14. Weekly Review Reframe Spec
|
||||||
|
|
||||||
|
Last Updated: 2026-03-14
|
||||||
|
|
||||||
|
이 문서는 VibeRoom의 `Weekly Review`를 단순 통계 화면이 아니라
|
||||||
|
**다음 세션의 성공률을 높이는 행동 시스템**으로 재정의하는 상세 기획 문서다.
|
||||||
|
|
||||||
|
관련 문서:
|
||||||
|
|
||||||
|
- `../app/19_app_atmosphere_entry_spec.md`
|
||||||
|
- `../space/10_refocus_system_spec.md`
|
||||||
|
- `../space/11_away_return_recovery_spec.md`
|
||||||
|
- `../../product/12_core_loop_execution_roadmap.md`
|
||||||
|
- `../space/13_space_intent_card_collapsed_expanded_spec.md`
|
||||||
|
- `../../product_principles.md`
|
||||||
|
- `../../current_context.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 문제 정의
|
||||||
|
|
||||||
|
현재 `/stats`는 factual summary 중심으로 정리되어 있다.
|
||||||
|
|
||||||
|
좋은 점:
|
||||||
|
|
||||||
|
- 과한 해석을 줄였다
|
||||||
|
- mock insight와 가짜 코칭을 제거했다
|
||||||
|
- started / completed / carried over / focus minutes 같은 실제 수치만 남겼다
|
||||||
|
|
||||||
|
부족한 점:
|
||||||
|
|
||||||
|
- 사용자가 `그래서 다음 주에 뭘 바꾸면 되는지`를 알 수 없다
|
||||||
|
- 총 시간은 보여주지만 `왜 잘 되었는지 / 왜 무너졌는지`는 행동 수준에서 연결되지 않는다
|
||||||
|
- Pro 가치가 `review`라는 이름에 비해 아직 약하다
|
||||||
|
- 현재 구조는 여전히 `summary dashboard`에 가깝고, `behavior change surface`로는 약하다
|
||||||
|
|
||||||
|
핵심 문제는 이거다.
|
||||||
|
|
||||||
|
> 지금의 `/stats`는 지난 시간을 보여주지만, 다음 집중의 성공률을 높이지는 못한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 한 줄 정의
|
||||||
|
|
||||||
|
> Weekly Review는 지난 7일의 집중 기록을 예쁘게 보여주는 화면이 아니라, 다음 주의 첫 세션을 더 잘 시작하게 만드는 주간 정리 ritual이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 제품 목표
|
||||||
|
|
||||||
|
Weekly Review가 해야 할 일은 4개뿐이다.
|
||||||
|
|
||||||
|
1. 내가 `시작`을 잘하는 조건을 보여준다
|
||||||
|
2. 내가 `흔들린 뒤 복귀`를 잘하는 조건을 보여준다
|
||||||
|
3. 내가 `완료`까지 가는 패턴을 보여준다
|
||||||
|
4. 다음 주에 바꿀 것을 1~2개만 남긴다
|
||||||
|
|
||||||
|
하지 말아야 할 일도 분명하다.
|
||||||
|
|
||||||
|
- 예쁜 그래프 전시장 되기
|
||||||
|
- 생산성 점수 놀이
|
||||||
|
- 성격 분석처럼 보이는 과한 해석
|
||||||
|
- planner / habit tracker / life dashboard로 확장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 시장 신호와 포지셔닝
|
||||||
|
|
||||||
|
2026-03-14 기준 공식 포지셔닝을 보면:
|
||||||
|
|
||||||
|
- [LifeAt pricing](https://lifeat.io/pricing)는 `daily planner`, `multiple calendars`, `unlimited todos and notes`, `advanced analytics`를 함께 판다.
|
||||||
|
- [Focusmate pricing](https://www.focusmate.com/pricing/)는 무료 주 3회, 유료 무제한으로 `accountability access`를 판다.
|
||||||
|
- [Portal](https://portal.app/)은 `immersive spatial audio`와 감각 품질을 전면에 둔다.
|
||||||
|
- [Endel ADHD](https://endel.io/adhd)는 `Focus Timer`, `Task Headline`, `App Blocker`, active exercises를 ADHD 친화적 도구로 묶는다.
|
||||||
|
- [Brain.fm pricing](https://www.brain.fm/pricing)는 `science-backed music` 자체를 프리미엄 가치로 판다.
|
||||||
|
|
||||||
|
VibeRoom의 Weekly Review는 LifeAt처럼 planner analytics로 가면 안 된다.
|
||||||
|
또 Focusmate처럼 외부 accountability를 핵심으로 가도 안 된다.
|
||||||
|
|
||||||
|
VibeRoom의 review는 아래처럼 포지셔닝해야 한다.
|
||||||
|
|
||||||
|
- planner review가 아니다
|
||||||
|
- performance dashboard도 아니다
|
||||||
|
- `내가 어떤 조건에서 실제로 시작하고 복귀하는지`를 보여주는 focus quality review다
|
||||||
|
|
||||||
|
즉, review는 Pro에서 이렇게 팔아야 한다.
|
||||||
|
|
||||||
|
> 더 많은 일을 관리하는 리뷰가 아니라, 덜 흔들리고 더 빨리 다시 시작하게 해주는 리뷰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 사용자 정의
|
||||||
|
|
||||||
|
### Primary User
|
||||||
|
|
||||||
|
- ADHD 성향이 있거나 시작 마찰이 큰 프리랜서
|
||||||
|
- 해야 할 일은 알지만 시작과 복귀가 어렵다
|
||||||
|
- 감각 환경이 집중 품질에 영향을 준다
|
||||||
|
- 통계는 싫지 않지만, 너무 많은 정보는 오히려 회피를 만든다
|
||||||
|
|
||||||
|
### Weekly Review에서의 실제 질문
|
||||||
|
|
||||||
|
- 나는 언제 시작이 잘 되었지?
|
||||||
|
- 어떤 배경/사운드/길이에서 덜 무너졌지?
|
||||||
|
- pause 후에 잘 돌아온 편인가?
|
||||||
|
- 다음 주에는 뭘 하나만 바꾸면 될까?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Weekly Review 경험 원칙
|
||||||
|
|
||||||
|
### 1. Review는 지난 주를 닫고 다음 주를 여는 ritual이어야 한다
|
||||||
|
|
||||||
|
- 회고는 회고로 끝나면 안 된다
|
||||||
|
- 반드시 다음 주의 첫 세션과 연결되어야 한다
|
||||||
|
|
||||||
|
### 2. 총 시간보다 행동 전환을 우선한다
|
||||||
|
|
||||||
|
- focus minutes는 보조 지표다
|
||||||
|
- 핵심은 `start`, `recovery`, `completion`
|
||||||
|
|
||||||
|
### 3. 모든 해석은 근거가 있는 factual summary여야 한다
|
||||||
|
|
||||||
|
- “당신은 밤형 인간입니다” 같은 해석 금지
|
||||||
|
- “최근 7일 중 오전 9시~12시 시작 세션의 완료율이 가장 높았어요”처럼
|
||||||
|
raw metric에 직접 연결되는 문장만 허용
|
||||||
|
|
||||||
|
### 4. 한 화면에서 바꾸는 제안은 최대 2개만
|
||||||
|
|
||||||
|
- insight가 많아 보이는 것은 premium이 아니다
|
||||||
|
- `다음 주엔 이 두 가지만 바꿔보자`가 더 premium하다
|
||||||
|
|
||||||
|
### 5. Review는 planner가 아니라 focus optimizer여야 한다
|
||||||
|
|
||||||
|
- todo completion analytics 금지
|
||||||
|
- calendar productivity reporting 금지
|
||||||
|
- session quality와 ritual fit만 본다
|
||||||
|
|
||||||
|
### 6. Free도 충분히 가치 있어야 하고, Pro는 더 깊어야 한다
|
||||||
|
|
||||||
|
- Free는 `기본적인 self-awareness`
|
||||||
|
- Pro는 `행동 변화에 쓸 수 있는 pattern visibility`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 정보 구조
|
||||||
|
|
||||||
|
Weekly Review는 `/stats` 안에서 아래 5개 구역으로 재구성한다.
|
||||||
|
|
||||||
|
### 현재 visual shell 원칙
|
||||||
|
|
||||||
|
- `/stats`는 밝은 factual dashboard가 아니라 dark immersive review stage로 간다
|
||||||
|
- 배경은 carry-forward ritual과 연결되는 atmosphere를 얇게 투영한다
|
||||||
|
- 상단은 quiet accessory만 두고, hero summary가 화면의 중심이 된다
|
||||||
|
- snapshot metrics는 작은 glass tile rail로, start/recovery/completion은 같은 family의 review panel로 통일한다
|
||||||
|
- 마지막 carry-forward CTA는 별도 버튼 묶음이 아니라 다음 세션 entry로 자연스럽게 이어지는 closure stage여야 한다
|
||||||
|
- `/app`의 entry ritual과는 다르게, `/stats`는 `observatory / signal board` 감각으로 읽혀야 한다
|
||||||
|
- 즉 같은 glass family는 유지하되, 입력 ritual을 닮은 중앙 구조를 복제하지 않는다
|
||||||
|
|
||||||
|
### Section A. Weekly Snapshot
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 이번 주를 한눈에 닫아주는 첫 문장
|
||||||
|
|
||||||
|
구성:
|
||||||
|
|
||||||
|
- title: `이번 주 집중 리듬`
|
||||||
|
- subline: `시작 9회 · 복귀 5회 · 완료 4회`
|
||||||
|
- short summary 1줄
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- `이번 주에는 시작은 자주 했고, pause 뒤 복귀는 절반 정도 이어졌어요.`
|
||||||
|
- `짧은 세션보다 45분 이상 세션에서 완료율이 더 높았어요.`
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
|
||||||
|
- summary는 1줄만
|
||||||
|
- 과장 금지
|
||||||
|
- 반드시 실제 수치에서 바로 읽히는 문장만 허용
|
||||||
|
|
||||||
|
### Section B. Start Quality
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 사용자가 `들어가는 힘`을 얼마나 잘 만들었는지 보여준다
|
||||||
|
|
||||||
|
노출 지표:
|
||||||
|
|
||||||
|
- started sessions
|
||||||
|
- start days
|
||||||
|
- first start consistency
|
||||||
|
- planned start vs ad-hoc start ratio
|
||||||
|
|
||||||
|
핵심 질문:
|
||||||
|
|
||||||
|
- 이번 주에 시작 자체를 자주 했는가?
|
||||||
|
- 특정 요일/시간대에서 시작이 잘 되었는가?
|
||||||
|
|
||||||
|
추천 카드:
|
||||||
|
|
||||||
|
- `시작 횟수`
|
||||||
|
- `가장 잘 시작된 시간대`
|
||||||
|
- `첫 세션 평균 시작 시각`
|
||||||
|
|
||||||
|
### Section C. Recovery Quality
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- pause / away 이후 얼마나 다시 올라탔는지 보여준다
|
||||||
|
|
||||||
|
노출 지표:
|
||||||
|
|
||||||
|
- paused sessions
|
||||||
|
- returned after pause
|
||||||
|
- returned after away
|
||||||
|
- recovery rate
|
||||||
|
- avg time to recovery
|
||||||
|
|
||||||
|
핵심 질문:
|
||||||
|
|
||||||
|
- 흔들린 뒤에도 다시 돌아왔는가?
|
||||||
|
- pause와 away 중 어디서 더 많이 끊겼는가?
|
||||||
|
|
||||||
|
추천 카드:
|
||||||
|
|
||||||
|
- `복귀율`
|
||||||
|
- `pause 뒤 복귀`
|
||||||
|
- `자리 비움 뒤 복귀`
|
||||||
|
|
||||||
|
### Section D. Completion Quality
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 끝까지 간 세션과 goal closure를 보여준다
|
||||||
|
|
||||||
|
노출 지표:
|
||||||
|
|
||||||
|
- completed sessions
|
||||||
|
- completed goals
|
||||||
|
- break started after completion
|
||||||
|
- carry-over count
|
||||||
|
|
||||||
|
핵심 질문:
|
||||||
|
|
||||||
|
- 마무리까지 가는 흐름이 있었는가?
|
||||||
|
- 목표를 닫지 못하고 다음 날로 밀린 경우가 많았는가?
|
||||||
|
|
||||||
|
추천 카드:
|
||||||
|
|
||||||
|
- `완료한 블록`
|
||||||
|
- `끝까지 간 세션 비율`
|
||||||
|
- `이월된 블록`
|
||||||
|
|
||||||
|
### Section E. Ritual Fit
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- 어떤 scene / sound / timer 조합이 더 잘 맞는지 보여준다
|
||||||
|
|
||||||
|
노출 원칙:
|
||||||
|
|
||||||
|
- Free는 단일 best-fit hint 1개만
|
||||||
|
- Pro는 ritual 비교까지 허용
|
||||||
|
|
||||||
|
예시:
|
||||||
|
|
||||||
|
- `Forest Draft · 50분에서 가장 오래 이어졌어요.`
|
||||||
|
- `Rain 계열에서는 pause 후 복귀가 더 높았어요.`
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- 이것도 “추천”이지 “정답 판정”이 아니다
|
||||||
|
- 적은 샘플 수에서는 확정적 문장 금지
|
||||||
|
|
||||||
|
### Section F. Next Week Carry Forward
|
||||||
|
|
||||||
|
목적:
|
||||||
|
|
||||||
|
- review를 다음 세션 행동으로 연결
|
||||||
|
|
||||||
|
구성:
|
||||||
|
|
||||||
|
- `다음 주에 유지할 것`
|
||||||
|
- `다음 주에 바꿔볼 것`
|
||||||
|
- CTA: `이 ritual로 다음 세션 시작`
|
||||||
|
|
||||||
|
이 섹션이 없으면 review는 예쁜 요약으로 끝난다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 핵심 지표 정의
|
||||||
|
|
||||||
|
### Base Metrics
|
||||||
|
|
||||||
|
- `startedSessions`
|
||||||
|
- `completedSessions`
|
||||||
|
- `completedGoals`
|
||||||
|
- `pausedSessions`
|
||||||
|
- `returnedAfterPause`
|
||||||
|
- `awayCandidates`
|
||||||
|
- `returnedAfterAway`
|
||||||
|
- `focusMinutes`
|
||||||
|
- `breakSessions`
|
||||||
|
- `carriedOverCount`
|
||||||
|
|
||||||
|
### Derived Metrics
|
||||||
|
|
||||||
|
- `startSuccessRate`
|
||||||
|
- started days / active days
|
||||||
|
- `completionRate`
|
||||||
|
- completed sessions / started sessions
|
||||||
|
- `pauseRecoveryRate`
|
||||||
|
- returned after pause / paused sessions
|
||||||
|
- `awayRecoveryRate`
|
||||||
|
- returned after away / away candidates
|
||||||
|
- `carryOverRate`
|
||||||
|
- carried over goals / created goals
|
||||||
|
- `ritualFitScore`
|
||||||
|
- completion + recovery + avg session depth를 조합한 비교용 내부 점수
|
||||||
|
|
||||||
|
중요:
|
||||||
|
|
||||||
|
- `ritualFitScore`는 내부 계산용이다
|
||||||
|
- 사용자에게 raw score를 그대로 보여주지 않는다
|
||||||
|
- 사용자에게는 `이번 주 가장 잘 맞은 조합`처럼 문장으로만 보여준다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 허용되는 해석과 금지되는 해석
|
||||||
|
|
||||||
|
### 허용
|
||||||
|
|
||||||
|
- `최근 7일 중 오전 시작 세션의 완료율이 가장 높았어요.`
|
||||||
|
- `25분보다 50분 세션에서 복귀율이 높았어요.`
|
||||||
|
- `Forest + Birds에서 pause 뒤 복귀가 가장 높았어요.`
|
||||||
|
|
||||||
|
### 금지
|
||||||
|
|
||||||
|
- `당신은 밤형 인간이에요.`
|
||||||
|
- `의지가 부족했어요.`
|
||||||
|
- `이번 주 생산성이 낮았어요.`
|
||||||
|
- `당신은 긴 세션에 맞지 않아요.`
|
||||||
|
|
||||||
|
원칙:
|
||||||
|
|
||||||
|
- 행동 데이터 -> 짧은 문장
|
||||||
|
- 심리 진단 -> 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. UI/UX 방향
|
||||||
|
|
||||||
|
### 전체 톤
|
||||||
|
|
||||||
|
- stats dashboard가 아니라 calm review desk
|
||||||
|
- 라이트 팔레트는 유지하되, 문서성과 감정적 안정감이 있어야 한다
|
||||||
|
- 차트는 줄이고, 문장과 비교를 늘린다
|
||||||
|
|
||||||
|
### 시각 원칙
|
||||||
|
|
||||||
|
- 큰 숫자 카드 남발 금지
|
||||||
|
- 한 줄 summary와 2~3개 핵심 비교를 우선
|
||||||
|
- “이번 주 / 시작 / 복귀 / 완료 / 다음 주” 순서로 읽히게 한다
|
||||||
|
- 회고가 무거운 분석처럼 보이면 안 된다
|
||||||
|
|
||||||
|
### 좋은 흐름
|
||||||
|
|
||||||
|
1. 이번 주 한 줄 요약
|
||||||
|
2. 시작은 어땠나
|
||||||
|
3. 흔들린 뒤 돌아왔나
|
||||||
|
4. 끝까지 갔나
|
||||||
|
5. 다음 주엔 뭘 유지/변경할까
|
||||||
|
|
||||||
|
### 나쁜 흐름
|
||||||
|
|
||||||
|
1. 카드 12개
|
||||||
|
2. 차트 4개
|
||||||
|
3. 해석 7개
|
||||||
|
4. 추천 9개
|
||||||
|
|
||||||
|
이건 LifeAt식 dashboard로 읽히고, VibeRoom과 맞지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Free / Pro 재정의
|
||||||
|
|
||||||
|
### Free Weekly Review
|
||||||
|
|
||||||
|
Free는 다음만 보여준다.
|
||||||
|
|
||||||
|
- 이번 주 snapshot 1개
|
||||||
|
- Start Quality 핵심 카드 1~2개
|
||||||
|
- Recovery Quality 핵심 카드 1개
|
||||||
|
- Completion Quality 핵심 카드 1개
|
||||||
|
- Next Week Carry Forward 한 줄 제안 1개
|
||||||
|
|
||||||
|
Free에서 하지 않는 것:
|
||||||
|
|
||||||
|
- multi-week trend
|
||||||
|
- ritual 비교표
|
||||||
|
- atmosphere/duration 조합 비교
|
||||||
|
- archive 기반 long-term pattern
|
||||||
|
|
||||||
|
Free 가치:
|
||||||
|
|
||||||
|
- `내가 이번 주에 어떻게 집중했는지`를 짧게 이해
|
||||||
|
- 다음 세션에 바로 적용할 한 줄 얻기
|
||||||
|
|
||||||
|
### Pro Weekly Review
|
||||||
|
|
||||||
|
Pro는 아래까지 연다.
|
||||||
|
|
||||||
|
- 4주 trend
|
||||||
|
- ritual fit comparison
|
||||||
|
- start window pattern
|
||||||
|
- recovery pattern split
|
||||||
|
- pause recovery
|
||||||
|
- away recovery
|
||||||
|
- best ritual recommendation
|
||||||
|
- carry-forward note history
|
||||||
|
- direct CTA
|
||||||
|
- `이 ritual로 다음 세션 시작`
|
||||||
|
- `이번 주 가장 잘 맞은 조합으로 시작`
|
||||||
|
|
||||||
|
Pro 가치:
|
||||||
|
|
||||||
|
- 더 깊은 분석이 아니라
|
||||||
|
- `내가 어떤 조건에서 덜 무너지는지`를 실제로 알게 한다
|
||||||
|
|
||||||
|
### BM 원칙
|
||||||
|
|
||||||
|
Review는 Pro에서 이렇게 팔아야 한다.
|
||||||
|
|
||||||
|
- “고급 통계”
|
||||||
|
- “생산성 분석”
|
||||||
|
|
||||||
|
가 아니라
|
||||||
|
|
||||||
|
- `내 집중 리듬을 더 잘 이해하게 해주는 weekly review`
|
||||||
|
- `다음 주의 시작 성공률을 높여주는 personalized reflection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 사용자 플로우
|
||||||
|
|
||||||
|
### Flow A. 주간 진입
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
|
||||||
|
- `/stats` 직접 진입
|
||||||
|
- `/app` teaser 클릭
|
||||||
|
- 주말/주간 종료 시점의 passive prompt
|
||||||
|
|
||||||
|
flow:
|
||||||
|
|
||||||
|
1. snapshot hero
|
||||||
|
2. start / recovery / completion
|
||||||
|
3. ritual fit
|
||||||
|
4. next week carry forward
|
||||||
|
5. next CTA
|
||||||
|
|
||||||
|
### Flow B. 세션 직후 light review teaser
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
|
||||||
|
- 금주 4번째 completed session
|
||||||
|
- 또는 금주 1번째 return after pause
|
||||||
|
|
||||||
|
teaser 예시:
|
||||||
|
|
||||||
|
- `이번 주 pause 뒤 복귀가 3번 있었어요`
|
||||||
|
- `Forest Draft 50분에서 가장 잘 이어졌어요`
|
||||||
|
|
||||||
|
CTA:
|
||||||
|
|
||||||
|
- `주간 review 보기`
|
||||||
|
|
||||||
|
### Flow C. 다음 세션 연결
|
||||||
|
|
||||||
|
weekly review 마지막 CTA:
|
||||||
|
|
||||||
|
- `이 조합으로 다음 세션 시작`
|
||||||
|
- `/app`으로 연결하되 atmosphere와 duration을 prefill
|
||||||
|
|
||||||
|
즉 review는 읽고 끝나는 화면이 아니라 다음 entry에 연결돼야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 정보 설계와 컴포넌트 구조 제안
|
||||||
|
|
||||||
|
### `/stats` 1-screen IA
|
||||||
|
|
||||||
|
1. Header
|
||||||
|
2. Weekly Snapshot hero
|
||||||
|
3. Start Quality
|
||||||
|
4. Recovery Quality
|
||||||
|
5. Completion Quality
|
||||||
|
6. Ritual Fit
|
||||||
|
7. Next Week Carry Forward
|
||||||
|
|
||||||
|
### 프론트 컴포넌트 제안
|
||||||
|
|
||||||
|
- `WeeklyReviewHero`
|
||||||
|
- `ReviewMetricPair`
|
||||||
|
- `RecoverySplitCard`
|
||||||
|
- `RitualFitHighlight`
|
||||||
|
- `CarryForwardPanel`
|
||||||
|
|
||||||
|
현재의 작은 stat card 반복을 그대로 늘리는 방향은 피한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. API / 데이터 계약 제안
|
||||||
|
|
||||||
|
### 새 응답 모델
|
||||||
|
|
||||||
|
`GET /api/v1/stats/weekly-review`
|
||||||
|
|
||||||
|
response shape:
|
||||||
|
|
||||||
|
- `weekRange`
|
||||||
|
- `snapshot`
|
||||||
|
- `startQuality`
|
||||||
|
- `recoveryQuality`
|
||||||
|
- `completionQuality`
|
||||||
|
- `ritualFit`
|
||||||
|
- `carryForward`
|
||||||
|
- `source`
|
||||||
|
|
||||||
|
### snapshot
|
||||||
|
|
||||||
|
- `startedSessions`
|
||||||
|
- `returnedSessions`
|
||||||
|
- `completedGoals`
|
||||||
|
- `focusMinutes`
|
||||||
|
- `summaryLine`
|
||||||
|
|
||||||
|
### startQuality
|
||||||
|
|
||||||
|
- `startedSessions`
|
||||||
|
- `activeDays`
|
||||||
|
- `bestStartWindow`
|
||||||
|
- `adHocVsPlanned`
|
||||||
|
|
||||||
|
### recoveryQuality
|
||||||
|
|
||||||
|
- `pausedSessions`
|
||||||
|
- `returnedAfterPause`
|
||||||
|
- `awayCandidates`
|
||||||
|
- `returnedAfterAway`
|
||||||
|
- `recoveryRate`
|
||||||
|
|
||||||
|
### completionQuality
|
||||||
|
|
||||||
|
- `completedSessions`
|
||||||
|
- `completedGoals`
|
||||||
|
- `carriedOverCount`
|
||||||
|
- `completionRate`
|
||||||
|
|
||||||
|
### ritualFit
|
||||||
|
|
||||||
|
- `topScene`
|
||||||
|
- `topSound`
|
||||||
|
- `topTimerPreset`
|
||||||
|
- `topComboSummary`
|
||||||
|
- `confidence`
|
||||||
|
|
||||||
|
### carryForward
|
||||||
|
|
||||||
|
- `keepDoing`
|
||||||
|
- `tryNext`
|
||||||
|
- `recommendedEntryPreset`
|
||||||
|
|
||||||
|
### write endpoint
|
||||||
|
|
||||||
|
선택 기능:
|
||||||
|
|
||||||
|
`POST /api/v1/stats/weekly-review/carry-forward`
|
||||||
|
|
||||||
|
용도:
|
||||||
|
|
||||||
|
- 다음 주 유지/변경 메모 저장
|
||||||
|
|
||||||
|
초기 v1에서는 read-only로 시작해도 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 구현 순서
|
||||||
|
|
||||||
|
### Slice 1. 데이터 모델 재정의
|
||||||
|
|
||||||
|
- weekly-review 응답 계약 설계
|
||||||
|
- mock + API 양쪽 shape 통일
|
||||||
|
|
||||||
|
### Slice 2. `/stats` IA 재구성
|
||||||
|
|
||||||
|
- factual summary card 반복을 해체
|
||||||
|
- hero + 4 quality sections + carry-forward로 재배치
|
||||||
|
|
||||||
|
### Slice 3. copy / interpretation system
|
||||||
|
|
||||||
|
- 허용되는 summary line 규칙 정리
|
||||||
|
- 과한 해석 제거
|
||||||
|
|
||||||
|
### Slice 4. Free / Pro gating
|
||||||
|
|
||||||
|
- Free는 7일 snapshot 중심
|
||||||
|
- Pro는 4주 trend / ritual fit 확장
|
||||||
|
|
||||||
|
### Slice 5. `/app` 연결
|
||||||
|
|
||||||
|
- review teaser
|
||||||
|
- next-session start CTA 연결
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 완료 기준
|
||||||
|
|
||||||
|
- `/stats`가 더 이상 generic dashboard처럼 보이지 않는다
|
||||||
|
- 사용자가 review를 보고 `다음 주엔 이걸 바꿔봐야겠다`를 얻는다
|
||||||
|
- review가 다음 세션 entry와 연결된다
|
||||||
|
- Free에서도 충분한 가치가 있고, Pro는 확실히 더 깊다
|
||||||
|
- Pro 가치가 `더 많은 분석`이 아니라 `더 잘 시작하고 덜 무너지는 자기 이해`로 읽힌다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 절대 피해야 할 방향
|
||||||
|
|
||||||
|
- planner analytics로 확장
|
||||||
|
- task completion dashboard화
|
||||||
|
- 생산성 점수화
|
||||||
|
- 심리 진단처럼 보이는 카피
|
||||||
|
- 차트와 카드 수를 premium으로 오해하는 것
|
||||||
|
- review를 `/space`보다 더 시끄러운 화면으로 만드는 것
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Session Brief
|
# Session Brief
|
||||||
|
|
||||||
Last Updated: 2026-03-05
|
Last Updated: 2026-03-16
|
||||||
|
|
||||||
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
||||||
|
|
||||||
@@ -14,23 +14,168 @@ Last Updated: 2026-03-05
|
|||||||
|
|
||||||
## 현재 우선순위
|
## 현재 우선순위
|
||||||
|
|
||||||
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
|
1. `/space` completion result modal browser QA
|
||||||
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리
|
2. `/space` current-session-only cleanup
|
||||||
3. Stage 가독성/모션/레이어 폴리시 최종 정리
|
3. `Core Loop Alignment` browser audit
|
||||||
|
4. `Weekly Review` carry-forward 고도화
|
||||||
|
|
||||||
## 최근 세션 상태
|
## 최근 세션 상태
|
||||||
|
|
||||||
|
- `/app` 전면 재설계 방향을 새 spec으로 고정했다.
|
||||||
|
- no-session `/app`은 `goal + duration + atmosphere` 중심의 premium entry stage로 바뀐다.
|
||||||
|
- microStep은 `/app`에서 제거하고, duration은 분 단위 직접 입력으로 전환한다.
|
||||||
|
- scene + sound 조합 카드는 `Atmosphere`로 부르며, 첫 구현은 12개 dummy grid를 사용한다.
|
||||||
|
- weekly review와 achieved-goal insight는 main stage가 아니라 quiet secondary dock로 유지한다.
|
||||||
|
- `/app` Atmosphere Entry Shell 1차 구현을 반영했다.
|
||||||
|
- no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다.
|
||||||
|
- 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다.
|
||||||
|
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
||||||
|
- duration은 raw `focusDurationMinutes`로 server에 전달한다.
|
||||||
|
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
||||||
|
- `/app` Atmosphere Entry Shell visual premium polish를 반영했다.
|
||||||
|
- utility card 묶음 대신 `decision rail + selected atmosphere stage + curated library` 구조로 재구성했다.
|
||||||
|
- 좌측은 입력과 결심, 우측은 immersive preview, 하단은 12개 atmosphere library로 위계를 고정했다.
|
||||||
|
- review entry는 start stage 아래의 quiet dock로 낮춰 main CTA와 경쟁하지 않게 정리했다.
|
||||||
|
- `/app`은 이제 session gate를 보여주지 않는다.
|
||||||
|
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다.
|
||||||
|
- 따라서 paused resume gate와 takeover sheet는 current UX가 아니다.
|
||||||
|
- server `startSession()`은 여전히 current session 존재 시 direct start를 거절해 silent abandon을 막는다.
|
||||||
|
- `/space` Refocus System 첫 slice를 구현했다.
|
||||||
|
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
||||||
|
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
||||||
|
- paused 상태의 refocus는 `적용하고 이어가기`로 바로 resume까지 연결된다.
|
||||||
|
- microStep 완료 후에는 checklist가 아니라 `다음 한 조각이 있나요?` prompt로만 이어진다.
|
||||||
|
- recovery UI는 `paused / refocus / next-beat / complete` 중 하나만 열리도록 단일 overlay 상태로 묶였다.
|
||||||
|
- `/space` goal complete 종료 경로를 복구했다.
|
||||||
|
- `여기서 마무리하기`로 현재 목표를 다음 목표 입력 없이도 정상 완료 처리할 수 있다.
|
||||||
|
- pause prompt의 `이대로 이어가기`는 단순 닫기가 아니라 실제 resume으로 연결된다.
|
||||||
|
- `/space` goal complete / next beat를 덜 form스럽게 정리했다.
|
||||||
|
- goal complete는 처음부터 input을 요구하지 않고, 선택지를 먼저 보여준 뒤 `다음 목표 이어가기`를 선택했을 때만 입력이 열린다.
|
||||||
|
- next-beat prompt는 현재 goal 문맥을 함께 보여줘서 사용자가 어떤 목표를 이어가는지 잃지 않게 했다.
|
||||||
|
- `/space` recovery tray material과 선택 위계를 같은 패밀리로 맞추기 시작했다.
|
||||||
|
- pause / next-beat / complete tray가 공통 dark-glass shell을 공유한다.
|
||||||
|
- inline 링크 중심이던 선택지를 quiet option row 구조로 바꿔, checklist보다 recovery decision처럼 읽히게 정리했다.
|
||||||
|
- `Goal Complete`는 `다음 블록 이어가기 / 잠시 비우기 / 여기서 마무리하기`를 같은 tray 안의 선택 행으로 제시한다.
|
||||||
|
- `Refocus`는 같은 shell 안에서 field / action 톤을 통일해 다른 tray와 같은 제품군처럼 보이게 맞추는 중이다.
|
||||||
|
- `/space` Away / Return Recovery를 구현했다.
|
||||||
|
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 감지를 추가했다.
|
||||||
|
- 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둬 오탐을 줄였다.
|
||||||
|
- 돌아왔을 때 focus가 계속 running이면 `Return` tray가 `이어서 하기 / 한 조각 다시 잡기`를 제안한다.
|
||||||
|
- 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray가 먼저 뜬다.
|
||||||
|
- 이 경우 `쉬기 이어가기 / 다음 목표 이어가기 / 한 조각 다시 잡기` 중 하나를 고를 수 있다.
|
||||||
|
- `다음 목표 이어가기`는 goal complete next view로 바로 연결된다.
|
||||||
|
- pause tray의 visual polish를 진행했다.
|
||||||
|
- tray 폭과 max-height를 늘려 한국어 제목/설명 잘림을 줄였다.
|
||||||
|
- title/body line-height와 spacing을 다시 잡아 임시 패널 느낌을 줄였다.
|
||||||
|
- option row의 radius, padding, chevron 정렬을 보정해 더 차분한 recovery panel처럼 읽히게 했다.
|
||||||
|
- `Pause / Break / Return`의 감정 톤 분리를 시작했다.
|
||||||
|
- `Return(break)`은 focus 복귀 tray와 같은 재질을 쓰지 않고, 더 부드러운 emerald tint release tone으로 분리했다.
|
||||||
|
- `Goal Complete`의 `잠시 비우기` 선택도 같은 release tone으로 연결했다.
|
||||||
|
- timer HUD도 break phase에서는 더 가벼운 emerald 계열 material로 바뀌어 pause/focus와 다르게 읽히도록 정리 중이다.
|
||||||
|
- `Pause / Break / Return`의 카피와 CTA 위계를 2차로 분리했다.
|
||||||
|
- `Pause`는 `멈춘 이유`보다 `다시 시작할 한 줄`에 집중하는 recovery tone으로 다시 썼다.
|
||||||
|
- `Return(focus)`는 `이어가기`, `Return(break)`는 `쉬기 이어가기 / 다음 블록 이어가기` 중심으로 문구를 분리했다.
|
||||||
|
- `Goal Complete`는 `이어가기 / 잠시 비우기 / 마무리하기`의 선택 tray가 먼저 보이고, 다음 블록 입력은 이후 단계에서만 열리도록 더 선명해졌다.
|
||||||
|
- `Pause / Break / Return`의 motion polish 1차를 반영했다.
|
||||||
|
- `Pause`는 빠르게 다시 붙잡는 recovery reveal로,
|
||||||
|
- `Return(focus)`는 재진입에 맞는 짧은 settle motion으로,
|
||||||
|
- `Return(break)`와 `Goal Complete`는 더 느슨한 release/closure reveal로 분리했다.
|
||||||
|
- `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다.
|
||||||
|
- `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다.
|
||||||
|
- `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다.
|
||||||
|
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다.
|
||||||
|
- `00:00`이 되면 `완료하고 종료하기 / 10분 더` 모달이 자동으로 열린다.
|
||||||
|
- `10분 더`는 server `extend-phase` 계약을 타고 현재 focus phase를 10분 연장한 뒤 다시 running으로 이어진다.
|
||||||
|
- 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다.
|
||||||
|
- `/space` 종료 결과 모달이 추가됐다.
|
||||||
|
- `완료하고 종료하기`와 `여기서 마무리하기`는 바로 `/app`으로 가지 않고 중앙 결과 모달을 먼저 띄운다.
|
||||||
|
- 결과 모달에는 `집중한 시간`, `완료한 목표`, `이번 세션 thought capsule`이 들어간다.
|
||||||
|
- 닫기 전까지 `/space`는 결과 모달 상태로 유지되고, `확인하고 돌아가기`에서만 `/app`으로 이동한다.
|
||||||
|
- thought capsule은 서버가 현재 세션에 내부 귀속한다.
|
||||||
|
- 클라이언트는 `focusSessionId`를 보내지 않는다.
|
||||||
|
- `/space`는 current session이 살아 있으면 server `current thoughts` API로 same-session thought 목록을 복원한다.
|
||||||
|
- 그래서 브라우저 재시작 후에도 같은 세션이라면 결과 모달에 같은 thought들이 포함된다.
|
||||||
|
- `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다.
|
||||||
|
- idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다.
|
||||||
|
- microStep과 `이번 목표 완료`는 expanded 상태에서만 드러난다.
|
||||||
|
- recovery tray가 열리면 base card는 자동으로 collapsed 상태를 유지한다.
|
||||||
|
- expanded rail은 outside click으로 접히지만, decision tray는 outside click으로 닫히지 않는다.
|
||||||
|
- rail 클릭은 펼침/접힘만 담당하고, 수정은 expanded 상태의 `수정` 액션으로만 진입한다.
|
||||||
|
- `/app`은 legacy single-goal gate에서 `goal + duration + atmosphere` 중심의 새 entry shell로 전환 중이다.
|
||||||
|
- current session이 있으면 `Resume` gate가 우선 노출된다.
|
||||||
|
- no-session 상태는 `goal 1개 + 예상 시간 + 선택된 atmosphere + 시작 CTA` 구조로 재설계 중이다.
|
||||||
|
- `microStep`은 `/app`에서 제거하고 `/space` recovery/refocus에서만 다시 잡는다.
|
||||||
|
- weekly review는 hero 아래 카드가 아니라 quiet secondary dock/entry로 유지한다.
|
||||||
|
- manage/list 성격의 affordance는 메인 진입 경로에서 계속 배제한다.
|
||||||
|
- `/space`는 execution-only surface로 정리됐다.
|
||||||
|
- setup drawer에서 Daily Plan / Ritual Library 섹션을 제거했다.
|
||||||
|
- goal, scene, sound, timer만 확인하고 focus HUD로 진입한다.
|
||||||
|
- 목표 완료 후 다음 목표 즉시 실행 흐름이 backend contract와 연결됐다.
|
||||||
|
- GoalCompleteSheet confirm 시 `advance-goal` endpoint를 사용한다.
|
||||||
|
- 현재 세션 완료, linked plan item 완료, 새 current item 생성, 다음 세션 시작을 한 번에 처리한다.
|
||||||
|
- 실패 시 시트를 닫지 않고 그대로 재시도할 수 있다.
|
||||||
|
- `/stats`는 factual summary에서 `Weekly Review` 1차 구조로 올라갔다.
|
||||||
|
- hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다.
|
||||||
|
- 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다.
|
||||||
|
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다.
|
||||||
|
- `/stats` immersive review stage polish를 반영했다.
|
||||||
|
- dark atmosphere 배경 위에 중앙 hero summary, snapshot signal board, review panel, carry-forward closure stage를 같은 glass family로 재구성했다.
|
||||||
|
- `/app`의 premium immersive tone과 같은 제품군은 유지하되, 입력 ritual을 닮지 않는 stats 고유의 observatory 톤으로 분리했다.
|
||||||
|
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
||||||
|
- current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다.
|
||||||
|
- review entry는 main start CTA보다 항상 낮은 강조를 유지한다.
|
||||||
|
- `/stats` 마지막 CTA의 `/app` return handoff가 연결됐다.
|
||||||
|
- carry-forward CTA는 `/app?review=weekly&carryHint=...`로 돌아온다.
|
||||||
|
- `/app`은 review-aware return hint를 먼저 보여주되, goal은 사용자가 직접 입력하게 유지한다.
|
||||||
|
- `Weekly Review Entry Flow`의 Pro personalized handoff까지 연결됐다.
|
||||||
|
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다.
|
||||||
|
- `/stats` 마지막 CTA와 `/app` teaser / return hint가 더 구체적인 handoff 톤으로 바뀐다.
|
||||||
|
- `Weekly Review` recovery의 서버 연결이 들어갔다.
|
||||||
|
- server `focus-summary` 응답에 `recovery`가 추가됐다.
|
||||||
|
- 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다.
|
||||||
|
- session routing 정책을 다시 단순화했다.
|
||||||
|
- current session이 있으면 `/app`에서 머무르지 않고 바로 `/space`로 이동한다.
|
||||||
|
- `/space`는 paused session이라고 `/app`으로 되돌리지 않는다.
|
||||||
|
- `/app`은 no-session entry surface로만 남는다.
|
||||||
|
- `Product Alignment Audit` 운영을 시작했다.
|
||||||
|
- `docs/product/16_product_alignment_audit_plan.md`를 기준 문서로 추가했다.
|
||||||
|
- `docs/product/17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다.
|
||||||
|
- 다음 라운드는 route-flow matrix와 state contract matrix를 만드는 static audit이다.
|
||||||
|
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
||||||
|
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
||||||
|
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
||||||
|
- `/app`은 더 이상 `/space` redirect가 아니다.
|
||||||
|
- `FocusDashboardWidget`에서 goal only start와 plan-linked start를 모두 처리한다.
|
||||||
|
- Free에서 두 번째 블록 추가 시도는 manage sheet 안에서 paywall로 진입한다.
|
||||||
|
- 플랜 tier를 route 간에 공유하도록 정리했다.
|
||||||
|
- `usePlanTier` localStorage store를 추가해 `/app`, `/space`, `/stats`가 같은 Free/Pro 상태를 본다.
|
||||||
|
- Session OS mock 도메인을 추가했다.
|
||||||
|
- `FocusPlanItem`, `SessionTemplate`, `SessionOutcome`, `WeeklyReview`, `AsyncCheckIn` 모델과 mock 데이터를 `entities/session`에 추가했다.
|
||||||
|
- `/space` stage 배경을 overscan으로 보정했다.
|
||||||
|
- background layer를 `-inset-8`로 확장해 pan 애니메이션 중 가장자리 빈틈 노출을 줄였다.
|
||||||
|
- Gemini가 진행한 대형 파일 분리를 다시 점검했다.
|
||||||
|
- `SpaceWorkspaceWidget`, `SpaceToolsDockWidget`, `admin/page.tsx`, `shared/i18n/ko.ts` 모두 500줄 기준 안으로 정리된 상태를 재확인했다.
|
||||||
|
- 현재 주요 최대 파일은 `AdminDashboardView.tsx` 482줄, `useSpaceWorkspaceSelection.ts` 440줄 수준이다.
|
||||||
|
- `/admin` 분리 과정의 placeholder 회귀를 복구했다.
|
||||||
|
- 로그인 후 `Dashboard is under construction`만 보이던 상태를 제거했다.
|
||||||
|
- 실제 scene/sound 업로드 폼을 `AdminDashboardView`로 복원해 `AdminConsoleWidget`은 조합만 담당하도록 되돌렸다.
|
||||||
|
- 인증 전역 저장소 위치를 정리했다.
|
||||||
|
- `src/store/useAuthStore.ts`를 제거했다.
|
||||||
|
- 인증 상태 저장소를 `entities/auth/model/useAuthStore.ts`로 이동해 feature가 루트 store를 직접 참조하지 않도록 정리했다.
|
||||||
|
- `/space` 배경 asset 해석을 보강했다.
|
||||||
|
- media manifest scene key를 alias-aware 하게 정규화해 `green-forest`와 `forest`를 같은 scene asset으로 읽는다.
|
||||||
|
- scene/sound asset에 `source(fallback|remote)` 메타를 추가해 remote asset 사용 여부를 코드에서 바로 식별할 수 있다.
|
||||||
|
- remote manifest load 실패와 scene fallback 사용 시 `/space`에서 진단 로그를 남기도록 보강했다.
|
||||||
- Focus 피드백 채널을 상단 중앙 1곳으로 통합했다.
|
- Focus 피드백 채널을 상단 중앙 1곳으로 통합했다.
|
||||||
- HUD 내부 status line 제거
|
- HUD 내부 status line 제거
|
||||||
- Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시
|
- Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시
|
||||||
- 기본 기능 잠금을 해소했다.
|
- 기본 기능 잠금을 해소했다.
|
||||||
- Time `90/20`을 Free로 개방
|
|
||||||
- 기본 Sound 잠금 제거
|
- 기본 Sound 잠금 제거
|
||||||
- Pro 잠금 구조를 Packs/Profiles 중심으로 재구성했다.
|
- Pro 잠금 구조를 Session OS 중심으로 재구성했다.
|
||||||
- `Scene Packs / Sound Packs / Profiles` 요약 카드 추가
|
- `Daily Focus Plan / Rituals / Weekly Review` 요약 카드 추가
|
||||||
- 기본 Scene/Time/Sound는 잠금 없이 선택 가능
|
- 기본 Scene/Time/Sound는 잠금 없이 선택 가능
|
||||||
- Paywall 시트는 잠금 카드 클릭에서만 열리도록 바꿨다.
|
- Paywall 시트는 잠금 카드 클릭에서만 열리도록 바꿨다.
|
||||||
- Plan Pill(NORMAL) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출
|
- Plan Pill(FREE) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출
|
||||||
- Paywall 카피를 3개 가치 포인트 + 2개 CTA로 간결화
|
- Paywall 카피를 3개 가치 포인트 + 2개 CTA로 간결화
|
||||||
- Focus-First 구조로 전환했다.
|
- Focus-First 구조로 전환했다.
|
||||||
- Quick Controls의 모드 전환 토글(기본/몰입)을 제거했다.
|
- Quick Controls의 모드 전환 토글(기본/몰입)을 제거했다.
|
||||||
@@ -40,23 +185,23 @@ Last Updated: 2026-03-05
|
|||||||
- 옵션: `컨트롤 자동 숨김`
|
- 옵션: `컨트롤 자동 숨김`
|
||||||
- ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리
|
- ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리
|
||||||
- `/space`에 Scene 추천 자동 적용 규칙을 도입했다.
|
- `/space`에 Scene 추천 자동 적용 규칙을 도입했다.
|
||||||
- Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다.
|
- Room 데이터에 `recommendedSoundPresetId`를 추가했다.
|
||||||
- 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다.
|
- 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다.
|
||||||
- `/space`에 override 상태(`sound`, `timer`)를 추가했다.
|
- `/space`에 override 상태(`sound`, `duration`)를 추가했다.
|
||||||
- 사용자가 직접 고른 사운드/타이머는 Scene 변경에도 자동 덮어쓰지 않는다.
|
- 사용자가 직접 고른 사운드/duration은 Scene 변경에도 자동 덮어쓰지 않는다.
|
||||||
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원이 가능하다.
|
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원이 가능하다.
|
||||||
- Control Center를 Scene/Time 중심으로 단순화했다.
|
- Control Center를 Scene/Time 중심으로 단순화했다.
|
||||||
- Sound/Preset Packs 섹션 제거
|
- Sound/Preset Packs 섹션 제거
|
||||||
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지
|
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지
|
||||||
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
|
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
|
||||||
- `/space` 선택 상태 로컬 저장/복원을 추가했다.
|
- `/space` 선택 상태 로컬 저장/복원을 추가했다.
|
||||||
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`
|
- 저장: `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`
|
||||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||||
- `/space` 진입 시 Resume CTA를 추가했다.
|
- `/space` 진입 시 Resume CTA를 추가했다.
|
||||||
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출
|
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출
|
||||||
- `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환
|
- `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환
|
||||||
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
|
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
|
||||||
- `workFlow.md`는 토큰 절약 모드를 사용한다.
|
- `ops/workFlow.md`는 토큰 절약 모드를 사용한다.
|
||||||
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
|
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
|
||||||
- `/space` 헤더 프레임을 축소하고 HUD를 하단 safe-area 기준으로 더 밀착시켰다.
|
- `/space` 헤더 프레임을 축소하고 HUD를 하단 safe-area 기준으로 더 밀착시켰다.
|
||||||
- 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다.
|
- 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다.
|
||||||
@@ -114,6 +259,14 @@ Last Updated: 2026-03-05
|
|||||||
|
|
||||||
## 리스크
|
## 리스크
|
||||||
|
|
||||||
|
- Session OS 데이터는 아직 mock 기반이므로 실제 저장/동기화 API 없이도 화면이 그럴듯하게만 보일 수 있다.
|
||||||
|
- empty state에서 CTA는 유지하지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다.
|
||||||
|
- current item이 아닌 preview row 선택은 ad-hoc start로 처리되므로, 큐 재정렬을 기대하는 사용자와 정신 모델 차이가 날 수 있다.
|
||||||
|
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다.
|
||||||
|
- stage background overscan으로 좁은 화면에서 배경 crop 체감이 조금 더 커질 수 있다.
|
||||||
|
- remote manifest 실패 시 원인 진단은 가능하지만, 사용자용 복구 CTA는 아직 없다.
|
||||||
|
- `/admin` 업로드 콘솔은 타입/구조상 복구됐지만 실제 브라우저 업로드 스모크는 아직 필요하다.
|
||||||
|
- alias 목록에 없는 legacy scene id가 다시 들어오면 scene fallback 문제가 재발할 수 있다.
|
||||||
- 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
|
- 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
|
||||||
- localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
|
- localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
|
||||||
- Scene 추천값이 사용자 선호와 어긋나면 자동 추천 체감 품질이 낮을 수 있음
|
- Scene 추천값이 사용자 선호와 어긋나면 자동 추천 체감 품질이 낮을 수 있음
|
||||||
@@ -135,5 +288,5 @@ Last Updated: 2026-03-05
|
|||||||
## 상세 원문 위치
|
## 상세 원문 위치
|
||||||
|
|
||||||
- 장문 상세 상태: `docs/90_current_state.md`
|
- 장문 상세 상태: `docs/90_current_state.md`
|
||||||
- 구조 규칙 상세: `docs/02_arch_fsd_rules.md`
|
- 구조 규칙 상세: `docs/foundation/02_arch_fsd_rules.md`
|
||||||
- 커밋 규칙 상세: `docs/06_commit_convention.md`
|
- 커밋 규칙 상세: `docs/foundation/06_commit_convention.md`
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
[Step 0: 컨텍스트 로드]
|
[Step 0: 컨텍스트 로드]
|
||||||
아래 파일을 먼저 읽고, 핵심 규칙/현재 상태/다음 작업을 10줄 내로 요약해라.
|
아래 파일을 먼저 읽고, 핵심 규칙/현재 상태/다음 작업을 10줄 내로 요약해라.
|
||||||
|
|
||||||
- docs/00_project_brief.md
|
- docs/foundation/00_project_brief.md
|
||||||
- docs/01_ui_guidelines.md
|
- docs/foundation/01_ui_guidelines.md
|
||||||
- docs/02_arch_fsd_rules.md
|
- docs/foundation/02_arch_fsd_rules.md
|
||||||
- docs/03_routes_map.md
|
- docs/foundation/03_routes_map.md
|
||||||
- docs/04_coding_rules.md
|
- docs/foundation/04_coding_rules.md
|
||||||
- docs/05_handoff_checklist.md
|
- docs/ops/05_handoff_checklist.md
|
||||||
- docs/06_commit_convention.md
|
- docs/foundation/06_commit_convention.md
|
||||||
- docs/07_session_recovery.md
|
- docs/ops/07_session_recovery.md
|
||||||
- docs/90_current_state.md
|
- docs/90_current_state.md
|
||||||
|
|
||||||
[Step 1: 현재 상태 점검]
|
[Step 1: 현재 상태 점검]
|
||||||
@@ -30,5 +30,5 @@ docs/90_current_state.md의 “NEXT” 섹션에 적힌 우선순위 1번 작업
|
|||||||
|
|
||||||
- docs/90_current_state.md를 업데이트해라:
|
- docs/90_current_state.md를 업데이트해라:
|
||||||
- DONE / NEXT / RISKS / CHANGED FILES
|
- DONE / NEXT / RISKS / CHANGED FILES
|
||||||
- 필요하면 docs/01_ui_guidelines.md 또는 docs/02_arch_fsd_rules.md에 결정사항을 추가해라
|
- 필요하면 docs/foundation/01_ui_guidelines.md 또는 docs/foundation/02_arch_fsd_rules.md에 결정사항을 추가해라
|
||||||
- 변경사항이 합리적인 단위로 커밋될 수 있도록 커밋 메시지 초안을 제안해라
|
- 변경사항이 합리적인 단위로 커밋될 수 있도록 커밋 메시지 초안을 제안해라
|
||||||
|
|||||||
143
docs/work.md
143
docs/work.md
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
- 작업은 가능한 한 "주제별"로 분리해서 작성한다.
|
- 작업은 가능한 한 "주제별"로 분리해서 작성한다.
|
||||||
- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다.
|
- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다.
|
||||||
- "금지사항/제외 범위"를 명시해서 불필요한 변경을 막는다.
|
- `finding -> fix -> docs -> validation -> commit`까지 한 라운드에서 닫는다.
|
||||||
|
- browser QA가 필요한 작업은 반드시 완료 조건에 명시한다.
|
||||||
|
|
||||||
## 우선순위
|
## 우선순위
|
||||||
|
|
||||||
@@ -17,111 +18,119 @@
|
|||||||
|
|
||||||
## 작업 1
|
## 작업 1
|
||||||
|
|
||||||
- 제목: 코어 루프 완성 — Goal Complete Sheet(다음 한 조각 입력) 마감
|
- 제목: `/space` completion result modal browser QA
|
||||||
- 목적:
|
- 목적:
|
||||||
- 이 앱의 재방문/체감은 “완료 → 다음 목표”가 자연스럽게 이어질 때 생긴다.
|
- 세션 완전 종료 직후 결과 모달이 자연스럽게 뜨는지 확인한다.
|
||||||
- Focus 화면에서 목표 완료가 폼 UI처럼 보이지 않도록 하고, 완료 후 다음 한 조각 입력 플로우를 프리미엄스럽게 만든다.
|
- timer-complete, End Session finish, 10분 더 이후 재종료, thought 복원 흐름을 실제 브라우저에서 검증한다.
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- Focus HUD의 목표는 “1줄 앵커”로 유지(상시 큰 카드 금지)
|
- `/space` timer-complete finish
|
||||||
- 완료 트리거(1개만 선택해 고정):
|
- `/space` End Session finish
|
||||||
- Goal 1줄 앵커 롱프레스(1초) 또는 작은 ghost ‘완료’(체크박스 금지)
|
- current-session thoughts restore
|
||||||
- 완료 시 Goal Complete Sheet 표시(하단 시트)
|
- 결과 모달 -> `/app`
|
||||||
- 타이틀 + 입력 1개 + 추천 칩 4개 + CTA 2개(바로 다음 조각 시작 / 잠깐 쉬기)
|
|
||||||
- Primary 클릭 시 다음 목표로 교체(더미) + 시트 닫기
|
|
||||||
- Secondary는 Break(더미) 또는 토스트 + 시트 닫기
|
|
||||||
- 전역 블러/딤 금지, 모션 200~300ms 저자극
|
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- 서버/DB/통계/실제 타이머 로직 구현 금지
|
- 새로운 stats/weekly review 기능 추가 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- 완료 → 다음 목표 입력 → 바로 시작이 2~3스텝 내로 끝난다.
|
- 종료 직후 중앙 결과 모달이 보인다
|
||||||
|
- 결과 모달에 집중 시간/목표/thought가 맞게 보인다
|
||||||
|
- 결과 모달 확인 후에만 `/app`으로 이동한다
|
||||||
|
- 브라우저 재실행 뒤 current session이 살아 있으면 thought capture가 유지된다
|
||||||
|
- 진행 상태:
|
||||||
|
- 다음 작업
|
||||||
- 검증:
|
- 검증:
|
||||||
- npx tsc --noEmit
|
- manual browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
- feat(goal): Goal Complete Sheet로 다음 한 조각 루프 완성
|
- docs(qa): completion result modal browser audit 기록
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 2
|
## 작업 2
|
||||||
|
|
||||||
- 제목: 세션 이어가기(Resume) — 새로고침/재진입 시 “지난 한 조각 이어서”
|
- 제목: `/app` Atmosphere Entry Shell
|
||||||
- 목적:
|
- 목적:
|
||||||
- 출시 전이라도 “다시 들어왔을 때 바로 이어서 시작”이 되면 사용성이 급격히 좋아지고 재방문을 만든다.
|
- `docs/screens/app/current/19_app_atmosphere_entry_spec.md` 기준으로 `/app` no-session 상태를 `goal + duration + atmosphere` 중심의 premium entry screen으로 재설계한다.
|
||||||
|
- entry에서 scene/sound의 감각 품질을 다시 살리되 planner/dashboard 톤으로 흐르지 않게 만든다.
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- 로컬 저장(더미)으로 마지막 상태를 복원:
|
- no-session `/app` shell
|
||||||
- 마지막 목표, Scene, Timer, Sound, override flags
|
- atmosphere dummy 12개
|
||||||
- /space 진입 시 “지난 한 조각 이어서”를 조용한 CTA로 제공(Setup가 아니라 Focus 진입 직전에 1회)
|
- goal input
|
||||||
- 사용자가 거절하면 새 세션(목표 입력)로
|
- duration input
|
||||||
- 카피는 저자극/확정 표현 금지
|
- 4x3 atmosphere grid
|
||||||
|
- primary CTA
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- 로그인/서버 동기화 금지
|
- `/space` recovery UX 재설계 금지
|
||||||
|
- weekly review 상세 IA 변경 금지
|
||||||
|
- server contract 변경 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- 새로고침 후에도 마지막 세션이 이어지는 것처럼 보이고, 이어서 시작이 가능하다.
|
- current session이 없을 때만 새 entry shell이 보인다
|
||||||
|
- goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다
|
||||||
|
- 12개 dummy atmosphere가 4열 그리드로 배치된다
|
||||||
|
- 진행 상태:
|
||||||
|
- 구현 완료, visual premium polish 반영, browser QA 대기
|
||||||
- 검증:
|
- 검증:
|
||||||
- npx tsc --noEmit
|
- `/app` no-session browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
- feat(resume): 지난 세션 이어서(더미) 플로우 추가
|
- feat(app): atmosphere entry shell 1차 구현
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 3
|
## 작업 3
|
||||||
|
|
||||||
- 제목: Recover 시그니처 — Notes(쓰기 전용) → Inbox(읽기/정리) + 30초 숨고르기 정리
|
- 제목: `Custom Duration Contract`
|
||||||
- 목적:
|
- 목적:
|
||||||
- ADHD 타겟의 차별점은 “산만해져도 다시 돌아오는 비용”을 줄이는 것이다.
|
- `/app`의 분 단위 duration 입력을 실제 세션 길이로 반영한다.
|
||||||
- 쓰기와 읽기/정리를 분리해 몰입을 깨지 않게 한다.
|
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- Notes 팝오버는 쓰기 전용(리스트/정리 버튼 제거)
|
- web start payload
|
||||||
- Inbox는 도크 시트에서 읽기/정리(완료/삭제 + Undo 더미)
|
- server startSession contract
|
||||||
- 30초 숨고르기(더미) 흐름 정리:
|
- break duration 정책
|
||||||
- 버튼 카피/위치/동작을 “다시 돌아오기” 느낌으로
|
- `/space` timer 연동
|
||||||
- 과한 UI 추가 금지
|
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- 실제 타이머/오디오 로직 구현 금지
|
- weekly review recommendation 확장 금지
|
||||||
|
- atmosphere personalization 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- Focus 중에는 쓰기만, 정리는 Inbox에서만 가능하며 복귀 흐름이 자연스럽다.
|
- `70분` 같은 값이 실제 focus duration으로 반영된다
|
||||||
|
- break duration이 정책 기준으로 계산된다
|
||||||
|
- 진행 상태:
|
||||||
|
- 구현 완료
|
||||||
- 검증:
|
- 검증:
|
||||||
- npx tsc --noEmit
|
- start -> `/space` -> timer duration 확인
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
- feat(recover): Notes→Inbox 복귀 흐름 및 30초 숨고르기 정리
|
- feat(flow): custom duration contract 연결
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 4
|
## 작업 4
|
||||||
|
|
||||||
- 제목: Stage 폴리시 규칙 고정 + 마감(가독성/모션/레이어)
|
- 제목: `Weekly Review Dock Reposition`
|
||||||
- 목적:
|
- 목적:
|
||||||
- Portal/LifeAt 느낌은 “미세한 마감”에서 결정된다.
|
- 새 `/app` entry shell 안에서 weekly review를 start를 방해하지 않는 quiet secondary dock로 재배치한다.
|
||||||
- 앞선 코어 동선이 확정된 후, 가독성과 모션/레이어를 일관되게 다듬는다.
|
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- 밝은/어두운 배경 모두에서 HUD/앵커 가독성 안정(전역 blur 금지, 로컬 스크림 최소)
|
- `/app` review teaser placement
|
||||||
- 모션 200~300ms 저자극 통일
|
- desktop/mobile responsive placement
|
||||||
- 아이콘/버튼 간격/재질 통일(글래스 톤)
|
- review return hint placement
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- 기능 추가 금지(스타일/레이어만)
|
- `/stats` IA 변경 금지
|
||||||
|
- `/space` recovery UX 재설계 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- 배경이 달라도 핵심 정보가 항상 읽히고, 전체가 프리미엄스럽게 정돈된다.
|
- review entry는 항상 발견 가능하지만 start보다 앞서지 않는다
|
||||||
|
- no-session shell 안에서만 quiet secondary dock로 읽힌다
|
||||||
|
- 진행 상태:
|
||||||
|
- 대기
|
||||||
- 검증:
|
- 검증:
|
||||||
- npx tsc --noEmit
|
- `/app` no-session browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
- style(stage): 가독성/모션/레이어 폴리시
|
- fix(app): review dock 위치 재정렬
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 작업 5
|
## 작업 5
|
||||||
|
|
||||||
- 제목: Pro/Paywall 최소 연결(의도 기반) — Packs/Profiles 중심
|
- 제목: `Core Loop Alignment Audit` browser slice
|
||||||
- 목적:
|
- 목적:
|
||||||
- 기본 기능 잠금 없이, 확장/품질/개인화로 유료 이유를 만든다.
|
- 새 `/app` entry shell까지 포함한 핵심 흐름을 브라우저에서 실제로 검증한다.
|
||||||
- Focus를 방해하지 않고 클릭 의도 기반으로만 paywall을 연다.
|
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- Time 같은 기본 기능 LOCK 제거 유지
|
- `/app` no-session
|
||||||
- Pro는 Scene Packs / Sound Packs / Profile 저장으로 재배치
|
- current session 상태에서 `/app -> /space` redirect
|
||||||
- Paywall Sheet(더미) 구현: 잠긴 항목 클릭 시에만 노출
|
- `/app -> /stats -> /app`
|
||||||
|
- `/space` pause / return / next beat / complete
|
||||||
|
- `/space` complete -> setup -> weekly review entry
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- 실제 결제 연동 금지
|
- new feature 추가 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- Pro가 “확장/팩/개인화”로 이해되고, Focus 흐름을 방해하지 않는다.
|
- browser QA findings가 ledger에 반영된다
|
||||||
|
- P1/P2 mismatch는 수정 대상 라운드로 분리된다
|
||||||
- 검증:
|
- 검증:
|
||||||
- npx tsc --noEmit
|
- manual browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
- feat(paywall): 의도 기반 Pro 진입/Paywall(더미) 연결
|
- docs(qa): core-loop browser audit 기록
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"react-apple-signin-auth": "^1.1.2",
|
"react-apple-signin-auth": "^1.1.2",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-facebook-login": "^4.1.1",
|
"react-facebook-login": "^4.1.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -6167,6 +6168,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss-animate": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react-apple-signin-auth": "^1.1.2",
|
"react-apple-signin-auth": "^1.1.2",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-facebook-login": "^4.1.1",
|
"react-facebook-login": "^4.1.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { FocusDashboardWidget } from '@/widgets/focus-dashboard';
|
||||||
|
|
||||||
export default function AppPage() {
|
export default function AppPage() {
|
||||||
redirect('/space');
|
return <FocusDashboardWidget />;
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/app/(app)/layout.tsx
Normal file
19
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TOKEN_COOKIE_KEY } from "@/shared/config/authTokens";
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const accessToken = cookieStore.get(TOKEN_COOKIE_KEY)?.value;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SocialLoginGroup } from "@/features/auth/components/SocialLoginGroup";
|
import { SocialLoginGroup } from "@/features/auth/components/SocialLoginGroup";
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const { login } = copy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex flex-col justify-center items-center p-6 selection:bg-brand-soft/50">
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center items-center p-6 selection:bg-brand-soft/50">
|
||||||
|
|
||||||
{/* 상단 로고 (홈으로 돌아가기) */}
|
{/* 상단 로고 (홈으로 돌아가기) */}
|
||||||
<Link href="/" className="mb-12 text-2xl font-bold text-brand-dark tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity">
|
<Link href="/" className="mb-12 text-2xl font-bold text-brand-dark tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
<span className="text-3xl">🪴</span> VibeRoom
|
<span className="text-3xl">🪴</span> {copy.appName}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 로그인 카드 컨테이너 */}
|
{/* 로그인 카드 컨테이너 */}
|
||||||
<div className="w-full max-w-md bg-white rounded-3xl shadow-sm border border-brand-dark/10 p-8 md:p-10">
|
<div className="w-full max-w-md bg-white rounded-3xl shadow-sm border border-brand-dark/10 p-8 md:p-10">
|
||||||
|
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<h1 className="text-2xl font-bold text-brand-dark mb-3">다시 오셨군요!</h1>
|
<h1 className="text-2xl font-bold text-brand-dark mb-3">{login.title}</h1>
|
||||||
<p className="text-brand-dark/60 text-sm">
|
<p className="text-brand-dark/60 text-sm">
|
||||||
비밀번호를 외울 필요 없이,<br />
|
{login.descriptionFirstLine}<br />
|
||||||
사용 중인 계정으로 3초 만에 시작하세요.
|
{login.descriptionSecondLine}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,8 +31,8 @@ export default function LoginPage() {
|
|||||||
<SocialLoginGroup />
|
<SocialLoginGroup />
|
||||||
|
|
||||||
<div className="mt-10 text-center text-xs text-brand-dark/40 leading-relaxed">
|
<div className="mt-10 text-center text-xs text-brand-dark/40 leading-relaxed">
|
||||||
로그인함으로써 VibeRoom의 <br className="md:hidden" />
|
{login.agreementPrefix} <br className="md:hidden" />
|
||||||
<a href="#" className="underline hover:text-brand-dark/70">이용약관</a> 및 <a href="#" className="underline hover:text-brand-dark/70">개인정보처리방침</a>에 동의하게 됩니다.
|
<a href="#" className="underline hover:text-brand-dark/70">{login.terms}</a> {login.agreementAnd} <a href="#" className="underline hover:text-brand-dark/70">{login.privacy}</a>{login.agreementSuffix}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { AuthLandingLoginButton } from "@/features/auth/components/AuthLandingLoginButton";
|
||||||
|
import { AuthRedirectButton } from "@/features/auth/components/AuthRedirectButton";
|
||||||
import { Button } from "@/shared/ui/Button";
|
import { Button } from "@/shared/ui/Button";
|
||||||
|
|
||||||
export default function MarketingPage() {
|
export default function MarketingPage() {
|
||||||
|
const { landing } = copy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-brand-dark font-sans selection:bg-brand-soft/50">
|
<div className="min-h-screen bg-slate-50 text-brand-dark font-sans selection:bg-brand-soft/50">
|
||||||
|
|
||||||
@@ -9,14 +14,16 @@ export default function MarketingPage() {
|
|||||||
<header className="sticky top-0 z-50 w-full border-b border-brand-dark/10 bg-slate-50/80 backdrop-blur-md">
|
<header className="sticky top-0 z-50 w-full border-b border-brand-dark/10 bg-slate-50/80 backdrop-blur-md">
|
||||||
<div className="container mx-auto px-6 h-16 flex items-center justify-between">
|
<div className="container mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
<Link href="/" className="text-xl font-bold text-brand-dark tracking-tight flex items-center gap-2">
|
<Link href="/" className="text-xl font-bold text-brand-dark tracking-tight flex items-center gap-2">
|
||||||
<span className="text-2xl">🪴</span> VibeRoom
|
<span className="text-2xl">🪴</span> {copy.appName}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-brand-dark/80">
|
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-brand-dark/80">
|
||||||
<Button variant="ghost" size="sm" href="#features">기능 소개</Button>
|
<Button variant="ghost" size="sm" href="#features">{landing.nav.features}</Button>
|
||||||
<Button variant="ghost" size="sm" href="#pricing">요금제</Button>
|
<Button variant="ghost" size="sm" href="#pricing">{landing.nav.pricing}</Button>
|
||||||
<Button variant="ghost" size="sm" href="/login">로그인</Button>
|
<AuthLandingLoginButton variant="ghost" size="sm">
|
||||||
<Button variant="primary" size="md" href="/space">무료로 시작하기</Button>
|
{landing.nav.login}
|
||||||
|
</AuthLandingLoginButton>
|
||||||
|
<AuthRedirectButton variant="primary" size="md">{landing.nav.startFree}</AuthRedirectButton>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -27,19 +34,18 @@ export default function MarketingPage() {
|
|||||||
<div className="container mx-auto max-w-5xl flex flex-col md:flex-row items-center gap-16">
|
<div className="container mx-auto max-w-5xl flex flex-col md:flex-row items-center gap-16">
|
||||||
<div className="flex-1 text-center md:text-left">
|
<div className="flex-1 text-center md:text-left">
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 tracking-tight leading-tight text-brand-dark">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 tracking-tight leading-tight text-brand-dark">
|
||||||
함께하는 조용한 몰입,<br />
|
{landing.hero.titleLead}<br />
|
||||||
<span className="text-brand-primary">VibeRoom</span>
|
<span className="text-brand-primary">{landing.hero.titleAccent}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg md:text-xl text-brand-dark/70 mb-10 max-w-xl mx-auto md:mx-0 leading-relaxed">
|
<p className="text-lg md:text-xl text-brand-dark/70 mb-10 max-w-xl mx-auto md:mx-0 leading-relaxed">
|
||||||
집중하기 어려운 순간, 당신을 다그치지 않는 편안한 공간으로 들어오세요.
|
{landing.hero.description}
|
||||||
구조화된 코워킹 세션과 느슨한 연대가 당신의 페이스를 되찾아 줍니다.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center md:justify-start">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center md:justify-start">
|
||||||
<Button variant="primary" size="lg" href="/space">
|
<AuthRedirectButton variant="primary" size="lg">
|
||||||
나만의 공간 만들기
|
{landing.hero.primaryCta}
|
||||||
</Button>
|
</AuthRedirectButton>
|
||||||
<Button variant="outline" size="lg" href="#features">
|
<Button variant="outline" size="lg" href="#features">
|
||||||
더 알아보기
|
{landing.hero.secondaryCta}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +70,7 @@ export default function MarketingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 h-12 bg-brand-soft/20 rounded-xl border border-brand-primary/20 flex items-center justify-center text-brand-primary font-medium text-sm">
|
<div className="mt-6 h-12 bg-brand-soft/20 rounded-xl border border-brand-primary/20 flex items-center justify-center text-brand-primary font-medium text-sm">
|
||||||
45:00 남음
|
{landing.hero.timerPreview}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,23 +82,19 @@ export default function MarketingPage() {
|
|||||||
<section id="features" className="py-24 bg-white px-6">
|
<section id="features" className="py-24 bg-white px-6">
|
||||||
<div className="container mx-auto max-w-6xl">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">당신을 위한 다정한 몰입 장치</h2>
|
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">{landing.features.title}</h2>
|
||||||
<p className="text-brand-dark/70 text-lg">단순한 타이머가 아닙니다. 무리하지 않고 오래 지속할 수 있는 환경을 제공합니다.</p>
|
<p className="text-brand-dark/70 text-lg">{landing.features.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{[
|
{landing.features.items.map((feature, idx) => (
|
||||||
{ icon: "⏳", title: "구조화된 세션 타이머", desc: "부담 없이 시작할 수 있는 짧은 몰입과 확실한 휴식. 당신만의 작업 리듬을 부드럽게 설정하고 관리하세요." },
|
|
||||||
{ icon: "🌱", title: "다정한 연대와 코워킹", desc: "화면 너머 누군가와 함께하는 바디 더블링 효과. 감시가 아닌, 조용하지만 강력한 동기를 서로 나누어보세요." },
|
|
||||||
{ icon: "🛋️", title: "나만의 심미적 공간", desc: "비 오는 다락방, 햇살 드는 카페. 백색소음과 함께 내가 가장 편안함을 느끼는 가상 공간을 꾸미고 머무르세요." }
|
|
||||||
].map((feature, idx) => (
|
|
||||||
<div key={idx} className="p-8 bg-brand-soft/10 rounded-2xl border border-brand-soft/30 transition-transform hover:-translate-y-1 duration-300 hover:shadow-md">
|
<div key={idx} className="p-8 bg-brand-soft/10 rounded-2xl border border-brand-soft/30 transition-transform hover:-translate-y-1 duration-300 hover:shadow-md">
|
||||||
<div className="w-14 h-14 bg-white text-brand-primary rounded-2xl flex items-center justify-center mb-6 text-2xl shadow-sm border border-brand-dark/5">
|
<div className="w-14 h-14 bg-white text-brand-primary rounded-2xl flex items-center justify-center mb-6 text-2xl shadow-sm border border-brand-dark/5">
|
||||||
{feature.icon}
|
{feature.icon}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-brand-dark mb-3">{feature.title}</h3>
|
<h3 className="text-xl font-bold text-brand-dark mb-3">{feature.title}</h3>
|
||||||
<p className="text-brand-dark/70 leading-relaxed">
|
<p className="text-brand-dark/70 leading-relaxed">
|
||||||
{feature.desc}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -104,65 +106,64 @@ export default function MarketingPage() {
|
|||||||
<section id="pricing" className="py-24 px-6 bg-slate-50">
|
<section id="pricing" className="py-24 px-6 bg-slate-50">
|
||||||
<div className="container mx-auto max-w-5xl">
|
<div className="container mx-auto max-w-5xl">
|
||||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">나에게 맞는 공간 선택하기</h2>
|
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">{landing.pricing.title}</h2>
|
||||||
<p className="text-brand-dark/70 text-lg">개인의 가벼운 집중부터 프리랜서의 완벽한 워크스페이스까지.</p>
|
<p className="text-brand-dark/70 text-lg">{landing.pricing.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
|
||||||
{/* Starter Plan */}
|
{/* Starter Plan */}
|
||||||
<div className="p-8 bg-white rounded-3xl border border-brand-dark/10 shadow-sm">
|
<div className="p-8 bg-white rounded-3xl border border-brand-dark/10 shadow-sm">
|
||||||
<h3 className="text-xl font-bold text-brand-dark mb-2">Starter</h3>
|
<h3 className="text-xl font-bold text-brand-dark mb-2">{landing.pricing.plans.starter.name}</h3>
|
||||||
<p className="text-brand-dark/60 text-sm mb-6 h-10">가벼운 집중이 필요한 분</p>
|
<p className="text-brand-dark/60 text-sm mb-6 h-10">{landing.pricing.plans.starter.subtitle}</p>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<span className="text-4xl font-bold text-brand-dark">무료</span>
|
<span className="text-4xl font-bold text-brand-dark">{landing.pricing.plans.starter.price}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-4 mb-8 text-brand-dark/80 text-sm">
|
<ul className="space-y-4 mb-8 text-brand-dark/80 text-sm">
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 기본 가상 공간 테마</li>
|
{landing.pricing.plans.starter.features.map((feature) => (
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 1:1 파트너 매칭 (주 3회)</li>
|
<li key={feature} className="flex items-center gap-3"><span className="text-brand-primary">✓</span>{feature}</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 오픈 코워킹 룸 입장</li>
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Button variant="secondary" size="full" href="/space">
|
<AuthRedirectButton variant="secondary" size="full">
|
||||||
무료로 시작하기
|
{landing.pricing.plans.starter.cta}
|
||||||
</Button>
|
</AuthRedirectButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pro Plan */}
|
{/* Pro Plan */}
|
||||||
<div className="p-8 bg-brand-dark rounded-3xl border border-brand-dark shadow-xl relative transform md:-translate-y-4">
|
<div className="p-8 bg-brand-dark rounded-3xl border border-brand-dark shadow-xl relative transform md:-translate-y-4">
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-brand-primary text-white px-4 py-1 rounded-full text-xs font-bold tracking-wide">
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-brand-primary text-white px-4 py-1 rounded-full text-xs font-bold tracking-wide">
|
||||||
추천
|
{landing.pricing.plans.pro.badge}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-white mb-2">Pro</h3>
|
<h3 className="text-xl font-bold text-white mb-2">{landing.pricing.plans.pro.name}</h3>
|
||||||
<p className="text-white/60 text-sm mb-6 h-10">방해 없는 완벽한 몰입 환경</p>
|
<p className="text-white/60 text-sm mb-6 h-10">{landing.pricing.plans.pro.subtitle}</p>
|
||||||
<div className="mb-8 flex items-baseline gap-1">
|
<div className="mb-8 flex items-baseline gap-1">
|
||||||
<span className="text-4xl font-bold text-white">₩6,900</span>
|
<span className="text-4xl font-bold text-white">{landing.pricing.plans.pro.price}</span>
|
||||||
<span className="text-white/60">/월</span>
|
<span className="text-white/60">{landing.pricing.plans.pro.priceSuffix}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-4 mb-8 text-white/90 text-sm">
|
<ul className="space-y-4 mb-8 text-white/90 text-sm">
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 프리미엄 테마 무제한</li>
|
{landing.pricing.plans.pro.features.map((feature) => (
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 1:1 매칭 무제한</li>
|
<li key={feature} className="flex items-center gap-3"><span className="text-brand-soft">✓</span>{feature}</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 고급 집중 통계 및 리포트</li>
|
))}
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 공간 커스텀 아이템 제공</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<Button variant="primary" size="full" href="/space">
|
<AuthRedirectButton variant="primary" size="full">
|
||||||
Pro 시작하기
|
{landing.pricing.plans.pro.cta}
|
||||||
</Button>
|
</AuthRedirectButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Teams Plan */}
|
{/* Teams Plan */}
|
||||||
<div className="p-8 bg-white rounded-3xl border border-brand-dark/10 shadow-sm">
|
<div className="p-8 bg-white rounded-3xl border border-brand-dark/10 shadow-sm">
|
||||||
<h3 className="text-xl font-bold text-brand-dark mb-2">Teams</h3>
|
<h3 className="text-xl font-bold text-brand-dark mb-2">{landing.pricing.plans.teams.name}</h3>
|
||||||
<p className="text-brand-dark/60 text-sm mb-6 h-10">리모트 워크 기업 및 팀</p>
|
<p className="text-brand-dark/60 text-sm mb-6 h-10">{landing.pricing.plans.teams.subtitle}</p>
|
||||||
<div className="mb-8 flex items-baseline gap-1">
|
<div className="mb-8 flex items-baseline gap-1">
|
||||||
<span className="text-4xl font-bold text-brand-dark">₩12,000</span>
|
<span className="text-4xl font-bold text-brand-dark">{landing.pricing.plans.teams.price}</span>
|
||||||
<span className="text-brand-dark/60 text-sm">/인·월</span>
|
<span className="text-brand-dark/60 text-sm">{landing.pricing.plans.teams.priceSuffix}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-4 mb-8 text-brand-dark/80 text-sm">
|
<ul className="space-y-4 mb-8 text-brand-dark/80 text-sm">
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> Pro 플랜의 모든 기능</li>
|
{landing.pricing.plans.teams.features.map((feature) => (
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 프라이빗 팀 스페이스</li>
|
<li key={feature} className="flex items-center gap-3"><span className="text-brand-primary">✓</span>{feature}</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 팀 전체 생산성 대시보드</li>
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<Button variant="secondary" size="full" href="#contact">
|
<Button variant="secondary" size="full" href="#contact">
|
||||||
도입 문의하기
|
{landing.pricing.plans.teams.cta}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,31 +177,31 @@ export default function MarketingPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Link href="/" className="text-xl font-bold text-white tracking-tight flex items-center gap-2 mb-4">
|
<Link href="/" className="text-xl font-bold text-white tracking-tight flex items-center gap-2 mb-4">
|
||||||
<span className="text-2xl">🪴</span> VibeRoom
|
<span className="text-2xl">🪴</span> {copy.appName}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-white/60 text-sm leading-relaxed max-w-xs">
|
<p className="text-white/60 text-sm leading-relaxed max-w-xs">
|
||||||
프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스입니다.
|
{landing.footer.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-white mb-4">제품</h4>
|
<h4 className="font-bold text-white mb-4">{landing.footer.productTitle}</h4>
|
||||||
<ul className="space-y-3 text-sm text-white/60">
|
<ul className="space-y-3 text-sm text-white/60">
|
||||||
<li><a href="#features" className="hover:text-brand-soft transition-colors">기능 소개</a></li>
|
<li><a href="#features" className="hover:text-brand-soft transition-colors">{landing.footer.links.features}</a></li>
|
||||||
<li><a href="#pricing" className="hover:text-brand-soft transition-colors">요금제</a></li>
|
<li><a href="#pricing" className="hover:text-brand-soft transition-colors">{landing.footer.links.pricing}</a></li>
|
||||||
<li><Link href="/login" className="hover:text-brand-soft transition-colors">웹앱 로그인</Link></li>
|
<li><Link href="/login" className="hover:text-brand-soft transition-colors">{landing.footer.links.webLogin}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-white mb-4">회사</h4>
|
<h4 className="font-bold text-white mb-4">{landing.footer.companyTitle}</h4>
|
||||||
<ul className="space-y-3 text-sm text-white/60">
|
<ul className="space-y-3 text-sm text-white/60">
|
||||||
<li><a href="#" className="hover:text-brand-soft transition-colors">소개</a></li>
|
<li><a href="#" className="hover:text-brand-soft transition-colors">{landing.footer.links.about}</a></li>
|
||||||
<li><a href="#" className="hover:text-brand-soft transition-colors">개인정보처리방침</a></li>
|
<li><a href="#" className="hover:text-brand-soft transition-colors">{landing.footer.links.privacy}</a></li>
|
||||||
<li><a href="#" className="hover:text-brand-soft transition-colors">이용약관</a></li>
|
<li><a href="#" className="hover:text-brand-soft transition-colors">{landing.footer.links.terms}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-white/10 pt-8 text-center text-sm text-white/40">
|
<div className="border-t border-white/10 pt-8 text-center text-sm text-white/40">
|
||||||
© 2026 VibeRoom. All rights reserved.
|
{landing.footer.copyright}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
7
src/app/admin/page.tsx
Normal file
7
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AdminConsoleWidget } from '@/widgets/admin-console';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <AdminConsoleWidget />;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Noto Sans 다국어 폰트 적용 (next/font/google 변수) */
|
/* Noto Sans 다국어 폰트 적용 (next/font/google 변수) */
|
||||||
@@ -81,6 +82,42 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-150 {
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-300 {
|
||||||
|
animation-delay: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
.scrollbar-none {
|
.scrollbar-none {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Noto_Sans_KR } from 'next/font/google';
|
import { Noto_Sans_KR } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
|
|
||||||
// 1. Noto Sans KR 폰트 설정 (라틴어, 프랑스어, 한국어 등 다국어 지원 베이스)
|
// 1. Noto Sans KR 폰트 설정 (라틴어, 프랑스어, 한국어 등 다국어 지원 베이스)
|
||||||
@@ -12,8 +13,8 @@ const notoSans = Noto_Sans_KR({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'VibeRoom - 당신만의 편안한 몰입 공간',
|
title: copy.metadata.title,
|
||||||
description: '프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스. 작업 타이머, 세션 관리, 그리고 느슨한 연대를 통해 당신의 리듬을 찾아보세요.',
|
description: copy.metadata.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -22,7 +23,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko" className={notoSans.variable}>
|
<html lang="en" className={notoSans.variable}>
|
||||||
<body className="antialiased font-sans">
|
<body className="antialiased font-sans">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
2
src/entities/auth/index.ts
Normal file
2
src/entities/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './model/types';
|
||||||
|
export * from './model/useAuthStore';
|
||||||
22
src/entities/auth/model/types.ts
Normal file
22
src/entities/auth/model/types.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export interface SocialLoginRequest {
|
||||||
|
provider: 'google' | 'apple' | 'facebook';
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordLoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMeResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
grade: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user?: UserMeResponse;
|
||||||
|
}
|
||||||
@@ -1,24 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { AuthResponse } from '@/features/auth/types';
|
import { create } from 'zustand';
|
||||||
|
import { REFRESH_TOKEN_COOKIE_KEY, TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
|
||||||
|
import type { AuthResponse } from './types';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
user: AuthResponse['user'] | null;
|
user: AuthResponse['user'] | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
// 액션
|
|
||||||
setAuth: (data: AuthResponse) => void;
|
setAuth: (data: AuthResponse) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 쿠키 키 상수 정의
|
|
||||||
const TOKEN_COOKIE_KEY = 'vr_access_token';
|
|
||||||
const REFRESH_TOKEN_COOKIE_KEY = 'vr_refresh_token';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VibeRoom 전역 인증(Auth) 상태 저장소
|
|
||||||
*/
|
|
||||||
export const useAuthStore = create<AuthState>((set) => {
|
export const useAuthStore = create<AuthState>((set) => {
|
||||||
const isClient = typeof window !== 'undefined';
|
const isClient = typeof window !== 'undefined';
|
||||||
const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null;
|
const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null;
|
||||||
@@ -26,36 +18,31 @@ export const useAuthStore = create<AuthState>((set) => {
|
|||||||
return {
|
return {
|
||||||
accessToken: savedToken || null,
|
accessToken: savedToken || null,
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: !!savedToken,
|
isAuthenticated: Boolean(savedToken),
|
||||||
|
|
||||||
setAuth: (data: AuthResponse) => {
|
setAuth: (data: AuthResponse) => {
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'strict' as const
|
sameSite: 'strict' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Access Token 저장 (7일)
|
|
||||||
Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, {
|
Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, {
|
||||||
...cookieOptions,
|
...cookieOptions,
|
||||||
expires: 7
|
expires: 7,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Refresh Token 저장 (30일)
|
|
||||||
if (data.refreshToken) {
|
if (data.refreshToken) {
|
||||||
Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
|
Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
|
||||||
...cookieOptions,
|
...cookieOptions,
|
||||||
expires: 30
|
expires: 30,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 상태 업데이트
|
|
||||||
set({
|
set({
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken,
|
||||||
user: data.user,
|
user: data.user ?? null,
|
||||||
isAuthenticated: true
|
isAuthenticated: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
Cookies.remove(TOKEN_COOKIE_KEY);
|
Cookies.remove(TOKEN_COOKIE_KEY);
|
||||||
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
|
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
|
||||||
@@ -63,7 +50,7 @@ export const useAuthStore = create<AuthState>((set) => {
|
|||||||
set({
|
set({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
86
src/entities/focus-plan/api/focusPlanApi.ts
Normal file
86
src/entities/focus-plan/api/focusPlanApi.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
import type {
|
||||||
|
CreateFocusPlanItemRequest,
|
||||||
|
FocusPlanItem,
|
||||||
|
FocusPlanItemStatus,
|
||||||
|
FocusPlanToday,
|
||||||
|
UpdateFocusPlanItemRequest,
|
||||||
|
} from '../model/types';
|
||||||
|
|
||||||
|
interface RawFocusPlanItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: FocusPlanItemStatus;
|
||||||
|
sortOrder: number;
|
||||||
|
carriedOverFromDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFocusPlanToday {
|
||||||
|
date: string;
|
||||||
|
currentItem: RawFocusPlanItem | null;
|
||||||
|
nextItems: RawFocusPlanItem[];
|
||||||
|
carriedOverCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeFocusPlanItem = (item: RawFocusPlanItem): FocusPlanItem => {
|
||||||
|
return {
|
||||||
|
id: String(item.id),
|
||||||
|
title: item.title,
|
||||||
|
status: item.status,
|
||||||
|
order: item.sortOrder,
|
||||||
|
carriedOverFromDate: item.carriedOverFromDate,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeFocusPlanToday = (plan: RawFocusPlanToday): FocusPlanToday => {
|
||||||
|
return {
|
||||||
|
date: plan.date,
|
||||||
|
currentItem: plan.currentItem ? normalizeFocusPlanItem(plan.currentItem) : null,
|
||||||
|
nextItems: plan.nextItems.map(normalizeFocusPlanItem),
|
||||||
|
carriedOverCount: plan.carriedOverCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const focusPlanApi = {
|
||||||
|
getToday: async (): Promise<FocusPlanToday> => {
|
||||||
|
const response = await apiClient<RawFocusPlanToday>('api/v1/focus-plan/today', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusPlanToday(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
createItem: async (payload: CreateFocusPlanItemRequest): Promise<FocusPlanToday> => {
|
||||||
|
const response = await apiClient<RawFocusPlanToday>('api/v1/focus-plan/items', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusPlanToday(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateItem: async (itemId: string, payload: UpdateFocusPlanItemRequest): Promise<FocusPlanToday> => {
|
||||||
|
const response = await apiClient<RawFocusPlanToday>(`api/v1/focus-plan/items/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusPlanToday(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
completeItem: async (itemId: string): Promise<FocusPlanToday> => {
|
||||||
|
const response = await apiClient<RawFocusPlanToday>(`api/v1/focus-plan/items/${itemId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusPlanToday(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteItem: async (itemId: string): Promise<FocusPlanToday> => {
|
||||||
|
const response = await apiClient<RawFocusPlanToday>(`api/v1/focus-plan/items/${itemId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusPlanToday(response);
|
||||||
|
},
|
||||||
|
};
|
||||||
3
src/entities/focus-plan/index.ts
Normal file
3
src/entities/focus-plan/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './api/focusPlanApi';
|
||||||
|
export * from './model/types';
|
||||||
|
export * from './model/useFocusPlan';
|
||||||
26
src/entities/focus-plan/model/types.ts
Normal file
26
src/entities/focus-plan/model/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type FocusPlanItemStatus = 'pending' | 'completed';
|
||||||
|
|
||||||
|
export interface FocusPlanItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: FocusPlanItemStatus;
|
||||||
|
order: number;
|
||||||
|
carriedOverFromDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusPlanToday {
|
||||||
|
date: string;
|
||||||
|
currentItem: FocusPlanItem | null;
|
||||||
|
nextItems: FocusPlanItem[];
|
||||||
|
carriedOverCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFocusPlanItemRequest {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFocusPlanItemRequest {
|
||||||
|
title?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
status?: FocusPlanItemStatus;
|
||||||
|
}
|
||||||
122
src/entities/focus-plan/model/useFocusPlan.ts
Normal file
122
src/entities/focus-plan/model/useFocusPlan.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { focusPlanApi } from '../api/focusPlanApi';
|
||||||
|
import type {
|
||||||
|
CreateFocusPlanItemRequest,
|
||||||
|
FocusPlanItem,
|
||||||
|
FocusPlanToday,
|
||||||
|
UpdateFocusPlanItemRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const EMPTY_FOCUS_PLAN: FocusPlanToday = {
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
currentItem: null,
|
||||||
|
nextItems: [],
|
||||||
|
carriedOverCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
type FocusPlanSource = 'api' | 'unavailable';
|
||||||
|
|
||||||
|
interface UseFocusPlanResult {
|
||||||
|
plan: FocusPlanToday;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
source: FocusPlanSource;
|
||||||
|
refetch: () => Promise<FocusPlanToday | null>;
|
||||||
|
createItem: (payload: CreateFocusPlanItemRequest) => Promise<FocusPlanToday | null>;
|
||||||
|
updateItem: (itemId: string, payload: UpdateFocusPlanItemRequest) => Promise<FocusPlanToday | null>;
|
||||||
|
completeItem: (itemId: string) => Promise<FocusPlanToday | null>;
|
||||||
|
deleteItem: (itemId: string) => Promise<FocusPlanToday | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildFocusEntryStartHrefInput {
|
||||||
|
goal: string;
|
||||||
|
planItemId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildFocusEntryStartHref = ({ goal, planItemId }: BuildFocusEntryStartHrefInput) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
goal: goal.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (planItemId) {
|
||||||
|
params.set('planItemId', planItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/space?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildFocusPlanStartHref = (item: FocusPlanItem) => {
|
||||||
|
return buildFocusEntryStartHref({
|
||||||
|
goal: item.title,
|
||||||
|
planItemId: item.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFocusPlan = (): UseFocusPlanResult => {
|
||||||
|
const [plan, setPlan] = useState<FocusPlanToday>(EMPTY_FOCUS_PLAN);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [isSaving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [source, setSource] = useState<FocusPlanSource>('unavailable');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPlan = await focusPlanApi.getToday();
|
||||||
|
setPlan(nextPlan);
|
||||||
|
setSource('api');
|
||||||
|
setError(null);
|
||||||
|
return nextPlan;
|
||||||
|
} catch (nextError) {
|
||||||
|
const message = nextError instanceof Error ? nextError.message : '계획을 불러오지 못했어요.';
|
||||||
|
setPlan(EMPTY_FOCUS_PLAN);
|
||||||
|
setSource('unavailable');
|
||||||
|
setError(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const runMutation = useCallback(
|
||||||
|
async (task: () => Promise<FocusPlanToday>) => {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPlan = await task();
|
||||||
|
setPlan(nextPlan);
|
||||||
|
setSource('api');
|
||||||
|
setError(null);
|
||||||
|
return nextPlan;
|
||||||
|
} catch (nextError) {
|
||||||
|
const message = nextError instanceof Error ? nextError.message : '계획을 저장하지 못했어요.';
|
||||||
|
setError(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
source,
|
||||||
|
refetch: load,
|
||||||
|
createItem: async (payload) => runMutation(() => focusPlanApi.createItem(payload)),
|
||||||
|
updateItem: async (itemId, payload) => runMutation(() => focusPlanApi.updateItem(itemId, payload)),
|
||||||
|
completeItem: async (itemId) => runMutation(() => focusPlanApi.completeItem(itemId)),
|
||||||
|
deleteItem: async (itemId) => runMutation(() => focusPlanApi.deleteItem(itemId)),
|
||||||
|
};
|
||||||
|
};
|
||||||
65
src/entities/media/api/mediaManifestApi.ts
Normal file
65
src/entities/media/api/mediaManifestApi.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { DEFAULT_MEDIA_MANIFEST } from '../model/mockMediaManifest';
|
||||||
|
import { normalizeMediaManifest } from '../model/resolveMediaAsset';
|
||||||
|
import type { MediaManifest } from '../model/types';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
|
const resolveMediaManifestUrl = () => {
|
||||||
|
const explicitManifestUrl = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL;
|
||||||
|
if (explicitManifestUrl) {
|
||||||
|
return explicitManifestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
if (!apiBaseUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${apiBaseUrl.replace(/\/$/, '')}/api/v1/media/manifest`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveManifestFetchCache = (): RequestCache => {
|
||||||
|
const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE;
|
||||||
|
|
||||||
|
if (configuredCacheMode === 'no-store') {
|
||||||
|
return configuredCacheMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredCacheMode === 'force-cache') {
|
||||||
|
return process.env.NODE_ENV === 'development' ? 'force-cache' : 'no-store';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'no-store';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEDIA_MANIFEST_URL = resolveMediaManifestUrl();
|
||||||
|
export const MEDIA_MANIFEST_FETCH_CACHE = resolveManifestFetchCache();
|
||||||
|
|
||||||
|
export const mediaManifestApi = {
|
||||||
|
getManifest: async (signal?: AbortSignal): Promise<MediaManifest> => {
|
||||||
|
if (!MEDIA_MANIFEST_URL) {
|
||||||
|
return DEFAULT_MEDIA_MANIFEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(MEDIA_MANIFEST_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: MEDIA_MANIFEST_FETCH_CACHE,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${copy.media.manifestLoadFailed} (${response.status} at ${MEDIA_MANIFEST_URL})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as Partial<MediaManifest>;
|
||||||
|
return normalizeMediaManifest(payload);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${copy.media.manifestLoadFailed}: ${error instanceof Error ? error.message : String(error)} (URL: ${MEDIA_MANIFEST_URL})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
3
src/entities/media/index.ts
Normal file
3
src/entities/media/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './model/types';
|
||||||
|
export * from './model/useMediaCatalog';
|
||||||
|
export * from './model/resolveMediaAsset';
|
||||||
26
src/entities/media/model/mockMediaManifest.ts
Normal file
26
src/entities/media/model/mockMediaManifest.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { SCENE_THEMES } from '@/entities/scene';
|
||||||
|
import { SOUND_PRESETS } from '@/entities/session';
|
||||||
|
import type { MediaManifest } from './types';
|
||||||
|
|
||||||
|
export const DEFAULT_MEDIA_MANIFEST: MediaManifest = {
|
||||||
|
version: 'local-fallback-v1',
|
||||||
|
updatedAt: '2026-03-09T00:00:00.000Z',
|
||||||
|
cdnBaseUrl: null,
|
||||||
|
scenes: SCENE_THEMES.map((scene) => ({
|
||||||
|
sceneId: scene.id,
|
||||||
|
cardUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||||
|
stageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||||
|
mobileStageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||||
|
hdStageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||||
|
placeholderGradient: scene.previewGradient,
|
||||||
|
})),
|
||||||
|
sounds: SOUND_PRESETS.map((preset) => ({
|
||||||
|
presetId: preset.id,
|
||||||
|
previewUrl: null,
|
||||||
|
loopUrl: null,
|
||||||
|
fallbackLoopUrl: null,
|
||||||
|
mimeType: null,
|
||||||
|
durationSec: null,
|
||||||
|
defaultVolume: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
280
src/entities/media/model/resolveMediaAsset.ts
Normal file
280
src/entities/media/model/resolveMediaAsset.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { normalizeSceneId, type SceneTheme } from '@/entities/scene';
|
||||||
|
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||||
|
import type {
|
||||||
|
MediaManifest,
|
||||||
|
SceneAssetManifestItem,
|
||||||
|
SceneAssetMap,
|
||||||
|
SoundAssetManifestItem,
|
||||||
|
SoundAssetMap,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const DEFAULT_STAGE_GRADIENT = 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)';
|
||||||
|
|
||||||
|
const normalizeSceneAssetId = (sceneId: string) => normalizeSceneId(sceneId) ?? sceneId;
|
||||||
|
|
||||||
|
const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
|
||||||
|
|
||||||
|
const appendCacheBustParam = (value: string, cacheBustKey?: string | null) => {
|
||||||
|
if (!cacheBustKey) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = value.startsWith('/')
|
||||||
|
? new URL(value, 'http://localhost')
|
||||||
|
: new URL(value);
|
||||||
|
|
||||||
|
if (!url.searchParams.has('v')) {
|
||||||
|
url.searchParams.set('v', cacheBustKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('/')) {
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAssetUrl = (
|
||||||
|
value: string | null | undefined,
|
||||||
|
baseUrl?: string | null,
|
||||||
|
cacheBustKey?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsoluteUrl(value) || value.startsWith('/')) {
|
||||||
|
return appendCacheBustParam(value, cacheBustKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
return appendCacheBustParam(value, cacheBustKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return appendCacheBustParam(
|
||||||
|
new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(),
|
||||||
|
cacheBustKey,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return appendCacheBustParam(value, cacheBustKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeSceneAssets = (manifest: MediaManifest) => {
|
||||||
|
const cacheBustKey = manifest.updatedAt || manifest.version;
|
||||||
|
const bySceneId = new Map(
|
||||||
|
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => {
|
||||||
|
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
normalizedSceneId,
|
||||||
|
{
|
||||||
|
...asset,
|
||||||
|
sceneId: normalizedSceneId,
|
||||||
|
source: 'fallback',
|
||||||
|
} as SceneAssetManifestItem,
|
||||||
|
] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const asset of manifest.scenes) {
|
||||||
|
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
|
||||||
|
|
||||||
|
bySceneId.set(normalizedSceneId, {
|
||||||
|
...bySceneId.get(normalizedSceneId),
|
||||||
|
...asset,
|
||||||
|
sceneId: normalizedSceneId,
|
||||||
|
source: 'remote',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(bySceneId.values()).map((asset) => ({
|
||||||
|
...asset,
|
||||||
|
cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl, cacheBustKey) ?? asset.cardUrl,
|
||||||
|
stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
|
mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
|
hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeSoundAssets = (manifest: MediaManifest) => {
|
||||||
|
const cacheBustKey = manifest.updatedAt || manifest.version;
|
||||||
|
const byPresetId = new Map(
|
||||||
|
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [
|
||||||
|
asset.presetId,
|
||||||
|
{
|
||||||
|
...asset,
|
||||||
|
source: 'fallback',
|
||||||
|
} as SoundAssetManifestItem,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const asset of manifest.sounds) {
|
||||||
|
byPresetId.set(asset.presetId, {
|
||||||
|
...byPresetId.get(asset.presetId),
|
||||||
|
...asset,
|
||||||
|
source: 'remote',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byPresetId.values()).map((asset) => ({
|
||||||
|
...asset,
|
||||||
|
previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
|
loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
|
fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeMediaManifest = (manifest: Partial<MediaManifest> | null | undefined): MediaManifest => {
|
||||||
|
const baseManifest: MediaManifest = {
|
||||||
|
version: manifest?.version ?? DEFAULT_MEDIA_MANIFEST.version,
|
||||||
|
updatedAt: manifest?.updatedAt ?? DEFAULT_MEDIA_MANIFEST.updatedAt,
|
||||||
|
cdnBaseUrl: manifest?.cdnBaseUrl ?? DEFAULT_MEDIA_MANIFEST.cdnBaseUrl,
|
||||||
|
scenes: manifest?.scenes ?? DEFAULT_MEDIA_MANIFEST.scenes,
|
||||||
|
sounds: manifest?.sounds ?? DEFAULT_MEDIA_MANIFEST.sounds,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseManifest,
|
||||||
|
scenes: mergeSceneAssets(baseManifest),
|
||||||
|
sounds: mergeSoundAssets(baseManifest),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => {
|
||||||
|
return manifest.scenes.reduce<SceneAssetMap>((accumulator, asset) => {
|
||||||
|
accumulator[normalizeSceneAssetId(asset.sceneId)] = asset;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSoundAssetMap = (manifest: MediaManifest): SoundAssetMap => {
|
||||||
|
return manifest.sounds.reduce<SoundAssetMap>((accumulator, asset) => {
|
||||||
|
accumulator[asset.presetId] = asset;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSceneCardPhotoUrl = (scene: SceneTheme, asset?: SceneAssetManifestItem | null) => {
|
||||||
|
return asset?.cardUrl ?? scene.managedCardPhotoUrl ?? scene.cardPhotoUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSceneStagePhotoUrl = (
|
||||||
|
scene: SceneTheme,
|
||||||
|
asset?: SceneAssetManifestItem | null,
|
||||||
|
options?: { preferMobile?: boolean },
|
||||||
|
) => {
|
||||||
|
if (asset) {
|
||||||
|
if (options?.preferMobile && asset.mobileStageUrl) {
|
||||||
|
return asset.mobileStageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.stageUrl) {
|
||||||
|
return asset.stageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSceneCardPhotoUrl(scene, asset);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SceneStageLayerSources {
|
||||||
|
placeholderGradient: string;
|
||||||
|
underpaintUrl: string | null;
|
||||||
|
baseImageUrl: string | null;
|
||||||
|
finalImageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveSceneStageLayerSources = (
|
||||||
|
scene: SceneTheme,
|
||||||
|
asset?: SceneAssetManifestItem | null,
|
||||||
|
options?: { preferMobile?: boolean; preferHd?: boolean },
|
||||||
|
): SceneStageLayerSources => {
|
||||||
|
const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT;
|
||||||
|
const baseImageUrl =
|
||||||
|
(options?.preferMobile ? asset?.mobileStageUrl : null) ??
|
||||||
|
asset?.mobileStageUrl ??
|
||||||
|
asset?.cardUrl ??
|
||||||
|
scene.managedCardPhotoUrl ??
|
||||||
|
scene.cardPhotoUrl;
|
||||||
|
const preferredFinalImageUrl =
|
||||||
|
(options?.preferHd ? asset?.hdStageUrl : null) ??
|
||||||
|
asset?.stageUrl ??
|
||||||
|
asset?.hdStageUrl ??
|
||||||
|
null;
|
||||||
|
const finalImageUrl =
|
||||||
|
preferredFinalImageUrl && preferredFinalImageUrl !== baseImageUrl
|
||||||
|
? preferredFinalImageUrl
|
||||||
|
: null;
|
||||||
|
const underpaintUrl = baseImageUrl ? null : asset?.blurDataUrl ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
placeholderGradient,
|
||||||
|
underpaintUrl,
|
||||||
|
baseImageUrl,
|
||||||
|
finalImageUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSceneCardBackgroundStyle = (
|
||||||
|
scene: SceneTheme,
|
||||||
|
asset?: SceneAssetManifestItem | null,
|
||||||
|
): CSSProperties => {
|
||||||
|
return {
|
||||||
|
backgroundImage: `url('${getSceneCardPhotoUrl(scene, asset)}')`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSceneStageBackgroundStyle = (
|
||||||
|
scene: SceneTheme,
|
||||||
|
asset?: SceneAssetManifestItem | null,
|
||||||
|
options?: { preferMobile?: boolean },
|
||||||
|
): CSSProperties => {
|
||||||
|
const stageUrl = getSceneStagePhotoUrl(scene, asset, options);
|
||||||
|
const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundImage: `url('${stageUrl}'), ${placeholderGradient}`,
|
||||||
|
backgroundSize: 'cover, cover',
|
||||||
|
backgroundPosition: 'center, center',
|
||||||
|
backgroundRepeat: 'no-repeat, no-repeat',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preloadAssetImage = (url: string | null | undefined) => {
|
||||||
|
if (!url || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new window.Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.src = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAssetImage = (url: string | null | undefined) => {
|
||||||
|
if (!url || typeof window === 'undefined') {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const image = new window.Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.onerror = () => {
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
};
|
||||||
32
src/entities/media/model/types.ts
Normal file
32
src/entities/media/model/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface SceneAssetManifestItem {
|
||||||
|
sceneId: string;
|
||||||
|
cardUrl: string;
|
||||||
|
stageUrl?: string | null;
|
||||||
|
mobileStageUrl?: string | null;
|
||||||
|
hdStageUrl?: string | null;
|
||||||
|
placeholderGradient?: string | null;
|
||||||
|
blurDataUrl?: string | null;
|
||||||
|
source?: 'fallback' | 'remote';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundAssetManifestItem {
|
||||||
|
presetId: string;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
loopUrl?: string | null;
|
||||||
|
fallbackLoopUrl?: string | null;
|
||||||
|
mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null;
|
||||||
|
durationSec?: number | null;
|
||||||
|
defaultVolume?: number | null;
|
||||||
|
source?: 'fallback' | 'remote';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaManifest {
|
||||||
|
version: string;
|
||||||
|
updatedAt: string;
|
||||||
|
cdnBaseUrl?: string | null;
|
||||||
|
scenes: SceneAssetManifestItem[];
|
||||||
|
sounds: SoundAssetManifestItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneAssetMap = Record<string, SceneAssetManifestItem>;
|
||||||
|
export type SoundAssetMap = Record<string, SoundAssetManifestItem>;
|
||||||
194
src/entities/media/model/useMediaCatalog.ts
Normal file
194
src/entities/media/model/useMediaCatalog.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi';
|
||||||
|
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||||
|
import {
|
||||||
|
buildSceneAssetMap,
|
||||||
|
buildSoundAssetMap,
|
||||||
|
normalizeMediaManifest,
|
||||||
|
} from './resolveMediaAsset';
|
||||||
|
import type { MediaManifest } from './types';
|
||||||
|
|
||||||
|
type MediaCatalogLoadResult = {
|
||||||
|
manifest: MediaManifest;
|
||||||
|
error: string | null;
|
||||||
|
usedFallbackManifest: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
||||||
|
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
|
||||||
|
const MANIFEST_REVALIDATE_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
|
||||||
|
if (!MEDIA_MANIFEST_URL) {
|
||||||
|
return {
|
||||||
|
manifest: manifestCache,
|
||||||
|
error: null,
|
||||||
|
usedFallbackManifest: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifestRequest) {
|
||||||
|
manifestRequest = mediaManifestApi
|
||||||
|
.getManifest(signal)
|
||||||
|
.then((manifest) => {
|
||||||
|
manifestCache = manifest;
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
error: null,
|
||||||
|
usedFallbackManifest: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Only return abort errors up the chain if we're not using fallback
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextError = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Explicitly use normalize with null to get a pure fallback manifest
|
||||||
|
const fallbackManifest = normalizeMediaManifest(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: fallbackManifest,
|
||||||
|
error: nextError,
|
||||||
|
usedFallbackManifest: true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
manifestRequest = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifestRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMediaCatalog = () => {
|
||||||
|
const [manifest, setManifest] = useState<MediaManifest>(manifestCache);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
|
||||||
|
const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL);
|
||||||
|
const isMountedRef = useRef(false);
|
||||||
|
const lastRefreshAtRef = useRef(0);
|
||||||
|
|
||||||
|
const applyLoadResult = useCallback((result: MediaCatalogLoadResult) => {
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setManifest(result.manifest);
|
||||||
|
setError(result.error);
|
||||||
|
setUsedFallbackManifest(result.usedFallbackManifest);
|
||||||
|
setHasResolvedManifest(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshManifest = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
lastRefreshAtRef.current = Date.now();
|
||||||
|
const result = await readMediaManifest();
|
||||||
|
applyLoadResult(result);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name !== 'AbortError' && isMountedRef.current) {
|
||||||
|
console.error('Failed to refresh media manifest:', err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [applyLoadResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
readMediaManifest(controller.signal)
|
||||||
|
.then((result) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
applyLoadResult(result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== 'AbortError' && !controller.signal.aborted) {
|
||||||
|
console.error('Failed to load media manifest:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [applyLoadResult]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!MEDIA_MANIFEST_URL || (!usedFallbackManifest && !error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void refreshManifest();
|
||||||
|
}, 4_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [error, refreshManifest, usedFallbackManifest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!MEDIA_MANIFEST_URL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeRefresh = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - lastRefreshAtRef.current < 15_000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshManifest();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
if (document.visibilityState !== 'visible') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeRefresh();
|
||||||
|
}, MANIFEST_REVALIDATE_INTERVAL_MS);
|
||||||
|
|
||||||
|
window.addEventListener('focus', maybeRefresh);
|
||||||
|
window.addEventListener('online', maybeRefresh);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
window.removeEventListener('focus', maybeRefresh);
|
||||||
|
window.removeEventListener('online', maybeRefresh);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [refreshManifest]);
|
||||||
|
|
||||||
|
const sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]);
|
||||||
|
const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
sceneAssetMap,
|
||||||
|
soundAssetMap,
|
||||||
|
error,
|
||||||
|
usedFallbackManifest,
|
||||||
|
hasResolvedManifest,
|
||||||
|
refreshManifest,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './model/mockPlan';
|
export * from './model/mockPlan';
|
||||||
export * from './model/types';
|
export * from './model/types';
|
||||||
|
export * from './model/usePlanTier';
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
import type { ProFeatureCard } from './types';
|
import type { ProFeatureCard } from './types';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
export const PRO_LOCKED_ROOM_IDS: string[] = [];
|
export const PRO_LOCKED_ROOM_IDS: string[] = [];
|
||||||
export const PRO_LOCKED_TIMER_LABELS: string[] = [];
|
export const PRO_LOCKED_TIMER_LABELS: string[] = [];
|
||||||
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
||||||
|
|
||||||
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
|
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [...copy.plan.proFeatureCards];
|
||||||
{
|
|
||||||
id: 'scene-packs',
|
|
||||||
name: 'Scene Packs',
|
|
||||||
description: '프리미엄 공간 묶음과 장면 변주',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sound-packs',
|
|
||||||
name: 'Sound Packs',
|
|
||||||
description: '확장 사운드 프리셋 묶음',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'profiles',
|
|
||||||
name: 'Profiles',
|
|
||||||
description: '내 기본 세팅 저장/불러오기',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface PlanLockedPack {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
|
export type ProFeatureId = 'daily-plan' | 'rituals' | 'weekly-review';
|
||||||
|
|
||||||
export interface ProFeatureCard {
|
export interface ProFeatureCard {
|
||||||
id: ProFeatureId;
|
id: ProFeatureId;
|
||||||
|
|||||||
77
src/entities/plan/model/usePlanTier.ts
Normal file
77
src/entities/plan/model/usePlanTier.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useSyncExternalStore } from 'react';
|
||||||
|
import type { PlanTier } from './types';
|
||||||
|
|
||||||
|
const PLAN_TIER_STORAGE_KEY = 'viberoom:plan-tier:v1';
|
||||||
|
const planTierSubscribers = new Set<() => void>();
|
||||||
|
|
||||||
|
const normalizePlanTier = (value: unknown): PlanTier => {
|
||||||
|
return value === 'pro' ? 'pro' : 'normal';
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyPlanTierSubscribers = () => {
|
||||||
|
for (const subscriber of planTierSubscribers) {
|
||||||
|
subscriber();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeToPlanTier = (onStoreChange: () => void) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== PLAN_TIER_STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStoreChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
planTierSubscribers.add(onStoreChange);
|
||||||
|
window.addEventListener('storage', handleStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
planTierSubscribers.delete(onStoreChange);
|
||||||
|
window.removeEventListener('storage', handleStorage);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readStoredPlanTier = (): PlanTier => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizePlanTier(window.localStorage.getItem(PLAN_TIER_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePlanTier = () => {
|
||||||
|
const plan = useSyncExternalStore<PlanTier>(
|
||||||
|
subscribeToPlanTier,
|
||||||
|
readStoredPlanTier,
|
||||||
|
() => 'normal',
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPlan = useCallback((nextPlan: PlanTier) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan);
|
||||||
|
} finally {
|
||||||
|
notifyPlanTierSubscribers();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
isPro: plan === 'pro',
|
||||||
|
setPlan,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './model/rooms';
|
|
||||||
export * from './model/types';
|
|
||||||
2
src/entities/scene/index.ts
Normal file
2
src/entities/scene/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './model/scenes';
|
||||||
|
export * from './model/types';
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import type { RoomTheme } from './types';
|
import { copy } from '@/shared/i18n';
|
||||||
|
import type { SceneTheme } from './types';
|
||||||
|
|
||||||
const HUB_CURATION_ORDER = [
|
const HUB_CURATION_ORDER = [
|
||||||
'quiet-library',
|
'quiet-library',
|
||||||
'rain-window',
|
'rain-window',
|
||||||
'dawn-cafe',
|
'dawn-cafe',
|
||||||
'green-forest',
|
'forest',
|
||||||
'fireplace',
|
'fireplace',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const HUB_RECOMMENDED_ROOM_COUNT = 3;
|
const HUB_RECOMMENDED_SCENE_COUNT = 3;
|
||||||
|
const SCENE_ID_ALIASES: Record<string, string> = {
|
||||||
|
'green-forest': 'forest',
|
||||||
|
};
|
||||||
|
|
||||||
export const ROOM_THEMES: RoomTheme[] = [
|
export const SCENE_THEMES: SceneTheme[] = [
|
||||||
{
|
{
|
||||||
id: 'rain-window',
|
id: 'rain-window',
|
||||||
name: '비 오는 창가',
|
name: copy.scenes[0].name,
|
||||||
description: '빗소리 위로 스탠드 조명이 부드럽게 번집니다.',
|
description: copy.scenes[0].description,
|
||||||
tags: ['저자극', '감성'],
|
tags: [...copy.scenes[0].tags],
|
||||||
recommendedSound: 'Rain Focus',
|
recommendedSound: copy.scenes[0].recommendedSound,
|
||||||
recommendedSoundPresetId: 'rain-focus',
|
recommendedSoundPresetId: 'rain-focus',
|
||||||
recommendedTimerPresetId: '25-5',
|
recommendedTime: copy.scenes[0].recommendedTime,
|
||||||
recommendedTime: '밤',
|
vibeLabel: copy.scenes[0].vibeLabel,
|
||||||
vibeLabel: '잔잔함',
|
|
||||||
hubColor: '#D6E6F7',
|
hubColor: '#D6E6F7',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/16763533/pexels-photo-16763533.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/16763533/pexels-photo-16763533.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -36,14 +39,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dawn-cafe',
|
id: 'dawn-cafe',
|
||||||
name: '새벽 카페',
|
name: copy.scenes[1].name,
|
||||||
description: '첫 커피 향처럼 잔잔하고 따뜻한 좌석.',
|
description: copy.scenes[1].description,
|
||||||
tags: ['감성', '딥워크'],
|
tags: [...copy.scenes[1].tags],
|
||||||
recommendedSound: 'Cafe Murmur',
|
recommendedSound: copy.scenes[1].recommendedSound,
|
||||||
recommendedSoundPresetId: 'cafe-work',
|
recommendedSoundPresetId: 'cafe-work',
|
||||||
recommendedTimerPresetId: '25-5',
|
recommendedTime: copy.scenes[1].recommendedTime,
|
||||||
recommendedTime: '새벽',
|
vibeLabel: copy.scenes[1].vibeLabel,
|
||||||
vibeLabel: '포근함',
|
|
||||||
hubColor: '#F5DDCB',
|
hubColor: '#F5DDCB',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/18340237/pexels-photo-18340237.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/18340237/pexels-photo-18340237.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -58,14 +60,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quiet-library',
|
id: 'quiet-library',
|
||||||
name: '도서관',
|
name: copy.scenes[2].name,
|
||||||
description: '넘기는 종이 소리만 들리는 정돈된 책상.',
|
description: copy.scenes[2].description,
|
||||||
tags: ['저자극', '딥워크'],
|
tags: [...copy.scenes[2].tags],
|
||||||
recommendedSound: 'Deep White',
|
recommendedSound: copy.scenes[2].recommendedSound,
|
||||||
recommendedSoundPresetId: 'deep-white',
|
recommendedSoundPresetId: 'deep-white',
|
||||||
recommendedTimerPresetId: '50-10',
|
recommendedTime: copy.scenes[2].recommendedTime,
|
||||||
recommendedTime: '오후',
|
vibeLabel: copy.scenes[2].vibeLabel,
|
||||||
vibeLabel: '몰입',
|
|
||||||
hubColor: '#DCE4D1',
|
hubColor: '#DCE4D1',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/31390421/pexels-photo-31390421.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/31390421/pexels-photo-31390421.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -80,14 +81,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wave-sound',
|
id: 'wave-sound',
|
||||||
name: '파도 소리',
|
name: copy.scenes[3].name,
|
||||||
description: '잔잔한 해변 위로 호흡을 고르는 공간.',
|
description: copy.scenes[3].description,
|
||||||
tags: ['움직임 적음', '감성'],
|
tags: [...copy.scenes[3].tags],
|
||||||
recommendedSound: 'Ocean Breath',
|
recommendedSound: copy.scenes[3].recommendedSound,
|
||||||
recommendedSoundPresetId: 'ocean-calm',
|
recommendedSoundPresetId: 'ocean-calm',
|
||||||
recommendedTimerPresetId: '25-5',
|
recommendedTime: copy.scenes[3].recommendedTime,
|
||||||
recommendedTime: '밤',
|
vibeLabel: copy.scenes[3].vibeLabel,
|
||||||
vibeLabel: '차분함',
|
|
||||||
hubColor: '#CFE9EA',
|
hubColor: '#CFE9EA',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/12715501/pexels-photo-12715501.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/12715501/pexels-photo-12715501.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -101,15 +101,14 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
'radial-gradient(125% 125% at 75% 8%, rgba(56,189,248,0.4) 0%, rgba(15,23,42,0) 50%), linear-gradient(145deg, rgba(8,47,73,0.93) 0%, rgba(15,23,42,0.9) 55%, rgba(3,7,18,0.96) 100%)',
|
'radial-gradient(125% 125% at 75% 8%, rgba(56,189,248,0.4) 0%, rgba(15,23,42,0) 50%), linear-gradient(145deg, rgba(8,47,73,0.93) 0%, rgba(15,23,42,0.9) 55%, rgba(3,7,18,0.96) 100%)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'green-forest',
|
id: 'forest',
|
||||||
name: '숲',
|
name: copy.scenes[4].name,
|
||||||
description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
|
description: copy.scenes[4].description,
|
||||||
tags: ['저자극', '움직임 적음'],
|
tags: [...copy.scenes[4].tags],
|
||||||
recommendedSound: 'Forest Hush',
|
recommendedSound: copy.scenes[4].recommendedSound,
|
||||||
recommendedSoundPresetId: 'rain-focus',
|
recommendedSoundPresetId: 'forest-birds',
|
||||||
recommendedTimerPresetId: '50-10',
|
recommendedTime: copy.scenes[4].recommendedTime,
|
||||||
recommendedTime: '오전',
|
vibeLabel: copy.scenes[4].vibeLabel,
|
||||||
vibeLabel: '맑음',
|
|
||||||
hubColor: '#D1E7C9',
|
hubColor: '#D1E7C9',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/34503448/pexels-photo-34503448.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/34503448/pexels-photo-34503448.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -124,14 +123,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fireplace',
|
id: 'fireplace',
|
||||||
name: '벽난로',
|
name: copy.scenes[5].name,
|
||||||
description: '작은 불꽃이 주는 리듬으로 집중을 붙잡습니다.',
|
description: copy.scenes[5].description,
|
||||||
tags: ['감성', '저자극'],
|
tags: [...copy.scenes[5].tags],
|
||||||
recommendedSound: 'Fireplace',
|
recommendedSound: copy.scenes[5].recommendedSound,
|
||||||
recommendedSoundPresetId: 'fireplace',
|
recommendedSoundPresetId: 'fireplace',
|
||||||
recommendedTimerPresetId: '25-5',
|
recommendedTime: copy.scenes[5].recommendedTime,
|
||||||
recommendedTime: '밤',
|
vibeLabel: copy.scenes[5].vibeLabel,
|
||||||
vibeLabel: '온기',
|
|
||||||
hubColor: '#F2D4C0',
|
hubColor: '#F2D4C0',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/14353716/pexels-photo-14353716.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/14353716/pexels-photo-14353716.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -146,14 +144,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'city-night',
|
id: 'city-night',
|
||||||
name: '도시 야경',
|
name: copy.scenes[6].name,
|
||||||
description: '유리창 너머 야경이 멀리 흐르는 고요한 밤.',
|
description: copy.scenes[6].description,
|
||||||
tags: ['딥워크', '감성'],
|
tags: [...copy.scenes[6].tags],
|
||||||
recommendedSound: 'Night Lo-fi',
|
recommendedSound: copy.scenes[6].recommendedSound,
|
||||||
recommendedSoundPresetId: 'deep-white',
|
recommendedSoundPresetId: 'deep-white',
|
||||||
recommendedTimerPresetId: '50-10',
|
recommendedTime: copy.scenes[6].recommendedTime,
|
||||||
recommendedTime: '심야',
|
vibeLabel: copy.scenes[6].vibeLabel,
|
||||||
vibeLabel: '고요함',
|
|
||||||
hubColor: '#D9D3ED',
|
hubColor: '#D9D3ED',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/17663181/pexels-photo-17663181.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/17663181/pexels-photo-17663181.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -168,14 +165,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'snow-mountain',
|
id: 'snow-mountain',
|
||||||
name: '설산',
|
name: copy.scenes[7].name,
|
||||||
description: '차분한 공기와 선명한 수평선이 머리를 맑게 합니다.',
|
description: copy.scenes[7].description,
|
||||||
tags: ['움직임 적음', '딥워크'],
|
tags: [...copy.scenes[7].tags],
|
||||||
recommendedSound: 'Cold Wind',
|
recommendedSound: copy.scenes[7].recommendedSound,
|
||||||
recommendedSoundPresetId: 'deep-white',
|
recommendedSoundPresetId: 'deep-white',
|
||||||
recommendedTimerPresetId: '50-10',
|
recommendedTime: copy.scenes[7].recommendedTime,
|
||||||
recommendedTime: '새벽',
|
vibeLabel: copy.scenes[7].vibeLabel,
|
||||||
vibeLabel: '선명함',
|
|
||||||
hubColor: '#D8E7F3',
|
hubColor: '#D8E7F3',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/34340672/pexels-photo-34340672.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/34340672/pexels-photo-34340672.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -190,14 +186,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sun-window',
|
id: 'sun-window',
|
||||||
name: '창가',
|
name: copy.scenes[8].name,
|
||||||
description: '햇살이 들어오는 간결한 책상, 부담 없는 시작.',
|
description: copy.scenes[8].description,
|
||||||
tags: ['저자극', '딥워크'],
|
tags: [...copy.scenes[8].tags],
|
||||||
recommendedSound: 'Soft Daylight',
|
recommendedSound: copy.scenes[8].recommendedSound,
|
||||||
recommendedSoundPresetId: 'silent',
|
recommendedSoundPresetId: 'silent',
|
||||||
recommendedTimerPresetId: '25-5',
|
recommendedTime: copy.scenes[8].recommendedTime,
|
||||||
recommendedTime: '오후',
|
vibeLabel: copy.scenes[8].vibeLabel,
|
||||||
vibeLabel: '가벼움',
|
|
||||||
hubColor: '#F6EDC7',
|
hubColor: '#F6EDC7',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/34833126/pexels-photo-34833126.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/34833126/pexels-photo-34833126.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -212,14 +207,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'outer-space',
|
id: 'outer-space',
|
||||||
name: '우주',
|
name: copy.scenes[9].name,
|
||||||
description: '별빛만 남긴 어둠 속에서 깊게 잠수합니다.',
|
description: copy.scenes[9].description,
|
||||||
tags: ['딥워크', '감성'],
|
tags: [...copy.scenes[9].tags],
|
||||||
recommendedSound: 'Deep Drone',
|
recommendedSound: copy.scenes[9].recommendedSound,
|
||||||
recommendedSoundPresetId: 'deep-white',
|
recommendedSoundPresetId: 'deep-white',
|
||||||
recommendedTimerPresetId: '90-20',
|
recommendedTime: copy.scenes[9].recommendedTime,
|
||||||
recommendedTime: '심야',
|
vibeLabel: copy.scenes[9].vibeLabel,
|
||||||
vibeLabel: '깊음',
|
|
||||||
hubColor: '#D4DCF4',
|
hubColor: '#D4DCF4',
|
||||||
cardPhotoUrl:
|
cardPhotoUrl:
|
||||||
'https://images.pexels.com/photos/18537868/pexels-photo-18537868.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
'https://images.pexels.com/photos/18537868/pexels-photo-18537868.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||||
@@ -234,65 +228,75 @@ export const ROOM_THEMES: RoomTheme[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getRoomById = (roomId: string) => {
|
export const getSceneById = (id: string) => {
|
||||||
return ROOM_THEMES.find((room) => room.id === roomId);
|
const normalizedSceneId = normalizeSceneId(id);
|
||||||
|
return SCENE_THEMES.find((scene) => scene.id === normalizedSceneId);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRoomCardPhotoUrl = (room: RoomTheme) => {
|
export const normalizeSceneId = (id: string | null | undefined) => {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SCENE_ID_ALIASES[id] ?? id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSceneCardPhotoUrl = (scene: SceneTheme) => {
|
||||||
// Swap managedCardPhotoUrl when you start serving first-party assets.
|
// Swap managedCardPhotoUrl when you start serving first-party assets.
|
||||||
return room.managedCardPhotoUrl ?? room.cardPhotoUrl;
|
return scene.managedCardPhotoUrl ?? scene.cardPhotoUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRoomCardBackgroundStyle = (room: RoomTheme): CSSProperties => {
|
export const getSceneCardBackgroundStyle = (scene: SceneTheme): CSSProperties => {
|
||||||
return {
|
return {
|
||||||
backgroundImage: `url('${getRoomCardPhotoUrl(room)}')`,
|
backgroundImage: `url('${getSceneCardPhotoUrl(scene)}')`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRoomBackgroundStyle = (room: RoomTheme): CSSProperties => {
|
export const getSceneBackgroundStyle = (scene: SceneTheme): CSSProperties => {
|
||||||
return {
|
return {
|
||||||
backgroundImage: `url('${getRoomCardPhotoUrl(room)}'), linear-gradient(160deg, #1e293b 0%, #0f172a 100%)`,
|
backgroundImage: `url('${getSceneCardPhotoUrl(scene)}'), linear-gradient(160deg, #1e293b 0%, #0f172a 100%)`,
|
||||||
backgroundSize: 'cover, cover',
|
backgroundSize: 'cover, cover',
|
||||||
backgroundPosition: 'center, center',
|
backgroundPosition: 'center, center',
|
||||||
backgroundRepeat: 'no-repeat, no-repeat',
|
backgroundRepeat: 'no-repeat, no-repeat',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const uniqueByRoomId = (rooms: Array<RoomTheme | undefined>) => {
|
const uniqueBySceneId = (scenes: Array<SceneTheme | undefined>) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
return rooms.filter((room): room is RoomTheme => {
|
return scenes.filter((scene): scene is SceneTheme => {
|
||||||
if (!room || seen.has(room.id)) {
|
if (!scene || seen.has(scene.id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
seen.add(room.id);
|
seen.add(scene.id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHubRoomSections = (
|
export const getHubSceneSections = (
|
||||||
rooms: RoomTheme[],
|
scenes: SceneTheme[],
|
||||||
selectedRoomId: string,
|
selectedSceneId: string,
|
||||||
recommendedCount = HUB_RECOMMENDED_ROOM_COUNT,
|
recommendedCount = HUB_RECOMMENDED_SCENE_COUNT,
|
||||||
) => {
|
) => {
|
||||||
const roomById = new Map(rooms.map((room) => [room.id, room] as const));
|
const sceneById = new Map(scenes.map((scene) => [scene.id, scene] as const));
|
||||||
const selectedRoom = roomById.get(selectedRoomId);
|
const normalizedSelectedSceneId = normalizeSceneId(selectedSceneId) ?? selectedSceneId;
|
||||||
const curatedRooms = HUB_CURATION_ORDER.map((id) => roomById.get(id));
|
const selectedScene = sceneById.get(normalizedSelectedSceneId);
|
||||||
|
const curatedScenes = HUB_CURATION_ORDER.map((id) => sceneById.get(id));
|
||||||
|
|
||||||
const recommendedRooms = uniqueByRoomId([
|
const recommendedScenes = uniqueBySceneId([
|
||||||
selectedRoom,
|
selectedScene,
|
||||||
...curatedRooms,
|
...curatedScenes,
|
||||||
...rooms,
|
...scenes,
|
||||||
]).slice(0, recommendedCount);
|
]).slice(0, recommendedCount);
|
||||||
|
|
||||||
const recommendedRoomIds = new Set(recommendedRooms.map((room) => room.id));
|
const recommendedSceneIds = new Set(recommendedScenes.map((scene) => scene.id));
|
||||||
const allRooms = [...recommendedRooms, ...rooms.filter((room) => !recommendedRoomIds.has(room.id))];
|
const allScenes = [...recommendedScenes, ...scenes.filter((scene) => !recommendedSceneIds.has(scene.id))];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recommendedRooms,
|
recommendedScenes,
|
||||||
allRooms,
|
allScenes,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
export type RoomTag = '저자극' | '움직임 적음' | '딥워크' | '감성';
|
export type SceneTag = '저자극' | '움직임 적음' | '딥워크' | '감성';
|
||||||
|
|
||||||
export interface RoomPresence {
|
export interface ScenePresence {
|
||||||
focus: number;
|
focus: number;
|
||||||
break: number;
|
break: number;
|
||||||
away: number;
|
away: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomTheme {
|
export interface SceneTheme {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: RoomTag[];
|
tags: SceneTag[];
|
||||||
recommendedSound: string;
|
recommendedSound: string;
|
||||||
recommendedSoundPresetId: string;
|
recommendedSoundPresetId: string;
|
||||||
recommendedTimerPresetId: string;
|
|
||||||
recommendedTime: string;
|
recommendedTime: string;
|
||||||
vibeLabel: string;
|
vibeLabel: string;
|
||||||
hubColor: string;
|
hubColor: string;
|
||||||
@@ -21,7 +20,7 @@ export interface RoomTheme {
|
|||||||
googleImageSearchUrl: string;
|
googleImageSearchUrl: string;
|
||||||
managedCardPhotoUrl: string | null;
|
managedCardPhotoUrl: string | null;
|
||||||
activeMembers: number;
|
activeMembers: number;
|
||||||
presence: RoomPresence;
|
presence: ScenePresence;
|
||||||
previewImage: string;
|
previewImage: string;
|
||||||
previewGradient: string;
|
previewGradient: string;
|
||||||
}
|
}
|
||||||
57
src/entities/session/api/inboxApi.ts
Normal file
57
src/entities/session/api/inboxApi.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export interface InboxThoughtResponse {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sceneName: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
capturedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInboxThoughtRequest {
|
||||||
|
text: string;
|
||||||
|
sceneName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInboxThoughtRequest {
|
||||||
|
isCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inboxApi = {
|
||||||
|
getThoughts: async (): Promise<InboxThoughtResponse[]> => {
|
||||||
|
return apiClient<InboxThoughtResponse[]>('api/v1/inbox/thoughts', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addThought: async (payload: CreateInboxThoughtRequest): Promise<InboxThoughtResponse> => {
|
||||||
|
return apiClient<InboxThoughtResponse>('api/v1/inbox/thoughts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateThoughtComplete: async (
|
||||||
|
thoughtId: string,
|
||||||
|
payload: UpdateInboxThoughtRequest,
|
||||||
|
): Promise<InboxThoughtResponse> => {
|
||||||
|
return apiClient<InboxThoughtResponse>(`api/v1/inbox/thoughts/${thoughtId}/complete`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteThought: async (thoughtId: string): Promise<void> => {
|
||||||
|
return apiClient<void>(`api/v1/inbox/thoughts/${thoughtId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
expectNoContent: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearThoughts: async (): Promise<void> => {
|
||||||
|
return apiClient<void>('api/v1/inbox/thoughts', {
|
||||||
|
method: 'DELETE',
|
||||||
|
expectNoContent: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -5,92 +5,23 @@ import type {
|
|||||||
RecentThought,
|
RecentThought,
|
||||||
ReactionOption,
|
ReactionOption,
|
||||||
SoundPreset,
|
SoundPreset,
|
||||||
TimerPreset,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
export const TODAY_ONE_LINER = '오늘의 한 줄: 완벽보다 시작, 한 조각이면 충분해요.';
|
export const TODAY_ONE_LINER = copy.session.todayOneLiner;
|
||||||
|
|
||||||
export const GOAL_CHIPS: GoalChip[] = [
|
export const GOAL_CHIPS: GoalChip[] = [...copy.session.goalChips];
|
||||||
{ id: 'mail-3', label: '메일 3개' },
|
|
||||||
{ id: 'doc-1p', label: '문서 1p' },
|
|
||||||
{ id: 'code-1-function', label: '코딩 1함수' },
|
|
||||||
{ id: 'tidy-10m', label: '정리 10분' },
|
|
||||||
{ id: 'reading-15m', label: '독서 15분' },
|
|
||||||
{ id: 'resume-1paragraph', label: '이력서 1문단' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CHECK_IN_PHRASES: CheckInPhrase[] = [
|
export const CHECK_IN_PHRASES: CheckInPhrase[] = [...copy.session.checkInPhrases];
|
||||||
{ id: 'arrived', text: '지금 들어왔어요' },
|
|
||||||
{ id: 'sprint-25', text: '25분만 달릴게요' },
|
|
||||||
{ id: 'on-break', text: '휴식 중' },
|
|
||||||
{ id: 'back-focus', text: '다시 집중!' },
|
|
||||||
{ id: 'slow-day', text: '오늘은 천천히' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const REACTION_OPTIONS: ReactionOption[] = [
|
export const REACTION_OPTIONS: ReactionOption[] = [...copy.session.reactionOptions];
|
||||||
{ id: 'thumbs-up', emoji: '👍', label: '응원해요' },
|
|
||||||
{ id: 'fire', emoji: '🔥', label: '집중 모드' },
|
|
||||||
{ id: 'clap', emoji: '👏', label: '잘하고 있어요' },
|
|
||||||
{ id: 'heart-hands', emoji: '🫶', label: '연결되어 있어요' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const SOUND_PRESETS: SoundPreset[] = [
|
export const SOUND_PRESETS: SoundPreset[] = [...copy.session.soundPresets];
|
||||||
{ id: 'deep-white', label: 'Deep White' },
|
|
||||||
{ id: 'rain-focus', label: 'Rain Focus' },
|
|
||||||
{ id: 'cafe-work', label: 'Cafe Work' },
|
|
||||||
{ id: 'ocean-calm', label: 'Ocean Calm' },
|
|
||||||
{ id: 'fireplace', label: 'Fireplace' },
|
|
||||||
{ id: 'silent', label: 'Silent' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TIMER_PRESETS: TimerPreset[] = [
|
export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder];
|
||||||
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
|
|
||||||
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
|
|
||||||
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
|
|
||||||
{ id: 'custom', label: '커스텀' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DISTRACTION_DUMP_PLACEHOLDER = [
|
export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats];
|
||||||
'디자인 QA 요청 확인',
|
|
||||||
'세금계산서 발행 메모',
|
|
||||||
'오후 미팅 질문 1개 정리',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TODAY_STATS: FocusStatCard[] = [
|
export const WEEKLY_STATS: FocusStatCard[] = [...copy.session.weeklyStats];
|
||||||
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },
|
|
||||||
{ id: 'today-cycles', label: '완료한 사이클', value: '5회', delta: '+1' },
|
|
||||||
{ id: 'today-entry', label: '입장 횟수', value: '3회', delta: '유지' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const WEEKLY_STATS: FocusStatCard[] = [
|
export const RECENT_THOUGHTS: RecentThought[] = [...copy.session.recentThoughts];
|
||||||
{ id: 'week-focus', label: '최근 7일 집중 시간', value: '14h 20m', delta: '+2h 10m' },
|
|
||||||
{ id: 'week-best-day', label: '최고 몰입일', value: '수요일', delta: '3h 30m' },
|
|
||||||
{ id: 'week-consistency', label: '연속 달성', value: '4일', delta: '+1일' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const RECENT_THOUGHTS: RecentThought[] = [
|
|
||||||
{
|
|
||||||
id: 'thought-1',
|
|
||||||
text: '내일 미팅 전에 제안서 첫 문단만 다시 다듬기',
|
|
||||||
roomName: '도서관',
|
|
||||||
capturedAt: '방금 전',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'thought-2',
|
|
||||||
text: '기획 문서의 핵심 흐름을 한 문장으로 정리해두기',
|
|
||||||
roomName: '비 오는 창가',
|
|
||||||
capturedAt: '24분 전',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'thought-3',
|
|
||||||
text: '오후에 확인할 이슈 번호만 메모하고 지금 작업 복귀',
|
|
||||||
roomName: '숲',
|
|
||||||
capturedAt: '1시간 전',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'thought-4',
|
|
||||||
text: '리뷰 코멘트는 오늘 17시 이후에 한 번에 처리',
|
|
||||||
roomName: '벽난로',
|
|
||||||
capturedAt: '어제',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ export interface SoundPreset {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimerPreset {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
focusMinutes?: number;
|
|
||||||
breakMinutes?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FocusStatCard {
|
export interface FocusStatCard {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -36,7 +29,7 @@ export interface FocusStatCard {
|
|||||||
export interface RecentThought {
|
export interface RecentThought {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
roomName: string;
|
sceneName: string;
|
||||||
capturedAt: string;
|
capturedAt: string;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,82 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { inboxApi } from '../api/inboxApi';
|
||||||
import type { RecentThought } from './types';
|
import type { RecentThought } from './types';
|
||||||
|
|
||||||
const THOUGHT_INBOX_STORAGE_KEY = 'viberoom:thought-inbox:v1';
|
|
||||||
const MAX_THOUGHT_INBOX_ITEMS = 40;
|
const MAX_THOUGHT_INBOX_ITEMS = 40;
|
||||||
|
|
||||||
const readStoredThoughts = () => {
|
const normalizeThought = (thought: {
|
||||||
if (typeof window === 'undefined') {
|
id: string;
|
||||||
return [];
|
text: string;
|
||||||
}
|
sceneName: string;
|
||||||
|
isCompleted: boolean;
|
||||||
const raw = window.localStorage.getItem(THOUGHT_INBOX_STORAGE_KEY);
|
capturedAt: string;
|
||||||
|
}): RecentThought => ({
|
||||||
if (!raw) {
|
id: thought.id,
|
||||||
return [];
|
text: thought.text,
|
||||||
}
|
sceneName: thought.sceneName,
|
||||||
|
isCompleted: thought.isCompleted,
|
||||||
try {
|
capturedAt: thought.capturedAt,
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.filter((thought): thought is RecentThought => {
|
|
||||||
return (
|
|
||||||
thought &&
|
|
||||||
typeof thought.id === 'string' &&
|
|
||||||
typeof thought.text === 'string' &&
|
|
||||||
typeof thought.roomName === 'string' &&
|
|
||||||
typeof thought.capturedAt === 'string' &&
|
|
||||||
(typeof thought.isCompleted === 'undefined' || typeof thought.isCompleted === 'boolean')
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistThoughts = (thoughts: RecentThought[]) => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(THOUGHT_INBOX_STORAGE_KEY, JSON.stringify(thoughts));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useThoughtInbox = () => {
|
export const useThoughtInbox = () => {
|
||||||
const [thoughts, setThoughts] = useState<RecentThought[]>(() => readStoredThoughts());
|
const [thoughts, setThoughts] = useState<RecentThought[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
persistThoughts(thoughts);
|
let mounted = true;
|
||||||
}, [thoughts]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
inboxApi.getThoughts()
|
||||||
const handleStorage = (event: StorageEvent) => {
|
.then((data) => {
|
||||||
if (event.key !== THOUGHT_INBOX_STORAGE_KEY) {
|
if (!mounted) return;
|
||||||
return;
|
setThoughts(data.map(normalizeThought));
|
||||||
}
|
})
|
||||||
|
.catch((err) => {
|
||||||
setThoughts(readStoredThoughts());
|
console.error('Failed to load inbox thoughts:', err);
|
||||||
};
|
});
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorage);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('storage', handleStorage);
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addThought = useCallback((text: string, roomName: string) => {
|
const addThought = useCallback(async (text: string, sceneName: string) => {
|
||||||
const trimmedText = text.trim();
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
if (!trimmedText) {
|
if (!trimmedText) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tempId = `thought-temp-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
|
||||||
const thought: RecentThought = {
|
const thought: RecentThought = {
|
||||||
id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
id: tempId,
|
||||||
text: trimmedText,
|
text: trimmedText,
|
||||||
roomName,
|
sceneName,
|
||||||
capturedAt: '방금 전',
|
capturedAt: copy.session.justNow,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
setThoughts((current) => {
|
setThoughts((current) => {
|
||||||
const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
return thought;
|
try {
|
||||||
|
const response = await inboxApi.addThought({ text: trimmedText, sceneName });
|
||||||
|
const normalized = normalizeThought(response);
|
||||||
|
|
||||||
|
setThoughts((current) =>
|
||||||
|
current.map((t) => (t.id === tempId ? normalized : t))
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add thought:', err);
|
||||||
|
setThoughts((current) => current.filter((t) => t.id !== tempId));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeThought = useCallback((thoughtId: string) => {
|
const removeThought = useCallback((thoughtId: string) => {
|
||||||
@@ -109,6 +94,12 @@ export const useThoughtInbox = () => {
|
|||||||
return current.filter((thought) => thought.id !== thoughtId);
|
return current.filter((thought) => thought.id !== thoughtId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (removedThought && !thoughtId.startsWith('thought-temp-')) {
|
||||||
|
inboxApi.deleteThought(thoughtId).catch((err) => {
|
||||||
|
console.error('Failed to delete thought:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return removedThought;
|
return removedThought;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -120,6 +111,10 @@ export const useThoughtInbox = () => {
|
|||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inboxApi.clearThoughts().catch((err) => {
|
||||||
|
console.error('Failed to clear thoughts:', err);
|
||||||
|
});
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -128,12 +123,36 @@ export const useThoughtInbox = () => {
|
|||||||
const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
|
const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
|
||||||
return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inboxApi.addThought({ text: thought.text, sceneName: thought.sceneName })
|
||||||
|
.then((res) => {
|
||||||
|
setThoughts((current) =>
|
||||||
|
current.map((t) => (t.id === thought.id ? { ...t, id: res.id } : t))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to restore thought:', err);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const restoreThoughts = useCallback((snapshot: RecentThought[]) => {
|
const restoreThoughts = useCallback((snapshot: RecentThought[]) => {
|
||||||
setThoughts(() => {
|
setThoughts(() => {
|
||||||
return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fire and forget bulk restore as we don't have a bulk API
|
||||||
|
Promise.all(
|
||||||
|
snapshot.map((t) => inboxApi.addThought({ text: t.text, sceneName: t.sceneName }))
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
return inboxApi.getThoughts();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setThoughts(data.map(normalizeThought));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to restore thoughts:', err);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => {
|
const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => {
|
||||||
@@ -156,6 +175,18 @@ export const useThoughtInbox = () => {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!thoughtId.startsWith('thought-temp-')) {
|
||||||
|
inboxApi.updateThoughtComplete(thoughtId, { isCompleted }).catch((err) => {
|
||||||
|
console.error('Failed to update thought completion:', err);
|
||||||
|
// Optionally revert state on failure
|
||||||
|
if (previousThought) {
|
||||||
|
setThoughts((current) => {
|
||||||
|
return current.map(t => t.id === thoughtId ? { ...t, isCompleted: !isCompleted } : t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return previousThought;
|
return previousThought;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
145
src/features/admin/api/adminApi.ts
Normal file
145
src/features/admin/api/adminApi.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { AuthResponse } from '@/entities/auth';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiErrorPayload {
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneMediaAssetUploadResponse {
|
||||||
|
sceneId: string;
|
||||||
|
cardObjectKey: string;
|
||||||
|
cardUrl: string;
|
||||||
|
stageObjectKey: string;
|
||||||
|
stageUrl: string;
|
||||||
|
mobileStageObjectKey?: string | null;
|
||||||
|
mobileStageUrl?: string | null;
|
||||||
|
hdStageObjectKey?: string | null;
|
||||||
|
hdStageUrl?: string | null;
|
||||||
|
placeholderGradient?: string | null;
|
||||||
|
blurDataUrl?: string | null;
|
||||||
|
assetVersion: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundMediaAssetUploadResponse {
|
||||||
|
presetId: string;
|
||||||
|
previewObjectKey?: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
loopObjectKey: string;
|
||||||
|
loopUrl: string;
|
||||||
|
fallbackLoopObjectKey?: string | null;
|
||||||
|
fallbackLoopUrl?: string | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
durationSec?: number | null;
|
||||||
|
defaultVolume?: number | null;
|
||||||
|
assetVersion: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginInput {
|
||||||
|
loginId: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUrl = (endpoint: string) => {
|
||||||
|
return `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseErrorMessage = async (response: Response) => {
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
return copy.common.requestFailed(response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as ApiErrorPayload;
|
||||||
|
return payload.message ?? payload.error ?? copy.common.requestFailed(response.status);
|
||||||
|
} catch {
|
||||||
|
return copy.common.requestFailed(response.status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unwrapPayload = <T>(payload: T | ApiEnvelope<T>) => {
|
||||||
|
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWithToken = async <T>(
|
||||||
|
endpoint: string,
|
||||||
|
accessToken: string,
|
||||||
|
body: FormData,
|
||||||
|
): Promise<T> => {
|
||||||
|
const response = await fetch(buildUrl(endpoint), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as T | ApiEnvelope<T>;
|
||||||
|
return unwrapPayload(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
login: async ({ loginId, password }: AdminLoginInput): Promise<AuthResponse> => {
|
||||||
|
const response = await fetch(buildUrl('/api/v1/auth/login'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: loginId,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as AuthResponse | ApiEnvelope<AuthResponse>;
|
||||||
|
return unwrapPayload(payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadScene: async (
|
||||||
|
sceneId: string,
|
||||||
|
formData: FormData,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<SceneMediaAssetUploadResponse> => {
|
||||||
|
return fetchWithToken<SceneMediaAssetUploadResponse>(
|
||||||
|
`/api/v1/admin/media/scenes/${encodeURIComponent(sceneId)}`,
|
||||||
|
accessToken,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadSound: async (
|
||||||
|
presetId: string,
|
||||||
|
formData: FormData,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<SoundMediaAssetUploadResponse> => {
|
||||||
|
return fetchWithToken<SoundMediaAssetUploadResponse>(
|
||||||
|
`/api/v1/admin/media/sounds/${encodeURIComponent(presetId)}`,
|
||||||
|
accessToken,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,17 +1,73 @@
|
|||||||
import { apiClient } from "@/shared/lib/apiClient";
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
import { AuthResponse, SocialLoginRequest } from "../types";
|
import type { AuthResponse, PasswordLoginRequest, SocialLoginRequest, UserMeResponse } from '../types';
|
||||||
|
|
||||||
|
interface RefreshTokenResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
/**
|
/**
|
||||||
* 프론트엔드에서 발급받은 소셜 토큰을 백엔드로 전송하여 VibeRoom 전용 토큰으로 교환합니다.
|
* Backend Codex:
|
||||||
* @param data 구글/애플/페이스북에서 발급받은 Provider 이름과 Token
|
* - 로컬 계정(email/password)으로 로그인하고 VibeRoom access/refresh token을 발급한다.
|
||||||
|
* - 응답에는 accessToken, refreshToken, user를 포함한다.
|
||||||
|
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 권한 UI를 판별할 수 있어야 한다.
|
||||||
*/
|
*/
|
||||||
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
|
loginWithPassword: async (data: PasswordLoginRequest): Promise<AuthResponse> => {
|
||||||
return apiClient<AuthResponse>("api/v1/auth/social", {
|
return apiClient<AuthResponse>('api/v1/auth/login', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// TODO: 이후 필요 시 logout, refreshAccessToken 등 인증 관련 API 추가
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.
|
||||||
|
* - 응답에는 accessToken, refreshToken, user를 포함한다.
|
||||||
|
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 헤더/프로필 UI를 채울 수 있게 한다.
|
||||||
|
*/
|
||||||
|
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
|
||||||
|
return apiClient<AuthResponse>('api/v1/auth/social', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 현재 액세스 토큰의 사용자 정보를 반환한다.
|
||||||
|
* - 응답에는 최소 id, name, email, grade를 포함한다.
|
||||||
|
* - 인증이 만료되었으면 401을 반환하고, 클라이언트가 refresh 흐름으로 넘어갈 수 있게 한다.
|
||||||
|
*/
|
||||||
|
getMe: async (): Promise<UserMeResponse> => {
|
||||||
|
return apiClient<UserMeResponse>('api/v1/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - refresh token을 받아 새 access/refresh token 쌍을 재발급한다.
|
||||||
|
* - refresh token이 유효하지 않으면 401을 반환한다.
|
||||||
|
* - 클라이언트는 이 응답으로 쿠키와 전역 인증 상태를 갱신한다.
|
||||||
|
*/
|
||||||
|
refreshToken: async (refreshToken: string): Promise<RefreshTokenResponse> => {
|
||||||
|
return apiClient<RefreshTokenResponse>('api/v1/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 현재 로그인 세션을 서버에서 종료한다.
|
||||||
|
* - 토큰 블랙리스트 또는 세션 무효화 정책이 있다면 여기서 처리한다.
|
||||||
|
* - 이미 만료된 세션이어도 멱등적으로 204 또는 성공 응답을 반환한다.
|
||||||
|
*/
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
return apiClient<void>('api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
expectNoContent: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
38
src/features/auth/components/AuthLandingLoginButton.tsx
Normal file
38
src/features/auth/components/AuthLandingLoginButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useAuthStore } from '@/entities/auth';
|
||||||
|
import { Button, type ButtonSize, type ButtonVariant } from '@/shared/ui/Button';
|
||||||
|
import { hasClientAuth } from '../model/hasClientAuth';
|
||||||
|
|
||||||
|
interface AuthLandingLoginButtonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: ButtonSize;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthLandingLoginButton({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
size = 'sm',
|
||||||
|
variant = 'ghost',
|
||||||
|
}: AuthLandingLoginButtonProps) {
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasClientAuth({
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button href="/login" variant={variant} size={size} className={className}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/features/auth/components/AuthRedirectButton.tsx
Normal file
52
src/features/auth/components/AuthRedirectButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useAuthStore } from '@/entities/auth';
|
||||||
|
import { Button, type ButtonSize, type ButtonVariant } from '@/shared/ui/Button';
|
||||||
|
import { hasClientAuth } from '../model/hasClientAuth';
|
||||||
|
|
||||||
|
interface AuthRedirectButtonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: ButtonSize;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
authenticatedHref?: string;
|
||||||
|
unauthenticatedHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthRedirectButton({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
size = 'md',
|
||||||
|
variant = 'primary',
|
||||||
|
authenticatedHref = '/app',
|
||||||
|
unauthenticatedHref = '/login',
|
||||||
|
}: AuthRedirectButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
router.push(
|
||||||
|
hasClientAuth({
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
})
|
||||||
|
? authenticatedHref
|
||||||
|
: unauthenticatedHref,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import { useSocialLogin } from "../hooks/useSocialLogin";
|
import { useSocialLogin } from "../hooks/useSocialLogin";
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||||
const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다.
|
* 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다.
|
||||||
@@ -13,8 +13,6 @@ const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
|
|||||||
const SocialLoginButtons = () => {
|
const SocialLoginButtons = () => {
|
||||||
const {
|
const {
|
||||||
loginWithGoogle,
|
loginWithGoogle,
|
||||||
loginWithApple,
|
|
||||||
handleFacebookCallback,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useSocialLogin();
|
} = useSocialLogin();
|
||||||
@@ -49,7 +47,7 @@ const SocialLoginButtons = () => {
|
|||||||
fill="#EA4335"
|
fill="#EA4335"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{isLoading ? "연결 중..." : "Google로 계속하기"}
|
{isLoading ? copy.auth.social.connecting : copy.auth.social.continueWithGoogle}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 2. Apple 로그인 (react-apple-signin-auth 연동) */}
|
{/* 2. Apple 로그인 (react-apple-signin-auth 연동) */}
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import { useAuthStore } from "@/store/useAuthStore";
|
import { useGoogleLogin } from '@react-oauth/google';
|
||||||
import { useGoogleLogin } from "@react-oauth/google";
|
import { useRouter } from 'next/navigation';
|
||||||
import { useRouter } from "next/navigation";
|
import { useState } from 'react';
|
||||||
import { useState } from "react";
|
import appleAuthHelpers from 'react-apple-signin-auth';
|
||||||
import appleAuthHelpers from "react-apple-signin-auth";
|
import { useAuthStore } from '@/entities/auth';
|
||||||
import { authApi } from "../api/authApi";
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { authApi } from '../api/authApi';
|
||||||
|
|
||||||
|
interface AppleSignInResponse {
|
||||||
|
authorization?: {
|
||||||
|
id_token?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppleSignInError {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppleAuthHelperBridge {
|
||||||
|
signIn: (options: {
|
||||||
|
authOptions: {
|
||||||
|
clientId: string;
|
||||||
|
scope: string;
|
||||||
|
redirectURI: string;
|
||||||
|
usePopup: boolean;
|
||||||
|
};
|
||||||
|
onSuccess: (response: AppleSignInResponse) => void;
|
||||||
|
onError: (err: AppleSignInError) => void;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FacebookLoginResponse {
|
||||||
|
accessToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const useSocialLogin = () => {
|
export const useSocialLogin = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -15,30 +43,23 @@ export const useSocialLogin = () => {
|
|||||||
* 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다.
|
* 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다.
|
||||||
*/
|
*/
|
||||||
const handleSocialLogin = async (
|
const handleSocialLogin = async (
|
||||||
provider: "google" | "apple" | "facebook",
|
provider: 'google' | 'apple' | 'facebook',
|
||||||
socialToken: string,
|
socialToken: string,
|
||||||
) => {
|
) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
console.log(`[${provider}] token:`, socialToken);
|
|
||||||
try {
|
try {
|
||||||
// 1. 백엔드로 소셜 토큰 전송 (토큰 교환)
|
|
||||||
const response = await authApi.loginWithSocial({
|
const response = await authApi.loginWithSocial({
|
||||||
provider,
|
provider,
|
||||||
token: socialToken,
|
token: socialToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[${provider}] 백엔드 연동 성공! JWT:`, response.accessToken);
|
|
||||||
|
|
||||||
// 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장
|
|
||||||
useAuthStore.getState().setAuth(response);
|
useAuthStore.getState().setAuth(response);
|
||||||
|
router.push('/app');
|
||||||
// 3. 메인 허브 화면으로 이동
|
|
||||||
router.push("/app");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${provider}] 로그인 실패:`, err);
|
console.error(`[${provider}] 로그인 실패:`, err);
|
||||||
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
|
setError(copy.auth.errors.loginFailed);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -50,11 +71,10 @@ export const useSocialLogin = () => {
|
|||||||
*/
|
*/
|
||||||
const loginWithGoogle = useGoogleLogin({
|
const loginWithGoogle = useGoogleLogin({
|
||||||
onSuccess: (tokenResponse) => {
|
onSuccess: (tokenResponse) => {
|
||||||
console.log(tokenResponse);
|
handleSocialLogin('google', tokenResponse.access_token);
|
||||||
handleSocialLogin("google", tokenResponse.access_token);
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setError("구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.");
|
setError(copy.auth.errors.googleFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,39 +84,28 @@ export const useSocialLogin = () => {
|
|||||||
*/
|
*/
|
||||||
const loginWithApple = () => {
|
const loginWithApple = () => {
|
||||||
try {
|
try {
|
||||||
const appleHelperBridge = appleAuthHelpers as unknown as {
|
const appleHelperBridge = appleAuthHelpers as unknown as AppleAuthHelperBridge;
|
||||||
signIn: (options: {
|
|
||||||
authOptions: {
|
|
||||||
clientId: string;
|
|
||||||
scope: string;
|
|
||||||
redirectURI: string;
|
|
||||||
usePopup: boolean;
|
|
||||||
};
|
|
||||||
onSuccess: (response: any) => void;
|
|
||||||
onError: (err: any) => void;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
appleHelperBridge.signIn({
|
appleHelperBridge.signIn({
|
||||||
authOptions: {
|
authOptions: {
|
||||||
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || "",
|
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || '',
|
||||||
scope: "email name",
|
scope: 'email name',
|
||||||
redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요
|
redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요
|
||||||
usePopup: true,
|
usePopup: true,
|
||||||
},
|
},
|
||||||
onSuccess: (response: any) => {
|
onSuccess: (response: AppleSignInResponse) => {
|
||||||
if (response.authorization?.id_token) {
|
if (response.authorization?.id_token) {
|
||||||
handleSocialLogin("apple", response.authorization.id_token);
|
handleSocialLogin('apple', response.authorization.id_token);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: AppleSignInError) => {
|
||||||
console.error("Apple SignIn error:", err);
|
console.error('Apple SignIn error:', err);
|
||||||
setError("애플 로그인 중 오류가 발생했습니다.");
|
setError(copy.auth.errors.appleFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setError("애플 로그인 초기화 실패");
|
setError(copy.auth.errors.appleInitFailed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,11 +113,11 @@ export const useSocialLogin = () => {
|
|||||||
* [비즈니스 로직 4] Facebook Callback
|
* [비즈니스 로직 4] Facebook Callback
|
||||||
* react-facebook-login 컴포넌트에서 콜백으로 받은 토큰을 처리합니다.
|
* react-facebook-login 컴포넌트에서 콜백으로 받은 토큰을 처리합니다.
|
||||||
*/
|
*/
|
||||||
const handleFacebookCallback = (response: any) => {
|
const handleFacebookCallback = (response: FacebookLoginResponse) => {
|
||||||
if (response?.accessToken) {
|
if (response?.accessToken) {
|
||||||
handleSocialLogin("facebook", response.accessToken);
|
handleSocialLogin('facebook', response.accessToken);
|
||||||
} else {
|
} else {
|
||||||
setError("페이스북 로그인에 실패했습니다.");
|
setError(copy.auth.errors.facebookFailed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
2
src/features/auth/model/constants.ts
Normal file
2
src/features/auth/model/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const TOKEN_COOKIE_KEY = "vr_access_token";
|
||||||
|
export const REFRESH_TOKEN_COOKIE_KEY = "vr_refresh_token";
|
||||||
13
src/features/auth/model/hasClientAuth.ts
Normal file
13
src/features/auth/model/hasClientAuth.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
|
||||||
|
|
||||||
|
interface HasClientAuthInput {
|
||||||
|
accessToken: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasClientAuth = ({ accessToken, isAuthenticated }: HasClientAuthInput) => {
|
||||||
|
return isAuthenticated || Boolean(accessToken) || Boolean(Cookies.get(TOKEN_COOKIE_KEY));
|
||||||
|
};
|
||||||
@@ -1,17 +1,6 @@
|
|||||||
export interface SocialLoginRequest {
|
export type {
|
||||||
provider: "google" | "apple" | "facebook";
|
AuthResponse,
|
||||||
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
|
PasswordLoginRequest,
|
||||||
}
|
SocialLoginRequest,
|
||||||
|
UserMeResponse,
|
||||||
export interface AuthResponse {
|
} from '@/entities/auth';
|
||||||
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
|
|
||||||
refreshToken: string; // 토큰 갱신용
|
|
||||||
user?: UserMeResponse; // 선택적으로 유저 정보를 포함할 수 있음
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserMeResponse {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
grade: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './ui/ExitHoldButton';
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
const HOLD_DURATION_MS = 1000;
|
|
||||||
const BOOST_DURATION_MS = 50;
|
|
||||||
const COMPLETE_HOLD_MS = 160;
|
|
||||||
|
|
||||||
const mapProgress = (elapsedMs: number) => {
|
|
||||||
if (elapsedMs <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elapsedMs <= BOOST_DURATION_MS) {
|
|
||||||
return 0.2 * (elapsedMs / BOOST_DURATION_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tailElapsedMs = Math.min(elapsedMs - BOOST_DURATION_MS, HOLD_DURATION_MS - BOOST_DURATION_MS);
|
|
||||||
return 0.2 + 0.8 * (tailElapsedMs / (HOLD_DURATION_MS - BOOST_DURATION_MS));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useHoldToConfirm = (onConfirm: () => void) => {
|
|
||||||
const frameRef = useRef<number | null>(null);
|
|
||||||
const confirmTimeoutRef = useRef<number | null>(null);
|
|
||||||
const completeTimeoutRef = useRef<number | null>(null);
|
|
||||||
const startRef = useRef<number | null>(null);
|
|
||||||
const confirmedRef = useRef(false);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [isHolding, setHolding] = useState(false);
|
|
||||||
const [isCompleted, setCompleted] = useState(false);
|
|
||||||
|
|
||||||
const clearFrame = () => {
|
|
||||||
if (frameRef.current !== null) {
|
|
||||||
window.cancelAnimationFrame(frameRef.current);
|
|
||||||
frameRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTimers = () => {
|
|
||||||
if (confirmTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(confirmTimeoutRef.current);
|
|
||||||
confirmTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completeTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(completeTimeoutRef.current);
|
|
||||||
completeTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = (withCompleted = false) => {
|
|
||||||
clearFrame();
|
|
||||||
clearTimers();
|
|
||||||
startRef.current = null;
|
|
||||||
confirmedRef.current = false;
|
|
||||||
setHolding(false);
|
|
||||||
setCompleted(withCompleted);
|
|
||||||
setProgress(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearFrame();
|
|
||||||
clearTimers();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const step = () => {
|
|
||||||
if (startRef.current === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedMs = performance.now() - startRef.current;
|
|
||||||
const nextProgress = mapProgress(elapsedMs);
|
|
||||||
const clampedProgress = Math.min(nextProgress, 1);
|
|
||||||
setProgress(clampedProgress);
|
|
||||||
|
|
||||||
if (clampedProgress >= 1 && !confirmedRef.current) {
|
|
||||||
confirmedRef.current = true;
|
|
||||||
if (confirmTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(confirmTimeoutRef.current);
|
|
||||||
confirmTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setHolding(false);
|
|
||||||
setCompleted(true);
|
|
||||||
onConfirm();
|
|
||||||
|
|
||||||
completeTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
reset(false);
|
|
||||||
}, COMPLETE_HOLD_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
frameRef.current = window.requestAnimationFrame(step);
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
if (isHolding || isCompleted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimers();
|
|
||||||
clearFrame();
|
|
||||||
confirmedRef.current = false;
|
|
||||||
setCompleted(false);
|
|
||||||
setProgress(0);
|
|
||||||
setHolding(true);
|
|
||||||
startRef.current = performance.now();
|
|
||||||
frameRef.current = window.requestAnimationFrame(step);
|
|
||||||
confirmTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
if (!confirmedRef.current) {
|
|
||||||
confirmedRef.current = true;
|
|
||||||
confirmTimeoutRef.current = null;
|
|
||||||
setProgress(1);
|
|
||||||
setHolding(false);
|
|
||||||
setCompleted(true);
|
|
||||||
onConfirm();
|
|
||||||
|
|
||||||
completeTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
reset(false);
|
|
||||||
}, COMPLETE_HOLD_MS);
|
|
||||||
}
|
|
||||||
}, HOLD_DURATION_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => {
|
|
||||||
if (!isHolding) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
progress,
|
|
||||||
isHolding,
|
|
||||||
isCompleted,
|
|
||||||
start,
|
|
||||||
cancel,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { KeyboardEvent } from 'react';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
import { useHoldToConfirm } from '../model/useHoldToConfirm';
|
|
||||||
|
|
||||||
interface ExitHoldButtonProps {
|
|
||||||
variant: 'bar' | 'ring';
|
|
||||||
onConfirm: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RING_RADIUS = 13;
|
|
||||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
|
||||||
|
|
||||||
export const ExitHoldButton = ({
|
|
||||||
variant,
|
|
||||||
onConfirm,
|
|
||||||
className,
|
|
||||||
}: ExitHoldButtonProps) => {
|
|
||||||
const { progress, isHolding, isCompleted, start, cancel } = useHoldToConfirm(onConfirm);
|
|
||||||
const ringOffset = RING_CIRCUMFERENCE * (1 - progress);
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
if (event.key === ' ' || event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
||||||
if (event.key === ' ' || event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === 'ring') {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="길게 눌러 나가기"
|
|
||||||
onMouseDown={start}
|
|
||||||
onMouseUp={cancel}
|
|
||||||
onMouseLeave={cancel}
|
|
||||||
onTouchStart={start}
|
|
||||||
onTouchEnd={cancel}
|
|
||||||
onTouchCancel={cancel}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onClick={(event) => event.preventDefault()}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/8 text-white/74 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
|
|
||||||
isHolding && 'bg-white/16',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden
|
|
||||||
className="-rotate-90 absolute inset-0 h-9 w-9"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="16"
|
|
||||||
cy="16"
|
|
||||||
r={RING_RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(255,255,255,0.2)"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="16"
|
|
||||||
cy="16"
|
|
||||||
r={RING_RADIUS}
|
|
||||||
fill="none"
|
|
||||||
stroke="rgba(186,230,253,0.92)"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={RING_CIRCUMFERENCE}
|
|
||||||
strokeDashoffset={ringOffset}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span aria-hidden className="relative z-10 text-[12px]">
|
|
||||||
⤫
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="길게 눌러 나가기"
|
|
||||||
onMouseDown={start}
|
|
||||||
onMouseUp={cancel}
|
|
||||||
onMouseLeave={cancel}
|
|
||||||
onTouchStart={start}
|
|
||||||
onTouchEnd={cancel}
|
|
||||||
onTouchCancel={cancel}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onClick={(event) => event.preventDefault()}
|
|
||||||
className={cn(
|
|
||||||
'relative overflow-hidden rounded-lg bg-white/8 px-2.5 py-1.5 text-xs text-white/82 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isHolding || isCompleted ? (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
'absolute inset-0 z-0 origin-left transform-gpu bg-sky-200/24 rounded-none',
|
|
||||||
isHolding && 'animate-[exit-hold-bar-fill_1000ms_linear_forwards]',
|
|
||||||
)}
|
|
||||||
style={isCompleted ? { transform: 'scaleX(1)' } : undefined}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className="relative z-10 inline-flex items-center gap-1">
|
|
||||||
<span aria-hidden>⤫</span>
|
|
||||||
<span>나가기</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
292
src/features/focus-session/api/focusSessionApi.ts
Normal file
292
src/features/focus-session/api/focusSessionApi.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { normalizeFocusPlanToday, type FocusPlanToday } from '@/entities/focus-plan';
|
||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export type FocusSessionPhase = 'focus' | 'break';
|
||||||
|
export type FocusSessionState = 'running' | 'paused';
|
||||||
|
export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete' | 'manual-end';
|
||||||
|
|
||||||
|
interface RawFocusSession {
|
||||||
|
id: number | string;
|
||||||
|
sceneId: string;
|
||||||
|
goal: string;
|
||||||
|
atmosphereId?: string | null;
|
||||||
|
soundPresetId: string | null;
|
||||||
|
focusPlanItemId?: number | null;
|
||||||
|
microStep?: string | null;
|
||||||
|
phase: FocusSessionPhase;
|
||||||
|
state: FocusSessionState;
|
||||||
|
phaseStartedAt: string;
|
||||||
|
phaseEndsAt: string | null;
|
||||||
|
phaseRemainingSeconds: number;
|
||||||
|
focusDurationSeconds: number;
|
||||||
|
breakDurationSeconds: number;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
abandonedAt: string | null;
|
||||||
|
serverNow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAdvanceCurrentGoalResponse {
|
||||||
|
nextSession: RawFocusSession;
|
||||||
|
updatedPlanToday: Parameters<typeof normalizeFocusPlanToday>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCurrentSessionThought {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sceneName: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
capturedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCompletionResult {
|
||||||
|
completedSessionId: string;
|
||||||
|
completionSource: 'timer-complete' | 'manual-end' | 'goal-complete';
|
||||||
|
goalText: string;
|
||||||
|
focusedSeconds: number;
|
||||||
|
thoughts: RawCurrentSessionThought[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusSession {
|
||||||
|
id: string;
|
||||||
|
sceneId: string;
|
||||||
|
goal: string;
|
||||||
|
atmosphereId?: string | null;
|
||||||
|
soundPresetId: string | null;
|
||||||
|
focusPlanItemId?: string | null;
|
||||||
|
microStep?: string | null;
|
||||||
|
phase: FocusSessionPhase;
|
||||||
|
state: FocusSessionState;
|
||||||
|
phaseStartedAt: string;
|
||||||
|
phaseEndsAt: string | null;
|
||||||
|
phaseRemainingSeconds: number;
|
||||||
|
focusDurationSeconds: number;
|
||||||
|
breakDurationSeconds: number;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
abandonedAt: string | null;
|
||||||
|
serverNow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentSessionThought {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sceneName: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
capturedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionResult {
|
||||||
|
completedSessionId: string;
|
||||||
|
completionSource: 'timer-complete' | 'manual-end' | 'goal-complete';
|
||||||
|
goalText: string;
|
||||||
|
focusedSeconds: number;
|
||||||
|
thoughts: CurrentSessionThought[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartFocusSessionRequest {
|
||||||
|
sceneId: string;
|
||||||
|
goal: string;
|
||||||
|
focusDurationMinutes: number;
|
||||||
|
atmosphereId: string;
|
||||||
|
soundPresetId?: string | null;
|
||||||
|
focusPlanItemId?: string;
|
||||||
|
microStep?: string | null;
|
||||||
|
entryPoint?: 'space-setup' | 'goal-complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteFocusSessionRequest {
|
||||||
|
completionType: FocusSessionCompletionType;
|
||||||
|
completedGoal?: string;
|
||||||
|
focusScore?: number;
|
||||||
|
distractionCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||||
|
sceneId?: string;
|
||||||
|
atmosphereId?: string | null;
|
||||||
|
soundPresetId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrentFocusSessionIntentRequest {
|
||||||
|
goal?: string;
|
||||||
|
microStep?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtendCurrentPhaseRequest {
|
||||||
|
additionalMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvanceCurrentGoalRequest {
|
||||||
|
completedGoal: string;
|
||||||
|
nextGoal: string;
|
||||||
|
sceneId?: string;
|
||||||
|
focusDurationMinutes?: number;
|
||||||
|
atmosphereId?: string | null;
|
||||||
|
soundPresetId?: string | null;
|
||||||
|
focusPlanItemId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvanceCurrentGoalResponse {
|
||||||
|
nextSession: FocusSession;
|
||||||
|
updatedPlanToday: FocusPlanToday;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNumericId = (value: string | undefined) => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFocusSession = (session: RawFocusSession): FocusSession => {
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
id: String(session.id),
|
||||||
|
focusPlanItemId: session.focusPlanItemId == null ? null : String(session.focusPlanItemId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAdvanceGoalResponse = (
|
||||||
|
response: RawAdvanceCurrentGoalResponse,
|
||||||
|
): AdvanceCurrentGoalResponse => {
|
||||||
|
return {
|
||||||
|
nextSession: normalizeFocusSession(response.nextSession),
|
||||||
|
updatedPlanToday: normalizeFocusPlanToday(response.updatedPlanToday),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCurrentSessionThought = (
|
||||||
|
thought: RawCurrentSessionThought,
|
||||||
|
): CurrentSessionThought => {
|
||||||
|
return {
|
||||||
|
...thought,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCompletionResult = (
|
||||||
|
result: RawCompletionResult,
|
||||||
|
): CompletionResult => {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
thoughts: result.thoughts.map(normalizeCurrentSessionThought),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const focusSessionApi = {
|
||||||
|
getCurrentSession: async (): Promise<FocusSession | null> => {
|
||||||
|
const response = await apiClient<RawFocusSession | null>('api/v1/focus-sessions/current', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response ? normalizeFocusSession(response) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentSessionThoughts: async (): Promise<CurrentSessionThought[]> => {
|
||||||
|
const response = await apiClient<RawCurrentSessionThought[]>('api/v1/focus-sessions/current/thoughts', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.map(normalizeCurrentSessionThought);
|
||||||
|
},
|
||||||
|
|
||||||
|
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
focusPlanItemId: toNumericId(payload.focusPlanItemId),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
pauseSession: async (): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/pause', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeSession: async (): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/resume', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
restartCurrentPhase: async (): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/restart-phase', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
extendCurrentPhase: async (payload: ExtendCurrentPhaseRequest): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/extend-phase', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCurrentSelection: async (
|
||||||
|
payload: UpdateCurrentFocusSessionSelectionRequest,
|
||||||
|
): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/selection', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCurrentIntent: async (
|
||||||
|
payload: UpdateCurrentFocusSessionIntentRequest,
|
||||||
|
): Promise<FocusSession> => {
|
||||||
|
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/intent', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeFocusSession(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
completeSession: async (payload: CompleteFocusSessionRequest): Promise<CompletionResult> => {
|
||||||
|
const response = await apiClient<RawCompletionResult>('api/v1/focus-sessions/current/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeCompletionResult(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
advanceGoal: async (payload: AdvanceCurrentGoalRequest): Promise<AdvanceCurrentGoalResponse> => {
|
||||||
|
const response = await apiClient<RawAdvanceCurrentGoalResponse>(
|
||||||
|
'api/v1/focus-sessions/current/advance-goal',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
focusPlanItemId: toNumericId(payload.focusPlanItemId),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeAdvanceGoalResponse(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
abandonSession: async (): Promise<void> => {
|
||||||
|
return apiClient<void>('api/v1/focus-sessions/current/abandon', {
|
||||||
|
method: 'POST',
|
||||||
|
expectNoContent: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/features/focus-session/index.ts
Normal file
2
src/features/focus-session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api/focusSessionApi';
|
||||||
|
export * from './model/useFocusSessionEngine';
|
||||||
330
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
330
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import {
|
||||||
|
type AdvanceCurrentGoalRequest,
|
||||||
|
type AdvanceCurrentGoalResponse,
|
||||||
|
type CompletionResult,
|
||||||
|
focusSessionApi,
|
||||||
|
type CompleteFocusSessionRequest,
|
||||||
|
type FocusSession,
|
||||||
|
type FocusSessionPhase,
|
||||||
|
type FocusSessionState,
|
||||||
|
type StartFocusSessionRequest,
|
||||||
|
type UpdateCurrentFocusSessionIntentRequest,
|
||||||
|
type UpdateCurrentFocusSessionSelectionRequest,
|
||||||
|
} from '../api/focusSessionApi';
|
||||||
|
|
||||||
|
const SESSION_SYNC_INTERVAL_MS = 30_000;
|
||||||
|
const TIMER_TICK_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
const padClock = (value: number) => String(value).padStart(2, '0');
|
||||||
|
|
||||||
|
export const formatDurationClock = (totalSeconds: number) => {
|
||||||
|
const safeSeconds = Math.max(0, totalSeconds);
|
||||||
|
const minutes = Math.floor(safeSeconds / 60);
|
||||||
|
const seconds = safeSeconds % 60;
|
||||||
|
return `${padClock(minutes)}:${padClock(seconds)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServerOffsetMs = (session: FocusSession) => {
|
||||||
|
const serverNowMs = Date.parse(session.serverNow);
|
||||||
|
|
||||||
|
if (Number.isNaN(serverNowMs)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverNowMs - Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemainingSeconds = (
|
||||||
|
session: FocusSession | null,
|
||||||
|
serverOffsetMs: number,
|
||||||
|
tickMs: number,
|
||||||
|
) => {
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.state === 'paused' || !session.phaseEndsAt) {
|
||||||
|
return Math.max(0, session.phaseRemainingSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseEndMs = Date.parse(session.phaseEndsAt);
|
||||||
|
|
||||||
|
if (Number.isNaN(phaseEndMs)) {
|
||||||
|
return Math.max(0, session.phaseRemainingSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.ceil((phaseEndMs - (tickMs + serverOffsetMs)) / 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseFocusSessionEngineResult {
|
||||||
|
currentSession: FocusSession | null;
|
||||||
|
isBootstrapping: boolean;
|
||||||
|
isMutating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
remainingSeconds: number | null;
|
||||||
|
timeDisplay: string | null;
|
||||||
|
playbackState: FocusSessionState | null;
|
||||||
|
phase: FocusSessionPhase | null;
|
||||||
|
syncCurrentSession: () => Promise<FocusSession | null>;
|
||||||
|
startSession: (payload: StartFocusSessionRequest) => Promise<FocusSession | null>;
|
||||||
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
|
||||||
|
updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>;
|
||||||
|
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||||
|
completeSession: (payload: CompleteFocusSessionRequest) => Promise<CompletionResult | null>;
|
||||||
|
advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise<AdvanceCurrentGoalResponse | null>;
|
||||||
|
abandonSession: () => Promise<boolean>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||||
|
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||||
|
const [isBootstrapping, setBootstrapping] = useState(true);
|
||||||
|
const [isMutating, setMutating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [serverOffsetMs, setServerOffsetMs] = useState(0);
|
||||||
|
const [tickMs, setTickMs] = useState(Date.now());
|
||||||
|
const didHydrateRef = useRef(false);
|
||||||
|
|
||||||
|
const applySession = useCallback((session: FocusSession | null) => {
|
||||||
|
setCurrentSession(session);
|
||||||
|
setServerOffsetMs(session ? getServerOffsetMs(session) : 0);
|
||||||
|
setTickMs(Date.now());
|
||||||
|
return session;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncCurrentSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const session = await focusSessionApi.getCurrentSession();
|
||||||
|
setError(null);
|
||||||
|
return applySession(session);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : copy.focusSession.syncFailed;
|
||||||
|
setError(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setBootstrapping(false);
|
||||||
|
}
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (didHydrateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
didHydrateRef.current = true;
|
||||||
|
void syncCurrentSession();
|
||||||
|
}, [syncCurrentSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession || currentSession.state !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void syncCurrentSession();
|
||||||
|
}, SESSION_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [currentSession, syncCurrentSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession || currentSession.state !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setTickMs(Date.now());
|
||||||
|
}, TIMER_TICK_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [currentSession]);
|
||||||
|
|
||||||
|
const remainingSeconds = useMemo(() => {
|
||||||
|
return getRemainingSeconds(currentSession, serverOffsetMs, tickMs);
|
||||||
|
}, [currentSession, serverOffsetMs, tickMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession || currentSession.state !== 'running' || remainingSeconds !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void syncCurrentSession();
|
||||||
|
}, 1_200);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [currentSession, remainingSeconds, syncCurrentSession]);
|
||||||
|
|
||||||
|
const runMutation = async <T>(task: () => Promise<T>, fallbackMessage: string) => {
|
||||||
|
setMutating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await task();
|
||||||
|
setError(null);
|
||||||
|
return result;
|
||||||
|
} catch (nextError) {
|
||||||
|
const message = nextError instanceof Error ? nextError.message : fallbackMessage;
|
||||||
|
setError(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setMutating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentSession,
|
||||||
|
isBootstrapping,
|
||||||
|
isMutating,
|
||||||
|
error,
|
||||||
|
remainingSeconds,
|
||||||
|
timeDisplay: remainingSeconds === null ? null : formatDurationClock(remainingSeconds),
|
||||||
|
playbackState: currentSession?.state ?? null,
|
||||||
|
phase: currentSession?.phase ?? null,
|
||||||
|
syncCurrentSession,
|
||||||
|
startSession: async (payload) => {
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.startSession(payload),
|
||||||
|
copy.focusSession.startFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
pauseSession: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.pauseSession(),
|
||||||
|
copy.focusSession.pauseFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
resumeSession: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.resumeSession(),
|
||||||
|
copy.focusSession.resumeFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
restartCurrentPhase: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.restartCurrentPhase(),
|
||||||
|
copy.focusSession.restartPhaseFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
extendCurrentPhase: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.extendCurrentPhase(payload),
|
||||||
|
copy.focusSession.resumeFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
updateCurrentIntent: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.updateCurrentIntent(payload),
|
||||||
|
copy.focusSession.intentUpdateFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
updateCurrentSelection: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.updateCurrentSelection(payload),
|
||||||
|
copy.focusSession.syncFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
completeSession: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runMutation(
|
||||||
|
() => focusSessionApi.completeSession(payload),
|
||||||
|
copy.focusSession.completeFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
applySession(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
advanceGoal: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runMutation(
|
||||||
|
() => focusSessionApi.advanceGoal(payload),
|
||||||
|
copy.focusSession.completeFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySession(result.nextSession);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
abandonSession: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runMutation(
|
||||||
|
() => focusSessionApi.abandonSession(),
|
||||||
|
copy.focusSession.abandonFailed,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySession(null);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
clearError: () => setError(null),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RecentThought } from '@/entities/session';
|
import type { RecentThought } from '@/entities/session';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
interface InboxListProps {
|
interface InboxListProps {
|
||||||
@@ -17,7 +18,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
지금은 비어 있어요. 집중 중 떠오른 생각을 여기로 주차할 수 있어요.
|
{copy.space.inbox.empty}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
|||||||
{thought.text}
|
{thought.text}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-[11px] text-white/54">
|
<p className="mt-1.5 text-[11px] text-white/54">
|
||||||
{thought.roomName} · {thought.capturedAt}
|
{thought.sceneName} · {thought.capturedAt}
|
||||||
</p>
|
</p>
|
||||||
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
|
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
|
||||||
<button
|
<button
|
||||||
@@ -51,14 +52,14 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
|||||||
: 'border-white/20 bg-white/8 text-white/76 hover:bg-white/14',
|
: 'border-white/20 bg-white/8 text-white/76 hover:bg-white/14',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{thought.isCompleted ? '완료됨' : '완료'}
|
{thought.isCompleted ? copy.space.inbox.completed : copy.space.inbox.complete}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteThought(thought)}
|
onClick={() => onDeleteThought(thought)}
|
||||||
className="inline-flex h-6 items-center rounded-full border border-rose-200/30 bg-rose-200/10 px-2 text-[10px] text-rose-100/88 transition-colors hover:bg-rose-200/18"
|
className="inline-flex h-6 items-center rounded-full border border-rose-200/30 bg-rose-200/10 px-2 text-[10px] text-rose-100/88 transition-colors hover:bg-rose-200/18"
|
||||||
>
|
>
|
||||||
삭제
|
{copy.space.inbox.delete}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
interface ManagePlanSheetContentProps {
|
interface ManagePlanSheetContentProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onManage: () => void;
|
onManage: () => void;
|
||||||
@@ -12,8 +14,8 @@ export const ManagePlanSheetContent = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO 관리</h3>
|
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.manageTitle}</h3>
|
||||||
<p className="text-xs text-white/62">결제/복원은 더미 동작이며 실제 연동은 하지 않아요.</p>
|
<p className="text-xs text-white/62">{copy.space.paywall.manageDescription}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -22,14 +24,14 @@ export const ManagePlanSheetContent = ({
|
|||||||
onClick={onManage}
|
onClick={onManage}
|
||||||
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||||
>
|
>
|
||||||
구독 관리 열기
|
{copy.space.paywall.openSubscription}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRestore}
|
onClick={onRestore}
|
||||||
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||||
>
|
>
|
||||||
구매 복원
|
{copy.space.paywall.restorePurchase}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export const ManagePlanSheetContent = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||||
>
|
>
|
||||||
닫기
|
{copy.common.close}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
interface PaywallSheetContentProps {
|
interface PaywallSheetContentProps {
|
||||||
onStartPro: () => void;
|
onStartPro: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALUE_POINTS = [
|
const VALUE_POINTS = copy.space.paywall.points;
|
||||||
'프리미엄 Scene Packs',
|
|
||||||
'확장 Sound Packs',
|
|
||||||
'프로필 저장 / 불러오기',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
|
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO에서 더 많은 공간과 사운드를 열어둘 수 있어요.</h3>
|
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.title}</h3>
|
||||||
<p className="text-xs text-white/62">잠금 항목을 누른 순간에만 열리는 더미 결제 시트입니다.</p>
|
<p className="text-xs text-white/62">{copy.space.paywall.description}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -33,14 +31,14 @@ export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContent
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||||
>
|
>
|
||||||
나중에
|
{copy.space.paywall.later}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStartPro}
|
onClick={onStartPro}
|
||||||
className="rounded-full border border-sky-200/44 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300"
|
className="rounded-full border border-sky-200/44 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300"
|
||||||
>
|
>
|
||||||
PRO 시작하기
|
{copy.space.paywall.startPro}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PlanTier } from '@/entities/plan';
|
import type { PlanTier } from '@/entities/plan';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
interface PlanPillProps {
|
interface PlanPillProps {
|
||||||
@@ -16,11 +17,11 @@ export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors',
|
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors',
|
||||||
isPro
|
isPro
|
||||||
? 'border-amber-200/46 bg-amber-200/14 text-amber-100 hover:bg-amber-200/24'
|
? 'border-amber-300/30 bg-amber-100/88 text-amber-950 hover:bg-amber-100'
|
||||||
: 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
|
: 'border-brand-dark/12 bg-white/88 text-brand-dark/72 hover:bg-white',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPro ? 'PRO' : 'Normal'}
|
{isPro ? copy.space.toolsDock.planPro : copy.space.toolsDock.planNormal}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
56
src/features/preferences/api/preferencesApi.ts
Normal file
56
src/features/preferences/api/preferencesApi.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
NOTIFICATION_INTENSITY_OPTIONS,
|
||||||
|
} from '@/shared/config/settingsOptions';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
|
||||||
|
|
||||||
|
export interface UserFocusPreferences {
|
||||||
|
reduceMotion: boolean;
|
||||||
|
notificationIntensity: NotificationIntensity;
|
||||||
|
defaultAtmosphereId?: string | null;
|
||||||
|
defaultDurationMinutes?: number | null;
|
||||||
|
defaultSceneId: string | null;
|
||||||
|
defaultSoundPresetId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||||
|
|
||||||
|
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
||||||
|
reduceMotion: false,
|
||||||
|
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
||||||
|
defaultAtmosphereId: null,
|
||||||
|
defaultDurationMinutes: null,
|
||||||
|
defaultSceneId: null,
|
||||||
|
defaultSoundPresetId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preferencesApi = {
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 로그인한 사용자의 집중 관련 개인 설정을 반환한다.
|
||||||
|
* - 최소 reduceMotion, notificationIntensity, defaultAtmosphereId, defaultDurationMinutes를 포함한다.
|
||||||
|
* - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다.
|
||||||
|
*/
|
||||||
|
getFocusPreferences: async (): Promise<UserFocusPreferences> => {
|
||||||
|
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 사용자의 집중 개인 설정 일부 또는 전체를 patch 방식으로 저장한다.
|
||||||
|
* - 저장 후 최종 스냅샷 전체를 반환해 프론트가 로컬 상태를 서버 기준으로 맞출 수 있게 한다.
|
||||||
|
* - 알 수 없는 필드는 무시하지 말고 400으로 검증 오류를 돌려준다.
|
||||||
|
*/
|
||||||
|
updateFocusPreferences: async (
|
||||||
|
payload: UpdateUserFocusPreferencesRequest,
|
||||||
|
): Promise<UserFocusPreferences> => {
|
||||||
|
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/features/preferences/index.ts
Normal file
2
src/features/preferences/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api/preferencesApi';
|
||||||
|
export * from './model/useUserFocusPreferences';
|
||||||
105
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
105
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import {
|
||||||
|
DEFAULT_USER_FOCUS_PREFERENCES,
|
||||||
|
preferencesApi,
|
||||||
|
type UpdateUserFocusPreferencesRequest,
|
||||||
|
type UserFocusPreferences,
|
||||||
|
} from '../api/preferencesApi';
|
||||||
|
|
||||||
|
interface UseUserFocusPreferencesResult {
|
||||||
|
preferences: UserFocusPreferences;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
saveStateLabel: string | null;
|
||||||
|
updatePreferences: (patch: UpdateUserFocusPreferencesRequest) => Promise<void>;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
|
||||||
|
const [preferences, setPreferences] = useState<UserFocusPreferences>(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [isSaving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saveStateLabel, setSaveStateLabel] = useState<string | null>(null);
|
||||||
|
const saveStateTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveStateTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(saveStateTimerRef.current);
|
||||||
|
saveStateTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pushSavedLabel = (label: string) => {
|
||||||
|
setSaveStateLabel(label);
|
||||||
|
|
||||||
|
if (saveStateTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(saveStateTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStateTimerRef.current = window.setTimeout(() => {
|
||||||
|
setSaveStateLabel(null);
|
||||||
|
saveStateTimerRef.current = null;
|
||||||
|
}, 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPreferences = await preferencesApi.getFocusPreferences();
|
||||||
|
setPreferences(nextPreferences);
|
||||||
|
setError(null);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : copy.preferences.loadFailed;
|
||||||
|
setPreferences(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePreferences = async (patch: UpdateUserFocusPreferencesRequest) => {
|
||||||
|
const previous = preferences;
|
||||||
|
const optimistic = { ...preferences, ...patch };
|
||||||
|
|
||||||
|
setPreferences(optimistic);
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const persisted = await preferencesApi.updateFocusPreferences(patch);
|
||||||
|
setPreferences(persisted);
|
||||||
|
pushSavedLabel(copy.preferences.saved);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : copy.preferences.saveFailed;
|
||||||
|
setPreferences(previous);
|
||||||
|
setError(message);
|
||||||
|
pushSavedLabel(copy.preferences.saveFailedLabel);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
saveStateLabel,
|
||||||
|
updatePreferences,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export const RECOVERY_30S_BUTTON_LABEL = '숨 고르기 30초';
|
import { copy } from '@/shared/i18n';
|
||||||
export const RECOVERY_30S_MODE_LABEL = 'BREATHE';
|
|
||||||
export const RECOVERY_30S_TOAST_MESSAGE = '잠깐 숨 고르고, 다시 천천히 시작해요.';
|
export const RECOVERY_30S_BUTTON_LABEL = copy.restart30s.button;
|
||||||
export const RECOVERY_30S_COMPLETE_MESSAGE = '준비됐어요. 집중으로 돌아가요.';
|
export const RECOVERY_30S_MODE_LABEL = copy.restart30s.mode;
|
||||||
|
export const RECOVERY_30S_TOAST_MESSAGE = copy.restart30s.toast;
|
||||||
|
export const RECOVERY_30S_COMPLETE_MESSAGE = copy.restart30s.complete;
|
||||||
|
|||||||
1
src/features/scene-select/index.ts
Normal file
1
src/features/scene-select/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './ui/SceneSelectCarousel';
|
||||||
70
src/features/scene-select/ui/SceneSelectCarousel.tsx
Normal file
70
src/features/scene-select/ui/SceneSelectCarousel.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { SceneTheme } from '@/entities/scene';
|
||||||
|
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
import { useDragScroll } from '@/shared/lib/useDragScroll';
|
||||||
|
|
||||||
|
interface SceneSelectCarouselProps {
|
||||||
|
scenes: SceneTheme[];
|
||||||
|
selectedSceneId: string;
|
||||||
|
sceneAssetMap?: SceneAssetMap;
|
||||||
|
onSelect: (sceneId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneSelectCarousel = ({
|
||||||
|
scenes,
|
||||||
|
selectedSceneId,
|
||||||
|
sceneAssetMap,
|
||||||
|
onSelect,
|
||||||
|
}: SceneSelectCarouselProps) => {
|
||||||
|
const { containerRef, events, isDragging, shouldSuppressClick } = useDragScroll();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
{...events}
|
||||||
|
className={cn(
|
||||||
|
"-mx-1 overflow-x-auto px-1 pb-1 scrollbar-none",
|
||||||
|
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-full gap-2.5">
|
||||||
|
{scenes.map((scene) => {
|
||||||
|
const selected = scene.id === selectedSceneId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scene.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!shouldSuppressClick) {
|
||||||
|
onSelect(scene.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]',
|
||||||
|
selected
|
||||||
|
? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]'
|
||||||
|
: 'border-white/16 hover:border-white/24',
|
||||||
|
isDragging && 'pointer-events-none'
|
||||||
|
)}
|
||||||
|
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||||
|
aria-label={`${scene.name} ${copy.common.select}`}
|
||||||
|
>
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />
|
||||||
|
{selected ? (
|
||||||
|
<span className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-200/64 bg-sky-200/24 text-[10px] font-semibold text-sky-50">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<div className="absolute inset-x-2 bottom-2">
|
||||||
|
<p className="truncate text-sm font-semibold text-white/96">{scene.name}</p>
|
||||||
|
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import type { GoalChip } from '@/entities/session';
|
import type { GoalChip } from '@/entities/session';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export const SessionGoalField = ({
|
|||||||
onGoalChange,
|
onGoalChange,
|
||||||
onGoalChipSelect,
|
onGoalChipSelect,
|
||||||
}: SessionGoalFieldProps) => {
|
}: SessionGoalFieldProps) => {
|
||||||
|
const { sessionGoal } = copy.space;
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,7 +43,7 @@ export const SessionGoalField = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="space-goal-input" className="text-[12px] font-medium text-white/88">
|
<label htmlFor="space-goal-input" className="text-[12px] font-medium text-white/88">
|
||||||
이번 25분, 딱 한 가지 <span className="text-sky-100">(필수)</span>
|
{sessionGoal.label} <span className="text-sky-100">{sessionGoal.required}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -49,10 +51,10 @@ export const SessionGoalField = ({
|
|||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
value={goalInput}
|
value={goalInput}
|
||||||
onChange={(event) => onGoalChange(event.target.value)}
|
onChange={(event) => onGoalChange(event.target.value)}
|
||||||
placeholder="예: 계약서 1페이지 정리"
|
placeholder={sessionGoal.placeholder}
|
||||||
className="h-10 w-full rounded-xl border border-white/14 bg-slate-950/42 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/46 focus:outline-none"
|
className="h-10 w-full rounded-xl border border-white/14 bg-slate-950/42 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/46 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-white/54">크게 말고, 바로 다음 한 조각.</p>
|
<p className="text-[11px] text-white/54">{sessionGoal.hint}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './model/useSoundPlayback';
|
||||||
export * from './model/useSoundPresetSelection';
|
export * from './model/useSoundPresetSelection';
|
||||||
export * from './ui/SoundPresetControls';
|
export * from './ui/SoundPresetControls';
|
||||||
|
|||||||
323
src/features/sound-preset/model/useSoundPlayback.ts
Normal file
323
src/features/sound-preset/model/useSoundPlayback.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { SoundAssetManifestItem } from '@/entities/media';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
|
||||||
|
interface UseSoundPlaybackOptions {
|
||||||
|
selectedPresetId: string;
|
||||||
|
soundAsset?: SoundAssetManifestItem;
|
||||||
|
masterVolume: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
shouldPlay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampVolume = (value: number) => {
|
||||||
|
return Math.min(1, Math.max(0, value / 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePlaybackErrorMessage = (error: unknown) => {
|
||||||
|
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||||
|
return copy.soundPlayback.browserDeferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy.soundPlayback.loadFailed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMediaUrl = (value: string | null | undefined) => {
|
||||||
|
if (!value || typeof window === 'undefined') {
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value, window.location.href).toString();
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSoundPlayback = ({
|
||||||
|
selectedPresetId,
|
||||||
|
soundAsset,
|
||||||
|
masterVolume,
|
||||||
|
isMuted,
|
||||||
|
shouldPlay,
|
||||||
|
}: UseSoundPlaybackOptions) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const isPlaybackUnlockedRef = useRef(false);
|
||||||
|
const hasUserInteractedRef = useRef(false);
|
||||||
|
const recoveryAttemptedUrlRef = useRef<string | null>(null);
|
||||||
|
const requestedUrlRef = useRef<string | null>(null);
|
||||||
|
const [isReady, setReady] = useState(false);
|
||||||
|
const [isPlaying, setPlaying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const activeUrl = useMemo(() => {
|
||||||
|
if (selectedPresetId === 'silent') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null;
|
||||||
|
}, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]);
|
||||||
|
|
||||||
|
const normalizedActiveUrl = useMemo(() => {
|
||||||
|
return normalizeMediaUrl(activeUrl);
|
||||||
|
}, [activeUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = new window.Audio();
|
||||||
|
audio.loop = true;
|
||||||
|
audio.preload = 'auto';
|
||||||
|
isPlaybackUnlockedRef.current = false;
|
||||||
|
hasUserInteractedRef.current = false;
|
||||||
|
recoveryAttemptedUrlRef.current = null;
|
||||||
|
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
setReady(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaying = () => {
|
||||||
|
setPlaying(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadStart = () => {
|
||||||
|
setReady(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
if (!requestedUrlRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReady(false);
|
||||||
|
setPlaying(false);
|
||||||
|
setError(copy.soundPlayback.loadFailed);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener('loadstart', handleLoadStart);
|
||||||
|
audio.addEventListener('canplay', handleCanPlay);
|
||||||
|
audio.addEventListener('playing', handlePlaying);
|
||||||
|
audio.addEventListener('pause', handlePause);
|
||||||
|
audio.addEventListener('error', handleError);
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
audio.load();
|
||||||
|
audio.removeEventListener('loadstart', handleLoadStart);
|
||||||
|
audio.removeEventListener('canplay', handleCanPlay);
|
||||||
|
audio.removeEventListener('playing', handlePlaying);
|
||||||
|
audio.removeEventListener('pause', handlePause);
|
||||||
|
audio.removeEventListener('error', handleError);
|
||||||
|
audioRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.volume = clampVolume(masterVolume);
|
||||||
|
audio.muted = isMuted;
|
||||||
|
}, [isMuted, masterVolume]);
|
||||||
|
|
||||||
|
const unlockPlayback = useCallback(async (requestedUrl?: string | null) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
const nextUrl =
|
||||||
|
selectedPresetId === 'silent'
|
||||||
|
? null
|
||||||
|
: requestedUrl ?? activeUrl;
|
||||||
|
const normalizedNextUrl = normalizeMediaUrl(nextUrl);
|
||||||
|
|
||||||
|
if (!audio || !nextUrl || !normalizedNextUrl) {
|
||||||
|
isPlaybackUnlockedRef.current = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlaybackUnlockedRef.current) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMuted = audio.muted;
|
||||||
|
const previousVolume = audio.volume;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (audio.src !== normalizedNextUrl) {
|
||||||
|
audio.src = normalizedNextUrl;
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.muted = true;
|
||||||
|
audio.volume = 0;
|
||||||
|
await audio.play();
|
||||||
|
audio.pause();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.muted = previousMuted;
|
||||||
|
audio.volume = previousVolume;
|
||||||
|
isPlaybackUnlockedRef.current = true;
|
||||||
|
setError(null);
|
||||||
|
return true;
|
||||||
|
} catch (playbackError) {
|
||||||
|
audio.pause();
|
||||||
|
audio.muted = previousMuted;
|
||||||
|
audio.volume = previousVolume;
|
||||||
|
setPlaying(false);
|
||||||
|
setError(resolvePlaybackErrorMessage(playbackError));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [activeUrl, selectedPresetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const markInteraction = () => {
|
||||||
|
hasUserInteractedRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', markInteraction, { passive: true });
|
||||||
|
window.addEventListener('keydown', markInteraction, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', markInteraction);
|
||||||
|
window.removeEventListener('keydown', markInteraction);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeUrl || !normalizedActiveUrl) {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
requestedUrlRef.current = null;
|
||||||
|
recoveryAttemptedUrlRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.src === normalizedActiveUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedUrlRef.current = normalizedActiveUrl;
|
||||||
|
audio.src = normalizedActiveUrl;
|
||||||
|
audio.load();
|
||||||
|
}, [activeUrl, normalizedActiveUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldPlay || !activeUrl || isPlaybackUnlockedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptRecovery = () => {
|
||||||
|
if (recoveryAttemptedUrlRef.current === activeUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryAttemptedUrlRef.current = activeUrl;
|
||||||
|
void unlockPlayback(activeUrl).then((didUnlock) => {
|
||||||
|
if (!didUnlock && recoveryAttemptedUrlRef.current === activeUrl) {
|
||||||
|
recoveryAttemptedUrlRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptRecovery();
|
||||||
|
|
||||||
|
if (isPlaybackUnlockedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUserInteractedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const handleInteraction = () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUserInteractedRef.current = true;
|
||||||
|
attemptRecovery();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', handleInteraction, { passive: true, once: true });
|
||||||
|
window.addEventListener('keydown', handleInteraction, { passive: true, once: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener('pointerdown', handleInteraction);
|
||||||
|
window.removeEventListener('keydown', handleInteraction);
|
||||||
|
};
|
||||||
|
}, [activeUrl, shouldPlay, unlockPlayback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldPlay || !activeUrl) {
|
||||||
|
audio.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlaybackUnlockedRef.current) {
|
||||||
|
audio.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const playAudio = async () => {
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (playbackError) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPlaying(false);
|
||||||
|
setError(resolvePlaybackErrorMessage(playbackError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void playAudio();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeUrl, shouldPlay]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeUrl,
|
||||||
|
isReady,
|
||||||
|
isPlaying,
|
||||||
|
error,
|
||||||
|
unlockPlayback,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SOUND_PRESETS } from '@/entities/session';
|
import { SOUND_PRESETS } from '@/entities/session';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
import { Toggle } from '@/shared/ui';
|
import { Toggle } from '@/shared/ui';
|
||||||
import type { SoundTrackKey } from '../model/useSoundPresetSelection';
|
import type { SoundTrackKey } from '../model/useSoundPresetSelection';
|
||||||
|
|
||||||
@@ -16,13 +17,7 @@ interface SoundPresetControlsProps {
|
|||||||
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
|
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRACK_LABELS: Record<SoundTrackKey, string> = {
|
const TRACK_LABELS: Record<SoundTrackKey, string> = copy.space.soundPresetControls.trackLabels;
|
||||||
white: 'White',
|
|
||||||
rain: 'Rain',
|
|
||||||
cafe: 'Cafe',
|
|
||||||
wave: 'Wave',
|
|
||||||
fan: 'Fan',
|
|
||||||
};
|
|
||||||
|
|
||||||
const clampSliderValue = (value: number) => Math.max(0, Math.min(100, value));
|
const clampSliderValue = (value: number) => Math.max(0, Math.min(100, value));
|
||||||
|
|
||||||
@@ -39,10 +34,11 @@ export const SoundPresetControls = ({
|
|||||||
trackLevels,
|
trackLevels,
|
||||||
onTrackLevelChange,
|
onTrackLevelChange,
|
||||||
}: SoundPresetControlsProps) => {
|
}: SoundPresetControlsProps) => {
|
||||||
|
const { soundPresetControls } = copy.space;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.11em] text-white/58">Preset</p>
|
<p className="text-xs uppercase tracking-[0.11em] text-white/58">{soundPresetControls.preset}</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{SOUND_PRESETS.map((preset) => (
|
{SOUND_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
@@ -66,16 +62,16 @@ export const SoundPresetControls = ({
|
|||||||
onClick={onToggleMixer}
|
onClick={onToggleMixer}
|
||||||
className="inline-flex items-center gap-2 text-xs text-white/70 transition hover:text-white"
|
className="inline-flex items-center gap-2 text-xs text-white/70 transition hover:text-white"
|
||||||
>
|
>
|
||||||
<span>{isMixerOpen ? 'Mixer 접기' : 'Mixer 펼치기'}</span>
|
<span>{isMixerOpen ? soundPresetControls.mixerClose : soundPresetControls.mixerOpen}</span>
|
||||||
<span className="inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-white/86 ring-1 ring-white/20">
|
<span className="inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-white/86 ring-1 ring-white/20">
|
||||||
더미
|
{soundPresetControls.mock}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isMixerOpen ? (
|
{isMixerOpen ? (
|
||||||
<div className="space-y-3 rounded-xl border border-white/14 bg-white/6 p-3">
|
<div className="space-y-3 rounded-xl border border-white/14 bg-white/6 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-white/78">마스터 볼륨</span>
|
<span className="text-xs text-white/78">{soundPresetControls.masterVolume}</span>
|
||||||
<span className="text-[11px] text-white/58">{masterVolume}%</span>
|
<span className="text-[11px] text-white/58">{masterVolume}%</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -90,11 +86,11 @@ export const SoundPresetControls = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-white/12 pt-2">
|
<div className="flex items-center justify-between border-t border-white/12 pt-2">
|
||||||
<p className="text-xs text-white/78">뮤트</p>
|
<p className="text-xs text-white/78">{soundPresetControls.mute}</p>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={isMuted}
|
checked={isMuted}
|
||||||
onChange={onMuteChange}
|
onChange={onMuteChange}
|
||||||
ariaLabel="마스터 뮤트 토글"
|
ariaLabel={soundPresetControls.muteToggleAriaLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './ui/SpaceSelectCarousel';
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
|
||||||
import { cn } from '@/shared/lib/cn';
|
|
||||||
|
|
||||||
interface SpaceSelectCarouselProps {
|
|
||||||
rooms: RoomTheme[];
|
|
||||||
selectedRoomId: string;
|
|
||||||
onSelect: (roomId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SpaceSelectCarousel = ({
|
|
||||||
rooms,
|
|
||||||
selectedRoomId,
|
|
||||||
onSelect,
|
|
||||||
}: SpaceSelectCarouselProps) => {
|
|
||||||
return (
|
|
||||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
|
||||||
<div className="flex min-w-full gap-2.5">
|
|
||||||
{rooms.map((room) => {
|
|
||||||
const selected = room.id === selectedRoomId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={room.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(room.id)}
|
|
||||||
className={cn(
|
|
||||||
'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]',
|
|
||||||
selected
|
|
||||||
? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]'
|
|
||||||
: 'border-white/16 hover:border-white/24',
|
|
||||||
)}
|
|
||||||
style={getRoomCardBackgroundStyle(room)}
|
|
||||||
aria-label={`${room.name} 선택`}
|
|
||||||
>
|
|
||||||
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-200/64 bg-sky-200/24 text-[10px] font-semibold text-sky-50">
|
|
||||||
✓
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="absolute inset-x-2 bottom-2">
|
|
||||||
<p className="truncate text-sm font-semibold text-white/96">{room.name}</p>
|
|
||||||
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
45
src/features/stats/api/statsApi.ts
Normal file
45
src/features/stats/api/statsApi.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export interface FocusTrendPoint {
|
||||||
|
date: string;
|
||||||
|
focusMinutes: number;
|
||||||
|
completedCycles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusStatsSummary {
|
||||||
|
today: {
|
||||||
|
focusMinutes: number;
|
||||||
|
completedCycles: number;
|
||||||
|
sessionEntries: number;
|
||||||
|
};
|
||||||
|
last7Days: {
|
||||||
|
focusMinutes: number;
|
||||||
|
bestDayLabel: string;
|
||||||
|
bestDayFocusMinutes: number;
|
||||||
|
streakDays: number;
|
||||||
|
startedSessions: number;
|
||||||
|
completedSessions: number;
|
||||||
|
carriedOverCount: number;
|
||||||
|
};
|
||||||
|
recovery: {
|
||||||
|
pausedSessions: number;
|
||||||
|
resumedSessions: number;
|
||||||
|
pauseRecoveryRate: number;
|
||||||
|
awayRecoveryReady: boolean;
|
||||||
|
};
|
||||||
|
trend: FocusTrendPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsApi = {
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 로그인한 사용자의 집중 통계 요약을 반환한다.
|
||||||
|
* - today, last7Days, trend를 한 번에 내려 프론트가 화면 진입 시 즉시 렌더링할 수 있게 한다.
|
||||||
|
* - 단위는 계산이 쉬운 숫자형(minutes, counts)으로 내려주고, 라벨 포맷팅은 프론트가 맡는다.
|
||||||
|
*/
|
||||||
|
getFocusStatsSummary: async (): Promise<FocusStatsSummary> => {
|
||||||
|
return apiClient<FocusStatsSummary>('api/v1/stats/focus-summary', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/features/stats/index.ts
Normal file
2
src/features/stats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api/statsApi';
|
||||||
|
export * from './model/useFocusStats';
|
||||||
431
src/features/stats/model/useFocusStats.ts
Normal file
431
src/features/stats/model/useFocusStats.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { statsApi, type FocusStatsSummary } from '../api/statsApi';
|
||||||
|
|
||||||
|
type StatsSource = 'api' | 'mock';
|
||||||
|
|
||||||
|
const parseDurationLabelToMinutes = (label: string) => {
|
||||||
|
const hourMatch = label.match(/(\d+)h/);
|
||||||
|
const minuteMatch = label.match(/(\d+)m/);
|
||||||
|
const hours = hourMatch ? Number(hourMatch[1]) : 0;
|
||||||
|
const minutes = minuteMatch ? Number(minuteMatch[1]) : 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMinutesLabel = (minutes: number) => {
|
||||||
|
const safeMinutes = Math.max(0, minutes);
|
||||||
|
const hourPart = Math.floor(safeMinutes / 60);
|
||||||
|
const minutePart = safeMinutes % 60;
|
||||||
|
|
||||||
|
if (hourPart === 0) {
|
||||||
|
return `${minutePart}분`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minutePart === 0) {
|
||||||
|
return `${hourPart}시간`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${hourPart}시간 ${minutePart}분`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => `${Math.round(value * 100)}%`;
|
||||||
|
|
||||||
|
const buildMockSummary = (): FocusStatsSummary => {
|
||||||
|
return {
|
||||||
|
today: {
|
||||||
|
focusMinutes: parseDurationLabelToMinutes(TODAY_STATS[0]?.value ?? '0m'),
|
||||||
|
completedCycles: Number.parseInt(TODAY_STATS[1]?.value ?? '0', 10) || 0,
|
||||||
|
sessionEntries: Number.parseInt(TODAY_STATS[2]?.value ?? '0', 10) || 0,
|
||||||
|
},
|
||||||
|
last7Days: {
|
||||||
|
focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'),
|
||||||
|
bestDayLabel: WEEKLY_STATS[1]?.value ?? '수요일',
|
||||||
|
bestDayFocusMinutes: 210,
|
||||||
|
streakDays: 4,
|
||||||
|
startedSessions: 6,
|
||||||
|
completedSessions: 4,
|
||||||
|
carriedOverCount: 1,
|
||||||
|
},
|
||||||
|
recovery: {
|
||||||
|
pausedSessions: 3,
|
||||||
|
resumedSessions: 2,
|
||||||
|
pauseRecoveryRate: 2 / 3,
|
||||||
|
awayRecoveryReady: true,
|
||||||
|
},
|
||||||
|
trend: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WeeklyReviewMetric {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyReviewSection {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
metrics: WeeklyReviewMetric[];
|
||||||
|
availability: 'ready' | 'limited';
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewCarryHint = 'smaller' | 'closure' | 'start' | 'steady';
|
||||||
|
|
||||||
|
export interface WeeklyReviewViewModel {
|
||||||
|
periodLabel: string;
|
||||||
|
snapshotTitle: string;
|
||||||
|
snapshotSummary: string;
|
||||||
|
snapshotMetrics: WeeklyReviewMetric[];
|
||||||
|
startQuality: WeeklyReviewSection;
|
||||||
|
recoveryQuality: WeeklyReviewSection;
|
||||||
|
completionQuality: WeeklyReviewSection;
|
||||||
|
carryForward: {
|
||||||
|
hintKey: ReviewCarryHint;
|
||||||
|
atmosphereId: string;
|
||||||
|
atmosphereLabel: string;
|
||||||
|
sceneId: string;
|
||||||
|
soundPresetId: string | null;
|
||||||
|
durationMinutes: number;
|
||||||
|
keepDoing: string;
|
||||||
|
tryNext: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaHref: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSnapshotSummary = (summary: FocusStatsSummary) => {
|
||||||
|
const { startedSessions, completedSessions, carriedOverCount } = summary.last7Days;
|
||||||
|
const completionRate = startedSessions > 0 ? completedSessions / startedSessions : 0;
|
||||||
|
|
||||||
|
if (startedSessions === 0) {
|
||||||
|
return copy.stats.reviewSnapshotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionRate >= 0.6 && carriedOverCount === 0) {
|
||||||
|
return copy.stats.reviewSnapshotStrong;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionRate >= 0.4) {
|
||||||
|
return copy.stats.reviewSnapshotSteady;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy.stats.reviewSnapshotRecoveryNeeded;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStartSummary = (summary: FocusStatsSummary) => {
|
||||||
|
const averageDepthMinutes =
|
||||||
|
summary.last7Days.startedSessions > 0
|
||||||
|
? Math.round(summary.last7Days.focusMinutes / summary.last7Days.startedSessions)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (summary.last7Days.startedSessions === 0) {
|
||||||
|
return copy.stats.reviewStartEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy.stats.reviewStartSummary(
|
||||||
|
summary.last7Days.startedSessions,
|
||||||
|
summary.last7Days.bestDayLabel,
|
||||||
|
formatMinutesLabel(averageDepthMinutes),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCompletionSummary = (summary: FocusStatsSummary) => {
|
||||||
|
const completionRate =
|
||||||
|
summary.last7Days.startedSessions > 0
|
||||||
|
? summary.last7Days.completedSessions / summary.last7Days.startedSessions
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (summary.last7Days.startedSessions === 0) {
|
||||||
|
return copy.stats.reviewCompletionEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.last7Days.carriedOverCount === 0 && completionRate >= 0.6) {
|
||||||
|
return copy.stats.reviewCompletionStrong;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy.stats.reviewCompletionSummary(
|
||||||
|
formatPercent(completionRate),
|
||||||
|
summary.last7Days.carriedOverCount,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRecoverySummary = (summary: FocusStatsSummary, source: StatsSource) => {
|
||||||
|
if (source === 'mock') {
|
||||||
|
return copy.stats.reviewRecoveryMockSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.recovery.pausedSessions === 0) {
|
||||||
|
return copy.stats.reviewRecoveryNoPauseSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy.stats.reviewRecoveryApiSummary(
|
||||||
|
formatPercent(summary.recovery.pauseRecoveryRate),
|
||||||
|
summary.recovery.resumedSessions,
|
||||||
|
summary.recovery.pausedSessions,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRecoverySection = (
|
||||||
|
summary: FocusStatsSummary,
|
||||||
|
source: StatsSource,
|
||||||
|
): WeeklyReviewSection => {
|
||||||
|
if (source === 'mock') {
|
||||||
|
return {
|
||||||
|
title: copy.stats.reviewRecoveryTitle,
|
||||||
|
summary: copy.stats.reviewRecoveryMockSummary,
|
||||||
|
availability: 'ready',
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'recovery-pause',
|
||||||
|
label: copy.stats.reviewPauseRecovery,
|
||||||
|
value: '67%',
|
||||||
|
hint: copy.stats.reviewPauseRecoveryHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recovery-away',
|
||||||
|
label: copy.stats.reviewAwayRecovery,
|
||||||
|
value: '50%',
|
||||||
|
hint: copy.stats.reviewAwayRecoveryHint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: copy.stats.reviewRecoveryMockNote,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = summary.recovery.awayRecoveryReady ? 'ready' : 'limited';
|
||||||
|
const metrics =
|
||||||
|
summary.recovery.pausedSessions > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'recovery-pause-rate',
|
||||||
|
label: copy.stats.reviewPauseRecovery,
|
||||||
|
value: formatPercent(summary.recovery.pauseRecoveryRate),
|
||||||
|
hint: copy.stats.reviewPauseRecoveryHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recovery-resumed-sessions',
|
||||||
|
label: copy.stats.reviewResumedSessions,
|
||||||
|
value: `${summary.recovery.resumedSessions}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewResumedSessionsHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recovery-paused-sessions',
|
||||||
|
label: copy.stats.reviewPausedSessions,
|
||||||
|
value: `${summary.recovery.pausedSessions}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewPausedSessionsHint,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: copy.stats.reviewRecoveryTitle,
|
||||||
|
summary: buildRecoverySummary(summary, source),
|
||||||
|
availability,
|
||||||
|
metrics,
|
||||||
|
note: availability === 'limited' ? copy.stats.reviewRecoveryPartialNote : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['carryForward'] => {
|
||||||
|
const completionRate =
|
||||||
|
summary.last7Days.startedSessions > 0
|
||||||
|
? summary.last7Days.completedSessions / summary.last7Days.startedSessions
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const keepDoing =
|
||||||
|
summary.last7Days.bestDayFocusMinutes > 0
|
||||||
|
? copy.stats.reviewCarryKeep(summary.last7Days.bestDayLabel)
|
||||||
|
: copy.stats.reviewCarryKeepGeneric;
|
||||||
|
|
||||||
|
let hintKey: ReviewCarryHint = 'steady';
|
||||||
|
let tryNext: string = copy.stats.reviewCarryTryDefault;
|
||||||
|
|
||||||
|
if (summary.last7Days.carriedOverCount >= 2) {
|
||||||
|
hintKey = 'smaller';
|
||||||
|
tryNext = copy.stats.reviewCarryTrySmaller;
|
||||||
|
} else if (completionRate < 0.45) {
|
||||||
|
hintKey = 'closure';
|
||||||
|
tryNext = copy.stats.reviewCarryTryClosure;
|
||||||
|
} else if (summary.last7Days.startedSessions <= 3) {
|
||||||
|
hintKey = 'start';
|
||||||
|
tryNext = copy.stats.reviewCarryTryStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
review: 'weekly',
|
||||||
|
carryHint: hintKey,
|
||||||
|
entryAtmosphereId: 'forest-draft',
|
||||||
|
entryDurationMinutes: '50',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hintKey,
|
||||||
|
atmosphereId: 'forest-draft',
|
||||||
|
atmosphereLabel: 'Forest Draft',
|
||||||
|
sceneId: 'forest',
|
||||||
|
soundPresetId: 'forest-birds',
|
||||||
|
durationMinutes: 50,
|
||||||
|
keepDoing,
|
||||||
|
tryNext,
|
||||||
|
ctaLabel: copy.stats.reviewCarryCta,
|
||||||
|
ctaHref: `/app?${params.toString()}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildReviewFromSummary = (
|
||||||
|
summary: FocusStatsSummary,
|
||||||
|
source: StatsSource,
|
||||||
|
): WeeklyReviewViewModel => {
|
||||||
|
const completionRate =
|
||||||
|
summary.last7Days.startedSessions > 0
|
||||||
|
? summary.last7Days.completedSessions / summary.last7Days.startedSessions
|
||||||
|
: 0;
|
||||||
|
const averageDepthMinutes =
|
||||||
|
summary.last7Days.startedSessions > 0
|
||||||
|
? Math.round(summary.last7Days.focusMinutes / summary.last7Days.startedSessions)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
periodLabel: copy.stats.reviewPeriodLabel,
|
||||||
|
snapshotTitle: copy.stats.reviewTitle,
|
||||||
|
snapshotSummary: buildSnapshotSummary(summary),
|
||||||
|
snapshotMetrics: [
|
||||||
|
{
|
||||||
|
id: 'snapshot-started',
|
||||||
|
label: copy.stats.reviewStarted,
|
||||||
|
value: `${summary.last7Days.startedSessions}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewStartedHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snapshot-completed',
|
||||||
|
label: copy.stats.reviewCompleted,
|
||||||
|
value: `${summary.last7Days.completedSessions}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewCompletedHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snapshot-focus',
|
||||||
|
label: copy.stats.reviewFocusMinutes,
|
||||||
|
value: formatMinutesLabel(summary.last7Days.focusMinutes),
|
||||||
|
hint: copy.stats.reviewFocusMinutesHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snapshot-carried',
|
||||||
|
label: copy.stats.reviewCarriedOver,
|
||||||
|
value: `${summary.last7Days.carriedOverCount}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewCarriedOverHint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
startQuality: {
|
||||||
|
title: copy.stats.reviewStartTitle,
|
||||||
|
summary: buildStartSummary(summary),
|
||||||
|
availability: 'ready',
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'start-sessions',
|
||||||
|
label: copy.stats.reviewStarted,
|
||||||
|
value: `${summary.last7Days.startedSessions}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewStartedHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'start-depth',
|
||||||
|
label: copy.stats.reviewAverageDepth,
|
||||||
|
value: formatMinutesLabel(averageDepthMinutes),
|
||||||
|
hint: copy.stats.reviewAverageDepthHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'start-best-day',
|
||||||
|
label: copy.stats.reviewBestDay,
|
||||||
|
value: summary.last7Days.bestDayLabel,
|
||||||
|
hint: copy.stats.reviewBestDayHint(summary.last7Days.bestDayFocusMinutes),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
recoveryQuality: buildRecoverySection(summary, source),
|
||||||
|
completionQuality: {
|
||||||
|
title: copy.stats.reviewCompletionTitle,
|
||||||
|
summary: buildCompletionSummary(summary),
|
||||||
|
availability: 'ready',
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'completion-rate',
|
||||||
|
label: copy.stats.reviewCompletionRate,
|
||||||
|
value: formatPercent(completionRate),
|
||||||
|
hint: copy.stats.reviewCompletionRateHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completion-goals',
|
||||||
|
label: copy.stats.reviewCompleted,
|
||||||
|
value: `${summary.last7Days.completedSessions}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewCompletedHint,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completion-carry',
|
||||||
|
label: copy.stats.reviewCarriedOver,
|
||||||
|
value: `${summary.last7Days.carriedOverCount}${copy.stats.countUnit}`,
|
||||||
|
hint: copy.stats.reviewCarriedOverHint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
carryForward: buildCarryForward(summary),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseFocusStatsResult {
|
||||||
|
summary: FocusStatsSummary;
|
||||||
|
review: WeeklyReviewViewModel;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
source: StatsSource;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFocusStats = (): UseFocusStatsResult => {
|
||||||
|
const initialSummary = buildMockSummary();
|
||||||
|
const [summary, setSummary] = useState<FocusStatsSummary>(initialSummary);
|
||||||
|
const [review, setReview] = useState<WeeklyReviewViewModel>(
|
||||||
|
buildReviewFromSummary(initialSummary, 'mock'),
|
||||||
|
);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [source, setSource] = useState<StatsSource>('mock');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextSummary = await statsApi.getFocusStatsSummary();
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setReview(buildReviewFromSummary(nextSummary, 'api'));
|
||||||
|
setSource('api');
|
||||||
|
setError(null);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : copy.stats.loadFailed;
|
||||||
|
const nextSummary = buildMockSummary();
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setReview(buildReviewFromSummary(nextSummary, 'mock'));
|
||||||
|
setSource('mock');
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
review,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
source,
|
||||||
|
refetch: load,
|
||||||
|
};
|
||||||
|
};
|
||||||
2
src/shared/config/authTokens.ts
Normal file
2
src/shared/config/authTokens.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const TOKEN_COOKIE_KEY = 'vr_access_token';
|
||||||
|
export const REFRESH_TOKEN_COOKIE_KEY = 'vr_refresh_token';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user