diff --git a/backend/src/me/me.controller.ts b/backend/src/me/me.controller.ts index fa0d31f..4d0961d 100644 --- a/backend/src/me/me.controller.ts +++ b/backend/src/me/me.controller.ts @@ -23,7 +23,7 @@ import { Max, Min, } from 'class-validator'; -import { MathUnit, Persona, ReviewIntensity } from '@prisma/client'; +import { MathUnit, Persona, ReviewIntensity, SubscriptionTier } from '@prisma/client'; import { extname, join } from 'path'; import { mkdirSync } from 'fs'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @@ -84,6 +84,11 @@ class OnboardingDto { onboarded?: boolean; } +class SwitchTierDto { + @IsEnum(SubscriptionTier) + tier: SubscriptionTier; +} + class ProfilePatchDto { @IsOptional() @IsString() @@ -187,6 +192,11 @@ export class MeController { return this.me.updateAvatar(user.id, file); } + @Patch('subscription-tier') + async switchTier(@CurrentUser() user: AuthUser, @Body() dto: SwitchTierDto) { + return this.me.switchTier(user.id, dto.tier); + } + @Delete('avatar') removeAvatar(@CurrentUser() user: AuthUser) { return this.me.removeAvatar(user.id); diff --git a/backend/src/me/me.service.ts b/backend/src/me/me.service.ts index abe7470..ec2e24b 100644 --- a/backend/src/me/me.service.ts +++ b/backend/src/me/me.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, } from '@nestjs/common'; -import { MathUnit, Persona, Prisma, ReviewIntensity } from '@prisma/client'; +import { MathUnit, Persona, Prisma, ReviewIntensity, SubscriptionTier } from '@prisma/client'; import { unlink } from 'fs/promises'; import { join } from 'path'; import { PrismaService } from '../prisma/prisma.service'; @@ -173,6 +173,14 @@ export class MeService { } } + async switchTier(userId: number, tier: SubscriptionTier) { + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { subscriptionTier: tier }, + }); + return this.toView(user); + } + async updateAvatar( userId: number, file?: { diff --git a/backend/src/reviews/reviews.service.ts b/backend/src/reviews/reviews.service.ts index b8de401..6c52e74 100644 --- a/backend/src/reviews/reviews.service.ts +++ b/backend/src/reviews/reviews.service.ts @@ -48,7 +48,11 @@ export class ReviewsService { take: 100, }); - return { items, rescheduledCount }; + // Free 플랜: 복습 큐 10개 제한 + const queueUser = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { subscriptionTier: true } }); + const limitedItems = queueUser.subscriptionTier === 'free' ? items.slice(0, 10) : items; + + return { items: limitedItems, rescheduledCount }; } /** diff --git a/backend/src/stats/stats.service.ts b/backend/src/stats/stats.service.ts index c9aab6e..8fa04e6 100644 --- a/backend/src/stats/stats.service.ts +++ b/backend/src/stats/stats.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { PersonaForgetService } from '../forget/persona-forget.service'; @@ -71,6 +71,12 @@ export class StatsService { * tag becomes mine"** instead of only the decay curve. */ async masteryPath(userId: number, tagId: number, targetP = 0.9) { + // Free 플랜: 마스터리 경로 차단 + const tierUser = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { subscriptionTier: true } }); + if (tierUser.subscriptionTier === 'free') { + throw new ForbiddenException('마스터리 경로는 Pro 플랜부터 이용할 수 있습니다.'); + } + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, }); diff --git a/backend/src/subjects/subjects.service.ts b/backend/src/subjects/subjects.service.ts index 7cdd80a..e0d7363 100644 --- a/backend/src/subjects/subjects.service.ts +++ b/backend/src/subjects/subjects.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { findPresetUnits } from './curriculum-presets'; @@ -24,6 +24,15 @@ export class SubjectsService { } async create(userId: number, name: string, color: string) { + // Free 플랜: 과목 3개 제한 + const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { subscriptionTier: true } }); + if (user.subscriptionTier === 'free') { + const count = await this.prisma.subject.count({ where: { userId } }); + if (count >= 3) { + throw new ForbiddenException('Free 플랜은 과목 3개까지만 등록할 수 있습니다. Pro로 업그레이드하세요.'); + } + } + const subject = await this.prisma.subject.create({ data: { userId, name, color }, }); diff --git a/frontend/src/app/pricing/page.tsx b/frontend/src/app/pricing/page.tsx index af4127b..24d5a62 100644 --- a/frontend/src/app/pricing/page.tsx +++ b/frontend/src/app/pricing/page.tsx @@ -50,10 +50,12 @@ const tiers: Tier[] = [ note: { monthly: '카드 등록 없이 바로 시작', yearly: '언제든 Pro로 전환 가능' }, featuresLabel: '바로 사용할 수 있는 기능', features: [ - '학습 주제 3개 등록', + '학습 과목 3개까지 등록', '학습 기록 무제한 저장', - '매일 50개 적응형 복습 큐', - '기본 망각 곡선 적용', + '일일 복습 큐 10개', + 'PDF 문제집 월 1회 업로드', + '샘플 문제집 열람', + '기본 복습 캘린더', '학습 페르소나 4종 선택', ], }, @@ -63,41 +65,41 @@ const tiers: Tier[] = [ summary: '시험 준비와 장기 학습을 본격화하는 추천 플랜', description: '문제 풀이부터 복습 분석까지, 꾸준한 성장을 위한 기능을 모두 엽니다.', cta: 'Pro 시작하기', - price: { monthly: '7,900', yearly: '79,000' }, + price: { monthly: '2,900', yearly: '24,900' }, period: { monthly: '/ 월', yearly: '/ 연' }, note: { monthly: '월 단위로 유연하게 사용', - yearly: '연간 결제 시 2개월 무료 혜택', + yearly: '연간 결제 시 약 30% 할인', }, highlight: true, featuresLabel: 'Free의 모든 기능 포함, 추가로', features: [ - '학습 주제 무제한 등록', - '기출 문제집 연동', - '마스터리 경로 뷰와 예상 정답률 예측', - 'PDF 문제/자료 이미지 업로드', + '학습 과목 무제한 등록', + '복습 큐 무제한', + '기출 문제집 전체 연동', + 'PDF 문제집 무제한 업로드', + '마스터리 경로 뷰 + 예상 정답률', '단원별 상세 분석 리포트', - '데이터 내보내기와 알림 위젯', ], }, { name: 'School', - summary: '학원, 스터디, 클래스 운영을 위한 플랜', - description: '학습자 관리, 단체 분석, 보고서 생성까지 한 번에 운영할 수 있습니다.', + summary: '학원, 학교, 교습소 운영을 위한 플랜', + description: '학습자 관리, 과제 배정, 리포트까지 한 번에 운영할 수 있습니다.', cta: 'School 시작하기', - price: { monthly: '20,000', yearly: '200,000' }, - period: { monthly: '/ 월', yearly: '/ 연' }, + price: { monthly: '2,900', yearly: '24,900' }, + period: { monthly: '/ 학생·월', yearly: '/ 학생·연' }, note: { - monthly: '기본 30명 시트 포함', - yearly: '연간 운영 예산에 맞춘 단체 플랜', + monthly: '학생 수에 따라 유연하게', + yearly: '연간 결제 시 약 30% 할인', }, featuresLabel: 'Pro의 모든 기능 포함, 추가로', features: [ - '기본 학습자 30명 시트 제공', - '운영자 대시보드', - '그룹 통계 및 진도 비교', - '학습자별 페르소나/주기 일괄 조정', - '월간 통합 리포트', + '교사 대시보드', + '과제 관리 (일괄 + 개인 지정)', + '학생별 풀이 상세 확인', + '학급 리포트 + 성적 비교', + '선생님 필기/해설 공유', ], }, ]; @@ -130,7 +132,7 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [ title: '학습', rows: [ { - feature: '등록 가능 학습 주제 수', + feature: '등록 가능 과목 수', free: { type: 'text', value: '3개' }, pro: { type: 'text', value: '무제한' }, school: { type: 'text', value: '무제한' }, @@ -142,11 +144,23 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [ school: { type: 'text', value: '무제한' }, }, { - feature: '일일 적응형 복습 큐', - free: { type: 'text', value: '50개' }, + feature: '일일 복습 큐', + free: { type: 'text', value: '10개' }, pro: { type: 'text', value: '무제한' }, school: { type: 'text', value: '무제한' }, }, + { + feature: 'PDF 문제집 업로드', + free: { type: 'text', value: '월 1회' }, + pro: { type: 'text', value: '무제한' }, + school: { type: 'text', value: '무제한' }, + }, + { + feature: '기출 문제집 연동', + free: { type: 'text', value: '샘플만' }, + pro: { type: 'check' }, + school: { type: 'check' }, + }, { feature: '학습 페르소나 선택', free: { type: 'text', value: '4종' }, @@ -158,15 +172,9 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [ { title: '분석', rows: [ - { - feature: '망각 곡선 모델', - free: { type: 'text', value: '기본' }, - pro: { type: 'text', value: '페르소나 맞춤형' }, - school: { type: 'text', value: '페르소나 맞춤형' }, - }, { feature: '마스터리 경로 뷰', - free: { type: 'dash' }, + free: { type: 'text', value: '미리보기' }, pro: { type: 'check' }, school: { type: 'check' }, }, @@ -182,46 +190,34 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [ pro: { type: 'dash' }, school: { type: 'check' }, }, - { - feature: '주간 요약과 알림', - free: { type: 'dash' }, - pro: { type: 'check' }, - school: { type: 'check' }, - }, ], }, { - title: '기타', + title: '학교/학원', rows: [ { - feature: '기출/문제집 연동', - free: { type: 'dash' }, - pro: { type: 'check' }, - school: { type: 'check' }, - }, - { - feature: 'PDF 문제 업로드', - free: { type: 'dash' }, - pro: { type: 'check' }, - school: { type: 'check' }, - }, - { - feature: '데이터 내보내기', - free: { type: 'dash' }, - pro: { type: 'check' }, - school: { type: 'check' }, - }, - { - feature: '선생님 관리자 대시보드', + feature: '교사 대시보드', free: { type: 'dash' }, pro: { type: 'dash' }, school: { type: 'check' }, }, { - feature: '우선 지원', + feature: '과제 관리 (일괄 + 개인)', free: { type: 'dash' }, - pro: { type: 'text', value: '일반' }, - school: { type: 'text', value: '1영업일 내' }, + pro: { type: 'dash' }, + school: { type: 'check' }, + }, + { + feature: '학생 풀이 상세 확인', + free: { type: 'dash' }, + pro: { type: 'dash' }, + school: { type: 'check' }, + }, + { + feature: '선생님 해설 공유', + free: { type: 'dash' }, + pro: { type: 'dash' }, + school: { type: 'check' }, }, ], }, @@ -231,7 +227,7 @@ const faqItems = [ { question: '연간 결제로 바꾸면 언제 할인 적용이 되나요?', answer: - '즉시 적용됩니다. 연간 플랜으로 전환하는 순간 2개월 무료가 반영된 가격으로 청구되고, 남아 있는 월간 사용 기간은 일할 계산해 차감합니다.', + '즉시 적용됩니다. 연간 플랜으로 전환하는 순간 약 30% 할인이 반영된 가격으로 청구되고, 남아 있는 월간 사용 기간은 일할 계산해 차감합니다.', }, { question: 'Free에서 Pro로 올리면 기존 데이터가 유지되나요?', @@ -376,8 +372,8 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent 나에게 맞는 플랜을 선택하세요 - Free로 가볍게 시작하고, 필요할 때 Pro와 School로 확장할 수 있습니다. 시험 - 준비, 언어 학습, 취미 공부까지 반복의 핵심은 모든 플랜에 동일하게 포함됩니다. + Free로 가볍게 시작하고, 필요할 때 Pro와 School로 확장할 수 있습니다. 월 + 2,900원, 편의점 음료 2개 값으로 무제한 복습과 AI 분석을 시작하세요. @@ -397,7 +393,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent > 연간 - 2개월 무료 + 약 30% 할인 @@ -408,8 +404,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent Pricing 세 가지 플랜, 하나의 복습 철학 - 월간은 유연하게, 연간은 더 경제적으로. 연간 결제 시 Pro와 School은 - 2개월 무료 혜택이 반영됩니다. + 월간은 유연하게, 연간은 더 경제적으로. 연간 결제 시 약 30% 할인이 적용됩니다. diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index b266e10..45c98cf 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -373,6 +373,22 @@ function ProfileBody() { } } + async function handleTierSwitch(tier: 'free' | 'pro' | 'school') { + if (!userRef.current || userRef.current.subscriptionTier === tier) return; + setSavingKey('tier'); + try { + const response = await api.patch('/me/subscription-tier', { tier }); + const nextUser: MeUser = { ...response.data, focusUnits: response.data.focusUnits ?? [] }; + userRef.current = nextUser; + setUser(nextUser); + showToast({ message: `${planLabel(tier)}으로 전환했어요`, variant: 'success' }); + } catch { + showToast({ message: '플랜 전환에 실패했습니다', variant: 'danger' }); + } finally { + setSavingKey((current) => (current === 'tier' ? null : current)); + } + } + function handleLogout() { clearToken(); router.replace('/login'); @@ -649,17 +665,39 @@ function ProfileBody() { 플랜 관리 - 현재 플랜을 확인하고 변경하세요 + 프로토타입 모드 — 플랜 자유 전환 - 현재 이용 중인 요금제와 업그레이드 옵션을 `/pricing`에서 바로 확인할 수 - 있습니다. + 프로토타입이므로 결제 없이 Free / Pro / School을 자유롭게 전환할 수 있습니다. - {planLabel(user.subscriptionTier)} - + + void handleTierSwitch('free')} + disabled={savingKey === 'tier'} + > + Free + + void handleTierSwitch('pro')} + disabled={savingKey === 'tier'} + > + Pro + + void handleTierSwitch('school')} + disabled={savingKey === 'tier'} + > + School + + @@ -1336,3 +1374,37 @@ const AlgoDesc = styled.p` font-weight: 600; } `; + +const TierSwitcher = styled.div` + display: flex; + gap: 8px; + flex-wrap: wrap; +`; + +const TierSwitchBtn = styled.button<{ $active: boolean; $accent?: boolean }>` + padding: 10px 18px; + border-radius: 999px; + border: 1px solid ${({ $active, $accent }) => + $active + ? $accent ? 'rgba(99, 102, 241, 0.6)' : 'rgba(255, 255, 255, 0.3)' + : theme.color.borderSoftAlpha}; + background: ${({ $active, $accent }) => + $active + ? $accent ? 'rgba(79, 70, 229, 0.25)' : 'rgba(255, 255, 255, 0.1)' + : 'rgba(255, 255, 255, 0.04)'}; + color: ${({ $active }) => $active ? theme.color.textBright : theme.color.textSub}; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: rgba(79, 70, 229, 0.15); + color: ${theme.color.textBright}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`;