feat(space/app): app 진입부 및 space 몰입 환경(HUD/Tools) 프리미엄 UI 리팩토링
맥락: - 기존 app 대시보드와 space 화면의 UI가 SaaS 툴처럼 딱딱하고 투박하여, 유저가 기꺼이 지갑을 열 만한 몰입감과 고급스러움(Premium feel)이 부족함. - 인지적 과부하를 줄이기 위해 제안된 '첫 5분 행동(Micro-step)'이 타이머 영역에 묻혀 있어 행동 유발 효과가 미미함. 변경사항: - app: 컨테이너 박스를 제거하고 전체 배경 화면(Immersive Background)과 Glassmorphism을 활용한 1.5 Step 진입 플로우로 전면 개편. - space/hud: 하단의 두꺼운 타이머 패널을 초박형(Slim) 글라스 알약 형태로 축소하여 배경 씬의 개방감 확보. - space/hud: 목표(Goal)와 첫 단계(Micro-step)를 분리하여 좌측 상단의 우아한 Floating UI로 재배치하고, 체크 완료 시 사라지는 도파민 인터랙션 추가. - space/tools: 흩어져 있던 노트, 사운드, 설정 도구들을 우측 레일(Right-Rail)로 통합하고 팝오버 디자인을 고급화함. - ui/contrast: 밝은 배경에서도 텍스트가 잘 보이도록 좌측 상단 비네팅(Vignette) 및 다중 텍스트 그림자(Multi-layered Shadow) 효과 적용. 검증: - npm run build 정상 통과 확인. - 브라우저 상에서 micro-step 완료 애니메이션 및 도구막대 팝오버 슬라이드 동작 확인. 세션-상태: app 진입부터 space 몰입까지의 코어 UX/UI 하이엔드 개편 완료. 세션-다음: 프로 요금제(PRO) 전환 유도(Paywall) 흐름 및 상세 분석 리포트(Analytics) 뷰 구현. 세션-리스크: 없음.
This commit is contained in:
@@ -1,34 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useFocusStats } from '@/features/stats';
|
||||
|
||||
const StatSection = ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: Array<{ id: string; label: string; value: string; delta: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{title}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-brand-dark/10 bg-white/80 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<p className="text-xs text-brand-dark/58">{item.label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-brand-dark">{item.value}</p>
|
||||
<p className="mt-1 text-xs text-brand-primary/90">{item.delta}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const formatMinutes = (minutes: number) => {
|
||||
const safeMinutes = Math.max(0, minutes);
|
||||
@@ -42,55 +16,84 @@ const formatMinutes = (minutes: number) => {
|
||||
return `${hourPart}h ${minutePart}m`;
|
||||
};
|
||||
|
||||
const FactualStatCard = ({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}) => {
|
||||
return (
|
||||
<article className="rounded-2xl border border-brand-dark/10 bg-white/82 p-4 backdrop-blur-sm">
|
||||
<p className="text-xs text-brand-dark/58">{label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-brand-dark">{value}</p>
|
||||
<p className="mt-1 text-xs text-brand-dark/48">{hint}</p>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatsOverviewWidget = () => {
|
||||
const { stats } = copy;
|
||||
const { summary, isLoading, error, source, refetch } = useFocusStats();
|
||||
|
||||
const todayItems = [
|
||||
{
|
||||
id: 'today-focus',
|
||||
label: stats.todayFocus,
|
||||
value: formatMinutes(summary.today.focusMinutes),
|
||||
delta: source === 'api' ? stats.apiLabel : stats.mockLabel,
|
||||
hint: source === 'api' ? stats.apiLabel : stats.mockLabel,
|
||||
},
|
||||
{
|
||||
id: 'today-cycles',
|
||||
id: 'today-complete',
|
||||
label: stats.completedCycles,
|
||||
value: `${summary.today.completedCycles}${stats.countUnit}`,
|
||||
delta: `${summary.today.completedCycles > 0 ? '+' : ''}${summary.today.completedCycles}`,
|
||||
hint: '오늘 완료한 focus cycle',
|
||||
},
|
||||
{
|
||||
id: 'today-entry',
|
||||
label: stats.sessionEntries,
|
||||
value: `${summary.today.sessionEntries}${stats.countUnit}`,
|
||||
delta: source === 'api' ? stats.syncedApi : stats.temporary,
|
||||
hint: '오늘 space에 들어간 횟수',
|
||||
},
|
||||
];
|
||||
|
||||
const weeklyItems = [
|
||||
{
|
||||
id: 'week-focus',
|
||||
label: stats.last7DaysFocus,
|
||||
value: formatMinutes(summary.last7Days.focusMinutes),
|
||||
delta: source === 'api' ? stats.actualAggregate : stats.mockAggregate,
|
||||
hint: '최근 7일 총 focus 시간',
|
||||
},
|
||||
{
|
||||
id: 'week-best-day',
|
||||
label: stats.bestDay,
|
||||
value: summary.last7Days.bestDayLabel,
|
||||
delta: formatMinutes(summary.last7Days.bestDayFocusMinutes),
|
||||
id: 'week-started',
|
||||
label: '시작한 세션',
|
||||
value: `${summary.last7Days.startedSessions}${stats.countUnit}`,
|
||||
hint: '최근 7일 시작 횟수',
|
||||
},
|
||||
{
|
||||
id: 'week-consistency',
|
||||
label: stats.streak,
|
||||
value: `${summary.last7Days.streakDays}${stats.dayUnit}`,
|
||||
delta: summary.last7Days.streakDays > 0 ? stats.streakActive : stats.streakStart,
|
||||
id: 'week-completed',
|
||||
label: '완료한 세션',
|
||||
value: `${summary.last7Days.completedSessions}${stats.countUnit}`,
|
||||
hint: '최근 7일 goal/timer 완료 횟수',
|
||||
},
|
||||
{
|
||||
id: 'week-carry',
|
||||
label: '이월된 블록',
|
||||
value: `${summary.last7Days.carriedOverCount}${stats.countUnit}`,
|
||||
hint: '다음 날로 이어진 계획 수',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_18%_0%,rgba(167,204,237,0.45),transparent_50%),radial-gradient(circle_at_88%_8%,rgba(191,219,254,0.4),transparent_42%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_52%,#e9f1fa_100%)] text-brand-dark">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between rounded-xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
||||
<h1 className="text-xl font-semibold">{stats.title}</h1>
|
||||
<div className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{stats.title}</h1>
|
||||
<p className="mt-1 text-xs text-brand-dark/56">해석형 insight 없이 factual summary만 표시합니다.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
|
||||
@@ -100,7 +103,7 @@ export const StatsOverviewWidget = () => {
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
|
||||
<section className="rounded-2xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-brand-dark/72">
|
||||
@@ -126,12 +129,27 @@ export const StatsOverviewWidget = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<StatSection title={stats.today} items={todayItems} />
|
||||
<StatSection title={stats.last7Days} items={weeklyItems} />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.today}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{todayItems.map((item) => (
|
||||
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.last7Days}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{weeklyItems.map((item) => (
|
||||
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-brand-dark">{stats.chartTitle}</h2>
|
||||
<div className="rounded-xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
|
||||
<div className="rounded-2xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
|
||||
<div className="h-52 rounded-lg border border-brand-dark/12 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))] p-4">
|
||||
{summary.trend.length > 0 ? (
|
||||
<div className="flex h-full items-end gap-2">
|
||||
@@ -145,9 +163,7 @@ export const StatsOverviewWidget = () => {
|
||||
style={{ height: `${barHeight}%` }}
|
||||
title={stats.barTitle(point.date, point.focusMinutes)}
|
||||
/>
|
||||
<span className="text-[10px] text-brand-dark/56">
|
||||
{point.date.slice(5)}
|
||||
</span>
|
||||
<span className="text-[10px] text-brand-dark/56">{point.date.slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -155,9 +171,7 @@ export const StatsOverviewWidget = () => {
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-brand-dark/56">
|
||||
{summary.trend.length > 0
|
||||
? stats.chartWithTrend
|
||||
: stats.chartWithoutTrend}
|
||||
{summary.trend.length > 0 ? stats.chartWithTrend : stats.chartWithoutTrend}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user