feat(app): atmosphere entry shell 1차 구현

This commit is contained in:
2026-03-16 12:12:03 +09:00
parent c6e342e93d
commit 721212ec1f
7 changed files with 759 additions and 166 deletions

View File

@@ -0,0 +1,288 @@
'use client';
import type { ReactNode, RefObject } from 'react';
import { getSceneCardPhotoUrl } from '@/entities/scene';
import { cn } from '@/shared/lib/cn';
import type { AtmosphereOption } from '../model/atmosphereEntry';
const shellCardClass =
'rounded-[2rem] border border-white/12 bg-[#0f1115]/26 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl';
const inputShellClass =
'w-full rounded-[1.5rem] 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';
interface AppAtmosphereEntryShellProps {
canStart: boolean;
durationDraft: string;
durationHelper: string;
durationInputLabel: string;
durationPlaceholder: string;
durationSuggestions: number[];
goalDraft: string;
goalInputRef: RefObject<HTMLInputElement | null>;
goalPlaceholder: string;
isStartingSession: boolean;
reviewEntry?: ReactNode;
selectedAtmosphere: AtmosphereOption;
sessionLookupError?: string | null;
startButtonLabel: string;
startButtonLoadingLabel: string;
atmosphereOptions: AtmosphereOption[];
atmosphereTitle: string;
atmosphereBody: string;
onDurationChange: (value: string) => void;
onGoalChange: (value: string) => void;
onSelectAtmosphere: (id: string) => void;
onSelectDuration: (minutes: number) => void;
onStartSession: () => void;
}
export const AppAtmosphereEntryShell = ({
canStart,
durationDraft,
durationHelper,
durationInputLabel,
durationPlaceholder,
durationSuggestions,
goalDraft,
goalInputRef,
goalPlaceholder,
isStartingSession,
reviewEntry,
selectedAtmosphere,
sessionLookupError,
startButtonLabel,
startButtonLoadingLabel,
atmosphereOptions,
atmosphereTitle,
atmosphereBody,
onDurationChange,
onGoalChange,
onSelectAtmosphere,
onSelectDuration,
onStartSession,
}: AppAtmosphereEntryShellProps) => {
return (
<div className="space-y-5">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.18fr)_minmax(19rem,0.82fr)]">
<section className={cn(shellCardClass, 'px-6 py-6 md:px-8 md:py-8')}>
<div className="space-y-3">
<h1 className="text-[2rem] font-light leading-[1.04] tracking-[-0.045em] text-white md:text-[2.75rem]">
?
</h1>
<p className="max-w-[34rem] text-[15px] leading-[1.7] text-white/68 md:text-[15.5px]">
.
.
</p>
</div>
<div className="mt-8 space-y-5">
<label className="block space-y-2.5">
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
</span>
<input
ref={goalInputRef}
value={goalDraft}
onChange={(event) => onGoalChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onStartSession();
}
}}
placeholder={goalPlaceholder}
className={cn(
inputShellClass,
'text-[1.12rem] font-light tracking-[-0.025em] placeholder:text-white/34 md:text-[1.34rem]',
)}
autoFocus
/>
</label>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<label className="block space-y-2.5">
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
{durationInputLabel}
</span>
<div className="relative">
<input
value={durationDraft}
onChange={(event) => onDurationChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onStartSession();
}
}}
inputMode="numeric"
placeholder={durationPlaceholder}
className={cn(
inputShellClass,
'pr-12 text-[1.02rem] font-medium tracking-[-0.02em] placeholder:text-white/30',
)}
/>
<span className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-sm text-white/48">
</span>
</div>
<p className="text-[12px] leading-[1.6] text-white/48">{durationHelper}</p>
</label>
<div className="flex flex-wrap gap-2 md:max-w-[14.5rem] md:justify-end">
{durationSuggestions.map((minutes) => (
<button
key={minutes}
type="button"
onClick={() => onSelectDuration(minutes)}
className={cn(
'rounded-full border border-white/12 bg-white/[0.05] px-3 py-1.5 text-[12px] font-medium text-white/76 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
durationDraft === String(minutes) && 'border-white/28 bg-white/[0.13] text-white',
)}
>
{minutes}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/40">
Selected Atmosphere
</p>
<p className="text-[14px] font-medium text-white/84">{selectedAtmosphere.name}</p>
<p className="text-[12px] text-white/52">
{selectedAtmosphere.soundLabel} · {selectedAtmosphere.caption}
</p>
</div>
<button
type="button"
onClick={onStartSession}
disabled={!canStart}
className={primaryButtonClass}
>
{isStartingSession ? startButtonLoadingLabel : startButtonLabel}
</button>
</div>
{sessionLookupError ? (
<p className="text-sm text-amber-100/80">{sessionLookupError}</p>
) : null}
</div>
</section>
<aside className="space-y-4">
<div className={cn(shellCardClass, 'overflow-hidden')}>
<div
className="relative min-h-[18rem] bg-cover bg-center"
style={{
backgroundImage: `linear-gradient(180deg, rgba(5,10,18,0.08) 0%, rgba(5,10,18,0.74) 100%), url('${getSceneCardPhotoUrl(selectedAtmosphere.scene)}')`,
}}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),rgba(255,255,255,0)_42%)]" />
<div className="absolute inset-x-0 bottom-0 p-5">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/52">
Selected Atmosphere
</p>
<h2 className="mt-2 text-[1.28rem] font-medium tracking-[-0.03em] text-white">
{selectedAtmosphere.name}
</h2>
<p className="mt-2 max-w-[22rem] text-[13px] leading-[1.65] text-white/72">
{selectedAtmosphere.description}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<span className="rounded-full border border-white/16 bg-white/[0.08] px-3 py-1 text-[11px] text-white/74">
{selectedAtmosphere.soundLabel}
</span>
<span className="rounded-full border border-white/16 bg-white/[0.08] px-3 py-1 text-[11px] text-white/74">
{selectedAtmosphere.scene.recommendedTime}
</span>
</div>
</div>
</div>
</div>
{reviewEntry}
</aside>
</div>
<section className="space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
Atmosphere
</p>
<h2 className="mt-2 text-[1.2rem] font-medium tracking-[-0.03em] text-white md:text-[1.4rem]">
{atmosphereTitle}
</h2>
<p className="mt-2 max-w-[42rem] text-[13px] leading-[1.7] text-white/60 md:text-[13.5px]">
{atmosphereBody}
</p>
</div>
<p className="text-[12px] text-white/42 md:text-right">
{atmosphereOptions.length} Atmosphere
</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
{atmosphereOptions.map((option) => {
const isSelected = option.id === selectedAtmosphere.id;
return (
<button
key={option.id}
type="button"
onClick={() => onSelectAtmosphere(option.id)}
className={cn(
'group relative min-h-[12.5rem] overflow-hidden rounded-[1.65rem] border text-left transition duration-300 ease-out',
isSelected
? 'border-white/28 shadow-[0_16px_36px_rgba(2,6,23,0.22)]'
: 'border-white/10 hover:border-white/18',
)}
aria-pressed={isSelected}
>
<div
className="absolute inset-0 bg-cover bg-center transition duration-500 ease-out group-hover:scale-[1.04]"
style={{
backgroundImage: `linear-gradient(180deg, rgba(7,10,14,0.08) 0%, rgba(7,10,14,0.88) 100%), url('${getSceneCardPhotoUrl(option.scene)}')`,
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),rgba(255,255,255,0)_44%)]" />
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-start justify-between gap-3">
<span className="rounded-full border border-white/14 bg-white/[0.08] px-2.5 py-1 text-[11px] text-white/72">
{option.caption}
</span>
{isSelected ? (
<span className="rounded-full border border-white/18 bg-white/[0.14] px-2.5 py-1 text-[11px] font-medium text-white">
</span>
) : null}
</div>
<div>
<h3 className="text-[1rem] font-medium tracking-[-0.025em] text-white">
{option.name}
</h3>
<p className="mt-2 line-clamp-2 text-[12px] leading-[1.55] text-white/66">
{option.description}
</p>
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-white/56">
<span>{option.soundLabel}</span>
<span>{option.scene.recommendedTime}</span>
</div>
</div>
</div>
</button>
);
})}
</div>
<p className="text-[12px] text-white/38">
Atmosphere는 ,
.
</p>
</section>
</div>
);
};

View File

@@ -13,6 +13,18 @@ import { focusSessionApi, type FocusSession } from '@/features/focus-session/api
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;
@@ -25,20 +37,20 @@ const REVIEW_ENTRY_PRESETS = {
label: '숲 · 50/10 · Forest Birds',
},
} as const;
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
const DEFAULT_ATMOSPHERE =
findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0];
const entryCopy = {
eyebrow: 'VibeRoom',
title: '지금 붙잡을 한 가지',
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
microStepLabel: '지금 할 한 조각',
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
startNow: '지금 시작',
startLoading: '입 준비 중...',
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
durationLabel: '예상 시간(분)',
durationPlaceholder: '예: 70',
durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.',
startNow: '이 분위기로 들어가기',
startLoading: '입 준비 중...',
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
atmosphereBody:
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
resumeEyebrow: 'Resume',
resumeRunning: '진행 중인 세션이 있어요.',
resumePaused: '잠시 멈춘 세션이 있어요.',
@@ -84,19 +96,11 @@ const entryCopy = {
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';
@@ -142,9 +146,26 @@ export const FocusDashboardWidget = () => {
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 [microStepDraft, setMicroStepDraft] = 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);
@@ -156,20 +177,35 @@ export const FocusDashboardWidget = () => {
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
const goalInputRef = useRef<HTMLInputElement | null>(null);
const selectedAtmosphere = useMemo(
() => getAtmosphereOptionById(selectedAtmosphereId),
[selectedAtmosphereId],
);
const parsedDurationMinutes = parseDurationMinutes(durationDraft);
const resolvedTimerPreset = useMemo(() => {
const targetMinutes =
parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere);
return resolveNearestTimerPreset(targetMinutes);
}, [parsedDurationMinutes, selectedAtmosphere]);
const activeScene = useMemo(() => {
return getSceneById(currentSession?.sceneId ?? reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
}, [currentSession?.sceneId, reviewEntryPresetConfig?.sceneId]);
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
}, [currentSession?.sceneId, selectedAtmosphere.sceneId]);
const activeRitualMeta = useMemo(() => {
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
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 && !isStartingSession && !currentSession;
const canStart =
trimmedGoal.length > 0 &&
parsedDurationMinutes !== null &&
!isStartingSession &&
!currentSession;
const hasEnoughWeeklyData =
weeklySummary.last7Days.startedSessions >= 3 &&
(weeklySummary.last7Days.completedSessions >= 2 ||
@@ -193,7 +229,12 @@ export const FocusDashboardWidget = () => {
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 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';
@@ -257,9 +298,30 @@ export const FocusDashboardWidget = () => {
}
};
const handleSelectSuggestion = (label: string) => {
setGoalDraft(label);
goalInputRef.current?.focus();
const resetEntryDrafts = () => {
setGoalDraft('');
setSelectedAtmosphereId(initialAtmosphere.id);
setDurationDraft(String(initialDurationMinutes));
setHasEditedDuration(false);
};
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 () => {
@@ -270,15 +332,19 @@ export const FocusDashboardWidget = () => {
return;
}
if (parsedDurationMinutes === null) {
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,
microStep: null,
sceneId: selectedAtmosphere.sceneId,
soundPresetId: selectedAtmosphere.soundPresetId,
timerPresetId: resolvedTimerPreset.id,
entryPoint: 'space-setup',
});
router.push('/space');
@@ -338,8 +404,7 @@ export const FocusDashboardWidget = () => {
setCurrentSession(null);
setIsTakeoverSheetOpen(false);
setSessionLookupError(null);
setGoalDraft('');
setMicroStepDraft('');
resetEntryDrafts();
setFocusGoalAfterTakeover(true);
} catch (error) {
setTakeoverError(
@@ -478,131 +543,58 @@ export const FocusDashboardWidget = () => {
) : 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}
<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"
>
{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 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>
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
{reviewTeaserCta}
</span>
</div>
</Link>
) : null}
</>
</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();
}}
/>
)}
</div>
)}