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:
@@ -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);
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user