feat(landing): typing effect + D-day sync + scroll animations + copy — 7G.4
This commit is contained in:
@@ -199,12 +199,12 @@ function DashboardBody() {
|
||||
<DateText>{todayLabel}</DateText>
|
||||
<Title>
|
||||
안녕 {data.user.nickname}, 오늘 <GradientCount>{queueCount}개</GradientCount>{' '}
|
||||
복습이 기다리고 있어요.
|
||||
잊을 뻔한 문제가 너를 기다려.
|
||||
</Title>
|
||||
</div>
|
||||
<StreakBadge>
|
||||
<Icon name="fire" weight="fill" size={18} color="#fb923c" />
|
||||
<span>{streakDays}일 연속 학습 중</span>
|
||||
<span>{streakDays}일째 기억을 붙잡는 중</span>
|
||||
</StreakBadge>
|
||||
</HeaderTop>
|
||||
</HeaderCard>
|
||||
|
||||
@@ -5,8 +5,11 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Icon } from '@/components/ui/Icon';
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal';
|
||||
import TypingEffect from '@/components/ui/TypingEffect';
|
||||
import { Button, Card } from '@/components/ui/primitives';
|
||||
import { hasToken } from '@/lib/auth';
|
||||
import { getCurrentDDay } from '@/lib/exam-date';
|
||||
import { animations, theme } from '@/styles/theme';
|
||||
|
||||
const featureItems = [
|
||||
@@ -117,9 +120,20 @@ const faqItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const heroPhrases = [
|
||||
'잊기 직전에, 딱 한 번 더.',
|
||||
'어제 틀린 문제가 오늘의 실력이 된다.',
|
||||
'한 번에 풀려고 하지 마. 여러 번 만나야 기억해.',
|
||||
'공부는 기억과의 싸움이야. 우리는 과학적으로 싸운다.',
|
||||
'수능은 반복의 다른 이름이다.',
|
||||
'오늘 한 문제가 내일의 등급을 바꾼다.',
|
||||
'네 공부 리듬, 네가 정한다.',
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(0);
|
||||
const [dDay, setDDay] = useState(() => getCurrentDDay());
|
||||
|
||||
useEffect(() => {
|
||||
if (hasToken()) {
|
||||
@@ -127,6 +141,10 @@ export default function LandingPage() {
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
setDDay(getCurrentDDay());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<BackgroundLayer aria-hidden="true">
|
||||
@@ -161,147 +179,160 @@ export default function LandingPage() {
|
||||
</NavBar>
|
||||
|
||||
<Main>
|
||||
<HeroSection>
|
||||
<HeroBadge>
|
||||
<StatusDot />
|
||||
수능 D-240, 적응형 복습 스케줄러 오픈
|
||||
</HeroBadge>
|
||||
<ScrollReveal>
|
||||
<HeroSection>
|
||||
<HeroBadge>
|
||||
<StatusDot />
|
||||
현재 {dDay.examYear}학년도 수능까지 {dDay.label}
|
||||
</HeroBadge>
|
||||
|
||||
<HeroTitle>
|
||||
잊기 직전에 <br />
|
||||
<HeroGradient>딱 한 번 더.</HeroGradient>
|
||||
</HeroTitle>
|
||||
<HeroTitle>
|
||||
<HeroGradient>
|
||||
<TypingEffect phrases={heroPhrases} />
|
||||
</HeroGradient>
|
||||
</HeroTitle>
|
||||
|
||||
<HeroSubtitle>
|
||||
페르소나 곡선 기반 적응형 복습 스케줄링. 조용하고 확실하게, 수능 1등급을
|
||||
향한 마스터리 경로를 걷습니다.
|
||||
</HeroSubtitle>
|
||||
<HeroSubtitle>
|
||||
페르소나 곡선 기반 적응형 복습 스케줄링. 감으로 버티지 말고, 잊을 타이밍을
|
||||
먼저 읽어내며 수능 1등급 루틴을 쌓아갑니다.
|
||||
</HeroSubtitle>
|
||||
|
||||
<HeroActions>
|
||||
<PrimaryCta as={Link} href="/register" $size="lg" $variant="white">
|
||||
무료로 시작하기
|
||||
</PrimaryCta>
|
||||
<GhostCta as={Link} href="/login" $size="lg" $variant="secondary">
|
||||
데모 보기
|
||||
<Icon name="arrow-right" size={18} />
|
||||
</GhostCta>
|
||||
</HeroActions>
|
||||
<HeroActions>
|
||||
<PrimaryCta as={Link} href="/register" $size="lg" $variant="white">
|
||||
무료로 시작하기
|
||||
</PrimaryCta>
|
||||
<GhostCta as={Link} href="/login" $size="lg" $variant="secondary">
|
||||
데모 보기
|
||||
<Icon name="arrow-right" size={18} />
|
||||
</GhostCta>
|
||||
</HeroActions>
|
||||
|
||||
<KeyboardHint>
|
||||
<Icon name="keyboard" size={16} />
|
||||
데모 계정: <HintMono>demo@reloop.local / demo1234</HintMono>
|
||||
</KeyboardHint>
|
||||
</HeroSection>
|
||||
<KeyboardHint>
|
||||
<Icon name="keyboard" size={16} />
|
||||
데모 계정: <HintMono>demo@reloop.local / demo1234</HintMono>
|
||||
</KeyboardHint>
|
||||
</HeroSection>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScreenshotSection aria-label="대시보드 미리보기">
|
||||
<BrowserFrame>
|
||||
<BrowserToolbar>
|
||||
<ToolbarDots>
|
||||
<ToolbarDot />
|
||||
<ToolbarDot />
|
||||
<ToolbarDot />
|
||||
</ToolbarDots>
|
||||
</BrowserToolbar>
|
||||
<ScrollReveal delay={80}>
|
||||
<ScreenshotSection aria-label="대시보드 미리보기">
|
||||
<BrowserFrame>
|
||||
<BrowserToolbar>
|
||||
<ToolbarDots>
|
||||
<ToolbarDot />
|
||||
<ToolbarDot />
|
||||
<ToolbarDot />
|
||||
</ToolbarDots>
|
||||
</BrowserToolbar>
|
||||
|
||||
<DashboardSurface>
|
||||
<DashboardHeader>
|
||||
<div>
|
||||
<DashboardEyebrow>환영합니다, 수험생님</DashboardEyebrow>
|
||||
<DashboardTitle>오늘 복습할 항목이 42개 있습니다</DashboardTitle>
|
||||
</div>
|
||||
<DashboardStreak>
|
||||
<Icon name="fire" size={16} weight="fill" />
|
||||
14일 연속 학습
|
||||
</DashboardStreak>
|
||||
</DashboardHeader>
|
||||
<DashboardSurface>
|
||||
<DashboardHeader>
|
||||
<div>
|
||||
<DashboardEyebrow>환영합니다, 수험생님</DashboardEyebrow>
|
||||
<DashboardTitle>오늘 잊기 전에 다시 볼 문제가 42개 있어요</DashboardTitle>
|
||||
</div>
|
||||
<DashboardStreak>
|
||||
<Icon name="fire" size={16} weight="fill" />
|
||||
14일째 기억을 붙잡는 중
|
||||
</DashboardStreak>
|
||||
</DashboardHeader>
|
||||
|
||||
<DashboardGrid>
|
||||
<MetricCard>
|
||||
<MetricLabel>오늘 복습 큐</MetricLabel>
|
||||
<MetricValue>
|
||||
42 <span>개</span>
|
||||
</MetricValue>
|
||||
<ProgressTrack>
|
||||
<ProgressFill $width="25%" />
|
||||
</ProgressTrack>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard>
|
||||
<MetricLabel>이번 주 정답률</MetricLabel>
|
||||
<MetricRow>
|
||||
<DashboardGrid>
|
||||
<MetricCard>
|
||||
<MetricLabel>오늘 복습 큐</MetricLabel>
|
||||
<MetricValue>
|
||||
87 <span>%</span>
|
||||
42 <span>개</span>
|
||||
</MetricValue>
|
||||
<MetricDelta>
|
||||
<Icon name="trend-up" size={14} />
|
||||
2.4%
|
||||
</MetricDelta>
|
||||
</MetricRow>
|
||||
</MetricCard>
|
||||
<ProgressTrack>
|
||||
<ProgressFill $width="25%" />
|
||||
</ProgressTrack>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard>
|
||||
<MetricGlow />
|
||||
<MetricLabel>마스터리 진행</MetricLabel>
|
||||
<MetricValue>
|
||||
64 <span>%</span>
|
||||
</MetricValue>
|
||||
<MetricHint>전체 태그 중 숙련 기준(90%) 도달</MetricHint>
|
||||
</MetricCard>
|
||||
</DashboardGrid>
|
||||
</DashboardSurface>
|
||||
</BrowserFrame>
|
||||
</ScreenshotSection>
|
||||
<MetricCard>
|
||||
<MetricLabel>이번 주 정답률</MetricLabel>
|
||||
<MetricRow>
|
||||
<MetricValue>
|
||||
87 <span>%</span>
|
||||
</MetricValue>
|
||||
<MetricDelta>
|
||||
<Icon name="trend-up" size={14} />
|
||||
2.4%
|
||||
</MetricDelta>
|
||||
</MetricRow>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard>
|
||||
<MetricGlow />
|
||||
<MetricLabel>마스터리 진행</MetricLabel>
|
||||
<MetricValue>
|
||||
64 <span>%</span>
|
||||
</MetricValue>
|
||||
<MetricHint>반복이 쌓이며 태그별 실전 감각이 올라가는 중</MetricHint>
|
||||
</MetricCard>
|
||||
</DashboardGrid>
|
||||
</DashboardSurface>
|
||||
</BrowserFrame>
|
||||
</ScreenshotSection>
|
||||
</ScrollReveal>
|
||||
|
||||
<Section id="features">
|
||||
<SectionHeader>
|
||||
<SectionTitle>왜 ReLoop 인가</SectionTitle>
|
||||
<SectionBody>
|
||||
기존의 수동적인 문제풀이에서 벗어나, 데이터 기반의 학습을 경험하세요.
|
||||
</SectionBody>
|
||||
</SectionHeader>
|
||||
<ScrollReveal>
|
||||
<SectionHeader>
|
||||
<SectionTitle>왜 ReLoop 인가</SectionTitle>
|
||||
<SectionBody>
|
||||
무작정 많이 푸는 대신, 지금 다시 만나야 점수가 되는 문제부터 고릅니다.
|
||||
</SectionBody>
|
||||
</SectionHeader>
|
||||
</ScrollReveal>
|
||||
|
||||
<FeatureGrid>
|
||||
{featureItems.map((item) => (
|
||||
<FeatureCard key={item.title} $variant="hoverable">
|
||||
<FeatureIconWrap>
|
||||
<Icon name={item.icon} size={24} />
|
||||
</FeatureIconWrap>
|
||||
<FeatureStrike>{item.strike}</FeatureStrike>
|
||||
<FeatureCardTitle>{item.title}</FeatureCardTitle>
|
||||
<FeatureCardBody>{item.description}</FeatureCardBody>
|
||||
</FeatureCard>
|
||||
{featureItems.map((item, index) => (
|
||||
<ScrollReveal key={item.title} delay={index * 90}>
|
||||
<FeatureCard $variant="hoverable">
|
||||
<FeatureIconWrap>
|
||||
<Icon name={item.icon} size={24} />
|
||||
</FeatureIconWrap>
|
||||
<FeatureStrike>{item.strike}</FeatureStrike>
|
||||
<FeatureCardTitle>{item.title}</FeatureCardTitle>
|
||||
<FeatureCardBody>{item.description}</FeatureCardBody>
|
||||
</FeatureCard>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</FeatureGrid>
|
||||
</Section>
|
||||
|
||||
<Section id="how-it-works">
|
||||
<SectionHeader>
|
||||
<SectionTitle>어떻게 작동하나</SectionTitle>
|
||||
<SectionBody>기록하고, 알림받고, 복습하는 단순하지만 강력한 사이클.</SectionBody>
|
||||
</SectionHeader>
|
||||
<ScrollReveal>
|
||||
<SectionHeader>
|
||||
<SectionTitle>어떻게 작동하나</SectionTitle>
|
||||
<SectionBody>기록하고, 다시 만나고, 결국 내 것으로 만드는 복습 루프.</SectionBody>
|
||||
</SectionHeader>
|
||||
</ScrollReveal>
|
||||
|
||||
<StepsWrap>
|
||||
<StepsLine aria-hidden="true" />
|
||||
<StepsGrid>
|
||||
{steps.map((step, index) => (
|
||||
<StepCard key={step.title}>
|
||||
<StepIconBox>
|
||||
<StepIndex>{step.number}</StepIndex>
|
||||
<Icon
|
||||
name={step.icon}
|
||||
size={24}
|
||||
color={
|
||||
index > 1
|
||||
? index === 2
|
||||
? theme.color.brandViolet
|
||||
: theme.color.brandIndigo
|
||||
: theme.color.textSub
|
||||
}
|
||||
/>
|
||||
</StepIconBox>
|
||||
<StepTitle>{step.title}</StepTitle>
|
||||
<StepBody>{step.description}</StepBody>
|
||||
</StepCard>
|
||||
<ScrollReveal key={step.title} delay={index * 80}>
|
||||
<StepCard>
|
||||
<StepIconBox>
|
||||
<StepIndex>{step.number}</StepIndex>
|
||||
<Icon
|
||||
name={step.icon}
|
||||
size={24}
|
||||
color={
|
||||
index > 1
|
||||
? index === 2
|
||||
? theme.color.brandViolet
|
||||
: theme.color.brandIndigo
|
||||
: theme.color.textSub
|
||||
}
|
||||
/>
|
||||
</StepIconBox>
|
||||
<StepTitle>{step.title}</StepTitle>
|
||||
<StepBody>{step.description}</StepBody>
|
||||
</StepCard>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</StepsGrid>
|
||||
</StepsWrap>
|
||||
@@ -309,79 +340,89 @@ export default function LandingPage() {
|
||||
|
||||
<StatsSection>
|
||||
<StatsGrid>
|
||||
{stats.map((stat) => (
|
||||
<StatBlock key={stat.label}>
|
||||
<StatValue>{stat.value}</StatValue>
|
||||
<StatLabel>{stat.label}</StatLabel>
|
||||
</StatBlock>
|
||||
{stats.map((stat, index) => (
|
||||
<ScrollReveal key={stat.label} delay={index * 90}>
|
||||
<StatBlock>
|
||||
<StatValue>{stat.value}</StatValue>
|
||||
<StatLabel>{stat.label}</StatLabel>
|
||||
</StatBlock>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</StatsGrid>
|
||||
</StatsSection>
|
||||
|
||||
<Section id="pricing">
|
||||
<PricingHeader>
|
||||
<div>
|
||||
<SectionTitle>단순하고 투명한 요금제</SectionTitle>
|
||||
<SectionBody>
|
||||
필요한 만큼만 쓰세요. 모든 플랜에 기본 스케줄링이 포함됩니다.
|
||||
</SectionBody>
|
||||
</div>
|
||||
<PricingLink href="/pricing">
|
||||
전체 요금제 비교보기
|
||||
<Icon name="arrow-right" size={16} />
|
||||
</PricingLink>
|
||||
</PricingHeader>
|
||||
<ScrollReveal>
|
||||
<PricingHeader>
|
||||
<div>
|
||||
<SectionTitle>단순하고 투명한 요금제</SectionTitle>
|
||||
<SectionBody>
|
||||
필요한 만큼만 쓰세요. 모든 플랜에 기본 스케줄링이 포함됩니다.
|
||||
</SectionBody>
|
||||
</div>
|
||||
<PricingLink href="/pricing">
|
||||
전체 요금제 비교보기
|
||||
<Icon name="arrow-right" size={16} />
|
||||
</PricingLink>
|
||||
</PricingHeader>
|
||||
</ScrollReveal>
|
||||
|
||||
<PricingGrid>
|
||||
{pricingCards.map((card) => (
|
||||
<PricingCard key={card.name} $featured={card.featured}>
|
||||
{card.badge ? <PricingBadge>{card.badge}</PricingBadge> : null}
|
||||
<PricingName>{card.name}</PricingName>
|
||||
<PricingValue>
|
||||
{card.price}
|
||||
<small>{card.period}</small>
|
||||
</PricingValue>
|
||||
<PricingList>
|
||||
{card.features.map((feature) => (
|
||||
<PricingListItem key={feature}>
|
||||
<Icon
|
||||
name="check"
|
||||
size={16}
|
||||
color={card.featured ? theme.color.brandViolet : theme.color.textBright}
|
||||
/>
|
||||
{feature}
|
||||
</PricingListItem>
|
||||
))}
|
||||
</PricingList>
|
||||
</PricingCard>
|
||||
{pricingCards.map((card, index) => (
|
||||
<ScrollReveal key={card.name} delay={index * 90}>
|
||||
<PricingCard $featured={card.featured}>
|
||||
{card.badge ? <PricingBadge>{card.badge}</PricingBadge> : null}
|
||||
<PricingName>{card.name}</PricingName>
|
||||
<PricingValue>
|
||||
{card.price}
|
||||
<small>{card.period}</small>
|
||||
</PricingValue>
|
||||
<PricingList>
|
||||
{card.features.map((feature) => (
|
||||
<PricingListItem key={feature}>
|
||||
<Icon
|
||||
name="check"
|
||||
size={16}
|
||||
color={card.featured ? theme.color.brandViolet : theme.color.textBright}
|
||||
/>
|
||||
{feature}
|
||||
</PricingListItem>
|
||||
))}
|
||||
</PricingList>
|
||||
</PricingCard>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</PricingGrid>
|
||||
</Section>
|
||||
|
||||
<Section id="faq">
|
||||
<FaqHeader>
|
||||
<SectionTitle>자주 묻는 질문</SectionTitle>
|
||||
</FaqHeader>
|
||||
<ScrollReveal>
|
||||
<FaqHeader>
|
||||
<SectionTitle>자주 묻는 질문</SectionTitle>
|
||||
</FaqHeader>
|
||||
</ScrollReveal>
|
||||
<FaqList>
|
||||
{faqItems.map((item, index) => {
|
||||
const isOpen = openFaq === index;
|
||||
return (
|
||||
<FaqItem key={item.question}>
|
||||
<FaqButton
|
||||
type="button"
|
||||
onClick={() => setOpenFaq(isOpen ? null : index)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`faq-panel-${index}`}
|
||||
>
|
||||
<span>{item.question}</span>
|
||||
<FaqCaret $open={isOpen}>
|
||||
<Icon name="caret-down" size={18} />
|
||||
</FaqCaret>
|
||||
</FaqButton>
|
||||
<FaqAnswer id={`faq-panel-${index}`} $open={isOpen}>
|
||||
<p>{item.answer}</p>
|
||||
</FaqAnswer>
|
||||
</FaqItem>
|
||||
<ScrollReveal key={item.question} delay={index * 70}>
|
||||
<FaqItem>
|
||||
<FaqButton
|
||||
type="button"
|
||||
onClick={() => setOpenFaq(isOpen ? null : index)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`faq-panel-${index}`}
|
||||
>
|
||||
<span>{item.question}</span>
|
||||
<FaqCaret $open={isOpen}>
|
||||
<Icon name="caret-down" size={18} />
|
||||
</FaqCaret>
|
||||
</FaqButton>
|
||||
<FaqAnswer id={`faq-panel-${index}`} $open={isOpen}>
|
||||
<p>{item.answer}</p>
|
||||
</FaqAnswer>
|
||||
</FaqItem>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</FaqList>
|
||||
@@ -441,6 +482,11 @@ const widthClamp = css`
|
||||
const animateIn = css`
|
||||
opacity: 0;
|
||||
animation: ${animations.fadeInUp} 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const PageShell = styled.div`
|
||||
@@ -661,21 +707,14 @@ const HeroTitle = styled.h1`
|
||||
${animateIn}
|
||||
animation-delay: 0.2s;
|
||||
margin: 0 0 1.5rem;
|
||||
min-height: 2.2em;
|
||||
font-family: ${theme.font.display};
|
||||
font-size: clamp(3.25rem, 9vw, 5.5rem);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.04em;
|
||||
|
||||
br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
font-size: clamp(2.5rem, 12vw, 4rem);
|
||||
|
||||
br {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -715,11 +754,17 @@ const HeroActions = styled.div`
|
||||
|
||||
const PrimaryCta = styled(Button)`
|
||||
width: 100%;
|
||||
position: relative;
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.12);
|
||||
animation: ${animations.float} 3.6s ease-in-out infinite;
|
||||
|
||||
@media (min-width: ${theme.breakpoint.mobile}) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const GhostCta = styled(Button)`
|
||||
@@ -770,6 +815,10 @@ const BrowserFrame = styled.div`
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 20px 60px rgba(124, 58, 237, 0.12);
|
||||
animation: ${animations.float} 6s ease-in-out infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const BrowserToolbar = styled.div`
|
||||
@@ -977,10 +1026,23 @@ const FeatureCard = styled(Card)`
|
||||
border-color: ${theme.color.borderSoftAlpha};
|
||||
border-radius: 1.5rem;
|
||||
background: ${theme.color.surfaceDeep};
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease,
|
||||
border-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: ${theme.shadow.cardElevated};
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: border-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: ${theme.breakpoint.tablet}) {
|
||||
|
||||
@@ -300,7 +300,7 @@ export default function ReviewPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<FullscreenState>
|
||||
<StateCopy>복습 세션 준비 중...</StateCopy>
|
||||
<StateCopy>오늘 다시 잡아둘 문제를 불러오는 중...</StateCopy>
|
||||
</FullscreenState>
|
||||
);
|
||||
}
|
||||
@@ -309,8 +309,10 @@ export default function ReviewPage() {
|
||||
return (
|
||||
<FullscreenState>
|
||||
<StateCard>
|
||||
<StateTitle>세션을 시작할 수 없어요.</StateTitle>
|
||||
<StateSub>{error ?? '복습 큐가 비어 있어요.'}</StateSub>
|
||||
<StateTitle>지금은 꺼내올 복습이 없어.</StateTitle>
|
||||
<StateSub>
|
||||
{error ?? '오늘 큐는 비어 있어. 대신 새 기록을 쌓아서 다음 루프를 만들어보자.'}
|
||||
</StateSub>
|
||||
<StateActions>
|
||||
<Button $variant="secondary" onClick={() => void load()}>
|
||||
다시 시도
|
||||
@@ -346,9 +348,9 @@ export default function ReviewPage() {
|
||||
<Content>
|
||||
<CompletionCard>
|
||||
<CompletionIcon>🎉</CompletionIcon>
|
||||
<CompletionTitle>오늘 복습 완료</CompletionTitle>
|
||||
<CompletionTitle>오늘의 복습 클리어</CompletionTitle>
|
||||
<CompletionSub>
|
||||
예정된 복습 {progressTotal}개를 모두 정리했어요.
|
||||
예정된 복습 {progressTotal}개를 모두 넘겼어. 네 기억 곡선이 오늘 더 단단해졌어.
|
||||
</CompletionSub>
|
||||
|
||||
<CompletionStats>
|
||||
@@ -409,11 +411,9 @@ export default function ReviewPage() {
|
||||
<TopPanel>
|
||||
<TitleBlock>
|
||||
<PageTitle>
|
||||
오늘의 복습 <DayBadge>Day {currentIteration}</DayBadge>
|
||||
오늘 다시 붙잡을 문제 <DayBadge>Day {currentIteration}</DayBadge>
|
||||
</PageTitle>
|
||||
<PageSubtitle>
|
||||
{progressCurrent} / {progressTotal}
|
||||
</PageSubtitle>
|
||||
<PageSubtitle>{progressCurrent} / {progressTotal} 지금 한 번 더 만나면 내일이 편해져</PageSubtitle>
|
||||
</TitleBlock>
|
||||
|
||||
<ProgressRail aria-hidden>
|
||||
@@ -546,10 +546,13 @@ export default function ReviewPage() {
|
||||
disabled={pending.has(currentItem.id)}
|
||||
onClick={() => void handleResult(currentItem, 'incorrect')}
|
||||
>
|
||||
<ActionLabel>
|
||||
<Icon name="x" size={20} weight="bold" />
|
||||
틀림
|
||||
</ActionLabel>
|
||||
<ActionCopy>
|
||||
<ActionLabel>
|
||||
<Icon name="x" size={20} weight="bold" />
|
||||
틀림
|
||||
</ActionLabel>
|
||||
<ActionHelper>괜찮아, 다음에 만날 때 더 빨라질 거야</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>1</ActionShortcut>
|
||||
</ActionButton>
|
||||
|
||||
@@ -559,10 +562,13 @@ export default function ReviewPage() {
|
||||
disabled={pending.has(currentItem.id)}
|
||||
onClick={() => void handleResult(currentItem, 'partial')}
|
||||
>
|
||||
<ActionLabel>
|
||||
<Icon name="triangle" size={20} weight="bold" />
|
||||
부분
|
||||
</ActionLabel>
|
||||
<ActionCopy>
|
||||
<ActionLabel>
|
||||
<Icon name="triangle" size={20} weight="bold" />
|
||||
부분
|
||||
</ActionLabel>
|
||||
<ActionHelper>거의 왔어. 한 번 더 보면 감이 붙어</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>2</ActionShortcut>
|
||||
</ActionButton>
|
||||
|
||||
@@ -572,10 +578,13 @@ export default function ReviewPage() {
|
||||
disabled={pending.has(currentItem.id)}
|
||||
onClick={() => void handleResult(currentItem, 'correct')}
|
||||
>
|
||||
<ActionLabel>
|
||||
<Icon name="check" size={20} weight="bold" />
|
||||
맞음
|
||||
</ActionLabel>
|
||||
<ActionCopy>
|
||||
<ActionLabel>
|
||||
<Icon name="check" size={20} weight="bold" />
|
||||
맞음
|
||||
</ActionLabel>
|
||||
<ActionHelper>좋아, 이 기억은 이제 더 오래 간다</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>3</ActionShortcut>
|
||||
</ActionButton>
|
||||
|
||||
@@ -585,10 +594,13 @@ export default function ReviewPage() {
|
||||
disabled={pending.has(currentItem.id)}
|
||||
onClick={() => void handleResult(currentItem, 'skip')}
|
||||
>
|
||||
<ActionLabel>
|
||||
<Icon name="skip" size={20} weight="bold" />
|
||||
스킵
|
||||
</ActionLabel>
|
||||
<ActionCopy>
|
||||
<ActionLabel>
|
||||
<Icon name="skip" size={20} weight="bold" />
|
||||
스킵
|
||||
</ActionLabel>
|
||||
<ActionHelper>리듬 안 맞는 문제는 잠깐 넘겨도 돼</ActionHelper>
|
||||
</ActionCopy>
|
||||
<ActionShortcut>S</ActionShortcut>
|
||||
</ActionButton>
|
||||
</ActionGrid>
|
||||
@@ -1193,6 +1205,20 @@ const ActionLabel = styled.span`
|
||||
font-weight: 700;
|
||||
`;
|
||||
|
||||
const ActionCopy = styled.span`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
`;
|
||||
|
||||
const ActionHelper = styled.span`
|
||||
color: rgba(241, 245, 249, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
const ActionShortcut = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -245,8 +245,8 @@ function StatsBody() {
|
||||
<PageWrap>
|
||||
<Header>
|
||||
<TitleBlock>
|
||||
<PageTitle>학습 통계</PageTitle>
|
||||
<PageSubtitle>나의 학습 패턴과 성장 곡선</PageSubtitle>
|
||||
<PageTitle>쌓여가는 성장 곡선</PageTitle>
|
||||
<PageSubtitle>네가 얼마나 꾸준히 기억을 붙잡고 있는지 여기서 보여줘.</PageSubtitle>
|
||||
</TitleBlock>
|
||||
|
||||
<RangeSelector role="tablist" aria-label="통계 기간 선택">
|
||||
@@ -312,7 +312,7 @@ function HeatmapPanel({
|
||||
학습 히트맵
|
||||
</PanelTitle>
|
||||
<PanelDescription>
|
||||
날짜별 학습 기록 빈도를 한눈에 보여줘.
|
||||
어느 날 네 리듬이 가장 강했는지 한눈에 보여줘.
|
||||
</PanelDescription>
|
||||
</PanelTitleGroup>
|
||||
|
||||
@@ -330,7 +330,7 @@ function HeatmapPanel({
|
||||
</PanelHeader>
|
||||
|
||||
{data.columns.length === 0 ? (
|
||||
<EmptyPanel>학습 기록이 아직 없어. 기록이 쌓이면 여기에 패턴이 그려져.</EmptyPanel>
|
||||
<EmptyPanel>데이터가 쌓이면 여기서 네 성장 리듬이 달력처럼 보이기 시작해.</EmptyPanel>
|
||||
) : (
|
||||
<HeatmapScroll>
|
||||
<HeatmapShell>
|
||||
@@ -392,13 +392,13 @@ function SubjectAccuracyPanel({ rows }: { rows: SubjectAccuracyRow[] }) {
|
||||
과목별 정답률
|
||||
</PanelTitle>
|
||||
<PanelDescription>
|
||||
선택한 기간 동안의 정답 비율과 풀이 수.
|
||||
지금 어느 과목이 올라오고 있는지 바로 비교할 수 있어.
|
||||
</PanelDescription>
|
||||
</PanelTitleGroup>
|
||||
</PanelHeader>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyPanel>선택한 기간에 집계할 학습 기록이 없어.</EmptyPanel>
|
||||
<EmptyPanel>아직 보여줄 데이터가 없어. 몇 문제만 더 쌓이면 과목별 흐름이 살아나.</EmptyPanel>
|
||||
) : (
|
||||
<AccuracyList>
|
||||
{rows.map((row) => (
|
||||
@@ -458,7 +458,7 @@ function MasteryGrowthPanel({
|
||||
마스터리 성장 곡선
|
||||
</PanelTitle>
|
||||
<PanelDescription>
|
||||
대표 태그의 숙련도 성장 경로.
|
||||
반복이 쌓이면서 실전 감각이 어떻게 올라가는지 보여줘.
|
||||
</PanelDescription>
|
||||
</PanelTitleGroup>
|
||||
|
||||
@@ -495,11 +495,11 @@ function MasteryGrowthPanel({
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<EmptyPanel>곡선을 계산 중이야...</EmptyPanel>
|
||||
<EmptyPanel>네 성장 곡선을 그리고 있어...</EmptyPanel>
|
||||
) : error ? (
|
||||
<EmptyPanel>{error}</EmptyPanel>
|
||||
) : points.length === 0 ? (
|
||||
<EmptyPanel>곡선을 그릴 데이터가 아직 충분하지 않아.</EmptyPanel>
|
||||
<EmptyPanel>데이터가 조금만 더 쌓이면 여기서 네 성장 곡선을 볼 수 있어.</EmptyPanel>
|
||||
) : (
|
||||
<CurveChart
|
||||
points={points}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function EmptyDashboard({
|
||||
{
|
||||
id: 1,
|
||||
title: '과목 만들기',
|
||||
description: '학습할 과목이나 카테고리를 먼저 추가해보세요.',
|
||||
description: '복습 루프를 돌릴 무대를 먼저 만들어두자.',
|
||||
href: '/subjects',
|
||||
ctaLabel: '과목 추가하러 가기',
|
||||
icon: 'folders',
|
||||
@@ -44,7 +44,7 @@ export default function EmptyDashboard({
|
||||
{
|
||||
id: 2,
|
||||
title: '첫 학습 기록',
|
||||
description: '첫 문제 풀이 또는 개념 학습을 기록해 복습 흐름을 시작하세요.',
|
||||
description: '첫 문제를 남기는 순간부터 잊을 타이밍까지 추적할 수 있어.',
|
||||
href: '/study',
|
||||
ctaLabel: '첫 학습 기록하기',
|
||||
icon: 'pencil-simple',
|
||||
@@ -54,7 +54,7 @@ export default function EmptyDashboard({
|
||||
{
|
||||
id: 3,
|
||||
title: '복습 큐 확인',
|
||||
description: '생성된 복습 큐를 확인하고 ReLoop 흐름을 바로 체험해보세요.',
|
||||
description: '이제 잊기 전에 다시 만날 차례야. 첫 복습 큐를 확인해봐.',
|
||||
href: '/review',
|
||||
ctaLabel: '복습 체험하기',
|
||||
icon: 'arrows-clockwise',
|
||||
@@ -70,13 +70,17 @@ export default function EmptyDashboard({
|
||||
<HeroCard>
|
||||
<WelcomeEyebrow>Welcome to ReLoop</WelcomeEyebrow>
|
||||
<WelcomeTitle>환영해 {nickname}!</WelcomeTitle>
|
||||
<WelcomeSubtitle>첫 학습을 시작할 준비가 됐어요.</WelcomeSubtitle>
|
||||
<WelcomeSubtitle>
|
||||
아직 네 실력을 들여다볼 데이터가 없어. 첫 문제부터 시작해보자.
|
||||
</WelcomeSubtitle>
|
||||
</HeroCard>
|
||||
|
||||
<ChecklistCard>
|
||||
<ChecklistHeader>
|
||||
<ChecklistTitle>시작하기 체크리스트</ChecklistTitle>
|
||||
<ChecklistCaption>3단계만 완료하면 대시보드가 본격적으로 채워집니다.</ChecklistCaption>
|
||||
<ChecklistCaption>
|
||||
세 번만 움직이면, 여기부터 네 성장 곡선이 보이기 시작해.
|
||||
</ChecklistCaption>
|
||||
</ChecklistHeader>
|
||||
|
||||
<ChecklistColumn>
|
||||
|
||||
98
frontend/src/components/ui/ScrollReveal.tsx
Normal file
98
frontend/src/components/ui/ScrollReveal.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { animations } from '@/styles/theme';
|
||||
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScrollReveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: ScrollRevealProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [isReducedMotion, setIsReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const updateMotionPreference = () => {
|
||||
const reduce = mediaQuery.matches;
|
||||
setIsReducedMotion(reduce);
|
||||
if (reduce) {
|
||||
setRevealed(true);
|
||||
}
|
||||
};
|
||||
|
||||
updateMotionPreference();
|
||||
mediaQuery.addEventListener('change', updateMotionPreference);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateMotionPreference);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (revealed || isReducedMotion || typeof IntersectionObserver === 'undefined') return;
|
||||
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry?.isIntersecting) return;
|
||||
|
||||
setRevealed(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: '0px 0px -80px 0px' },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isReducedMotion, revealed]);
|
||||
|
||||
return (
|
||||
<RevealRoot
|
||||
ref={ref}
|
||||
className={className}
|
||||
$delay={delay}
|
||||
$revealed={revealed}
|
||||
$reducedMotion={isReducedMotion}
|
||||
>
|
||||
{children}
|
||||
</RevealRoot>
|
||||
);
|
||||
}
|
||||
|
||||
const RevealRoot = styled.div<{
|
||||
$delay: number;
|
||||
$revealed: boolean;
|
||||
$reducedMotion: boolean;
|
||||
}>`
|
||||
opacity: ${({ $revealed, $reducedMotion }) => ($revealed || $reducedMotion ? 1 : 0)};
|
||||
transform: ${({ $revealed, $reducedMotion }) =>
|
||||
$revealed || $reducedMotion ? 'translateY(0)' : 'translateY(20px)'};
|
||||
animation: ${({ $revealed, $reducedMotion }) =>
|
||||
$revealed && !$reducedMotion
|
||||
? css`${animations.fadeInUp} 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards`
|
||||
: 'none'};
|
||||
animation-delay: ${({ $delay }) => `${$delay}ms`};
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
137
frontend/src/components/ui/TypingEffect.tsx
Normal file
137
frontend/src/components/ui/TypingEffect.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { theme } from '@/styles/theme';
|
||||
|
||||
interface TypingEffectProps {
|
||||
phrases: string[];
|
||||
typingSpeedMs?: number;
|
||||
holdMs?: number;
|
||||
deletingSpeedMs?: number;
|
||||
}
|
||||
|
||||
export default function TypingEffect({
|
||||
phrases,
|
||||
typingSpeedMs = 55,
|
||||
holdMs = 2200,
|
||||
deletingSpeedMs = 30,
|
||||
}: TypingEffectProps) {
|
||||
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isReducedMotion, setIsReducedMotion] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isCaretVisible, setIsCaretVisible] = useState(true);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const updateMotionPreference = () => {
|
||||
setIsReducedMotion(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateMotionPreference();
|
||||
mediaQuery.addEventListener('change', updateMotionPreference);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateMotionPreference);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReducedMotion) {
|
||||
setIsCaretVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setIsCaretVisible((prev) => !prev);
|
||||
}, 530);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [isReducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phrases.length === 0) return;
|
||||
if (isReducedMotion) {
|
||||
setDisplayText(phrases[0]);
|
||||
return;
|
||||
}
|
||||
if (isPaused) return;
|
||||
|
||||
const currentPhrase = phrases[phraseIndex] ?? phrases[0];
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (!isDeleting) {
|
||||
const nextText = currentPhrase.slice(0, displayText.length + 1);
|
||||
setDisplayText(nextText);
|
||||
|
||||
if (nextText === currentPhrase) {
|
||||
setIsDeleting(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextText = currentPhrase.slice(0, Math.max(displayText.length - 1, 0));
|
||||
setDisplayText(nextText);
|
||||
|
||||
if (nextText.length === 0) {
|
||||
setIsDeleting(false);
|
||||
setPhraseIndex((prev) => (prev + 1) % phrases.length);
|
||||
}
|
||||
}, !isDeleting ? (displayText === currentPhrase ? holdMs : typingSpeedMs) : deletingSpeedMs);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
deletingSpeedMs,
|
||||
displayText,
|
||||
holdMs,
|
||||
isDeleting,
|
||||
isPaused,
|
||||
isReducedMotion,
|
||||
phraseIndex,
|
||||
phrases,
|
||||
typingSpeedMs,
|
||||
]);
|
||||
|
||||
if (phrases.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Root
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{isReducedMotion ? phrases[0] : displayText}
|
||||
<Caret $visible={isCaretVisible}>|</Caret>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
const Root = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.08em;
|
||||
min-height: 1.2em;
|
||||
`;
|
||||
|
||||
const Caret = styled.span<{ $visible: boolean }>`
|
||||
color: ${theme.color.brandViolet};
|
||||
font-weight: 500;
|
||||
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
|
||||
transition: opacity 120ms linear;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
`;
|
||||
34
frontend/src/lib/exam-date.ts
Normal file
34
frontend/src/lib/exam-date.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 수능 날짜 (매년 11월 셋째 주 목요일 관례). 2026학년도 수능 = 2025년 11월 13일 (목).
|
||||
// 정확히는 매년 다르지만, 클라이언트에서 하드코딩한 가장 가까운 미래 수능일 상수 배열로 관리.
|
||||
export const UPCOMING_SUNEUNG_DATES = [
|
||||
new Date('2026-11-12T00:00:00+09:00'), // 2027학년도 수능 (목)
|
||||
new Date('2027-11-18T00:00:00+09:00'), // 2028학년도 (목)
|
||||
new Date('2028-11-16T00:00:00+09:00'), // 2029학년도 (목)
|
||||
] as const;
|
||||
|
||||
export interface DDayInfo {
|
||||
targetDate: Date;
|
||||
examYear: number;
|
||||
daysLeft: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function getCurrentDDay(now: Date = new Date()): DDayInfo {
|
||||
const next =
|
||||
UPCOMING_SUNEUNG_DATES.find((date) => date.getTime() > now.getTime()) ??
|
||||
UPCOMING_SUNEUNG_DATES[0];
|
||||
const daysLeft = Math.ceil((next.getTime() - now.getTime()) / 86_400_000);
|
||||
const examYear = next.getFullYear() + 1;
|
||||
|
||||
return {
|
||||
targetDate: next,
|
||||
examYear,
|
||||
daysLeft,
|
||||
label:
|
||||
daysLeft > 0
|
||||
? `D-${daysLeft}`
|
||||
: daysLeft === 0
|
||||
? 'D-DAY'
|
||||
: `D+${Math.abs(daysLeft)}`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user