feat(dashboard): empty state per design-08 — 7D.6
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
374
frontend/src/components/dashboard/EmptyDashboard.tsx
Normal file
374
frontend/src/components/dashboard/EmptyDashboard.tsx
Normal 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};
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user