feat(app): focus entry surface로 진입 화면 재구성
This commit is contained in:
@@ -1,9 +1,76 @@
|
|||||||
# 90. Current State
|
# 90. Current State
|
||||||
|
|
||||||
Last Updated: 2026-03-11
|
Last Updated: 2026-03-12
|
||||||
|
|
||||||
## DONE
|
## DONE
|
||||||
|
|
||||||
|
- 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를 허용
|
||||||
|
- block CRUD는 메인 화면에서 제거하고 `블록 정리` manage sheet 안으로 내렸다
|
||||||
|
- `FocusPlan` current item은 hero prefill로 이어지고, preview row는 최대 2개까지만 보조적으로 노출
|
||||||
|
- Free는 1개, Pro는 최대 5개 블록까지 관리하도록 프론트 제한을 유지
|
||||||
|
- `/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를 Session OS focus entry surface로 복구:
|
||||||
|
- `/app` route가 `/space` redirect 대신 `FocusDashboardWidget`을 렌더링
|
||||||
|
- current/next summary card와 list-first 구조를 제거하고, entry hero가 above-the-fold를 차지한다
|
||||||
|
- `start`를 `plan persistence`와 분리해 goal only 쿼리로도 `/space` 진입 가능하게 정리했다
|
||||||
|
- Free에서 두 번째 블록 추가 시도 시 manage sheet 내부에서 paywall로 진입
|
||||||
|
- 플랜 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 정착:
|
||||||
|
- 기존 API summary/trend 유지
|
||||||
|
- 해석형 insight/quiet accountability preview를 제거하고 factual card만 유지
|
||||||
|
- 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 보정:
|
- `/space` stage 배경 overscan 보정:
|
||||||
- pan 애니메이션 중 가장자리 빈틈이 드러나지 않도록 stage background layer를 `-inset-8`로 확장
|
- pan 애니메이션 중 가장자리 빈틈이 드러나지 않도록 stage background layer를 `-inset-8`로 확장
|
||||||
- `/space` 배경 asset 해석 안정화:
|
- `/space` 배경 asset 해석 안정화:
|
||||||
@@ -17,12 +84,12 @@ Last Updated: 2026-03-11
|
|||||||
- Quick Controls Time의 `90/20` 잠금을 제거
|
- 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) 클릭은 즉시 결제창 대신 상태 안내만 표시
|
||||||
@@ -159,12 +226,21 @@ Last Updated: 2026-03-11
|
|||||||
|
|
||||||
## NEXT
|
## NEXT
|
||||||
|
|
||||||
1. `/space`에서 `forest` / `green-forest` manifest 변형을 실제 브라우저 기준으로 QA
|
1. `/app` focus entry surface start/manage 브라우저 스모크
|
||||||
2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
|
2. `/space` goal-complete -> next goal immediate start 흐름 QA
|
||||||
3. Stage 가독성/모션/레이어 폴리시 최종 통일
|
3. `/stats` factual summary / trend / refresh 브라우저 QA
|
||||||
|
|
||||||
## RISKS
|
## RISKS
|
||||||
|
|
||||||
|
- `/app` manage sheet의 리스트는 append-only라 drag/drop reorder는 아직 없다
|
||||||
|
- Free/Pro 제한은 클라이언트 local tier 기준이므로 서버에서 직접 막지 않는다
|
||||||
|
- Free/Pro gating은 localStorage mock tier 기반이라 실제 구독 상태와 연결되지 않았다
|
||||||
|
- `advance-goal`은 atomic endpoint 기준으로 동작하지만, 네트워크 실패 시 사용자는 현재 시트에서 재시도해야 한다
|
||||||
|
- Session OS 도메인은 mock 기반이므로 실제 저장/복구 API 없이도 화면만 먼저 완성된 상태다
|
||||||
|
- empty state에서 CTA는 살아 있지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다
|
||||||
|
- current item이 아닌 preview row 선택은 ad-hoc start로 처리되므로, 큐 재정렬을 기대하는 사용자와 정신 모델 차이가 생길 수 있다
|
||||||
|
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다
|
||||||
|
- `/admin` 업로드 콘솔은 구조 복구가 끝났지만, 실제 파일 업로드 경로는 브라우저 수동 검증 전까지 확정할 수 없다
|
||||||
- stage background overscan으로 좁은 화면에서 배경 crop이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다
|
- stage background overscan으로 좁은 화면에서 배경 crop이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다
|
||||||
- remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다
|
- remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다
|
||||||
- alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다
|
- alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다
|
||||||
@@ -191,11 +267,38 @@ Last Updated: 2026-03-11
|
|||||||
|
|
||||||
## 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/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/types.ts`
|
||||||
- `src/entities/media/model/resolveMediaAsset.ts`
|
- `src/entities/media/model/resolveMediaAsset.ts`
|
||||||
- `src/entities/media/model/useMediaCatalog.ts`
|
- `src/entities/media/model/useMediaCatalog.ts`
|
||||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
|
||||||
- `docs/work.md`
|
- `docs/work.md`
|
||||||
- `docs/90_current_state.md`
|
- `docs/90_current_state.md`
|
||||||
- `docs/session_brief.md`
|
- `docs/session_brief.md`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Session Brief
|
# Session Brief
|
||||||
|
|
||||||
Last Updated: 2026-03-11
|
Last Updated: 2026-03-12
|
||||||
|
|
||||||
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
||||||
|
|
||||||
@@ -14,14 +14,50 @@ Last Updated: 2026-03-11
|
|||||||
|
|
||||||
## 현재 우선순위
|
## 현재 우선순위
|
||||||
|
|
||||||
1. `/space` forest 배경이 `forest` / `green-forest` manifest key 모두에서 동일하게 붙는지 브라우저 QA
|
1. `/app` focus entry surface start/manage 브라우저 QA
|
||||||
2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
|
2. `/space` goal-complete -> next goal immediate start 흐름 QA
|
||||||
3. Stage 가독성/모션/레이어 폴리시 최종 정리
|
3. `/stats` factual summary / trend / refresh QA
|
||||||
|
|
||||||
## 최근 세션 상태
|
## 최근 세션 상태
|
||||||
|
|
||||||
|
- `/app`을 planning home이 아니라 focus entry surface로 다시 재구성했다.
|
||||||
|
- hero에 one-line goal input과 단일 CTA `지금 시작`을 두고, 첫 블록 진입을 화면의 주 행동으로 올렸다.
|
||||||
|
- empty state에서는 값을 미리 채우지 않고 placeholder만 두며, 입력 후 바로 `/space`로 들어간다.
|
||||||
|
- suggestion chip으로 draft를 빠르게 바꿀 수 있고, 직접 수정하면 ad-hoc start가 가능하다.
|
||||||
|
- plan CRUD는 메인 화면에서 제거하고 `블록 정리` manage sheet 안으로 내렸다.
|
||||||
|
- current item은 hero를 prefill하고, next item은 최대 2개까지만 얕은 preview로 남긴다.
|
||||||
|
- Free는 1개, Pro는 최대 5개까지 관리한다.
|
||||||
|
- `/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`는 해석형 review 화면이 아니라 factual summary로 정리됐다.
|
||||||
|
- today / last7Days / trend만 유지한다.
|
||||||
|
- started/completed/carried over/focus minutes 중심으로 표시한다.
|
||||||
|
- 유료화 포지셔닝을 `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으로 보정했다.
|
- `/space` stage 배경을 overscan으로 보정했다.
|
||||||
- background layer를 `-inset-8`로 확장해 pan 애니메이션 중 가장자리 빈틈 노출을 줄였다.
|
- 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 해석을 보강했다.
|
- `/space` 배경 asset 해석을 보강했다.
|
||||||
- media manifest scene key를 alias-aware 하게 정규화해 `green-forest`와 `forest`를 같은 scene asset으로 읽는다.
|
- media manifest scene key를 alias-aware 하게 정규화해 `green-forest`와 `forest`를 같은 scene asset으로 읽는다.
|
||||||
- scene/sound asset에 `source(fallback|remote)` 메타를 추가해 remote asset 사용 여부를 코드에서 바로 식별할 수 있다.
|
- scene/sound asset에 `source(fallback|remote)` 메타를 추가해 remote asset 사용 여부를 코드에서 바로 식별할 수 있다.
|
||||||
@@ -32,11 +68,11 @@ Last Updated: 2026-03-11
|
|||||||
- 기본 기능 잠금을 해소했다.
|
- 기본 기능 잠금을 해소했다.
|
||||||
- Time `90/20`을 Free로 개방
|
- 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의 모드 전환 토글(기본/몰입)을 제거했다.
|
||||||
@@ -120,8 +156,13 @@ Last Updated: 2026-03-11
|
|||||||
|
|
||||||
## 리스크
|
## 리스크
|
||||||
|
|
||||||
|
- Session OS 데이터는 아직 mock 기반이므로 실제 저장/동기화 API 없이도 화면이 그럴듯하게만 보일 수 있다.
|
||||||
|
- empty state에서 CTA는 유지하지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다.
|
||||||
|
- current item이 아닌 preview row 선택은 ad-hoc start로 처리되므로, 큐 재정렬을 기대하는 사용자와 정신 모델 차이가 날 수 있다.
|
||||||
|
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다.
|
||||||
- stage background overscan으로 좁은 화면에서 배경 crop 체감이 조금 더 커질 수 있다.
|
- stage background overscan으로 좁은 화면에서 배경 crop 체감이 조금 더 커질 수 있다.
|
||||||
- remote manifest 실패 시 원인 진단은 가능하지만, 사용자용 복구 CTA는 아직 없다.
|
- remote manifest 실패 시 원인 진단은 가능하지만, 사용자용 복구 CTA는 아직 없다.
|
||||||
|
- `/admin` 업로드 콘솔은 타입/구조상 복구됐지만 실제 브라우저 업로드 스모크는 아직 필요하다.
|
||||||
- alias 목록에 없는 legacy scene id가 다시 들어오면 scene fallback 문제가 재발할 수 있다.
|
- alias 목록에 없는 legacy scene id가 다시 들어오면 scene fallback 문제가 재발할 수 있다.
|
||||||
- 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
|
- 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
|
||||||
- localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
|
- localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
|
||||||
|
|||||||
63
docs/work.md
63
docs/work.md
@@ -17,21 +17,62 @@
|
|||||||
|
|
||||||
## 작업 1
|
## 작업 1
|
||||||
|
|
||||||
- 제목: Space 배경 QA - forest / green-forest manifest 변형 검증
|
- 제목: Focus Entry Surface /space linked flow 브라우저 QA
|
||||||
- 목적:
|
- 목적:
|
||||||
- 최근 적용한 scene alias/fallback 보강이 실제 브라우저에서 기대대로 동작하는지 확인한다.
|
- `/app`이 planning home이 아니라 focus entry surface로 보이는지 확인한다.
|
||||||
- `forest`와 `green-forest` 두 manifest 변형 모두에서 같은 배경이 표시되는지 검증한다.
|
- hero input + primary CTA만으로 `/space` 진입이 가능한지 검증한다.
|
||||||
- 변경 범위:
|
- 변경 범위:
|
||||||
- 로컬 또는 스테이징 환경에서 `/space?scene=forest` 진입 확인
|
- `/app` hero input / placeholder / suggestion chip / start CTA 확인
|
||||||
- manifest의 `sceneId=forest` / `sceneId=green-forest` 두 경우 모두 동일 배경 적용 확인
|
- `블록 정리` sheet 열기, row 선택, row 수정, row 삭제 확인
|
||||||
- manifest 실패 시 HUD 메시지/console 진단 로그 노출 확인
|
- Free 1개 / Pro 5개 제한 확인
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- 추가 코드 수정 금지
|
- 실제 결제 연동 금지
|
||||||
- R2 업로드 파이프라인 수정 금지
|
- calendar/task 외부 연동 금지
|
||||||
- 완료 조건:
|
- 완료 조건:
|
||||||
- 두 scene key 변형 모두에서 같은 forest 배경이 노출된다.
|
- `/app`이 리스트 CRUD보다 `지금 시작` hero가 먼저 읽힌다.
|
||||||
- manifest 실패 시 조용한 fallback만 남지 않고 진단 정보가 확인된다.
|
- empty state에서도 disabled primary CTA 없이 `/space` 진입 경로가 살아 있다.
|
||||||
|
- manage sheet 안에서만 add/edit/delete가 보이고, 메인 화면에는 row-level 관리 버튼이 없다.
|
||||||
|
- start link에 담긴 goal/plan item이 `/space` 세션 시작까지 유지된다.
|
||||||
- 검증:
|
- 검증:
|
||||||
- 브라우저 수동 확인
|
- 브라우저 수동 확인
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
- chore(qa): space 배경 alias/fallback 브라우저 검증
|
- chore(qa): focus-entry-surface smoke
|
||||||
|
|
||||||
|
## 작업 2
|
||||||
|
|
||||||
|
- 제목: 목표 완료 후 다음 목표 즉시 실행 QA
|
||||||
|
- 목적:
|
||||||
|
- `/space`가 execution-only surface로 보이는지 확인한다.
|
||||||
|
- goal complete sheet에서 다음 목표를 입력하면 setup으로 돌아가지 않고 즉시 다음 세션이 시작되는지 검증한다.
|
||||||
|
- 변경 범위:
|
||||||
|
- `/space?goal=...&planItemId=...` 진입 확인
|
||||||
|
- goal complete -> next goal -> running session 전환 확인
|
||||||
|
- scene/sound/timer 유지 여부 확인
|
||||||
|
- 제외 범위:
|
||||||
|
- timer 종료 자동 전환 추가 금지
|
||||||
|
- ritual/template persistence 추가 금지
|
||||||
|
- 완료 조건:
|
||||||
|
- 현재 목표 완료 시 linked plan item이 완료 처리되고, 새 목표가 즉시 running session으로 이어진다.
|
||||||
|
- `/space` setup drawer에 planning/ritual 섹션이 남아 있지 않다.
|
||||||
|
- 검증:
|
||||||
|
- 브라우저 수동 확인
|
||||||
|
- 커밋 힌트:
|
||||||
|
- chore(qa): advance-goal linked session smoke
|
||||||
|
|
||||||
|
## 작업 3
|
||||||
|
|
||||||
|
- 제목: `/stats` factual summary QA
|
||||||
|
- 목적:
|
||||||
|
- `/stats`가 해석형 insight 없이 factual summary만 보여주는지 확인한다.
|
||||||
|
- 변경 범위:
|
||||||
|
- today / last7Days factual cards 확인
|
||||||
|
- trend 그래프와 refresh/source 상태 확인
|
||||||
|
- 제외 범위:
|
||||||
|
- 해석형 패턴 추천 추가 금지
|
||||||
|
- social/accountability mock 복구 금지
|
||||||
|
- 완료 조건:
|
||||||
|
- `/stats`에서 started/completed/carried over/focus minutes만 일관되게 보인다.
|
||||||
|
- 검증:
|
||||||
|
- 브라우저 수동 확인
|
||||||
|
- 커밋 힌트:
|
||||||
|
- chore(qa): stats factual summary smoke
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
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)),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './model/mockPlan';
|
export * from './model/mockPlan';
|
||||||
export * from './model/types';
|
export * from './model/types';
|
||||||
|
export * from './model/usePlanTier';
|
||||||
|
|||||||
63
src/entities/plan/model/usePlanTier.ts
Normal file
63
src/entities/plan/model/usePlanTier.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { PlanTier } from './types';
|
||||||
|
|
||||||
|
const PLAN_TIER_STORAGE_KEY = 'viberoom:plan-tier:v1';
|
||||||
|
|
||||||
|
const normalizePlanTier = (value: unknown): PlanTier => {
|
||||||
|
return value === 'pro' ? 'pro' : 'normal';
|
||||||
|
};
|
||||||
|
|
||||||
|
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, setPlanState] = useState<PlanTier>('normal');
|
||||||
|
const [hasHydratedPlan, setHasHydratedPlan] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlanState(readStoredPlanTier());
|
||||||
|
setHasHydratedPlan(true);
|
||||||
|
|
||||||
|
const handleStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== PLAN_TIER_STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlanState(normalizePlanTier(event.newValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorage);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPlan = useCallback((nextPlan: PlanTier) => {
|
||||||
|
setPlanState(nextPlan);
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
hasHydratedPlan,
|
||||||
|
isPro: plan === 'pro',
|
||||||
|
setPlan,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -17,6 +17,9 @@ export interface FocusStatsSummary {
|
|||||||
bestDayLabel: string;
|
bestDayLabel: string;
|
||||||
bestDayFocusMinutes: number;
|
bestDayFocusMinutes: number;
|
||||||
streakDays: number;
|
streakDays: number;
|
||||||
|
startedSessions: number;
|
||||||
|
completedSessions: number;
|
||||||
|
carriedOverCount: number;
|
||||||
};
|
};
|
||||||
trend: FocusTrendPoint[];
|
trend: FocusTrendPoint[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ const buildMockSummary = (): FocusStatsSummary => {
|
|||||||
},
|
},
|
||||||
last7Days: {
|
last7Days: {
|
||||||
focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'),
|
focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'),
|
||||||
bestDayLabel: WEEKLY_STATS[1]?.value ?? '-',
|
bestDayLabel: WEEKLY_STATS[1]?.value ?? '수요일',
|
||||||
bestDayFocusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[1]?.delta ?? '0m'),
|
bestDayFocusMinutes: 210,
|
||||||
streakDays: Number.parseInt(WEEKLY_STATS[2]?.value ?? '0', 10) || 0,
|
streakDays: 4,
|
||||||
|
startedSessions: 6,
|
||||||
|
completedSessions: 4,
|
||||||
|
carriedOverCount: 1,
|
||||||
},
|
},
|
||||||
trend: [],
|
trend: [],
|
||||||
};
|
};
|
||||||
|
|||||||
1
src/widgets/focus-dashboard/index.ts
Normal file
1
src/widgets/focus-dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './ui/FocusDashboardWidget';
|
||||||
538
src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
Normal file
538
src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
buildFocusEntryStartHref,
|
||||||
|
type FocusPlanItem,
|
||||||
|
type FocusPlanToday,
|
||||||
|
useFocusPlan,
|
||||||
|
} from '@/entities/focus-plan';
|
||||||
|
import { usePlanTier } from '@/entities/plan';
|
||||||
|
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||||
|
import { PlanPill } from '@/features/plan-pill';
|
||||||
|
import { useFocusStats } from '@/features/stats';
|
||||||
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
|
||||||
|
|
||||||
|
const FREE_MAX_ITEMS = 1;
|
||||||
|
const PRO_MAX_ITEMS = 5;
|
||||||
|
|
||||||
|
const focusEntryCopy = {
|
||||||
|
eyebrow: 'Focus Entry',
|
||||||
|
title: '지금 시작할 첫 블록',
|
||||||
|
description: '한 줄로 정하고 바로 들어가요.',
|
||||||
|
inputLabel: '첫 블록',
|
||||||
|
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||||
|
helper: '아주 작게 잡아도 괜찮아요.',
|
||||||
|
startNow: '지금 시작',
|
||||||
|
manageBlocks: '블록 정리',
|
||||||
|
previewTitle: '이어갈 블록',
|
||||||
|
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
|
||||||
|
reviewLinkLabel: 'stats',
|
||||||
|
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
|
||||||
|
ritualMeta: '기본 ritual로 들어가요. 배경과 타이머는 /space에서 이어서 바꿀 수 있어요.',
|
||||||
|
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
|
||||||
|
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||||
|
paywallSource: 'focus-entry-manage-sheet',
|
||||||
|
paywallLead: 'Calm Session OS PRO',
|
||||||
|
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTRY_SUGGESTIONS = [
|
||||||
|
{ id: 'tidy-10m', label: '정리 10분', goal: '정리 10분만 하기' },
|
||||||
|
{ id: 'mail-3', label: '메일 3개', goal: '메일 3개 정리' },
|
||||||
|
{ id: 'doc-1p', label: '문서 1p', goal: '문서 1p 다듬기' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type EntrySource = 'starter' | 'plan' | 'custom';
|
||||||
|
|
||||||
|
const getVisiblePlanItems = (
|
||||||
|
currentItem: FocusPlanItem | null,
|
||||||
|
nextItems: FocusPlanItem[],
|
||||||
|
limit: number,
|
||||||
|
) => {
|
||||||
|
return [currentItem, ...nextItems]
|
||||||
|
.filter((item): item is FocusPlanItem => Boolean(item))
|
||||||
|
.slice(0, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatReviewLine = (startedSessions: number, completedSessions: number, carriedOverCount: number) => {
|
||||||
|
return `최근 7일 시작 ${startedSessions}회 · 완료 ${completedSessions}회 · 이월 ${carriedOverCount}개`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startButtonClassName =
|
||||||
|
'inline-flex h-12 w-full items-center justify-center rounded-[1rem] bg-brand-primary text-sm font-semibold text-white shadow-[0_14px_32px_rgba(59,130,246,0.22)] transition hover:bg-brand-primary/92 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18';
|
||||||
|
|
||||||
|
const previewButtonClassName =
|
||||||
|
'w-full rounded-[1.1rem] border border-slate-200/88 bg-white/72 px-4 py-3 text-left transition hover:border-slate-300/88 hover:bg-white';
|
||||||
|
|
||||||
|
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
|
||||||
|
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FocusDashboardWidget = () => {
|
||||||
|
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||||
|
const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||||
|
const { summary } = useFocusStats();
|
||||||
|
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||||
|
const [manageSheetOpen, setManageSheetOpen] = useState(false);
|
||||||
|
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
|
||||||
|
const [entryDraft, setEntryDraft] = useState('');
|
||||||
|
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
|
||||||
|
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
|
||||||
|
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
|
||||||
|
const planItems = useMemo(() => {
|
||||||
|
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
|
||||||
|
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||||
|
|
||||||
|
const currentItem = planItems[0] ?? null;
|
||||||
|
const previewItems = planItems.slice(1, 3);
|
||||||
|
const reviewLine = formatReviewLine(
|
||||||
|
summary.last7Days.startedSessions,
|
||||||
|
summary.last7Days.completedSessions,
|
||||||
|
summary.last7Days.carriedOverCount,
|
||||||
|
);
|
||||||
|
const hasPendingEdit = editingState !== null;
|
||||||
|
const canAddMore = planItems.length < maxItems;
|
||||||
|
const canManagePlan = source === 'api' && !isLoading;
|
||||||
|
const trimmedEntryGoal = entryDraft.trim();
|
||||||
|
const startHref = trimmedEntryGoal
|
||||||
|
? buildFocusEntryStartHref({
|
||||||
|
goal: trimmedEntryGoal,
|
||||||
|
planItemId: selectedPlanItemId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [editingState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) {
|
||||||
|
setEntryDraft(currentItem.title);
|
||||||
|
setSelectedPlanItemId(currentItem.id);
|
||||||
|
setEntrySource('plan');
|
||||||
|
}
|
||||||
|
}, [currentItem, entryDraft, entrySource, selectedPlanItemId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPlanItemId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planItems.some((item) => item.id === selectedPlanItemId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentItem) {
|
||||||
|
setEntryDraft(currentItem.title);
|
||||||
|
setSelectedPlanItemId(currentItem.id);
|
||||||
|
setEntrySource('plan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntryDraft('');
|
||||||
|
setSelectedPlanItemId(null);
|
||||||
|
setEntrySource('custom');
|
||||||
|
}, [currentItem, planItems, selectedPlanItemId]);
|
||||||
|
|
||||||
|
const openPaywall = () => {
|
||||||
|
setPaywallSource(focusEntryCopy.paywallSource);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
||||||
|
const isCurrentSelection = currentItem?.id === item.id;
|
||||||
|
|
||||||
|
setEntryDraft(item.title);
|
||||||
|
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
|
||||||
|
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
|
||||||
|
setManageSheetOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSuggestion = (goal: string) => {
|
||||||
|
setEntryDraft(goal);
|
||||||
|
setSelectedPlanItemId(null);
|
||||||
|
setEntrySource('starter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntryDraftChange = (value: string) => {
|
||||||
|
setEntryDraft(value);
|
||||||
|
setEntrySource('custom');
|
||||||
|
setSelectedPlanItemId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddBlock = () => {
|
||||||
|
if (hasPendingEdit || isSaving || !canManagePlan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAddMore) {
|
||||||
|
if (!isPro) {
|
||||||
|
openPaywall();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingState({
|
||||||
|
mode: 'new',
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditRow = (item: FocusPlanItem) => {
|
||||||
|
if (hasPendingEdit || isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingState({
|
||||||
|
mode: 'edit',
|
||||||
|
itemId: item.id,
|
||||||
|
value: item.title,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageDraftChange = (value: string) => {
|
||||||
|
setEditingState((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingState(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editingState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedTitle = editingState.value.trim();
|
||||||
|
|
||||||
|
if (!trimmedTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingState.mode === 'new') {
|
||||||
|
const nextPlan = await createItem({ title: trimmedTitle });
|
||||||
|
|
||||||
|
if (!nextPlan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingState(null);
|
||||||
|
|
||||||
|
if (!currentItem) {
|
||||||
|
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||||
|
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||||
|
|
||||||
|
if (nextCurrentItem) {
|
||||||
|
setEntryDraft(nextCurrentItem.title);
|
||||||
|
setSelectedPlanItemId(nextCurrentItem.id);
|
||||||
|
setEntrySource('plan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRow = planItems.find((item) => item.id === editingState.itemId);
|
||||||
|
|
||||||
|
if (!currentRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentRow.title === trimmedTitle) {
|
||||||
|
setEditingState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPlan = await updateItem(editingState.itemId, {
|
||||||
|
title: trimmedTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nextPlan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingState(null);
|
||||||
|
|
||||||
|
if (selectedPlanItemId === editingState.itemId) {
|
||||||
|
setEntryDraft(trimmedTitle);
|
||||||
|
setEntrySource('plan');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRow = async (itemId: string) => {
|
||||||
|
const nextPlan = await deleteItem(itemId);
|
||||||
|
|
||||||
|
if (!nextPlan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||||
|
setEditingState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPlanItemId === itemId) {
|
||||||
|
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||||
|
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||||
|
|
||||||
|
if (nextCurrentItem) {
|
||||||
|
setEntryDraft(nextCurrentItem.title);
|
||||||
|
setSelectedPlanItemId(nextCurrentItem.id);
|
||||||
|
setEntrySource('plan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntryDraft('');
|
||||||
|
setSelectedPlanItemId(null);
|
||||||
|
setEntrySource('custom');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_12%_0%,rgba(191,219,254,0.42),transparent_36%),linear-gradient(180deg,#f8fafc_0%,#edf4fb_56%,#e7eef7_100%)] text-brand-dark">
|
||||||
|
<div className="mx-auto w-full max-w-2xl px-4 pb-12 pt-8 sm:px-6">
|
||||||
|
<header className="flex items-start justify-between gap-4">
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.16em] text-brand-dark/40">
|
||||||
|
{focusEntryCopy.eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-brand-dark">
|
||||||
|
{focusEntryCopy.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-brand-dark/62">
|
||||||
|
{focusEntryCopy.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PlanPill
|
||||||
|
plan={planTier}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isPro) {
|
||||||
|
openPaywall();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mt-8 space-y-5">
|
||||||
|
<section className="overflow-hidden rounded-[2rem] border border-black/5 bg-white/78 p-5 shadow-[0_24px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl sm:p-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.title}</p>
|
||||||
|
<p className="text-sm text-brand-dark/58">{focusEntryCopy.helper}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label className="min-w-0 flex-1 space-y-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.14em] text-brand-dark/40">
|
||||||
|
{focusEntryCopy.inputLabel}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={entryInputRef}
|
||||||
|
value={entryDraft}
|
||||||
|
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||||
|
placeholder={focusEntryCopy.inputPlaceholder}
|
||||||
|
className="h-12 w-full rounded-[1rem] border border-slate-200/88 bg-white px-4 text-[15px] text-brand-dark outline-none transition focus:border-brand-primary/38 focus:ring-2 focus:ring-brand-primary/12"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{startHref ? (
|
||||||
|
<Link href={startHref} className={cn(startButtonClassName, 'sm:w-[164px]')}>
|
||||||
|
{focusEntryCopy.startNow}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => entryInputRef.current?.focus()}
|
||||||
|
className={cn(startButtonClassName, 'sm:w-[164px]')}
|
||||||
|
>
|
||||||
|
{focusEntryCopy.startNow}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
||||||
|
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={suggestion.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectSuggestion(suggestion.goal)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-3 py-1.5 text-sm transition',
|
||||||
|
isActive
|
||||||
|
? 'border-brand-primary/26 bg-brand-primary/10 text-brand-dark'
|
||||||
|
: 'border-slate-200/84 bg-white/72 text-brand-dark/68 hover:bg-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{suggestion.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200/80 pt-4">
|
||||||
|
<p className="text-xs text-brand-dark/54">{focusEntryCopy.ritualMeta}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManageSheetOpen(true)}
|
||||||
|
disabled={!canManagePlan}
|
||||||
|
className="text-sm font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:text-brand-dark/34"
|
||||||
|
>
|
||||||
|
{focusEntryCopy.manageBlocks}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewItems.length > 0 ? (
|
||||||
|
<div className="space-y-3 border-t border-slate-200/80 pt-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.previewTitle}</p>
|
||||||
|
<p className="text-xs leading-6 text-brand-dark/54">
|
||||||
|
{focusEntryCopy.previewDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{previewItems.map((item) => {
|
||||||
|
const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPlanItem(item)}
|
||||||
|
className={cn(
|
||||||
|
previewButtonClassName,
|
||||||
|
isSelected && 'border-brand-primary/24 bg-brand-primary/8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'truncate text-[15px] font-medium',
|
||||||
|
isSelected ? 'text-brand-dark' : 'text-brand-dark/78',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{source === 'unavailable' && !isLoading ? (
|
||||||
|
<p className="border-t border-slate-200/80 pt-4 text-xs text-brand-dark/54">
|
||||||
|
{focusEntryCopy.apiUnavailableNote}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1">
|
||||||
|
<p className="text-xs text-brand-dark/54">
|
||||||
|
{isLoading ? focusEntryCopy.reviewFallback : reviewLine}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/stats"
|
||||||
|
className="text-xs font-medium text-brand-primary transition hover:text-brand-primary/82"
|
||||||
|
>
|
||||||
|
{focusEntryCopy.reviewLinkLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && source === 'api' ? <p className="px-1 text-xs text-rose-500">{error}</p> : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FocusPlanManageSheet
|
||||||
|
isOpen={manageSheetOpen}
|
||||||
|
planItems={planItems}
|
||||||
|
selectedPlanItemId={selectedPlanItemId}
|
||||||
|
editingState={editingState}
|
||||||
|
isSaving={isSaving}
|
||||||
|
canAddMore={canAddMore}
|
||||||
|
isPro={isPro}
|
||||||
|
inputRef={inputRef}
|
||||||
|
onClose={() => {
|
||||||
|
if (!isSaving) {
|
||||||
|
setManageSheetOpen(false);
|
||||||
|
setEditingState(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onAddBlock={handleAddBlock}
|
||||||
|
onDraftChange={handleManageDraftChange}
|
||||||
|
onSelect={handleSelectPlanItem}
|
||||||
|
onEdit={handleEditRow}
|
||||||
|
onDelete={(itemId) => {
|
||||||
|
void handleDeleteRow(itemId);
|
||||||
|
}}
|
||||||
|
onSave={() => {
|
||||||
|
void handleSaveEdit();
|
||||||
|
}}
|
||||||
|
onCancel={handleCancelEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{paywallSource ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={copy.modal.closeAriaLabel}
|
||||||
|
onClick={() => setPaywallSource(null)}
|
||||||
|
className="absolute inset-0 bg-slate-950/48 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]">
|
||||||
|
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42">
|
||||||
|
{focusEntryCopy.paywallLead}
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 text-sm text-white/62">
|
||||||
|
{focusEntryCopy.paywallBody}
|
||||||
|
</p>
|
||||||
|
<PaywallSheetContent
|
||||||
|
onStartPro={() => {
|
||||||
|
setPlan('pro');
|
||||||
|
setPaywallSource(null);
|
||||||
|
}}
|
||||||
|
onClose={() => setPaywallSource(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
130
src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx
Normal file
130
src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { KeyboardEvent, RefObject } from 'react';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface FocusPlanListRowProps {
|
||||||
|
title: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
draftValue: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
|
placeholder?: string;
|
||||||
|
onDraftChange: (value: string) => void;
|
||||||
|
onSelect: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FocusPlanListRow = ({
|
||||||
|
title,
|
||||||
|
isCurrent,
|
||||||
|
isSelected = false,
|
||||||
|
isEditing,
|
||||||
|
draftValue,
|
||||||
|
isSaving,
|
||||||
|
isBusy,
|
||||||
|
inputRef,
|
||||||
|
placeholder = '예: 제안서 첫 문단 다듬기',
|
||||||
|
onDraftChange,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: FocusPlanListRowProps) => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void onSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 sm:px-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draftValue}
|
||||||
|
onChange={(event) => onDraftChange(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="min-w-[220px] flex-1 rounded-[14px] bg-slate-100/95 px-3.5 py-2.5 text-[15px] text-brand-dark outline-none ring-1 ring-slate-200/80 placeholder:text-slate-400 focus:ring-2 focus:ring-brand-primary/18"
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-[15px] font-medium text-brand-dark/58 transition hover:text-brand-dark disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onSave();
|
||||||
|
}}
|
||||||
|
className="text-[15px] font-semibold text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
disabled={draftValue.trim().length === 0 || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? '저장 중…' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3.5 sm:px-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate text-left text-[15px] transition',
|
||||||
|
isSelected
|
||||||
|
? 'font-semibold text-brand-dark'
|
||||||
|
: isCurrent
|
||||||
|
? 'font-semibold text-brand-dark/88'
|
||||||
|
: 'font-medium text-brand-dark/78',
|
||||||
|
isBusy ? 'cursor-default' : 'hover:text-brand-dark',
|
||||||
|
)}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="text-[15px] font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void onDelete();
|
||||||
|
}}
|
||||||
|
className="text-[15px] font-medium text-rose-500 transition hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
155
src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx
Normal file
155
src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
import type { FocusPlanItem } from '@/entities/focus-plan';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
import { Modal } from '@/shared/ui';
|
||||||
|
import { FocusPlanListRow } from './FocusPlanListRow';
|
||||||
|
|
||||||
|
export type FocusPlanEditingState =
|
||||||
|
| {
|
||||||
|
mode: 'new';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: 'edit';
|
||||||
|
itemId: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
|
||||||
|
interface FocusPlanManageSheetProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
planItems: FocusPlanItem[];
|
||||||
|
selectedPlanItemId: string | null;
|
||||||
|
editingState: FocusPlanEditingState;
|
||||||
|
isSaving: boolean;
|
||||||
|
canAddMore: boolean;
|
||||||
|
isPro: boolean;
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
|
onClose: () => void;
|
||||||
|
onAddBlock: () => void;
|
||||||
|
onDraftChange: (value: string) => void;
|
||||||
|
onSelect: (item: FocusPlanItem) => void;
|
||||||
|
onEdit: (item: FocusPlanItem) => void;
|
||||||
|
onDelete: (itemId: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manageSheetCopy = {
|
||||||
|
title: '블록 정리',
|
||||||
|
description: '집중에 들어간 뒤 이어갈 블록만 가볍게 남겨두세요.',
|
||||||
|
empty: '아직 저장된 블록이 없어요.',
|
||||||
|
addBlock: '+ 블록 추가',
|
||||||
|
placeholder: '예: 리뷰 코멘트 2개 정리',
|
||||||
|
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||||
|
maxBlocksReached: '최대 5개까지 정리해 둘 수 있어요.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FocusPlanManageSheet = ({
|
||||||
|
isOpen,
|
||||||
|
planItems,
|
||||||
|
selectedPlanItemId,
|
||||||
|
editingState,
|
||||||
|
isSaving,
|
||||||
|
canAddMore,
|
||||||
|
isPro,
|
||||||
|
inputRef,
|
||||||
|
onClose,
|
||||||
|
onAddBlock,
|
||||||
|
onDraftChange,
|
||||||
|
onSelect,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: FocusPlanManageSheetProps) => {
|
||||||
|
const hasPendingEdit = editingState !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={manageSheetCopy.title}
|
||||||
|
description={manageSheetCopy.description}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="overflow-hidden rounded-[1.4rem] border border-brand-dark/10 bg-white/66">
|
||||||
|
<div className="divide-y divide-slate-200/78">
|
||||||
|
{planItems.length === 0 && editingState?.mode !== 'new' ? (
|
||||||
|
<div className="px-4 py-3.5 text-[15px] text-brand-dark/46 sm:px-5">
|
||||||
|
{manageSheetCopy.empty}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{planItems.map((item, index) => (
|
||||||
|
<FocusPlanListRow
|
||||||
|
key={item.id}
|
||||||
|
title={item.title}
|
||||||
|
isCurrent={index === 0}
|
||||||
|
isSelected={selectedPlanItemId === item.id}
|
||||||
|
isEditing={editingState?.mode === 'edit' && editingState.itemId === item.id}
|
||||||
|
draftValue={
|
||||||
|
editingState?.mode === 'edit' && editingState.itemId === item.id
|
||||||
|
? editingState.value
|
||||||
|
: item.title
|
||||||
|
}
|
||||||
|
isSaving={isSaving}
|
||||||
|
isBusy={isSaving || hasPendingEdit}
|
||||||
|
inputRef={editingState?.mode === 'edit' && editingState.itemId === item.id ? inputRef : undefined}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
onSelect={() => onSelect(item)}
|
||||||
|
onEdit={() => onEdit(item)}
|
||||||
|
onDelete={() => onDelete(item.id)}
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{editingState?.mode === 'new' ? (
|
||||||
|
<FocusPlanListRow
|
||||||
|
title=""
|
||||||
|
isCurrent={planItems.length === 0}
|
||||||
|
isEditing
|
||||||
|
draftValue={editingState.value}
|
||||||
|
isSaving={isSaving}
|
||||||
|
isBusy={isSaving}
|
||||||
|
inputRef={inputRef}
|
||||||
|
placeholder={manageSheetCopy.placeholder}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onEdit={() => {}}
|
||||||
|
onDelete={() => {}}
|
||||||
|
onSave={onSave}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-1">
|
||||||
|
{isPro && !canAddMore ? (
|
||||||
|
<p className="text-sm text-brand-dark/46">{manageSheetCopy.maxBlocksReached}</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddBlock}
|
||||||
|
disabled={hasPendingEdit || isSaving}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 text-[15px] font-medium text-brand-primary transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18 disabled:cursor-not-allowed disabled:opacity-45',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{manageSheetCopy.addBlock}</span>
|
||||||
|
{!isPro && !canAddMore ? (
|
||||||
|
<span className="rounded-full bg-brand-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-brand-primary">
|
||||||
|
{manageSheetCopy.freeUpgradeLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user