707 lines
31 KiB
TypeScript
707 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
|
import { usePlanTier } from '@/entities/plan';
|
|
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
|
import { SOUND_PRESETS } from '@/entities/session';
|
|
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
|
import { PlanPill } from '@/features/plan-pill';
|
|
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
|
import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
|
|
import { copy } from '@/shared/i18n';
|
|
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 = {
|
|
eyebrow: 'VibeRoom',
|
|
title: '지금 붙잡을 한 가지',
|
|
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
|
|
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
|
microStepLabel: '지금 할 한 조각',
|
|
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
|
|
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
|
|
startNow: '지금 시작',
|
|
startLoading: '몰입 준비 중...',
|
|
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
|
|
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
|
|
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를 잠깐 보고 갈까요?',
|
|
reviewCta: '주간 review 보기',
|
|
reviewHelper: '다음 세션 전에 가볍게 보고 갈 수 있어요.',
|
|
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?',
|
|
reviewCtaPro: '나에게 맞는 흐름 보기',
|
|
reviewHelperPro: '가장 잘 맞았던 ritual과 carry-forward를 보고 돌아올 수 있어요.',
|
|
resumeReviewEyebrow: 'Weekly Review',
|
|
resumeReviewTitle: '잠깐 review를 보고 다시 들어갈 수 있어요.',
|
|
resumeReviewHelper: '현재 세션은 그대로 두고, 이번 주 흐름만 짧게 확인합니다.',
|
|
reviewReturnEyebrow: '방금 본 review 기준',
|
|
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
|
|
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
|
|
reviewReturnTitleClosure: '이번엔 어디서 닫을지 먼저 정해보세요.',
|
|
reviewReturnTitleStart: '이번 주는 시작 횟수 하나를 더 만드는 게 먼저예요.',
|
|
reviewReturnBodySteady: 'goal은 직접 정하되, 지금처럼 가볍게 들어가는 리듬을 유지해 보세요.',
|
|
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.',
|
|
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.',
|
|
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.',
|
|
reviewReturnRitualLabel: '추천 ritual · 숲 · 50/10 · 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 inputShellClass =
|
|
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
|
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 timerLabelById: Record<string, string> = {
|
|
'25-5': '25/5',
|
|
'50-10': '50/10',
|
|
'90-20': '90/20',
|
|
};
|
|
|
|
const resolveSoundLabel = (soundPresetId?: string | null) => {
|
|
if (!soundPresetId) {
|
|
return 'Silent';
|
|
}
|
|
|
|
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
|
|
};
|
|
|
|
const reviewCarryCopyByHint: Record<
|
|
ReviewCarryHint,
|
|
{ title: string; body: string }
|
|
> = {
|
|
steady: {
|
|
title: entryCopy.reviewReturnTitleSteady,
|
|
body: entryCopy.reviewReturnBodySteady,
|
|
},
|
|
smaller: {
|
|
title: entryCopy.reviewReturnTitleSmaller,
|
|
body: entryCopy.reviewReturnBodySmaller,
|
|
},
|
|
closure: {
|
|
title: entryCopy.reviewReturnTitleClosure,
|
|
body: entryCopy.reviewReturnBodyClosure,
|
|
},
|
|
start: {
|
|
title: entryCopy.reviewReturnTitleStart,
|
|
body: entryCopy.reviewReturnBodyStart,
|
|
},
|
|
};
|
|
|
|
export const FocusDashboardWidget = () => {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { plan, isPro, setPlan } = usePlanTier();
|
|
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);
|
|
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
|
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 activeScene = useMemo(() => {
|
|
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';
|
|
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 && !isStartingSession && !currentSession;
|
|
const hasEnoughWeeklyData =
|
|
weeklySummary.last7Days.startedSessions >= 3 &&
|
|
(weeklySummary.last7Days.completedSessions >= 2 ||
|
|
weeklySummary.recovery.pausedSessions > 0);
|
|
const reviewSource = searchParams.get('review');
|
|
const reviewCarryHint = searchParams.get('carryHint');
|
|
const normalizedReviewCarryHint: ReviewCarryHint | null =
|
|
reviewCarryHint === 'steady' ||
|
|
reviewCarryHint === 'smaller' ||
|
|
reviewCarryHint === 'closure' ||
|
|
reviewCarryHint === 'start'
|
|
? reviewCarryHint
|
|
: null;
|
|
const isReviewReturn =
|
|
reviewSource === 'weekly' && normalizedReviewCarryHint !== null;
|
|
const reviewReturnCopy =
|
|
normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null;
|
|
const reviewReturnRitualLabel =
|
|
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;
|
|
const isRunningSession = currentSession?.state === 'running';
|
|
const isPausedSession = currentSession?.state === 'paused';
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const loadCurrentSession = async () => {
|
|
setIsCheckingSession(true);
|
|
|
|
try {
|
|
const session = await focusSessionApi.getCurrentSession();
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setCurrentSession(session);
|
|
setSessionLookupError(null);
|
|
} catch (error) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setCurrentSession(null);
|
|
setSessionLookupError(error instanceof Error ? error.message : entryCopy.loadFailed);
|
|
} finally {
|
|
if (!cancelled) {
|
|
setIsCheckingSession(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void loadCurrentSession();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isCheckingSession && isRunningSession) {
|
|
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]);
|
|
|
|
const openPaywall = () => {
|
|
if (!isPro) {
|
|
setPaywallSource('app-entry-plan-pill');
|
|
}
|
|
};
|
|
|
|
const handleSelectSuggestion = (label: string) => {
|
|
setGoalDraft(label);
|
|
goalInputRef.current?.focus();
|
|
};
|
|
|
|
const handleStartSession = async () => {
|
|
if (!trimmedGoal || isStartingSession || currentSession) {
|
|
if (!trimmedGoal) {
|
|
goalInputRef.current?.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
setIsStartingSession(true);
|
|
|
|
try {
|
|
await focusSessionApi.startSession({
|
|
goal: trimmedGoal,
|
|
microStep: microStepDraft.trim() || null,
|
|
sceneId: reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
|
|
soundPresetId: reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
|
|
timerPresetId: reviewEntryPresetConfig?.timerPresetId ?? DEFAULT_TIMER_ID,
|
|
entryPoint: 'space-setup',
|
|
});
|
|
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);
|
|
}
|
|
|
|
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);
|
|
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 =
|
|
!isCheckingSession && isPausedSession && hasEnoughWeeklyData;
|
|
|
|
return (
|
|
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out',
|
|
isStartingSession ? 'scale-[1.04]' : 'scale-100',
|
|
)}
|
|
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
|
/>
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.38)_55%,rgba(2,6,23,0.5)_100%)]" />
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),rgba(255,255,255,0)_42%)]" />
|
|
|
|
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
|
|
<p className="text-sm font-semibold tracking-[0.28em] text-white/56 uppercase">
|
|
{entryCopy.eyebrow}
|
|
</p>
|
|
<PlanPill plan={plan} onClick={openPaywall} />
|
|
</header>
|
|
|
|
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-center justify-center px-4 pb-8 pt-4 md:px-6">
|
|
<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>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{reviewReturnCopy ? (
|
|
<div className="rounded-[1.55rem] border border-white/10 bg-[#0f1115]/16 px-5 py-4 backdrop-blur-lg">
|
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
|
{entryCopy.reviewReturnEyebrow}
|
|
</p>
|
|
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/90">
|
|
{reviewReturnCopy.title}
|
|
</p>
|
|
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
|
{reviewReturnCopy.body}
|
|
</p>
|
|
{reviewReturnRitualLabel ? (
|
|
<p className="mt-3 text-[12px] text-white/46">{reviewReturnRitualLabel}</p>
|
|
) : null}
|
|
</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 ? (
|
|
<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>
|
|
) : (
|
|
<>
|
|
<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">
|
|
{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>
|
|
|
|
{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>
|
|
</main>
|
|
|
|
{paywallSource ? (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
|
<button
|
|
type="button"
|
|
aria-label={copy.modal.closeAriaLabel}
|
|
onClick={() => setPaywallSource(null)}
|
|
className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]"
|
|
/>
|
|
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]">
|
|
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42">
|
|
{entryCopy.paywallLead}
|
|
</p>
|
|
<p className="mb-4 text-sm text-white/62">{entryCopy.paywallBody}</p>
|
|
<PaywallSheetContent
|
|
onStartPro={() => {
|
|
setPlan('pro');
|
|
setPaywallSource(null);
|
|
}}
|
|
onClose={() => setPaywallSource(null)}
|
|
/>
|
|
</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>
|
|
);
|
|
};
|