fix(app): premium entry 조정과 duration 입력 버그 수정
This commit is contained in:
@@ -21,6 +21,11 @@
|
|||||||
- **필수 속성:** `backdrop-blur-xl` 또는 `backdrop-blur-2xl`을 반드시 사용합니다.
|
- **필수 속성:** `backdrop-blur-xl` 또는 `backdrop-blur-2xl`을 반드시 사용합니다.
|
||||||
- **테두리와 명암:** 투박한 solid border 대신, `border-white/5` ~ `border-white/10` 수준의 매우 얇고 투명한 테두리를 사용합니다. 깊이감을 위해 다중 그림자(`shadow-2xl` 등)와 미세한 그라데이션(`bg-[linear-gradient(...)]`)을 조합합니다.
|
- **테두리와 명암:** 투박한 solid border 대신, `border-white/5` ~ `border-white/10` 수준의 매우 얇고 투명한 테두리를 사용합니다. 깊이감을 위해 다중 그림자(`shadow-2xl` 등)와 미세한 그라데이션(`bg-[linear-gradient(...)]`)을 조합합니다.
|
||||||
|
|
||||||
|
### 1-4. 무대와 대기실의 분리 (Stage vs. Lobby Separation)
|
||||||
|
- **세션(Space) 외의 화면(`/app`, `/stats`, `/settings` 등)은 실제 집중 배경(Atmosphere)을 그대로 띄우지 않습니다.**
|
||||||
|
- 대기실(Lobby) 역할을 하는 화면에 너무 구체적인 풍경이나 영상이 띄워져 있으면, 사용자가 이미 집중 세션에 들어왔다고 착각하거나 인지적 피로감을 느낄 수 있습니다.
|
||||||
|
- **해결책:** 대기실 화면의 배경은 집중할 때 볼 풍경을 블러 처리(`blur-3xl`)하거나, 매우 어둡고 깊이 있는 추상적 그라데이션(예: `bg-black`에 은은한 틴트)으로 처리하여 **"아직 무대에 오르기 전(또는 내려온 후)"** 이라는 심리적 분리감을 명확히 주어야 합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 레이아웃 & 컴포넌트 배치 원칙 (Layout & Placement)
|
## 2. 레이아웃 & 컴포넌트 배치 원칙 (Layout & Placement)
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ Last Updated: 2026-03-16
|
|||||||
- 필수
|
- 필수
|
||||||
- 숫자만
|
- 숫자만
|
||||||
- 권장 범위:
|
- 권장 범위:
|
||||||
- 최소 10분
|
- 최소 5분
|
||||||
- 최대 180분
|
- 최대 180분
|
||||||
- helper:
|
- helper:
|
||||||
- `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.`
|
- `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.`
|
||||||
|
|||||||
@@ -158,11 +158,15 @@ export const parseDurationMinutes = (value: string) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(10, Math.min(180, parsed));
|
if (parsed < 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(180, parsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sanitizeDurationDraft = (value: string) => {
|
export const sanitizeDurationDraft = (value: string) => {
|
||||||
const digitsOnly = value.replace(/[^\d]/g, '');
|
const digitsOnly = value.replace(/[^\d]/g, '').slice(0, 3);
|
||||||
if (!digitsOnly) {
|
if (!digitsOnly) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -172,7 +176,7 @@ export const sanitizeDurationDraft = (value: string) => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(Math.max(10, Math.min(180, parsed)));
|
return String(Math.min(180, parsed));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTimerPresetMetaById = (timerPresetId: string) => {
|
export const getTimerPresetMetaById = (timerPresetId: string) => {
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
{/* Main Focus Entry Ritual */}
|
{/* Main Focus Entry Ritual */}
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-4 pb-10">
|
<div className="flex flex-1 flex-col items-center justify-center px-4 pb-10">
|
||||||
{/* Inline Accessories (No overlap guarantee) */}
|
{/* 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">
|
<div className="mb-12 flex min-h-[5rem] flex-col items-center justify-end opacity-0 animate-fade-in-up delay-150">
|
||||||
{errorAccessory}
|
{errorAccessory}
|
||||||
{!errorAccessory && topAccessory}
|
{!errorAccessory && topAccessory}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-4xl space-y-8 text-center opacity-0 animate-fade-in-up delay-150">
|
<div className="w-full max-w-4xl text-center opacity-0 animate-fade-in-up delay-150 mb-16">
|
||||||
<p className="text-sm font-medium uppercase tracking-[0.25em] text-white/50">
|
<p className="text-xs font-bold uppercase tracking-[0.3em] text-white/40 drop-shadow-sm mb-6">
|
||||||
What will you focus on?
|
What will you focus on?
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
@@ -77,62 +77,66 @@ export const AppAtmosphereEntryShell = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={goalPlaceholder}
|
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]"
|
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] transition-colors focus:placeholder:text-white/10"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col items-center space-y-8 opacity-0 animate-fade-in-up delay-150">
|
<div className="flex w-full flex-col items-center space-y-10 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 flex-col items-center">
|
||||||
<div className="flex gap-2">
|
{/* Primary Action: Massive Custom Timer Input */}
|
||||||
{durationSuggestions.map((minutes) => {
|
<div className="group relative flex items-baseline justify-center">
|
||||||
const isSelected = durationDraft === String(minutes);
|
<input
|
||||||
return (
|
value={durationDraft}
|
||||||
<button
|
onChange={(event) => onDurationChange(event.target.value)}
|
||||||
key={minutes}
|
onKeyDown={(event) => {
|
||||||
type="button"
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
onClick={() => onSelectDuration(minutes)}
|
event.preventDefault();
|
||||||
className={cn(
|
onStartSession();
|
||||||
'rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300',
|
}
|
||||||
isSelected
|
}}
|
||||||
? 'bg-white text-black shadow-md'
|
inputMode="numeric"
|
||||||
: 'text-white/70 hover:bg-white/10 hover:text-white',
|
placeholder="0"
|
||||||
)}
|
className="w-24 bg-transparent text-center text-6xl font-light tracking-tighter text-white outline-none placeholder:text-white/10 transition-all focus:w-32 md:text-7xl"
|
||||||
>
|
/>
|
||||||
{minutes}m
|
<span className="absolute -right-12 bottom-2 text-xl font-medium tracking-wide text-white/30 transition-colors group-focus-within:text-white/60 md:bottom-3 md:text-2xl">
|
||||||
</button>
|
min
|
||||||
);
|
</span>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-px bg-white/10" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={durationDraft}
|
|
||||||
onChange={(event) => onDurationChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
onStartSession();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="Custom"
|
|
||||||
className="w-16 bg-transparent text-right text-lg font-medium text-white outline-none placeholder:text-white/30"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-white/50">min</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-white/40">{durationHelper}</p>
|
|
||||||
|
{/* Secondary Action: Subtle Quick Select Pills */}
|
||||||
|
<div className="mt-8 flex items-center gap-2 rounded-full border border-white/5 bg-[linear-gradient(145deg,rgba(255,255,255,0.04)_0%,rgba(255,255,255,0.01)_100%)] p-1.5 backdrop-blur-md">
|
||||||
|
{durationSuggestions.map((minutes) => {
|
||||||
|
const isSelected = durationDraft === String(minutes);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={minutes}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDuration(minutes)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-4 py-2 text-[12px] font-medium tracking-wide transition-all duration-300',
|
||||||
|
isSelected
|
||||||
|
? 'bg-white/10 text-white shadow-sm ring-1 ring-white/20'
|
||||||
|
: 'text-white/40 hover:text-white/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{minutes}m
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-[11px] font-medium tracking-wide text-white/30">{durationHelper}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStartSession}
|
onClick={onStartSession}
|
||||||
disabled={!canStart}
|
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"
|
className="group relative flex h-14 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-[linear-gradient(145deg,rgba(255,255,255,0.15)_0%,rgba(255,255,255,0.05)_100%)] px-12 text-[14px] font-medium tracking-widest text-white shadow-[0_0_40px_rgba(255,255,255,0.1)] backdrop-blur-2xl transition-all duration-500 hover:border-white/40 hover:bg-white/20 hover:shadow-[0_0_60px_rgba(255,255,255,0.2)] hover:scale-[1.03] active:scale-[0.98] disabled:pointer-events-none disabled:opacity-30 uppercase"
|
||||||
>
|
>
|
||||||
<span className="relative z-10">
|
<span className="relative z-10 drop-shadow-md">
|
||||||
{isStartingSession ? startButtonLoadingLabel : startButtonLabel}
|
{isStartingSession ? startButtonLoadingLabel : startButtonLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ export const FocusDashboardWidget = () => {
|
|||||||
() => getAtmosphereOptionById(selectedAtmosphereId),
|
() => getAtmosphereOptionById(selectedAtmosphereId),
|
||||||
[selectedAtmosphereId],
|
[selectedAtmosphereId],
|
||||||
);
|
);
|
||||||
|
const rawDurationValue = useMemo(() => {
|
||||||
|
const digitsOnly = durationDraft.replace(/[^\d]/g, '');
|
||||||
|
if (!digitsOnly) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(digitsOnly);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}, [durationDraft]);
|
||||||
const parsedDurationMinutes = parseDurationMinutes(durationDraft);
|
const parsedDurationMinutes = parseDurationMinutes(durationDraft);
|
||||||
const resolvedTimerPreset = useMemo(() => {
|
const resolvedTimerPreset = useMemo(() => {
|
||||||
const targetMinutes =
|
const targetMinutes =
|
||||||
@@ -178,9 +187,11 @@ export const FocusDashboardWidget = () => {
|
|||||||
isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null;
|
isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null;
|
||||||
const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle;
|
const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle;
|
||||||
const durationHelper =
|
const durationHelper =
|
||||||
parsedDurationMinutes === null
|
rawDurationValue !== null && rawDurationValue < 5
|
||||||
? 'Please enter the estimated duration in minutes.'
|
? 'Please enter at least 5 minutes.'
|
||||||
: entryCopy.durationHelper;
|
: parsedDurationMinutes === null
|
||||||
|
? 'Please enter the estimated duration in minutes.'
|
||||||
|
: entryCopy.durationHelper;
|
||||||
const hasCurrentSession = Boolean(currentSession);
|
const hasCurrentSession = Boolean(currentSession);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -301,13 +312,13 @@ export const FocusDashboardWidget = () => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 bg-cover bg-center transition-transform duration-[1.5s] ease-[cubic-bezier(0.22,1,0.36,1)]',
|
'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',
|
isStartingSession ? 'scale-[1.08] blur-[2px] brightness-75' : 'scale-100 blur-[60px] brightness-50',
|
||||||
)}
|
)}
|
||||||
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
||||||
/>
|
/>
|
||||||
{/* Immersive Overlay Gradients */}
|
{/* 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-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.85)_100%)] mix-blend-multiply pointer-events-none" />
|
||||||
<div className="absolute inset-0 bg-black/10 pointer-events-none" />
|
<div className="absolute inset-0 bg-black/40 pointer-events-none" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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">
|
<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">
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ import { useFocusStats } from '@/features/stats';
|
|||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
const panelClass =
|
|
||||||
'relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(160deg,rgba(8,12,18,0.46)_0%,rgba(8,12,18,0.2)_58%,rgba(8,12,18,0.52)_100%)] shadow-[0_24px_80px_rgba(3,7,18,0.28)] backdrop-blur-[24px]';
|
|
||||||
const innerTileClass =
|
|
||||||
'rounded-[1.4rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.04)_100%)] px-4 py-4 backdrop-blur-xl';
|
|
||||||
|
|
||||||
const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
||||||
|
|
||||||
const reviewStageSceneByPreset = (presetId: string) => {
|
const reviewStageSceneByPreset = (presetId: string) => {
|
||||||
@@ -24,138 +19,6 @@ const reviewStageSceneByPreset = (presetId: string) => {
|
|||||||
return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
|
return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccessoryPill = ({
|
|
||||||
label,
|
|
||||||
subtle = false,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
subtle?: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] backdrop-blur-md',
|
|
||||||
subtle
|
|
||||||
? 'border-white/8 bg-white/[0.05] text-white/46'
|
|
||||||
: 'border-white/12 bg-white/[0.07] text-white/64',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-white/50" />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SnapshotCell = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
hint,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
hint: string;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={innerTileClass}>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">{label}</p>
|
|
||||||
<p className="mt-3 text-[1.1rem] font-medium tracking-[-0.03em] text-white/92">{value}</p>
|
|
||||||
<p className="mt-2 text-[12px] leading-[1.58] text-white/54">{hint}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const InsightBoard = ({
|
|
||||||
title,
|
|
||||||
summary,
|
|
||||||
metrics,
|
|
||||||
availability,
|
|
||||||
note,
|
|
||||||
accentClass,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
metrics: Array<{ id: string; label: string; value: string; hint: string }>;
|
|
||||||
availability: 'ready' | 'limited';
|
|
||||||
note?: string;
|
|
||||||
accentClass: string;
|
|
||||||
}) => {
|
|
||||||
const heroMetric = metrics[0];
|
|
||||||
const supportMetrics = metrics.slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={cn(panelClass, 'p-5 sm:p-6')}>
|
|
||||||
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-24 opacity-90', accentClass)} />
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="max-w-[34rem]">
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40">
|
|
||||||
Review Signal
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-[1.18rem] font-medium tracking-[-0.04em] text-white">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-[13px] leading-[1.68] text-white/64">{summary}</p>
|
|
||||||
</div>
|
|
||||||
{availability === 'limited' ? <AccessoryPill label="Limited" subtle /> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 xl:grid-cols-[minmax(0,0.94fr)_minmax(15rem,0.78fr)]">
|
|
||||||
<div className={cn(innerTileClass, 'min-h-[11.5rem]')}>
|
|
||||||
{heroMetric ? (
|
|
||||||
<>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
|
||||||
{heroMetric.label}
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 flex items-end gap-3">
|
|
||||||
<p className="text-[2.55rem] font-light leading-none tracking-[-0.06em] text-white md:text-[3rem]">
|
|
||||||
{heroMetric.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 max-w-[24rem] text-[12px] leading-[1.62] text-white/54">
|
|
||||||
{heroMetric.hint}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-[12px] leading-[1.62] text-white/52">아직 보여줄 지표가 충분하지 않아요.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{supportMetrics.length > 0 ? (
|
|
||||||
supportMetrics.map((metric) => (
|
|
||||||
<div key={metric.id} className={innerTileClass}>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
|
||||||
{metric.label}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.03em] text-white/88">
|
|
||||||
{metric.value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-[12px] leading-[1.58] text-white/54">{metric.hint}</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className={innerTileClass}>
|
|
||||||
<p className="text-[12px] leading-[1.58] text-white/52">보조 지표는 데이터가 쌓이면 함께 보입니다.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{note ? (
|
|
||||||
<p className="mt-4 rounded-[1.3rem] border border-white/8 bg-white/[0.05] px-4 py-3 text-[12px] leading-[1.62] text-white/52">
|
|
||||||
{note}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StatsOverviewWidget = () => {
|
export const StatsOverviewWidget = () => {
|
||||||
const { stats } = copy;
|
const { stats } = copy;
|
||||||
const { isPro } = usePlanTier();
|
const { isPro } = usePlanTier();
|
||||||
@@ -171,181 +34,214 @@ export const StatsOverviewWidget = () => {
|
|||||||
const carryForwardCtaLabel = isPro ? stats.reviewCarryCtaPro : review.carryForward.ctaLabel;
|
const carryForwardCtaLabel = isPro ? stats.reviewCarryCtaPro : review.carryForward.ctaLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-dvh overflow-hidden bg-black text-white selection:bg-white/20">
|
<div className="relative min-h-dvh overflow-hidden bg-black text-white selection:bg-white/20 font-sans">
|
||||||
|
{/* Immersive Background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center"
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-[2s] ease-[cubic-bezier(0.22,1,0.36,1)] scale-105 blur-[60px] brightness-50"
|
||||||
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.62)_100%)] mix-blend-multiply pointer-events-none" />
|
{/* Premium Cinematic Overlays */}
|
||||||
<div className="absolute inset-0 bg-black/20 pointer-events-none" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.85)_100%)] mix-blend-multiply pointer-events-none" />
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.14)_0%,rgba(2,6,23,0.34)_44%,rgba(2,6,23,0.64)_100%)] pointer-events-none" />
|
<div className="absolute inset-0 bg-black/40 pointer-events-none" />
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:72px_72px] opacity-[0.14] pointer-events-none" />
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent_0%,rgba(0,0,0,0.6)_50%,rgba(0,0,0,0.9)_100%)] pointer-events-none" />
|
||||||
|
|
||||||
<header className="absolute inset-x-0 top-0 z-40 flex items-center justify-between px-5 py-5 md:px-8 md:py-8">
|
{/* Header */}
|
||||||
|
<header className="absolute inset-x-0 top-0 z-40 flex items-center justify-between px-8 py-8 md:px-12 md:py-10 opacity-0 animate-fade-in delay-150">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-[11px] font-bold uppercase tracking-[0.4em] text-white/54">
|
<p className="text-[11px] font-bold uppercase tracking-[0.4em] text-white/50 drop-shadow-sm">
|
||||||
Weekly Review
|
Weekly Review
|
||||||
</p>
|
</p>
|
||||||
{isPro ? (
|
{isPro && (
|
||||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.18em] text-white/60 backdrop-blur-md">
|
<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
|
PRO
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
}}
|
}}
|
||||||
className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/62 backdrop-blur-md transition hover:bg-white/[0.1] hover:text-white"
|
className="group flex items-center gap-2 rounded-full border border-white/5 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"
|
||||||
>
|
>
|
||||||
{stats.refresh}
|
{stats.refresh}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/app"
|
href="/app"
|
||||||
className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/62 backdrop-blur-md transition hover:bg-white/[0.1] hover:text-white"
|
className="group flex items-center gap-2 rounded-full border border-white/5 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"
|
||||||
>
|
>
|
||||||
{copy.common.hub}
|
<span>{copy.common.hub}</span>
|
||||||
|
<span className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="relative z-10 mx-auto flex min-h-dvh w-full max-w-[92rem] flex-col px-4 pb-10 pt-28 md:px-8 md:pb-12 md:pt-32">
|
{/* Main Flow */}
|
||||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.08fr)_minmax(20rem,0.92fr)]">
|
<main className="relative z-10 mx-auto flex min-h-dvh w-full max-w-6xl flex-col px-6 pb-24 pt-32 md:px-12 md:pt-40">
|
||||||
<div className={cn(panelClass, 'p-6 sm:p-7')}>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),rgba(255,255,255,0)_62%)]" />
|
|
||||||
|
|
||||||
<div className="relative">
|
{/* 1. Hero Summary (Cinematic Entry) */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<section className="mb-20 flex flex-col items-center text-center opacity-0 animate-fade-in-up delay-150">
|
||||||
<AccessoryPill label={review.periodLabel} />
|
<div className="mb-6 flex flex-wrap justify-center gap-3">
|
||||||
<AccessoryPill label={sourceLabel} subtle />
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/5 bg-white/5 px-3 py-1.5 backdrop-blur-md">
|
||||||
</div>
|
<span className="h-1.5 w-1.5 rounded-full bg-white/40" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-white/70">
|
||||||
<div className="mt-8 max-w-[44rem]">
|
{review.periodLabel}
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-white/42">
|
</span>
|
||||||
집중 리듬 요약
|
</div>
|
||||||
</p>
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/5 bg-white/5 px-3 py-1.5 backdrop-blur-md">
|
||||||
<h1 className="mt-4 text-[2.35rem] font-light leading-[0.98] tracking-[-0.06em] text-white md:text-[3.65rem]">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-white/40">
|
||||||
{review.snapshotSummary}
|
{sourceLabel}
|
||||||
</h1>
|
</span>
|
||||||
<p className="mt-4 max-w-[34rem] text-[13px] leading-[1.72] text-white/54 md:text-[14px]">
|
|
||||||
{syncLabel}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(panelClass, 'p-5 sm:p-6')}>
|
<h1 className="max-w-4xl text-[2.5rem] font-light leading-[1.1] tracking-tight text-white/95 md:text-[4rem]">
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.24),rgba(96,165,250,0)_62%)]" />
|
{review.snapshotSummary}
|
||||||
<div className="relative">
|
</h1>
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40">
|
<p className="mt-6 text-[13px] font-medium tracking-widest uppercase text-white/40">
|
||||||
Snapshot Signals
|
{syncLabel}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
|
||||||
{review.snapshotMetrics.map((metric) => (
|
{/* Inline Snapshot Metrics */}
|
||||||
<SnapshotCell
|
<div className="mt-16 flex flex-wrap justify-center gap-8 md:gap-16">
|
||||||
key={metric.id}
|
{review.snapshotMetrics.map((metric) => (
|
||||||
label={metric.label}
|
<div key={metric.id} className="flex flex-col items-center">
|
||||||
value={metric.value}
|
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-white/40">
|
||||||
hint={metric.hint}
|
{metric.label}
|
||||||
/>
|
</p>
|
||||||
))}
|
<p className="mt-2 text-4xl font-light tracking-tight text-white/90 md:text-5xl">
|
||||||
|
{metric.value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-[12rem] text-center text-[12px] leading-relaxed text-white/50">
|
||||||
|
{metric.hint}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-12 grid gap-5 xl:grid-cols-[1.06fr_0.94fr]">
|
{/* 2. Insight Pillars (Start, Recovery, Completion) */}
|
||||||
<InsightBoard
|
<section className="mb-24 grid gap-6 md:grid-cols-3 opacity-0 animate-fade-in-up delay-300">
|
||||||
title={review.startQuality.title}
|
{[
|
||||||
summary={review.startQuality.summary}
|
{
|
||||||
metrics={review.startQuality.metrics}
|
data: review.startQuality,
|
||||||
availability={review.startQuality.availability}
|
accent: 'bg-[radial-gradient(circle_at_top,rgba(96,165,250,0.15),transparent_70%)]'
|
||||||
note={review.startQuality.note}
|
},
|
||||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.32),rgba(96,165,250,0)_62%)]"
|
{
|
||||||
/>
|
data: review.recoveryQuality,
|
||||||
|
accent: 'bg-[radial-gradient(circle_at_top,rgba(20,184,166,0.15),transparent_70%)]'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: review.completionQuality,
|
||||||
|
accent: 'bg-[radial-gradient(circle_at_top,rgba(245,158,11,0.15),transparent_70%)]'
|
||||||
|
}
|
||||||
|
].map((column, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="relative flex flex-col overflow-hidden rounded-[2rem] border border-white/5 bg-white/5 p-8 backdrop-blur-2xl transition-all hover:bg-white/10 hover:border-white/10"
|
||||||
|
>
|
||||||
|
<div className={cn("absolute inset-0 pointer-events-none", column.accent)} />
|
||||||
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-white/40">
|
||||||
|
{column.data.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-[14px] leading-relaxed text-white/70">
|
||||||
|
{column.data.summary}
|
||||||
|
</p>
|
||||||
|
|
||||||
<InsightBoard
|
<div className="mt-8 flex-1 space-y-6">
|
||||||
title={review.recoveryQuality.title}
|
{column.data.metrics.map((metric, mIdx) => (
|
||||||
summary={review.recoveryQuality.summary}
|
<div key={metric.id} className={cn("flex flex-col", mIdx > 0 && "pt-6 border-t border-white/5")}>
|
||||||
metrics={review.recoveryQuality.metrics}
|
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/40">
|
||||||
availability={review.recoveryQuality.availability}
|
{metric.label}
|
||||||
note={review.recoveryQuality.note}
|
</p>
|
||||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(20,184,166,0.3),rgba(20,184,166,0)_62%)]"
|
<p className={cn("mt-1 font-light tracking-tight text-white/90", mIdx === 0 ? "text-4xl" : "text-2xl")}>
|
||||||
/>
|
{metric.value}
|
||||||
</section>
|
</p>
|
||||||
|
<p className="mt-2 text-[12px] leading-relaxed text-white/50">
|
||||||
<section className="mt-5 grid gap-5 xl:grid-cols-[0.9fr_1.1fr]">
|
{metric.hint}
|
||||||
<InsightBoard
|
</p>
|
||||||
title={review.completionQuality.title}
|
</div>
|
||||||
summary={review.completionQuality.summary}
|
))}
|
||||||
metrics={review.completionQuality.metrics}
|
{column.data.metrics.length === 0 && (
|
||||||
availability={review.completionQuality.availability}
|
<p className="text-[12px] leading-relaxed text-white/30 italic">지표 데이터가 부족합니다.</p>
|
||||||
note={review.completionQuality.note}
|
)}
|
||||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.32),rgba(245,158,11,0)_62%)]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className={cn(panelClass, 'p-6 sm:p-7')}>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_top_left,rgba(168,85,247,0.32),rgba(168,85,247,0)_62%)]" />
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div className="max-w-[34rem]">
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40">
|
|
||||||
Carry Forward
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-3 text-[1.45rem] font-medium tracking-[-0.04em] text-white md:text-[1.8rem]">
|
|
||||||
다음 세션에 그대로 가져갈 흐름
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-[14px] leading-[1.72] text-white/66">
|
|
||||||
{review.carryForward.keepDoing}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPro ? <AccessoryPill label="Recommended Ritual" subtle /> : null}
|
{column.data.note && (
|
||||||
|
<div className="mt-8 rounded-2xl border border-white/5 bg-white/5 p-4">
|
||||||
|
<p className="text-[11px] leading-relaxed text-white/50">
|
||||||
|
{column.data.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{column.data.availability === 'limited' && (
|
||||||
|
<div className="mt-6 inline-flex self-start rounded-full border border-white/5 bg-white/5 px-3 py-1 text-[9px] font-bold uppercase tracking-widest text-white/30">
|
||||||
|
Limited Data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3. Carry Forward Climax */}
|
||||||
|
<section className="flex flex-col items-center text-center opacity-0 animate-fade-in-up delay-500">
|
||||||
|
<div className="relative w-full max-w-3xl overflow-hidden rounded-[2.5rem] border border-white/10 bg-white/5 p-10 backdrop-blur-3xl shadow-2xl md:p-16">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(168,85,247,0.15),transparent_70%)] pointer-events-none" />
|
||||||
|
<div className="relative z-10 flex flex-col items-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/5 bg-white/5 px-3 py-1.5 backdrop-blur-md mb-6">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-white/40" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-white/60">
|
||||||
|
Carry Forward
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(15rem,0.78fr)]">
|
<h2 className="text-3xl font-light tracking-tight text-white/95 md:text-4xl">
|
||||||
<div className={innerTileClass}>
|
다음 세션에 가져갈 흐름
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
</h2>
|
||||||
다음 주에 바꿔볼 것
|
<p className="mt-6 text-lg text-white/70 font-light leading-relaxed max-w-xl">
|
||||||
|
"{review.carryForward.keepDoing}"
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-6 text-left w-full md:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-white/5 bg-white/5 p-5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-white/40">
|
||||||
|
작은 변화 시도하기
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-[14px] leading-[1.72] text-white/72">
|
<p className="mt-3 text-[13px] leading-relaxed text-white/70">
|
||||||
{review.carryForward.tryNext}
|
{review.carryForward.tryNext}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/5 bg-white/5 p-5">
|
||||||
<div className={innerTileClass}>
|
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-white/40 flex justify-between">
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
<span>추천 Atmosphere</span>
|
||||||
Atmosphere
|
{isPro && <span className="text-white/30">PRO</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-[14px] font-medium tracking-[-0.03em] text-white/88">
|
<p className="mt-3 text-[15px] font-medium tracking-tight text-white/90">
|
||||||
{review.carryForward.presetLabel}
|
{review.carryForward.presetLabel}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[12px] leading-[1.58] text-white/52">
|
<p className="mt-1 text-[12px] text-white/50">가장 무리 없이 들어갈 수 있는 흐름.</p>
|
||||||
가장 무리 없이 다시 들어갈 수 있는 기본 흐름입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-5">
|
<div className="mt-12 flex flex-col items-center gap-4">
|
||||||
<p className="max-w-[30rem] text-[12px] leading-[1.65] text-white/44">
|
|
||||||
review는 지난 시간을 예쁘게 요약하는 화면이 아니라, 다음 세션을 더 가볍게 열기
|
|
||||||
위한 출발점이어야 합니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={review.carryForward.ctaHref}
|
href={review.carryForward.ctaHref}
|
||||||
className="inline-flex min-h-[3.4rem] items-center justify-center rounded-full border border-white/14 bg-white/[0.14] px-6 text-[13px] font-medium tracking-[0.02em] text-white shadow-[0_12px_28px_rgba(3,7,18,0.22)] backdrop-blur-md transition hover:bg-white/[0.18] active:scale-[0.99]"
|
className="group relative flex h-14 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 px-10 text-[14px] 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]"
|
||||||
>
|
>
|
||||||
{carryForwardCtaLabel}
|
<span className="relative z-10">{carryForwardCtaLabel}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<p className="text-[11px] text-white/30 tracking-wide">
|
||||||
|
Review는 끝이 아니라, 더 가벼운 시작을 위한 출발점입니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user