feat(phase6): BM tiers + Landing + Pricing + 평가원 기출 ProblemSet — tracks 2/4

트랙 2 — BM / Landing / Pricing
- schema.prisma: SubscriptionTier enum(free/pro/school) + User.subscriptionTier
  + User.subscriptionUntil. Migration 20260411161837_bm_and_problemsets.
- auth.service.ts / me.service.ts safeUser/toView 에 구독 필드 포함.
- frontend/lib/api.ts MeUser 에 subscriptionTier/subscriptionUntil 추가.
- app/page.tsx 전면 재작성: 토큰 있으면 /dashboard, 없으면 비로그인 Landing
  (히어로 + 왜 ReLoop 3카드 + 페르소나 4장 + 4단계 루프 + 가격 티저 +
  FAQ 4개 + footer).
- app/pricing/page.tsx 신규: Free/Pro/School 3 티어 카드(Pro 강조 PopBadge),
  월/년 cycle 토글, 기능 비교 테이블(데스크탑 전용), FAQ 5개, 바닥 CTA.
  Pro ₩7,900/월, 연간 ₩79,000(2개월 무료), School ₩20,000/월·30시트.
- app/profile/page.tsx: 구독 섹션 + 티어 배지 + pricing 으로 가는 업그레이드
  버튼.

트랙 4 — 평가원 기출 ProblemSet 데모
- schema.prisma: ProblemSet + Problem 신규 모델. StudyLog.problemId
  (optional, SetNull). 각 ProblemSet 은 (year, examType, subjectName)
  복합 unique.
- prisma/seed.ts: 2024학년도 수능 수학 샘플 10 문항 (1/5/8/11/13/15/20/22/
  28/30번) 대략치 난이도+정답률로 upsert. 실제 KICE 수치로 교체 가능하도록
  주석 명시.
- backend/src/problem-sets/: ProblemSetsModule + Service + Controller.
  GET /api/problem-sets (list, subject/year 필터) + GET /api/problem-sets/:id
  (문제 배열 포함). JwtAuthGuard 적용.
- study-logs.service/controller: problemId optional 필드 수용.
- frontend/lib/api.ts: ProblemSetSummary/ProblemSetDetail/Problem 타입.
- frontend/app/study/page.tsx 재설계: 상단 탭 [직접 입력 | 문제집에서 가져오기].
  문제집 탭은 set 드롭다운 + 문제 리스트 클릭 → title/difficulty/baseCorrectRate
  자동 채움 + subjectName 매핑으로 사용자 과목 자동 선택 + topic 을 tag
  이름과 매칭. 결과/메모/시간만 수동 입력.
This commit is contained in:
reloop
2026-04-12 01:26:35 +09:00
parent f22f837fe2
commit 871ca89da0
16 changed files with 1772 additions and 61 deletions

View File

@@ -1,13 +1,561 @@
'use client';
import { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { hasToken } from '@/lib/auth';
import { Button } from '@/components/ui/primitives';
export default function RootPage() {
export default function LandingPage() {
const router = useRouter();
const [ready, setReady] = useState(false);
useEffect(() => {
router.replace(hasToken() ? '/dashboard' : '/login');
if (hasToken()) {
router.replace('/dashboard');
return;
}
setReady(true);
}, [router]);
return null;
if (!ready) return null;
return (
<Page>
<Nav>
<Brand>ReLoop</Brand>
<NavLinks>
<Link href="/pricing"></Link>
<Link href="/login"></Link>
<Link href="/register">
<Button $size="sm"></Button>
</Link>
</NavLinks>
</Nav>
<Hero>
<HeroLabel> · </HeroLabel>
<HeroTitle>
<Accent> </Accent>.
</HeroTitle>
<HeroSub>
ReLoop .
,
.
</HeroSub>
<CTARow>
<Link href="/register">
<Button $size="lg"> </Button>
</Link>
<Link href="/login">
<Button $size="lg" $variant="secondary">
</Button>
</Link>
</CTARow>
<HeroNote>demo@reloop.local / demo1234 </HeroNote>
</Hero>
<Section>
<SectionEyebrow> ReLoop </SectionEyebrow>
<SectionTitle2> SRS 3</SectionTitle2>
<ReasonGrid>
<ReasonCard>
<ReasonEmoji>🧭</ReasonEmoji>
<ReasonTitle> </ReasonTitle>
<ReasonBody>
// .
λ .
</ReasonBody>
</ReasonCard>
<ReasonCard>
<ReasonEmoji>🎯</ReasonEmoji>
<ReasonTitle> </ReasonTitle>
<ReasonBody>
, .
(D) (S) .
</ReasonBody>
</ReasonCard>
<ReasonCard>
<ReasonEmoji>📈</ReasonEmoji>
<ReasonTitle> </ReasonTitle>
<ReasonBody>
. &quot;
&quot; .
</ReasonBody>
</ReasonCard>
</ReasonGrid>
</Section>
<Section>
<SectionEyebrow> 4 </SectionEyebrow>
<SectionTitle2> </SectionTitle2>
<PersonaGrid>
<PersonaCard $color={theme.persona.senior.color}>
<PEmoji>{theme.persona.senior.emoji}</PEmoji>
<PName></PName>
<PLambda>λ = 0.1</PLambda>
<PDesc> . .</PDesc>
</PersonaCard>
<PersonaCard $color={theme.persona.mid.color}>
<PEmoji>{theme.persona.mid.emoji}</PEmoji>
<PName></PName>
<PLambda>λ = 0.2</PLambda>
<PDesc>. .</PDesc>
</PersonaCard>
<PersonaCard $color={theme.persona.junior.color}>
<PEmoji>{theme.persona.junior.emoji}</PEmoji>
<PName></PName>
<PLambda>λ = 0.4</PLambda>
<PDesc> . .</PDesc>
</PersonaCard>
<PersonaCard $color={theme.persona.crammer.color}>
<PEmoji>{theme.persona.crammer.emoji}</PEmoji>
<PName></PName>
<PLambda>λ = 0.6</PLambda>
<PDesc> .</PDesc>
</PersonaCard>
</PersonaGrid>
</Section>
<Section>
<SectionEyebrow> </SectionEyebrow>
<SectionTitle2>4 </SectionTitle2>
<Steps>
<Step>
<StepN>1</StepN>
<StepTitle> </StepTitle>
<StepBody> .</StepBody>
</Step>
<Step>
<StepN>2</StepN>
<StepTitle> </StepTitle>
<StepBody>
× × t .
</StepBody>
</Step>
<Step>
<StepN>3</StepN>
<StepTitle> </StepTitle>
<StepBody> / / .</StepBody>
</Step>
<Step>
<StepN>4</StepN>
<StepTitle> </StepTitle>
<StepBody>
P · .
</StepBody>
</Step>
</Steps>
</Section>
<PricingTeaser>
<PTitle></PTitle>
<PSub>Free Pro .</PSub>
<PRow>
<PCell>
<PCellName>Free</PCellName>
<PCellPrice>0</PCellPrice>
<PCellNote> 3 · · </PCellNote>
</PCell>
<PCell $highlight>
<PCellName>Pro</PCellName>
<PCellPrice>
7,900<small>/</small>
</PCellPrice>
<PCellNote> · · </PCellNote>
</PCell>
<PCell>
<PCellName>School</PCellName>
<PCellPrice>
20,000<small>/·30</small>
</PCellPrice>
<PCellNote> · </PCellNote>
</PCell>
</PRow>
<Link href="/pricing">
<Button $size="md" $variant="secondary">
</Button>
</Link>
</PricingTeaser>
<Faq>
<SectionEyebrow> </SectionEyebrow>
<FaqItem>
<summary>AI AI ?</summary>
<p>
AI .
,
. .
</p>
</FaqItem>
<FaqItem>
<summary> ?</summary>
<p>
, ··
//TOEIC . .
</p>
</FaqItem>
<FaqItem>
<summary> ?</summary>
<p>
HTTPS, bcrypt 10 rounds , JWT
. Pro
JSON/CSV export .
</p>
</FaqItem>
<FaqItem>
<summary> ?</summary>
<p>
. Free . Pro
· · ·
export .
</p>
</FaqItem>
</Faq>
<Footer>
<FooterBrand>ReLoop v2</FooterBrand>
<FooterLinks>
<Link href="/pricing"></Link>
<Link href="/login"></Link>
<Link href="/register"></Link>
</FooterLinks>
<FooterNote>© 2026 Nabomhalang · </FooterNote>
</Footer>
</Page>
);
}
// ── styled ──
const Page = styled.div`
min-height: 100vh;
max-width: 1100px;
margin: 0 auto;
padding: ${theme.space.lg} ${theme.space.lg} ${theme.space.xxl};
display: flex;
flex-direction: column;
gap: ${theme.space.xxl};
`;
const Nav = styled.nav`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.space.sm} 0;
`;
const Brand = styled.div`
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #818cf8, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
`;
const NavLinks = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.md};
font-size: 14px;
color: ${theme.color.textSub};
a {
transition: color 0.15s ease;
&:hover {
color: ${theme.color.textMain};
}
}
`;
const Hero = styled.section`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: ${theme.space.md};
padding: ${theme.space.xl} 0;
`;
const HeroLabel = styled.span`
font-size: 12px;
font-weight: 600;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: ${theme.font.mono};
`;
const HeroTitle = styled.h1`
font-size: clamp(36px, 7vw, 64px);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
margin: ${theme.space.sm} 0;
`;
const Accent = styled.span`
background: linear-gradient(135deg, #818cf8, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
`;
const HeroSub = styled.p`
font-size: 16px;
color: ${theme.color.textSub};
max-width: 640px;
line-height: 1.6;
`;
const CTARow = styled.div`
display: flex;
gap: ${theme.space.md};
margin-top: ${theme.space.md};
flex-wrap: wrap;
justify-content: center;
`;
const HeroNote = styled.p`
font-size: 12px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;
const Section = styled.section`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
`;
const SectionEyebrow = styled.span`
font-size: 11px;
font-weight: 700;
color: ${theme.color.accent};
text-transform: uppercase;
letter-spacing: 0.1em;
font-family: ${theme.font.mono};
`;
const SectionTitle2 = styled.h2`
font-size: clamp(24px, 4vw, 36px);
font-weight: 800;
letter-spacing: -0.02em;
`;
const ReasonGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: ${theme.space.md};
`;
const ReasonCard = styled.div`
padding: ${theme.space.lg};
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const ReasonEmoji = styled.div`
font-size: 32px;
`;
const ReasonTitle = styled.h3`
font-size: 16px;
font-weight: 700;
`;
const ReasonBody = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
line-height: 1.6;
`;
const PersonaGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: ${theme.space.md};
`;
const PersonaCard = styled.div<{ $color: string }>`
padding: ${theme.space.lg};
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
display: flex;
flex-direction: column;
gap: ${theme.space.xs};
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
background: ${({ $color }) => $color};
}
`;
const PEmoji = styled.div`
font-size: 28px;
`;
const PName = styled.h3`
font-size: 15px;
font-weight: 700;
`;
const PLambda = styled.span`
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;
const PDesc = styled.p`
font-size: 12px;
color: ${theme.color.textSub};
margin-top: ${theme.space.xs};
`;
const Steps = styled.ol`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: ${theme.space.md};
`;
const Step = styled.li`
padding: ${theme.space.lg};
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const StepN = styled.span`
font-size: 11px;
font-weight: 700;
color: ${theme.color.accent};
font-family: ${theme.font.mono};
`;
const StepTitle = styled.h3`
font-size: 15px;
font-weight: 700;
`;
const StepBody = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
line-height: 1.55;
`;
const PricingTeaser = styled.section`
padding: ${theme.space.xl};
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.xl};
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.space.md};
text-align: center;
`;
const PTitle = styled.h2`
font-size: 24px;
font-weight: 800;
`;
const PSub = styled.p`
font-size: 13px;
color: ${theme.color.textSub};
`;
const PRow = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: ${theme.space.md};
width: 100%;
margin: ${theme.space.md} 0;
`;
const PCell = styled.div<{ $highlight?: boolean }>`
padding: ${theme.space.lg};
background: ${({ $highlight }) =>
$highlight ? theme.color.surfaceHover : theme.color.surface2};
border: 1px solid
${({ $highlight }) => ($highlight ? theme.color.accent : theme.color.border)};
border-radius: ${theme.radius.lg};
display: flex;
flex-direction: column;
gap: ${theme.space.xs};
transform: ${({ $highlight }) => ($highlight ? 'translateY(-4px)' : 'none')};
`;
const PCellName = styled.div`
font-size: 12px;
font-weight: 700;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.06em;
`;
const PCellPrice = styled.div`
font-size: 24px;
font-weight: 800;
small {
font-size: 12px;
font-weight: 400;
color: ${theme.color.textMute};
}
`;
const PCellNote = styled.div`
font-size: 12px;
color: ${theme.color.textSub};
margin-top: ${theme.space.xs};
`;
const Faq = styled.section`
display: flex;
flex-direction: column;
gap: ${theme.space.md};
`;
const FaqItem = styled.details`
padding: ${theme.space.md} ${theme.space.lg};
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
cursor: pointer;
summary {
font-size: 14px;
font-weight: 700;
list-style: none;
&::-webkit-details-marker {
display: none;
}
&::before {
content: ' ';
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
}
}
&[open] summary::before {
content: ' ';
}
p {
margin-top: ${theme.space.sm};
font-size: 13px;
color: ${theme.color.textSub};
line-height: 1.6;
}
`;
const Footer = styled.footer`
display: flex;
flex-direction: column;
align-items: center;
gap: ${theme.space.sm};
padding-top: ${theme.space.xl};
border-top: 1px solid ${theme.color.borderSoft};
`;
const FooterBrand = styled.div`
font-size: 13px;
font-weight: 700;
color: ${theme.color.textSub};
`;
const FooterLinks = styled.div`
display: flex;
gap: ${theme.space.md};
font-size: 12px;
color: ${theme.color.textSub};
a:hover {
color: ${theme.color.textMain};
}
`;
const FooterNote = styled.p`
font-size: 11px;
color: ${theme.color.textMute};
font-family: ${theme.font.mono};
`;