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

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