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:
63
src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
Normal file
63
src/widgets/space-focus-hud/ui/FloatingGoalWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,34 +108,20 @@ export const GoalCompleteSheet = ({
|
||||
placeholder={placeholder}
|
||||
className="h-9 w-full rounded-xl border border-white/14 bg-white/[0.04] px-3 text-sm text-white placeholder:text-white/40 focus:border-sky-200/42 focus:outline-none"
|
||||
/>
|
||||
|
||||
<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"
|
||||
onClick={onRest}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
>
|
||||
{copy.space.goalComplete.restButton}
|
||||
</button>
|
||||
onClick={onRest}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
>
|
||||
{copy.space.goalComplete.restButton}
|
||||
</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>
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user