feat(prototype): 수학 단일 과목 잠금 + 샘플 ebook 데모 + B2B QR 비전 문서

- 백엔드 read-side 필터: subjects/stats/problem-sets findMany 에 name='수학' / subjectName='수학' 고정
- 백엔드 write-side 가드: subjects.controller @IsIn(['수학']) for create/update
- 프론트 subjects: 클라이언트 검증으로 수학 외 입력 차단
- 프론트 exams: 카탈로그 맨 앞에 [샘플] 수능 수학 미리보기 ebook 데모 카드 항상 노출 (4분기 모두)
- 신규 페이지 /exams/sample-ebook: 종이책 펼침 two-page spread, 5문항 (이차함수/등차/극한/확률/정적분), 키보드 ←→ nav, 정답 확인+해설, B2B 비전 안내 박스
- docs/b2b-qr-physical-book.md: 종이책 QR → 앱 ebook + 망각곡선 복습 큐 자동 연동, 출판사 라이선스 모델 미래 스펙

데이터는 DB 보존 (soft filter), '수학' 한 단어 sed 로 풀 수 있음.
백엔드 tsc build / 프론트 tsc + lint 클린.
This commit is contained in:
reloop
2026-04-16 09:56:57 +09:00
parent c8d0de3322
commit 674fdfcf3d
8 changed files with 798 additions and 29 deletions

View File

@@ -6,9 +6,10 @@ export class ProblemSetsService {
constructor(private readonly prisma: PrismaService) {}
list(opts: { subjectName?: string; year?: number }) {
// 프로토타입: 수학 ProblemSet만 노출 (soft filter — DB 데이터는 유지)
return this.prisma.problemSet.findMany({
where: {
...(opts.subjectName && { subjectName: opts.subjectName }),
subjectName: '수학',
...(opts.year && { year: opts.year }),
},
include: {

View File

@@ -193,8 +193,9 @@ export class StatsService {
}
async bySubject(userId: number) {
// 프로토타입: 수학 과목만 노출 (soft filter — DB 데이터는 유지)
const subjects = await this.prisma.subject.findMany({
where: { userId },
where: { userId, name: '수학' },
include: {
tags: {
include: {

View File

@@ -9,16 +9,20 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
import { SubjectsService } from './subjects.service';
// 프로토타입: 수학만 허용 — 나중에 sed 한 방으로 해제 가능
const ALLOWED_SUBJECT_NAMES = ['수학'] as const;
class CreateSubjectDto {
@IsString()
@MinLength(1)
@MaxLength(40)
@IsIn(ALLOWED_SUBJECT_NAMES)
name: string;
@IsOptional()
@@ -31,6 +35,7 @@ class UpdateSubjectDto {
@IsString()
@MinLength(1)
@MaxLength(40)
@IsIn(ALLOWED_SUBJECT_NAMES)
name?: string;
@IsOptional()

View File

@@ -6,16 +6,18 @@ export class SubjectsService {
constructor(private readonly prisma: PrismaService) {}
list(userId: number) {
// 프로토타입: 수학 과목만 노출 (soft filter — DB 데이터는 유지)
return this.prisma.subject.findMany({
where: { userId },
where: { userId, name: '수학' },
include: { tags: { orderBy: { name: 'asc' } } },
orderBy: { createdAt: 'asc' },
});
}
async getOne(userId: number, id: number) {
// 프로토타입: 수학 과목만 노출 (soft filter — DB 데이터는 유지)
const subject = await this.prisma.subject.findFirst({
where: { id, userId },
where: { id, userId, name: '수학' },
include: { tags: { orderBy: { name: 'asc' } } },
});
if (!subject) throw new NotFoundException();

View File

@@ -0,0 +1,45 @@
# B2B — 종이책 QR 연동 (Future Spec)
> 상태: **비전 / 미실행**. 사업 확장 단계에서 검토. 현재 프로토타입(수학 단일 과목)에는 포함되지 않음.
## 한 줄 요약
이용자가 출판사로부터 종이 문제집을 구매하면, **표지 또는 책 뒤의 QR 코드**를 ReLoop 앱에서 스캔해 해당 문제집을 ebook 형태로 즉시 열람하고, 동일 문제집의 **틀린 문제·복습 큐**까지 앱 안에서 자동 연동한다.
## 사용자 시나리오
1. 학생이 서점/학교에서 ReLoop 제휴 문제집을 구매
2. 책 뒤 QR 코드를 ReLoop 앱 카메라로 스캔
3. 앱에서 해당 문제집(ProblemSet)이 자동으로 사용자 라이브러리에 추가
4. 디지털 ebook 뷰어로 문제 본문을 그대로 열람 가능 (이미지 또는 텍스트 렌더)
5. 풀이 결과를 ReLoop가 기록 → 틀린 문제는 망각곡선 큐에 자동 진입
6. 다음 학습 시 앱 대시보드에 "OO문제집 복습할 문제 N개" 형태로 노출
## B2B 모델 가설
- **출판사 파트너십**: 문제집 단위로 라이선스 계약, ReLoop ProblemSet 카탈로그에 등록
- **권리 관리**: QR = (publisherId, productCode, oneTimeRedeemToken). 이용자 1인 1라이선스로 매핑하여 무단 공유 차단
- **수익 모델**: B2C 구독료 + B2B 출판사 라이선스 수수료(Per-redeemed-book) 이중 구조 가능
- **차별점**: 종이책의 학습 경험 + 디지털 망각곡선 복습이라는 두 강점 결합
## 기술 스케치 (요구사항만, 구현 X)
| 영역 | 내용 |
|---|---|
| 데이터 모델 | `Publisher`, `LicensedProblemSet`, `RedemptionCode (one-time)`, `UserBookLicense` |
| QR 스킴 | `reloop://redeem?code=<jwt>` 또는 `https://reloop.app/r/<token>` (앱 미설치 시 다운로드 유도) |
| 콘텐츠 형식 | 텍스트형(국어/영어 등): 본문+선택지 DB 저장 / 수식·도표형(수학 등): 페이지 이미지 + 좌표 매핑 |
| 동기화 | 서버에서 문제집 풀이 진도/오답 → ReLoop 코어의 StudyLog/ReviewSchedule 와 동일 구조로 정규화 |
| 보안 | redemption 토큰 1회성, 디바이스 fingerprint, 동일 책 재등록 차단 |
| 오프라인 | 한 번 등록한 문제집은 로컬 캐시로 오프라인 풀이 지원 |
## 의존 사전 작업
- **현재 단계 (수학 단일 과목 프로토타입)** 가 사용자 검증을 통과해 망각곡선 + 복습 큐의 가치가 입증된 후 진행
- ProblemSet 카탈로그가 다과목·다출판사 구조를 받을 수 있도록 schema 확장 필요 (현재는 KICE 단일 출처)
## Open Questions
- 종이책 문제 풀이 결과를 어떻게 입력받나? (스캔, 카메라 OCR, 수동 체크)
- ebook 뷰어를 자체 개발 vs 기존 PDF/EPUB 라이브러리 활용
- 출판사 입장에서의 인센티브 구조 (단순 라이선스 vs 학습 데이터 리포트 제공)

View File

@@ -102,34 +102,50 @@ function ExamsBody() {
</FilterCard>
{error ? (
<StateCard role="alert">
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>{error}</StateText>
<Button type="button" $variant="secondary" onClick={() => setReloadKey((prev) => prev + 1)}>
</Button>
</StateCard>
<>
<Grid>
<SampleEbookCard router={router} />
</Grid>
<StateCard role="alert">
<StateIcon>
<Icon name="info" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText>{error}</StateText>
<Button type="button" $variant="secondary" onClick={() => setReloadKey((prev) => prev + 1)}>
</Button>
</StateCard>
</>
) : problemSets === null ? (
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
<>
<Grid>
<SampleEbookCard router={router} />
</Grid>
<StateCard>
<StateIcon>
<Icon name="clock" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
</>
) : problemSets.length === 0 ? (
<StateCard>
<StateIcon>
<Icon name="book-open-text" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
<>
<Grid>
<SampleEbookCard router={router} />
</Grid>
<StateCard>
<StateIcon>
<Icon name="book-open-text" size={24} />
</StateIcon>
<StateTitle> </StateTitle>
<StateText> .</StateText>
</StateCard>
</>
) : (
<Grid>
<SampleEbookCard router={router} />
{problemSets.map((problemSet) => {
const isAutoGradeUnavailable =
(problemSet.problems?.length ?? 0) > 0 &&
@@ -313,3 +329,74 @@ const StateTitle = styled.h2`
const StateText = styled.p`
color: ${theme.color.textSub};
`;
// ─── 샘플 ebook 카드 ─────────────────────────────────────────────────────────
function SampleEbookCard({ router }: { router: ReturnType<typeof useRouter> }) {
return (
<SampleCard>
<SampleCardHeader>
<CardTitle>[] ebook</CardTitle>
<SampleBadge $variant="default">
<Icon name="book-open-text" size={11} />
·
</SampleBadge>
</SampleCardHeader>
<CardMeta>2026 · · 5</CardMeta>
<CardFooter>
<InfoLine>
<Icon name="book-open-text" size={16} />
</InfoLine>
<Button
type="button"
$variant="primary"
onClick={() => router.push('/exams/sample-ebook')}
>
</Button>
</CardFooter>
</SampleCard>
);
}
const SampleCard = styled(Card)`
display: flex;
flex-direction: column;
gap: 16px;
min-height: 220px;
justify-content: space-between;
background:
radial-gradient(circle at top right, rgba(124, 58, 237, 0.18), transparent 40%),
rgba(255, 255, 255, 0.02);
border: 1px solid rgba(124, 58, 237, 0.35);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.5), rgba(124, 58, 237, 0.5));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
`;
const SampleCardHeader = styled(CardHeader)`
align-items: flex-start;
`;
const SampleBadge = styled(MutedBadge)`
background: rgba(124, 58, 237, 0.18);
border-color: rgba(124, 58, 237, 0.4);
color: ${theme.color.accent2};
white-space: nowrap;
flex-shrink: 0;
`;

View File

@@ -0,0 +1,623 @@
'use client';
import React, { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import styled, { css, keyframes } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
import { Icon } from '@/components/ui/Icon';
import { Badge, Button, Card } from '@/components/ui/primitives';
import { theme } from '@/styles/theme';
// ─── 샘플 문제 데이터 ──────────────────────────────────────────────────────────
interface Problem {
number: number;
question: string;
choices: string[];
answer: number; // 1-based
explanation: string;
}
const SAMPLE_PROBLEMS: Problem[] = [
{
number: 1,
question:
'함수 f(x) = x² 4x + 3 의 최솟값을 구하시오.',
choices: ['3', '2', '1', '0', '1'],
answer: 3,
explanation:
'꼭짓점 공식: f(x) = (x 2)² 1. 꼭짓점 x = 2 에서 최솟값 f(2) = 4 8 + 3 = 1.',
},
{
number: 2,
question:
'등차수열 {aₙ} 에서 a₁ = 3, 공차 d = 4 일 때, a₁₀ 의 값은?',
choices: ['35', '38', '39', '40', '43'],
answer: 3,
explanation:
'aₙ = a₁ + (n 1)d. a₁₀ = 3 + 9 × 4 = 3 + 36 = 39.',
},
{
number: 3,
question:
'lim(x→2) (x² 4) / (x 2) 의 값은?',
choices: ['0', '2', '4', '6', '수렴하지 않음'],
answer: 3,
explanation:
'분자를 인수분해: (x² 4) = (x 2)(x + 2). 약분하면 lim(x→2) (x + 2) = 4.',
},
{
number: 4,
question:
'주머니 속에 빨간 공 3개, 파란 공 2개가 있다. 한 개를 꺼낼 때 빨간 공일 확률은?',
choices: ['1/5', '2/5', '3/5', '4/5', '1'],
answer: 3,
explanation:
'전체 5개 중 빨간 공 3개. 확률 = 3/5.',
},
{
number: 5,
question:
'∫₀² (2x + 1) dx 의 값은?',
choices: ['4', '5', '6', '7', '8'],
answer: 3,
explanation:
'∫(2x + 1) dx = x² + x + C. [x² + x]₀² = (4 + 2) 0 = 6.',
},
];
// ─── 페이지 컴포넌트 ────────────────────────────────────────────────────────────
export default function SampleEbookPage() {
return (
<AppShell>
<EbookViewer />
</AppShell>
);
}
function EbookViewer() {
const [pageIndex, setPageIndex] = useState(0);
const [selectedAnswers, setSelectedAnswers] = useState<Record<number, number>>({});
const [revealed, setRevealed] = useState<Record<number, boolean>>({});
const [fading, setFading] = useState(false);
const total = SAMPLE_PROBLEMS.length;
const problem = SAMPLE_PROBLEMS[pageIndex];
const goTo = useCallback(
(index: number) => {
if (index < 0 || index >= total || fading) return;
setFading(true);
setTimeout(() => {
setPageIndex(index);
setFading(false);
}, 160);
},
[total, fading],
);
// 키보드 ← / → 지원
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') goTo(pageIndex - 1);
if (e.key === 'ArrowRight') goTo(pageIndex + 1);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [pageIndex, goTo]);
const selectAnswer = (choiceIndex: number) => {
setSelectedAnswers((prev) => ({ ...prev, [problem.number]: choiceIndex }));
// 답 바꾸면 해설 숨김
setRevealed((prev) => ({ ...prev, [problem.number]: false }));
};
const revealAnswer = () => {
setRevealed((prev) => ({ ...prev, [problem.number]: true }));
};
const chosen = selectedAnswers[problem.number]; // undefined | number (1-based)
const isRevealed = revealed[problem.number] ?? false;
const isCorrect = chosen !== undefined && chosen === problem.answer;
return (
<ViewerWrap>
{/* 상단 헤더 */}
<ViewerHeader>
<BackLink href="/exams">
<Icon name="arrow-left" size={16} />
</BackLink>
<HeaderRight>
<PreviewBadge $variant="default">
<Icon name="book-open-text" size={11} />
</PreviewBadge>
</HeaderRight>
</ViewerHeader>
<ViewerTitle>[] ebook</ViewerTitle>
<ViewerDivider />
{/* 책 펼침 영역 */}
<BookSpread $fading={fading}>
{/* 왼쪽 페이지 — 문제 본문 */}
<BookPage $side="left">
<PageLabel> {problem.number}</PageLabel>
<QuestionText>{problem.question}</QuestionText>
</BookPage>
{/* 책등 */}
<BookSpine />
{/* 오른쪽 페이지 — 선택지 + 답 확인 */}
<BookPage $side="right">
<PageLabel></PageLabel>
<ChoiceList>
{problem.choices.map((choice, idx) => {
const choiceNum = idx + 1; // 1-based
const isSelected = chosen === choiceNum;
const isAnswerChoice = problem.answer === choiceNum;
let choiceState: 'default' | 'selected' | 'correct' | 'wrong' = 'default';
if (isRevealed) {
if (isAnswerChoice) choiceState = 'correct';
else if (isSelected) choiceState = 'wrong';
} else if (isSelected) {
choiceState = 'selected';
}
return (
<ChoiceItem
key={choiceNum}
$state={choiceState}
onClick={() => !isRevealed && selectAnswer(choiceNum)}
role="radio"
aria-checked={isSelected}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') selectAnswer(choiceNum);
}}
>
<ChoiceCircle $state={choiceState}>
{CIRCLE_NUMS[idx]}
</ChoiceCircle>
<ChoiceText>{choice}</ChoiceText>
{isRevealed && isAnswerChoice && (
<CorrectMark>
<Icon name="check" size={14} />
</CorrectMark>
)}
</ChoiceItem>
);
})}
</ChoiceList>
{/* 답 선택 상태 표시 */}
<AnswerStatusRow>
{chosen !== undefined ? (
<AnswerChosen>
: {CIRCLE_NUMS[chosen - 1]}
</AnswerChosen>
) : (
<AnswerChosen $muted> </AnswerChosen>
)}
{!isRevealed && (
<Button
type="button"
$variant="secondary"
$size="sm"
onClick={revealAnswer}
disabled={chosen === undefined}
>
</Button>
)}
</AnswerStatusRow>
{/* 해설 */}
{isRevealed && (
<ExplanationBox $correct={isCorrect}>
<ExplanationLabel>
{isCorrect ? '정답이야! 👍' : '아쉽게 틀렸어.'}
</ExplanationLabel>
<ExplanationText>{problem.explanation}</ExplanationText>
</ExplanationBox>
)}
</BookPage>
</BookSpread>
{/* 페이지 네비게이션 */}
<PageNav>
<NavButton
type="button"
$variant="ghost"
$size="sm"
onClick={() => goTo(pageIndex - 1)}
disabled={pageIndex === 0}
aria-label="이전 페이지"
>
<Icon name="caret-left" size={16} />
</NavButton>
<PageIndicator>
{pageIndex + 1} / {total}
</PageIndicator>
<NavButton
type="button"
$variant="ghost"
$size="sm"
onClick={() => goTo(pageIndex + 1)}
disabled={pageIndex === total - 1}
aria-label="다음 페이지"
>
<Icon name="caret-right" size={16} />
</NavButton>
</PageNav>
<ViewerDivider />
{/* B2B 비전 안내 */}
<InfoBox>
<InfoIcon>
<Icon name="info" size={18} />
</InfoIcon>
<InfoContent>
<InfoTitle> </InfoTitle>
<InfoDesc>
ebook B2B QR .
QR ,
.
</InfoDesc>
</InfoContent>
</InfoBox>
</ViewerWrap>
);
}
// ─── 유틸 ──────────────────────────────────────────────────────────────────────
const CIRCLE_NUMS = ['①', '②', '③', '④', '⑤'];
// ─── 스타일 ────────────────────────────────────────────────────────────────────
const fadeIn = keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
const ViewerWrap = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.lg};
max-width: 960px;
margin: 0 auto;
padding: ${theme.space.lg};
@media (max-width: ${theme.breakpoint.mobile}) {
padding: ${theme.space.md};
}
`;
const ViewerHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: ${theme.space.md};
`;
const BackLink = styled(Link)`
display: inline-flex;
align-items: center;
gap: 6px;
color: ${theme.color.textSub};
font-size: 14px;
text-decoration: none;
transition: color 0.15s ease;
&:hover {
color: ${theme.color.textMain};
}
`;
const HeaderRight = styled.div`
display: flex;
align-items: center;
`;
const PreviewBadge = styled(Badge)`
background: rgba(124, 58, 237, 0.18);
border-color: rgba(124, 58, 237, 0.4);
color: ${theme.color.accent2};
`;
const ViewerTitle = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${theme.color.textBright};
margin: 0;
`;
const ViewerDivider = styled.hr`
border: none;
border-top: 1px solid ${theme.color.borderSoftAlpha};
margin: 0;
`;
// 책 펼침 레이아웃
const BookSpread = styled.div<{ $fading: boolean }>`
display: grid;
grid-template-columns: 1fr 2px 1fr;
gap: 0;
min-height: 400px;
opacity: ${({ $fading }) => ($fading ? 0 : 1)};
transition: opacity 0.16s ease;
border-radius: ${theme.radius.lg};
overflow: hidden;
box-shadow: ${theme.shadow.lg};
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 1fr;
}
`;
const BookPage = styled(Card)<{ $side: 'left' | 'right' }>`
border-radius: 0;
border: none;
background: rgba(22, 20, 36, 0.95);
display: flex;
flex-direction: column;
gap: ${theme.space.md};
padding: ${theme.space.xl};
min-height: 380px;
/* cream tint */
background: linear-gradient(
160deg,
rgba(20, 18, 40, 0.98) 0%,
rgba(22, 20, 38, 0.96) 100%
);
${({ $side }) =>
$side === 'left'
? css`
border-right: none;
`
: css`
border-left: none;
`}
@media (max-width: ${theme.breakpoint.tablet}) {
border-radius: 0;
min-height: auto;
padding: ${theme.space.lg};
${({ $side }) =>
$side === 'right' &&
css`
border-top: 1px solid ${theme.color.borderSoftAlpha};
`}
}
`;
const BookSpine = styled.div`
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.35),
rgba(124, 58, 237, 0.15),
rgba(0, 0, 0, 0.35)
);
width: 2px;
align-self: stretch;
@media (max-width: ${theme.breakpoint.tablet}) {
display: none;
}
`;
const PageLabel = styled.span`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: ${theme.color.accent};
`;
const QuestionText = styled.p`
font-size: 17px;
line-height: 1.8;
color: ${theme.color.textBright};
font-family: 'Georgia', 'Batang', serif;
white-space: pre-wrap;
flex: 1;
`;
// 선택지
const ChoiceList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
type ChoiceState = 'default' | 'selected' | 'correct' | 'wrong';
const choiceStateStyles = {
default: css`
background: rgba(255, 255, 255, 0.03);
border-color: ${theme.color.borderSoftAlpha};
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.07);
border-color: ${theme.color.borderBrightAlpha};
}
`,
selected: css`
background: rgba(79, 70, 229, 0.12);
border-color: rgba(79, 70, 229, 0.5);
cursor: pointer;
`,
correct: css`
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.5);
cursor: default;
`,
wrong: css`
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.5);
cursor: default;
`,
};
const ChoiceItem = styled.div<{ $state: ChoiceState }>`
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: ${theme.radius.md};
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease;
user-select: none;
outline-offset: 2px;
&:focus-visible {
outline: 2px solid ${theme.color.accent};
}
${({ $state }) => choiceStateStyles[$state]}
`;
const circleStateStyles: Record<ChoiceState, ReturnType<typeof css>> = {
default: css`
color: ${theme.color.textSub};
`,
selected: css`
color: ${theme.color.accent};
font-weight: 700;
`,
correct: css`
color: ${theme.color.success};
font-weight: 700;
`,
wrong: css`
color: ${theme.color.danger};
font-weight: 700;
`,
};
const ChoiceCircle = styled.span<{ $state: ChoiceState }>`
font-size: 18px;
line-height: 1;
flex-shrink: 0;
${({ $state }) => circleStateStyles[$state]}
`;
const ChoiceText = styled.span`
font-size: 15px;
color: ${theme.color.textMain};
flex: 1;
`;
const CorrectMark = styled.span`
color: ${theme.color.success};
display: flex;
align-items: center;
animation: ${fadeIn} 0.2s ease;
`;
// 답 선택 상태
const AnswerStatusRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: ${theme.space.sm};
`;
const AnswerChosen = styled.span<{ $muted?: boolean }>`
font-size: 14px;
color: ${({ $muted }) => ($muted ? theme.color.textMute : theme.color.textSub)};
`;
// 해설 박스
const ExplanationBox = styled.div<{ $correct: boolean }>`
padding: ${theme.space.md};
border-radius: ${theme.radius.md};
border: 1px solid
${({ $correct }) =>
$correct ? 'rgba(34, 197, 94, 0.35)' : 'rgba(239, 68, 68, 0.35)'};
background: ${({ $correct }) =>
$correct ? 'rgba(34, 197, 94, 0.07)' : 'rgba(239, 68, 68, 0.07)'};
display: flex;
flex-direction: column;
gap: 6px;
animation: ${fadeIn} 0.25s ease;
`;
const ExplanationLabel = styled.span`
font-size: 13px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const ExplanationText = styled.p`
font-size: 13px;
line-height: 1.7;
color: ${theme.color.textSub};
`;
// 페이지 네비게이션
const PageNav = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: ${theme.space.lg};
`;
const NavButton = styled(Button)`
min-width: 110px;
`;
const PageIndicator = styled.span`
font-size: 15px;
font-weight: 600;
color: ${theme.color.textSub};
min-width: 60px;
text-align: center;
`;
// B2B 안내 박스
const InfoBox = styled(Card)`
display: flex;
align-items: flex-start;
gap: ${theme.space.md};
background: rgba(56, 189, 248, 0.06);
border-color: rgba(56, 189, 248, 0.25);
`;
const InfoIcon = styled.div`
color: ${theme.color.info};
flex-shrink: 0;
padding-top: 2px;
`;
const InfoContent = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const InfoTitle = styled.span`
font-size: 13px;
font-weight: 700;
color: ${theme.color.textBright};
`;
const InfoDesc = styled.p`
font-size: 13px;
line-height: 1.7;
color: ${theme.color.textSub};
margin: 0;
`;

View File

@@ -180,6 +180,11 @@ function SubjectsBody() {
setSubjectModalError('과목 이름을 입력해 주세요.');
return;
}
// 프로토타입: 수학 단일 과목 잠금 — 추후 sed 한 방으로 해제
if (name !== '수학') {
setSubjectModalError('현재는 수학 과목만 추가할 수 있어요.');
return;
}
setSubjectModalSaving(true);
setSubjectModalError(null);