450 lines
18 KiB
TypeScript
450 lines
18 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 { 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';
|
|
import {
|
|
ATMOSPHERE_OPTIONS,
|
|
ENTRY_DURATION_SUGGESTIONS,
|
|
findAtmosphereOptionForSelection,
|
|
getAtmosphereOptionById,
|
|
getRecommendedDurationMinutes,
|
|
getTimerPresetMetaById,
|
|
parseDurationMinutes,
|
|
resolveNearestTimerPreset,
|
|
sanitizeDurationDraft,
|
|
} from '../model/atmosphereEntry';
|
|
import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell';
|
|
|
|
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: '숲 · Forest Birds',
|
|
},
|
|
} as const;
|
|
const DEFAULT_ATMOSPHERE =
|
|
findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0];
|
|
|
|
const entryCopy = {
|
|
eyebrow: 'VibeRoom',
|
|
goalPlaceholder: 'e.g. Write the first draft',
|
|
durationLabel: 'Estimated Time',
|
|
durationPlaceholder: 'e.g. 70',
|
|
durationHelper: 'Set a realistic time to accomplish this goal.',
|
|
startNow: 'Begin Session',
|
|
startLoading: 'Entering...',
|
|
atmosphereTitle: 'Atmosphere',
|
|
atmosphereBody:
|
|
'Background and sound play together. Choose an atmosphere to dive into deep focus.',
|
|
loadFailed: 'Failed to load session state. You can still start a new one.',
|
|
reviewEyebrow: 'Weekly Review',
|
|
reviewTitle: 'Take a quick look at your weekly review?',
|
|
reviewCta: 'View Review',
|
|
reviewHelper: 'A brief look back before you start.',
|
|
reviewTitlePro: 'Revisit a flow that worked well for you?',
|
|
reviewCtaPro: 'View My Flow',
|
|
reviewHelperPro: 'Check your best rituals and carry-forwards.',
|
|
reviewReturnEyebrow: 'From your recent review',
|
|
reviewReturnTitleSteady: 'Keep the rhythm that worked well this week.',
|
|
reviewReturnTitleSmaller: 'Try setting a smaller goal this time.',
|
|
reviewReturnTitleClosure: 'Decide where to wrap up before you start.',
|
|
reviewReturnTitleStart: 'Your focus right now: just start one more session.',
|
|
reviewReturnBodySteady: 'Set your own goal, but maintain the light entry rhythm.',
|
|
reviewReturnBodySmaller: 'Instead of extending time, a smaller goal makes it easier to keep going.',
|
|
reviewReturnBodyClosure: 'Think about where to close the block first to finish strong.',
|
|
reviewReturnBodyStart: 'Just aim to open one more short session to build momentum.',
|
|
reviewReturnRitualLabel: 'Recommended Ritual · Forest · Forest Birds',
|
|
paywallLead: 'Calm Session OS PRO',
|
|
paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.',
|
|
};
|
|
|
|
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 { 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 initialAtmosphere = useMemo(() => {
|
|
return (
|
|
findAtmosphereOptionForSelection(
|
|
reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
|
|
reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
|
|
) ?? DEFAULT_ATMOSPHERE
|
|
);
|
|
}, [reviewEntryPresetConfig]);
|
|
const initialDurationMinutes = useMemo(() => {
|
|
if (reviewEntryPresetConfig) {
|
|
return getTimerPresetMetaById(reviewEntryPresetConfig.timerPresetId).focusMinutes;
|
|
}
|
|
|
|
return getRecommendedDurationMinutes(initialAtmosphere);
|
|
}, [initialAtmosphere, reviewEntryPresetConfig]);
|
|
|
|
const [goalDraft, setGoalDraft] = useState('');
|
|
const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes));
|
|
const [selectedAtmosphereId, setSelectedAtmosphereId] = useState(initialAtmosphere.id);
|
|
const [hasEditedDuration, setHasEditedDuration] = useState(false);
|
|
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 goalInputRef = useRef<HTMLInputElement | null>(null);
|
|
const selectedAtmosphere = useMemo(
|
|
() => getAtmosphereOptionById(selectedAtmosphereId),
|
|
[selectedAtmosphereId],
|
|
);
|
|
const rawDurationValue = useMemo(() => {
|
|
const digitsOnly = durationDraft.replace(/[^\d]/g, '');
|
|
if (!digitsOnly) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number(digitsOnly);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}, [durationDraft]);
|
|
const parsedDurationMinutes = parseDurationMinutes(durationDraft);
|
|
const resolvedTimerPreset = useMemo(() => {
|
|
const targetMinutes =
|
|
parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere);
|
|
return resolveNearestTimerPreset(targetMinutes);
|
|
}, [parsedDurationMinutes, selectedAtmosphere]);
|
|
|
|
const activeScene = useMemo(() => {
|
|
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
|
|
}, [currentSession?.sceneId, selectedAtmosphere.sceneId]);
|
|
|
|
const trimmedGoal = goalDraft.trim();
|
|
const canStart =
|
|
trimmedGoal.length > 0 &&
|
|
parsedDurationMinutes !== null &&
|
|
!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 durationHelper =
|
|
rawDurationValue !== null && rawDurationValue < 5
|
|
? 'Please enter at least 5 minutes.'
|
|
: parsedDurationMinutes === null
|
|
? 'Please enter the estimated duration in minutes.'
|
|
: entryCopy.durationHelper;
|
|
const hasCurrentSession = Boolean(currentSession);
|
|
|
|
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 && hasCurrentSession) {
|
|
router.replace('/space');
|
|
}
|
|
}, [hasCurrentSession, isCheckingSession, router]);
|
|
|
|
const openPaywall = () => {
|
|
if (!isPro) {
|
|
setPaywallSource('app-entry-plan-pill');
|
|
}
|
|
};
|
|
|
|
const handleDurationChange = (value: string) => {
|
|
setDurationDraft(sanitizeDurationDraft(value));
|
|
setHasEditedDuration(true);
|
|
};
|
|
|
|
const handleSelectDuration = (minutes: number) => {
|
|
setDurationDraft(String(minutes));
|
|
setHasEditedDuration(true);
|
|
};
|
|
|
|
const handleSelectAtmosphere = (atmosphereId: string) => {
|
|
const nextAtmosphere = getAtmosphereOptionById(atmosphereId);
|
|
setSelectedAtmosphereId(nextAtmosphere.id);
|
|
|
|
if (!hasEditedDuration) {
|
|
setDurationDraft(String(getRecommendedDurationMinutes(nextAtmosphere)));
|
|
}
|
|
};
|
|
|
|
const handleStartSession = async () => {
|
|
if (!trimmedGoal || isStartingSession || currentSession) {
|
|
if (!trimmedGoal) {
|
|
goalInputRef.current?.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsedDurationMinutes === null) {
|
|
return;
|
|
}
|
|
|
|
setIsStartingSession(true);
|
|
|
|
try {
|
|
await focusSessionApi.startSession({
|
|
goal: trimmedGoal,
|
|
microStep: null,
|
|
sceneId: selectedAtmosphere.sceneId,
|
|
soundPresetId: selectedAtmosphere.soundPresetId,
|
|
timerPresetId: resolvedTimerPreset.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 shouldShowWeeklyReviewTeaser =
|
|
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
|
|
|
return (
|
|
<div className="relative min-h-dvh bg-black text-white selection:bg-white/20">
|
|
{/* Background Media */}
|
|
<div
|
|
className={cn(
|
|
'fixed inset-0 bg-cover bg-center transition-transform duration-[1.5s] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
|
isStartingSession ? 'scale-[1.08] blur-[2px] brightness-75' : 'scale-100 blur-[60px] brightness-50',
|
|
)}
|
|
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
|
/>
|
|
{/* Immersive Overlay Gradients */}
|
|
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.85)_100%)] mix-blend-multiply pointer-events-none" />
|
|
<div className="fixed inset-0 bg-black/40 pointer-events-none" />
|
|
|
|
{/* Header */}
|
|
<header className="absolute top-0 inset-x-0 z-50 flex items-center justify-between px-8 py-8 md:px-12 md:py-10">
|
|
<div className="flex items-center gap-3 opacity-0 animate-fade-in delay-150">
|
|
<p className="text-[11px] font-bold uppercase tracking-[0.4em] text-white/50 drop-shadow-sm">
|
|
{entryCopy.eyebrow}
|
|
</p>
|
|
{plan === 'pro' && (
|
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-white/60 backdrop-blur-md">
|
|
PRO
|
|
</span>
|
|
)}
|
|
</div>
|
|
{plan !== 'pro' && (
|
|
<button
|
|
type="button"
|
|
onClick={openPaywall}
|
|
className="group flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/50 backdrop-blur-md transition-all hover:bg-white/10 hover:text-white/80 opacity-0 animate-fade-in delay-150"
|
|
>
|
|
<span>Upgrade</span>
|
|
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
|
</button>
|
|
)}
|
|
</header>
|
|
|
|
{/* Main Content Area */}
|
|
<main className="relative z-10 flex min-h-[100dvh] flex-col pb-8 pt-20 md:pt-24">
|
|
{isCheckingSession ? (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<p className="text-[15px] font-medium text-white/70 animate-pulse">Loading session...</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex flex-col w-full relative">
|
|
<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}
|
|
topAccessory={
|
|
reviewReturnCopy ? (
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.2em] text-white/60 backdrop-blur-md">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-white/40" />
|
|
{entryCopy.reviewReturnEyebrow}
|
|
</div>
|
|
<p className="text-lg font-medium tracking-tight text-white/90">
|
|
{reviewReturnCopy.title}
|
|
</p>
|
|
<p className="mt-1.5 max-w-md text-[13px] leading-relaxed text-white/50">
|
|
{reviewReturnCopy.body}
|
|
</p>
|
|
{reviewReturnRitualLabel && (
|
|
<p className="mt-3 text-[11px] font-medium text-white/40">
|
|
{reviewReturnRitualLabel}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : shouldShowWeeklyReviewTeaser ? (
|
|
<Link href="/stats" className="group inline-flex items-center gap-3 rounded-full border border-white/5 bg-white/5 py-2 pl-4 pr-3 backdrop-blur-md transition-all hover:bg-white/10 hover:border-white/10">
|
|
<span className="flex h-1.5 w-1.5 rounded-full bg-white/40 group-hover:bg-white/60 transition-colors" />
|
|
<span className="text-[13px] font-medium text-white/70 group-hover:text-white/90">
|
|
{reviewTeaserTitle}
|
|
</span>
|
|
<span className="text-white/40 transition-transform group-hover:translate-x-0.5">→</span>
|
|
</Link>
|
|
) : undefined
|
|
}
|
|
errorAccessory={
|
|
sessionLookupError ? (
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-200 backdrop-blur-md">
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
{sessionLookupError}
|
|
</div>
|
|
) : undefined
|
|
}
|
|
selectedAtmosphere={selectedAtmosphere}
|
|
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>
|
|
)}
|
|
</main>
|
|
|
|
{/* Paywall Overlay */}
|
|
{paywallSource && (
|
|
<div className="fixed inset-0 z-[100] 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-black/60 backdrop-blur-sm transition-opacity"
|
|
/>
|
|
<div className="relative z-10 w-full max-w-md rounded-[2rem] border border-white/10 bg-zinc-900/90 p-6 shadow-2xl backdrop-blur-xl">
|
|
<p className="mb-2 text-[11px] font-medium uppercase tracking-[0.2em] text-white/50">
|
|
{entryCopy.paywallLead}
|
|
</p>
|
|
<p className="mb-6 text-sm leading-relaxed text-white/80">{entryCopy.paywallBody}</p>
|
|
<PaywallSheetContent
|
|
onStartPro={() => {
|
|
setPlan('pro');
|
|
setPaywallSource(null);
|
|
}}
|
|
onClose={() => setPaywallSource(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|