diff --git a/src/entities/plan/index.ts b/src/entities/plan/index.ts
new file mode 100644
index 0000000..f39caef
--- /dev/null
+++ b/src/entities/plan/index.ts
@@ -0,0 +1,2 @@
+export * from './model/mockPlan';
+export * from './model/types';
diff --git a/src/entities/plan/model/mockPlan.ts b/src/entities/plan/model/mockPlan.ts
new file mode 100644
index 0000000..4b350b7
--- /dev/null
+++ b/src/entities/plan/model/mockPlan.ts
@@ -0,0 +1,23 @@
+import type { PlanLockedPack } from './types';
+
+export const PRO_LOCKED_ROOM_IDS = ['outer-space', 'snow-mountain'];
+export const PRO_LOCKED_TIMER_LABELS = ['90/20'];
+export const PRO_LOCKED_SOUND_IDS = ['cafe-work', 'fireplace'];
+
+export const PRO_PRESET_PACKS: PlanLockedPack[] = [
+ {
+ id: 'deep-work',
+ name: 'Deep Work',
+ description: '긴 몰입 세션을 위한 무드 묶음',
+ },
+ {
+ id: 'gentle',
+ name: 'Gentle',
+ description: '저자극 휴식 중심 프리셋',
+ },
+ {
+ id: 'cafe',
+ name: 'Cafe',
+ description: '카페톤 배경과 사운드 조합',
+ },
+];
diff --git a/src/entities/plan/model/types.ts b/src/entities/plan/model/types.ts
new file mode 100644
index 0000000..3540455
--- /dev/null
+++ b/src/entities/plan/model/types.ts
@@ -0,0 +1,7 @@
+export type PlanTier = 'normal' | 'pro';
+
+export interface PlanLockedPack {
+ id: string;
+ name: string;
+ description: string;
+}
diff --git a/src/entities/session/model/types.ts b/src/entities/session/model/types.ts
index e405067..16de486 100644
--- a/src/entities/session/model/types.ts
+++ b/src/entities/session/model/types.ts
@@ -38,4 +38,5 @@ export interface RecentThought {
text: string;
roomName: string;
capturedAt: string;
+ isCompleted?: boolean;
}
diff --git a/src/entities/session/model/useThoughtInbox.ts b/src/entities/session/model/useThoughtInbox.ts
index 30f81b8..e3090e1 100644
--- a/src/entities/session/model/useThoughtInbox.ts
+++ b/src/entities/session/model/useThoughtInbox.ts
@@ -30,7 +30,8 @@ const readStoredThoughts = () => {
typeof thought.id === 'string' &&
typeof thought.text === 'string' &&
typeof thought.roomName === 'string' &&
- typeof thought.capturedAt === 'string'
+ typeof thought.capturedAt === 'string' &&
+ (typeof thought.isCompleted === 'undefined' || typeof thought.isCompleted === 'boolean')
);
});
} catch {
@@ -73,26 +74,89 @@ export const useThoughtInbox = () => {
const trimmedText = text.trim();
if (!trimmedText) {
- return;
+ return null;
}
+ const thought: RecentThought = {
+ id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
+ text: trimmedText,
+ roomName,
+ capturedAt: '방금 전',
+ isCompleted: false,
+ };
+
setThoughts((current) => {
- const next: RecentThought[] = [
- {
- id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
- text: trimmedText,
- roomName,
- capturedAt: '방금 전',
- },
- ...current,
- ].slice(0, MAX_THOUGHT_INBOX_ITEMS);
+ const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
return next;
});
+
+ return thought;
+ }, []);
+
+ const removeThought = useCallback((thoughtId: string) => {
+ let removedThought: RecentThought | null = null;
+
+ setThoughts((current) => {
+ const target = current.find((thought) => thought.id === thoughtId);
+
+ if (!target) {
+ return current;
+ }
+
+ removedThought = target;
+
+ return current.filter((thought) => thought.id !== thoughtId);
+ });
+
+ return removedThought;
}, []);
const clearThoughts = useCallback(() => {
- setThoughts([]);
+ let snapshot: RecentThought[] = [];
+
+ setThoughts((current) => {
+ snapshot = current;
+ return [];
+ });
+
+ return snapshot;
+ }, []);
+
+ const restoreThought = useCallback((thought: RecentThought) => {
+ setThoughts((current) => {
+ const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
+ return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS);
+ });
+ }, []);
+
+ const restoreThoughts = useCallback((snapshot: RecentThought[]) => {
+ setThoughts(() => {
+ return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS);
+ });
+ }, []);
+
+ const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => {
+ let previousThought: RecentThought | null = null;
+
+ setThoughts((current) => {
+ const targetIndex = current.findIndex((thought) => thought.id === thoughtId);
+
+ if (targetIndex < 0) {
+ return current;
+ }
+
+ previousThought = current[targetIndex];
+ const next = [...current];
+ next[targetIndex] = {
+ ...current[targetIndex],
+ isCompleted,
+ };
+
+ return next;
+ });
+
+ return previousThought;
}, []);
const recentThoughts = useMemo(() => thoughts.slice(0, 3), [thoughts]);
@@ -102,6 +166,10 @@ export const useThoughtInbox = () => {
recentThoughts,
thoughtCount: thoughts.length,
addThought,
+ removeThought,
clearThoughts,
+ restoreThought,
+ restoreThoughts,
+ setThoughtCompleted,
};
};
diff --git a/src/features/inbox/ui/InboxList.tsx b/src/features/inbox/ui/InboxList.tsx
index e5c67cd..38458f4 100644
--- a/src/features/inbox/ui/InboxList.tsx
+++ b/src/features/inbox/ui/InboxList.tsx
@@ -3,10 +3,12 @@ import { cn } from '@/shared/lib/cn';
interface InboxListProps {
thoughts: RecentThought[];
+ onCompleteThought: (thought: RecentThought) => void;
+ onDeleteThought: (thought: RecentThought) => void;
className?: string;
}
-export const InboxList = ({ thoughts, className }: InboxListProps) => {
+export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, className }: InboxListProps) => {
if (thoughts.length === 0) {
return (
{
{thoughts.slice(0, 10).map((thought) => (
- {thought.text}
+
+ {thought.text}
+
{thought.roomName} · {thought.capturedAt}
+
+
+
+
))}
diff --git a/src/features/paywall-sheet/index.ts b/src/features/paywall-sheet/index.ts
new file mode 100644
index 0000000..53dd2ab
--- /dev/null
+++ b/src/features/paywall-sheet/index.ts
@@ -0,0 +1,2 @@
+export * from './ui/ManagePlanSheetContent';
+export * from './ui/PaywallSheetContent';
diff --git a/src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx b/src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx
new file mode 100644
index 0000000..25e846a
--- /dev/null
+++ b/src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx
@@ -0,0 +1,47 @@
+interface ManagePlanSheetContentProps {
+ onClose: () => void;
+ onManage: () => void;
+ onRestore: () => void;
+}
+
+export const ManagePlanSheetContent = ({
+ onClose,
+ onManage,
+ onRestore,
+}: ManagePlanSheetContentProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/features/paywall-sheet/ui/PaywallSheetContent.tsx b/src/features/paywall-sheet/ui/PaywallSheetContent.tsx
new file mode 100644
index 0000000..81927c3
--- /dev/null
+++ b/src/features/paywall-sheet/ui/PaywallSheetContent.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { useState } from 'react';
+import { cn } from '@/shared/lib/cn';
+
+interface PaywallSheetContentProps {
+ onStartPro: () => void;
+ onClose: () => void;
+}
+
+type BillingCycle = 'monthly' | 'yearly';
+
+const BILLING_OPTIONS: Array<{ id: BillingCycle; label: string; caption: string }> = [
+ { id: 'monthly', label: '월간', caption: '월 9,900원 (더미)' },
+ { id: 'yearly', label: '연간', caption: '연 79,000원 (더미)' },
+];
+
+const VALUE_POINTS = [
+ '더 많은 공간 / 고화질 배경',
+ '작업용 BGM / 사운드 확장',
+ '프리셋 팩 / 고급 타이머',
+];
+
+export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
+ const [cycle, setCycle] = useState('monthly');
+
+ return (
+
+
+
+
+ {VALUE_POINTS.map((point) => (
+ -
+ {point}
+
+ ))}
+
+
+
+ 가격
+
+ {BILLING_OPTIONS.map((option) => {
+ const selected = option.id === cycle;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/features/plan-pill/index.ts b/src/features/plan-pill/index.ts
new file mode 100644
index 0000000..e81475b
--- /dev/null
+++ b/src/features/plan-pill/index.ts
@@ -0,0 +1 @@
+export * from './ui/PlanPill';
diff --git a/src/features/plan-pill/ui/PlanPill.tsx b/src/features/plan-pill/ui/PlanPill.tsx
new file mode 100644
index 0000000..55e23ae
--- /dev/null
+++ b/src/features/plan-pill/ui/PlanPill.tsx
@@ -0,0 +1,26 @@
+import type { PlanTier } from '@/entities/plan';
+import { cn } from '@/shared/lib/cn';
+
+interface PlanPillProps {
+ plan: PlanTier;
+ onClick: () => void;
+}
+
+export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
+ const isPro = plan === 'pro';
+
+ return (
+
+ );
+};
diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx
index de85d8b..3234c8e 100644
--- a/src/shared/ui/Toast.tsx
+++ b/src/shared/ui/Toast.tsx
@@ -7,6 +7,11 @@ import { cn } from '@/shared/lib/cn';
interface ToastPayload {
title: string;
description?: string;
+ durationMs?: number;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
}
interface ToastItem extends ToastPayload {
@@ -22,15 +27,20 @@ const ToastContext = createContext(null);
export const ToastProvider = ({ children }: { children: ReactNode }) => {
const [toasts, setToasts] = useState([]);
+ const removeToast = useCallback((id: number) => {
+ setToasts((current) => current.filter((toast) => toast.id !== id));
+ }, []);
+
const pushToast = useCallback((payload: ToastPayload) => {
const id = Date.now() + Math.floor(Math.random() * 10000);
+ const durationMs = payload.durationMs ?? 2400;
setToasts((current) => [...current, { id, ...payload }]);
window.setTimeout(() => {
- setToasts((current) => current.filter((toast) => toast.id !== id));
- }, 2400);
- }, []);
+ removeToast(id);
+ }, durationMs);
+ }, [removeToast]);
const value = useMemo(() => ({ pushToast }), [pushToast]);
@@ -42,7 +52,7 @@ export const ToastProvider = ({ children }: { children: ReactNode }) => {
@@ -50,6 +60,20 @@ export const ToastProvider = ({ children }: { children: ReactNode }) => {
{toast.description ? (
{toast.description}
) : null}
+ {toast.action ? (
+
+
+
+ ) : null}
))}
diff --git a/src/widgets/control-center-sheet/index.ts b/src/widgets/control-center-sheet/index.ts
new file mode 100644
index 0000000..feb4811
--- /dev/null
+++ b/src/widgets/control-center-sheet/index.ts
@@ -0,0 +1 @@
+export * from './ui/ControlCenterSheetWidget';
diff --git a/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx b/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx
new file mode 100644
index 0000000..8614c6d
--- /dev/null
+++ b/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx
@@ -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 (
+
+ LOCK PRO
+
+ );
+};
+
+const SectionTitle = ({ title, description }: { title: string; description: string }) => {
+ return (
+
+ {title}
+ {description}
+
+ );
+};
+
+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 (
+
+
+
+
+ {rooms.slice(0, 6).map((room) => {
+ const selected = room.id === selectedRoomId;
+ const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ {timerPresets.slice(0, 3).map((preset) => {
+ const selected = preset.label === selectedTimerLabel;
+ const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ {soundPresets.slice(0, 6).map((preset) => {
+ const selected = preset.id === selectedSoundPresetId;
+ const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ {QUICK_PACKS.map((pack) => {
+ const locked = !isPro && pack.locked;
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx b/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx
index 7c7977a..32d4d2c 100644
--- a/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx
+++ b/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx
@@ -14,6 +14,7 @@ interface SpaceSideSheetProps {
footer?: ReactNode;
widthClassName?: string;
dismissible?: boolean;
+ headerAction?: ReactNode;
}
export const SpaceSideSheet = ({
@@ -25,6 +26,7 @@ export const SpaceSideSheet = ({
footer,
widthClassName,
dismissible = true,
+ headerAction,
}: SpaceSideSheetProps) => {
const closeTimerRef = useRef(null);
const [shouldRender, setShouldRender] = useState(open);
@@ -123,16 +125,19 @@ export const SpaceSideSheet = ({
{title}
{subtitle ? {subtitle}
: null}
- {dismissible ? (
-
- ) : null}
+
+ {headerAction}
+ {dismissible ? (
+
+ ) : null}
+
{children}
diff --git a/src/widgets/space-tools-dock/model/applyQuickPack.ts b/src/widgets/space-tools-dock/model/applyQuickPack.ts
new file mode 100644
index 0000000..a7a2805
--- /dev/null
+++ b/src/widgets/space-tools-dock/model/applyQuickPack.ts
@@ -0,0 +1,31 @@
+interface ApplyQuickPackParams {
+ packId: 'balanced' | 'deep-work' | 'gentle';
+ onTimerSelect: (timerLabel: string) => void;
+ onSelectPreset: (presetId: string) => void;
+ pushToast: (payload: { title: string; description?: string }) => void;
+}
+
+export const applyQuickPack = ({
+ packId,
+ onTimerSelect,
+ onSelectPreset,
+ pushToast,
+}: ApplyQuickPackParams) => {
+ if (packId === 'balanced') {
+ onTimerSelect('25/5');
+ onSelectPreset('rain-focus');
+ pushToast({ title: 'Balanced 팩을 적용했어요.' });
+ return;
+ }
+
+ if (packId === 'deep-work') {
+ onTimerSelect('50/10');
+ onSelectPreset('deep-white');
+ pushToast({ title: 'Deep Work 팩을 적용했어요.' });
+ return;
+ }
+
+ onTimerSelect('25/5');
+ onSelectPreset('silent');
+ pushToast({ title: 'Gentle 팩을 적용했어요.' });
+};
diff --git a/src/widgets/space-tools-dock/model/types.ts b/src/widgets/space-tools-dock/model/types.ts
index 4f0636d..1afbd32 100644
--- a/src/widgets/space-tools-dock/model/types.ts
+++ b/src/widgets/space-tools-dock/model/types.ts
@@ -1,2 +1,2 @@
export type SpaceAnchorPopoverId = 'sound' | 'notes';
-export type SpaceUtilityPanelId = 'settings' | 'inbox' | 'stats';
+export type SpaceUtilityPanelId = 'control-center' | 'inbox' | 'manage-plan' | 'paywall';
diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
index be0df6e..958c165 100644
--- a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
+++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
@@ -1,17 +1,19 @@
'use client';
-
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
+import type { PlanTier } from '@/entities/plan';
import type { RoomTheme } from '@/entities/room';
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
import { ExitHoldButton } from '@/features/exit-hold';
+import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
+import { PlanPill } from '@/features/plan-pill';
import { useToast } from '@/shared/ui';
import { cn } from '@/shared/lib/cn';
+import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
+import { applyQuickPack } from '../model/applyQuickPack';
+import { ANCHOR_ICON, formatThoughtCount, RAIL_ICON, UTILITY_PANEL_TITLE } from './constants';
import { InboxToolPanel } from './panels/InboxToolPanel';
-import { SettingsToolPanel } from './panels/SettingsToolPanel';
-import { StatsToolPanel } from './panels/StatsToolPanel';
-
interface SpaceToolsDockWidgetProps {
isFocusMode: boolean;
rooms: RoomTheme[];
@@ -24,61 +26,15 @@ interface SpaceToolsDockWidgetProps {
onRoomSelect: (roomId: string) => void;
onTimerSelect: (timerLabel: string) => void;
onSelectPreset: (presetId: string) => void;
- onCaptureThought: (note: string) => void;
- onClearInbox: () => void;
+ onCaptureThought: (note: string) => RecentThought | null;
+ onDeleteThought: (thoughtId: string) => RecentThought | null;
+ onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
+ onRestoreThought: (thought: RecentThought) => void;
+ onRestoreThoughts: (thoughts: RecentThought[]) => void;
+ onClearInbox: () => RecentThought[];
onExitRequested: () => void;
}
-const ANCHOR_ICON = {
- sound: (
-
- ),
- notes: (
-
- ),
-};
-
-const UTILITY_PANEL_TITLE: Record = {
- inbox: '인박스',
- stats: '집중 요약',
- settings: '설정',
-};
-
-const formatThoughtCount = (count: number) => {
- if (count < 1) {
- return '0';
- }
-
- if (count > 9) {
- return '9+';
- }
-
- return String(count);
-};
-
export const SpaceToolsDockWidget = ({
isFocusMode,
rooms,
@@ -92,6 +48,10 @@ export const SpaceToolsDockWidget = ({
onTimerSelect,
onSelectPreset,
onCaptureThought,
+ onDeleteThought,
+ onSetThoughtCompleted,
+ onRestoreThought,
+ onRestoreThoughts,
onClearInbox,
onExitRequested,
}: SpaceToolsDockWidgetProps) => {
@@ -99,6 +59,7 @@ export const SpaceToolsDockWidget = ({
const [openPopover, setOpenPopover] = useState(null);
const [utilityPanel, setUtilityPanel] = useState(null);
const [noteDraft, setNoteDraft] = useState('');
+ const [plan, setPlan] = useState('normal');
const [isIdle, setIdle] = useState(false);
const selectedSoundLabel = useMemo(() => {
@@ -112,7 +73,7 @@ export const SpaceToolsDockWidget = ({
return;
}
- const handleEscape = (event: KeyboardEvent) => {
+ const handleEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenPopover(null);
}
@@ -176,11 +137,126 @@ export const SpaceToolsDockWidget = ({
return;
}
- onCaptureThought(trimmedNote);
+ const addedThought = onCaptureThought(trimmedNote);
+
+ if (!addedThought) {
+ return;
+ }
+
setNoteDraft('');
- pushToast({ title: '메모를 잠깐 주차했어요.' });
+ pushToast({
+ title: '인박스에 저장됨',
+ durationMs: 4200,
+ action: {
+ label: '실행취소',
+ onClick: () => {
+ const removed = onDeleteThought(addedThought.id);
+
+ if (!removed) {
+ return;
+ }
+
+ pushToast({ title: '저장 취소됨' });
+ },
+ },
+ });
};
+ const handleNoteKeyDown = (event: ReactKeyboardEvent) => {
+ if (event.key !== 'Enter') {
+ return;
+ }
+
+ event.preventDefault();
+ handleNoteSubmit();
+ };
+
+ const handleInboxComplete = (thought: RecentThought) => {
+ const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
+
+ if (!previousThought) {
+ return;
+ }
+
+ const willBeCompleted = !thought.isCompleted;
+
+ pushToast({
+ title: willBeCompleted ? '완료 처리됨' : '완료 해제됨',
+ durationMs: 4200,
+ action: {
+ label: '실행취소',
+ onClick: () => {
+ onRestoreThought(previousThought);
+ pushToast({ title: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' });
+ },
+ },
+ });
+ };
+
+ const handleInboxDelete = (thought: RecentThought) => {
+ const removedThought = onDeleteThought(thought.id);
+
+ if (!removedThought) {
+ return;
+ }
+
+ pushToast({
+ title: '삭제됨',
+ durationMs: 4200,
+ action: {
+ label: '실행취소',
+ onClick: () => {
+ onRestoreThought(removedThought);
+ pushToast({ title: '삭제를 취소했어요.' });
+ },
+ },
+ });
+ };
+
+ const handleInboxClear = () => {
+ const snapshot = onClearInbox();
+
+ if (snapshot.length === 0) {
+ pushToast({ title: '인박스가 비어 있어요.' });
+ return;
+ }
+
+ pushToast({
+ title: '모두 비워짐',
+ durationMs: 4200,
+ action: {
+ label: '실행취소',
+ onClick: () => {
+ onRestoreThoughts(snapshot);
+ pushToast({ title: '인박스를 복구했어요.' });
+ },
+ },
+ });
+ };
+
+ const handlePlanPillClick = () => {
+ if (plan === 'pro') {
+ openUtilityPanel('manage-plan');
+ return;
+ }
+
+ openUtilityPanel('paywall');
+ };
+
+ const handleLockedClick = (source: string) => {
+ pushToast({ title: `${source}은(는) PRO 기능이에요.` });
+ openUtilityPanel('paywall');
+ };
+
+ const handleStartPro = () => {
+ setPlan('pro');
+ pushToast({ title: '결제(더미)' });
+ openUtilityPanel('control-center');
+ };
+
+ const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') =>
+ applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast });
+
return (
<>
{openPopover ? (
@@ -206,6 +282,41 @@ export const SpaceToolsDockWidget = ({
{isFocusMode ? (
<>
+
+
+
+
+
+
+
+
+
setNoteDraft(event.target.value)}
- placeholder="한 줄 메모"
+ onKeyDown={handleNoteKeyDown}
+ placeholder="떠오른 생각을 잠깐 주차…"
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
/>
-
- {thoughts.slice(0, 3).map((thought) => (
- -
- {thought.text}
-
- ))}
- {thoughts.length === 0 ? (
- -
- 아직 메모가 없어요.
-
- ) : null}
-
-
-
-
-
-
+ 나중에 인박스에서 정리해요.
) : null}
@@ -345,10 +420,10 @@ export const SpaceToolsDockWidget = ({
@@ -361,25 +436,23 @@ export const SpaceToolsDockWidget = ({
+ ) : undefined
+ }
onClose={() => setUtilityPanel(null)}
>
- {utilityPanel === 'inbox' ? (
- {
- onClearInbox();
- pushToast({ title: '인박스를 비웠어요 (더미)' });
- }}
- />
- ) : null}
-
- {utilityPanel === 'stats' ? : null}
- {utilityPanel === 'settings' ? (
- {
onRoomSelect(roomId);
pushToast({ title: '공간을 바꿨어요.' });
@@ -388,6 +461,36 @@ export const SpaceToolsDockWidget = ({
onTimerSelect(label);
pushToast({ title: `타이머를 ${label}로 바꿨어요.` });
}}
+ onSelectSound={(presetId) => {
+ onSelectPreset(presetId);
+ pushToast({ title: '사운드를 바꿨어요.' });
+ }}
+ onApplyPack={handleApplyPack}
+ onLockedClick={handleLockedClick}
+ />
+ ) : null}
+
+ {utilityPanel === 'inbox' ? (
+
+ ) : null}
+
+ {utilityPanel === 'paywall' ? (
+ setUtilityPanel(null)}
+ />
+ ) : null}
+
+ {utilityPanel === 'manage-plan' ? (
+ setUtilityPanel(null)}
+ onManage={() => pushToast({ title: '구독 관리(더미)' })}
+ onRestore={() => pushToast({ title: '구매 복원(더미)' })}
/>
) : null}
diff --git a/src/widgets/space-tools-dock/ui/constants.tsx b/src/widgets/space-tools-dock/ui/constants.tsx
new file mode 100644
index 0000000..1f2e65a
--- /dev/null
+++ b/src/widgets/space-tools-dock/ui/constants.tsx
@@ -0,0 +1,85 @@
+import type { SpaceUtilityPanelId } from '../model/types';
+
+export const ANCHOR_ICON = {
+ sound: (
+
+ ),
+ notes: (
+
+ ),
+};
+
+export const RAIL_ICON = {
+ controlCenter: (
+
+ ),
+ inbox: (
+
+ ),
+};
+
+export const UTILITY_PANEL_TITLE: Record = {
+ 'control-center': 'Quick Controls',
+ inbox: '인박스',
+ paywall: 'PRO',
+ 'manage-plan': '플랜 관리',
+};
+
+export const formatThoughtCount = (count: number) => {
+ if (count < 1) {
+ return '0';
+ }
+
+ if (count > 9) {
+ return '9+';
+ }
+
+ return String(count);
+};
diff --git a/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx
index 9a448eb..986e580 100644
--- a/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx
+++ b/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx
@@ -1,25 +1,67 @@
+import { useState } from 'react';
import type { RecentThought } from '@/entities/session';
import { InboxList } from '@/features/inbox';
interface InboxToolPanelProps {
thoughts: RecentThought[];
+ onCompleteThought: (thought: RecentThought) => void;
+ onDeleteThought: (thought: RecentThought) => void;
onClear: () => void;
}
-export const InboxToolPanel = ({ thoughts, onClear }: InboxToolPanelProps) => {
+export const InboxToolPanel = ({
+ thoughts,
+ onCompleteThought,
+ onDeleteThought,
+ onClear,
+}: InboxToolPanelProps) => {
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
return (
-
+
나중에 모아보는 읽기 전용 인박스
-
+
+
+ {confirmOpen ? (
+
+
+
정말 인박스를 비울까요?
+
실수라면 토스트에서 실행취소할 수 있어요.
+
+
+
+
+
+
+ ) : null}
);
};
diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx
index bfd8dcd..a41cc24 100644
--- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx
+++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx
@@ -55,7 +55,16 @@ const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => {
export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const { pushToast } = useToast();
- const { thoughts, thoughtCount, addThought, clearThoughts } = useThoughtInbox();
+ const {
+ thoughts,
+ thoughtCount,
+ addThought,
+ removeThought,
+ clearThoughts,
+ restoreThought,
+ restoreThoughts,
+ setThoughtCompleted,
+ } = useThoughtInbox();
const initialRoomId = resolveInitialRoomId(searchParams.get('room'));
const initialGoal = searchParams.get('goal')?.trim() ?? '';
@@ -204,6 +213,10 @@ export const SpaceWorkspaceWidget = () => {
onTimerSelect={setSelectedTimerLabel}
onSelectPreset={setSelectedPresetId}
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
+ onDeleteThought={removeThought}
+ onSetThoughtCompleted={setThoughtCompleted}
+ onRestoreThought={restoreThought}
+ onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts}
onExitRequested={handleExitRequested}
/>