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 ( +
+
+

{copy.admin.views.scene.workspaceTitle}

+

{copy.admin.views.scene.workspaceDescription}

+
+ +
+
+
+
+ + +

{copy.admin.views.scene.sceneIdHint}

+
+
+ +
+
+ + +

{copy.admin.views.scene.sourceImageHint}

+

{copy.admin.views.scene.sourceImageDerivedHint}

+
+
+ +
+ +