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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user