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

@@ -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>
);
};