fix(flow): 기획-구현 불일치 정렬

This commit is contained in:
2026-03-15 11:46:21 +09:00
parent de95505d2f
commit 6bf3336aec
11 changed files with 262 additions and 199 deletions

View File

@@ -17,6 +17,14 @@ import { cn } from '@/shared/lib/cn';
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
const DEFAULT_TIMER_ID = '50-10';
const REVIEW_ENTRY_PRESETS = {
'forest-50-10': {
sceneId: DEFAULT_SCENE_ID,
soundPresetId: DEFAULT_SOUND_ID,
timerPresetId: DEFAULT_TIMER_ID,
label: '숲 · 50/10 · Forest Birds',
},
} as const;
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
const entryCopy = {
@@ -45,6 +53,9 @@ const entryCopy = {
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?',
reviewCtaPro: '나에게 맞는 흐름 보기',
reviewHelperPro: '가장 잘 맞았던 ritual과 carry-forward를 보고 돌아올 수 있어요.',
resumeReviewEyebrow: 'Weekly Review',
resumeReviewTitle: '잠깐 review를 보고 다시 들어갈 수 있어요.',
resumeReviewHelper: '현재 세션은 그대로 두고, 이번 주 흐름만 짧게 확인합니다.',
reviewReturnEyebrow: '방금 본 review 기준',
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
@@ -109,6 +120,15 @@ export const FocusDashboardWidget = () => {
const { sceneAssetMap } = useMediaCatalog();
const { review, summary: weeklySummary } = useFocusStats();
const reviewEntryPreset = searchParams.get('entryPreset');
const reviewEntryPresetConfig = useMemo(() => {
if (!reviewEntryPreset) {
return null;
}
return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null;
}, [reviewEntryPreset]);
const [goalDraft, setGoalDraft] = useState('');
const [microStepDraft, setMicroStepDraft] = useState('');
const [isStartingSession, setIsStartingSession] = useState(false);
@@ -120,8 +140,8 @@ export const FocusDashboardWidget = () => {
const goalInputRef = useRef<HTMLInputElement | null>(null);
const activeScene = useMemo(() => {
return getSceneById(currentSession?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
}, [currentSession?.sceneId]);
return getSceneById(currentSession?.sceneId ?? reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
}, [currentSession?.sceneId, reviewEntryPresetConfig?.sceneId]);
const activeRitualMeta = useMemo(() => {
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
@@ -138,7 +158,6 @@ export const FocusDashboardWidget = () => {
review.recoveryQuality.availability === 'ready');
const reviewSource = searchParams.get('review');
const reviewCarryHint = searchParams.get('carryHint');
const reviewEntryPreset = searchParams.get('entryPreset');
const normalizedReviewCarryHint: ReviewCarryHint | null =
reviewCarryHint === 'steady' ||
reviewCarryHint === 'smaller' ||
@@ -151,11 +170,12 @@ export const FocusDashboardWidget = () => {
const reviewReturnCopy =
normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null;
const reviewReturnRitualLabel =
isPro && reviewEntryPreset === 'forest-50-10' ? entryCopy.reviewReturnRitualLabel : null;
isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null;
const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle;
const reviewTeaserSummary = isPro ? review.carryForward.keepDoing : review.snapshotSummary;
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta;
const entryRitualHint = reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : entryCopy.ritualHint;
useEffect(() => {
let cancelled = false;
@@ -215,9 +235,9 @@ export const FocusDashboardWidget = () => {
await focusSessionApi.startSession({
goal: trimmedGoal,
microStep: microStepDraft.trim() || null,
sceneId: DEFAULT_SCENE_ID,
soundPresetId: DEFAULT_SOUND_ID,
timerPresetId: DEFAULT_TIMER_ID,
sceneId: reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
soundPresetId: reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
timerPresetId: reviewEntryPresetConfig?.timerPresetId ?? DEFAULT_TIMER_ID,
entryPoint: 'space-setup',
});
router.push('/space');
@@ -235,6 +255,8 @@ export const FocusDashboardWidget = () => {
const shouldShowWeeklyReviewTeaser =
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
const shouldShowResumeReviewEntry =
!isCheckingSession && Boolean(currentSession) && hasEnoughWeeklyData;
return (
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
@@ -264,37 +286,6 @@ export const FocusDashboardWidget = () => {
</p>
<p className="text-[15px] text-white/72"> .</p>
</div>
) : currentSession ? (
<div className={cn(goalCardClass, 'space-y-5')}>
<div className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
{entryCopy.resumeEyebrow}
</p>
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
{currentSession.goal}
</h1>
<p className="text-sm text-white/68">
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
</p>
{currentSession.microStep ? (
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
{entryCopy.resumeMicroStepLabel}
</p>
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
</div>
) : null}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
{entryCopy.resumeCta}
</button>
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
</div>
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
</div>
) : (
<div className="space-y-4">
{reviewReturnCopy ? (
@@ -314,129 +305,188 @@ export const FocusDashboardWidget = () => {
</div>
) : null}
<div className={goalCardClass}>
<div className="space-y-3 text-center">
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
{entryCopy.title}
</h1>
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
{entryCopy.description}
</p>
</div>
<div className="mt-8 space-y-4">
<label className="block">
<span className="sr-only">Goal</span>
<input
ref={goalInputRef}
value={goalDraft}
onChange={(event) => setGoalDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleStartSession();
}
}}
placeholder={entryCopy.goalPlaceholder}
className={cn(
inputShellClass,
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
)}
autoFocus
/>
</label>
<label className="block space-y-2">
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
{entryCopy.microStepLabel}
</span>
<input
value={microStepDraft}
onChange={(event) => setMicroStepDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleStartSession();
}
}}
placeholder={entryCopy.microStepPlaceholder}
className={cn(inputShellClass, 'text-[0.98rem] placeholder:text-white/30')}
/>
</label>
<p className="text-sm text-white/48">{entryCopy.microStepHelper}</p>
</div>
<div className="mt-6 flex flex-wrap gap-2.5">
{GOAL_SUGGESTIONS.map((suggestion) => {
const isActive = trimmedGoal === suggestion.label;
return (
<button
key={suggestion.id}
type="button"
onClick={() => handleSelectSuggestion(suggestion.label)}
className={cn(
'rounded-full border px-3.5 py-1.5 text-sm transition',
isActive
? 'border-white/32 bg-white/14 text-white'
: 'border-white/14 bg-white/[0.04] text-white/72 hover:border-white/22 hover:text-white',
)}
>
{suggestion.label}
</button>
);
})}
</div>
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={() => {
void handleStartSession();
}}
disabled={!canStart}
className={primaryButtonClass}
>
{isStartingSession ? entryCopy.startLoading : entryCopy.startNow}
</button>
<div className="space-y-1 text-left sm:text-right">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-white/44">
{entryCopy.ritualHint}
{currentSession ? (
<div className={cn(goalCardClass, 'space-y-5')}>
<div className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
{entryCopy.resumeEyebrow}
</p>
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
{currentSession.goal}
</h1>
<p className="text-sm text-white/68">
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
</p>
{currentSession.microStep ? (
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
{entryCopy.resumeMicroStepLabel}
</p>
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
</div>
) : null}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
{entryCopy.resumeCta}
</button>
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
</div>
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
{shouldShowResumeReviewEntry ? (
<Link
href="/stats"
className="block rounded-[1.35rem] border border-white/10 bg-[#0f1115]/12 px-4 py-3 backdrop-blur-lg transition hover:bg-[#0f1115]/18"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
{entryCopy.resumeReviewEyebrow}
</p>
<p className="mt-2 text-[0.96rem] font-medium tracking-[-0.02em] text-white/88">
{entryCopy.resumeReviewTitle}
</p>
<p className="mt-2 max-w-[30rem] text-[12px] leading-[1.6] text-white/60">
{isPro ? review.carryForward.keepDoing : entryCopy.resumeReviewHelper}
</p>
</div>
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/72">
{reviewTeaserCta}
</span>
</div>
</Link>
) : null}
</div>
{sessionLookupError ? (
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
) : null}
</div>
{shouldShowWeeklyReviewTeaser ? (
<Link
href="/stats"
className="block rounded-[1.6rem] border border-white/10 bg-[#0f1115]/18 px-5 py-4 backdrop-blur-lg transition hover:bg-[#0f1115]/24"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
{entryCopy.reviewEyebrow}
) : (
<>
<div className={goalCardClass}>
<div className="space-y-3 text-center">
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
{entryCopy.title}
</h1>
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
{entryCopy.description}
</p>
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
{reviewTeaserTitle}
</p>
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
{reviewTeaserSummary}
</p>
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
</div>
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
{reviewTeaserCta}
</span>
<div className="mt-8 space-y-4">
<label className="block">
<span className="sr-only">Goal</span>
<input
ref={goalInputRef}
value={goalDraft}
onChange={(event) => setGoalDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleStartSession();
}
}}
placeholder={entryCopy.goalPlaceholder}
className={cn(
inputShellClass,
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
)}
autoFocus
/>
</label>
<label className="block space-y-2">
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
{entryCopy.microStepLabel}
</span>
<input
value={microStepDraft}
onChange={(event) => setMicroStepDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleStartSession();
}
}}
placeholder={entryCopy.microStepPlaceholder}
className={cn(inputShellClass, 'text-[0.98rem] placeholder:text-white/30')}
/>
</label>
<p className="text-sm text-white/48">{entryCopy.microStepHelper}</p>
</div>
<div className="mt-6 flex flex-wrap gap-2.5">
{GOAL_SUGGESTIONS.map((suggestion) => {
const isActive = trimmedGoal === suggestion.label;
return (
<button
key={suggestion.id}
type="button"
onClick={() => handleSelectSuggestion(suggestion.label)}
className={cn(
'rounded-full border px-3.5 py-1.5 text-sm transition',
isActive
? 'border-white/32 bg-white/14 text-white'
: 'border-white/14 bg-white/[0.04] text-white/72 hover:border-white/22 hover:text-white',
)}
>
{suggestion.label}
</button>
);
})}
</div>
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={() => {
void handleStartSession();
}}
disabled={!canStart}
className={primaryButtonClass}
>
{isStartingSession ? entryCopy.startLoading : entryCopy.startNow}
</button>
<div className="space-y-1 text-left sm:text-right">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-white/44">
{entryRitualHint}
</p>
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
</div>
</div>
{sessionLookupError ? (
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
) : null}
</div>
</Link>
) : null}
{shouldShowWeeklyReviewTeaser ? (
<Link
href="/stats"
className="block rounded-[1.6rem] border border-white/10 bg-[#0f1115]/18 px-5 py-4 backdrop-blur-lg transition hover:bg-[#0f1115]/24"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
{entryCopy.reviewEyebrow}
</p>
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
{reviewTeaserTitle}
</p>
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
{reviewTeaserSummary}
</p>
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
</div>
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
{reviewTeaserCta}
</span>
</div>
</Link>
) : null}
</>
)}
</div>
)}
</div>