feat(app): focus entry surface로 진입 화면 재구성

This commit is contained in:
2026-03-13 09:54:33 +09:00
parent 698c124ade
commit 2506dd53a7
16 changed files with 1346 additions and 30 deletions

View File

@@ -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`

View File

@@ -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 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음

View File

@@ -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

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import { FocusDashboardWidget } from '@/widgets/focus-dashboard';
export default function AppPage() {
redirect('/space');
return <FocusDashboardWidget />;
}

View 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);
},
};

View File

@@ -0,0 +1,3 @@
export * from './api/focusPlanApi';
export * from './model/types';
export * from './model/useFocusPlan';

View 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;
}

View 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)),
};
};

View File

@@ -1,2 +1,3 @@
export * from './model/mockPlan';
export * from './model/types';
export * from './model/usePlanTier';

View 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,
};
};

View File

@@ -17,6 +17,9 @@ export interface FocusStatsSummary {
bestDayLabel: string;
bestDayFocusMinutes: number;
streakDays: number;
startedSessions: number;
completedSessions: number;
carriedOverCount: number;
};
trend: FocusTrendPoint[];
}

View File

@@ -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: [],
};

View File

@@ -0,0 +1 @@
export * from './ui/FocusDashboardWidget';

View 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}
</>
);
};

View 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>
);
};

View 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>
);
};