fix(flow): app entry를 no-session 전용으로 단순화

This commit is contained in:
2026-03-16 12:28:28 +09:00
parent 721212ec1f
commit 16d620ee4a
9 changed files with 172 additions and 787 deletions

View File

@@ -34,7 +34,7 @@ const REVIEW_ENTRY_PRESETS = {
sceneId: DEFAULT_SCENE_ID,
soundPresetId: DEFAULT_SOUND_ID,
timerPresetId: DEFAULT_TIMER_ID,
label: '숲 · 50/10 · Forest Birds',
label: '숲 · Forest Birds',
},
} as const;
const DEFAULT_ATMOSPHERE =
@@ -45,30 +45,12 @@ const entryCopy = {
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
durationLabel: '예상 시간(분)',
durationPlaceholder: '예: 70',
durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.',
durationHelper: '이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.',
startNow: '이 분위기로 들어가기',
startLoading: '입장 준비 중...',
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
atmosphereBody:
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
resumeEyebrow: 'Resume',
resumeRunning: '진행 중인 세션이 있어요.',
resumePaused: '잠시 멈춘 세션이 있어요.',
resumeCta: '이어서 몰입하기',
resumeRefocusCta: '한 조각 다시 잡기',
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
resumeMicroStepLabel: '마지막 한 조각',
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
resumeNewGoalHint: '새 목표로 전환하려면 지금 멈춘 세션을 먼저 어떻게 정리할지 결정해야 해요.',
resumeTakeoverCta: '새 목표로 전환',
takeoverEyebrow: '새 목표로 전환',
takeoverTitle: '현재 멈춘 세션을 어떻게 할까요?',
takeoverBody: '지금 멈춘 흐름을 조용히 없애지 않고, 먼저 정리한 뒤 새 목표로 넘어가요.',
takeoverKeepCta: '이어서 하기',
takeoverConfirmCta: '이 세션은 여기서 정리하고 새로 시작',
takeoverCancelCta: '취소',
takeoverLoading: '세션을 정리하는 중...',
takeoverFailed: '멈춘 세션을 정리하지 못했어요. 잠시 후 다시 시도해 주세요.',
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
reviewEyebrow: 'Weekly Review',
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
@@ -77,9 +59,6 @@ const entryCopy = {
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?',
reviewCtaPro: '나에게 맞는 흐름 보기',
reviewHelperPro: '가장 잘 맞았던 ritual과 carry-forward를 보고 돌아올 수 있어요.',
resumeReviewEyebrow: 'Weekly Review',
resumeReviewTitle: '잠깐 review를 보고 다시 들어갈 수 있어요.',
resumeReviewHelper: '현재 세션은 그대로 두고, 이번 주 흐름만 짧게 확인합니다.',
reviewReturnEyebrow: '방금 본 review 기준',
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
@@ -89,25 +68,13 @@ const entryCopy = {
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.',
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.',
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.',
reviewReturnRitualLabel: '추천 ritual · 숲 · 50/10 · Forest Birds',
reviewReturnRitualLabel: '추천 atmosphere · 숲 · Forest Birds',
paywallLead: 'Calm Session OS PRO',
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
};
const goalCardClass =
'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8';
const primaryButtonClass =
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
const secondaryButtonClass =
'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]';
const resolveSoundLabel = (soundPresetId?: string | null) => {
if (!soundPresetId) {
return 'Silent';
}
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
};
const reviewCarryCopyByHint: Record<
ReviewCarryHint,
@@ -171,10 +138,6 @@ export const FocusDashboardWidget = () => {
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
const [isCheckingSession, setIsCheckingSession] = useState(true);
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
const [isTakeoverSheetOpen, setIsTakeoverSheetOpen] = useState(false);
const [isResolvingTakeover, setIsResolvingTakeover] = useState(false);
const [takeoverError, setTakeoverError] = useState<string | null>(null);
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
const goalInputRef = useRef<HTMLInputElement | null>(null);
const selectedAtmosphere = useMemo(
@@ -192,14 +155,6 @@ export const FocusDashboardWidget = () => {
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
}, [currentSession?.sceneId, selectedAtmosphere.sceneId]);
const activeRitualMeta = useMemo(() => {
const timerLabel =
getTimerPresetMetaById(currentSession?.timerPresetId ?? DEFAULT_TIMER_ID).label;
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
const trimmedGoal = goalDraft.trim();
const canStart =
trimmedGoal.length > 0 &&
@@ -232,11 +187,8 @@ export const FocusDashboardWidget = () => {
const durationHelper =
parsedDurationMinutes === null
? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.'
: parsedDurationMinutes === resolvedTimerPreset.focusMinutes
? `${entryCopy.durationHelper} 지금은 ${resolvedTimerPreset.label} 리듬으로 바로 들어가요.`
: `${entryCopy.durationHelper} ${parsedDurationMinutes}분은 지금 ${resolvedTimerPreset.label} 리듬으로 먼저 들어가요.`;
const isRunningSession = currentSession?.state === 'running';
const isPausedSession = currentSession?.state === 'paused';
: entryCopy.durationHelper;
const hasCurrentSession = Boolean(currentSession);
useEffect(() => {
let cancelled = false;
@@ -272,25 +224,10 @@ export const FocusDashboardWidget = () => {
}, []);
useEffect(() => {
if (!isCheckingSession && isRunningSession) {
if (!isCheckingSession && hasCurrentSession) {
router.replace('/space');
}
}, [isCheckingSession, isRunningSession, router]);
useEffect(() => {
if (!focusGoalAfterTakeover || currentSession) {
return;
}
const frameId = window.requestAnimationFrame(() => {
goalInputRef.current?.focus();
setFocusGoalAfterTakeover(false);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [currentSession, focusGoalAfterTakeover]);
}, [hasCurrentSession, isCheckingSession, router]);
const openPaywall = () => {
if (!isPro) {
@@ -298,13 +235,6 @@ export const FocusDashboardWidget = () => {
}
};
const resetEntryDrafts = () => {
setGoalDraft('');
setSelectedAtmosphereId(initialAtmosphere.id);
setDurationDraft(String(initialDurationMinutes));
setHasEditedDuration(false);
};
const handleDurationChange = (value: string) => {
setDurationDraft(sanitizeDurationDraft(value));
setHasEditedDuration(true);
@@ -369,56 +299,8 @@ export const FocusDashboardWidget = () => {
setIsStartingSession(false);
};
const handleResumeSession = () => {
router.push('/space?resume=continue');
};
const handleResumeRefocus = () => {
router.push('/space?resume=refocus');
};
const handleOpenTakeoverSheet = () => {
setTakeoverError(null);
setIsTakeoverSheetOpen(true);
};
const handleCloseTakeoverSheet = () => {
if (isResolvingTakeover) {
return;
}
setIsTakeoverSheetOpen(false);
setTakeoverError(null);
};
const handleConfirmTakeover = async () => {
if (!currentSession || isResolvingTakeover) {
return;
}
setIsResolvingTakeover(true);
setTakeoverError(null);
try {
await focusSessionApi.abandonSession();
setCurrentSession(null);
setIsTakeoverSheetOpen(false);
setSessionLookupError(null);
resetEntryDrafts();
setFocusGoalAfterTakeover(true);
} catch (error) {
setTakeoverError(
error instanceof Error ? error.message : entryCopy.takeoverFailed,
);
} finally {
setIsResolvingTakeover(false);
}
};
const shouldShowWeeklyReviewTeaser =
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
const shouldShowResumeReviewEntry =
!isCheckingSession && isPausedSession && hasEnoughWeeklyData;
return (
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
@@ -443,9 +325,6 @@ export const FocusDashboardWidget = () => {
<div className="w-full max-w-[42rem]">
{isCheckingSession ? (
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
{entryCopy.resumeEyebrow}
</p>
<p className="text-[15px] text-white/72"> .</p>
</div>
) : (
@@ -467,135 +346,58 @@ export const FocusDashboardWidget = () => {
</div>
) : null}
{isRunningSession ? (
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
{entryCopy.resumeEyebrow}
</p>
<p className="text-[15px] text-white/72">{entryCopy.resumeRouting}</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">
{entryCopy.resumePaused}
</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">
<div className="flex flex-col gap-2.5 sm:flex-row sm:flex-wrap sm:items-center">
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
{entryCopy.resumeCta}
</button>
<button type="button" onClick={handleResumeRefocus} className={secondaryButtonClass}>
{entryCopy.resumeRefocusCta}
</button>
</div>
<p className="text-xs text-white/48 sm:max-w-[15rem] sm:text-right">{activeRitualMeta}</p>
</div>
<p className="text-sm text-white/56">{entryCopy.resumePausedHint}</p>
<p className="text-sm text-white/46">{entryCopy.resumeNewGoalHint}</p>
<button
type="button"
onClick={handleOpenTakeoverSheet}
className="inline-flex w-fit items-center text-sm font-medium text-white/62 transition hover:text-white/84"
>
{entryCopy.resumeTakeoverCta}
</button>
{shouldShowResumeReviewEntry ? (
<AppAtmosphereEntryShell
canStart={canStart}
durationDraft={durationDraft}
durationHelper={durationHelper}
durationInputLabel={entryCopy.durationLabel}
durationPlaceholder={entryCopy.durationPlaceholder}
durationSuggestions={ENTRY_DURATION_SUGGESTIONS}
goalDraft={goalDraft}
goalInputRef={goalInputRef}
goalPlaceholder={entryCopy.goalPlaceholder}
isStartingSession={isStartingSession}
reviewEntry={
shouldShowWeeklyReviewTeaser ? (
<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"
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="flex flex-col gap-3">
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
{entryCopy.resumeReviewEyebrow}
{entryCopy.reviewEyebrow}
</p>
<p className="mt-2 text-[0.96rem] font-medium tracking-[-0.02em] text-white/88">
{entryCopy.resumeReviewTitle}
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
{reviewTeaserTitle}
</p>
<p className="mt-2 max-w-[30rem] text-[12px] leading-[1.6] text-white/60">
{isPro ? review.carryForward.keepDoing : entryCopy.resumeReviewHelper}
<p className="mt-2 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/72">
<span className="inline-flex items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
{reviewTeaserCta}
</span>
</div>
</Link>
) : null}
</div>
) : (
<AppAtmosphereEntryShell
canStart={canStart}
durationDraft={durationDraft}
durationHelper={durationHelper}
durationInputLabel={entryCopy.durationLabel}
durationPlaceholder={entryCopy.durationPlaceholder}
durationSuggestions={ENTRY_DURATION_SUGGESTIONS}
goalDraft={goalDraft}
goalInputRef={goalInputRef}
goalPlaceholder={entryCopy.goalPlaceholder}
isStartingSession={isStartingSession}
reviewEntry={
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">
<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 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 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
{reviewTeaserCta}
</span>
</div>
</Link>
) : undefined
}
selectedAtmosphere={selectedAtmosphere}
sessionLookupError={sessionLookupError}
startButtonLabel={entryCopy.startNow}
startButtonLoadingLabel={entryCopy.startLoading}
atmosphereOptions={ATMOSPHERE_OPTIONS}
atmosphereTitle={entryCopy.atmosphereTitle}
atmosphereBody={entryCopy.atmosphereBody}
onDurationChange={handleDurationChange}
onGoalChange={setGoalDraft}
onSelectAtmosphere={handleSelectAtmosphere}
onSelectDuration={handleSelectDuration}
onStartSession={() => {
void handleStartSession();
}}
/>
)}
) : undefined
}
selectedAtmosphere={selectedAtmosphere}
sessionLookupError={sessionLookupError}
startButtonLabel={entryCopy.startNow}
startButtonLoadingLabel={entryCopy.startLoading}
atmosphereOptions={ATMOSPHERE_OPTIONS}
atmosphereTitle={entryCopy.atmosphereTitle}
atmosphereBody={entryCopy.atmosphereBody}
onDurationChange={handleDurationChange}
onGoalChange={setGoalDraft}
onSelectAtmosphere={handleSelectAtmosphere}
onSelectDuration={handleSelectDuration}
onStartSession={() => {
void handleStartSession();
}}
/>
</div>
)}
</div>
@@ -624,75 +426,6 @@ export const FocusDashboardWidget = () => {
</div>
</div>
) : null}
{isTakeoverSheetOpen ? (
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
<div className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]" />
<div className="relative z-10 w-full max-w-[30rem] rounded-[2rem] border border-white/12 bg-[linear-gradient(165deg,rgba(15,17,21,0.92)_0%,rgba(7,10,14,0.98)_100%)] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur-2xl">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
{entryCopy.takeoverEyebrow}
</p>
<h2 className="mt-3 text-[1.45rem] font-light leading-[1.18] tracking-[-0.03em] text-white">
{entryCopy.takeoverTitle}
</h2>
<p className="mt-3 text-[14px] leading-[1.7] text-white/62">
{entryCopy.takeoverBody}
</p>
{currentSession ? (
<div className="mt-5 rounded-[1.35rem] border border-white/10 bg-white/[0.05] px-4 py-4">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/40">
{entryCopy.resumeEyebrow}
</p>
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
{currentSession.goal}
</p>
{currentSession.microStep ? (
<p className="mt-2 text-[13px] leading-[1.6] text-white/58">
{entryCopy.resumeMicroStepLabel} · {currentSession.microStep}
</p>
) : null}
</div>
) : null}
{takeoverError ? (
<p className="mt-4 text-sm text-amber-100/82">{takeoverError}</p>
) : null}
<div className="mt-6 flex flex-col gap-2.5">
<button
type="button"
onClick={handleResumeSession}
disabled={isResolvingTakeover}
className={primaryButtonClass}
>
{entryCopy.takeoverKeepCta}
</button>
<button
type="button"
onClick={() => {
void handleConfirmTakeover();
}}
disabled={isResolvingTakeover}
className={cn(
secondaryButtonClass,
'border-white/14 bg-white/[0.05] text-white/86 hover:bg-white/[0.1]',
)}
>
{isResolvingTakeover ? entryCopy.takeoverLoading : entryCopy.takeoverConfirmCta}
</button>
<button
type="button"
onClick={handleCloseTakeoverSheet}
disabled={isResolvingTakeover}
className="inline-flex items-center justify-center rounded-full px-4 py-2.5 text-sm font-medium text-white/54 transition hover:text-white/78 disabled:opacity-45"
>
{entryCopy.takeoverCancelCta}
</button>
</div>
</div>
</div>
) : null}
</div>
);
};