feat(app): premium immersive entry ui 적용
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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