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,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">
{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();
}
}}
/>
</header>
<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'
)} />
<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>
{/* 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>
<PlanPill
plan={planTier}
onClick={() => {
if (!isPro) openPaywall();
}}
/>
</header>
<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>
<input
ref={entryInputRef}
value={entryDraft}
onChange={(event) => handleEntryDraftChange(event.target.value)}
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"
/>
</label>
{/* Main Content Area */}
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
{/* 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={glassInputClass}
autoFocus
/>
{startHref ? (
<Link href={startHref} className={cn(startButtonClassName, 'sm:w-[164px]')}>
{focusEntryCopy.startNow}
</Link>
) : (
<button
type="button"
onClick={() => entryInputRef.current?.focus()}
className={cn(startButtonClassName, 'sm:w-[164px]')}
>
{focusEntryCopy.startNow}
</button>
)}
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
type="button"
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"
>
{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;
return (
<button
key={item.id}
type="button"
onClick={() => handleSelectPlanItem(item)}
className={cn(
previewButtonClassName,
isSelected && 'border-brand-primary/24 bg-brand-primary/8',
)}
>
<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}
<button
type="button"
onClick={() => setManageSheetOpen(true)}
disabled={!canManagePlan}
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
>
{focusEntryCopy.manageBlocks}
</button>
</div>
</section>
</div>
</div>
</div>
<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"
{/* 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
type="button"
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"
>
{focusEntryCopy.reviewLinkLabel}
</Link>
수정
</button>
</div>
{error && source === 'api' ? <p className="px-1 text-xs text-rose-500">{error}</p> : null}
</main>
</div>
</div>
<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>
{/* 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)}
>
<span className="text-sm font-medium">{timer.label}</span>
</button>
))}
</div>
</div>
</div>
<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>
{/* Plan Sheet & Paywall */}
<FocusPlanManageSheet
isOpen={manageSheetOpen}
planItems={planItems}
@@ -533,6 +557,6 @@ export const FocusDashboardWidget = () => {
</div>
</div>
) : null}
</>
</div>
);
};
};