feat(app): paused session takeover flow 추가
This commit is contained in:
@@ -47,7 +47,16 @@ const entryCopy = {
|
||||
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
|
||||
resumeMicroStepLabel: '마지막 한 조각',
|
||||
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
|
||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||
resumeNewGoalHint: '새 목표로 전환하려면 지금 멈춘 세션을 먼저 어떻게 정리할지 결정해야 해요.',
|
||||
resumeTakeoverCta: '새 목표로 전환',
|
||||
takeoverEyebrow: '새 목표로 전환',
|
||||
takeoverTitle: '현재 멈춘 세션을 어떻게 할까요?',
|
||||
takeoverBody: '지금 멈춘 흐름을 조용히 없애지 않고, 먼저 정리한 뒤 새 목표로 넘어가요.',
|
||||
takeoverKeepCta: '이어서 하기',
|
||||
takeoverConfirmCta: '이 세션은 여기서 정리하고 새로 시작',
|
||||
takeoverCancelCta: '취소',
|
||||
takeoverLoading: '세션을 정리하는 중...',
|
||||
takeoverFailed: '멈춘 세션을 정리하지 못했어요. 잠시 후 다시 시도해 주세요.',
|
||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||
reviewEyebrow: 'Weekly Review',
|
||||
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
||||
@@ -141,6 +150,10 @@ 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);
|
||||
|
||||
@@ -223,6 +236,21 @@ export const FocusDashboardWidget = () => {
|
||||
}
|
||||
}, [isCheckingSession, isRunningSession, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusGoalAfterTakeover || currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
goalInputRef.current?.focus();
|
||||
setFocusGoalAfterTakeover(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [currentSession, focusGoalAfterTakeover]);
|
||||
|
||||
const openPaywall = () => {
|
||||
if (!isPro) {
|
||||
setPaywallSource('app-entry-plan-pill');
|
||||
@@ -256,6 +284,19 @@ export const FocusDashboardWidget = () => {
|
||||
router.push('/space');
|
||||
return;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : entryCopy.loadFailed;
|
||||
setSessionLookupError(message);
|
||||
|
||||
try {
|
||||
const session = await focusSessionApi.getCurrentSession();
|
||||
if (session) {
|
||||
setCurrentSession(session);
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.error('Failed to sync current session after /app start failure', syncError);
|
||||
}
|
||||
|
||||
console.error('Failed to start focus session from /app', error);
|
||||
}
|
||||
|
||||
@@ -270,6 +311,45 @@ export const FocusDashboardWidget = () => {
|
||||
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);
|
||||
setGoalDraft('');
|
||||
setMicroStepDraft('');
|
||||
setFocusGoalAfterTakeover(true);
|
||||
} catch (error) {
|
||||
setTakeoverError(
|
||||
error instanceof Error ? error.message : entryCopy.takeoverFailed,
|
||||
);
|
||||
} finally {
|
||||
setIsResolvingTakeover(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowWeeklyReviewTeaser =
|
||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
||||
const shouldShowResumeReviewEntry =
|
||||
@@ -365,6 +445,13 @@ export const FocusDashboardWidget = () => {
|
||||
|
||||
<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 ? (
|
||||
<Link
|
||||
@@ -545,6 +632,75 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user