feat(landing): typing effect + D-day sync + scroll animations + copy — 7G.4

This commit is contained in:
reloop
2026-04-12 07:29:19 +09:00
parent 9068fcbd48
commit feb1a624a5
8 changed files with 585 additions and 224 deletions

View File

@@ -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>

View File

@@ -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}) {

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>

View 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;
}
`;

View 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;
}
`;

View 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)}`,
};
}