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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user