feat(dashboard): empty state per design-08 — 7D.6

This commit is contained in:
reloop
2026-04-12 04:40:52 +09:00
parent eeb772a755
commit e79c4d8d17
2 changed files with 391 additions and 8 deletions

View File

@@ -3,12 +3,14 @@
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import styled, { css } from 'styled-components';
import EmptyDashboard from '@/components/dashboard/EmptyDashboard';
import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { Button, Card } from '@/components/ui/primitives';
import {
api,
type DashboardSummary,
type MeUser,
type MasteryPathResponse,
type Persona,
type StudyLog,
@@ -62,6 +64,7 @@ export default function DashboardPage() {
}
function DashboardBody() {
const [me, setMe] = useState<MeUser | null>(null);
const [data, setData] = useState<DashboardSummary | null>(null);
const [recentLogs, setRecentLogs] = useState<StudyLog[]>([]);
const [activityLogs, setActivityLogs] = useState<StudyLog[]>([]);
@@ -82,7 +85,8 @@ function DashboardBody() {
setError(null);
try {
const [summaryRes, recentRes, activityRes, subjectsRes] = await Promise.all([
const [meRes, summaryRes, recentRes, activityRes, subjectsRes] = await Promise.all([
api.get<MeUser>('/auth/me'),
api.get<DashboardSummary>('/dashboard/summary'),
api.get<StudyLog[]>('/study-logs', { params: { limit: 5 } }),
api.get<StudyLog[]>('/study-logs', { params: { limit: 200 } }),
@@ -96,6 +100,7 @@ function DashboardBody() {
const nextActivityLogs = activityRes.data;
const nextSubjects = subjectsRes.data;
setMe(meRes.data);
setData(summary);
setRecentLogs(nextRecentLogs);
setActivityLogs(nextActivityLogs);
@@ -154,7 +159,7 @@ function DashboardBody() {
const todayLabel = useMemo(() => formatKoreanDate(new Date()), []);
if (!data) {
if (!data || !me) {
if (error) return <ErrorState>{error}</ErrorState>;
return <Loading> ...</Loading>;
}
@@ -166,16 +171,20 @@ function DashboardBody() {
const masteryStats = buildMasteryStats(subjects);
const coachCopy = coachMessage(data.user.persona);
const personaTag = `${capitalizePersona(data.user.persona)} 페르소나`;
const isEmpty = queueCount === 0 && recentLogs.length === 0;
const hasSubjects = subjects.length > 0;
const hasStudyLogs = data.recentLogs.length > 0;
const hasReviewQueue = queueCount > 0;
const isEmpty = !hasSubjects && !hasStudyLogs;
if (isEmpty) {
return (
<Wrap>
<HeaderCard>
<DateText>{todayLabel}</DateText>
<Title> .</Title>
<Subtitle>/study .</Subtitle>
</HeaderCard>
<EmptyDashboard
nickname={me.nickname}
hasSubjects={hasSubjects}
hasStudyLogs={hasStudyLogs}
hasReviewQueue={hasReviewQueue}
/>
</Wrap>
);
}

View File

@@ -0,0 +1,374 @@
'use client';
import Link from 'next/link';
import styled, { css } from 'styled-components';
import { Icon, type IconName } from '@/components/ui/Icon';
import { Button, Card } from '@/components/ui/primitives';
import { animations, theme } from '@/styles/theme';
interface EmptyDashboardProps {
nickname: string;
hasSubjects: boolean;
hasStudyLogs: boolean;
hasReviewQueue: boolean;
}
interface ChecklistStep {
id: number;
title: string;
description: string;
href: string;
ctaLabel: string;
icon: IconName;
completed: boolean;
enabled: boolean;
}
export default function EmptyDashboard({
nickname,
hasSubjects,
hasStudyLogs,
hasReviewQueue,
}: EmptyDashboardProps) {
const steps: ChecklistStep[] = [
{
id: 1,
title: '과목 만들기',
description: '학습할 과목이나 카테고리를 먼저 추가해보세요.',
href: '/subjects',
ctaLabel: '과목 추가하러 가기',
icon: 'folders',
completed: hasSubjects,
enabled: true,
},
{
id: 2,
title: '첫 학습 기록',
description: '첫 문제 풀이 또는 개념 학습을 기록해 복습 흐름을 시작하세요.',
href: '/study',
ctaLabel: '첫 학습 기록하기',
icon: 'pencil-simple',
completed: hasStudyLogs,
enabled: true,
},
{
id: 3,
title: '복습 큐 확인',
description: '생성된 복습 큐를 확인하고 ReLoop 흐름을 바로 체험해보세요.',
href: '/review',
ctaLabel: '복습 체험하기',
icon: 'arrows-clockwise',
completed: hasReviewQueue,
enabled: hasStudyLogs,
},
];
return (
<Shell>
<BackdropGlow />
<HeroCard>
<WelcomeEyebrow>Welcome to ReLoop</WelcomeEyebrow>
<WelcomeTitle> {nickname}!</WelcomeTitle>
<WelcomeSubtitle> .</WelcomeSubtitle>
</HeroCard>
<ChecklistCard>
<ChecklistHeader>
<ChecklistTitle> </ChecklistTitle>
<ChecklistCaption>3 .</ChecklistCaption>
</ChecklistHeader>
<ChecklistColumn>
{steps.map((step) => (
<StepCard key={step.id} $enabled={step.enabled} $completed={step.completed}>
<StepMain>
<StepBadge $completed={step.completed}>
{step.completed ? (
<Icon name="check" weight="bold" size={14} color="currentColor" />
) : (
<span>{step.id}</span>
)}
</StepBadge>
<StepCopy>
<StepTitleRow>
<StepIconWrap $completed={step.completed}>
<Icon name={step.icon} size={18} color="currentColor" />
</StepIconWrap>
<StepTitle>{step.title}</StepTitle>
</StepTitleRow>
<StepDescription>{step.description}</StepDescription>
</StepCopy>
</StepMain>
{step.enabled ? (
<Button as={Link} href={step.href} $variant={step.completed ? 'secondary' : 'white'}>
{step.ctaLabel}
<Icon name="arrow-right" weight="bold" size={16} />
</Button>
) : (
<DisabledButton aria-disabled="true">
<Icon name="arrow-right" weight="bold" size={16} />
</DisabledButton>
)}
</StepCard>
))}
</ChecklistColumn>
</ChecklistCard>
<TutorialLink href="/onboarding">
<Icon name="info" size={16} />
</TutorialLink>
</Shell>
);
}
const Shell = styled.section`
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.space.lg};
width: 100%;
max-width: 820px;
margin: 0 auto;
padding: clamp(12px, 3vw, 28px) 0 ${theme.space.xl};
animation: ${animations.fadeInUp} 0.4s ease-out;
`;
const BackdropGlow = styled.div`
position: absolute;
inset: 40px auto auto 50%;
width: min(72vw, 560px);
height: min(72vw, 560px);
border-radius: 50%;
background:
radial-gradient(circle, rgba(79, 70, 229, 0.22) 0%, rgba(124, 58, 237, 0.14) 36%, transparent 72%);
transform: translateX(-50%);
filter: blur(120px);
pointer-events: none;
`;
const HeroCard = styled(Card)`
width: 100%;
padding: clamp(28px, 5vw, 44px);
border-radius: 28px;
border: 1px solid rgba(129, 140, 248, 0.18);
background:
radial-gradient(circle at top left, rgba(99, 102, 241, 0.26) 0%, transparent 34%),
radial-gradient(circle at bottom right, rgba(124, 58, 237, 0.22) 0%, transparent 28%),
linear-gradient(145deg, rgba(21, 21, 28, 0.94) 0%, rgba(15, 15, 20, 0.98) 100%);
box-shadow: ${theme.shadow.cardElevated};
text-align: center;
`;
const WelcomeEyebrow = styled.p`
margin: 0 0 10px;
color: #a5b4fc;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
`;
const WelcomeTitle = styled.h1`
margin: 0;
color: ${theme.color.textBright};
font-family: ${theme.font.display};
font-size: clamp(32px, 5vw, 48px);
font-weight: 650;
letter-spacing: -0.05em;
line-height: 1.05;
`;
const WelcomeSubtitle = styled.p`
margin: 14px auto 0;
max-width: 420px;
color: ${theme.color.textSub};
font-size: 17px;
line-height: 1.65;
`;
const ChecklistCard = styled(Card)`
width: 100%;
padding: clamp(24px, 4vw, 32px);
border-radius: 24px;
background: rgba(21, 21, 28, 0.82);
border: 1px solid ${theme.color.borderSoftAlpha};
backdrop-filter: blur(16px);
box-shadow: ${theme.shadow.cardElevated};
`;
const ChecklistHeader = styled.header`
margin-bottom: ${theme.space.lg};
`;
const ChecklistTitle = styled.h2`
margin: 0;
color: ${theme.color.textBright};
font-size: 22px;
font-weight: 600;
letter-spacing: -0.03em;
`;
const ChecklistCaption = styled.p`
margin: 8px 0 0;
color: ${theme.color.textSub};
font-size: 14px;
line-height: 1.6;
`;
const ChecklistColumn = styled.div`
display: flex;
flex-direction: column;
gap: 14px;
`;
const StepCard = styled.div<{ $enabled: boolean; $completed: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
padding: 18px;
border-radius: 20px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
${({ $completed, $enabled }) => {
if ($completed) {
return css`
border-color: rgba(79, 70, 229, 0.26);
background: rgba(79, 70, 229, 0.1);
`;
}
if ($enabled) {
return css`
border-color: rgba(255, 255, 255, 0.08);
&:hover {
transform: translateY(-1px);
border-color: rgba(129, 140, 248, 0.2);
background: rgba(255, 255, 255, 0.045);
}
`;
}
return css`
opacity: 0.62;
background: rgba(255, 255, 255, 0.02);
`;
}}
@media (max-width: ${theme.breakpoint.tablet}) {
flex-direction: column;
align-items: stretch;
}
`;
const StepMain = styled.div`
display: flex;
align-items: flex-start;
gap: 14px;
min-width: 0;
`;
const StepBadge = styled.div<{ $completed: boolean }>`
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
font-family: ${theme.font.mono};
font-size: 12px;
font-weight: 700;
${({ $completed }) =>
$completed
? css`
background: ${theme.color.brandGradient};
color: white;
box-shadow: ${theme.shadow.glowIndigo};
`
: css`
border: 1px solid ${theme.color.borderBrightAlpha};
color: ${theme.color.textSub};
background: rgba(255, 255, 255, 0.02);
`}
`;
const StepCopy = styled.div`
min-width: 0;
`;
const StepTitleRow = styled.div`
display: flex;
align-items: center;
gap: 10px;
`;
const StepIconWrap = styled.div<{ $completed: boolean }>`
width: 36px;
height: 36px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: ${({ $completed }) => ($completed ? '#c7d2fe' : theme.color.textSub)};
background: ${({ $completed }) =>
$completed ? 'rgba(79, 70, 229, 0.18)' : 'rgba(255, 255, 255, 0.05)'};
border: 1px solid
${({ $completed }) =>
$completed ? 'rgba(129, 140, 248, 0.24)' : 'rgba(255, 255, 255, 0.06)'};
`;
const StepTitle = styled.h3`
margin: 0;
color: ${theme.color.textBright};
font-size: 17px;
font-weight: 600;
letter-spacing: -0.02em;
`;
const StepDescription = styled.p`
margin: 8px 0 0;
color: ${theme.color.textSub};
font-size: 14px;
line-height: 1.6;
`;
const DisabledButton = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
gap: ${theme.space.sm};
min-height: 44px;
padding: 10px 18px;
border-radius: ${theme.radius.md};
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textMute};
font-size: 14px;
font-weight: 600;
white-space: nowrap;
`;
const TutorialLink = styled(Link)`
display: inline-flex;
align-items: center;
gap: 8px;
color: ${theme.color.textSub};
font-size: 14px;
font-weight: 600;
transition: color 0.16s ease;
&:hover {
color: ${theme.color.textBright};
}
`;