feat(app): premium immersive entry ui 적용

This commit is contained in:
2026-03-16 13:26:15 +09:00
parent 81e969c116
commit 8f4a69fc77
7 changed files with 432 additions and 436 deletions

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@theme {
/* Noto Sans 다국어 폰트 적용 (next/font/google 변수) */
@@ -81,6 +82,42 @@ body {
}
}
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.animate-fade-in-up {
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-fade-in {
animation: fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.delay-150 {
animation-delay: 150ms;
}
.delay-300 {
animation-delay: 300ms;
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;

View File

@@ -5,15 +5,6 @@ import { getSceneCardPhotoUrl } from '@/entities/scene';
import { cn } from '@/shared/lib/cn';
import type { AtmosphereOption } from '../model/atmosphereEntry';
const stageShellClass =
'relative overflow-hidden rounded-[2.35rem] border border-white/12 bg-[linear-gradient(160deg,rgba(9,13,20,0.54)_0%,rgba(9,13,20,0.2)_52%,rgba(9,13,20,0.48)_100%)] shadow-[0_26px_90px_rgba(3,7,18,0.34)] backdrop-blur-[26px]';
const fieldShellClass =
'w-full rounded-[1.45rem] border border-white/12 bg-[linear-gradient(180deg,rgba(255,255,255,0.09)_0%,rgba(255,255,255,0.05)_100%)] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.1]';
const reviewDockClass =
'group relative overflow-hidden rounded-[1.65rem] border border-white/12 bg-[linear-gradient(145deg,rgba(255,255,255,0.09)_0%,rgba(255,255,255,0.04)_100%)] px-5 py-4 backdrop-blur-xl transition hover:border-white/18 hover:bg-white/[0.1]';
const primaryButtonClass =
'inline-flex min-h-[3.65rem] items-center justify-center rounded-full border border-white/14 bg-white/[0.16] px-6 text-[15px] font-medium tracking-[-0.01em] text-white shadow-[0_10px_22px_rgba(5,10,20,0.24)] transition hover:bg-white/[0.2] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50';
interface AppAtmosphereEntryShellProps {
canStart: boolean;
durationDraft: string;
@@ -25,9 +16,9 @@ interface AppAtmosphereEntryShellProps {
goalInputRef: RefObject<HTMLInputElement | null>;
goalPlaceholder: string;
isStartingSession: boolean;
reviewEntry?: ReactNode;
topAccessory?: ReactNode;
errorAccessory?: ReactNode;
selectedAtmosphere: AtmosphereOption;
sessionLookupError?: string | null;
startButtonLabel: string;
startButtonLoadingLabel: string;
atmosphereOptions: AtmosphereOption[];
@@ -44,21 +35,17 @@ export const AppAtmosphereEntryShell = ({
canStart,
durationDraft,
durationHelper,
durationInputLabel,
durationPlaceholder,
durationSuggestions,
goalDraft,
goalInputRef,
goalPlaceholder,
isStartingSession,
reviewEntry,
topAccessory,
errorAccessory,
selectedAtmosphere,
sessionLookupError,
startButtonLabel,
startButtonLoadingLabel,
atmosphereOptions,
atmosphereTitle,
atmosphereBody,
onDurationChange,
onGoalChange,
onSelectAtmosphere,
@@ -66,313 +53,144 @@ export const AppAtmosphereEntryShell = ({
onStartSession,
}: AppAtmosphereEntryShellProps) => {
return (
<div className="space-y-6 md:space-y-7">
<div className="grid gap-5 xl:grid-cols-[minmax(20rem,0.92fr)_minmax(0,1.08fr)]">
<section className={cn(stageShellClass, 'px-6 py-6 md:px-8 md:py-8')}>
<div className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),rgba(255,255,255,0)_64%)]" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-40 bg-[linear-gradient(270deg,rgba(255,255,255,0.06),rgba(255,255,255,0))]" />
<div className="flex h-full flex-col justify-between pt-8 md:pt-16">
{/* Main Focus Entry Ritual */}
<div className="flex flex-1 flex-col items-center justify-center px-4 pb-10">
{/* Inline Accessories (No overlap guarantee) */}
<div className="mb-10 flex min-h-[5rem] flex-col items-center justify-end opacity-0 animate-fade-in-up delay-150">
{errorAccessory}
{!errorAccessory && topAccessory}
</div>
<div className="relative">
<div className="flex items-start justify-between gap-6">
<div className="max-w-[30rem] space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-white/46">
Focus Entry
</p>
<h1 className="max-w-[13ch] text-[2.45rem] font-light leading-[0.96] tracking-[-0.055em] text-white md:text-[3.65rem]">
<br />
?
</h1>
<p className="max-w-[30rem] text-[14px] leading-[1.72] text-white/64 md:text-[14.5px]">
. , atmosphere
.
</p>
<div className="w-full max-w-4xl space-y-8 text-center opacity-0 animate-fade-in-up delay-150">
<p className="text-sm font-medium uppercase tracking-[0.25em] text-white/50">
What will you focus on?
</p>
<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="w-full bg-transparent text-center text-4xl font-light tracking-tight text-white outline-none placeholder:text-white/20 md:text-5xl lg:text-[4.5rem] lg:leading-[1.1]"
autoFocus
/>
</div>
<div className="flex w-full flex-col items-center space-y-8 opacity-0 animate-fade-in-up delay-150">
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-4 rounded-[2rem] border border-white/10 bg-white/5 p-2 pr-6 shadow-2xl backdrop-blur-xl">
<div className="flex gap-2">
{durationSuggestions.map((minutes) => {
const isSelected = durationDraft === String(minutes);
return (
<button
key={minutes}
type="button"
onClick={() => onSelectDuration(minutes)}
className={cn(
'rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300',
isSelected
? 'bg-white text-black shadow-md'
: 'text-white/70 hover:bg-white/10 hover:text-white',
)}
>
{minutes}m
</button>
);
})}
</div>
<div className="hidden min-w-[11.5rem] rounded-[1.4rem] border border-white/10 bg-white/[0.05] px-4 py-4 text-right xl:block">
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/42">
Atmosphere
</p>
<p className="mt-3 text-[1rem] font-medium tracking-[-0.03em] text-white/90">
{selectedAtmosphere.name}
</p>
<p className="mt-2 text-[12px] leading-[1.6] text-white/52">
{selectedAtmosphere.soundLabel}
<br />
{selectedAtmosphere.caption}
</p>
</div>
</div>
<div className="mt-8 space-y-5 border-t border-white/10 pt-6">
<label className="block space-y-2.5">
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
</span>
<div className="h-8 w-px bg-white/10" />
<div className="flex items-center gap-2">
<input
ref={goalInputRef}
value={goalDraft}
onChange={(event) => onGoalChange(event.target.value)}
value={durationDraft}
onChange={(event) => onDurationChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
onStartSession();
}
}}
placeholder={goalPlaceholder}
className={cn(
fieldShellClass,
'text-[1.16rem] font-light tracking-[-0.032em] placeholder:text-white/28 md:text-[1.42rem]',
)}
autoFocus
inputMode="numeric"
placeholder="Custom"
className="w-16 bg-transparent text-right text-lg font-medium text-white outline-none placeholder:text-white/30"
/>
</label>
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.88fr)_minmax(14rem,0.72fr)]">
<div className="space-y-2.5">
<label className="block space-y-2.5">
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
{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(
fieldShellClass,
'pr-14 text-[1.04rem] 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/46">
</span>
</div>
</label>
<p className="text-[12px] leading-[1.65] text-white/46">{durationHelper}</p>
</div>
<div className="space-y-2.5">
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
</p>
<div className="grid grid-cols-2 gap-2">
{durationSuggestions.map((minutes) => (
<button
key={minutes}
type="button"
onClick={() => onSelectDuration(minutes)}
className={cn(
'rounded-[1.1rem] border border-white/12 bg-white/[0.05] px-3 py-3 text-[12px] font-medium text-white/74 transition hover:border-white/18 hover:bg-white/[0.09] hover:text-white',
durationDraft === String(minutes) &&
'border-white/24 bg-white/[0.14] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]',
)}
>
{minutes}
</button>
))}
</div>
</div>
<span className="text-sm text-white/50">min</span>
</div>
<div className="grid gap-4 border-t border-white/10 pt-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="space-y-3">
<div className="rounded-[1.45rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.03)_100%)] px-4 py-4">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/40">
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-[1rem] font-medium tracking-[-0.03em] text-white/90">
{selectedAtmosphere.name}
</span>
<span className="rounded-full border border-white/12 bg-white/[0.08] px-2.5 py-1 text-[11px] text-white/66">
{selectedAtmosphere.soundLabel}
</span>
</div>
<p className="mt-3 max-w-[28rem] text-[12px] leading-[1.65] text-white/54">
{selectedAtmosphere.description}
</p>
</div>
{sessionLookupError ? (
<p className="text-[13px] leading-[1.6] text-amber-100/84">{sessionLookupError}</p>
) : null}
</div>
<button
type="button"
onClick={onStartSession}
disabled={!canStart}
className={primaryButtonClass}
>
{isStartingSession ? startButtonLoadingLabel : startButtonLabel}
</button>
</div>
{reviewEntry ? (
<div className="border-t border-white/10 pt-5">
<div className={reviewDockClass}>{reviewEntry}</div>
</div>
) : null}
</div>
<p className="text-xs text-white/40">{durationHelper}</p>
</div>
</section>
<aside className={cn(stageShellClass, 'min-h-[29rem]')}>
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `linear-gradient(180deg, rgba(7,10,16,0.08) 0%, rgba(7,10,16,0.46) 46%, rgba(7,10,16,0.88) 100%), url('${getSceneCardPhotoUrl(selectedAtmosphere.scene)}')`,
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),rgba(255,255,255,0)_36%)]" />
<div className="absolute inset-y-0 left-0 w-[52%] bg-[linear-gradient(90deg,rgba(5,8,14,0.56)_0%,rgba(5,8,14,0.22)_56%,rgba(5,8,14,0)_100%)]" />
<div className="relative flex min-h-[29rem] h-full flex-col justify-between p-6 md:p-7">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="rounded-full border border-white/14 bg-white/[0.08] px-3 py-1.5 text-[11px] font-medium tracking-[0.14em] text-white/76">
Atmosphere Preview
</div>
<div className="rounded-full border border-white/14 bg-black/10 px-3 py-1.5 text-[11px] text-white/70 backdrop-blur-md">
{selectedAtmosphere.caption}
</div>
</div>
<div className="max-w-[28rem] space-y-4">
<div className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
</p>
<h2 className="text-[2rem] font-light leading-[0.95] tracking-[-0.05em] text-white md:text-[2.85rem]">
{selectedAtmosphere.name}
</h2>
<p className="max-w-[26rem] text-[14px] leading-[1.72] text-white/72">
{selectedAtmosphere.description}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Sound
</p>
<p className="mt-3 text-[13px] font-medium text-white/84">
{selectedAtmosphere.soundLabel}
</p>
</div>
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Scene
</p>
<p className="mt-3 text-[13px] font-medium text-white/84">
{selectedAtmosphere.scene.name}
</p>
</div>
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Best For
</p>
<p className="mt-3 text-[13px] font-medium text-white/84">
{selectedAtmosphere.scene.recommendedTime}
</p>
</div>
</div>
</div>
</div>
</aside>
<button
type="button"
onClick={onStartSession}
disabled={!canStart}
className="group relative flex h-16 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 px-12 text-lg font-medium tracking-wide text-white shadow-2xl backdrop-blur-md transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] active:scale-[0.98] disabled:pointer-events-none disabled:opacity-40"
>
<span className="relative z-10">
{isStartingSession ? startButtonLoadingLabel : startButtonLabel}
</span>
</button>
</div>
</div>
<section className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[44rem]">
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-white/42">
Atmosphere Library
</p>
<h2 className="mt-3 text-[1.42rem] font-medium tracking-[-0.04em] text-white md:text-[1.9rem]">
{atmosphereTitle}
</h2>
<p className="mt-3 text-[13px] leading-[1.7] text-white/58 md:text-[13.5px]">
{atmosphereBody}
</p>
</div>
<p className="text-[12px] text-white/40 lg:text-right">
{atmosphereOptions.length} curated atmosphere
{/* Atmosphere Selection Dock */}
<div className="w-full pb-6 opacity-0 animate-fade-in-up delay-300">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center">
<p className="mb-2 text-[10px] font-bold uppercase tracking-[0.3em] text-white/40 drop-shadow-md">
Atmosphere
</p>
</div>
<div className="flex w-full snap-x snap-mandatory gap-5 overflow-x-auto px-8 py-8 [&::-webkit-scrollbar]:hidden">
{atmosphereOptions.map((option) => {
const isSelected = option.id === selectedAtmosphere.id;
<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-[14rem] overflow-hidden rounded-[1.65rem] border text-left transition duration-300 ease-out',
isSelected
? 'border-white/26 shadow-[0_18px_42px_rgba(2,6,23,0.24)]'
: 'border-white/10 hover:border-white/18 hover:shadow-[0_14px_34px_rgba(2,6,23,0.18)]',
)}
aria-pressed={isSelected}
>
<div
className="absolute inset-0 bg-cover bg-center transition duration-500 ease-out group-hover:scale-[1.035]"
style={{
backgroundImage: `linear-gradient(180deg, rgba(7,10,14,0.08) 0%, rgba(7,10,14,0.42) 44%, rgba(7,10,14,0.9) 100%), url('${getSceneCardPhotoUrl(option.scene)}')`,
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),rgba(255,255,255,0)_42%)]" />
<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-[10px] font-medium tracking-[0.14em] text-white/72">
{option.caption}
</span>
{isSelected ? (
<span className="rounded-full border border-white/16 bg-white/[0.14] px-2.5 py-1 text-[10px] font-medium tracking-[0.14em] text-white">
Current
</span>
) : null}
return (
<button
key={option.id}
type="button"
onClick={() => onSelectAtmosphere(option.id)}
className={cn(
'group relative flex min-w-[140px] snap-center flex-col overflow-hidden rounded-[1.75rem] border text-left transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] md:min-w-[160px]',
isSelected
? 'border-white/40 shadow-[0_20px_40px_-10px_rgba(0,0,0,0.5),0_0_30px_rgba(255,255,255,0.15)] ring-1 ring-white/40 scale-110 z-10'
: 'border-white/10 hover:border-white/30 hover:scale-105 hover:z-10 hover:shadow-[0_10px_20px_-10px_rgba(0,0,0,0.5)]',
)}
>
<div className="aspect-[4/5] w-full overflow-hidden">
<div
className="h-full w-full bg-cover bg-center transition-transform duration-1000 group-hover:scale-110"
style={{
backgroundImage: `url('${getSceneCardPhotoUrl(option.scene)}')`,
}}
/>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
<div className="absolute bottom-0 w-full p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-white/60 mb-1">
{option.soundLabel}
</p>
<p className="text-sm font-medium leading-tight text-white">
{option.name}
</p>
</div>
<div className="space-y-3">
<div>
<h3 className="text-[1.08rem] font-medium tracking-[-0.03em] text-white">
{option.name}
</h3>
<p className="mt-2 line-clamp-3 text-[12px] leading-[1.62] text-white/66">
{option.description}
</p>
{isSelected && (
<div className="absolute right-3 top-3 rounded-full bg-white/20 p-1.5 backdrop-blur-md">
<div className="h-2 w-2 rounded-full bg-white shadow-[0_0_10px_rgba(255,255,255,0.8)]" />
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/60">
<span className="rounded-full border border-white/12 bg-black/10 px-2.5 py-1 backdrop-blur-sm">
{option.soundLabel}
</span>
<span className="rounded-full border border-white/12 bg-black/10 px-2.5 py-1 backdrop-blur-sm">
{option.scene.recommendedTime}
</span>
</div>
</div>
</div>
</button>
);
})}
)}
</button>
);
})}
</div>
</div>
<p className="text-[12px] leading-[1.6] text-white/38">
atmosphere는 .
, atmosphere의 .
</p>
</section>
</div>
</div>
);
};

View File

@@ -8,7 +8,6 @@ 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';
@@ -42,40 +41,37 @@ const DEFAULT_ATMOSPHERE =
const entryCopy = {
eyebrow: 'VibeRoom',
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
durationLabel: '예상 시간(분)',
durationPlaceholder: '예: 70',
durationHelper: '이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.',
startNow: '이 분위기로 들어가기',
startLoading: '입장 준비 중...',
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
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:
'배경과 사운드는 하나의 atmosphere로 움직입니다. 지금 할 일의 온도에 맞는 분위기 하나만 고르면 바로 들어갈 수 있어요.',
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
'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: '이번 주 review를 잠깐 보고 갈까요?',
reviewCta: '주간 review 보기',
reviewHelper: '다음 세션 전에 가볍게 보고 갈 수 있어요.',
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?',
reviewCtaPro: '나에게 맞는 흐름 보기',
reviewHelperPro: '가장 잘 맞았던 ritual carry-forward를 보고 돌아올 수 있어요.',
reviewReturnEyebrow: '방금 본 review 기준',
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
reviewReturnTitleClosure: '이번엔 어디서 닫을지 먼저 정해보세요.',
reviewReturnTitleStart: '이번 주는 시작 횟수 하나를 더 만드는 게 먼저예요.',
reviewReturnBodySteady: 'goal은 직접 정하되, 지금처럼 가볍게 들어가는 리듬을 유지해 보세요.',
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.',
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.',
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.',
reviewReturnRitualLabel: '추천 atmosphere · 숲 · Forest Birds',
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는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.',
};
const goalCardClass =
'w-full rounded-[2.2rem] border border-white/12 bg-[linear-gradient(160deg,rgba(9,13,20,0.52)_0%,rgba(9,13,20,0.24)_52%,rgba(9,13,20,0.5)_100%)] px-6 py-6 shadow-[0_26px_90px_rgba(3,7,18,0.34)] backdrop-blur-[24px] md:px-8 md:py-8';
const reviewCarryCopyByHint: Record<
ReviewCarryHint,
{ title: string; body: string }
@@ -103,7 +99,7 @@ export const FocusDashboardWidget = () => {
const searchParams = useSearchParams();
const { plan, isPro, setPlan } = usePlanTier();
const { sceneAssetMap } = useMediaCatalog();
const { review, summary: weeklySummary } = useFocusStats();
const { summary: weeklySummary } = useFocusStats();
const reviewEntryPreset = searchParams.get('entryPreset');
const reviewEntryPresetConfig = useMemo(() => {
@@ -181,12 +177,9 @@ export const FocusDashboardWidget = () => {
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 durationHelper =
parsedDurationMinutes === null
? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.'
? 'Please enter the estimated duration in minutes.'
: entryCopy.durationHelper;
const hasCurrentSession = Boolean(currentSession);
@@ -303,120 +296,133 @@ export const FocusDashboardWidget = () => {
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
return (
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
<div className="relative min-h-dvh overflow-hidden bg-black text-white selection:bg-white/20">
{/* Background Media */}
<div
className={cn(
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out',
isStartingSession ? 'scale-[1.04]' : 'scale-100',
'absolute 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-0 brightness-100',
)}
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.28)_42%,rgba(2,6,23,0.54)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),rgba(255,255,255,0)_42%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_left,rgba(6,10,20,0.24),rgba(6,10,20,0)_36%)]" />
{/* Immersive Overlay Gradients */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] mix-blend-multiply pointer-events-none" />
<div className="absolute inset-0 bg-black/10 pointer-events-none" />
<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 uppercase tracking-[0.28em] text-white/56">
{entryCopy.eyebrow}
</p>
<PlanPill plan={plan} onClick={openPaywall} />
</header>
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-start justify-center px-4 pb-10 pt-3 md:px-6 md:pb-12 md:pt-6">
<div className="w-full max-w-[86rem]">
{isCheckingSession ? (
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
<p className="text-[15px] text-white/72"> .</p>
</div>
) : (
<div className="space-y-4">
{reviewReturnCopy ? (
<div className="rounded-[1.65rem] border border-white/12 bg-[linear-gradient(145deg,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0.04)_100%)] px-5 py-4 backdrop-blur-xl">
<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.03em] 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}
<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 transition"
>
<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.03em] 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 transition group-hover:text-white">
{reviewTeaserCta}
</span>
</div>
</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>
{/* 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 h-[100dvh] flex-col">
{isCheckingSession ? (
<div className="flex h-full 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 h-full w-full">
<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>
{paywallSource ? (
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
{/* 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-slate-950/52 backdrop-blur-[3px]"
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
/>
<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">
<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-4 text-sm text-white/62">{entryCopy.paywallBody}</p>
<p className="mb-6 text-sm leading-relaxed text-white/80">{entryCopy.paywallBody}</p>
<PaywallSheetContent
onStartPro={() => {
setPlan('pro');
@@ -426,7 +432,7 @@ export const FocusDashboardWidget = () => {
/>
</div>
</div>
) : null}
)}
</div>
);
};