Compare commits

...

10 Commits

Author SHA1 Message Date
5f7ca99f44 feat(resume): 지난 한 조각 이어서 시작하는 진입 플로우 추가
맥락:
- /space 재진입 시 마지막 목표를 다시 쓰게 되어 시작 마찰이 컸다.
- work.md 작업 2 요구사항에 맞춰 목표 기반 Resume CTA를 진입 의식 안에 추가했다.

변경사항:
- workspace localStorage 스키마에 goal 필드를 추가하고 저장/복원에 반영했다.
- Setup Ritual에 지난 한 조각 이어서 블록을 추가했다.
- 이어서 시작은 저장 목표로 즉시 Focus 전환, 새로 시작은 목표 초기화 후 새 세션 입력으로 전환하도록 연결했다.
- session 문서 docs/session_brief.md, docs/90_current_state.md를 최신 상태로 갱신했다.

검증:
- npx tsc --noEmit

세션-상태: Resume CTA와 목표 복원 흐름이 /space 진입에 반영됨
세션-다음: Goal Complete 루프와 Recover(Notes→Inbox) 플로우 마감
세션-리스크: localStorage 기반 복원이라 다중 탭/스토리지 초기화 시 세션 연속성이 약할 수 있음
2026-03-05 18:18:13 +09:00
8917cd8e77 docs(session): Pro 잠금 재배치와 상단 중앙 토스트 기준으로 상태 갱신
맥락:
- workflow 실행 후 세션 복구 문서가 최신 구현 상태를 반영해야 다음 턴 복구 품질이 유지됩니다.

변경사항:
- 90_current_state의 DONE/NEXT/RISKS/CHANGED FILES를 최신 커밋 기준으로 갱신했습니다.
- session_brief의 우선순위와 최근 세션 상태를 Packs/Paywall/Feedback 구조로 압축 업데이트했습니다.

검증:
- npx tsc --noEmit

세션-상태: 문서 기준점이 최신 Focus/Plan/Paywall UX와 일치함
세션-다음: 선택 작업(Profiles 더미 슬롯) 여부 결정 후 구현
세션-리스크: docs/work.md는 사용자 작업 입력 파일로 워킹트리에 유지
2026-03-05 17:36:49 +09:00
c64a08ddf2 feat(paywall): 의도 기반 Paywall Sheet(더미) 트리거로 정리
맥락:
- Focus 경험을 해치지 않으려면 결제 시트는 잠금 항목을 클릭한 의도 순간에만 열려야 합니다.

변경사항:
- Plan Pill(NORMAL) 클릭 시 Paywall을 바로 열지 않고 상태 안내만 노출하도록 조정했습니다.
- 잠금 카드 클릭에서만 Paywall이 열리도록 트리거를 단일화했습니다.
- Paywall Sheet를 3개 가치 포인트 + 2개 CTA 중심의 간결한 구조로 리디자인했습니다.

검증:
- npx tsc --noEmit

세션-상태: Paywall 노출이 잠금 클릭 의도 기반으로만 동작함
세션-다음: (선택) Profiles 저장/불러오기 더미 슬롯을 Packs 영역에 추가 가능
세션-리스크: NORMAL 사용자가 Plan Pill에서 업그레이드 진입을 기대할 수 있어 UX 카피 미세 조정 여지
2026-03-05 17:30:57 +09:00
1c7c6d396f style(control-center): Packs/Profiles를 프리미엄 톤으로 최소 노출
맥락:
- Control Center에서 Pro 기능이 설정 패널처럼 과밀하게 보이면 Focus 흐름이 끊깁니다.

변경사항:
- Quick Controls의 추천 조합 영역에서 클릭 버튼을 제거하고 정보 1줄만 남겼습니다.
- 하단에 Scene Packs/Sound Packs/Profiles 요약 카드를 추가해 확장 기능을 조용한 카드 형태로 수납했습니다.
- 기본 Scene/Time/Sound는 선택 중심으로 유지하고 Packs 잠금 클릭만 별도 동선으로 연결했습니다.

검증:
- npx tsc --noEmit

세션-상태: Control Center가 Scene/Time 중심 + 조용한 Packs 확장 구조로 정리됨
세션-다음: 잠금 카드 클릭 기반 Paywall 시트를 의도 기반 메시지로 간결화
세션-리스크: Plan Pill normal 클릭 시 paywall 오픈 동선은 다음 커밋에서 제거 예정
2026-03-05 17:24:33 +09:00
3c6c5e6aa0 feat(pro): Pro 잠금 대상을 Packs/Profiles로 재구성
맥락:
- Pro 가치는 기본 기능 잠금이 아니라 확장/개인화 영역에서 명확히 보여야 합니다.

변경사항:
- plan 모델에 Pro 기능 카드를 Scene Packs/Sound Packs/Profiles로 재정의했습니다.
- Quick Controls의 기본 Scene/Time/Sound 선택에서 잠금 로직을 제거해 코어 기능을 Free로 유지했습니다.
- Pro 기능 카드를 Control Center 하단 확장 영역으로 이동하고, 잠금 클릭 시 Paywall 트리거 경로를 연결했습니다.

검증:
- npx tsc --noEmit

세션-상태: 기본 조작은 Free, Pro는 확장 카드 기반 잠금 구조로 전환됨
세션-다음: Control Center 하단 영역을 더 조용한 요약 카드 톤으로 다듬고 추천 조합 라인을 비인터랙티브로 정리
세션-리스크: Plan Pill normal 클릭 동선은 paywall 트리거 정책과 추가 정합 조정이 남아 있음
2026-03-05 17:15:51 +09:00
922342b115 refactor(plan): 기본 타이머 프리셋 Free 개방 및 LOCK 제거
맥락:
- 코어 집중 루프에서 기본 타이머와 기본 사운드 잠금은 사용자 신뢰와 진입 전환을 떨어뜨립니다.

변경사항:
- Pro 잠금 타이머 목록에서 90/20 잠금을 제거했습니다.
- 기본 사운드 잠금 목록을 비워 Quick/기본 선택이 Free에서 동작하도록 정리했습니다.

검증:
- npx tsc --noEmit

세션-상태: 기본 타이머/기본 사운드가 Free 정책으로 개방됨
세션-다음: Pro 잠금 대상을 Packs/Profiles 중심으로 재배치
세션-리스크: 화면 컴포넌트에 남은 잠금 표현 로직 정리는 다음 커밋에서 반영 예정
2026-03-05 17:08:33 +09:00
a056fc841e refactor(feedback): Focus 토스트를 상단 중앙 단일 채널로 통합
맥락:
- Focus 화면에서 토스트 위치가 Notes 저장/Goal 완료 상황마다 달라져 가독성과 일관성이 떨어졌습니다.

변경사항:
- HUD 내부 status line 렌더를 제거하고 상단 중앙 고정 토스트 컴포넌트를 추가했습니다.
- /space 루트에서 activeStatus를 상단 중앙 토스트로만 표시하도록 피드백 채널을 단일화했습니다.
- Undo 액션은 상단 중앙 토스트 내부 링크로 동일하게 노출되도록 유지했습니다.

검증:
- npx tsc --noEmit

세션-상태: Focus 피드백 채널이 상단 중앙 1곳으로 통일됨
세션-다음: work.md 작업 1부터 Pro/플랜 잠금 정책 재구성 진행
세션-리스크: 기존 lint 규칙의 set-state-in-effect 오류는 별도 축으로 남아 있음
2026-03-05 17:03:00 +09:00
f3f0518588 fix(space): Quick Controls 사운드 복원과 HUD 피드백 정합성 수정 2026-03-05 16:24:53 +09:00
b1bafd5e9a docs(session): Focus-First 전환 및 자동 숨김 정책 기준으로 상태 갱신
맥락:
- workflow 종료 규칙에 따라 최근 구현(토글 제거, 표시 정책 옵션)의 상태를 세션 복구 문서에 반영해야 했습니다.

변경사항:
- docs/90_current_state.md DONE/NEXT/CHANGED FILES를 Focus-First 전환과 컨트롤 자동 숨김 정책 기준으로 업데이트했습니다.
- docs/session_brief.md 최근 세션 상태를 최신 구조(토글 제거, 정책 옵션 추가)로 갱신했습니다.

검증:
- 문서 갱신 작업(코드 검증 없음)

세션-상태: 세션 복구 문서가 최신 Focus-First 구조를 반영합니다.
세션-다음: 추천 매핑 품질과 override UX 체감 검증을 진행합니다.
세션-리스크: docs/work.md는 사용자 편집 상태로 워크트리에 남아 있습니다.
2026-03-05 15:26:45 +09:00
245746a996 feat(control-policy): 컨트롤 자동 숨김 표시 정책 옵션 추가
맥락:
- Focus-First 구조에서 패널을 열어둔 채 방치되면 몰입 흐름이 깨질 수 있어, 모드 토글이 아닌 표시 정책 기반 정리가 필요했습니다.

변경사항:
- Quick Controls 패널 하단에 컨트롤 자동 숨김 옵션을 추가했습니다.
- 옵션은 Focus 화면 상시 UI가 아닌 패널 내부에서만 노출되도록 제한했습니다.
- 옵션 ON 상태에서 Control Center가 열려 있고 입력이 없으면 8초 후 패널이 자동으로 닫히도록 UI 상태 로직을 추가했습니다.
- 모드 전환 토스트는 추가하지 않고 상태 변화만 반영했습니다.

검증:
- npx tsc --noEmit

세션-상태: 컨트롤 자동 숨김 정책으로 설정 후 자연스럽게 몰입 화면으로 복귀됩니다.
세션-다음: Scene 추천 매핑 품질과 override UX 체감 검증을 진행합니다.
세션-리스크: 자동 닫힘 8초 타이밍은 실사용 피드백에 따라 추가 조정이 필요할 수 있습니다.
2026-03-05 15:19:02 +09:00
14 changed files with 441 additions and 240 deletions

View File

@@ -4,6 +4,30 @@ Last Updated: 2026-03-05
## DONE ## DONE
- Focus 피드백 채널 단일화:
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
- Free 코어 루프 개방:
- Quick Controls Time의 `90/20` 잠금을 제거
- 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능
- Pro 가치 재배치:
- Pro 잠금 대상을 `Scene Packs / Sound Packs / Profiles`로 재정의
- 기본 Scene/Time/Sound는 잠금 없이 선택 중심으로 정리
- Control Center UI 재구성:
- Scene/Time/Sound 중심 구조 유지
- 추천 조합을 정보 1줄로 축소(비인터랙션)
- 하단에 Packs/Profiles 요약 카드(작은 🔒 배지) 추가
- Paywall 의도 기반 트리거 적용:
- 잠금 카드 클릭 시에만 Paywall Sheet 오픈
- Plan Pill(NORMAL) 클릭은 즉시 결제창 대신 상태 안내만 표시
- Paywall Sheet를 3개 가치 포인트 + 2개 CTA로 간결화
- Focus-First 전환:
- Quick Controls의 `기본/몰입` 토글 제거
- HUD를 외부 모드 상태 없이 기본 몰입 톤으로 고정
- 컨트롤 노출은 패널 열림 상태에서만 보이도록 단순화
- 표시 정책 옵션 추가:
- Quick Controls 패널 하단에 `컨트롤 자동 숨김` 옵션 추가
- 옵션 ON 상태에서 Control Center 8초 무입력 시 자동 닫힘 처리
- Quick Controls 모드 전환 UI 재정렬: - Quick Controls 모드 전환 UI 재정렬:
- 헤더에서 모드 토글 UI를 제거하고 `Plan + 닫기`만 유지 - 헤더에서 모드 토글 UI를 제거하고 `Plan + 닫기`만 유지
- 패널 바디 첫 섹션에 `기본/몰입` segmented pill 배치 - 패널 바디 첫 섹션에 `기본/몰입` segmented pill 배치
@@ -24,8 +48,12 @@ Last Updated: 2026-03-05
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리: - 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정 - `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
- 세션 상태 더미 저장/복원 추가: - 세션 상태 더미 저장/복원 추가:
- `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)`를 localStorage에 저장 - `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
- `/space` 진입 Resume CTA 추가:
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
- `이어서 시작`: 저장 목표로 즉시 Focus 진입
- `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환
- 세션 복구 운영 문서 추가: - 세션 복구 운영 문서 추가:
- `docs/06_commit_convention.md` - `docs/06_commit_convention.md`
- `docs/07_session_recovery.md` - `docs/07_session_recovery.md`
@@ -125,10 +153,9 @@ Last Updated: 2026-03-05
## NEXT ## NEXT
1. Scene 추천 매핑(`recommendedSoundPresetId`, `recommendedTimerPresetId`)의 큐레이션 품질 점검 및 보정 1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
2. Setup Drawer에서 수동 선택한 타이머/사운드도 override 정책과 사용자 기대치가 일치하는지 UX 검증 2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리
3. Focus 전환/Scene 변경/추천 복원 시 HUD 피드백 노출 정책(무표시 vs 최소 표시) 최종 확정 3. Stage 가독성/모션/레이어 폴리시 최종 통일
4. 터치 환경에서 우측 도구 레일 발견성(미니 핸들 UX) 보완 여부 확정
## RISKS ## RISKS
@@ -150,18 +177,27 @@ Last Updated: 2026-03-05
- 전체 배경 블러 강도 증가로 저사양 환경에서 GPU 부담이 늘 수 있어 실기기 체감 점검 필요 - 전체 배경 블러 강도 증가로 저사양 환경에서 GPU 부담이 늘 수 있어 실기기 체감 점검 필요
- 밝은 배경 사진과 라이트 헤더 조합에서 상단 경계 인지가 약해질 수 있어 대비 점검 필요 - 밝은 배경 사진과 라이트 헤더 조합에서 상단 경계 인지가 약해질 수 있어 대비 점검 필요
- 등급 칩 최소폭 증가로 초소형 화면에서 헤더 가로 여유가 줄어들 수 있어 간격 점검 필요 - 등급 칩 최소폭 증가로 초소형 화면에서 헤더 가로 여유가 줄어들 수 있어 간격 점검 필요
- Plan Pill에서 바로 결제창이 열리지 않도록 바뀌어, 일부 사용자는 업그레이드 진입 경로를 늦게 인지할 수 있음
- Packs 카드가 더미 상태이므로 Pro 가치 설명 카피가 약하면 클릭 동기가 낮아질 수 있음
## CHANGED FILES ## CHANGED FILES
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx` - (최근 workflow 반영)
- `src/widgets/space-workspace/ui/FocusTopToast.tsx`
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
- `src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx`
- `src/entities/plan/model/mockPlan.ts`
- `src/entities/plan/model/types.ts`
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
- `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx`
- `src/features/paywall-sheet/ui/PaywallSheetContent.tsx`
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
- `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx` - `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx`
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` - `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx` - `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
- `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-workspace/ui/SpaceWorkspaceWidget.tsx`
- `src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx`
- `src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx`
- `src/widgets/space-tools-dock/model/applyQuickPack.ts` (삭제) - `src/widgets/space-tools-dock/model/applyQuickPack.ts` (삭제)
- `docs/06_commit_convention.md` - `docs/06_commit_convention.md`
- `docs/07_session_recovery.md` - `docs/07_session_recovery.md`

View File

@@ -14,16 +14,31 @@ Last Updated: 2026-03-05
## 현재 우선순위 ## 현재 우선순위
1. Scene 추천 매핑 품질 점검(공간별 사운드/타이머 추천값 보정) 1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
2. override 정책(수동 선택 후 Scene 변경 시 유지)의 사용자 기대치 검증 2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리
3. 터치 환경 도구 레일 발견성(미니 핸들 UX) 보완 여부 결정 3. Stage 가독성/모션/레이어 폴리시 최종 정리
## 최근 세션 상태 ## 최근 세션 상태
- Quick Controls 모드 전환 UI를 헤더에서 제거하고 패널 바디 첫 섹션으로 이동했다. - Focus 피드백 채널을 상단 중앙 1곳으로 통합했다.
- 헤더는 Plan + 닫기만 유지 - HUD 내부 status line 제거
- 바디에는 `기본/몰입` segmented pill + 설명 1줄을 배치 - Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시
- 모드 상태는 Focus HUD 톤과 연동되도록 workspace 경로에 연결했다. - 기본 기능 잠금을 해소했다.
- Time `90/20`을 Free로 개방
- 기본 Sound 잠금 제거
- Pro 잠금 구조를 Packs/Profiles 중심으로 재구성했다.
- `Scene Packs / Sound Packs / Profiles` 요약 카드 추가
- 기본 Scene/Time/Sound는 잠금 없이 선택 가능
- Paywall 시트는 잠금 카드 클릭에서만 열리도록 바꿨다.
- Plan Pill(NORMAL) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출
- Paywall 카피를 3개 가치 포인트 + 2개 CTA로 간결화
- Focus-First 구조로 전환했다.
- Quick Controls의 모드 전환 토글(기본/몰입)을 제거했다.
- HUD는 외부 모드 상태 없이 기본 몰입 톤으로 유지한다.
- 컨트롤 노출은 패널 열림 상태에서만 보이도록 단순화했다.
- Quick Controls 패널 내부에 표시 정책 옵션을 추가했다.
- 옵션: `컨트롤 자동 숨김`
- ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리
- `/space`에 Scene 추천 자동 적용 규칙을 도입했다. - `/space`에 Scene 추천 자동 적용 규칙을 도입했다.
- Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다. - Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다.
- 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다. - 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다.
@@ -35,8 +50,11 @@ Last Updated: 2026-03-05
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지 - 추천 정보 1줄 + `추천으로 되돌리기`만 유지
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다. - 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
- `/space` 선택 상태 로컬 저장/복원을 추가했다. - `/space` 선택 상태 로컬 저장/복원을 추가했다.
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `override(sound/timer)` - 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천 - 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
- `/space` 진입 시 Resume CTA를 추가했다.
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출
- `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다. - 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
- `workFlow.md`는 토큰 절약 모드를 사용한다. - `workFlow.md`는 토큰 절약 모드를 사용한다.
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다. - `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.

View File

@@ -1,10 +1,15 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import { defineConfig, globalIgnores } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals"; import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
let storybookConfig = [];
try {
// Optional dependency: lint should still run when Storybook plugin is not installed.
const storybook = await import("eslint-plugin-storybook");
storybookConfig = storybook.default?.configs?.["flat/recommended"] ?? [];
} catch {}
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
@@ -16,7 +21,7 @@ const eslintConfig = defineConfig([
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
]), ]),
...storybook.configs["flat/recommended"] ...storybookConfig
]); ]);
export default eslintConfig; export default eslintConfig;

View File

@@ -1,23 +1,23 @@
import type { PlanLockedPack } from './types'; import type { ProFeatureCard } from './types';
export const PRO_LOCKED_ROOM_IDS = ['outer-space', 'snow-mountain']; export const PRO_LOCKED_ROOM_IDS: string[] = [];
export const PRO_LOCKED_TIMER_LABELS = ['90/20']; export const PRO_LOCKED_TIMER_LABELS: string[] = [];
export const PRO_LOCKED_SOUND_IDS = ['cafe-work', 'fireplace']; export const PRO_LOCKED_SOUND_IDS: string[] = [];
export const PRO_PRESET_PACKS: PlanLockedPack[] = [ export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
{ {
id: 'deep-work', id: 'scene-packs',
name: 'Deep Work', name: 'Scene Packs',
description: '긴 몰입 세션을 위한 무드 묶음', description: '프리미엄 공간 묶음과 장면 변주',
}, },
{ {
id: 'gentle', id: 'sound-packs',
name: 'Gentle', name: 'Sound Packs',
description: '저자극 휴식 중심 프리셋', description: '확장 사운드 프리셋 묶음',
}, },
{ {
id: 'cafe', id: 'profiles',
name: 'Cafe', name: 'Profiles',
description: '카페톤 배경과 사운드 조합', description: '내 기본 세팅 저장/불러오기',
}, },
]; ];

View File

@@ -5,3 +5,11 @@ export interface PlanLockedPack {
name: string; name: string;
description: string; description: string;
} }
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
export interface ProFeatureCard {
id: ProFeatureId;
name: string;
description: string;
}

View File

@@ -1,70 +1,32 @@
'use client'; 'use client';
import { useState } from 'react';
import { cn } from '@/shared/lib/cn';
interface PaywallSheetContentProps { interface PaywallSheetContentProps {
onStartPro: () => void; onStartPro: () => void;
onClose: () => void; onClose: () => void;
} }
type BillingCycle = 'monthly' | 'yearly';
const BILLING_OPTIONS: Array<{ id: BillingCycle; label: string; caption: string }> = [
{ id: 'monthly', label: '월간', caption: '월 9,900원 (더미)' },
{ id: 'yearly', label: '연간', caption: '연 79,000원 (더미)' },
];
const VALUE_POINTS = [ const VALUE_POINTS = [
'더 많은 공간 / 고화질 배경', '프리미엄 Scene Packs',
'작업용 BGM / 사운드 확장', '확장 Sound Packs',
'프리셋 팩 / 고급 타이머', '프로필 저장 / 불러오기',
]; ];
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => { export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
const [cycle, setCycle] = useState<BillingCycle>('monthly');
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">PRO에서 .</h3>
<p className="text-xs text-white/62"> , .</p> <p className="text-xs text-white/62"> .</p>
</header> </header>
<ul className="space-y-2"> <ul className="space-y-2">
{VALUE_POINTS.map((point) => ( {VALUE_POINTS.map((point) => (
<li key={point} className="rounded-xl border border-white/14 bg-white/[0.04] px-3 py-2 text-sm text-white/86"> <li key={point} className="rounded-xl border border-white/14 bg-white/[0.04] px-3 py-2 text-sm text-white/82">
{point} {point}
</li> </li>
))} ))}
</ul> </ul>
<section className="rounded-2xl border border-white/14 bg-white/[0.04] p-3">
<p className="text-xs text-white/60"></p>
<div className="mt-2 grid grid-cols-2 gap-2">
{BILLING_OPTIONS.map((option) => {
const selected = option.id === cycle;
return (
<button
key={option.id}
type="button"
onClick={() => setCycle(option.id)}
className={cn(
'rounded-xl border px-2.5 py-2 text-left text-xs transition-colors',
selected
? 'border-sky-200/40 bg-sky-200/14 text-white'
: 'border-white/16 bg-white/[0.03] text-white/72 hover:bg-white/[0.08]',
)}
>
<p className="font-medium">{option.label}</p>
<p className="mt-0.5 text-[10px] text-white/56">{option.caption}</p>
</button>
);
})}
</div>
</section>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
type="button" type="button"

View File

@@ -1,32 +1,17 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useToast } from '@/shared/ui';
import {
RECOVERY_30S_BUTTON_LABEL,
RECOVERY_30S_TOAST_MESSAGE,
} from './copy';
const MODE_DURATION_MS = 2000; const MODE_DURATION_MS = 2000;
const HINT_DURATION_MS = 1800;
export const useRestart30s = () => { export const useRestart30s = () => {
const { pushToast } = useToast();
const [isBreatheMode, setBreatheMode] = useState(false); const [isBreatheMode, setBreatheMode] = useState(false);
const [hintMessage, setHintMessage] = useState<string | null>(null);
const resetTimerRef = useRef<number | null>(null); const resetTimerRef = useRef<number | null>(null);
const hintTimerRef = useRef<number | null>(null);
const clearTimers = () => { const clearTimers = () => {
if (resetTimerRef.current !== null) { if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current); window.clearTimeout(resetTimerRef.current);
resetTimerRef.current = null; resetTimerRef.current = null;
} }
if (hintTimerRef.current !== null) {
window.clearTimeout(hintTimerRef.current);
hintTimerRef.current = null;
}
}; };
useEffect(() => { useEffect(() => {
@@ -38,16 +23,6 @@ export const useRestart30s = () => {
const triggerRestart = () => { const triggerRestart = () => {
clearTimers(); clearTimers();
setBreatheMode(true); setBreatheMode(true);
setHintMessage(RECOVERY_30S_TOAST_MESSAGE);
pushToast({
title: RECOVERY_30S_BUTTON_LABEL,
description: RECOVERY_30S_TOAST_MESSAGE,
});
hintTimerRef.current = window.setTimeout(() => {
setHintMessage(null);
}, HINT_DURATION_MS);
resetTimerRef.current = window.setTimeout(() => { resetTimerRef.current = window.setTimeout(() => {
setBreatheMode(false); setBreatheMode(false);
@@ -56,7 +31,6 @@ export const useRestart30s = () => {
return { return {
isBreatheMode, isBreatheMode,
hintMessage,
triggerRestart, triggerRestart,
}; };
}; };

View File

@@ -3,36 +3,32 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { PlanTier } from '@/entities/plan'; import type { PlanTier } from '@/entities/plan';
import { import {
PRO_LOCKED_ROOM_IDS, PRO_FEATURE_CARDS,
PRO_LOCKED_TIMER_LABELS,
} from '@/entities/plan'; } from '@/entities/plan';
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room'; import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
import type { TimerPreset } from '@/entities/session'; import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { useReducedMotion } from '@/shared/lib/useReducedMotion'; import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { Toggle } from '@/shared/ui';
interface ControlCenterSheetWidgetProps { interface ControlCenterSheetWidgetProps {
plan: PlanTier; plan: PlanTier;
rooms: RoomTheme[]; rooms: RoomTheme[];
selectedRoomId: string; selectedRoomId: string;
selectedTimerLabel: string; selectedTimerLabel: string;
selectedSoundPresetId: string;
sceneRecommendedSoundLabel: string; sceneRecommendedSoundLabel: string;
sceneRecommendedTimerLabel: string; sceneRecommendedTimerLabel: string;
timerPresets: TimerPreset[]; timerPresets: TimerPreset[];
autoHideControls: boolean;
onAutoHideControlsChange: (next: boolean) => void;
onSelectRoom: (roomId: string) => void; onSelectRoom: (roomId: string) => void;
onSelectTimer: (timerLabel: string) => void; onSelectTimer: (timerLabel: string) => void;
onSelectSound: (presetId: string) => void;
onSelectProFeature: (featureId: string) => void;
onLockedClick: (source: string) => void; onLockedClick: (source: string) => void;
onResetToRecommended: () => void;
} }
const LockBadge = () => {
return (
<span className="absolute right-2 top-2 rounded-full border border-white/20 bg-black/46 px-1.5 py-0.5 text-[9px] font-semibold tracking-[0.08em] text-white/86">
LOCK PRO
</span>
);
};
const SectionTitle = ({ title, description }: { title: string; description: string }) => { const SectionTitle = ({ title, description }: { title: string; description: string }) => {
return ( return (
<header className="flex items-end justify-between gap-2"> <header className="flex items-end justify-between gap-2">
@@ -47,13 +43,17 @@ export const ControlCenterSheetWidget = ({
rooms, rooms,
selectedRoomId, selectedRoomId,
selectedTimerLabel, selectedTimerLabel,
selectedSoundPresetId,
sceneRecommendedSoundLabel, sceneRecommendedSoundLabel,
sceneRecommendedTimerLabel, sceneRecommendedTimerLabel,
timerPresets, timerPresets,
autoHideControls,
onAutoHideControlsChange,
onSelectRoom, onSelectRoom,
onSelectTimer, onSelectTimer,
onSelectSound,
onSelectProFeature,
onLockedClick, onLockedClick,
onResetToRecommended,
}: ControlCenterSheetWidgetProps) => { }: ControlCenterSheetWidgetProps) => {
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const isPro = plan === 'pro'; const isPro = plan === 'pro';
@@ -81,18 +81,12 @@ export const ControlCenterSheetWidget = ({
> >
{rooms.slice(0, 6).map((room) => { {rooms.slice(0, 6).map((room) => {
const selected = room.id === selectedRoomId; const selected = room.id === selectedRoomId;
const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
return ( return (
<button <button
key={room.id} key={room.id}
type="button" type="button"
onClick={() => { onClick={() => {
if (locked) {
onLockedClick(`공간: ${room.name}`);
return;
}
onSelectRoom(room.id); onSelectRoom(room.id);
}} }}
className={cn( className={cn(
@@ -101,13 +95,12 @@ export const ControlCenterSheetWidget = ({
reducedMotion ? '' : 'hover:-translate-y-0.5', reducedMotion ? '' : 'hover:-translate-y-0.5',
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16', selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
)} )}
> >
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} /> <div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" /> <div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
{locked ? <LockBadge /> : null} <div className="absolute inset-x-2 bottom-2 min-w-0">
<div className="absolute inset-x-2 bottom-2 min-w-0"> <p className="truncate text-sm font-medium text-white/90">{room.name}</p>
<p className="truncate text-sm font-medium text-white/90">{room.name}</p> <p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
</div> </div>
</button> </button>
); );
@@ -120,18 +113,12 @@ export const ControlCenterSheetWidget = ({
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{timerPresets.slice(0, 3).map((preset) => { {timerPresets.slice(0, 3).map((preset) => {
const selected = preset.label === selectedTimerLabel; const selected = preset.label === selectedTimerLabel;
const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
return ( return (
<button <button
key={preset.id} key={preset.id}
type="button" type="button"
onClick={() => { onClick={() => {
if (locked) {
onLockedClick(`타이머: ${preset.label}`);
return;
}
onSelectTimer(preset.label); onSelectTimer(preset.label);
}} }}
className={cn( className={cn(
@@ -143,7 +130,37 @@ export const ControlCenterSheetWidget = ({
)} )}
> >
{preset.label} {preset.label}
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null} </button>
);
})}
</div>
</section>
<section className="space-y-2.5 rounded-2xl border border-white/12 bg-black/22 p-3.5 backdrop-blur-md">
<SectionTitle
title="Sound"
description={SOUND_PRESETS.find((preset) => preset.id === selectedSoundPresetId)?.label ?? '기본'}
/>
<div className="grid grid-cols-3 gap-2">
{SOUND_PRESETS.slice(0, 6).map((preset) => {
const selected = preset.id === selectedSoundPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => {
onSelectSound(preset.id);
}}
className={cn(
'relative rounded-xl border px-3 py-2 text-[11px]',
colorMotionClass,
selected
? 'border-sky-200/42 bg-sky-200/16 text-white'
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
)}
>
{preset.label}
</button> </button>
); );
})} })}
@@ -152,17 +169,54 @@ export const ControlCenterSheetWidget = ({
<div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5"> <div className="space-y-1.5 rounded-xl border border-white/12 bg-white/[0.03] px-3 py-2.5">
<p className="text-[11px] text-white/58">: {sceneRecommendedSoundLabel} · {sceneRecommendedTimerLabel}</p> <p className="text-[11px] text-white/58">: {sceneRecommendedSoundLabel} · {sceneRecommendedTimerLabel}</p>
<button <p className="text-[10px] text-white/48"> .</p>
type="button"
onClick={onResetToRecommended}
className={cn(
'text-left text-[11px] text-white/72 transition-colors hover:text-white/90',
colorMotionClass,
)}
>
</button>
</div> </div>
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 p-3 backdrop-blur-md">
<SectionTitle title="Packs" description="확장/개인화" />
<div className="space-y-1.5">
{PRO_FEATURE_CARDS.map((feature) => {
const locked = !isPro;
return (
<button
key={feature.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(feature.name);
return;
}
onSelectProFeature(feature.id);
}}
className="flex w-full items-center justify-between rounded-xl border border-white/14 bg-white/[0.03] px-3 py-2 text-left transition-colors hover:bg-white/[0.08]"
>
<div>
<p className="text-xs font-medium text-white/88">{feature.name}</p>
<p className="mt-0.5 text-[10px] text-white/56">{feature.description}</p>
</div>
{locked ? <span className="text-xs text-white/70">🔒</span> : null}
</button>
);
})}
</div>
</section>
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 px-3 py-2.5 backdrop-blur-md">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] text-white/72"> </p>
<p className="mt-0.5 text-[10px] text-white/52"> .</p>
</div>
<Toggle
checked={autoHideControls}
onChange={onAutoHideControlsChange}
ariaLabel="컨트롤 자동 숨김"
className="shrink-0"
/>
</div>
</section>
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { HudStatusLineItem, HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { useReducedMotion } from '@/shared/lib/useReducedMotion'; import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud'; import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { GoalCompleteSheet } from './GoalCompleteSheet'; import { GoalCompleteSheet } from './GoalCompleteSheet';
@@ -10,8 +10,6 @@ interface SpaceFocusHudWidgetProps {
timerLabel: string; timerLabel: string;
visible: boolean; visible: boolean;
onGoalUpdate: (nextGoal: string) => void; onGoalUpdate: (nextGoal: string) => void;
statusLine: HudStatusLineItem | null;
onStatusAction: () => void;
onStatusMessage: (payload: HudStatusLinePayload) => void; onStatusMessage: (payload: HudStatusLinePayload) => void;
} }
@@ -20,8 +18,6 @@ export const SpaceFocusHudWidget = ({
timerLabel, timerLabel,
visible, visible,
onGoalUpdate, onGoalUpdate,
statusLine,
onStatusAction,
onStatusMessage, onStatusMessage,
}: SpaceFocusHudWidgetProps) => { }: SpaceFocusHudWidgetProps) => {
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
@@ -100,18 +96,9 @@ export const SpaceFocusHudWidget = ({
<SpaceTimerHudWidget <SpaceTimerHudWidget
timerLabel={timerLabel} timerLabel={timerLabel}
goal={goal} goal={goal}
statusLine={
statusLine
? {
message: statusLine.message,
actionLabel: statusLine.action?.label,
}
: null
}
isImmersionMode isImmersionMode
className="pr-[4.2rem]" className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet} onGoalCompleteRequest={handleOpenCompleteSheet}
onStatusAction={onStatusAction}
onPlaybackStateChange={(state) => { onPlaybackStateChange={(state) => {
if (reducedMotion) { if (reducedMotion) {
playbackStateRef.current = state; playbackStateRef.current = state;

View File

@@ -28,6 +28,11 @@ interface SpaceSetupDrawerWidgetProps {
onGoalChange: (value: string) => void; onGoalChange: (value: string) => void;
onGoalChipSelect: (chip: GoalChip) => void; onGoalChipSelect: (chip: GoalChip) => void;
onStart: () => void; onStart: () => void;
resumeHint?: {
goal: string;
onResume: () => void;
onStartFresh: () => void;
};
} }
interface SummaryChipProps { interface SummaryChipProps {
@@ -74,6 +79,7 @@ export const SpaceSetupDrawerWidget = ({
onGoalChange, onGoalChange,
onGoalChipSelect, onGoalChipSelect,
onStart, onStart,
resumeHint,
}: SpaceSetupDrawerWidgetProps) => { }: SpaceSetupDrawerWidgetProps) => {
const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null); const [openPopover, setOpenPopover] = useState<RitualPopover | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null); const panelRef = useRef<HTMLDivElement | null>(null);
@@ -151,6 +157,29 @@ export const SpaceSetupDrawerWidget = ({
<p className="text-xs text-white/60"> Focus .</p> <p className="text-xs text-white/60"> Focus .</p>
</header> </header>
{resumeHint ? (
<div className="mb-3 rounded-2xl border border-white/14 bg-black/22 px-3 py-2.5">
<p className="text-[11px] text-white/62"> </p>
<p className="mt-1 truncate text-sm text-white/88">{resumeHint.goal}</p>
<div className="mt-2 flex items-center justify-end gap-1.5">
<button
type="button"
onClick={resumeHint.onStartFresh}
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/72 transition-colors hover:bg-white/[0.1]"
>
</button>
<button
type="button"
onClick={resumeHint.onResume}
className="rounded-full border border-sky-200/34 bg-sky-200/14 px-2.5 py-1 text-[11px] text-white/90 transition-colors hover:bg-sky-200/22"
>
</button>
</div>
</div>
) : null}
<div className="relative mb-3"> <div className="relative mb-3">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<SummaryChip <SummaryChip

View File

@@ -12,13 +12,8 @@ interface SpaceTimerHudWidgetProps {
goal: string; goal: string;
className?: string; className?: string;
isImmersionMode?: boolean; isImmersionMode?: boolean;
statusLine?: {
message: string;
actionLabel?: string;
} | null;
onPlaybackStateChange?: (state: 'running' | 'paused') => void; onPlaybackStateChange?: (state: 'running' | 'paused') => void;
onGoalCompleteRequest?: () => void; onGoalCompleteRequest?: () => void;
onStatusAction?: () => void;
} }
const HUD_ACTIONS = [ const HUD_ACTIONS = [
@@ -32,10 +27,8 @@ export const SpaceTimerHudWidget = ({
goal, goal,
className, className,
isImmersionMode = false, isImmersionMode = false,
statusLine = null,
onPlaybackStateChange, onPlaybackStateChange,
onGoalCompleteRequest, onGoalCompleteRequest,
onStatusAction,
}: SpaceTimerHudWidgetProps) => { }: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s(); const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.'; const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
@@ -132,30 +125,6 @@ export const SpaceTimerHudWidget = ({
/> />
</div> </div>
</section> </section>
<div
className="pointer-events-none absolute bottom-2 left-3.5 z-[12] max-w-[72%]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div
className={cn(
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/12 bg-black/24 px-2.5 py-1 text-[10px] text-white/72 backdrop-blur-sm transition-all duration-[220ms] ease-out motion-reduce:duration-0',
statusLine ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
)}
>
<span className="truncate">{statusLine?.message ?? ''}</span>
{statusLine?.actionLabel ? (
<button
type="button"
onClick={onStatusAction}
className="pointer-events-auto shrink-0 text-[10px] font-medium text-white/84 underline-offset-2 transition-colors hover:text-white hover:underline"
>
{statusLine.actionLabel}
</button>
) : null}
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -7,7 +7,6 @@ import { ExitHoldButton } from '@/features/exit-hold';
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet'; import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill'; import { PlanPill } from '@/features/plan-pill';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { useToast } from '@/shared/ui';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet'; import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
import { SpaceSideSheet } from '@/widgets/space-sheet-shell'; import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
@@ -40,8 +39,8 @@ interface SpaceToolsDockWidgetProps {
onDeleteThought: (thoughtId: string) => RecentThought | null; onDeleteThought: (thoughtId: string) => RecentThought | null;
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null; onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
onRestoreThought: (thought: RecentThought) => void; onRestoreThought: (thought: RecentThought) => void;
onRestoreThoughts: (thoughts: RecentThought[]) => void;
onClearInbox: () => RecentThought[]; onClearInbox: () => RecentThought[];
onResetToSceneRecommended: () => void;
onStatusMessage: (payload: HudStatusLinePayload) => void; onStatusMessage: (payload: HudStatusLinePayload) => void;
onExitRequested: () => void; onExitRequested: () => void;
} }
@@ -68,14 +67,14 @@ export const SpaceToolsDockWidget = ({
onDeleteThought, onDeleteThought,
onSetThoughtCompleted, onSetThoughtCompleted,
onRestoreThought, onRestoreThought,
onRestoreThoughts,
onClearInbox, onClearInbox,
onResetToSceneRecommended,
onStatusMessage, onStatusMessage,
onExitRequested, onExitRequested,
}: SpaceToolsDockWidgetProps) => { }: SpaceToolsDockWidgetProps) => {
const { pushToast } = useToast();
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null); const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null); const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [autoHideControls, setAutoHideControls] = useState(true);
const [noteDraft, setNoteDraft] = useState(''); const [noteDraft, setNoteDraft] = useState('');
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null); const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
const [plan, setPlan] = useState<PlanTier>('normal'); const [plan, setPlan] = useState<PlanTier>('normal');
@@ -120,8 +119,23 @@ export const SpaceToolsDockWidget = ({
}, [openPopover]); }, [openPopover]);
useEffect(() => { useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) { if (isFocusMode) {
return;
}
const rafId = window.requestAnimationFrame(() => {
setOpenPopover(null);
setUtilityPanel(null);
setIdle(false); setIdle(false);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [isFocusMode]);
useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) {
return; return;
} }
@@ -158,7 +172,45 @@ export const SpaceToolsDockWidget = ({
}; };
}, [isFocusMode, openPopover, utilityPanel]); }, [isFocusMode, openPopover, utilityPanel]);
useEffect(() => {
if (utilityPanel !== 'control-center' || !autoHideControls) {
return;
}
let timerId: number | null = null;
const closeDelayMs = 8000;
const armCloseTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(() => {
setUtilityPanel((current) => (current === 'control-center' ? null : current));
}, closeDelayMs);
};
const resetTimer = () => {
armCloseTimer();
};
armCloseTimer();
window.addEventListener('pointermove', resetTimer);
window.addEventListener('pointerdown', resetTimer);
window.addEventListener('keydown', resetTimer);
return () => {
if (timerId) {
window.clearTimeout(timerId);
}
window.removeEventListener('pointermove', resetTimer);
window.removeEventListener('pointerdown', resetTimer);
window.removeEventListener('keydown', resetTimer);
};
}, [autoHideControls, utilityPanel]);
const openUtilityPanel = (panel: SpaceUtilityPanelId) => { const openUtilityPanel = (panel: SpaceUtilityPanelId) => {
setIdle(false);
setOpenPopover(null); setOpenPopover(null);
setUtilityPanel(panel); setUtilityPanel(panel);
}; };
@@ -222,7 +274,25 @@ export const SpaceToolsDockWidget = ({
}; };
const handleInboxClear = () => { const handleInboxClear = () => {
onClearInbox(); const snapshot = onClearInbox();
if (snapshot.length === 0) {
onStatusMessage({ message: '비울 항목이 없어요.' });
return;
}
onStatusMessage({
message: '모두 비워짐',
durationMs: 4200,
priority: 'undo',
action: {
label: '실행취소',
onClick: () => {
onRestoreThoughts(snapshot);
onStatusMessage({ message: '복원했어요.' });
},
},
});
}; };
const handlePlanPillClick = () => { const handlePlanPillClick = () => {
@@ -231,17 +301,28 @@ export const SpaceToolsDockWidget = ({
return; return;
} }
openUtilityPanel('paywall'); onStatusMessage({ message: 'NORMAL 플랜 사용 중 · 잠금 항목에서만 업그레이드할 수 있어요.' });
}; };
const handleLockedClick = (source: string) => { const handleLockedClick = (source: string) => {
pushToast({ title: `${source}은(는) PRO 기능이에요.` }); onStatusMessage({ message: `${source}은(는) PRO 기능이에요.` });
openUtilityPanel('paywall'); openUtilityPanel('paywall');
}; };
const handleSelectProFeature = (featureId: string) => {
const label =
featureId === 'scene-packs'
? 'Scene Packs'
: featureId === 'sound-packs'
? 'Sound Packs'
: 'Profiles';
onStatusMessage({ message: `${label} 준비 중(더미)` });
};
const handleStartPro = () => { const handleStartPro = () => {
setPlan('pro'); setPlan('pro');
pushToast({ title: '결제(더미)' }); onStatusMessage({ message: '결제(더미)' });
openUtilityPanel('control-center'); openUtilityPanel('control-center');
}; };
@@ -283,7 +364,7 @@ export const SpaceToolsDockWidget = ({
return ( return (
<> <>
{openPopover ? ( {isFocusMode && openPopover ? (
<button <button
type="button" type="button"
aria-label="팝오버 닫기" aria-label="팝오버 닫기"
@@ -326,7 +407,10 @@ export const SpaceToolsDockWidget = ({
/> />
<button <button
type="button" type="button"
onClick={() => setOpenPopover((current) => (current === 'notes' ? null : 'notes'))} onClick={() => {
setIdle(false);
setOpenPopover((current) => (current === 'notes' ? null : 'notes'));
}}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100" className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
> >
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span> <span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
@@ -358,7 +442,10 @@ export const SpaceToolsDockWidget = ({
/> />
<button <button
type="button" type="button"
onClick={() => setOpenPopover((current) => (current === 'sound' ? null : 'sound'))} onClick={() => {
setIdle(false);
setOpenPopover((current) => (current === 'sound' ? null : 'sound'));
}}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100" className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
> >
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span> <span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
@@ -393,7 +480,7 @@ export const SpaceToolsDockWidget = ({
) : null} ) : null}
<SpaceSideSheet <SpaceSideSheet
open={utilityPanel !== null} open={isFocusMode && utilityPanel !== null}
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''} title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined} subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined}
headerAction={ headerAction={
@@ -411,17 +498,21 @@ export const SpaceToolsDockWidget = ({
rooms={rooms} rooms={rooms}
selectedRoomId={selectedRoomId} selectedRoomId={selectedRoomId}
selectedTimerLabel={selectedTimerLabel} selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId}
sceneRecommendedSoundLabel={sceneRecommendedSoundLabel} sceneRecommendedSoundLabel={sceneRecommendedSoundLabel}
sceneRecommendedTimerLabel={sceneRecommendedTimerLabel} sceneRecommendedTimerLabel={sceneRecommendedTimerLabel}
timerPresets={timerPresets} timerPresets={timerPresets}
autoHideControls={autoHideControls}
onAutoHideControlsChange={setAutoHideControls}
onSelectRoom={(roomId) => { onSelectRoom={(roomId) => {
onRoomSelect(roomId); onRoomSelect(roomId);
}} }}
onSelectTimer={(label) => { onSelectTimer={(label) => {
onTimerSelect(label); onTimerSelect(label);
}} }}
onSelectSound={onQuickSoundSelect}
onSelectProFeature={handleSelectProFeature}
onLockedClick={handleLockedClick} onLockedClick={handleLockedClick}
onResetToRecommended={onResetToSceneRecommended}
/> />
) : null} ) : null}
@@ -444,8 +535,8 @@ export const SpaceToolsDockWidget = ({
{utilityPanel === 'manage-plan' ? ( {utilityPanel === 'manage-plan' ? (
<ManagePlanSheetContent <ManagePlanSheetContent
onClose={() => setUtilityPanel(null)} onClose={() => setUtilityPanel(null)}
onManage={() => pushToast({ title: '구독 관리(더미)' })} onManage={() => onStatusMessage({ message: '구독 관리(더미)' })}
onRestore={() => pushToast({ title: '구매 복원(더미)' })} onRestore={() => onStatusMessage({ message: '구매 복원(더미)' })}
/> />
) : null} ) : null}
</SpaceSideSheet> </SpaceSideSheet>

View File

@@ -0,0 +1,44 @@
import { cn } from '@/shared/lib/cn';
interface FocusTopToastProps {
visible: boolean;
message: string;
actionLabel?: string;
onAction?: () => void;
}
export const FocusTopToast = ({
visible,
message,
actionLabel,
onAction,
}: FocusTopToastProps) => {
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-50 flex justify-center px-3 transition-all duration-[220ms] ease-out motion-reduce:duration-0',
'top-[calc(env(safe-area-inset-top,0px)+0.75rem)]',
visible ? 'translate-y-0 opacity-100' : '-translate-y-1 opacity-0',
)}
aria-hidden={!visible}
>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="pointer-events-auto inline-flex max-w-[min(420px,92vw)] items-center gap-2 rounded-full border border-white/14 bg-black/32 px-3 py-1.5 text-xs text-white/86 shadow-[0_8px_24px_rgba(2,6,23,0.28)] backdrop-blur-md"
>
<span className="truncate">{message}</span>
{actionLabel ? (
<button
type="button"
onClick={onAction}
className="shrink-0 text-xs font-medium text-white/92 underline underline-offset-2 transition-colors hover:text-white"
>
{actionLabel}
</button>
) : null}
</div>
</div>
);
};

View File

@@ -20,6 +20,7 @@ import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud'; import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer'; import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock'; import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
import { FocusTopToast } from './FocusTopToast';
type WorkspaceMode = 'setup' | 'focus'; type WorkspaceMode = 'setup' | 'focus';
type SelectionOverride = { type SelectionOverride = {
@@ -30,6 +31,7 @@ interface StoredWorkspaceSelection {
sceneId?: string; sceneId?: string;
timerPresetId?: string; timerPresetId?: string;
soundPresetId?: string; soundPresetId?: string;
goal?: string;
override?: Partial<SelectionOverride>; override?: Partial<SelectionOverride>;
} }
@@ -142,6 +144,13 @@ const resolveInitialTimerLabel = (
export const SpaceWorkspaceWidget = () => { export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []); const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []);
const roomQuery = searchParams.get('room');
const goalQuery = searchParams.get('goal');
const soundQuery = searchParams.get('sound');
const timerQuery = searchParams.get('timer');
const storedGoal = storedSelection.goal?.trim() ?? '';
const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery);
const canOfferResume = storedGoal.length > 0 && !hasQueryOverrides;
const { const {
thoughts, thoughts,
thoughtCount, thoughtCount,
@@ -149,19 +158,20 @@ export const SpaceWorkspaceWidget = () => {
removeThought, removeThought,
clearThoughts, clearThoughts,
restoreThought, restoreThought,
restoreThoughts,
setThoughtCompleted, setThoughtCompleted,
} = useThoughtInbox(); } = useThoughtInbox();
const initialRoomId = resolveInitialRoomId(searchParams.get('room'), storedSelection.sceneId); const initialRoomId = resolveInitialRoomId(roomQuery, storedSelection.sceneId);
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0]; const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0];
const initialGoal = searchParams.get('goal')?.trim() ?? ''; const initialGoal = goalQuery?.trim() ?? '';
const initialSoundPresetId = resolveInitialSoundPreset( const initialSoundPresetId = resolveInitialSoundPreset(
searchParams.get('sound'), soundQuery,
storedSelection.soundPresetId, storedSelection.soundPresetId,
initialRoom.recommendedSoundPresetId, initialRoom.recommendedSoundPresetId,
); );
const initialTimerLabel = resolveInitialTimerLabel( const initialTimerLabel = resolveInitialTimerLabel(
searchParams.get('timer'), timerQuery,
storedSelection.timerPresetId, storedSelection.timerPresetId,
initialRoom.recommendedTimerPresetId, initialRoom.recommendedTimerPresetId,
); );
@@ -171,6 +181,7 @@ export const SpaceWorkspaceWidget = () => {
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
const [goalInput, setGoalInput] = useState(initialGoal); const [goalInput, setGoalInput] = useState(initialGoal);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null); const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [showResumePrompt, setShowResumePrompt] = useState(canOfferResume);
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({ const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
sound: Boolean(storedSelection.override?.sound), sound: Boolean(storedSelection.override?.sound),
timer: Boolean(storedSelection.override?.timer), timer: Boolean(storedSelection.override?.timer),
@@ -263,34 +274,17 @@ export const SpaceWorkspaceWidget = () => {
}); });
}; };
const handleResetToSceneRecommended = () => {
const room = getRoomById(selectedRoomId);
if (!room) {
return;
}
setSelectionOverride({ sound: false, timer: false });
const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId);
if (recommendedTimerLabel) {
setSelectedTimerLabel(recommendedTimerLabel);
}
if (SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)) {
setSelectedPresetId(room.recommendedSoundPresetId);
}
pushStatusLine({ message: '추천으로 되돌림(더미)' });
};
const handleGoalChipSelect = (chip: GoalChip) => { const handleGoalChipSelect = (chip: GoalChip) => {
setShowResumePrompt(false);
setSelectedGoalId(chip.id); setSelectedGoalId(chip.id);
setGoalInput(chip.label); setGoalInput(chip.label);
}; };
const handleGoalChange = (value: string) => { const handleGoalChange = (value: string) => {
if (showResumePrompt) {
setShowResumePrompt(false);
}
setGoalInput(value); setGoalInput(value);
if (value.trim().length === 0) { if (value.trim().length === 0) {
@@ -303,6 +297,7 @@ export const SpaceWorkspaceWidget = () => {
return; return;
} }
setShowResumePrompt(false);
setWorkspaceMode('focus'); setWorkspaceMode('focus');
}; };
@@ -329,6 +324,11 @@ export const SpaceWorkspaceWidget = () => {
} }
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
const normalizedGoal = goalInput.trim().length > 0
? goalInput.trim()
: showResumePrompt
? storedGoal
: '';
window.localStorage.setItem( window.localStorage.setItem(
WORKSPACE_SELECTION_STORAGE_KEY, WORKSPACE_SELECTION_STORAGE_KEY,
@@ -336,10 +336,11 @@ export const SpaceWorkspaceWidget = () => {
sceneId: selectedRoomId, sceneId: selectedRoomId,
timerPresetId, timerPresetId,
soundPresetId: selectedPresetId, soundPresetId: selectedPresetId,
goal: normalizedGoal,
override: selectionOverride, override: selectionOverride,
}), }),
); );
}, [selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride]); }, [goalInput, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt, storedGoal]);
return ( return (
<div className="relative h-dvh overflow-hidden text-white"> <div className="relative h-dvh overflow-hidden text-white">
@@ -371,14 +372,30 @@ export const SpaceWorkspaceWidget = () => {
onGoalChange={handleGoalChange} onGoalChange={handleGoalChange}
onGoalChipSelect={handleGoalChipSelect} onGoalChipSelect={handleGoalChipSelect}
onStart={handleStart} onStart={handleStart}
resumeHint={
showResumePrompt
? {
goal: storedGoal,
onResume: () => {
setGoalInput(storedGoal);
setSelectedGoalId(null);
setShowResumePrompt(false);
setWorkspaceMode('focus');
},
onStartFresh: () => {
setGoalInput('');
setSelectedGoalId(null);
setShowResumePrompt(false);
},
}
: undefined
}
/> />
<SpaceFocusHudWidget <SpaceFocusHudWidget
goal={goalInput.trim()} goal={goalInput.trim()}
timerLabel={selectedTimerLabel} timerLabel={selectedTimerLabel}
visible={isFocusMode} visible={isFocusMode}
statusLine={activeStatus}
onStatusAction={runActiveAction}
onStatusMessage={pushStatusLine} onStatusMessage={pushStatusLine}
onGoalUpdate={(nextGoal) => { onGoalUpdate={(nextGoal) => {
setGoalInput(nextGoal); setGoalInput(nextGoal);
@@ -386,6 +403,13 @@ export const SpaceWorkspaceWidget = () => {
}} }}
/> />
<FocusTopToast
visible={isFocusMode && Boolean(activeStatus)}
message={activeStatus?.message ?? ''}
actionLabel={activeStatus?.action?.label}
onAction={runActiveAction}
/>
<SpaceToolsDockWidget <SpaceToolsDockWidget
isFocusMode={isFocusMode} isFocusMode={isFocusMode}
rooms={setupRooms} rooms={setupRooms}
@@ -400,7 +424,6 @@ export const SpaceWorkspaceWidget = () => {
onQuickSoundSelect={(presetId) => handleSelectSound(presetId, true)} onQuickSoundSelect={(presetId) => handleSelectSound(presetId, true)}
sceneRecommendedSoundLabel={selectedRoom.recommendedSound} sceneRecommendedSoundLabel={selectedRoom.recommendedSound}
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selectedRoom.recommendedTimerPresetId) ?? selectedTimerLabel} sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selectedRoom.recommendedTimerPresetId) ?? selectedTimerLabel}
onResetToSceneRecommended={handleResetToSceneRecommended}
soundVolume={masterVolume} soundVolume={masterVolume}
onSetSoundVolume={setMasterVolume} onSetSoundVolume={setMasterVolume}
isSoundMuted={isMuted} isSoundMuted={isMuted}
@@ -409,6 +432,7 @@ export const SpaceWorkspaceWidget = () => {
onDeleteThought={removeThought} onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted} onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought} onRestoreThought={restoreThought}
onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts} onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine} onStatusMessage={pushStatusLine}
onExitRequested={handleExitRequested} onExitRequested={handleExitRequested}