From abdde2a8ae6088960220f4f2c907d9dfbfc7608a Mon Sep 17 00:00:00 2001
From: corpi
Date: Fri, 13 Mar 2026 14:57:35 +0900
Subject: [PATCH] =?UTF-8?q?feat(space/app):=20app=20=EC=A7=84=EC=9E=85?=
=?UTF-8?q?=EB=B6=80=20=EB=B0=8F=20space=20=EB=AA=B0=EC=9E=85=20=ED=99=98?=
=?UTF-8?q?=EA=B2=BD(HUD/Tools)=20=ED=94=84=EB=A6=AC=EB=AF=B8=EC=97=84=20U?=
=?UTF-8?q?I=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
맥락:
- 기존 app 대시보드와 space 화면의 UI가 SaaS 툴처럼 딱딱하고 투박하여, 유저가 기꺼이 지갑을 열 만한 몰입감과 고급스러움(Premium feel)이 부족함.
- 인지적 과부하를 줄이기 위해 제안된 '첫 5분 행동(Micro-step)'이 타이머 영역에 묻혀 있어 행동 유발 효과가 미미함.
변경사항:
- app: 컨테이너 박스를 제거하고 전체 배경 화면(Immersive Background)과 Glassmorphism을 활용한 1.5 Step 진입 플로우로 전면 개편.
- space/hud: 하단의 두꺼운 타이머 패널을 초박형(Slim) 글라스 알약 형태로 축소하여 배경 씬의 개방감 확보.
- space/hud: 목표(Goal)와 첫 단계(Micro-step)를 분리하여 좌측 상단의 우아한 Floating UI로 재배치하고, 체크 완료 시 사라지는 도파민 인터랙션 추가.
- space/tools: 흩어져 있던 노트, 사운드, 설정 도구들을 우측 레일(Right-Rail)로 통합하고 팝오버 디자인을 고급화함.
- ui/contrast: 밝은 배경에서도 텍스트가 잘 보이도록 좌측 상단 비네팅(Vignette) 및 다중 텍스트 그림자(Multi-layered Shadow) 효과 적용.
검증:
- npm run build 정상 통과 확인.
- 브라우저 상에서 micro-step 완료 애니메이션 및 도구막대 팝오버 슬라이드 동작 확인.
세션-상태: app 진입부터 space 몰입까지의 코어 UX/UI 하이엔드 개편 완료.
세션-다음: 프로 요금제(PRO) 전환 유도(Paywall) 흐름 및 상세 분석 리포트(Analytics) 뷰 구현.
세션-리스크: 없음.
---
docs/02_arch_fsd_rules.md | 4 +-
docs/08_app_reframe_strategy.md | 61 ++
src/entities/auth/index.ts | 1 +
.../auth/model}/useAuthStore.ts | 50 +-
src/entities/media/model/useMediaCatalog.ts | 20 +-
src/entities/plan/model/types.ts | 2 +-
src/entities/session/index.ts | 1 +
src/entities/session/model/focusSystem.ts | 306 ++++++++++
src/features/admin/api/adminApi.ts | 2 +-
.../auth/components/AuthRedirectButton.tsx | 22 +-
src/features/auth/hooks/useSocialLogin.ts | 2 +-
src/features/auth/types/index.ts | 28 +-
.../focus-session/api/focusSessionApi.ts | 175 ++++--
.../model/useFocusSessionEngine.ts | 20 +
src/features/plan-pill/ui/PlanPill.tsx | 4 +-
src/shared/i18n/messages/app.ts | 25 +-
src/shared/i18n/messages/core.ts | 61 +-
src/shared/i18n/messages/product.ts | 18 +-
src/shared/i18n/messages/space.ts | 45 +-
.../admin-console/ui/AdminConsoleWidget.tsx | 39 +-
.../admin-console/ui/AdminDashboardView.tsx | 482 +++++++++++++++
.../ui/FocusDashboardWidget.tsx | 574 +++++++++---------
.../space-focus-hud/ui/FloatingGoalWidget.tsx | 63 ++
.../space-focus-hud/ui/GoalCompleteSheet.tsx | 49 +-
.../ui/SpaceFocusHudWidget.tsx | 17 +-
.../ui/SpaceSetupDrawerWidget.tsx | 16 +-
.../ui/SpaceTimerHudWidget.tsx | 172 ++----
.../model/useSpaceToolsDockHandlers.ts | 14 +-
.../space-tools-dock/ui/FocusModeAnchors.tsx | 84 +--
.../space-tools-dock/ui/FocusRightRail.tsx | 146 ++++-
.../ui/popovers/QuickNotesPopover.tsx | 29 +-
.../ui/popovers/QuickSoundPopover.tsx | 95 +--
.../model/useSpaceWorkspaceSelection.ts | 8 +
.../model/useSpaceWorkspaceSessionControls.ts | 81 ++-
.../ui/SpaceWorkspaceWidget.tsx | 207 ++++---
.../stats-overview/ui/StatsOverviewWidget.tsx | 120 ++--
36 files changed, 2120 insertions(+), 923 deletions(-)
create mode 100644 docs/08_app_reframe_strategy.md
rename src/{store => entities/auth/model}/useAuthStore.ts (60%)
create mode 100644 src/entities/session/model/focusSystem.ts
create mode 100644 src/widgets/admin-console/ui/AdminDashboardView.tsx
create mode 100644 src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
diff --git a/docs/02_arch_fsd_rules.md b/docs/02_arch_fsd_rules.md
index b4830e8..f09349c 100644
--- a/docs/02_arch_fsd_rules.md
+++ b/docs/02_arch_fsd_rules.md
@@ -9,7 +9,6 @@ src/
features/ # 사용자 액션/유즈케이스 단위
entities/ # 도메인 타입/더미 데이터
shared/ # 공용 UI/유틸
- store/ # 전역 상태(필요 최소)
```
## 핵심 규칙
@@ -19,6 +18,7 @@ src/
3. UI 상태(토글/선택)만 컴포넌트 내부에서 최소 허용한다.
4. 파일 길이 500줄 이상이면 즉시 분리한다.
5. 하위 레이어가 상위 레이어를 import하지 않는다.
+6. 전역 상태가 필요하면 먼저 해당 도메인 slice의 `model/` 안에 둔다.
## Import 방향 규칙
@@ -33,12 +33,14 @@ src/
- `features` -> 다른 `features` 직접 참조 (강한 결합 유발)
- `shared` -> `entities/features/widgets/app` 참조
- `page.tsx`에 도메인 로직/세부 UI 구현 누적
+- 루트 전역 저장소를 관성적으로 추가하는 것
## 구현 정책 (이 프로젝트 전용)
- 실제 타이머/오디오/서버/DB 기능은 구현하지 않는다.
- 기능 트리거는 토스트 또는 더미 상태 전환으로 표현한다.
- 도메인 표시는 `entities` 데이터에서 읽고 뷰 하드코딩을 지양한다.
+- 인증/세션 같은 전역 상태도 가능하면 해당 도메인 `entities/*/model` 안에서 관리한다.
## 파일 분리 기준
diff --git a/docs/08_app_reframe_strategy.md b/docs/08_app_reframe_strategy.md
new file mode 100644
index 0000000..bbfafa6
--- /dev/null
+++ b/docs/08_app_reframe_strategy.md
@@ -0,0 +1,61 @@
+# VibeRoom `/app` 프로덕트 고도화 및 수익화 기획안
+
+## 1. 문제 정의: 왜 `/app`의 존재 의의가 애매해졌는가?
+현재 `/app` 화면은 단순히 '목표를 하나 입력하고 `/space`로 넘어가는 정거장' 역할만 수행하고 있습니다.
+사용자 입장에서는 "어차피 `/space`에서 타이머 돌리고 일할 건데, 굳이 `/app`이라는 별도 화면에서 목표 하나만 달랑 쳐야 하나?"라는 기능적 회의감이 들 수밖에 없습니다. 즉, **경험적 가치(UX Value) 없이 Depth만 하나 늘어난 상태**입니다.
+
+## 2. 타겟 인사이트: ADHD & 프리랜서의 심리
+이 서비스의 핵심 타겟은 **ADHD 성향을 가진 사람들과 혼자 일하는 프리랜서**입니다. 이들의 가장 큰 페인포인트는 다음과 같습니다.
+1. **시작의 두려움 (High Activation Energy):** '무엇을 해야 할지'는 알지만, 첫 단추를 꿰는 것을 극도로 미룹니다.
+2. **작업 기억 용량 초과 (RAM Overload):** 머릿속에 해야 할 일과 잡념이 뒤엉켜 있어 하나에 집중하지 못합니다.
+3. **도파민 갈구 (Dopamine Seeking):** 즉각적인 피드백이나 보상이 없으면 쉽게 지루해하고 이탈합니다.
+
+## 3. 핵심 전략: `/app`을 '단순 입력창'에서 **'몰입을 위한 의식(Ritual)의 공간'**으로 재정의
+최고의 서비스는 사용자에게 '행동'을 요구하기 전에 '감정'을 만져줍니다. `/app`은 투두리스트(To-do)를 적는 곳이 아닙니다. **복잡한 현실에서 벗어나, 고도의 집중 상태(`/space`)로 들어가기 전 마음을 다잡고 준비 운동을 하는 '감압실(Decompression Chamber)'**이 되어야 합니다.
+
+---
+
+## 4. `/app` 공간의 4가지 핵심 기능 기획
+
+### A. Brain Dump (뇌 비우기) & The One Thing
+ADHD 유저에게 "지금 할 목표 하나만 적으세요"라고 하면 오히려 압박감을 느낍니다.
+- **기능:** 머릿속에 맴도는 모든 잡념과 해야 할 일들을 일단 마구잡이로 쏟아내게 합니다(Brain Dump).
+- **UX:** 쏟아낸 여러 항목 중 **"지금 당장 딱 하나만 쥐고 `/space`로 들어갑시다. 나머지는 저희가 안전하게 보관해 둘게요"**라며 단 하나의 목표(The One Thing)를 선택하게 유도합니다.
+- **효과:** 머릿속 RAM을 비워줌으로써 인지적 과부하를 해결하고 안도감을 줍니다.
+
+### B. Micro-Stepping (초소형 첫 단계 쪼개기)
+"제안서 작성하기"라는 목표는 너무 거대해서 시작을 미루게 만듭니다.
+- **기능:** 유저가 큰 목표를 선택했을 때, "이 목표를 위해 지금 당장 할 수 있는 5분짜리 행동은 무엇인가요?"라고 묻습니다.
+- **UX:** "제안서 폴더 열기", "노션 새 페이지 만들기" 수준으로 목표를 극단적으로 잘게 쪼개도록 유도합니다.
+- **효과:** 시작의 허들(Activation Energy)을 바닥까지 낮춰줍니다.
+
+### C. Commitment Ritual (몰입 세팅과 선언)
+`/space`에 들어가서 환경을 바꾸는 것이 아니라, `/app`에서 오늘 나를 도와줄 환경을 선택하고 '입장'하는 것이 하나의 의식이 되어야 합니다.
+- **기능:** 목표를 정한 후, 오늘 나의 기분에 맞는 **Vibe(배경 씬 + 백색소음)**와 **Pace(뽀모도로 25분 / 딥워크 50분 등)**를 선택합니다.
+- **UX:** 세팅을 마치고 "집중 모드 진입하기" 버튼을 누를 때, 시각적/청각적인 전환 효과와 함께 비행기가 이륙하듯 `/space`로 부드럽게 빨려 들어가는 연출을 줍니다.
+
+### D. Momentum & Streak (과거의 작은 성공 시각화)
+- **기능:** `/app` 화면 한 켠에, 최근에 내가 해냈던 작은 몰입 세션들의 기록(잔디 심기, 자라나는 식물 등)을 시각적이고 감성적으로 보여줍니다.
+- **효과:** "어제도 해냈으니 오늘도 할 수 있다"는 자기 효능감을 심어주어 도파민을 자극합니다.
+
+---
+
+## 5. 비즈니스 모델(BM): 기꺼이 돈을 내게 만드는 수익화 전략
+
+유저가 제품에 깊이 몰입하고 나면, 다음과 같은 **PRO 플랜(구독 모델)**을 통해 수익화를 달성할 수 있습니다.
+
+1. **AI Micro-Stepping Coach (AI 목표 쪼개기 및 코칭)**
+ - **무료:** 유저가 직접 첫 단계를 고민해서 적어야 함.
+ - **PRO:** "기획서 쓰기"라고 치면, AI가 [1. 레퍼런스 3개 찾기, 2. 목차 5개 적기, 3. 서론 쓰기] 등으로 즉각 분해해주고 응원의 메시지를 건넴. (ADHD 유저에게 압도적인 가치 제공)
+2. **Premium Vibes (독점 씬 & 사운드스케이프)**
+ - **무료:** 기본 자연음(숲, 비투비) 2~3종 제공.
+ - **PRO:** 유명 아티스트가 작곡한 Lo-Fi 집중 비트, 세계 유명 도서관/카페의 3D 공간 음향, 초고화질 동적 배경(비 오는 도쿄 야경 등) 무제한 접근.
+3. **Deep Focus Analytics (심층 분석 리포트)**
+ - **무료:** 오늘 하루 총 집중 시간(단순 타이머 기록)만 확인 가능.
+ - **PRO:** 웹 서비스의 한계를 극복하기 위해 **세션 종료 후 유저가 직접 평가하는 '집중도 점수(1~5점)'와 Page Visibility API를 활용한 '탭 이탈(딴짓) 횟수 기록'을 결합**합니다. 이를 통해 단순히 켜둔 시간이 아니라 어떤 사운드/환경에서 가장 '질 높은' 집중을 했는지, 요일별 언제가 집중 Peak Time인지 분석하여 나만의 최적화된 루틴을 제안합니다.
+4. **Calendar & Notion Integration (업무 툴 연동)**
+ - **PRO:** 구글 캘린더의 일정이나 노션의 Task를 `/app`의 Brain Dump로 자동 불러오고, 집중 세션이 끝나면 소요 시간과 함께 자동으로 기록/동기화됨. (프리랜서들의 필수 니즈)
+ - **기술적 실현 가능성 (Feasibility Check):** 웹 서비스 환경에서도 **OAuth 2.0 기반의 Google Calendar API 및 Notion API**를 연동하여 완벽히 구현 가능합니다. 서버에서 유저의 접근 권한 토큰을 관리하여 웹 클라이언트와 외부 서비스 간의 양방향 데이터 동기화를 지원할 수 있습니다.
+
+## 요약 (Next Action)
+`/app`은 단순히 데이터를 넘기는 껍데기가 아니라, **유저의 마음을 달래고(Brain Dump), 허들을 낮춰주며(Micro-Stepping), 몰입을 세팅(Ritual)하는 심리적 안전기지**가 되어야 합니다. 이 경험이 탄탄해지면 유저는 VibeRoom을 단순한 타이머가 아닌 '나의 루틴 파트너'로 인식하게 되며, AI 코칭과 프리미엄 환경을 위해 기꺼이 지갑을 열게 될 것입니다.
\ No newline at end of file
diff --git a/src/entities/auth/index.ts b/src/entities/auth/index.ts
index 04103b8..41f7192 100644
--- a/src/entities/auth/index.ts
+++ b/src/entities/auth/index.ts
@@ -1 +1,2 @@
export * from './model/types';
+export * from './model/useAuthStore';
diff --git a/src/store/useAuthStore.ts b/src/entities/auth/model/useAuthStore.ts
similarity index 60%
rename from src/store/useAuthStore.ts
rename to src/entities/auth/model/useAuthStore.ts
index 95383ee..77b6161 100644
--- a/src/store/useAuthStore.ts
+++ b/src/entities/auth/model/useAuthStore.ts
@@ -1,21 +1,16 @@
-import { create } from 'zustand';
import Cookies from 'js-cookie';
-import type { AuthResponse } from '@/entities/auth';
+import { create } from 'zustand';
import { REFRESH_TOKEN_COOKIE_KEY, TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
+import type { AuthResponse } from './types';
interface AuthState {
accessToken: string | null;
user: AuthResponse['user'] | null;
isAuthenticated: boolean;
-
- // 액션
setAuth: (data: AuthResponse) => void;
logout: () => void;
}
-/**
- * VibeRoom 전역 인증(Auth) 상태 저장소
- */
export const useAuthStore = create((set) => {
const isClient = typeof window !== 'undefined';
const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null;
@@ -23,44 +18,39 @@ export const useAuthStore = create((set) => {
return {
accessToken: savedToken || null,
user: null,
- isAuthenticated: !!savedToken,
-
+ isAuthenticated: Boolean(savedToken),
setAuth: (data: AuthResponse) => {
- const cookieOptions = {
+ const cookieOptions = {
secure: process.env.NODE_ENV === 'production',
- sameSite: 'strict' as const
+ sameSite: 'strict' as const,
};
- // 1. Access Token 저장 (7일)
- Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, {
+ Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, {
...cookieOptions,
- expires: 7
+ expires: 7,
});
-
- // 2. Refresh Token 저장 (30일)
+
if (data.refreshToken) {
- Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
+ Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
...cookieOptions,
- expires: 30
+ expires: 30,
});
}
-
- // 3. 상태 업데이트
- set({
- accessToken: data.accessToken,
- user: data.user,
- isAuthenticated: true
+
+ set({
+ accessToken: data.accessToken,
+ user: data.user ?? null,
+ isAuthenticated: true,
});
},
-
logout: () => {
Cookies.remove(TOKEN_COOKIE_KEY);
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
-
- set({
- accessToken: null,
- user: null,
- isAuthenticated: false
+
+ set({
+ accessToken: null,
+ user: null,
+ isAuthenticated: false,
});
},
};
diff --git a/src/entities/media/model/useMediaCatalog.ts b/src/entities/media/model/useMediaCatalog.ts
index f79a9c4..a25a84a 100644
--- a/src/entities/media/model/useMediaCatalog.ts
+++ b/src/entities/media/model/useMediaCatalog.ts
@@ -73,12 +73,20 @@ export const useMediaCatalog = () => {
useEffect(() => {
const controller = new AbortController();
- void readMediaManifest(controller.signal).then((result) => {
- setManifest(result.manifest);
- setError(result.error);
- setUsedFallbackManifest(result.usedFallbackManifest);
- setHasResolvedManifest(true);
- });
+ readMediaManifest(controller.signal)
+ .then((result) => {
+ if (!controller.signal.aborted) {
+ setManifest(result.manifest);
+ setError(result.error);
+ setUsedFallbackManifest(result.usedFallbackManifest);
+ setHasResolvedManifest(true);
+ }
+ })
+ .catch((err) => {
+ if (err.name !== 'AbortError' && !controller.signal.aborted) {
+ console.error('Failed to load media manifest:', err);
+ }
+ });
return () => {
controller.abort();
diff --git a/src/entities/plan/model/types.ts b/src/entities/plan/model/types.ts
index 20fb172..a798f5c 100644
--- a/src/entities/plan/model/types.ts
+++ b/src/entities/plan/model/types.ts
@@ -6,7 +6,7 @@ export interface PlanLockedPack {
description: string;
}
-export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
+export type ProFeatureId = 'daily-plan' | 'rituals' | 'weekly-review';
export interface ProFeatureCard {
id: ProFeatureId;
diff --git a/src/entities/session/index.ts b/src/entities/session/index.ts
index 0db20f4..e2c6b82 100644
--- a/src/entities/session/index.ts
+++ b/src/entities/session/index.ts
@@ -1,3 +1,4 @@
export * from './model/mockSession';
+export * from './model/focusSystem';
export * from './model/types';
export * from './model/useThoughtInbox';
diff --git a/src/entities/session/model/focusSystem.ts b/src/entities/session/model/focusSystem.ts
new file mode 100644
index 0000000..6303e13
--- /dev/null
+++ b/src/entities/session/model/focusSystem.ts
@@ -0,0 +1,306 @@
+export interface FocusPlanItem {
+ id: string;
+ title: string;
+ goal: string;
+ blockLabel: string;
+ ritualLabel: string;
+ energyLabel: string;
+ successSignal: string;
+ sceneId: string;
+ soundPresetId: string;
+ timerLabel: string;
+ proOnly?: boolean;
+ locked?: boolean;
+}
+
+export interface SessionTemplate {
+ id: string;
+ name: string;
+ description: string;
+ goalPrompt: string;
+ cadenceLabel: string;
+ notificationTone: string;
+ sceneId: string;
+ soundPresetId: string;
+ timerLabel: string;
+ proOnly?: boolean;
+ locked?: boolean;
+}
+
+export interface SessionOutcome {
+ id: string;
+ title: string;
+ description: string;
+ nextSuggestion: string;
+}
+
+export interface WeeklyReviewMetric {
+ id: string;
+ label: string;
+ value: string;
+ delta: string;
+ locked?: boolean;
+}
+
+export interface WeeklyReviewInsight {
+ id: string;
+ label: string;
+ value: string;
+ description: string;
+ proOnly?: boolean;
+ locked?: boolean;
+}
+
+export interface WeeklyReview {
+ headline: string;
+ summary: string;
+ metrics: WeeklyReviewMetric[];
+ insights: WeeklyReviewInsight[];
+}
+
+export interface AsyncCheckIn {
+ id: string;
+ name: string;
+ role: string;
+ status: string;
+ message: string;
+ timeLabel: string;
+ reactionSummary: string;
+ proOnly?: boolean;
+ locked?: boolean;
+}
+
+const FOCUS_PLAN_ITEMS_BASE: FocusPlanItem[] = [
+ {
+ id: 'design-qa',
+ title: '디자인 QA 요청 3개 정리',
+ goal: '디자인 QA 요청 3개 우선순위 정리',
+ blockLabel: '15분 triage + 10분 결정',
+ ritualLabel: 'Soft Landing 25/5',
+ energyLabel: '낮은 진입 장벽',
+ successSignal: '버릴 것 1개, 오늘 처리 1개만 확정',
+ sceneId: 'quiet-library',
+ soundPresetId: 'deep-white',
+ timerLabel: '25/5',
+ },
+ {
+ id: 'proposal-intro',
+ title: '제안서 첫 문단 다듬기',
+ goal: '제안서 첫 문단 다듬기',
+ blockLabel: '30분 초안 + 20분 정리',
+ ritualLabel: 'Rain Draft 50/10',
+ energyLabel: '깊은 사고',
+ successSignal: '첫 문단을 소리 내어 읽었을 때 어색함이 없어질 것',
+ sceneId: 'rain-window',
+ soundPresetId: 'rain-focus',
+ timerLabel: '50/10',
+ proOnly: true,
+ },
+ {
+ id: 'ship-one-function',
+ title: '핵심 함수 1개 마무리',
+ goal: '핵심 함수 1개 마무리',
+ blockLabel: '40분 구현 + 10분 정리',
+ ritualLabel: 'Forest Ship 50/10',
+ energyLabel: '중간 몰입',
+ successSignal: '리뷰 전에 스스로 설명 가능한 상태 만들기',
+ sceneId: 'forest',
+ soundPresetId: 'forest-birds',
+ timerLabel: '50/10',
+ proOnly: true,
+ },
+];
+
+const SESSION_TEMPLATES_BASE: SessionTemplate[] = [
+ {
+ id: 'soft-landing',
+ name: 'Soft Landing',
+ description: '몸을 풀듯 천천히 진입하는 시작 ritual',
+ goalPrompt: '메일 3개 또는 작은 정리 1개',
+ cadenceLabel: '25/5 · 낮은 압박',
+ notificationTone: '조용함',
+ sceneId: 'sun-window',
+ soundPresetId: 'rain-focus',
+ timerLabel: '25/5',
+ },
+ {
+ id: 'rain-draft',
+ name: 'Rain Draft',
+ description: '문서, 글쓰기, 제안서 초안용 깊은 진입 ritual',
+ goalPrompt: '문단 1개 또는 초안 1개',
+ cadenceLabel: '50/10 · 깊은 몰입',
+ notificationTone: '기본',
+ sceneId: 'rain-window',
+ soundPresetId: 'rain-focus',
+ timerLabel: '50/10',
+ proOnly: true,
+ },
+ {
+ id: 'forest-ship',
+ name: 'Forest Ship',
+ description: '코드, QA, 실행 정리용 차분한 마감 ritual',
+ goalPrompt: '함수 1개 또는 리뷰 2개',
+ cadenceLabel: '50/10 · 차분한 추진',
+ notificationTone: '강함',
+ sceneId: 'forest',
+ soundPresetId: 'forest-birds',
+ timerLabel: '50/10',
+ proOnly: true,
+ },
+];
+
+export const SESSION_OUTCOMES: SessionOutcome[] = [
+ {
+ id: 'carry-forward',
+ title: '다음 한 조각으로 이어가기',
+ description: '세션이 끝난 뒤 바로 다음 블록을 붙여서 흐름을 지킵니다.',
+ nextSuggestion: '방금 한 작업의 다음 문단 1개',
+ },
+ {
+ id: 'promote-inbox',
+ title: '주차한 생각 끌어올리기',
+ description: 'distraction dump에 넣어둔 항목을 오늘 큐로 승격합니다.',
+ nextSuggestion: '인박스에서 가장 가벼운 메모 1개',
+ },
+ {
+ id: 'reset-with-smaller-goal',
+ title: '더 작게 다시 시작하기',
+ description: '무너진 날에는 범위를 절반으로 줄여 다시 시작합니다.',
+ nextSuggestion: '5분 안에 끝낼 수 있는 아주 작은 조각',
+ },
+];
+
+export const CALM_WEEKLY_REVIEW: WeeklyReview = {
+ headline: '이번 주는 짧은 진입 ritual이 시작 성공률을 끌어올렸어요.',
+ summary:
+ '길게 버티는 것보다, 짧게 시작하고 이어가는 패턴이 가장 안정적이었어요. 점심 직전에는 목표를 더 작게 쪼개는 편이 좋았습니다.',
+ metrics: [
+ { id: 'start-success', label: '시작 성공률', value: '78%', delta: '+12%' },
+ { id: 'completion-rate', label: '완료율', value: '64%', delta: '+9%' },
+ { id: 'recovery-rate', label: '중단 후 복귀율', value: '71%', delta: '+6%', locked: true },
+ { id: 'ritual-fit', label: 'ritual 적합도', value: 'Soft Landing', delta: '가장 안정적', locked: true },
+ ],
+ insights: [
+ {
+ id: 'fragile-window',
+ label: '흔들리는 시간대',
+ value: '11:30 - 12:30',
+ description: '점심 직전엔 25/5와 작은 목표가 더 잘 맞았어요.',
+ },
+ {
+ id: 'best-window',
+ label: '잘 풀리는 시간대',
+ value: '14:00 - 16:00',
+ description: '오후 초반엔 50/10 깊은 몰입 블록이 유지됐어요.',
+ },
+ {
+ id: 'best-ritual',
+ label: '가장 잘 맞는 ritual',
+ value: 'Soft Landing',
+ description: '짧은 준비와 낮은 압박이 시작 지연을 줄였습니다.',
+ proOnly: true,
+ },
+ {
+ id: 'recovery-cue',
+ label: '복귀 신호',
+ value: '문장 1개만 고치기',
+ description: '큰 목표보다 작은 복귀 문장이 재시작을 쉽게 만들었어요.',
+ proOnly: true,
+ },
+ ],
+};
+
+const ASYNC_CHECK_INS_BASE: AsyncCheckIn[] = [
+ {
+ id: 'buddy-mina',
+ name: 'Mina',
+ role: '브랜드 디자이너',
+ status: '오늘은 천천히',
+ message: '오후에는 QA만 정리하고 끝내려 해요.',
+ timeLabel: '12분 전',
+ reactionSummary: '👍 3 · 🫶 1',
+ },
+ {
+ id: 'buddy-jisoo',
+ name: 'Jisoo',
+ role: '프리랜서 라이터',
+ status: '25분만 달릴게요',
+ message: '첫 문단만 정리해두면 오늘은 충분해요.',
+ timeLabel: '31분 전',
+ reactionSummary: '👏 2 · 🔥 1',
+ },
+ {
+ id: 'digest-repeat-buddy',
+ name: '반복 파트너 digest',
+ role: 'PRO',
+ status: '이번 주 리듬 요약',
+ message: '같은 시간대에 서로 시작한 날이 4일이었어요.',
+ timeLabel: '금요일 오전',
+ reactionSummary: '주간 digest',
+ proOnly: true,
+ },
+];
+
+const markLocked = (items: T[], isPro: boolean) => {
+ return items.map((item) => ({
+ ...item,
+ locked: Boolean(!isPro && item.proOnly),
+ }));
+};
+
+export const getFocusPlanItems = (isPro: boolean) => {
+ return markLocked(FOCUS_PLAN_ITEMS_BASE, isPro);
+};
+
+export const getSessionTemplates = (isPro: boolean) => {
+ return markLocked(SESSION_TEMPLATES_BASE, isPro);
+};
+
+export const getWeeklyReview = (isPro: boolean): WeeklyReview => {
+ return {
+ ...CALM_WEEKLY_REVIEW,
+ metrics: CALM_WEEKLY_REVIEW.metrics.map((metric) => ({
+ ...metric,
+ locked: Boolean(!isPro && metric.locked),
+ })),
+ insights: markLocked(CALM_WEEKLY_REVIEW.insights, isPro),
+ };
+};
+
+export const getAsyncCheckIns = (isPro: boolean) => {
+ return markLocked(ASYNC_CHECK_INS_BASE, isPro);
+};
+
+export const buildSessionStartHref = (params: {
+ goal: string;
+ sceneId: string;
+ soundPresetId: string;
+ timerLabel: string;
+}) => {
+ const query = new URLSearchParams({
+ goal: params.goal,
+ scene: params.sceneId,
+ sound: params.soundPresetId,
+ timer: params.timerLabel,
+ });
+
+ return `/space?${query.toString()}`;
+};
+
+export const getFocusPlanStartHref = (item: FocusPlanItem) => {
+ return buildSessionStartHref({
+ goal: item.goal,
+ sceneId: item.sceneId,
+ soundPresetId: item.soundPresetId,
+ timerLabel: item.timerLabel,
+ });
+};
+
+export const getSessionTemplateStartHref = (template: SessionTemplate) => {
+ return buildSessionStartHref({
+ goal: template.goalPrompt,
+ sceneId: template.sceneId,
+ soundPresetId: template.soundPresetId,
+ timerLabel: template.timerLabel,
+ });
+};
diff --git a/src/features/admin/api/adminApi.ts b/src/features/admin/api/adminApi.ts
index 3f8ffd2..41a5c89 100644
--- a/src/features/admin/api/adminApi.ts
+++ b/src/features/admin/api/adminApi.ts
@@ -1,4 +1,4 @@
-import type { AuthResponse } from '@/features/auth/types';
+import type { AuthResponse } from '@/entities/auth';
import { copy } from '@/shared/i18n';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
diff --git a/src/features/auth/components/AuthRedirectButton.tsx b/src/features/auth/components/AuthRedirectButton.tsx
index 79def02..083ee0c 100644
--- a/src/features/auth/components/AuthRedirectButton.tsx
+++ b/src/features/auth/components/AuthRedirectButton.tsx
@@ -1,11 +1,11 @@
-"use client";
+'use client';
-import type { ReactNode } from "react";
-import Cookies from "js-cookie";
-import { useRouter } from "next/navigation";
-import { TOKEN_COOKIE_KEY } from "@/shared/config/authTokens";
-import { Button, type ButtonSize, type ButtonVariant } from "@/shared/ui/Button";
-import { useAuthStore } from "@/store/useAuthStore";
+import Cookies from 'js-cookie';
+import { useRouter } from 'next/navigation';
+import type { ReactNode } from 'react';
+import { useAuthStore } from '@/entities/auth';
+import { TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
+import { Button, type ButtonSize, type ButtonVariant } from '@/shared/ui/Button';
interface AuthRedirectButtonProps {
children: ReactNode;
@@ -19,10 +19,10 @@ interface AuthRedirectButtonProps {
export function AuthRedirectButton({
children,
className,
- size = "md",
- variant = "primary",
- authenticatedHref = "/space",
- unauthenticatedHref = "/login",
+ size = 'md',
+ variant = 'primary',
+ authenticatedHref = '/space',
+ unauthenticatedHref = '/login',
}: AuthRedirectButtonProps) {
const router = useRouter();
const accessToken = useAuthStore((state) => state.accessToken);
diff --git a/src/features/auth/hooks/useSocialLogin.ts b/src/features/auth/hooks/useSocialLogin.ts
index 5ac8465..d415929 100644
--- a/src/features/auth/hooks/useSocialLogin.ts
+++ b/src/features/auth/hooks/useSocialLogin.ts
@@ -2,8 +2,8 @@ import { useGoogleLogin } from '@react-oauth/google';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import appleAuthHelpers from 'react-apple-signin-auth';
+import { useAuthStore } from '@/entities/auth';
import { copy } from '@/shared/i18n';
-import { useAuthStore } from '@/store/useAuthStore';
import { authApi } from '../api/authApi';
interface AppleSignInResponse {
diff --git a/src/features/auth/types/index.ts b/src/features/auth/types/index.ts
index 6ff81b0..4a88bcc 100644
--- a/src/features/auth/types/index.ts
+++ b/src/features/auth/types/index.ts
@@ -1,22 +1,6 @@
-export interface SocialLoginRequest {
- provider: "google" | "apple" | "facebook";
- token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
-}
-
-export interface PasswordLoginRequest {
- email: string;
- password: string;
-}
-
-export interface AuthResponse {
- accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
- refreshToken: string; // 토큰 갱신용
- user?: UserMeResponse; // 선택적으로 유저 정보를 포함할 수 있음
-}
-
-export interface UserMeResponse {
- id: number;
- name: string;
- email: string;
- grade: string;
-}
+export type {
+ AuthResponse,
+ PasswordLoginRequest,
+ SocialLoginRequest,
+ UserMeResponse,
+} from '@/entities/auth';
diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts
index e5bb0a4..25c93fc 100644
--- a/src/features/focus-session/api/focusSessionApi.ts
+++ b/src/features/focus-session/api/focusSessionApi.ts
@@ -1,15 +1,44 @@
+import { normalizeFocusPlanToday, type FocusPlanToday } from '@/entities/focus-plan';
import { apiClient } from '@/shared/lib/apiClient';
export type FocusSessionPhase = 'focus' | 'break';
export type FocusSessionState = 'running' | 'paused';
export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete';
+interface RawFocusSession {
+ id: number | string;
+ sceneId: string;
+ goal: string;
+ timerPresetId: string;
+ soundPresetId: string | null;
+ focusPlanItemId?: number | null;
+ microStep?: string | null;
+ phase: FocusSessionPhase;
+ state: FocusSessionState;
+ phaseStartedAt: string;
+ phaseEndsAt: string | null;
+ phaseRemainingSeconds: number;
+ focusDurationSeconds: number;
+ breakDurationSeconds: number;
+ startedAt: string;
+ completedAt: string | null;
+ abandonedAt: string | null;
+ serverNow: string;
+}
+
+interface RawAdvanceCurrentGoalResponse {
+ nextSession: RawFocusSession;
+ updatedPlanToday: Parameters[0];
+}
+
export interface FocusSession {
id: string;
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId: string | null;
+ focusPlanItemId?: string | null;
+ microStep?: string | null;
phase: FocusSessionPhase;
state: FocusSessionState;
phaseStartedAt: string;
@@ -28,12 +57,16 @@ export interface StartFocusSessionRequest {
goal: string;
timerPresetId: string;
soundPresetId?: string | null;
+ focusPlanItemId?: string;
+ microStep?: string | null;
entryPoint?: 'space-setup' | 'goal-complete' | 'resume-restore';
}
export interface CompleteFocusSessionRequest {
completionType: FocusSessionCompletionType;
completedGoal?: string;
+ focusScore?: number;
+ distractionCount?: number;
}
export interface UpdateCurrentFocusSessionSelectionRequest {
@@ -41,108 +74,126 @@ export interface UpdateCurrentFocusSessionSelectionRequest {
soundPresetId?: string | null;
}
+export interface AdvanceCurrentGoalRequest {
+ completedGoal: string;
+ nextGoal: string;
+ sceneId: string;
+ timerPresetId: string;
+ soundPresetId?: string | null;
+ focusPlanItemId?: string;
+}
+
+export interface AdvanceCurrentGoalResponse {
+ nextSession: FocusSession;
+ updatedPlanToday: FocusPlanToday;
+}
+
+const toNumericId = (value: string | undefined) => {
+ if (!value) {
+ return undefined;
+ }
+
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+};
+
+const normalizeFocusSession = (session: RawFocusSession): FocusSession => {
+ return {
+ ...session,
+ id: String(session.id),
+ focusPlanItemId: session.focusPlanItemId == null ? null : String(session.focusPlanItemId),
+ };
+};
+
+const normalizeAdvanceGoalResponse = (
+ response: RawAdvanceCurrentGoalResponse,
+): AdvanceCurrentGoalResponse => {
+ return {
+ nextSession: normalizeFocusSession(response.nextSession),
+ updatedPlanToday: normalizeFocusPlanToday(response.updatedPlanToday),
+ };
+};
+
export const focusSessionApi = {
- /**
- * Backend Codex:
- * - 로그인한 사용자의 진행 중 세션을 0개 또는 1개 반환한다.
- * - 응답에는 serverNow, phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 포함해
- * 클라이언트가 남은 시간을 안정적으로 계산할 수 있게 한다.
- * - 진행 중 세션이 없으면 `data: null`을 반환한다.
- */
getCurrentSession: async (): Promise => {
- return apiClient('api/v1/focus-sessions/current', {
+ const response = await apiClient('api/v1/focus-sessions/current', {
method: 'GET',
});
+
+ return response ? normalizeFocusSession(response) : null;
},
- /**
- * Backend Codex:
- * - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
- * - sceneId, goal, timerPresetId, soundPresetId를 저장한다.
- * - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
- */
startSession: async (payload: StartFocusSessionRequest): Promise => {
- return apiClient('api/v1/focus-sessions', {
+ const response = await apiClient('api/v1/focus-sessions', {
method: 'POST',
- body: JSON.stringify(payload),
+ body: JSON.stringify({
+ ...payload,
+ focusPlanItemId: toNumericId(payload.focusPlanItemId),
+ }),
});
+
+ return normalizeFocusSession(response);
},
- /**
- * Backend Codex:
- * - 현재 세션의 현재 phase를 일시정지한다.
- * - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
- * - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다.
- * - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다.
- */
pauseSession: async (): Promise => {
- return apiClient('api/v1/focus-sessions/current/pause', {
+ const response = await apiClient('api/v1/focus-sessions/current/pause', {
method: 'POST',
});
+
+ return normalizeFocusSession(response);
},
- /**
- * Backend Codex:
- * - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다.
- * - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
- * - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다.
- * - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다.
- */
resumeSession: async (): Promise => {
- return apiClient('api/v1/focus-sessions/current/resume', {
+ const response = await apiClient('api/v1/focus-sessions/current/resume', {
method: 'POST',
});
+
+ return normalizeFocusSession(response);
},
- /**
- * Backend Codex:
- * - 현재 진행 중인 phase를 처음 길이로 다시 시작한다.
- * - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
- * - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다.
- * - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다.
- */
restartCurrentPhase: async (): Promise => {
- return apiClient('api/v1/focus-sessions/current/restart-phase', {
+ const response = await apiClient('api/v1/focus-sessions/current/restart-phase', {
method: 'POST',
});
+
+ return normalizeFocusSession(response);
},
- /**
- * Backend Codex:
- * - Space 우측 패널에서 scene/sound를 바꿨을 때 현재 활성 세션의 선택값을 patch 방식으로 갱신한다.
- * - sceneId, soundPresetId 중 일부만 보내도 된다.
- * - 응답은 갱신 후 최신 current session 스냅샷을 반환한다.
- */
updateCurrentSelection: async (
payload: UpdateCurrentFocusSessionSelectionRequest,
): Promise => {
- return apiClient('api/v1/focus-sessions/current/selection', {
+ const response = await apiClient('api/v1/focus-sessions/current/selection', {
method: 'PATCH',
body: JSON.stringify(payload),
});
+
+ return normalizeFocusSession(response);
},
- /**
- * Backend Codex:
- * - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
- * - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
- * - completionType으로 goal-complete / timer-complete을 구분해 저장한다.
- * - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다.
- */
completeSession: async (payload: CompleteFocusSessionRequest): Promise => {
- return apiClient('api/v1/focus-sessions/current/complete', {
+ const response = await apiClient('api/v1/focus-sessions/current/complete', {
method: 'POST',
body: JSON.stringify(payload),
});
+
+ return normalizeFocusSession(response);
+ },
+
+ advanceGoal: async (payload: AdvanceCurrentGoalRequest): Promise => {
+ const response = await apiClient(
+ 'api/v1/focus-sessions/current/advance-goal',
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ ...payload,
+ focusPlanItemId: toNumericId(payload.focusPlanItemId),
+ }),
+ },
+ );
+
+ return normalizeAdvanceGoalResponse(response);
},
- /**
- * Backend Codex:
- * - 현재 세션을 중도 종료(abandon) 처리한다.
- * - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
- * - 통계에서는 abandon 여부를 구분할 수 있게 저장한다.
- * - 성공 시 204 또는 빈 성공 응답을 반환한다.
- */
abandonSession: async (): Promise => {
return apiClient('api/v1/focus-sessions/current/abandon', {
method: 'POST',
diff --git a/src/features/focus-session/model/useFocusSessionEngine.ts b/src/features/focus-session/model/useFocusSessionEngine.ts
index 43b1eaf..856c85d 100644
--- a/src/features/focus-session/model/useFocusSessionEngine.ts
+++ b/src/features/focus-session/model/useFocusSessionEngine.ts
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import {
+ type AdvanceCurrentGoalRequest,
+ type AdvanceCurrentGoalResponse,
focusSessionApi,
type CompleteFocusSessionRequest,
type FocusSession,
@@ -72,6 +74,7 @@ interface UseFocusSessionEngineResult {
restartCurrentPhase: () => Promise;
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise;
completeSession: (payload: CompleteFocusSessionRequest) => Promise;
+ advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise;
abandonSession: () => Promise;
clearError: () => void;
}
@@ -260,6 +263,23 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
return session;
},
+ advanceGoal: async (payload) => {
+ if (!currentSession) {
+ return null;
+ }
+
+ const result = await runMutation(
+ () => focusSessionApi.advanceGoal(payload),
+ copy.focusSession.completeFailed,
+ );
+
+ if (!result) {
+ return null;
+ }
+
+ applySession(result.nextSession);
+ return result;
+ },
abandonSession: async () => {
if (!currentSession) {
return true;
diff --git a/src/features/plan-pill/ui/PlanPill.tsx b/src/features/plan-pill/ui/PlanPill.tsx
index 4599f48..34bda18 100644
--- a/src/features/plan-pill/ui/PlanPill.tsx
+++ b/src/features/plan-pill/ui/PlanPill.tsx
@@ -17,8 +17,8 @@ export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors',
isPro
- ? 'border-amber-200/46 bg-amber-200/14 text-amber-100 hover:bg-amber-200/24'
- : 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
+ ? 'border-amber-300/30 bg-amber-100/88 text-amber-950 hover:bg-amber-100'
+ : 'border-brand-dark/12 bg-white/88 text-brand-dark/72 hover:bg-white',
)}
>
{isPro ? copy.space.toolsDock.planPro : copy.space.toolsDock.planNormal}
diff --git a/src/shared/i18n/messages/app.ts b/src/shared/i18n/messages/app.ts
index d92a924..6e8ae6b 100644
--- a/src/shared/i18n/messages/app.ts
+++ b/src/shared/i18n/messages/app.ts
@@ -37,14 +37,13 @@ export const app = {
completedCycles: '완료한 사이클',
sessionEntries: '입장 횟수',
last7DaysFocus: '최근 7일 집중 시간',
- bestDay: '최고 몰입일',
- streak: '연속 달성',
+ startedSessions: '시작한 세션',
+ completedSessions: '완료한 세션',
+ carriedOverCount: '이월된 계획',
syncedApi: '동기화됨',
temporary: '임시값',
actualAggregate: '실집계',
mockAggregate: '목업',
- streakActive: '유지 중',
- streakStart: '시작 전',
countUnit: '회',
dayUnit: '일',
minuteUnit: '분',
@@ -53,19 +52,19 @@ export const app = {
plan: {
proFeatureCards: [
{
- id: 'scene-packs',
- name: 'Scene Packs',
- description: '프리미엄 공간 묶음과 장면 변주',
+ id: 'daily-plan',
+ name: 'Daily Focus Plan',
+ description: '오늘의 집중을 블록 단위로 쪼개고 큐를 운영합니다.',
},
{
- id: 'sound-packs',
- name: 'Sound Packs',
- description: '확장 사운드 프리셋 묶음',
+ id: 'rituals',
+ name: 'Rituals',
+ description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.',
},
{
- id: 'profiles',
- name: 'Profiles',
- description: '내 기본 세팅 저장/불러오기',
+ id: 'weekly-review',
+ name: 'Weekly Review',
+ description: '총 시간보다 시작 성공률과 복귀 패턴을 먼저 해석합니다.',
},
],
},
diff --git a/src/shared/i18n/messages/core.ts b/src/shared/i18n/messages/core.ts
index 540ccfb..202d46e 100644
--- a/src/shared/i18n/messages/core.ts
+++ b/src/shared/i18n/messages/core.ts
@@ -1,9 +1,9 @@
export const core = {
appName: 'VibeRoom',
metadata: {
- title: 'VibeRoom - 당신만의 편안한 몰입 공간',
+ title: 'VibeRoom - 조용한 집중을 위한 Calm Session OS',
description:
- '프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스. 작업 타이머, 세션 관리, 그리고 느슨한 연대를 통해 당신의 리듬을 찾아보세요.',
+ '프리랜서와 창작자를 위한 조용한 집중 운영체제. 오늘의 큐, ritual, 주간 리뷰를 통해 더 빨리 시작하고 더 잘 이어가세요.',
},
common: {
close: '닫기',
@@ -28,76 +28,77 @@ export const core = {
startFree: '무료로 시작하기',
},
hero: {
- titleLead: '함께하는 조용한 몰입,',
+ titleLead: '조용한 집중을 위한,',
titleAccent: 'VibeRoom',
description:
- '집중하기 어려운 순간, 당신을 다그치지 않는 편안한 공간으로 들어오세요. 구조화된 코워킹 세션과 느슨한 연대가 당신의 페이스를 되찾아 줍니다.',
- primaryCta: '나만의 공간 만들기',
+ '예쁜 공간보다 중요한 건 오늘 무엇을 할지 고르고, 무너진 뒤에도 다시 돌아오는 흐름입니다. 오늘의 집중 큐, ritual, 주간 리뷰를 한 화면에서 시작하세요.',
+ primaryCta: '오늘의 집중 열기',
secondaryCta: '더 알아보기',
timerPreview: '45:00 남음',
},
features: {
- title: '당신을 위한 다정한 몰입 장치',
- description: '단순한 타이머가 아닙니다. 무리하지 않고 오래 지속할 수 있는 환경을 제공합니다.',
+ title: '더 잘 이어가기 위한 집중 장치',
+ description: '타이머만 주는 대신, 시작과 복귀를 돕는 운영 흐름을 제공합니다.',
items: [
{
icon: '⏳',
- title: '구조화된 세션 타이머',
+ title: 'Daily Focus Plan',
description:
- '부담 없이 시작할 수 있는 짧은 몰입과 확실한 휴식. 당신만의 작업 리듬을 부드럽게 설정하고 관리하세요.',
+ '오늘 해야 할 일을 블록 단위로 쪼개고, 세션 시작 전에 이번 한 조각을 고르게 만듭니다.',
},
{
- icon: '🌱',
- title: '다정한 연대와 코워킹',
+ icon: '🪷',
+ title: 'Rituals / Templates',
description:
- '화면 너머 누군가와 함께하는 바디 더블링 효과. 감시가 아닌, 조용하지만 강력한 동기를 서로 나누어보세요.',
+ 'scene, sound, timer, 알림 강도를 반복 가능한 시작 방식으로 저장해 지치기 전에 바로 진입할 수 있습니다.',
},
{
- icon: '🛋️',
- title: '나만의 심미적 공간',
+ icon: '🛋️',
+ title: 'Weekly Review',
description:
- '비 오는 다락방, 햇살 드는 카페. 백색소음과 함께 내가 가장 편안함을 느끼는 가상 공간을 꾸미고 머무르세요.',
+ '총 집중 시간보다 시작 성공률, 완료율, 중단 후 복귀 패턴을 먼저 보여줘 다음 주의 리듬을 정리합니다.',
},
],
},
pricing: {
- title: '나에게 맞는 공간 선택하기',
- description: '개인의 가벼운 집중부터 프리랜서의 완벽한 워크스페이스까지.',
+ title: '집중을 이어가는 방식에 맞는 플랜',
+ description: 'Free는 기본 시작을, Pro는 Calm Session OS 전체 흐름을 제공합니다.',
plans: {
starter: {
- name: 'Starter',
- subtitle: '가벼운 집중이 필요한 분',
+ name: 'Free',
+ subtitle: '부담 없이 바로 시작하고 싶은 분',
price: '무료',
cta: '무료로 시작하기',
- features: ['기본 가상 공간 테마', '1:1 파트너 매칭 (주 3회)', '오픈 코워킹 룸 입장'],
+ features: ['기본 scene / sound / timer', '오늘의 집중 큐 1개', '저장 ritual 1개', '최근 7일 기본 통계'],
},
pro: {
badge: '추천',
name: 'Pro',
- subtitle: '방해 없는 완벽한 몰입 환경',
+ subtitle: '더 빨리 시작하고 더 잘 이어가고 싶은 분',
price: '₩6,900',
priceSuffix: '/월',
cta: 'Pro 시작하기',
features: [
- '프리미엄 테마 무제한',
- '1:1 매칭 무제한',
- '고급 집중 통계 및 리포트',
- '공간 커스텀 아이템 제공',
+ '다중 focus queue',
+ 'ritual/template 무제한',
+ '주간 review + 고급 session analytics',
+ 'premium scene / sound packs',
+ '비동기 accountability 기능',
],
},
teams: {
name: 'Teams',
- subtitle: '리모트 워크 기업 및 팀',
- price: '₩12,000',
- priceSuffix: '/인·월',
+ subtitle: '소규모 크리에이티브 팀용 준비 중',
+ price: '준비 중',
+ priceSuffix: '',
cta: '도입 문의하기',
- features: ['Pro 플랜의 모든 기능', '프라이빗 팀 스페이스', '팀 전체 생산성 대시보드'],
+ features: ['Pro 기반 팀 운영 설계', '반복 check-in 운영 지원', '팀 기능은 순차 출시 예정'],
},
},
},
footer: {
description:
- '프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스입니다.',
+ '프리랜서와 창작자를 위한 조용한 집중 운영체제입니다. 오늘의 큐, ritual, 주간 리뷰로 더 잘 이어가세요.',
productTitle: '제품',
companyTitle: '회사',
links: {
diff --git a/src/shared/i18n/messages/product.ts b/src/shared/i18n/messages/product.ts
index 054aecc..ed44858 100644
--- a/src/shared/i18n/messages/product.ts
+++ b/src/shared/i18n/messages/product.ts
@@ -54,19 +54,19 @@ export const stats = {
export const plan = {
proFeatureCards: [
{
- id: 'scene-packs',
- name: 'Scene Packs',
- description: '프리미엄 공간 묶음과 장면 변주',
+ id: 'daily-plan',
+ name: 'Daily Focus Plan',
+ description: '오늘의 집중을 블록 단위로 쪼개고 큐를 운영합니다.',
},
{
- id: 'sound-packs',
- name: 'Sound Packs',
- description: '확장 사운드 프리셋 묶음',
+ id: 'rituals',
+ name: 'Rituals',
+ description: 'scene + sound + timer 조합을 반복 가능한 시작 방식으로 저장합니다.',
},
{
- id: 'profiles',
- name: 'Profiles',
- description: '내 기본 세팅 저장/불러오기',
+ id: 'weekly-review',
+ name: 'Weekly Review',
+ description: '총 시간보다 시작 성공률과 복귀 패턴을 먼저 해석합니다.',
},
],
} as const;
diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts
index c04b75a..8a94ba5 100644
--- a/src/shared/i18n/messages/space.ts
+++ b/src/shared/i18n/messages/space.ts
@@ -7,9 +7,9 @@ export const space = {
},
setup: {
panelAriaLabel: '집중 시작 패널',
- eyebrow: 'Ritual',
- title: '이번 한 조각을 정하고 시작해요.',
- description: '목표를 정한 뒤 HUD의 시작 버튼으로 실제 세션을 시작해요.',
+ eyebrow: 'Execution Setup',
+ title: '이번 세션만 가볍게 맞추고 들어가요.',
+ description: '목표, 배경, 타이머, 사운드만 정하고 바로 실행 화면으로 들어갑니다.',
resumeTitle: '지난 한 조각 이어서',
startFresh: '새로 시작',
resumePrepare: '이어서 준비',
@@ -17,7 +17,7 @@ export const space = {
timerLabel: '타이머',
soundLabel: '사운드',
readyHint: '목표를 적으면 시작할 수 있어요.',
- openFocusScreen: '집중 화면 열기',
+ openFocusScreen: '실행 화면 열기',
},
timerHud: {
actions: [
@@ -45,29 +45,30 @@ export const space = {
description: '너무 크게 잡지 말고, 바로 다음 한 조각만.',
closeAriaLabel: '닫기',
restButton: '잠깐 쉬기',
- confirmButton: '바로 다음 조각 시작',
+ confirmButton: '다음 목표로 바로 시작',
+ confirmPending: '시작 중…',
},
controlCenter: {
sectionTitles: {
background: 'Background',
time: 'Time',
sound: 'Sound',
- packs: 'Packs',
+ packs: 'Session OS',
},
- packsDescription: '확장/개인화',
+ packsDescription: 'plan · ritual · review',
recommendation: (soundLabel: string, timerLabel: string) => `추천: ${soundLabel} · ${timerLabel}`,
recommendationHint: '추천 조합은 참고 정보로만 제공돼요.',
autoHideTitle: '컨트롤 자동 숨김',
autoHideDescription: '입력이 없으면 잠시 후 패널을 닫아요.',
autoHideAriaLabel: '컨트롤 자동 숨김',
- sideSheetSubtitle: '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.',
+ sideSheetSubtitle: '배경 · 타이머 · 사운드와 Session OS 진입점을 그 자리에서 바꿔요.',
quickControlsTitle: 'Quick Controls',
},
toolsDock: {
notesButton: 'Notes',
popoverCloseAria: '팝오버 닫기',
planPro: 'PRO',
- planNormal: 'Normal',
+ planNormal: 'FREE',
inboxSaved: '인박스에 저장됨',
undo: '실행취소',
inboxSaveUndone: '저장 취소됨',
@@ -76,21 +77,21 @@ export const space = {
emptyToClear: '비울 항목이 없어요.',
clearedAll: '모두 비워짐',
restored: '복원했어요.',
- normalPlanInfo: 'NORMAL 플랜 사용 중 · 잠금 항목에서만 업그레이드할 수 있어요.',
- proFeatureLocked: (source: string) => `${source}은(는) PRO 기능이에요.`,
+ normalPlanInfo: 'FREE 플랜 사용 중 · Calm Session OS PRO에서 다중 큐와 주간 리뷰가 열려요.',
+ proFeatureLocked: (source: string) => `${source}은(는) Calm Session OS PRO 기능이에요.`,
proFeaturePending: (label: string) => `${label} 준비 중(더미)`,
- purchaseMock: '결제(더미)',
+ purchaseMock: 'PRO 전환됨',
manageSubscriptionMock: '구독 관리(더미)',
restorePurchaseMock: '구매 복원(더미)',
featureLabels: {
- scenePacks: 'Scene Packs',
- soundPacks: 'Sound Packs',
- profiles: 'Profiles',
+ dailyPlan: 'Daily Focus Plan',
+ rituals: 'Rituals',
+ weeklyReview: 'Weekly Review',
},
utilityPanelTitle: {
'control-center': 'Quick Controls',
inbox: '인박스',
- paywall: 'PRO',
+ paywall: 'Calm Session OS PRO',
'manage-plan': '플랜 관리',
},
},
@@ -141,13 +142,13 @@ export const space = {
openQuickControlsTitle: 'Quick Controls',
},
paywall: {
- points: ['프리미엄 Scene Packs', '확장 Sound Packs', '프로필 저장 / 불러오기'],
- title: 'PRO에서 더 많은 공간과 사운드를 열어둘 수 있어요.',
- description: '잠금 항목을 누른 순간에만 열리는 더미 결제 시트입니다.',
+ points: ['오늘의 집중 큐를 여러 개 운영', 'ritual/template를 무제한 저장', '주간 리뷰와 조용한 accountability 열기'],
+ title: 'PRO는 더 많이 꾸미는 플랜이 아니라, 더 잘 이어가는 플랜이에요.',
+ description: 'Daily plan, rituals, weekly review를 한 흐름으로 여는 더미 결제 시트입니다.',
later: '나중에',
startPro: 'PRO 시작하기',
- manageTitle: 'PRO 관리',
- manageDescription: '결제/복원은 더미 동작이며 실제 연동은 하지 않아요.',
+ manageTitle: 'Calm Session OS PRO 관리',
+ manageDescription: '결제/복원은 더미 동작이지만 유료 가치는 plan · ritual · review 흐름에 맞춰 정리했습니다.',
openSubscription: '구독 관리 열기',
restorePurchase: '구매 복원',
},
@@ -173,7 +174,7 @@ export const space = {
restartFailed: '현재 페이즈를 다시 시작하지 못했어요.',
restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
- nextGoalReady: '다음 한 조각 준비 완료 · 시작 버튼을 눌러 이어가요.',
+ nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
},
diff --git a/src/widgets/admin-console/ui/AdminConsoleWidget.tsx b/src/widgets/admin-console/ui/AdminConsoleWidget.tsx
index 22a86b4..4e05c41 100644
--- a/src/widgets/admin-console/ui/AdminConsoleWidget.tsx
+++ b/src/widgets/admin-console/ui/AdminConsoleWidget.tsx
@@ -1,18 +1,33 @@
'use client';
import { useAdminConsole } from '../model/useAdminConsole';
+import { AdminDashboardView } from './AdminDashboardView';
import { AdminLoginView } from './AdminLoginView';
export const AdminConsoleWidget = () => {
const {
session,
+ activeView,
+ setActiveView,
+ activeMeta,
+ isDurationOverrideEnabled,
+ setIsDurationOverrideEnabled,
loginId,
password,
loginError,
loginPending,
+ scenePending,
+ soundPending,
+ currentMessage,
+ uploadResult,
+ resultSummary,
+ lastExtractedDurationSec,
setLoginId,
setPassword,
handleLogin,
+ handleLogout,
+ handleSceneUpload,
+ handleSoundUpload,
} = useAdminConsole();
if (!session) {
@@ -30,12 +45,22 @@ export const AdminConsoleWidget = () => {
}
return (
-
-
-
Admin Dashboard
-
Welcome, {session.user?.name}!
-
Dashboard is under construction.
-
-
+
);
};
diff --git a/src/widgets/admin-console/ui/AdminDashboardView.tsx b/src/widgets/admin-console/ui/AdminDashboardView.tsx
new file mode 100644
index 0000000..c6256bc
--- /dev/null
+++ b/src/widgets/admin-console/ui/AdminDashboardView.tsx
@@ -0,0 +1,482 @@
+import type { FormEventHandler } from 'react';
+import type { AuthResponse } from '@/entities/auth';
+import { copy } from '@/shared/i18n';
+import { Button } from '@/shared/ui';
+import type { AdminView, UploadResult } from '../model/types';
+import { fieldClassName, fileClassName, navItems, textareaClassName } from './constants';
+
+interface AdminDashboardViewProps {
+ session: AuthResponse;
+ activeView: AdminView;
+ onActiveViewChange: (view: AdminView) => void;
+ activeMeta: typeof copy.admin.views.scene | typeof copy.admin.views.sound;
+ isDurationOverrideEnabled: boolean;
+ onDurationOverrideChange: (next: boolean) => void;
+ currentMessage: string | null;
+ uploadResult: UploadResult | null;
+ resultSummary: string;
+ lastExtractedDurationSec: number | null;
+ scenePending: boolean;
+ soundPending: boolean;
+ onLogout: () => void;
+ onSceneSubmit: FormEventHandler;
+ onSoundSubmit: FormEventHandler;
+}
+
+const MessagePanel = ({ message }: { message: string | null }) => {
+ if (!message) {
+ return null;
+ }
+
+ return (
+
+ {message}
+
+ );
+};
+
+const AdminSceneForm = ({
+ currentMessage,
+ scenePending,
+ onSubmit,
+}: {
+ currentMessage: string | null;
+ scenePending: boolean;
+ onSubmit: FormEventHandler;
+}) => {
+ return (
+
+ );
+};
+
+const AdminSoundForm = ({
+ currentMessage,
+ isDurationOverrideEnabled,
+ lastExtractedDurationSec,
+ onDurationOverrideChange,
+ onSubmit,
+ soundPending,
+}: {
+ currentMessage: string | null;
+ isDurationOverrideEnabled: boolean;
+ lastExtractedDurationSec: number | null;
+ onDurationOverrideChange: (next: boolean) => void;
+ onSubmit: FormEventHandler;
+ soundPending: boolean;
+}) => {
+ return (
+
+ );
+};
+
+export const AdminDashboardView = ({
+ session,
+ activeView,
+ onActiveViewChange,
+ activeMeta,
+ isDurationOverrideEnabled,
+ onDurationOverrideChange,
+ currentMessage,
+ uploadResult,
+ resultSummary,
+ lastExtractedDurationSec,
+ scenePending,
+ soundPending,
+ onLogout,
+ onSceneSubmit,
+ onSoundSubmit,
+}: AdminDashboardViewProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {copy.admin.manifestReady}
+
+
+
+ A
+
+
+
{session.user?.name ?? copy.common.admin}
+
{session.user?.email}
+
+
+
+
+
+
+
+
+
+
+
+
+ {activeMeta.eyebrow}
+
+
+ {activeMeta.title}
+
+
{activeMeta.description}
+
+
+
{activeMeta.statTitle}
+
{activeMeta.statValue}
+
{activeMeta.statHint}
+
+
+
{copy.admin.inspector.currentRoleTitle}
+
+ {session.user?.grade ?? 'ADMIN'}
+
+
{copy.admin.inspector.bearerTokenSession}
+
+
+
+ {activeView === 'scene' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
index 87cdf3e..d657f69 100644
--- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
+++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
@@ -1,17 +1,21 @@
'use client';
import Link from 'next/link';
+import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
- buildFocusEntryStartHref,
type FocusPlanItem,
type FocusPlanToday,
useFocusPlan,
} from '@/entities/focus-plan';
import { usePlanTier } from '@/entities/plan';
+import { SCENE_THEMES, getSceneById } from '@/entities/scene';
+import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
+import { SOUND_PRESETS } from '@/entities/session';
import { PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill';
import { useFocusStats } from '@/features/stats';
+import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
@@ -20,24 +24,30 @@ const FREE_MAX_ITEMS = 1;
const PRO_MAX_ITEMS = 5;
const focusEntryCopy = {
- eyebrow: 'Focus Entry',
- title: '지금 시작할 첫 블록',
- description: '한 줄로 정하고 바로 들어가요.',
+ eyebrow: 'VibeRoom',
+ title: '오늘의 깊은 몰입을 위한 단 하나의 목표',
+ description: '지금 당장 시작할 딱 하나만 남겨두세요.',
inputLabel: '첫 블록',
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
helper: '아주 작게 잡아도 괜찮아요.',
- startNow: '지금 시작',
- manageBlocks: '블록 정리',
+ startNow: '바로 몰입하기',
+ nextStep: '환경 세팅',
+ manageBlocks: '내 계획에서 가져오기',
previewTitle: '이어갈 블록',
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
reviewLinkLabel: 'stats',
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
- ritualMeta: '기본 ritual로 들어가요. 배경과 타이머는 /space에서 이어서 바꿀 수 있어요.',
+ ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
freeUpgradeLabel: '두 번째 블록부터는 PRO',
paywallSource: 'focus-entry-manage-sheet',
paywallLead: 'Calm Session OS PRO',
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
+ microStepTitle: '가장 작은 첫 단계 (선택)',
+ microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
+ microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
+ ritualTitle: '어떤 환경에서 몰입하시겠어요?',
+ ritualHelper: '오늘의 무드를 선택하세요.',
};
const ENTRY_SUGGESTIONS = [
@@ -47,6 +57,7 @@ const ENTRY_SUGGESTIONS = [
] as const;
type EntrySource = 'starter' | 'plan' | 'custom';
+type DashboardStep = 'goal' | 'ritual';
const getVisiblePlanItems = (
currentItem: FocusPlanItem | null,
@@ -58,76 +69,71 @@ const getVisiblePlanItems = (
.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);
};
+// Premium Glassmorphism UI Classes
+const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10';
+const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
+const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
+const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl';
+const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer';
+const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]';
+
export const FocusDashboardWidget = () => {
+ const router = useRouter();
const { plan: planTier, isPro, setPlan } = usePlanTier();
const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan();
- const { summary } = useFocusStats();
+ const { sceneAssetMap } = useMediaCatalog();
+
+ const [step, setStep] = useState('goal');
const [paywallSource, setPaywallSource] = useState(null);
const [manageSheetOpen, setManageSheetOpen] = useState(false);
const [editingState, setEditingState] = useState(null);
+
const [entryDraft, setEntryDraft] = useState('');
const [selectedPlanItemId, setSelectedPlanItemId] = useState(null);
const [entrySource, setEntrySource] = useState('starter');
+
+ const [microStepDraft, setMicroStepDraft] = useState('');
+ // Use user's last preference or default to first
+ const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id);
+ const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
+ const [selectedTimerId, setSelectedTimerId] = useState('50-10');
+
+ const [isStartingSession, setIsStartingSession] = useState(false);
+
const entryInputRef = useRef(null);
+ const microStepInputRef = useRef(null);
const inputRef = useRef(null);
+ const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
+
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;
+ const isGoalReady = trimmedEntryGoal.length > 0;
useEffect(() => {
- if (!editingState) {
- return;
- }
-
+ if (!editingState) return;
const rafId = window.requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
-
- return () => {
- window.cancelAnimationFrame(rafId);
- };
+ return () => window.cancelAnimationFrame(rafId);
}, [editingState]);
useEffect(() => {
- if (!currentItem) {
- return;
- }
-
+ if (!currentItem) return;
if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) {
setEntryDraft(currentItem.title);
setSelectedPlanItemId(currentItem.id);
@@ -135,34 +141,10 @@ export const FocusDashboardWidget = () => {
}
}, [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 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');
@@ -182,111 +164,58 @@ export const FocusDashboardWidget = () => {
};
const handleAddBlock = () => {
- if (hasPendingEdit || isSaving || !canManagePlan) {
- return;
- }
-
+ if (hasPendingEdit || isSaving || !canManagePlan) return;
if (!canAddMore) {
- if (!isPro) {
- openPaywall();
- }
+ if (!isPro) openPaywall();
return;
}
-
- setEditingState({
- mode: 'new',
- value: '',
- });
+ setEditingState({ mode: 'new', value: '' });
};
const handleEditRow = (item: FocusPlanItem) => {
- if (hasPendingEdit || isSaving) {
- return;
- }
-
- setEditingState({
- mode: 'edit',
- itemId: item.id,
- value: item.title,
- });
+ 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,
- };
- });
+ setEditingState((current) => current ? { ...current, value } : current);
};
const handleCancelEdit = () => {
- if (isSaving) {
- return;
- }
-
- setEditingState(null);
+ if (!isSaving) setEditingState(null);
};
const handleSaveEdit = async () => {
- if (!editingState) {
- return;
- }
-
+ if (!editingState) return;
const trimmedTitle = editingState.value.trim();
-
- if (!trimmedTitle) {
- return;
- }
+ if (!trimmedTitle) return;
if (editingState.mode === 'new') {
const nextPlan = await createItem({ title: trimmedTitle });
-
- if (!nextPlan) {
- return;
- }
-
+ 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) return;
if (currentRow.title === trimmedTitle) {
setEditingState(null);
return;
}
- const nextPlan = await updateItem(editingState.itemId, {
- title: trimmedTitle,
- });
-
- if (!nextPlan) {
- return;
- }
-
+ const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
+ if (!nextPlan) return;
setEditingState(null);
-
if (selectedPlanItemId === editingState.itemId) {
setEntryDraft(trimmedTitle);
setEntrySource('plan');
@@ -295,109 +224,144 @@ export const FocusDashboardWidget = () => {
const handleDeleteRow = async (itemId: string) => {
const nextPlan = await deleteItem(itemId);
-
- if (!nextPlan) {
- return;
- }
-
+ 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');
}
};
+ const handleNextStep = () => {
+ if (!isGoalReady) {
+ entryInputRef.current?.focus();
+ return;
+ }
+ if (step === 'goal') setStep('ritual');
+ };
+
+ const handleStartSession = async () => {
+ if (isStartingSession) return;
+ setIsStartingSession(true);
+
+ try {
+ await focusSessionApi.startSession({
+ goal: trimmedEntryGoal,
+ microStep: microStepDraft.trim() || null,
+ sceneId: selectedSceneId,
+ soundPresetId: selectedSoundId,
+ timerPresetId: selectedTimerId,
+ focusPlanItemId: selectedPlanItemId || undefined,
+ entryPoint: 'space-setup'
+ });
+ router.push('/space');
+ } catch (err) {
+ console.error('Failed to start session', err);
+ setIsStartingSession(false);
+ }
+ };
+
return (
- <>
-
-
-
+
+ {/* Premium Cinematic Background */}
+
+ {/* Global Gradient Overlay for text readability */}
+
-
-
-
-
-
{focusEntryCopy.title}
-
{focusEntryCopy.helper}
-
+ {/* Header */}
+
-
-
+ {/* Main Content Area */}
+
+
+ {/* Step 1: Goal Setup */}
+
+
+
+ {focusEntryCopy.title}
+
+
+
+ handleEntryDraftChange(event.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
+ placeholder={focusEntryCopy.inputPlaceholder}
+ className={glassInputClass}
+ autoFocus
+ />
- {startHref ? (
-
- {focusEntryCopy.startNow}
-
- ) : (
-
- )}
-
+
+
+
+
-
+ {/* Suggestions / Manage - very minimal */}
+
+
{ENTRY_SUGGESTIONS.map((suggestion) => {
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
-
return (
-
-
-
{focusEntryCopy.ritualMeta}
-
-
-
- {previewItems.length > 0 ? (
-
-
-
{focusEntryCopy.previewTitle}
-
- {focusEntryCopy.previewDescription}
-
-
-
- {previewItems.map((item) => {
- const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title;
-
- return (
-
- );
- })}
-
-
- ) : null}
-
- {source === 'unavailable' && !isLoading ? (
-
- {focusEntryCopy.apiUnavailableNote}
-
- ) : null}
+
-
+
+
+
-
-
- {isLoading ? focusEntryCopy.reviewFallback : reviewLine}
-
-
+
+
+
+
Today's Focus
+
{trimmedEntryGoal}
+
+
- {error && source === 'api' ?
{error}
: null}
-
-
-
+
+
+ {/* Microstep */}
+
+
+
{focusEntryCopy.microStepHelper}
+
+ {/* Timer */}
+
+
몰입 리듬
+
+ {[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
+
+ ))}
+
+
+
+
+
+ {/* Scene */}
+
+
배경 공간
+
+ {SCENE_THEMES.map(scene => (
+
+ ))}
+
+
+
+ {/* Sound */}
+
+
사운드
+
+ {SOUND_PRESETS.map(sound => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Plan Sheet & Paywall */}
{
) : null}
- >
+
);
-};
+};
\ No newline at end of file
diff --git a/src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx b/src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
new file mode 100644
index 0000000..3be1e74
--- /dev/null
+++ b/src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useState } from 'react';
+import { cn } from '@/shared/lib/cn';
+import { copy } from '@/shared/i18n';
+
+interface FloatingGoalWidgetProps {
+ goal: string;
+ microStep?: string | null;
+ onGoalCompleteRequest?: () => void;
+ hasActiveSession?: boolean;
+ sessionPhase?: 'focus' | 'break' | null;
+}
+
+export const FloatingGoalWidget = ({
+ goal,
+ microStep,
+ onGoalCompleteRequest,
+ hasActiveSession,
+ sessionPhase,
+}: FloatingGoalWidgetProps) => {
+ const [isMicroStepCompleted, setIsMicroStepCompleted] = useState(false);
+ const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
+
+ return (
+
+
+ {/* Main Goal */}
+
+
+ {normalizedGoal}
+
+ {hasActiveSession && sessionPhase === 'focus' ? (
+
+ ) : null}
+
+
+ {/* Micro Step */}
+ {microStep && !isMicroStepCompleted && (
+
+
+
+ {microStep}
+
+
+ )}
+
+
+ );
+};
diff --git a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx
index 4ad7356..0b1d15d 100644
--- a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx
+++ b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx
@@ -8,13 +8,11 @@ import { cn } from '@/shared/lib/cn';
interface GoalCompleteSheetProps {
open: boolean;
currentGoal: string;
- onConfirm: (nextGoal: string) => void;
+ onConfirm: (nextGoal: string) => Promise | boolean;
onRest: () => void;
onClose: () => void;
}
-const GOAL_SUGGESTIONS = copy.space.goalComplete.suggestions;
-
export const GoalCompleteSheet = ({
open,
currentGoal,
@@ -24,6 +22,7 @@ export const GoalCompleteSheet = ({
}: GoalCompleteSheetProps) => {
const inputRef = useRef(null);
const [draft, setDraft] = useState('');
+ const [isSubmitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) {
@@ -56,14 +55,24 @@ export const GoalCompleteSheet = ({
}, [currentGoal]);
const canConfirm = draft.trim().length > 0;
- const handleSubmit = (event: FormEvent) => {
+ const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
- if (!canConfirm) {
+ if (!canConfirm || isSubmitting) {
return;
}
- onConfirm(draft.trim());
+ setSubmitting(true);
+
+ try {
+ const didAdvance = await onConfirm(draft.trim());
+
+ if (didAdvance) {
+ onClose();
+ }
+ } finally {
+ setSubmitting(false);
+ }
};
return (
@@ -99,34 +108,20 @@ export const GoalCompleteSheet = ({
placeholder={placeholder}
className="h-9 w-full rounded-xl border border-white/14 bg-white/[0.04] px-3 text-sm text-white placeholder:text-white/40 focus:border-sky-200/42 focus:outline-none"
/>
-
-
- {GOAL_SUGGESTIONS.map((suggestion) => (
-
- ))}
-
-
diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx
index c369b93..4d7d8e2 100644
--- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx
+++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx
@@ -2,10 +2,12 @@ import { useEffect, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
+import { FloatingGoalWidget } from './FloatingGoalWidget';
import { GoalCompleteSheet } from './GoalCompleteSheet';
interface SpaceFocusHudWidgetProps {
goal: string;
+ microStep?: string | null;
timerLabel: string;
timeDisplay?: string;
visible: boolean;
@@ -19,12 +21,13 @@ interface SpaceFocusHudWidgetProps {
onStartRequested?: () => void;
onPauseRequested?: () => void;
onRestartRequested?: () => void;
- onGoalUpdate: (nextGoal: string) => void | Promise;
+ onGoalUpdate: (nextGoal: string) => boolean | Promise;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
export const SpaceFocusHudWidget = ({
goal,
+ microStep,
timerLabel,
timeDisplay,
visible,
@@ -86,9 +89,15 @@ export const SpaceFocusHudWidget = ({
return (
<>
+
{
- void onGoalUpdate(nextGoal);
- setSheetOpen(false);
+ return Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
>
diff --git a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx
index e550484..2c7d356 100644
--- a/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx
+++ b/src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx
@@ -3,14 +3,18 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { SceneAssetMap } from '@/entities/media';
import type { SceneTheme } from '@/entities/scene';
-import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
+import type {
+ GoalChip,
+ SoundPreset,
+ TimerPreset,
+} from '@/entities/session';
import { copy } from '@/shared/i18n';
import { SceneSelectCarousel } from '@/features/scene-select';
import { SessionGoalField } from '@/features/session-goal';
import { Button } from '@/shared/ui';
import { cn } from '@/shared/lib/cn';
-type RitualPopover = 'space' | 'timer' | 'sound';
+type SelectionPopover = 'space' | 'timer' | 'sound';
interface SpaceSetupDrawerWidgetProps {
open: boolean;
@@ -86,7 +90,7 @@ export const SpaceSetupDrawerWidget = ({
resumeHint,
}: SpaceSetupDrawerWidgetProps) => {
const { setup } = copy.space;
- const [openPopover, setOpenPopover] = useState(null);
+ const [openPopover, setOpenPopover] = useState(null);
const panelRef = useRef(null);
const selectedScene = useMemo(() => {
@@ -133,7 +137,7 @@ export const SpaceSetupDrawerWidget = ({
return null;
}
- const togglePopover = (popover: RitualPopover) => {
+ const togglePopover = (popover: SelectionPopover) => {
setOpenPopover((current) => (current === popover ? null : popover));
};
@@ -280,7 +284,7 @@ export const SpaceSetupDrawerWidget = ({
) : null}
-
: null}