Files
viberoom-web/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx

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">&rarr;</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">&rarr;</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>
);
};