feat(app): premium immersive entry ui 적용
This commit is contained in:
@@ -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">→</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">→</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user