feat(space/app): app 진입부 및 space 몰입 환경(HUD/Tools) 프리미엄 UI 리팩토링

맥락:
- 기존 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) 뷰 구현.
세션-리스크: 없음.
This commit is contained in:
2026-03-13 14:57:35 +09:00
parent 2506dd53a7
commit abdde2a8ae
36 changed files with 2120 additions and 923 deletions

View File

@@ -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` 안에서 관리한다.
## 파일 분리 기준

View File

@@ -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 코칭과 프리미엄 환경을 위해 기꺼이 지갑을 열게 될 것입니다.

View File

@@ -1 +1,2 @@
export * from './model/types';
export * from './model/useAuthStore';

View File

@@ -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<AuthState>((set) => {
const isClient = typeof window !== 'undefined';
const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null;
@@ -23,36 +18,31 @@ export const useAuthStore = create<AuthState>((set) => {
return {
accessToken: savedToken || null,
user: null,
isAuthenticated: !!savedToken,
isAuthenticated: Boolean(savedToken),
setAuth: (data: AuthResponse) => {
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, {
...cookieOptions,
expires: 7
expires: 7,
});
// 2. Refresh Token 저장 (30일)
if (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
user: data.user ?? null,
isAuthenticated: true,
});
},
logout: () => {
Cookies.remove(TOKEN_COOKIE_KEY);
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
@@ -60,7 +50,7 @@ export const useAuthStore = create<AuthState>((set) => {
set({
accessToken: null,
user: null,
isAuthenticated: false
isAuthenticated: false,
});
},
};

View File

@@ -73,11 +73,19 @@ export const useMediaCatalog = () => {
useEffect(() => {
const controller = new AbortController();
void readMediaManifest(controller.signal).then((result) => {
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 () => {

View File

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

View File

@@ -1,3 +1,4 @@
export * from './model/mockSession';
export * from './model/focusSystem';
export * from './model/types';
export * from './model/useThoughtInbox';

View File

@@ -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 = <T extends { proOnly?: boolean }>(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,
});
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof normalizeFocusPlanToday>[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<FocusSession | null> => {
return apiClient<FocusSession | null>('api/v1/focus-sessions/current', {
const response = await apiClient<RawFocusSession | null>('api/v1/focus-sessions/current', {
method: 'GET',
});
return response ? normalizeFocusSession(response) : null;
},
/**
* Backend Codex:
* - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
* - sceneId, goal, timerPresetId, soundPresetId를 저장한다.
* - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
*/
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions', {
const response = await apiClient<RawFocusSession>('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<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/pause', {
const response = await apiClient<RawFocusSession>('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<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/resume', {
const response = await apiClient<RawFocusSession>('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<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/restart-phase', {
const response = await apiClient<RawFocusSession>('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<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/selection', {
const response = await apiClient<RawFocusSession>('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<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/complete', {
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/complete', {
method: 'POST',
body: JSON.stringify(payload),
});
return normalizeFocusSession(response);
},
advanceGoal: async (payload: AdvanceCurrentGoalRequest): Promise<AdvanceCurrentGoalResponse> => {
const response = await apiClient<RawAdvanceCurrentGoalResponse>(
'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<void> => {
return apiClient<void>('api/v1/focus-sessions/current/abandon', {
method: 'POST',

View File

@@ -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<FocusSession | null>;
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise<AdvanceCurrentGoalResponse | null>;
abandonSession: () => Promise<boolean>;
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;

View File

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

View File

@@ -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: '총 시간보다 시작 성공률과 복귀 패턴을 먼저 해석합니다.',
},
],
},

View File

@@ -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: '나만의 심미적 공간',
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: {

View File

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

View File

@@ -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: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',
},

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-[#f3f4f8] text-slate-900">
<div className="text-center">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="mt-2 text-slate-500">Welcome, {session.user?.name}!</p>
<p className="mt-4 text-sm text-slate-400">Dashboard is under construction.</p>
</div>
</div>
<AdminDashboardView
session={session}
activeView={activeView}
onActiveViewChange={setActiveView}
activeMeta={activeMeta}
isDurationOverrideEnabled={isDurationOverrideEnabled}
onDurationOverrideChange={setIsDurationOverrideEnabled}
currentMessage={currentMessage}
uploadResult={uploadResult}
resultSummary={resultSummary}
lastExtractedDurationSec={lastExtractedDurationSec}
scenePending={scenePending}
soundPending={soundPending}
onLogout={handleLogout}
onSceneSubmit={handleSceneUpload}
onSoundSubmit={handleSoundUpload}
/>
);
};

View File

@@ -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<HTMLFormElement>;
onSoundSubmit: FormEventHandler<HTMLFormElement>;
}
const MessagePanel = ({ message }: { message: string | null }) => {
if (!message) {
return null;
}
return (
<div className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
{message}
</div>
);
};
const AdminSceneForm = ({
currentMessage,
scenePending,
onSubmit,
}: {
currentMessage: string | null;
scenePending: boolean;
onSubmit: FormEventHandler<HTMLFormElement>;
}) => {
return (
<form onSubmit={onSubmit} className="rounded-2xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-6 py-5">
<p className="text-lg font-semibold text-slate-950">{copy.admin.views.scene.workspaceTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.admin.views.scene.workspaceDescription}</p>
</div>
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6">
<div className="max-w-xl">
<div>
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.sceneIdLabel}
</label>
<input
id="scene-id"
name="sceneId"
className={fieldClassName}
placeholder={copy.admin.views.scene.sceneIdPlaceholder}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sceneIdHint}</p>
</div>
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
<div className="max-w-2xl">
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.sourceImageLabel}
</label>
<input
name="sourceImageFile"
type="file"
accept="image/jpeg,image/png"
className={fileClassName}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.sourceImageHint}</p>
<p className="mt-1 text-xs text-slate-400">{copy.admin.views.scene.sourceImageDerivedHint}</p>
</div>
</div>
<div className="mt-6 max-w-2xl">
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.scene.blurDataUrlLabel}
</label>
<textarea
name="blurDataUrl"
rows={6}
className={textareaClassName}
placeholder={copy.admin.views.scene.blurDataUrlPlaceholder}
/>
<p className="mt-2 text-xs text-slate-500">{copy.admin.views.scene.blurDataUrlHint}</p>
</div>
</div>
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.scene.notesTitle}</p>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
{copy.admin.views.scene.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<MessagePanel message={currentMessage} />
<Button
type="submit"
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
disabled={scenePending}
>
{scenePending ? copy.admin.views.scene.pending : copy.admin.views.scene.submit}
</Button>
</div>
</div>
</form>
);
};
const AdminSoundForm = ({
currentMessage,
isDurationOverrideEnabled,
lastExtractedDurationSec,
onDurationOverrideChange,
onSubmit,
soundPending,
}: {
currentMessage: string | null;
isDurationOverrideEnabled: boolean;
lastExtractedDurationSec: number | null;
onDurationOverrideChange: (next: boolean) => void;
onSubmit: FormEventHandler<HTMLFormElement>;
soundPending: boolean;
}) => {
return (
<form onSubmit={onSubmit} className="rounded-2xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-6 py-5">
<p className="text-lg font-semibold text-slate-950">{copy.admin.views.sound.workspaceTitle}</p>
<p className="mt-1 text-sm text-slate-500">{copy.admin.views.sound.workspaceDescription}</p>
</div>
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="px-6 py-6">
<div className="grid gap-5 md:grid-cols-3">
<div className="md:col-span-2">
<label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.presetIdLabel}
</label>
<input
id="preset-id"
name="presetId"
className={fieldClassName}
placeholder={copy.admin.views.sound.presetIdPlaceholder}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.defaultVolumeLabel}
</label>
<input
name="defaultVolume"
type="number"
min={0}
max={100}
className={fieldClassName}
placeholder={copy.admin.views.sound.defaultVolumePlaceholder}
/>
</div>
</div>
<div className="mt-5 grid gap-5 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.loopFileLabel}
</label>
<input name="loopFile" type="file" accept="audio/*" className={fileClassName} />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.previewFileLabel}
</label>
<input name="previewFile" type="file" accept="audio/*" className={fileClassName} />
</div>
</div>
<div className="mt-5">
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.fallbackLoopFileLabel}
</label>
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
</div>
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-slate-900">
{copy.admin.views.sound.durationOverrideToggle}
</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
{copy.admin.views.sound.durationOverrideHint}
</p>
</div>
<button
type="button"
onClick={() => onDurationOverrideChange(!isDurationOverrideEnabled)}
className={`inline-flex h-7 w-12 items-center rounded-full px-1 transition ${
isDurationOverrideEnabled ? 'bg-slate-900' : 'bg-slate-300'
}`}
aria-pressed={isDurationOverrideEnabled}
>
<span
className={`h-5 w-5 rounded-full bg-white transition ${
isDurationOverrideEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{isDurationOverrideEnabled ? (
<div className="mt-4">
<label className="mb-2 block text-sm font-medium text-slate-700">
{copy.admin.views.sound.durationLabel}
</label>
<input
name="durationSec"
type="number"
min={0}
className={fieldClassName}
placeholder={copy.admin.views.sound.durationPlaceholder}
/>
</div>
) : null}
</div>
</div>
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
<p className="text-sm font-semibold text-slate-900">{copy.admin.views.sound.notesTitle}</p>
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
{copy.admin.views.sound.extractedDurationLabel}
</p>
<p className="mt-2 text-sm font-medium text-slate-900">
{lastExtractedDurationSec == null
? copy.admin.views.sound.extractedDurationEmpty
: copy.admin.views.sound.extractedDurationValue(lastExtractedDurationSec)}
</p>
</div>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
{copy.admin.views.sound.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<MessagePanel message={currentMessage} />
<Button
type="submit"
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
disabled={soundPending}
>
{soundPending ? copy.admin.views.sound.pending : copy.admin.views.sound.submit}
</Button>
</div>
</div>
</form>
);
};
export const AdminDashboardView = ({
session,
activeView,
onActiveViewChange,
activeMeta,
isDurationOverrideEnabled,
onDurationOverrideChange,
currentMessage,
uploadResult,
resultSummary,
lastExtractedDurationSec,
scenePending,
soundPending,
onLogout,
onSceneSubmit,
onSoundSubmit,
}: AdminDashboardViewProps) => {
return (
<main className="min-h-screen bg-[#f3f4f8] text-slate-900">
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="flex flex-col bg-[#171821] px-5 py-6 text-white">
<div className="flex items-center gap-3 px-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-sky-500 text-lg font-bold">
V
</div>
<div>
<p className="text-2xl font-semibold tracking-tight">{copy.appName}</p>
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">{copy.admin.mediaAdminLabel}</p>
</div>
</div>
<nav className="mt-10 flex-1">
<div className="px-3">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
{copy.admin.navItems[0].section}
</p>
</div>
<div className="space-y-1">
{navItems.map((item) => {
const isActive = item.id === activeView;
return (
<button
key={item.id}
type="button"
onClick={() => onActiveViewChange(item.id)}
className={`flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition ${
isActive
? 'bg-white/10 text-white'
: 'text-slate-400 hover:bg-white/6 hover:text-white'
}`}
>
<span className={`h-8 w-1 rounded-full ${isActive ? 'bg-sky-400' : 'bg-transparent'}`} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
<p className="truncate text-xs text-slate-500">{item.subtitle}</p>
</div>
</button>
);
})}
</div>
<div className="mt-10 px-3">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
{copy.admin.sessionSection}
</p>
<div className="space-y-3 text-sm text-slate-300">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{copy.admin.sessionAdminLabel}</p>
<p className="mt-1 font-medium text-white">{session.user?.name ?? copy.common.admin}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{copy.admin.sessionRoleLabel}</p>
<p className="mt-1 font-medium text-emerald-300">
{session.user?.grade ?? 'ADMIN'}
{copy.admin.roleAccessSuffix}
</p>
</div>
</div>
</div>
</nav>
<div className="border-t border-white/8 px-3 pt-5">
<Button
type="button"
variant="ghost"
size="full"
className="h-11 justify-start rounded-xl bg-white/6 px-4 text-white hover:bg-white/10"
onClick={onLogout}
>
{copy.admin.logout}
</Button>
</div>
</aside>
<div className="flex min-w-0 flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
<div className="flex min-w-[280px] flex-1 items-center gap-3">
<button
type="button"
className="flex h-11 w-11 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500"
>
</button>
<div className="relative w-full max-w-md">
<input
value={activeView === 'scene' ? copy.admin.searchValues.scene : copy.admin.searchValues.sound}
readOnly
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 pl-11 pr-4 text-sm text-slate-500 outline-none"
/>
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="hidden items-center gap-2 rounded-full bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500 md:flex">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
{copy.admin.manifestReady}
</div>
<div className="flex h-11 items-center gap-3 rounded-full border border-slate-200 bg-white px-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
A
</div>
<div className="hidden sm:block">
<p className="text-sm font-semibold text-slate-900">{session.user?.name ?? copy.common.admin}</p>
<p className="text-xs text-slate-500">{session.user?.email}</p>
</div>
</div>
</div>
</div>
</header>
<section className="flex-1 overflow-auto px-6 py-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="min-w-0 space-y-6">
<div className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_repeat(2,minmax(0,1fr))]">
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
{activeMeta.eyebrow}
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
{activeMeta.title}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-500">{activeMeta.description}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
<p className="text-sm font-medium text-slate-500">{activeMeta.statTitle}</p>
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">{activeMeta.statValue}</p>
<p className="mt-2 text-sm text-slate-500">{activeMeta.statHint}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
<p className="text-sm font-medium text-slate-500">{copy.admin.inspector.currentRoleTitle}</p>
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">
{session.user?.grade ?? 'ADMIN'}
</p>
<p className="mt-2 text-sm text-slate-500">{copy.admin.inspector.bearerTokenSession}</p>
</div>
</div>
{activeView === 'scene' ? (
<AdminSceneForm
currentMessage={currentMessage}
scenePending={scenePending}
onSubmit={onSceneSubmit}
/>
) : (
<AdminSoundForm
currentMessage={currentMessage}
isDurationOverrideEnabled={isDurationOverrideEnabled}
lastExtractedDurationSec={lastExtractedDurationSec}
onDurationOverrideChange={onDurationOverrideChange}
onSubmit={onSoundSubmit}
soundPending={soundPending}
/>
)}
</div>
<aside className="space-y-6">
<div className="rounded-2xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-5 py-4">
<p className="text-sm font-semibold text-slate-950">{copy.admin.inspector.recentResponse}</p>
</div>
<div className="px-5 py-4">
<p className="text-sm leading-6 text-slate-500">{resultSummary}</p>
<pre className="mt-4 max-h-[340px] overflow-auto rounded-xl bg-[#171821] px-4 py-4 text-[11px] leading-6 text-slate-100">
{uploadResult ? JSON.stringify(uploadResult, null, 2) : copy.admin.inspector.noUploadPayload}
</pre>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-5 py-4">
<p className="text-sm font-semibold text-slate-950">{copy.admin.inspector.sessionToken}</p>
</div>
<div className="px-5 py-4">
<p className="break-all text-xs leading-6 text-slate-500">{session.accessToken}</p>
</div>
</div>
</aside>
</div>
</section>
</div>
</div>
</main>
);
};

View File

@@ -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<DashboardStep>('goal');
const [paywallSource, setPaywallSource] = useState<string | null>(null);
const [manageSheetOpen, setManageSheetOpen] = useState(false);
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
const [entryDraft, setEntryDraft] = useState('');
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
const [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<HTMLInputElement | null>(null);
const microStepInputRef = useRef<HTMLInputElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(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 (
<>
<div className="min-h-screen bg-[radial-gradient(circle_at_12%_0%,rgba(191,219,254,0.42),transparent_36%),linear-gradient(180deg,#f8fafc_0%,#edf4fb_56%,#e7eef7_100%)] text-brand-dark">
<div className="mx-auto w-full max-w-2xl px-4 pb-12 pt-8 sm:px-6">
<header className="flex items-start justify-between gap-4">
<div className="max-w-lg">
<p className="text-[11px] uppercase tracking-[0.16em] text-brand-dark/40">
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
{/* Premium Cinematic Background */}
<div
className={cn(
"absolute inset-0 bg-cover bg-center transition-all duration-1000 ease-out will-change-transform",
isStartingSession ? 'scale-110 blur-2xl opacity-0' : 'scale-100 opacity-100',
step === 'ritual' ? 'scale-105 blur-sm' : ''
)}
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
/>
{/* Global Gradient Overlay for text readability */}
<div className={cn(
"absolute inset-0 bg-gradient-to-b from-black/20 via-black/40 to-black/60 transition-opacity duration-1000",
step === 'ritual' ? 'opacity-80' : 'opacity-100'
)} />
{/* Header */}
<header className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-6 md:p-8">
<p className="text-sm font-semibold tracking-[0.3em] text-white/50 uppercase">
{focusEntryCopy.eyebrow}
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-brand-dark">
{focusEntryCopy.title}
</h1>
<p className="mt-3 text-sm leading-7 text-brand-dark/62">
{focusEntryCopy.description}
</p>
</div>
<PlanPill
plan={planTier}
onClick={() => {
if (!isPro) {
openPaywall();
}
if (!isPro) openPaywall();
}}
/>
</header>
<main className="mt-8 space-y-5">
<section className="overflow-hidden rounded-[2rem] border border-black/5 bg-white/78 p-5 shadow-[0_24px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl sm:p-6">
<div className="space-y-5">
<div className="space-y-1">
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.title}</p>
<p className="text-sm text-brand-dark/58">{focusEntryCopy.helper}</p>
</div>
{/* Main Content Area */}
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="min-w-0 flex-1 space-y-2">
<span className="text-[11px] uppercase tracking-[0.14em] text-brand-dark/40">
{focusEntryCopy.inputLabel}
</span>
{/* Step 1: Goal Setup */}
<div className={cn(
"w-full max-w-2xl transition-all duration-700 absolute",
step === 'goal'
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 -translate-y-8 pointer-events-none'
)}>
<div className="flex flex-col items-center space-y-10 text-center">
<h1 className="text-3xl md:text-5xl font-light tracking-tight text-white drop-shadow-lg leading-tight">
{focusEntryCopy.title}
</h1>
<div className="w-full max-w-xl mx-auto space-y-8">
<input
ref={entryInputRef}
value={entryDraft}
onChange={(event) => handleEntryDraftChange(event.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder={focusEntryCopy.inputPlaceholder}
className="h-12 w-full rounded-[1rem] border border-slate-200/88 bg-white px-4 text-[15px] text-brand-dark outline-none transition focus:border-brand-primary/38 focus:ring-2 focus:ring-brand-primary/12"
className={glassInputClass}
autoFocus
/>
</label>
{startHref ? (
<Link href={startHref} className={cn(startButtonClassName, 'sm:w-[164px]')}>
{focusEntryCopy.startNow}
</Link>
) : (
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
type="button"
onClick={() => entryInputRef.current?.focus()}
className={cn(startButtonClassName, 'sm:w-[164px]')}
onClick={handleStartSession}
disabled={!isGoalReady || isStartingSession}
className={primaryGlassBtnClass}
>
{focusEntryCopy.startNow}
</button>
)}
<button
type="button"
onClick={handleNextStep}
disabled={!isGoalReady || isStartingSession}
className={secondaryGlassBtnClass}
>
{focusEntryCopy.nextStep}
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Suggestions / Manage - very minimal */}
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
<div className="flex flex-wrap justify-center gap-2">
{ENTRY_SUGGESTIONS.map((suggestion) => {
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
return (
<button
key={suggestion.id}
type="button"
onClick={() => handleSelectSuggestion(suggestion.goal)}
className={cn(
'inline-flex items-center rounded-full border px-3 py-1.5 text-sm transition',
'rounded-full px-4 py-1.5 text-sm transition-all border',
isActive
? 'border-brand-primary/26 bg-brand-primary/10 text-brand-dark'
: 'border-slate-200/84 bg-white/72 text-brand-dark/68 hover:bg-white',
? 'bg-white/20 border-white text-white'
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
)}
>
{suggestion.label}
@@ -405,81 +369,141 @@ export const FocusDashboardWidget = () => {
);
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200/80 pt-4">
<p className="text-xs text-brand-dark/54">{focusEntryCopy.ritualMeta}</p>
<button
type="button"
onClick={() => setManageSheetOpen(true)}
disabled={!canManagePlan}
className="text-sm font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:text-brand-dark/34"
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
>
{focusEntryCopy.manageBlocks}
</button>
</div>
{previewItems.length > 0 ? (
<div className="space-y-3 border-t border-slate-200/80 pt-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.previewTitle}</p>
<p className="text-xs leading-6 text-brand-dark/54">
{focusEntryCopy.previewDescription}
</p>
</div>
<div className="grid gap-2">
{previewItems.map((item) => {
const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title;
</div>
</div>
return (
{/* Step 2: Ritual Setup */}
<div className={cn(
"w-full max-w-4xl transition-all duration-700 absolute",
step === 'ritual'
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-8 pointer-events-none'
)}>
<div className={panelGlassClass}>
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
<div className="flex-1">
<p className="text-xs uppercase tracking-widest text-white/40 mb-2">Today's Focus</p>
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
</div>
<button
key={item.id}
type="button"
onClick={() => handleSelectPlanItem(item)}
className={cn(
previewButtonClassName,
isSelected && 'border-brand-primary/24 bg-brand-primary/8',
)}
onClick={() => setStep('goal')}
className="rounded-full border border-white/20 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
>
<p
className={cn(
'truncate text-[15px] font-medium',
isSelected ? 'text-brand-dark' : 'text-brand-dark/78',
)}
>
{item.title}
</p>
수정
</button>
);
})}
</div>
</div>
) : null}
{source === 'unavailable' && !isLoading ? (
<p className="border-t border-slate-200/80 pt-4 text-xs text-brand-dark/54">
{focusEntryCopy.apiUnavailableNote}
</p>
) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
<div className="space-y-8">
{/* Microstep */}
<div className="space-y-3">
<label className="block space-y-2">
<span className="text-sm font-medium text-white/80">
{focusEntryCopy.microStepTitle}
</span>
<input
ref={microStepInputRef}
value={microStepDraft}
onChange={(e) => setMicroStepDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartSession()}
placeholder={focusEntryCopy.microStepPlaceholder}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-white/30 outline-none transition-all focus:border-white/30 focus:bg-white/10"
/>
</label>
<p className="text-xs text-white/40">{focusEntryCopy.microStepHelper}</p>
</div>
</section>
<div className="flex items-center justify-between gap-3 px-1">
<p className="text-xs text-brand-dark/54">
{isLoading ? focusEntryCopy.reviewFallback : reviewLine}
</p>
<Link
href="/stats"
className="text-xs font-medium text-brand-primary transition hover:text-brand-primary/82"
{/* Timer */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">몰입 리듬</p>
<div className="grid grid-cols-3 gap-2">
{[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
<button
key={timer.id}
type="button"
onClick={() => setSelectedTimerId(timer.id)}
className={cn(itemCardGlassClass, selectedTimerId === timer.id && itemCardGlassSelectedClass)}
>
{focusEntryCopy.reviewLinkLabel}
</Link>
<span className="text-sm font-medium">{timer.label}</span>
</button>
))}
</div>
</div>
</div>
{error && source === 'api' ? <p className="px-1 text-xs text-rose-500">{error}</p> : null}
<div className="space-y-8">
{/* Scene */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">배경 공간</p>
<div className="flex gap-3 overflow-x-auto pb-2 snap-x hide-scrollbar">
{SCENE_THEMES.map(scene => (
<button
key={scene.id}
type="button"
onClick={() => setSelectedSceneId(scene.id)}
className={cn(
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left snap-start transition-all overflow-hidden bg-white/5 active:scale-95 cursor-pointer',
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]'
)}
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
>
<div className="absolute inset-0 bg-black/40 transition-opacity group-hover:bg-black/20" />
<span className="absolute bottom-2 left-2 text-sm font-medium z-10 text-white text-shadow-sm">{scene.name}</span>
{selectedSceneId === scene.id && (
<span className="absolute top-2 right-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm border border-white/40 text-white text-[10px]">
</span>
)}
</button>
))}
</div>
</div>
{/* Sound */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80">사운드</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{SOUND_PRESETS.map(sound => (
<button
key={sound.id}
type="button"
onClick={() => setSelectedSoundId(sound.id)}
className={cn(itemCardGlassClass, "py-3", selectedSoundId === sound.id && itemCardGlassSelectedClass)}
>
<span className="text-sm font-medium">{sound.label}</span>
</button>
))}
</div>
</div>
</div>
</div>
<div className="mt-10 pt-6 flex justify-end border-t border-white/10">
<button
type="button"
onClick={handleStartSession}
disabled={isStartingSession}
className={primaryGlassBtnClass}
>
{isStartingSession ? ' ...' : ''}
</button>
</div>
</div>
</div>
</main>
</div>
</div>
{/* Plan Sheet & Paywall */}
<FocusPlanManageSheet
isOpen={manageSheetOpen}
planItems={planItems}
@@ -533,6 +557,6 @@ export const FocusDashboardWidget = () => {
</div>
</div>
) : null}
</>
</div>
);
};

View File

@@ -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 (
<div className="pointer-events-none fixed left-0 top-0 z-20 w-full max-w-[800px] h-48 bg-[radial-gradient(ellipse_at_top_left,rgba(0,0,0,0.6)_0%,rgba(0,0,0,0)_60%)]">
<div className="flex flex-col items-start gap-4 p-8 md:p-12">
{/* Main Goal */}
<div className="pointer-events-auto group relative flex items-center gap-4">
<h2 className="text-2xl md:text-[1.75rem] font-medium tracking-tight text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)] [text-shadow:0_4px_24px_rgba(0,0,0,0.6)]">
{normalizedGoal}
</h2>
{hasActiveSession && sessionPhase === 'focus' ? (
<button
type="button"
onClick={onGoalCompleteRequest}
className="opacity-0 group-hover:opacity-100 shrink-0 rounded-full border border-white/20 bg-black/40 backdrop-blur-md px-3.5 py-1.5 text-[11px] font-medium text-white/90 shadow-lg transition-all hover:bg-black/60 hover:text-white"
>
</button>
) : null}
</div>
{/* Micro Step */}
{microStep && !isMicroStepCompleted && (
<div className="pointer-events-auto flex items-center gap-3.5 animate-in fade-in slide-in-from-top-2 duration-500 bg-black/10 backdrop-blur-[2px] rounded-full pr-4 py-1 -ml-1 border border-white/5">
<button
type="button"
onClick={() => setIsMicroStepCompleted(true)}
className="flex h-6 w-6 ml-1 items-center justify-center rounded-full border border-white/40 bg-black/20 shadow-inner transition-all hover:bg-white/20 hover:scale-110 active:scale-95"
aria-label="첫 단계 완료"
>
<span className="sr-only"> </span>
</button>
<span className="text-[15px] font-medium text-white/95 drop-shadow-[0_2px_4px_rgba(0,0,0,0.6)] [text-shadow:0_2px_12px_rgba(0,0,0,0.5)]">
{microStep}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -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> | 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<HTMLInputElement | null>(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<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
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,20 +108,6 @@ 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"
/>
<div className="flex flex-wrap gap-1.5">
{GOAL_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setDraft(suggestion)}
className="rounded-full border border-white/16 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/76 transition-colors hover:bg-white/[0.1]"
>
{suggestion}
</button>
))}
</div>
<footer className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
@@ -123,10 +118,10 @@ export const GoalCompleteSheet = ({
</button>
<button
type="submit"
disabled={!canConfirm}
disabled={!canConfirm || isSubmitting}
className="rounded-full border border-sky-200/42 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300 disabled:cursor-not-allowed disabled:border-white/16 disabled:bg-white/[0.08] disabled:text-white/48"
>
{copy.space.goalComplete.confirmButton}
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
</button>
</footer>
</form>

View File

@@ -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<void>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
export const SpaceFocusHudWidget = ({
goal,
microStep,
timerLabel,
timeDisplay,
visible,
@@ -86,9 +89,15 @@ export const SpaceFocusHudWidget = ({
return (
<>
<FloatingGoalWidget
goal={goal}
microStep={microStep}
onGoalCompleteRequest={handleOpenCompleteSheet}
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase}
/>
<SpaceTimerHudWidget
timerLabel={timerLabel}
goal={goal}
timeDisplay={timeDisplay}
isImmersionMode
hasActiveSession={hasActiveSession}
@@ -99,7 +108,6 @@ export const SpaceFocusHudWidget = ({
canPause={canPauseSession}
canReset={canRestartSession}
className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet}
onStartClick={onStartRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}
@@ -121,8 +129,7 @@ export const SpaceFocusHudWidget = ({
}, 5 * 60 * 1000);
}}
onConfirm={(nextGoal) => {
void onGoalUpdate(nextGoal);
setSheetOpen(false);
return Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
</>

View File

@@ -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<RitualPopover | null>(null);
const [openPopover, setOpenPopover] = useState<SelectionPopover | null>(null);
const panelRef = useRef<HTMLDivElement | null>(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}
</div>
<form id="space-setup-ritual-form" className="space-y-3" onSubmit={handleSubmit}>
<form id="space-setup-form" className="space-y-3" onSubmit={handleSubmit}>
<SessionGoalField
autoFocus={open}
goalInput={goalInput}
@@ -294,7 +298,7 @@ export const SpaceSetupDrawerWidget = ({
{!canStart ? <p className="text-[10px] text-white/56">{setup.readyHint}</p> : null}
<Button
type="submit"
form="space-setup-ritual-form"
form="space-setup-form"
size="full"
disabled={!canStart}
className={cn(

View File

@@ -10,7 +10,6 @@ import {
interface SpaceTimerHudWidgetProps {
timerLabel: string;
goal: string;
timeDisplay?: string;
className?: string;
hasActiveSession?: boolean;
@@ -24,14 +23,12 @@ interface SpaceTimerHudWidgetProps {
onStartClick?: () => void;
onPauseClick?: () => void;
onResetClick?: () => void;
onGoalCompleteRequest?: () => void;
}
const HUD_ACTIONS = copy.space.timerHud.actions;
export const SpaceTimerHudWidget = ({
timerLabel,
goal,
timeDisplay = '25:00',
className,
hasActiveSession = false,
@@ -45,10 +42,8 @@ export const SpaceTimerHudWidget = ({
onStartClick,
onPauseClick,
onResetClick,
onGoalCompleteRequest,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
const modeLabel = isBreatheMode
? RECOVERY_30S_MODE_LABEL
: !hasActiveSession
@@ -60,63 +55,41 @@ export const SpaceTimerHudWidget = ({
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-20 px-4 pr-16 sm:px-6',
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 sm:px-6',
className,
)}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.35rem)' }}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2rem)' }}
>
<div className="relative mx-auto w-full max-w-xl pointer-events-auto">
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 z-0 h-28 w-[min(760px,96vw)] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.2)_0%,rgba(2,6,23,0.12)_45%,rgba(2,6,23,0)_78%)]"
/>
<div className="relative pointer-events-auto">
<section
className={cn(
'relative z-10 flex h-[4.85rem] items-center justify-between gap-3 overflow-hidden rounded-2xl px-3.5 py-2 transition-colors',
'relative z-10 flex h-[3.5rem] items-center justify-between gap-6 overflow-hidden rounded-full px-5 transition-colors',
isImmersionMode
? 'border border-white/12 bg-black/22 backdrop-blur-md'
: 'border border-white/12 bg-black/24 backdrop-blur-md',
? 'border border-white/10 bg-black/20 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)]'
: 'border border-white/15 bg-black/30 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.16)]',
)}
>
<div className="min-w-0">
<div className="flex items-baseline gap-2">
<div className="flex items-center gap-3">
<span
className={cn(
'text-[11px] font-semibold uppercase tracking-[0.16em]',
isImmersionMode ? 'text-white/90' : 'text-white/88',
'w-14 text-right text-[10px] font-bold uppercase tracking-[0.15em] opacity-80',
sessionPhase === 'break' ? 'text-emerald-400' : 'text-brand-primary'
)}
>
{modeLabel}
</span>
<span className="w-[1px] h-4 bg-white/10" />
<span
className={cn(
'text-[1.7rem] font-semibold tracking-tight sm:text-[1.78rem]',
isImmersionMode ? 'text-white/90' : 'text-white/92',
'w-20 text-[1.4rem] font-medium tracking-tight text-center',
isImmersionMode ? 'text-white/90' : 'text-white',
)}
>
{timeDisplay}
</span>
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
{timerLabel}
</span>
</div>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
<p className={cn('min-w-0 truncate text-sm', isImmersionMode ? 'text-white/88' : 'text-white/86')}>
<span className="text-white/62">{copy.space.timerHud.goalPrefix}</span>
<span className="text-white/90">{normalizedGoal}</span>
</p>
<button
type="button"
onClick={onGoalCompleteRequest}
className="shrink-0 rounded-full border border-white/16 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white/86"
>
{copy.space.timerHud.completeButton}
</button>
</div>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 pl-2 border-l border-white/10">
{HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
@@ -136,32 +109,20 @@ export const SpaceTimerHudWidget = ({
aria-pressed={isHighlighted}
disabled={isDisabled}
onClick={() => {
if (isStartAction) {
onStartClick?.();
}
if (isPauseAction) {
onPauseClick?.();
}
if (isResetAction) {
onResetClick?.();
}
if (isStartAction) onStartClick?.();
if (isPauseAction) onPauseClick?.();
if (isResetAction) onResetClick?.();
}}
className={cn(
'inline-flex h-9 w-9 items-center justify-center rounded-full border text-sm transition-[transform,background-color,border-color,box-shadow,color,opacity] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 active:translate-y-px active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-38 disabled:shadow-none',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_8px_18px_rgba(2,6,23,0.18)]',
'inline-flex h-8 w-8 items-center justify-center rounded-full text-sm transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30',
isImmersionMode
? 'border-white/14 bg-black/28 text-white/82 hover:border-white/22 hover:bg-white/[0.09]'
: 'border-white/14 bg-black/28 text-white/84 hover:border-white/22 hover:bg-white/[0.09]',
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-white/80 hover:bg-white/15 hover:text-white',
isStartAction && isHighlighted
? 'border-sky-200/56 bg-sky-200/20 text-sky-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(56,189,248,0.24)]'
? 'bg-white/10 text-white shadow-sm'
: '',
isPauseAction && isHighlighted
? 'border-amber-200/52 bg-amber-200/18 text-amber-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(251,191,36,0.18)]'
: '',
isResetAction && !isDisabled
? 'hover:border-white/26 hover:bg-white/[0.12] hover:text-white'
? 'bg-white/10 text-white shadow-sm'
: '',
)}
>
@@ -170,10 +131,9 @@ export const SpaceTimerHudWidget = ({
</button>
);
})}
</div>
<Restart30sAction
onTrigger={triggerRestart}
className={cn(isImmersionMode ? 'text-white/72 hover:text-white/92' : 'text-white/74 hover:text-white/92')}
className="h-8 w-8 ml-1"
/>
</div>
</section>

View File

@@ -1,11 +1,11 @@
'use client';
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { usePlanTier } from '@/entities/plan';
import type { RecentThought } from '@/entities/session';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
import type { PlanTier } from '@/entities/plan';
interface UseSpaceToolsDockHandlersParams {
setIdle: (idle: boolean) => void;
@@ -44,7 +44,7 @@ export const useSpaceToolsDockHandlers = ({
}: UseSpaceToolsDockHandlersParams) => {
const { toolsDock } = copy.space;
const [noteDraft, setNoteDraft] = useState('');
const [plan, setPlan] = useState<PlanTier>('normal');
const { plan, setPlan } = usePlanTier();
const openUtilityPanel = useCallback((panel: SpaceUtilityPanelId) => {
setIdle(false);
@@ -148,11 +148,11 @@ export const useSpaceToolsDockHandlers = ({
const handleSelectProFeature = useCallback((featureId: string) => {
const label =
featureId === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
featureId === 'daily-plan'
? toolsDock.featureLabels.dailyPlan
: featureId === 'rituals'
? toolsDock.featureLabels.rituals
: toolsDock.featureLabels.weeklyReview;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
}, [onStatusMessage, toolsDock.featureLabels]);

View File

@@ -79,82 +79,32 @@ export const FocusModeAnchors = ({
type="button"
aria-label={copy.space.toolsDock.popoverCloseAria}
onClick={onClosePopover}
className="fixed inset-0 z-30"
className="fixed inset-0 z-30 cursor-default"
/>
) : null}
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
onOpenInbox={onOpenInbox}
onOpenControlCenter={onOpenControlCenter}
/>
<div
className={cn(
anchorContainerClassName,
'left-[calc(env(safe-area-inset-left,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleNotes}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{copy.space.toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
openPopover={openPopover}
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
anchorContainerClassName,
'right-[calc(env(safe-area-inset-right,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleSound}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onOpenInbox={onOpenInbox}
onOpenControlCenter={onOpenControlCenter}
onToggleNotes={onToggleNotes}
onToggleSound={onToggleSound}
onNoteDraftChange={onNoteDraftChange}
onNoteSubmit={onNoteSubmit}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
) : null}
</div>
</div>
</>
);
};

View File

@@ -1,54 +1,180 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { SoundPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
import { copy } from '@/shared/i18n';
import { formatThoughtCount, RAIL_ICON } from './constants';
import type { SpaceAnchorPopoverId } from '../model/types';
import { formatThoughtCount, RAIL_ICON, ANCHOR_ICON } from './constants';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
interface FocusRightRailProps {
isIdle: boolean;
thoughtCount: number;
openPopover: SpaceAnchorPopoverId | null;
noteDraft: string;
selectedSoundLabel: string;
isSoundMuted: boolean;
soundVolume: number;
volumeFeedback: string | null;
quickSoundPresets: SoundPreset[];
selectedPresetId: string;
onOpenInbox: () => void;
onOpenControlCenter: () => void;
onToggleNotes: () => void;
onToggleSound: () => void;
onNoteDraftChange: (value: string) => void;
onNoteSubmit: () => void;
onToggleMute: () => void;
onVolumeChange: (nextVolume: number) => void;
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
onSelectPreset: (presetId: string) => void;
}
export const FocusRightRail = ({
isIdle,
thoughtCount,
openPopover,
noteDraft,
selectedSoundLabel,
isSoundMuted,
soundVolume,
volumeFeedback,
quickSoundPresets,
selectedPresetId,
onOpenInbox,
onOpenControlCenter,
onToggleNotes,
onToggleSound,
onNoteDraftChange,
onNoteSubmit,
onToggleMute,
onVolumeChange,
onVolumeKeyDown,
onSelectPreset,
}: FocusRightRailProps) => {
return (
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-34' : 'opacity-78',
'fixed z-30 transition-all duration-500 right-[calc(env(safe-area-inset-right,0px)+1.5rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-0 translate-x-4 pointer-events-none' : 'opacity-100 translate-x-0',
)}
>
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
<div className="flex flex-col gap-1">
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
{/* Notes Toggle */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.toolsDock.notesButton}
onClick={onToggleNotes}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'notes' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
)}
>
{ANCHOR_ICON.notes}
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.toolsDock.notesButton}
</span>
</div>
{openPopover === 'notes' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
</div>
) : null}
</div>
{/* Sound Toggle */}
<div className="relative group">
<button
type="button"
aria-label="사운드"
onClick={onToggleSound}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'sound' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
)}
>
{ANCHOR_ICON.sound}
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
</span>
</div>
{openPopover === 'sound' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
</div>
) : null}
</div>
<div className="w-6 h-px bg-white/10 mx-auto my-1" />
{/* Inbox Button */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.inbox.openInboxAriaLabel}
title={copy.space.inbox.openInboxTitle}
onClick={onOpenInbox}
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{RAIL_ICON.inbox}
{thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
<span className="absolute 0 top-0 right-0 inline-flex min-w-[1rem] items-center justify-center rounded-full bg-brand-primary px-1 py-0.5 text-[9px] font-bold text-white shadow-sm ring-2 ring-black/20">
{formatThoughtCount(thoughtCount)}
</span>
) : null}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.inbox.openInboxTitle}
</span>
</div>
</div>
{/* Control Center Button */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.rightRail.openQuickControlsAriaLabel}
title={copy.space.rightRail.openQuickControlsTitle}
onClick={onOpenControlCenter}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{RAIL_ICON.controlCenter}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.rightRail.openQuickControlsTitle}
</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -15,11 +15,10 @@ export const QuickNotesPopover = ({
}: QuickNotesPopoverProps) => {
return (
<div
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
>
<p className="text-[11px] text-white/56">{copy.space.quickNotes.title}</p>
<div className="mt-2 flex gap-1.5">
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickNotes.title}</p>
<div className="mt-4 flex flex-col gap-3">
<input
value={noteDraft}
onChange={(event) => onDraftChange(event.target.value)}
@@ -32,17 +31,21 @@ export const QuickNotesPopover = ({
onDraftEnter();
}}
placeholder={copy.space.quickNotes.placeholder}
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
autoFocus
className="w-full border-b border-white/20 bg-transparent pb-2 text-sm text-white placeholder:text-white/30 transition-colors focus:border-white/60 focus:outline-none"
/>
<div className="flex items-center justify-between">
<p className="text-[10px] text-white/40">{copy.space.quickNotes.hint}</p>
<button
type="button"
onClick={onSubmit}
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
disabled={!noteDraft.trim()}
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium text-white transition-all hover:bg-white/20 active:scale-95 disabled:opacity-30"
>
{copy.space.quickNotes.submit}
</button>
</div>
<p className="mt-2 text-[11px] text-white/52">{copy.space.quickNotes.hint}</p>
</div>
</div>
);
};

View File

@@ -30,22 +30,27 @@ export const QuickSoundPopover = ({
}: QuickSoundPopoverProps) => {
return (
<div
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
>
<p className="text-[11px] text-white/56">{copy.space.quickSound.currentSound}</p>
<p className="mt-1 truncate text-sm font-medium text-white/88">{selectedSoundLabel}</p>
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickSound.currentSound}</p>
<span className="text-[11px] font-medium text-white/90 bg-white/10 px-2 py-0.5 rounded-md">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
</div>
<p className="mt-2 truncate text-base font-medium text-white/90">{selectedSoundLabel}</p>
<div className="mt-3 rounded-xl border border-white/14 bg-white/[0.04] px-2.5 py-2">
<div className="flex items-center gap-2">
<div className="mt-5 rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur-md">
<div className="flex items-center gap-3">
<button
type="button"
aria-label={isSoundMuted ? copy.space.quickSound.unmuteAriaLabel : copy.space.quickSound.muteAriaLabel}
onClick={onToggleMute}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-xs text-white/80 transition-colors hover:bg-white/[0.12]"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-[13px] text-white/80 transition-all hover:bg-white/20 active:scale-95"
>
🔇
{isSoundMuted ? '🔇' : '🔊'}
</button>
<div className="relative flex w-full items-center">
<input
type="range"
min={0}
@@ -55,16 +60,21 @@ export const QuickSoundPopover = ({
onChange={(event) => onVolumeChange(Number(event.target.value))}
onKeyDown={onVolumeKeyDown}
aria-label={copy.space.quickSound.volumeAriaLabel}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/18 accent-sky-200"
className="absolute z-10 w-full cursor-pointer appearance-none bg-transparent accent-white outline-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
/>
<span className="w-9 text-right text-[11px] text-white/66">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
<div
className="h-full bg-white/90 transition-all duration-150 ease-out"
style={{ width: `${soundVolume}%` }}
/>
</div>
</div>
</div>
</div>
<p className="mt-3 text-[11px] text-white/56">{copy.space.quickSound.quickSwitch}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<div className="mt-6">
<p className="text-[10px] font-medium uppercase tracking-widest text-white/40 mb-3">{copy.space.quickSound.quickSwitch}</p>
<div className="flex flex-wrap gap-2">
{quickSoundPresets.map((preset) => {
const selected = preset.id === selectedPresetId;
@@ -74,10 +84,10 @@ export const QuickSoundPopover = ({
type="button"
onClick={() => onSelectPreset(preset.id)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
'rounded-full border px-3 py-1.5 text-[11px] font-medium transition-all active:scale-95',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
? 'border-white/40 bg-white/20 text-white shadow-sm'
: 'border-transparent bg-white/5 text-white/60 hover:bg-white/15 hover:text-white',
)}
>
{preset.label}
@@ -86,5 +96,6 @@ export const QuickSoundPopover = ({
})}
</div>
</div>
</div>
);
};

View File

@@ -28,6 +28,7 @@ import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
interface UseSpaceWorkspaceSelectionParams {
initialSceneId: string;
initialGoal: string;
initialFocusPlanItemId: string | null;
initialTimerLabel: string;
sceneQuery: string | null;
goalQuery: string;
@@ -64,6 +65,7 @@ const getVisibleSetupScenes = (selectedScene: SceneTheme) => {
export const useSpaceWorkspaceSelection = ({
initialSceneId,
initialGoal,
initialFocusPlanItemId,
initialTimerLabel,
sceneQuery,
goalQuery,
@@ -86,6 +88,7 @@ export const useSpaceWorkspaceSelection = ({
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
const [goalInput, setGoalInput] = useState(initialGoal);
const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false);
@@ -262,6 +265,7 @@ export const useSpaceWorkspaceSelection = ({
const handleGoalChipSelect = useCallback((chip: GoalChip) => {
setShowResumePrompt(false);
setLinkedFocusPlanItemId(null);
setSelectedGoalId(chip.id);
setGoalInput(chip.label);
}, []);
@@ -271,6 +275,7 @@ export const useSpaceWorkspaceSelection = ({
setShowResumePrompt(false);
}
setLinkedFocusPlanItemId(null);
setGoalInput(value);
if (value.trim().length === 0) {
@@ -385,6 +390,7 @@ export const useSpaceWorkspaceSelection = ({
setSelectedTimerLabel(nextTimerLabel);
setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal);
setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null);
setSelectedGoalId(null);
setShowResumePrompt(false);
});
@@ -418,6 +424,7 @@ export const useSpaceWorkspaceSelection = ({
selectedSceneId,
selectedTimerLabel,
goalInput,
linkedFocusPlanItemId,
selectedGoalId,
resumeGoal,
showResumePrompt,
@@ -428,6 +435,7 @@ export const useSpaceWorkspaceSelection = ({
setupScenes,
canStart,
setGoalInput,
setLinkedFocusPlanItemId,
setSelectedGoalId,
setShowResumePrompt,
setResumeGoal,

View File

@@ -17,6 +17,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
canStart: boolean;
currentSession: FocusSession | null;
goalInput: string;
linkedFocusPlanItemId: string | null;
selectedSceneId: string;
selectedTimerLabel: string;
selectedPresetId: string;
@@ -28,18 +29,24 @@ interface UseSpaceWorkspaceSessionControlsParams {
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId: string;
soundPresetId: string | null;
focusPlanItemId?: string;
entryPoint: SessionEntryPoint;
}) => Promise<FocusSession | null>;
pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>;
completeSession: (input: {
completionType: 'goal-complete';
advanceGoal: (input: {
completedGoal: string;
}) => Promise<FocusSession | null>;
nextGoal: string;
sceneId: string;
timerPresetId: string;
soundPresetId: string;
focusPlanItemId?: string;
}) => Promise<{ nextSession: FocusSession } | null>;
abandonSession: () => Promise<boolean>;
setGoalInput: (value: string) => void;
setLinkedFocusPlanItemId: (value: string | null) => void;
setSelectedGoalId: (value: string | null) => void;
setShowResumePrompt: (value: boolean) => void;
}
@@ -54,6 +61,7 @@ export const useSpaceWorkspaceSessionControls = ({
canStart,
currentSession,
goalInput,
linkedFocusPlanItemId,
selectedSceneId,
selectedTimerLabel,
selectedPresetId,
@@ -65,9 +73,10 @@ export const useSpaceWorkspaceSessionControls = ({
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
advanceGoal,
abandonSession,
setGoalInput,
setLinkedFocusPlanItemId,
setSelectedGoalId,
setShowResumePrompt,
}: UseSpaceWorkspaceSessionControlsParams) => {
@@ -110,6 +119,7 @@ export const useSpaceWorkspaceSessionControls = ({
goal: trimmedGoal,
timerPresetId,
soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
entryPoint: pendingSessionEntryPoint,
});
@@ -129,6 +139,7 @@ export const useSpaceWorkspaceSessionControls = ({
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
linkedFocusPlanItemId,
setPreviewPlaybackState,
startSession,
]);
@@ -222,33 +233,61 @@ export const useSpaceWorkspaceSessionControls = ({
const handleGoalAdvance = useCallback(async (nextGoal: string) => {
const trimmedNextGoal = nextGoal.trim();
const trimmedCurrentGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
if (!trimmedNextGoal) {
return;
if (!trimmedNextGoal || !trimmedCurrentGoal || !timerPresetId || !currentSession) {
return false;
}
if (currentSession) {
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: goalInput.trim(),
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
const nextState = await advanceGoal({
completedGoal: trimmedCurrentGoal,
nextGoal: trimmedNextGoal,
sceneId: selectedSceneId,
timerPresetId,
soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
});
if (!completedSession) {
if (!nextState) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return;
}
return false;
}
setGoalInput(trimmedNextGoal);
setLinkedFocusPlanItemId(nextState.nextSession.focusPlanItemId ?? null);
setSelectedGoalId(null);
setShowResumePrompt(false);
setPendingSessionEntryPoint('goal-complete');
setPreviewPlaybackState('paused');
setPreviewPlaybackState('running');
setWorkspaceMode('focus');
pushStatusLine({
message: copy.space.workspace.nextGoalReady,
message: copy.space.workspace.nextGoalStarted,
});
}, [completeSession, currentSession, goalInput, pushStatusLine, setGoalInput, setPendingSessionEntryPoint, setPreviewPlaybackState, setSelectedGoalId]);
return true;
}, [
advanceGoal,
currentSession,
goalInput,
linkedFocusPlanItemId,
pushStatusLine,
resolveSoundPlaybackUrl,
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
setGoalInput,
setLinkedFocusPlanItemId,
setPendingSessionEntryPoint,
setPreviewPlaybackState,
setSelectedGoalId,
setShowResumePrompt,
setWorkspaceMode,
unlockPlayback,
]);
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;

View File

@@ -1,45 +1,48 @@
'use client';
"use client";
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
import {
getSceneStageBackgroundStyle,
getSceneStagePhotoUrl,
preloadAssetImage,
useMediaCatalog,
} from '@/entities/media';
} from "@/entities/media";
import { getSceneById, SCENE_THEMES } from "@/entities/scene";
import { GOAL_CHIPS, SOUND_PRESETS, useThoughtInbox } from "@/entities/session";
import { useFocusSessionEngine } from "@/features/focus-session";
import {
GOAL_CHIPS,
SOUND_PRESETS,
useThoughtInbox,
} from '@/entities/session';
import { useFocusSessionEngine } from '@/features/focus-session';
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
import type { SessionEntryPoint, WorkspaceMode } from '../model/types';
import { useSpaceWorkspaceSelection } from '../model/useSpaceWorkspaceSelection';
import { useSpaceWorkspaceSessionControls } from '../model/useSpaceWorkspaceSessionControls';
useSoundPlayback,
useSoundPresetSelection,
} from "@/features/sound-preset";
import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import {
resolveFocusTimeDisplayFromTimerLabel,
resolveInitialSceneId,
resolveInitialSoundPreset,
resolveInitialTimerLabel,
resolveTimerLabelFromPresetId,
TIMER_SELECTION_PRESETS,
resolveFocusTimeDisplayFromTimerLabel,
} from '../model/workspaceSelection';
import { FocusTopToast } from './FocusTopToast';
} from "../model/workspaceSelection";
import { FocusTopToast } from "./FocusTopToast";
export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const sceneQuery = searchParams.get('scene') ?? searchParams.get('room');
const goalQuery = searchParams.get('goal')?.trim() ?? '';
const soundQuery = searchParams.get('sound');
const timerQuery = searchParams.get('timer');
const hasQueryOverrides = Boolean(sceneQuery || goalQuery || soundQuery || timerQuery);
const router = useRouter();
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
const goalQuery = searchParams.get("goal")?.trim() ?? "";
const focusPlanItemIdQuery = searchParams.get("planItemId");
const soundQuery = searchParams.get("sound");
const timerQuery = searchParams.get("timer");
const hasQueryOverrides = Boolean(
sceneQuery || goalQuery || focusPlanItemIdQuery || soundQuery || timerQuery,
);
const {
thoughts,
@@ -60,22 +63,39 @@ export const SpaceWorkspaceWidget = () => {
hasResolvedManifest,
} = useMediaCatalog();
const initialSceneId = useMemo(() => resolveInitialSceneId(sceneQuery, undefined), [sceneQuery]);
const initialScene = useMemo(() => getSceneById(initialSceneId) ?? SCENE_THEMES[0], [initialSceneId]);
const initialSoundPresetId = useMemo(() => resolveInitialSoundPreset(
const initialSceneId = useMemo(
() => resolveInitialSceneId(sceneQuery, undefined),
[sceneQuery],
);
const initialScene = useMemo(
() => getSceneById(initialSceneId) ?? SCENE_THEMES[0],
[initialSceneId],
);
const initialSoundPresetId = useMemo(
() =>
resolveInitialSoundPreset(
soundQuery,
undefined,
initialScene.recommendedSoundPresetId,
), [initialScene.recommendedSoundPresetId, soundQuery]);
const initialTimerLabel = useMemo(() => resolveInitialTimerLabel(
),
[initialScene.recommendedSoundPresetId, soundQuery],
);
const initialTimerLabel = useMemo(
() =>
resolveInitialTimerLabel(
timerQuery,
undefined,
initialScene.recommendedTimerPresetId,
), [initialScene.recommendedTimerPresetId, timerQuery]);
),
[initialScene.recommendedTimerPresetId, timerQuery],
);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('paused');
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = useState<SessionEntryPoint>('space-setup');
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup");
const [previewPlaybackState, setPreviewPlaybackState] = useState<
"running" | "paused"
>("paused");
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>("space-setup");
const {
selectedPresetId,
@@ -85,9 +105,9 @@ export const SpaceWorkspaceWidget = () => {
isMuted,
setMuted,
} = useSoundPresetSelection(initialSoundPresetId);
const {
currentSession,
isBootstrapping,
isMutating: isSessionMutating,
timeDisplay,
playbackState,
@@ -97,15 +117,16 @@ export const SpaceWorkspaceWidget = () => {
resumeSession,
restartCurrentPhase,
updateCurrentSelection,
completeSession,
advanceGoal,
abandonSession,
} = useFocusSessionEngine();
const isFocusMode = workspaceMode === 'focus';
const isFocusMode = workspaceMode === "focus";
const resolvedPlaybackState = currentSession?.state ?? previewPlaybackState;
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
const shouldPlaySound = isFocusMode && resolvedPlaybackState === "running";
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
const { activeStatus, pushStatusLine, runActiveAction } =
useHudStatusLine(isFocusMode);
const { error: soundPlaybackError, unlockPlayback } = useSoundPlayback({
selectedPresetId,
@@ -116,7 +137,7 @@ export const SpaceWorkspaceWidget = () => {
});
const resolveSoundPlaybackUrl = (presetId: string) => {
if (presetId === 'silent') {
if (presetId === "silent") {
return null;
}
const asset = soundAssetMap[presetId];
@@ -126,6 +147,7 @@ export const SpaceWorkspaceWidget = () => {
const selection = useSpaceWorkspaceSelection({
initialSceneId,
initialGoal: goalQuery,
initialFocusPlanItemId: focusPlanItemIdQuery,
initialTimerLabel,
sceneQuery,
goalQuery,
@@ -156,6 +178,7 @@ export const SpaceWorkspaceWidget = () => {
canStart: selection.canStart,
currentSession,
goalInput: selection.goalInput,
linkedFocusPlanItemId: selection.linkedFocusPlanItemId,
selectedSceneId: selection.selectedSceneId,
selectedTimerLabel: selection.selectedTimerLabel,
selectedPresetId,
@@ -167,27 +190,47 @@ export const SpaceWorkspaceWidget = () => {
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
advanceGoal,
abandonSession,
setGoalInput: selection.setGoalInput,
setLinkedFocusPlanItemId: selection.setLinkedFocusPlanItemId,
setSelectedGoalId: selection.setSelectedGoalId,
setShowResumePrompt: selection.setShowResumePrompt,
});
useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
router.replace("/app");
}
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
useEffect(() => {
const preferMobile =
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
preloadAssetImage(getSceneStagePhotoUrl(selection.selectedScene, selection.selectedSceneAsset, { preferMobile }));
typeof window !== "undefined"
? window.matchMedia("(max-width: 767px)").matches
: false;
preloadAssetImage(
getSceneStagePhotoUrl(
selection.selectedScene,
selection.selectedSceneAsset,
{ preferMobile },
),
);
}, [selection.selectedScene, selection.selectedSceneAsset]);
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
const resolvedTimeDisplay =
timeDisplay ??
resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
return (
<div className="relative h-dvh overflow-hidden text-white">
<div
aria-hidden
className="absolute -inset-8 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
style={getSceneStageBackgroundStyle(selection.selectedScene, selection.selectedSceneAsset)}
style={getSceneStageBackgroundStyle(
selection.selectedScene,
selection.selectedSceneAsset,
)}
/>
<div className="relative z-10 flex h-full flex-col">
@@ -208,8 +251,12 @@ export const SpaceWorkspaceWidget = () => {
timerPresets={TIMER_SELECTION_PRESETS}
canStart={selection.canStart}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
onSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
onGoalChange={selection.handleGoalChange}
onGoalChipSelect={selection.handleGoalChipSelect}
onStart={controls.handleSetupFocusOpen}
@@ -221,10 +268,13 @@ export const SpaceWorkspaceWidget = () => {
selection.setGoalInput(selection.resumeGoal);
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
controls.openFocusMode(selection.resumeGoal, 'resume-restore');
controls.openFocusMode(
selection.resumeGoal,
"resume-restore",
);
},
onStartFresh: () => {
selection.setGoalInput('');
selection.setGoalInput("");
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
},
@@ -235,12 +285,13 @@ export const SpaceWorkspaceWidget = () => {
<SpaceFocusHudWidget
goal={selection.goalInput.trim()}
microStep={currentSession?.microStep ?? null}
timerLabel={selection.selectedTimerLabel}
timeDisplay={resolvedTimeDisplay}
visible={isFocusMode}
hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'}
sessionPhase={phase ?? "focus"}
isSessionActionPending={isSessionMutating}
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
@@ -260,7 +311,7 @@ export const SpaceWorkspaceWidget = () => {
<FocusTopToast
visible={isFocusMode && Boolean(activeStatus)}
message={activeStatus?.message ?? ''}
message={activeStatus?.message ?? ""}
actionLabel={activeStatus?.action?.label}
onAction={runActiveAction}
/>
@@ -276,15 +327,25 @@ export const SpaceWorkspaceWidget = () => {
thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
onQuickSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onQuickSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
sceneRecommendedSoundLabel={selection.selectedScene.recommendedSound}
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selection.selectedScene.recommendedTimerPresetId) ?? selection.selectedTimerLabel}
sceneRecommendedTimerLabel={
resolveTimerLabelFromPresetId(
selection.selectedScene.recommendedTimerPresetId,
) ?? selection.selectedTimerLabel
}
soundVolume={masterVolume}
onSetSoundVolume={setMasterVolume}
isSoundMuted={isMuted}
onSetSoundMuted={setMuted}
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
onCaptureThought={(note) =>
addThought(note, selection.selectedScene.name)
}
onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought}

View File

@@ -1,34 +1,8 @@
'use client';
import Link from 'next/link';
import { copy } from '@/shared/i18n';
import { useFocusStats } from '@/features/stats';
const StatSection = ({
title,
items,
}: {
title: string;
items: Array<{ id: string; label: string; value: string; delta: string }>;
}) => {
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{title}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{items.map((item) => (
<article
key={item.id}
className="rounded-xl border border-brand-dark/10 bg-white/80 p-4 backdrop-blur-sm"
>
<p className="text-xs text-brand-dark/58">{item.label}</p>
<p className="mt-2 text-xl font-semibold text-brand-dark">{item.value}</p>
<p className="mt-1 text-xs text-brand-primary/90">{item.delta}</p>
</article>
))}
</div>
</section>
);
};
import { copy } from '@/shared/i18n';
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes);
@@ -42,55 +16,84 @@ const formatMinutes = (minutes: number) => {
return `${hourPart}h ${minutePart}m`;
};
const FactualStatCard = ({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) => {
return (
<article className="rounded-2xl border border-brand-dark/10 bg-white/82 p-4 backdrop-blur-sm">
<p className="text-xs text-brand-dark/58">{label}</p>
<p className="mt-2 text-xl font-semibold text-brand-dark">{value}</p>
<p className="mt-1 text-xs text-brand-dark/48">{hint}</p>
</article>
);
};
export const StatsOverviewWidget = () => {
const { stats } = copy;
const { summary, isLoading, error, source, refetch } = useFocusStats();
const todayItems = [
{
id: 'today-focus',
label: stats.todayFocus,
value: formatMinutes(summary.today.focusMinutes),
delta: source === 'api' ? stats.apiLabel : stats.mockLabel,
hint: source === 'api' ? stats.apiLabel : stats.mockLabel,
},
{
id: 'today-cycles',
id: 'today-complete',
label: stats.completedCycles,
value: `${summary.today.completedCycles}${stats.countUnit}`,
delta: `${summary.today.completedCycles > 0 ? '+' : ''}${summary.today.completedCycles}`,
hint: '오늘 완료한 focus cycle',
},
{
id: 'today-entry',
label: stats.sessionEntries,
value: `${summary.today.sessionEntries}${stats.countUnit}`,
delta: source === 'api' ? stats.syncedApi : stats.temporary,
hint: '오늘 space에 들어간 횟수',
},
];
const weeklyItems = [
{
id: 'week-focus',
label: stats.last7DaysFocus,
value: formatMinutes(summary.last7Days.focusMinutes),
delta: source === 'api' ? stats.actualAggregate : stats.mockAggregate,
hint: '최근 7일 총 focus 시간',
},
{
id: 'week-best-day',
label: stats.bestDay,
value: summary.last7Days.bestDayLabel,
delta: formatMinutes(summary.last7Days.bestDayFocusMinutes),
id: 'week-started',
label: '시작한 세션',
value: `${summary.last7Days.startedSessions}${stats.countUnit}`,
hint: '최근 7일 시작 횟수',
},
{
id: 'week-consistency',
label: stats.streak,
value: `${summary.last7Days.streakDays}${stats.dayUnit}`,
delta: summary.last7Days.streakDays > 0 ? stats.streakActive : stats.streakStart,
id: 'week-completed',
label: '완료한 세션',
value: `${summary.last7Days.completedSessions}${stats.countUnit}`,
hint: '최근 7일 goal/timer 완료 횟수',
},
{
id: 'week-carry',
label: '이월된 블록',
value: `${summary.last7Days.carriedOverCount}${stats.countUnit}`,
hint: '다음 날로 이어진 계획 수',
},
];
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_18%_0%,rgba(167,204,237,0.45),transparent_50%),radial-gradient(circle_at_88%_8%,rgba(191,219,254,0.4),transparent_42%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_52%,#e9f1fa_100%)] text-brand-dark">
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
<header className="mb-6 flex items-center justify-between rounded-xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
<div className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 sm:px-6">
<header className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
<div>
<h1 className="text-xl font-semibold">{stats.title}</h1>
<p className="mt-1 text-xs text-brand-dark/56"> insight factual summary만 .</p>
</div>
<Link
href="/app"
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
@@ -100,7 +103,7 @@ export const StatsOverviewWidget = () => {
</header>
<div className="space-y-6">
<section className="rounded-xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
<section className="rounded-2xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs font-medium text-brand-dark/72">
@@ -126,12 +129,27 @@ export const StatsOverviewWidget = () => {
</div>
</section>
<StatSection title={stats.today} items={todayItems} />
<StatSection title={stats.last7Days} items={weeklyItems} />
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.today}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{todayItems.map((item) => (
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.last7Days}</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{weeklyItems.map((item) => (
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.chartTitle}</h2>
<div className="rounded-xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
<div className="rounded-2xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
<div className="h-52 rounded-lg border border-brand-dark/12 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))] p-4">
{summary.trend.length > 0 ? (
<div className="flex h-full items-end gap-2">
@@ -145,9 +163,7 @@ export const StatsOverviewWidget = () => {
style={{ height: `${barHeight}%` }}
title={stats.barTitle(point.date, point.focusMinutes)}
/>
<span className="text-[10px] text-brand-dark/56">
{point.date.slice(5)}
</span>
<span className="text-[10px] text-brand-dark/56">{point.date.slice(5)}</span>
</div>
);
})}
@@ -155,9 +171,7 @@ export const StatsOverviewWidget = () => {
) : null}
</div>
<p className="mt-3 text-xs text-brand-dark/56">
{summary.trend.length > 0
? stats.chartWithTrend
: stats.chartWithoutTrend}
{summary.trend.length > 0 ? stats.chartWithTrend : stats.chartWithoutTrend}
</p>
</div>
</section>