feat: 가격표 2,900원 리뉴얼 + 티어 전환기 + 기능 게이트

가격표: Free/Pro(2,900)/School(학생당 2,900) 3-tier, 비교표 재구성
프로필: 프로토타입용 Free/Pro/School 자유 전환 버튼
기능 게이트: Free 과목 3개, 복습 큐 10개, 마스터리 경로 차단

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 19:06:57 +09:00
parent b59cc8fd57
commit 1873c65a02
7 changed files with 182 additions and 78 deletions

View File

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

View File

@@ -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?: {

View File

@@ -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 };
}
/**

View File

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

View File

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

View File

@@ -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
<HeroGradient> </HeroGradient>
</HeroTitle>
<HeroSubtitle>
Free로 , Pro와 School로 .
, , .
Free로 , Pro와 School로 .
2,900, 2 AI .
</HeroSubtitle>
<BillingToggle aria-label="결제 주기 선택">
@@ -397,7 +393,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
>
<DiscountBadge $visible={isYearly || billing === 'monthly'}>
2
30%
</DiscountBadge>
</ToggleButton>
</BillingToggle>
@@ -408,8 +404,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
<SectionEyebrow id="pricing-heading">Pricing</SectionEyebrow>
<SectionTitle> , </SectionTitle>
<SectionBody>
, . Pro와 School은
2 .
, . 30% .
</SectionBody>
</SectionHeader>

View File

@@ -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<MeUser>('/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() {
<Icon name="crown" size={18} />
</PlanManagementLabel>
<PlanManagementTitle> </PlanManagementTitle>
<PlanManagementTitle> </PlanManagementTitle>
<PlanManagementDesc>
`/pricing`
.
Free / Pro / School을 .
</PlanManagementDesc>
</PlanManagementMeta>
<PlanManagementActions>
<PlanTierBadge>{planLabel(user.subscriptionTier)}</PlanTierBadge>
<Button as={Link} href="/pricing" $variant="secondary">
</Button>
<TierSwitcher>
<TierSwitchBtn
type="button"
$active={user.subscriptionTier === 'free'}
onClick={() => void handleTierSwitch('free')}
disabled={savingKey === 'tier'}
>
Free
</TierSwitchBtn>
<TierSwitchBtn
type="button"
$active={user.subscriptionTier === 'pro'}
$accent
onClick={() => void handleTierSwitch('pro')}
disabled={savingKey === 'tier'}
>
Pro
</TierSwitchBtn>
<TierSwitchBtn
type="button"
$active={user.subscriptionTier === 'school'}
onClick={() => void handleTierSwitch('school')}
disabled={savingKey === 'tier'}
>
School
</TierSwitchBtn>
</TierSwitcher>
</PlanManagementActions>
</PlanManagementBody>
</PlanManagementCard>
@@ -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;
}
`;