feat(pro): Pro 잠금 대상을 Packs/Profiles로 재구성

맥락:
- Pro 가치는 기본 기능 잠금이 아니라 확장/개인화 영역에서 명확히 보여야 합니다.

변경사항:
- plan 모델에 Pro 기능 카드를 Scene Packs/Sound Packs/Profiles로 재정의했습니다.
- Quick Controls의 기본 Scene/Time/Sound 선택에서 잠금 로직을 제거해 코어 기능을 Free로 유지했습니다.
- Pro 기능 카드를 Control Center 하단 확장 영역으로 이동하고, 잠금 클릭 시 Paywall 트리거 경로를 연결했습니다.

검증:
- npx tsc --noEmit

세션-상태: 기본 조작은 Free, Pro는 확장 카드 기반 잠금 구조로 전환됨
세션-다음: Control Center 하단 영역을 더 조용한 요약 카드 톤으로 다듬고 추천 조합 라인을 비인터랙티브로 정리
세션-리스크: Plan Pill normal 클릭 동선은 paywall 트리거 정책과 추가 정합 조정이 남아 있음
This commit is contained in:
2026-03-05 17:15:51 +09:00
parent 922342b115
commit 3c6c5e6aa0
4 changed files with 72 additions and 50 deletions

View File

@@ -1,23 +1,23 @@
import type { PlanLockedPack } from './types';
import type { ProFeatureCard } from './types';
export const PRO_LOCKED_ROOM_IDS = ['outer-space', 'snow-mountain'];
export const PRO_LOCKED_ROOM_IDS: string[] = [];
export const PRO_LOCKED_TIMER_LABELS: string[] = [];
export const PRO_LOCKED_SOUND_IDS: string[] = [];
export const PRO_PRESET_PACKS: PlanLockedPack[] = [
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
{
id: 'deep-work',
name: 'Deep Work',
description: '긴 몰입 세션을 위한 무드 묶음',
id: 'scene-packs',
name: 'Scene Packs',
description: '프리미엄 공간 묶음과 장면 변주',
},
{
id: 'gentle',
name: 'Gentle',
description: '저자극 휴식 중심 프리셋',
id: 'sound-packs',
name: 'Sound Packs',
description: '확장 사운드 프리셋 묶음',
},
{
id: 'cafe',
name: 'Cafe',
description: '카페톤 배경과 사운드 조합',
id: 'profiles',
name: 'Profiles',
description: '내 기본 세팅 저장/불러오기',
},
];

View File

@@ -5,3 +5,11 @@ export interface PlanLockedPack {
name: string;
description: string;
}
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
export interface ProFeatureCard {
id: ProFeatureId;
name: string;
description: string;
}

View File

@@ -3,9 +3,7 @@
import { useMemo } from 'react';
import type { PlanTier } from '@/entities/plan';
import {
PRO_LOCKED_ROOM_IDS,
PRO_LOCKED_SOUND_IDS,
PRO_LOCKED_TIMER_LABELS,
PRO_FEATURE_CARDS,
} from '@/entities/plan';
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
@@ -27,18 +25,11 @@ interface ControlCenterSheetWidgetProps {
onSelectRoom: (roomId: string) => void;
onSelectTimer: (timerLabel: string) => void;
onSelectSound: (presetId: string) => void;
onSelectProFeature: (featureId: string) => void;
onLockedClick: (source: string) => void;
onResetToRecommended: () => void;
}
const LockBadge = () => {
return (
<span className="absolute right-2 top-2 rounded-full border border-white/20 bg-black/46 px-1.5 py-0.5 text-[9px] font-semibold tracking-[0.08em] text-white/86">
LOCK PRO
</span>
);
};
const SectionTitle = ({ title, description }: { title: string; description: string }) => {
return (
<header className="flex items-end justify-between gap-2">
@@ -62,6 +53,7 @@ export const ControlCenterSheetWidget = ({
onSelectRoom,
onSelectTimer,
onSelectSound,
onSelectProFeature,
onLockedClick,
onResetToRecommended,
}: ControlCenterSheetWidgetProps) => {
@@ -91,18 +83,12 @@ export const ControlCenterSheetWidget = ({
>
{rooms.slice(0, 6).map((room) => {
const selected = room.id === selectedRoomId;
const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
return (
<button
key={room.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`공간: ${room.name}`);
return;
}
onSelectRoom(room.id);
}}
className={cn(
@@ -111,13 +97,12 @@ export const ControlCenterSheetWidget = ({
reducedMotion ? '' : 'hover:-translate-y-0.5',
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
)}
>
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
{locked ? <LockBadge /> : null}
<div className="absolute inset-x-2 bottom-2 min-w-0">
<p className="truncate text-sm font-medium text-white/90">{room.name}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
>
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
<div className="absolute inset-x-2 bottom-2 min-w-0">
<p className="truncate text-sm font-medium text-white/90">{room.name}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
</div>
</button>
);
@@ -130,18 +115,12 @@ export const ControlCenterSheetWidget = ({
<div className="grid grid-cols-3 gap-2">
{timerPresets.slice(0, 3).map((preset) => {
const selected = preset.label === selectedTimerLabel;
const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
return (
<button
key={preset.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`타이머: ${preset.label}`);
return;
}
onSelectTimer(preset.label);
}}
className={cn(
@@ -153,7 +132,6 @@ export const ControlCenterSheetWidget = ({
)}
>
{preset.label}
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
</button>
);
})}
@@ -168,18 +146,12 @@ export const ControlCenterSheetWidget = ({
<div className="grid grid-cols-3 gap-2">
{SOUND_PRESETS.slice(0, 6).map((preset) => {
const selected = preset.id === selectedSoundPresetId;
const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id);
return (
<button
key={preset.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`사운드: ${preset.label}`);
return;
}
onSelectSound(preset.id);
}}
className={cn(
@@ -191,7 +163,6 @@ export const ControlCenterSheetWidget = ({
)}
>
{preset.label}
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
</button>
);
})}
@@ -212,6 +183,37 @@ export const ControlCenterSheetWidget = ({
</button>
</div>
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 p-3 backdrop-blur-md">
<SectionTitle title="Packs" description="확장/개인화" />
<div className="space-y-1.5">
{PRO_FEATURE_CARDS.map((feature) => {
const locked = !isPro;
return (
<button
key={feature.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(feature.name);
return;
}
onSelectProFeature(feature.id);
}}
className="flex w-full items-center justify-between rounded-xl border border-white/14 bg-white/[0.03] px-3 py-2 text-left transition-colors hover:bg-white/[0.08]"
>
<div>
<p className="text-xs font-medium text-white/88">{feature.name}</p>
<p className="mt-0.5 text-[10px] text-white/56">{feature.description}</p>
</div>
{locked ? <span className="text-xs text-white/70">🔒</span> : null}
</button>
);
})}
</div>
</section>
<section className="space-y-2 rounded-2xl border border-white/12 bg-black/18 px-3 py-2.5 backdrop-blur-md">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">

View File

@@ -311,6 +311,17 @@ export const SpaceToolsDockWidget = ({
openUtilityPanel('paywall');
};
const handleSelectProFeature = (featureId: string) => {
const label =
featureId === 'scene-packs'
? 'Scene Packs'
: featureId === 'sound-packs'
? 'Sound Packs'
: 'Profiles';
onStatusMessage({ message: `${label} 준비 중(더미)` });
};
const handleStartPro = () => {
setPlan('pro');
onStatusMessage({ message: '결제(더미)' });
@@ -502,6 +513,7 @@ export const SpaceToolsDockWidget = ({
onTimerSelect(label);
}}
onSelectSound={onQuickSoundSelect}
onSelectProFeature={handleSelectProFeature}
onLockedClick={handleLockedClick}
onResetToRecommended={onResetToSceneRecommended}
/>