From 2506dd53a79943f1cc2c260675d3bb26c30901a8 Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 13 Mar 2026 09:54:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(app):=20focus=20entry=20surface=EB=A1=9C?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85=20=ED=99=94=EB=A9=B4=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/90_current_state.md | 117 +++- docs/session_brief.md | 55 +- docs/work.md | 63 +- src/app/(app)/app/page.tsx | 4 +- src/entities/focus-plan/api/focusPlanApi.ts | 86 +++ src/entities/focus-plan/index.ts | 3 + src/entities/focus-plan/model/types.ts | 26 + src/entities/focus-plan/model/useFocusPlan.ts | 122 ++++ src/entities/plan/index.ts | 1 + src/entities/plan/model/usePlanTier.ts | 63 ++ src/features/stats/api/statsApi.ts | 3 + src/features/stats/model/useFocusStats.ts | 9 +- src/widgets/focus-dashboard/index.ts | 1 + .../ui/FocusDashboardWidget.tsx | 538 ++++++++++++++++++ .../focus-dashboard/ui/FocusPlanListRow.tsx | 130 +++++ .../ui/FocusPlanManageSheet.tsx | 155 +++++ 16 files changed, 1346 insertions(+), 30 deletions(-) create mode 100644 src/entities/focus-plan/api/focusPlanApi.ts create mode 100644 src/entities/focus-plan/index.ts create mode 100644 src/entities/focus-plan/model/types.ts create mode 100644 src/entities/focus-plan/model/useFocusPlan.ts create mode 100644 src/entities/plan/model/usePlanTier.ts create mode 100644 src/widgets/focus-dashboard/index.ts create mode 100644 src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx create mode 100644 src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx create mode 100644 src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 94b93d5..764c36c 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -1,9 +1,76 @@ # 90. Current State -Last Updated: 2026-03-11 +Last Updated: 2026-03-12 ## 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 보정: - pan 애니메이션 중 가장자리 빈틈이 드러나지 않도록 stage background layer를 `-inset-8`로 확장 - `/space` 배경 asset 해석 안정화: @@ -17,12 +84,12 @@ Last Updated: 2026-03-11 - Quick Controls Time의 `90/20` 잠금을 제거 - 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능 - Pro 가치 재배치: - - Pro 잠금 대상을 `Scene Packs / Sound Packs / Profiles`로 재정의 + - Pro 잠금 대상을 `Daily Focus Plan / Rituals / Weekly Review`로 재정의 - 기본 Scene/Time/Sound는 잠금 없이 선택 중심으로 정리 - Control Center UI 재구성: - Scene/Time/Sound 중심 구조 유지 - 추천 조합을 정보 1줄로 축소(비인터랙션) - - 하단에 Packs/Profiles 요약 카드(작은 🔒 배지) 추가 + - 하단에 Session OS 요약 카드(작은 🔒 배지) 추가 - Paywall 의도 기반 트리거 적용: - 잠금 카드 클릭 시에만 Paywall Sheet 오픈 - Plan Pill(NORMAL) 클릭은 즉시 결제창 대신 상태 안내만 표시 @@ -159,12 +226,21 @@ Last Updated: 2026-03-11 ## NEXT -1. `/space`에서 `forest` / `green-forest` manifest 변형을 실제 브라우저 기준으로 QA -2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감 -3. Stage 가독성/모션/레이어 폴리시 최종 통일 +1. `/app` focus entry surface start/manage 브라우저 스모크 +2. `/space` goal-complete -> next goal immediate start 흐름 QA +3. `/stats` factual summary / trend / refresh 브라우저 QA ## 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이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다 - remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다 - alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다 @@ -191,11 +267,38 @@ Last Updated: 2026-03-11 ## 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/resolveMediaAsset.ts` - `src/entities/media/model/useMediaCatalog.ts` - - `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` - `docs/work.md` - `docs/90_current_state.md` - `docs/session_brief.md` diff --git a/docs/session_brief.md b/docs/session_brief.md index 90095d7..568e9bb 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -1,6 +1,6 @@ # 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 -2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검 -3. Stage 가독성/모션/레이어 폴리시 최종 정리 +1. `/app` focus entry surface start/manage 브라우저 QA +2. `/space` goal-complete -> next goal immediate start 흐름 QA +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으로 보정했다. - background layer를 `-inset-8`로 확장해 pan 애니메이션 중 가장자리 빈틈 노출을 줄였다. +- Gemini가 진행한 대형 파일 분리를 다시 점검했다. + - `SpaceWorkspaceWidget`, `SpaceToolsDockWidget`, `admin/page.tsx`, `shared/i18n/ko.ts` 모두 500줄 기준 안으로 정리된 상태를 재확인했다. + - 현재 주요 최대 파일은 `AdminDashboardView.tsx` 482줄, `useSpaceWorkspaceSelection.ts` 440줄 수준이다. +- `/admin` 분리 과정의 placeholder 회귀를 복구했다. + - 로그인 후 `Dashboard is under construction`만 보이던 상태를 제거했다. + - 실제 scene/sound 업로드 폼을 `AdminDashboardView`로 복원해 `AdminConsoleWidget`은 조합만 담당하도록 되돌렸다. +- 인증 전역 저장소 위치를 정리했다. + - `src/store/useAuthStore.ts`를 제거했다. + - 인증 상태 저장소를 `entities/auth/model/useAuthStore.ts`로 이동해 feature가 루트 store를 직접 참조하지 않도록 정리했다. - `/space` 배경 asset 해석을 보강했다. - media manifest scene key를 alias-aware 하게 정규화해 `green-forest`와 `forest`를 같은 scene asset으로 읽는다. - scene/sound asset에 `source(fallback|remote)` 메타를 추가해 remote asset 사용 여부를 코드에서 바로 식별할 수 있다. @@ -32,11 +68,11 @@ Last Updated: 2026-03-11 - 기본 기능 잠금을 해소했다. - Time `90/20`을 Free로 개방 - 기본 Sound 잠금 제거 -- Pro 잠금 구조를 Packs/Profiles 중심으로 재구성했다. - - `Scene Packs / Sound Packs / Profiles` 요약 카드 추가 +- Pro 잠금 구조를 Session OS 중심으로 재구성했다. + - `Daily Focus Plan / Rituals / Weekly Review` 요약 카드 추가 - 기본 Scene/Time/Sound는 잠금 없이 선택 가능 - Paywall 시트는 잠금 카드 클릭에서만 열리도록 바꿨다. - - Plan Pill(NORMAL) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출 + - Plan Pill(FREE) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출 - Paywall 카피를 3개 가치 포인트 + 2개 CTA로 간결화 - Focus-First 구조로 전환했다. - 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 체감이 조금 더 커질 수 있다. - remote manifest 실패 시 원인 진단은 가능하지만, 사용자용 복구 CTA는 아직 없다. +- `/admin` 업로드 콘솔은 타입/구조상 복구됐지만 실제 브라우저 업로드 스모크는 아직 필요하다. - alias 목록에 없는 legacy scene id가 다시 들어오면 scene fallback 문제가 재발할 수 있다. - 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능 - localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음 diff --git a/docs/work.md b/docs/work.md index b7fa98d..7d84627 100644 --- a/docs/work.md +++ b/docs/work.md @@ -17,21 +17,62 @@ ## 작업 1 -- 제목: Space 배경 QA - forest / green-forest manifest 변형 검증 +- 제목: Focus Entry Surface /space linked flow 브라우저 QA - 목적: - - 최근 적용한 scene alias/fallback 보강이 실제 브라우저에서 기대대로 동작하는지 확인한다. - - `forest`와 `green-forest` 두 manifest 변형 모두에서 같은 배경이 표시되는지 검증한다. + - `/app`이 planning home이 아니라 focus entry surface로 보이는지 확인한다. + - hero input + primary CTA만으로 `/space` 진입이 가능한지 검증한다. - 변경 범위: - - 로컬 또는 스테이징 환경에서 `/space?scene=forest` 진입 확인 - - manifest의 `sceneId=forest` / `sceneId=green-forest` 두 경우 모두 동일 배경 적용 확인 - - manifest 실패 시 HUD 메시지/console 진단 로그 노출 확인 + - `/app` hero input / placeholder / suggestion chip / start CTA 확인 + - `블록 정리` sheet 열기, row 선택, row 수정, row 삭제 확인 + - Free 1개 / Pro 5개 제한 확인 - 제외 범위: - - 추가 코드 수정 금지 - - R2 업로드 파이프라인 수정 금지 + - 실제 결제 연동 금지 + - calendar/task 외부 연동 금지 - 완료 조건: - - 두 scene key 변형 모두에서 같은 forest 배경이 노출된다. - - manifest 실패 시 조용한 fallback만 남지 않고 진단 정보가 확인된다. + - `/app`이 리스트 CRUD보다 `지금 시작` hero가 먼저 읽힌다. + - 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 diff --git a/src/app/(app)/app/page.tsx b/src/app/(app)/app/page.tsx index 4aecf75..0913c6c 100644 --- a/src/app/(app)/app/page.tsx +++ b/src/app/(app)/app/page.tsx @@ -1,5 +1,5 @@ -import { redirect } from 'next/navigation'; +import { FocusDashboardWidget } from '@/widgets/focus-dashboard'; export default function AppPage() { - redirect('/space'); + return ; } diff --git a/src/entities/focus-plan/api/focusPlanApi.ts b/src/entities/focus-plan/api/focusPlanApi.ts new file mode 100644 index 0000000..f9d9081 --- /dev/null +++ b/src/entities/focus-plan/api/focusPlanApi.ts @@ -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 => { + const response = await apiClient('api/v1/focus-plan/today', { + method: 'GET', + }); + + return normalizeFocusPlanToday(response); + }, + + createItem: async (payload: CreateFocusPlanItemRequest): Promise => { + const response = await apiClient('api/v1/focus-plan/items', { + method: 'POST', + body: JSON.stringify(payload), + }); + + return normalizeFocusPlanToday(response); + }, + + updateItem: async (itemId: string, payload: UpdateFocusPlanItemRequest): Promise => { + const response = await apiClient(`api/v1/focus-plan/items/${itemId}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + + return normalizeFocusPlanToday(response); + }, + + completeItem: async (itemId: string): Promise => { + const response = await apiClient(`api/v1/focus-plan/items/${itemId}/complete`, { + method: 'POST', + }); + + return normalizeFocusPlanToday(response); + }, + + deleteItem: async (itemId: string): Promise => { + const response = await apiClient(`api/v1/focus-plan/items/${itemId}`, { + method: 'DELETE', + }); + + return normalizeFocusPlanToday(response); + }, +}; diff --git a/src/entities/focus-plan/index.ts b/src/entities/focus-plan/index.ts new file mode 100644 index 0000000..2136520 --- /dev/null +++ b/src/entities/focus-plan/index.ts @@ -0,0 +1,3 @@ +export * from './api/focusPlanApi'; +export * from './model/types'; +export * from './model/useFocusPlan'; diff --git a/src/entities/focus-plan/model/types.ts b/src/entities/focus-plan/model/types.ts new file mode 100644 index 0000000..aaa61a4 --- /dev/null +++ b/src/entities/focus-plan/model/types.ts @@ -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; +} diff --git a/src/entities/focus-plan/model/useFocusPlan.ts b/src/entities/focus-plan/model/useFocusPlan.ts new file mode 100644 index 0000000..e40e37e --- /dev/null +++ b/src/entities/focus-plan/model/useFocusPlan.ts @@ -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; + createItem: (payload: CreateFocusPlanItemRequest) => Promise; + updateItem: (itemId: string, payload: UpdateFocusPlanItemRequest) => Promise; + completeItem: (itemId: string) => Promise; + deleteItem: (itemId: string) => Promise; +} + +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(EMPTY_FOCUS_PLAN); + const [isLoading, setLoading] = useState(true); + const [isSaving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [source, setSource] = useState('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) => { + 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)), + }; +}; diff --git a/src/entities/plan/index.ts b/src/entities/plan/index.ts index f39caef..44b0395 100644 --- a/src/entities/plan/index.ts +++ b/src/entities/plan/index.ts @@ -1,2 +1,3 @@ export * from './model/mockPlan'; export * from './model/types'; +export * from './model/usePlanTier'; diff --git a/src/entities/plan/model/usePlanTier.ts b/src/entities/plan/model/usePlanTier.ts new file mode 100644 index 0000000..6a7c413 --- /dev/null +++ b/src/entities/plan/model/usePlanTier.ts @@ -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('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, + }; +}; diff --git a/src/features/stats/api/statsApi.ts b/src/features/stats/api/statsApi.ts index 4293e49..8705b71 100644 --- a/src/features/stats/api/statsApi.ts +++ b/src/features/stats/api/statsApi.ts @@ -17,6 +17,9 @@ export interface FocusStatsSummary { bestDayLabel: string; bestDayFocusMinutes: number; streakDays: number; + startedSessions: number; + completedSessions: number; + carriedOverCount: number; }; trend: FocusTrendPoint[]; } diff --git a/src/features/stats/model/useFocusStats.ts b/src/features/stats/model/useFocusStats.ts index fa0d7a9..b00cb92 100644 --- a/src/features/stats/model/useFocusStats.ts +++ b/src/features/stats/model/useFocusStats.ts @@ -24,9 +24,12 @@ const buildMockSummary = (): FocusStatsSummary => { }, last7Days: { focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'), - bestDayLabel: WEEKLY_STATS[1]?.value ?? '-', - bestDayFocusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[1]?.delta ?? '0m'), - streakDays: Number.parseInt(WEEKLY_STATS[2]?.value ?? '0', 10) || 0, + bestDayLabel: WEEKLY_STATS[1]?.value ?? '수요일', + bestDayFocusMinutes: 210, + streakDays: 4, + startedSessions: 6, + completedSessions: 4, + carriedOverCount: 1, }, trend: [], }; diff --git a/src/widgets/focus-dashboard/index.ts b/src/widgets/focus-dashboard/index.ts new file mode 100644 index 0000000..099da21 --- /dev/null +++ b/src/widgets/focus-dashboard/index.ts @@ -0,0 +1 @@ +export * from './ui/FocusDashboardWidget'; diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx new file mode 100644 index 0000000..87cdf3e --- /dev/null +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -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(null); + const [manageSheetOpen, setManageSheetOpen] = useState(false); + const [editingState, setEditingState] = useState(null); + const [entryDraft, setEntryDraft] = useState(''); + const [selectedPlanItemId, setSelectedPlanItemId] = useState(null); + const [entrySource, setEntrySource] = useState('starter'); + const entryInputRef = useRef(null); + const inputRef = useRef(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 ( + <> +
+
+
+
+

+ {focusEntryCopy.eyebrow} +

+

+ {focusEntryCopy.title} +

+

+ {focusEntryCopy.description} +

+
+ { + if (!isPro) { + openPaywall(); + } + }} + /> +
+ +
+
+
+
+

{focusEntryCopy.title}

+

{focusEntryCopy.helper}

+
+ +
+ + + {startHref ? ( + + {focusEntryCopy.startNow} + + ) : ( + + )} +
+ +
+ {ENTRY_SUGGESTIONS.map((suggestion) => { + const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal; + + return ( + + ); + })} +
+ +
+

{focusEntryCopy.ritualMeta}

+ +
+ + {previewItems.length > 0 ? ( +
+
+

{focusEntryCopy.previewTitle}

+

+ {focusEntryCopy.previewDescription} +

+
+
+ {previewItems.map((item) => { + const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title; + + return ( + + ); + })} +
+
+ ) : null} + + {source === 'unavailable' && !isLoading ? ( +

+ {focusEntryCopy.apiUnavailableNote} +

+ ) : null} +
+
+ +
+

+ {isLoading ? focusEntryCopy.reviewFallback : reviewLine} +

+ + {focusEntryCopy.reviewLinkLabel} + +
+ + {error && source === 'api' ?

{error}

: null} +
+
+
+ + { + 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 ? ( +
+
+ ) : null} + + ); +}; diff --git a/src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx b/src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx new file mode 100644 index 0000000..aaa2dbe --- /dev/null +++ b/src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx @@ -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; + 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) => { + if (event.key === 'Enter') { + event.preventDefault(); + void onSave(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + onCancel(); + } + }; + + if (isEditing) { + return ( +
+
+ 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" + /> +
+ + +
+
+
+ ); + } + + return ( +
+ +
+ + +
+
+ ); +}; diff --git a/src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx b/src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx new file mode 100644 index 0000000..09dc359 --- /dev/null +++ b/src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx @@ -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; + 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 ( + +
+
+
+ {planItems.length === 0 && editingState?.mode !== 'new' ? ( +
+ {manageSheetCopy.empty} +
+ ) : null} + + {planItems.map((item, index) => ( + onSelect(item)} + onEdit={() => onEdit(item)} + onDelete={() => onDelete(item.id)} + onSave={onSave} + onCancel={onCancel} + /> + ))} + + {editingState?.mode === 'new' ? ( + {}} + onEdit={() => {}} + onDelete={() => {}} + onSave={onSave} + onCancel={onCancel} + /> + ) : null} +
+
+ +
+ {isPro && !canAddMore ? ( +

{manageSheetCopy.maxBlocksReached}

+ ) : ( + + )} +
+
+
+ ); +};