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:
@@ -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_TIMER_LABELS: string[] = [];
|
||||||
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
||||||
|
|
||||||
export const PRO_PRESET_PACKS: PlanLockedPack[] = [
|
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
|
||||||
{
|
{
|
||||||
id: 'deep-work',
|
id: 'scene-packs',
|
||||||
name: 'Deep Work',
|
name: 'Scene Packs',
|
||||||
description: '긴 몰입 세션을 위한 무드 묶음',
|
description: '프리미엄 공간 묶음과 장면 변주',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gentle',
|
id: 'sound-packs',
|
||||||
name: 'Gentle',
|
name: 'Sound Packs',
|
||||||
description: '저자극 휴식 중심 프리셋',
|
description: '확장 사운드 프리셋 묶음',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cafe',
|
id: 'profiles',
|
||||||
name: 'Cafe',
|
name: 'Profiles',
|
||||||
description: '카페톤 배경과 사운드 조합',
|
description: '내 기본 세팅 저장/불러오기',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ export interface PlanLockedPack {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
|
||||||
|
|
||||||
|
export interface ProFeatureCard {
|
||||||
|
id: ProFeatureId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { PlanTier } from '@/entities/plan';
|
import type { PlanTier } from '@/entities/plan';
|
||||||
import {
|
import {
|
||||||
PRO_LOCKED_ROOM_IDS,
|
PRO_FEATURE_CARDS,
|
||||||
PRO_LOCKED_SOUND_IDS,
|
|
||||||
PRO_LOCKED_TIMER_LABELS,
|
|
||||||
} from '@/entities/plan';
|
} from '@/entities/plan';
|
||||||
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
||||||
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
||||||
@@ -27,18 +25,11 @@ interface ControlCenterSheetWidgetProps {
|
|||||||
onSelectRoom: (roomId: string) => void;
|
onSelectRoom: (roomId: string) => void;
|
||||||
onSelectTimer: (timerLabel: string) => void;
|
onSelectTimer: (timerLabel: string) => void;
|
||||||
onSelectSound: (presetId: string) => void;
|
onSelectSound: (presetId: string) => void;
|
||||||
|
onSelectProFeature: (featureId: string) => void;
|
||||||
onLockedClick: (source: string) => void;
|
onLockedClick: (source: string) => void;
|
||||||
onResetToRecommended: () => 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 }) => {
|
const SectionTitle = ({ title, description }: { title: string; description: string }) => {
|
||||||
return (
|
return (
|
||||||
<header className="flex items-end justify-between gap-2">
|
<header className="flex items-end justify-between gap-2">
|
||||||
@@ -62,6 +53,7 @@ export const ControlCenterSheetWidget = ({
|
|||||||
onSelectRoom,
|
onSelectRoom,
|
||||||
onSelectTimer,
|
onSelectTimer,
|
||||||
onSelectSound,
|
onSelectSound,
|
||||||
|
onSelectProFeature,
|
||||||
onLockedClick,
|
onLockedClick,
|
||||||
onResetToRecommended,
|
onResetToRecommended,
|
||||||
}: ControlCenterSheetWidgetProps) => {
|
}: ControlCenterSheetWidgetProps) => {
|
||||||
@@ -91,18 +83,12 @@ export const ControlCenterSheetWidget = ({
|
|||||||
>
|
>
|
||||||
{rooms.slice(0, 6).map((room) => {
|
{rooms.slice(0, 6).map((room) => {
|
||||||
const selected = room.id === selectedRoomId;
|
const selected = room.id === selectedRoomId;
|
||||||
const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={room.id}
|
key={room.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (locked) {
|
|
||||||
onLockedClick(`공간: ${room.name}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectRoom(room.id);
|
onSelectRoom(room.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -111,13 +97,12 @@ export const ControlCenterSheetWidget = ({
|
|||||||
reducedMotion ? '' : 'hover:-translate-y-0.5',
|
reducedMotion ? '' : 'hover:-translate-y-0.5',
|
||||||
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
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-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 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">
|
||||||
<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-sm font-medium text-white/90">{room.name}</p>
|
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
|
||||||
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -130,18 +115,12 @@ export const ControlCenterSheetWidget = ({
|
|||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{timerPresets.slice(0, 3).map((preset) => {
|
{timerPresets.slice(0, 3).map((preset) => {
|
||||||
const selected = preset.label === selectedTimerLabel;
|
const selected = preset.label === selectedTimerLabel;
|
||||||
const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (locked) {
|
|
||||||
onLockedClick(`타이머: ${preset.label}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectTimer(preset.label);
|
onSelectTimer(preset.label);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -153,7 +132,6 @@ export const ControlCenterSheetWidget = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -168,18 +146,12 @@ export const ControlCenterSheetWidget = ({
|
|||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{SOUND_PRESETS.slice(0, 6).map((preset) => {
|
{SOUND_PRESETS.slice(0, 6).map((preset) => {
|
||||||
const selected = preset.id === selectedSoundPresetId;
|
const selected = preset.id === selectedSoundPresetId;
|
||||||
const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (locked) {
|
|
||||||
onLockedClick(`사운드: ${preset.label}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelectSound(preset.id);
|
onSelectSound(preset.id);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -191,7 +163,6 @@ export const ControlCenterSheetWidget = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -212,6 +183,37 @@ export const ControlCenterSheetWidget = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -311,6 +311,17 @@ export const SpaceToolsDockWidget = ({
|
|||||||
openUtilityPanel('paywall');
|
openUtilityPanel('paywall');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectProFeature = (featureId: string) => {
|
||||||
|
const label =
|
||||||
|
featureId === 'scene-packs'
|
||||||
|
? 'Scene Packs'
|
||||||
|
: featureId === 'sound-packs'
|
||||||
|
? 'Sound Packs'
|
||||||
|
: 'Profiles';
|
||||||
|
|
||||||
|
onStatusMessage({ message: `${label} 준비 중(더미)` });
|
||||||
|
};
|
||||||
|
|
||||||
const handleStartPro = () => {
|
const handleStartPro = () => {
|
||||||
setPlan('pro');
|
setPlan('pro');
|
||||||
onStatusMessage({ message: '결제(더미)' });
|
onStatusMessage({ message: '결제(더미)' });
|
||||||
@@ -502,6 +513,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
onTimerSelect(label);
|
onTimerSelect(label);
|
||||||
}}
|
}}
|
||||||
onSelectSound={onQuickSoundSelect}
|
onSelectSound={onQuickSoundSelect}
|
||||||
|
onSelectProFeature={handleSelectProFeature}
|
||||||
onLockedClick={handleLockedClick}
|
onLockedClick={handleLockedClick}
|
||||||
onResetToRecommended={onResetToSceneRecommended}
|
onResetToRecommended={onResetToSceneRecommended}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user