refactor(control-center): Quick Controls 재디자인 및 플랜/잠금 결제 동선 정리
This commit is contained in:
237
src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx
Normal file
237
src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import {
|
||||
PRO_LOCKED_ROOM_IDS,
|
||||
PRO_LOCKED_SOUND_IDS,
|
||||
PRO_LOCKED_TIMER_LABELS,
|
||||
} from '@/entities/plan';
|
||||
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
||||
import type { SoundPreset, TimerPreset } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface ControlCenterSheetWidgetProps {
|
||||
plan: PlanTier;
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
timerPresets: TimerPreset[];
|
||||
soundPresets: SoundPreset[];
|
||||
onSelectRoom: (roomId: string) => void;
|
||||
onSelectTimer: (timerLabel: string) => void;
|
||||
onSelectSound: (soundPresetId: string) => void;
|
||||
onApplyPack: (packId: QuickPackId) => void;
|
||||
onLockedClick: (source: string) => void;
|
||||
}
|
||||
|
||||
type QuickPackId = 'balanced' | 'deep-work' | 'gentle';
|
||||
|
||||
interface QuickPack {
|
||||
id: QuickPackId;
|
||||
name: string;
|
||||
combo: string;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
const QUICK_PACKS: QuickPack[] = [
|
||||
{
|
||||
id: 'balanced',
|
||||
name: 'Balanced',
|
||||
combo: '25/5 + Rain Focus',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'deep-work',
|
||||
name: 'Deep Work',
|
||||
combo: '50/10 + Deep White',
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
id: 'gentle',
|
||||
name: 'Gentle',
|
||||
combo: '25/5 + Silent',
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<h4 className="text-sm font-semibold text-white/90">{title}</h4>
|
||||
<p className="text-[11px] text-white/56">{description}</p>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export const ControlCenterSheetWidget = ({
|
||||
plan,
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
timerPresets,
|
||||
soundPresets,
|
||||
onSelectRoom,
|
||||
onSelectTimer,
|
||||
onSelectSound,
|
||||
onApplyPack,
|
||||
onLockedClick,
|
||||
}: ControlCenterSheetWidgetProps) => {
|
||||
const isPro = plan === 'pro';
|
||||
|
||||
const selectedRoom = useMemo(() => {
|
||||
return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0];
|
||||
}, [rooms, selectedRoomId]);
|
||||
|
||||
const selectedSound = useMemo(() => {
|
||||
return soundPresets.find((preset) => preset.id === selectedSoundPresetId) ?? soundPresets[0];
|
||||
}, [selectedSoundPresetId, soundPresets]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||
<SectionTitle title="Scene" description={selectedRoom?.name ?? '공간'} />
|
||||
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||
{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(
|
||||
'relative h-24 w-[130px] shrink-0 overflow-hidden rounded-xl border text-left transition-transform 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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||
<SectionTitle title="Time" description={selectedTimerLabel} />
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{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(
|
||||
'relative rounded-xl border px-2 py-2 text-xs transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
||||
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||
<SectionTitle title="Sound" description={selectedSound?.label ?? '기본'} />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{soundPresets.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(
|
||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
||||
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
{locked ? <span className="ml-1.5 text-[10px] text-white/66">LOCK PRO</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||
<SectionTitle title="Preset Packs" description="원탭 조합" />
|
||||
<div className="grid gap-1.5">
|
||||
{QUICK_PACKS.map((pack) => {
|
||||
const locked = !isPro && pack.locked;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pack.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (locked) {
|
||||
onLockedClick(`프리셋 팩: ${pack.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
onApplyPack(pack.id);
|
||||
}}
|
||||
className="relative rounded-xl border border-white/16 bg-white/[0.04] px-3 py-2 text-left transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
{locked ? <LockBadge /> : null}
|
||||
<p className="text-sm font-medium text-white/90">{pack.name}</p>
|
||||
<p className="mt-0.5 text-[11px] text-white/62">{pack.combo}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user