feat(stats): observatory tone으로 review 재구성
This commit is contained in:
@@ -9,10 +9,10 @@ import { useFocusStats } from '@/features/stats';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
const glassPanelClass =
|
||||
'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 metricTileClass =
|
||||
'rounded-[1.45rem] 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 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;
|
||||
|
||||
@@ -24,7 +24,7 @@ const reviewStageSceneByPreset = (presetId: string) => {
|
||||
return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
};
|
||||
|
||||
const StatusAccessory = ({
|
||||
const AccessoryPill = ({
|
||||
label,
|
||||
subtle = false,
|
||||
}: {
|
||||
@@ -36,7 +36,7 @@ const StatusAccessory = ({
|
||||
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/44'
|
||||
? 'border-white/8 bg-white/[0.05] text-white/46'
|
||||
: 'border-white/12 bg-white/[0.07] text-white/64',
|
||||
)}
|
||||
>
|
||||
@@ -46,7 +46,7 @@ const StatusAccessory = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SnapshotMetric = ({
|
||||
const SnapshotCell = ({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
@@ -56,15 +56,15 @@ const SnapshotMetric = ({
|
||||
hint: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={metricTileClass}>
|
||||
<div className={innerTileClass}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">{label}</p>
|
||||
<p className="mt-3 text-[1.05rem] font-medium tracking-[-0.03em] text-white/92">{value}</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 ReviewSection = ({
|
||||
const InsightBoard = ({
|
||||
title,
|
||||
summary,
|
||||
metrics,
|
||||
@@ -79,38 +79,75 @@ const ReviewSection = ({
|
||||
note?: string;
|
||||
accentClass: string;
|
||||
}) => {
|
||||
const heroMetric = metrics[0];
|
||||
const supportMetrics = metrics.slice(1);
|
||||
|
||||
return (
|
||||
<section className={cn(glassPanelClass, 'relative overflow-hidden p-5 sm:p-6')}>
|
||||
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-20 opacity-90', accentClass)} />
|
||||
<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">
|
||||
Weekly Review
|
||||
Review Signal
|
||||
</p>
|
||||
<h2 className="mt-3 text-[1.2rem] font-medium tracking-[-0.04em] text-white">
|
||||
<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' ? <StatusAccessory label="Limited" subtle /> : null}
|
||||
{availability === 'limited' ? <AccessoryPill label="Limited" subtle /> : null}
|
||||
</div>
|
||||
|
||||
{metrics.length > 0 ? (
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{metrics.map((metric) => (
|
||||
<SnapshotMetric
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
hint={metric.hint}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<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-5 rounded-[1.35rem] border border-white/8 bg-white/[0.05] px-4 py-3 text-[12px] leading-[1.62] text-white/52">
|
||||
<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}
|
||||
@@ -140,8 +177,9 @@ export const StatsOverviewWidget = () => {
|
||||
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" />
|
||||
<div className="absolute inset-0 bg-black/18 pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.12)_0%,rgba(2,6,23,0.32)_44%,rgba(2,6,23,0.62)_100%)] pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-black/20 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-[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" />
|
||||
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -174,69 +212,83 @@ export const StatsOverviewWidget = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto flex min-h-dvh w-full max-w-[90rem] flex-col px-4 pb-10 pt-28 md:px-8 md:pb-12 md:pt-32">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<StatusAccessory label={review.periodLabel} />
|
||||
<StatusAccessory label={sourceLabel} subtle />
|
||||
<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">
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.08fr)_minmax(20rem,0.92fr)]">
|
||||
<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">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AccessoryPill label={review.periodLabel} />
|
||||
<AccessoryPill label={sourceLabel} subtle />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 max-w-[44rem]">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-white/42">
|
||||
집중 리듬 요약
|
||||
</p>
|
||||
<h1 className="mt-4 text-[2.35rem] font-light leading-[0.98] tracking-[-0.06em] text-white md:text-[3.65rem]">
|
||||
{review.snapshotSummary}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[34rem] text-[13px] leading-[1.72] text-white/54 md:text-[14px]">
|
||||
{syncLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 max-w-[58rem] space-y-4">
|
||||
<p className="text-[12px] font-medium uppercase tracking-[0.22em] text-white/42">
|
||||
{review.snapshotTitle}
|
||||
</p>
|
||||
<h1 className="text-[2.5rem] font-light leading-[0.98] tracking-[-0.06em] text-white md:text-[4rem]">
|
||||
{review.snapshotSummary}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[38rem] text-[13px] leading-[1.7] text-white/54 md:text-[14px]">
|
||||
{syncLabel}
|
||||
</p>
|
||||
<div className={cn(panelClass, 'p-5 sm:p-6')}>
|
||||
<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%)]" />
|
||||
<div className="relative">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40">
|
||||
Snapshot Signals
|
||||
</p>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{review.snapshotMetrics.map((metric) => (
|
||||
<SnapshotCell
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
hint={metric.hint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-10 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{review.snapshotMetrics.map((metric) => (
|
||||
<SnapshotMetric
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
hint={metric.hint}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="mt-12 grid gap-5 xl:grid-cols-[1.08fr_0.92fr]">
|
||||
<ReviewSection
|
||||
<section className="mt-12 grid gap-5 xl:grid-cols-[1.06fr_0.94fr]">
|
||||
<InsightBoard
|
||||
title={review.startQuality.title}
|
||||
summary={review.startQuality.summary}
|
||||
metrics={review.startQuality.metrics}
|
||||
availability={review.startQuality.availability}
|
||||
note={review.startQuality.note}
|
||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.34),rgba(96,165,250,0)_62%)]"
|
||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.32),rgba(96,165,250,0)_62%)]"
|
||||
/>
|
||||
|
||||
<ReviewSection
|
||||
<InsightBoard
|
||||
title={review.recoveryQuality.title}
|
||||
summary={review.recoveryQuality.summary}
|
||||
metrics={review.recoveryQuality.metrics}
|
||||
availability={review.recoveryQuality.availability}
|
||||
note={review.recoveryQuality.note}
|
||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(20,184,166,0.32),rgba(20,184,166,0)_62%)]"
|
||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(20,184,166,0.3),rgba(20,184,166,0)_62%)]"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-5 grid gap-5 xl:grid-cols-[0.94fr_1.06fr]">
|
||||
<ReviewSection
|
||||
<section className="mt-5 grid gap-5 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<InsightBoard
|
||||
title={review.completionQuality.title}
|
||||
summary={review.completionQuality.summary}
|
||||
metrics={review.completionQuality.metrics}
|
||||
availability={review.completionQuality.availability}
|
||||
note={review.completionQuality.note}
|
||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.34),rgba(245,158,11,0)_62%)]"
|
||||
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.32),rgba(245,158,11,0)_62%)]"
|
||||
/>
|
||||
|
||||
<section className={cn(glassPanelClass, 'relative overflow-hidden 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(168,85,247,0.32),rgba(168,85,247,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">
|
||||
@@ -247,25 +299,25 @@ export const StatsOverviewWidget = () => {
|
||||
<h2 className="mt-3 text-[1.45rem] font-medium tracking-[-0.04em] text-white md:text-[1.8rem]">
|
||||
다음 세션에 그대로 가져갈 흐름
|
||||
</h2>
|
||||
<p className="mt-3 text-[14px] leading-[1.7] text-white/64">
|
||||
<p className="mt-4 text-[14px] leading-[1.72] text-white/66">
|
||||
{review.carryForward.keepDoing}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isPro ? <StatusAccessory label="Recommended Ritual" subtle /> : null}
|
||||
{isPro ? <AccessoryPill label="Recommended Ritual" subtle /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(15rem,0.78fr)]">
|
||||
<div className={metricTileClass}>
|
||||
<div className={innerTileClass}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
||||
다음 주에 바꿔볼 것
|
||||
</p>
|
||||
<p className="mt-3 text-[14px] leading-[1.7] text-white/72">
|
||||
<p className="mt-3 text-[14px] leading-[1.72] text-white/72">
|
||||
{review.carryForward.tryNext}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={metricTileClass}>
|
||||
<div className={innerTileClass}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
|
||||
Atmosphere
|
||||
</p>
|
||||
@@ -273,15 +325,15 @@ export const StatsOverviewWidget = () => {
|
||||
{review.carryForward.presetLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] leading-[1.58] text-white/52">
|
||||
지금 가장 무리 없이 다시 들어갈 수 있는 기본 흐름입니다.
|
||||
가장 무리 없이 다시 들어갈 수 있는 기본 흐름입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-5">
|
||||
<p className="max-w-[30rem] text-[12px] leading-[1.65] text-white/44">
|
||||
review는 지난 시간을 요약하는 화면이 아니라, 다음 세션을 더 가볍게 열기 위한
|
||||
출발점입니다.
|
||||
review는 지난 시간을 예쁘게 요약하는 화면이 아니라, 다음 세션을 더 가볍게 열기
|
||||
위한 출발점이어야 합니다.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user