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, Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { MathUnit, Persona, ReviewIntensity } from '@prisma/client'; import { MathUnit, Persona, ReviewIntensity, SubscriptionTier } from '@prisma/client';
import { extname, join } from 'path'; import { extname, join } from 'path';
import { mkdirSync } from 'fs'; import { mkdirSync } from 'fs';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -84,6 +84,11 @@ class OnboardingDto {
onboarded?: boolean; onboarded?: boolean;
} }
class SwitchTierDto {
@IsEnum(SubscriptionTier)
tier: SubscriptionTier;
}
class ProfilePatchDto { class ProfilePatchDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -187,6 +192,11 @@ export class MeController {
return this.me.updateAvatar(user.id, file); 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') @Delete('avatar')
removeAvatar(@CurrentUser() user: AuthUser) { removeAvatar(@CurrentUser() user: AuthUser) {
return this.me.removeAvatar(user.id); return this.me.removeAvatar(user.id);

View File

@@ -2,7 +2,7 @@ import {
BadRequestException, BadRequestException,
Injectable, Injectable,
} from '@nestjs/common'; } 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 { unlink } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { PrismaService } from '../prisma/prisma.service'; 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( async updateAvatar(
userId: number, userId: number,
file?: { file?: {

View File

@@ -48,7 +48,11 @@ export class ReviewsService {
take: 100, 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 { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service'; import { PersonaForgetService } from '../forget/persona-forget.service';
@@ -71,6 +71,12 @@ export class StatsService {
* tag becomes mine"** instead of only the decay curve. * tag becomes mine"** instead of only the decay curve.
*/ */
async masteryPath(userId: number, tagId: number, targetP = 0.9) { 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({ const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId }, 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 { PrismaService } from '../prisma/prisma.service';
import { findPresetUnits } from './curriculum-presets'; import { findPresetUnits } from './curriculum-presets';
@@ -24,6 +24,15 @@ export class SubjectsService {
} }
async create(userId: number, name: string, color: string) { 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({ const subject = await this.prisma.subject.create({
data: { userId, name, color }, data: { userId, name, color },
}); });

View File

@@ -50,10 +50,12 @@ const tiers: Tier[] = [
note: { monthly: '카드 등록 없이 바로 시작', yearly: '언제든 Pro로 전환 가능' }, note: { monthly: '카드 등록 없이 바로 시작', yearly: '언제든 Pro로 전환 가능' },
featuresLabel: '바로 사용할 수 있는 기능', featuresLabel: '바로 사용할 수 있는 기능',
features: [ features: [
'학습 주제 3개 등록', '학습 과목 3개까지 등록',
'학습 기록 무제한 저장', '학습 기록 무제한 저장',
'매일 50개 적응형 복습 큐', '일일 복습 큐 10개',
'기본 망각 곡선 적용', 'PDF 문제집 월 1회 업로드',
'샘플 문제집 열람',
'기본 복습 캘린더',
'학습 페르소나 4종 선택', '학습 페르소나 4종 선택',
], ],
}, },
@@ -63,41 +65,41 @@ const tiers: Tier[] = [
summary: '시험 준비와 장기 학습을 본격화하는 추천 플랜', summary: '시험 준비와 장기 학습을 본격화하는 추천 플랜',
description: '문제 풀이부터 복습 분석까지, 꾸준한 성장을 위한 기능을 모두 엽니다.', description: '문제 풀이부터 복습 분석까지, 꾸준한 성장을 위한 기능을 모두 엽니다.',
cta: 'Pro 시작하기', cta: 'Pro 시작하기',
price: { monthly: '7,900', yearly: '79,000' }, price: { monthly: '2,900', yearly: '24,900' },
period: { monthly: '/ 월', yearly: '/ 연' }, period: { monthly: '/ 월', yearly: '/ 연' },
note: { note: {
monthly: '월 단위로 유연하게 사용', monthly: '월 단위로 유연하게 사용',
yearly: '연간 결제 시 2개월 무료 혜택', yearly: '연간 결제 시 약 30% 할인',
}, },
highlight: true, highlight: true,
featuresLabel: 'Free의 모든 기능 포함, 추가로', featuresLabel: 'Free의 모든 기능 포함, 추가로',
features: [ features: [
'학습 주제 무제한 등록', '학습 과목 무제한 등록',
'기출 문제집 연동', '복습 큐 무제한',
'마스터리 경로 뷰와 예상 정답률 예측', '기출 문제집 전체 연동',
'PDF 문제/자료 이미지 업로드', 'PDF 문제집 무제한 업로드',
'마스터리 경로 뷰 + 예상 정답률',
'단원별 상세 분석 리포트', '단원별 상세 분석 리포트',
'데이터 내보내기와 알림 위젯',
], ],
}, },
{ {
name: 'School', name: 'School',
summary: '학원, 스터디, 클래스 운영을 위한 플랜', summary: '학원, 학교, 교습소 운영을 위한 플랜',
description: '학습자 관리, 단체 분석, 보고서 생성까지 한 번에 운영할 수 있습니다.', description: '학습자 관리, 과제 배정, 리포트까지 한 번에 운영할 수 있습니다.',
cta: 'School 시작하기', cta: 'School 시작하기',
price: { monthly: '20,000', yearly: '200,000' }, price: { monthly: '2,900', yearly: '24,900' },
period: { monthly: '/ 월', yearly: '/ 연' }, period: { monthly: '/ 학생·월', yearly: '/ 학생·연' },
note: { note: {
monthly: '기본 30명 시트 포함', monthly: '학생 수에 따라 유연하게',
yearly: '연간 운영 예산에 맞춘 단체 플랜', yearly: '연간 결제 시 약 30% 할인',
}, },
featuresLabel: 'Pro의 모든 기능 포함, 추가로', featuresLabel: 'Pro의 모든 기능 포함, 추가로',
features: [ features: [
'기본 학습자 30명 시트 제공', '교사 대시보드',
'운영자 대시보드', '과제 관리 (일괄 + 개인 지정)',
'그룹 통계 및 진도 비교', '학생별 풀이 상세 확인',
'학습자별 페르소나/주기 일괄 조정', '학급 리포트 + 성적 비교',
'월간 통합 리포트', '선생님 필기/해설 공유',
], ],
}, },
]; ];
@@ -130,7 +132,7 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
title: '학습', title: '학습',
rows: [ rows: [
{ {
feature: '등록 가능 학습 주제 수', feature: '등록 가능 과목 수',
free: { type: 'text', value: '3개' }, free: { type: 'text', value: '3개' },
pro: { type: 'text', value: '무제한' }, pro: { type: 'text', value: '무제한' },
school: { type: 'text', value: '무제한' }, school: { type: 'text', value: '무제한' },
@@ -142,11 +144,23 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
school: { type: 'text', value: '무제한' }, school: { type: 'text', value: '무제한' },
}, },
{ {
feature: '일일 적응형 복습 큐', feature: '일일 복습 큐',
free: { type: 'text', value: '50개' }, free: { type: 'text', value: '10개' },
pro: { type: 'text', value: '무제한' }, pro: { type: 'text', value: '무제한' },
school: { 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: '학습 페르소나 선택', feature: '학습 페르소나 선택',
free: { type: 'text', value: '4종' }, free: { type: 'text', value: '4종' },
@@ -158,15 +172,9 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
{ {
title: '분석', title: '분석',
rows: [ rows: [
{
feature: '망각 곡선 모델',
free: { type: 'text', value: '기본' },
pro: { type: 'text', value: '페르소나 맞춤형' },
school: { type: 'text', value: '페르소나 맞춤형' },
},
{ {
feature: '마스터리 경로 뷰', feature: '마스터리 경로 뷰',
free: { type: 'dash' }, free: { type: 'text', value: '미리보기' },
pro: { type: 'check' }, pro: { type: 'check' },
school: { type: 'check' }, school: { type: 'check' },
}, },
@@ -182,46 +190,34 @@ const comparisonSections: Array<{ title: string; rows: ComparisonRow[] }> = [
pro: { type: 'dash' }, pro: { type: 'dash' },
school: { type: 'check' }, school: { type: 'check' },
}, },
{
feature: '주간 요약과 알림',
free: { type: 'dash' },
pro: { type: 'check' },
school: { type: 'check' },
},
], ],
}, },
{ {
title: '기타', title: '학교/학원',
rows: [ rows: [
{ {
feature: '기출/문제집 연동', 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: '선생님 관리자 대시보드',
free: { type: 'dash' }, free: { type: 'dash' },
pro: { type: 'dash' }, pro: { type: 'dash' },
school: { type: 'check' }, school: { type: 'check' },
}, },
{ {
feature: '우선 지원', feature: '과제 관리 (일괄 + 개인)',
free: { type: 'dash' }, free: { type: 'dash' },
pro: { type: 'text', value: '일반' }, pro: { type: 'dash' },
school: { type: 'text', value: '1영업일 내' }, 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: '연간 결제로 바꾸면 언제 할인 적용이 되나요?', question: '연간 결제로 바꾸면 언제 할인 적용이 되나요?',
answer: answer:
'즉시 적용됩니다. 연간 플랜으로 전환하는 순간 2개월 무료가 반영된 가격으로 청구되고, 남아 있는 월간 사용 기간은 일할 계산해 차감합니다.', '즉시 적용됩니다. 연간 플랜으로 전환하는 순간 약 30% 할인이 반영된 가격으로 청구되고, 남아 있는 월간 사용 기간은 일할 계산해 차감합니다.',
}, },
{ {
question: 'Free에서 Pro로 올리면 기존 데이터가 유지되나요?', question: 'Free에서 Pro로 올리면 기존 데이터가 유지되나요?',
@@ -376,8 +372,8 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
<HeroGradient> </HeroGradient> <HeroGradient> </HeroGradient>
</HeroTitle> </HeroTitle>
<HeroSubtitle> <HeroSubtitle>
Free로 , Pro와 School로 . Free로 , Pro와 School로 .
, , . 2,900, 2 AI .
</HeroSubtitle> </HeroSubtitle>
<BillingToggle aria-label="결제 주기 선택"> <BillingToggle aria-label="결제 주기 선택">
@@ -397,7 +393,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
> >
<DiscountBadge $visible={isYearly || billing === 'monthly'}> <DiscountBadge $visible={isYearly || billing === 'monthly'}>
2 30%
</DiscountBadge> </DiscountBadge>
</ToggleButton> </ToggleButton>
</BillingToggle> </BillingToggle>
@@ -408,8 +404,7 @@ function PricingContent({ appMode, me, meLoading, billingToast }: PricingContent
<SectionEyebrow id="pricing-heading">Pricing</SectionEyebrow> <SectionEyebrow id="pricing-heading">Pricing</SectionEyebrow>
<SectionTitle> , </SectionTitle> <SectionTitle> , </SectionTitle>
<SectionBody> <SectionBody>
, . Pro와 School은 , . 30% .
2 .
</SectionBody> </SectionBody>
</SectionHeader> </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() { function handleLogout() {
clearToken(); clearToken();
router.replace('/login'); router.replace('/login');
@@ -649,17 +665,39 @@ function ProfileBody() {
<Icon name="crown" size={18} /> <Icon name="crown" size={18} />
</PlanManagementLabel> </PlanManagementLabel>
<PlanManagementTitle> </PlanManagementTitle> <PlanManagementTitle> </PlanManagementTitle>
<PlanManagementDesc> <PlanManagementDesc>
`/pricing` Free / Pro / School을 .
.
</PlanManagementDesc> </PlanManagementDesc>
</PlanManagementMeta> </PlanManagementMeta>
<PlanManagementActions> <PlanManagementActions>
<PlanTierBadge>{planLabel(user.subscriptionTier)}</PlanTierBadge> <TierSwitcher>
<Button as={Link} href="/pricing" $variant="secondary"> <TierSwitchBtn
type="button"
</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> </PlanManagementActions>
</PlanManagementBody> </PlanManagementBody>
</PlanManagementCard> </PlanManagementCard>
@@ -1336,3 +1374,37 @@ const AlgoDesc = styled.p`
font-weight: 600; 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;
}
`;