feat(app): focus entry surface로 진입 화면 재구성
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
|
||||
|
||||
63
docs/work.md
63
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { FocusDashboardWidget } from '@/widgets/focus-dashboard';
|
||||
|
||||
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/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;
|
||||
bestDayFocusMinutes: number;
|
||||
streakDays: number;
|
||||
startedSessions: number;
|
||||
completedSessions: number;
|
||||
carriedOverCount: number;
|
||||
};
|
||||
trend: FocusTrendPoint[];
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
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