feat(app): focus entry surface로 진입 화면 재구성
This commit is contained in:
1
src/widgets/focus-dashboard/index.ts
Normal file
1
src/widgets/focus-dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/FocusDashboardWidget';
|
||||
538
src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
Normal file
538
src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildFocusEntryStartHref,
|
||||
type FocusPlanItem,
|
||||
type FocusPlanToday,
|
||||
useFocusPlan,
|
||||
} from '@/entities/focus-plan';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import { useFocusStats } from '@/features/stats';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
|
||||
|
||||
const FREE_MAX_ITEMS = 1;
|
||||
const PRO_MAX_ITEMS = 5;
|
||||
|
||||
const focusEntryCopy = {
|
||||
eyebrow: 'Focus Entry',
|
||||
title: '지금 시작할 첫 블록',
|
||||
description: '한 줄로 정하고 바로 들어가요.',
|
||||
inputLabel: '첫 블록',
|
||||
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
helper: '아주 작게 잡아도 괜찮아요.',
|
||||
startNow: '지금 시작',
|
||||
manageBlocks: '블록 정리',
|
||||
previewTitle: '이어갈 블록',
|
||||
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
|
||||
reviewLinkLabel: 'stats',
|
||||
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
|
||||
ritualMeta: '기본 ritual로 들어가요. 배경과 타이머는 /space에서 이어서 바꿀 수 있어요.',
|
||||
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
|
||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||
paywallSource: 'focus-entry-manage-sheet',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
|
||||
};
|
||||
|
||||
const ENTRY_SUGGESTIONS = [
|
||||
{ id: 'tidy-10m', label: '정리 10분', goal: '정리 10분만 하기' },
|
||||
{ id: 'mail-3', label: '메일 3개', goal: '메일 3개 정리' },
|
||||
{ id: 'doc-1p', label: '문서 1p', goal: '문서 1p 다듬기' },
|
||||
] as const;
|
||||
|
||||
type EntrySource = 'starter' | 'plan' | 'custom';
|
||||
|
||||
const getVisiblePlanItems = (
|
||||
currentItem: FocusPlanItem | null,
|
||||
nextItems: FocusPlanItem[],
|
||||
limit: number,
|
||||
) => {
|
||||
return [currentItem, ...nextItems]
|
||||
.filter((item): item is FocusPlanItem => Boolean(item))
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const formatReviewLine = (startedSessions: number, completedSessions: number, carriedOverCount: number) => {
|
||||
return `최근 7일 시작 ${startedSessions}회 · 완료 ${completedSessions}회 · 이월 ${carriedOverCount}개`;
|
||||
};
|
||||
|
||||
const startButtonClassName =
|
||||
'inline-flex h-12 w-full items-center justify-center rounded-[1rem] bg-brand-primary text-sm font-semibold text-white shadow-[0_14px_32px_rgba(59,130,246,0.22)] transition hover:bg-brand-primary/92 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18';
|
||||
|
||||
const previewButtonClassName =
|
||||
'w-full rounded-[1.1rem] border border-slate-200/88 bg-white/72 px-4 py-3 text-left transition hover:border-slate-300/88 hover:bg-white';
|
||||
|
||||
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
|
||||
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
|
||||
};
|
||||
|
||||
export const FocusDashboardWidget = () => {
|
||||
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||
const { plan, isLoading, isSaving, error, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||
const { summary } = useFocusStats();
|
||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [manageSheetOpen, setManageSheetOpen] = useState(false);
|
||||
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
|
||||
const [entryDraft, setEntryDraft] = useState('');
|
||||
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
|
||||
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
|
||||
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
|
||||
const planItems = useMemo(() => {
|
||||
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
|
||||
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||
|
||||
const currentItem = planItems[0] ?? null;
|
||||
const previewItems = planItems.slice(1, 3);
|
||||
const reviewLine = formatReviewLine(
|
||||
summary.last7Days.startedSessions,
|
||||
summary.last7Days.completedSessions,
|
||||
summary.last7Days.carriedOverCount,
|
||||
);
|
||||
const hasPendingEdit = editingState !== null;
|
||||
const canAddMore = planItems.length < maxItems;
|
||||
const canManagePlan = source === 'api' && !isLoading;
|
||||
const trimmedEntryGoal = entryDraft.trim();
|
||||
const startHref = trimmedEntryGoal
|
||||
? buildFocusEntryStartHref({
|
||||
goal: trimmedEntryGoal,
|
||||
planItemId: selectedPlanItemId,
|
||||
})
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [editingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId)) {
|
||||
setEntryDraft(currentItem.title);
|
||||
setSelectedPlanItemId(currentItem.id);
|
||||
setEntrySource('plan');
|
||||
}
|
||||
}, [currentItem, entryDraft, entrySource, selectedPlanItemId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPlanItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (planItems.some((item) => item.id === selectedPlanItemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentItem) {
|
||||
setEntryDraft(currentItem.title);
|
||||
setSelectedPlanItemId(currentItem.id);
|
||||
setEntrySource('plan');
|
||||
return;
|
||||
}
|
||||
|
||||
setEntryDraft('');
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
}, [currentItem, planItems, selectedPlanItemId]);
|
||||
|
||||
const openPaywall = () => {
|
||||
setPaywallSource(focusEntryCopy.paywallSource);
|
||||
};
|
||||
|
||||
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
||||
const isCurrentSelection = currentItem?.id === item.id;
|
||||
|
||||
setEntryDraft(item.title);
|
||||
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
|
||||
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
|
||||
setManageSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (goal: string) => {
|
||||
setEntryDraft(goal);
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('starter');
|
||||
};
|
||||
|
||||
const handleEntryDraftChange = (value: string) => {
|
||||
setEntryDraft(value);
|
||||
setEntrySource('custom');
|
||||
setSelectedPlanItemId(null);
|
||||
};
|
||||
|
||||
const handleAddBlock = () => {
|
||||
if (hasPendingEdit || isSaving || !canManagePlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAddMore) {
|
||||
if (!isPro) {
|
||||
openPaywall();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState({
|
||||
mode: 'new',
|
||||
value: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditRow = (item: FocusPlanItem) => {
|
||||
if (hasPendingEdit || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState({
|
||||
mode: 'edit',
|
||||
itemId: item.id,
|
||||
value: item.title,
|
||||
});
|
||||
};
|
||||
|
||||
const handleManageDraftChange = (value: string) => {
|
||||
setEditingState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
value,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedTitle = editingState.value.trim();
|
||||
|
||||
if (!trimmedTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingState.mode === 'new') {
|
||||
const nextPlan = await createItem({ title: trimmedTitle });
|
||||
|
||||
if (!nextPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState(null);
|
||||
|
||||
if (!currentItem) {
|
||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||
|
||||
if (nextCurrentItem) {
|
||||
setEntryDraft(nextCurrentItem.title);
|
||||
setSelectedPlanItemId(nextCurrentItem.id);
|
||||
setEntrySource('plan');
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRow = planItems.find((item) => item.id === editingState.itemId);
|
||||
|
||||
if (!currentRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRow.title === trimmedTitle) {
|
||||
setEditingState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlan = await updateItem(editingState.itemId, {
|
||||
title: trimmedTitle,
|
||||
});
|
||||
|
||||
if (!nextPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingState(null);
|
||||
|
||||
if (selectedPlanItemId === editingState.itemId) {
|
||||
setEntryDraft(trimmedTitle);
|
||||
setEntrySource('plan');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRow = async (itemId: string) => {
|
||||
const nextPlan = await deleteItem(itemId);
|
||||
|
||||
if (!nextPlan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||
setEditingState(null);
|
||||
}
|
||||
|
||||
if (selectedPlanItemId === itemId) {
|
||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||
|
||||
if (nextCurrentItem) {
|
||||
setEntryDraft(nextCurrentItem.title);
|
||||
setSelectedPlanItemId(nextCurrentItem.id);
|
||||
setEntrySource('plan');
|
||||
return;
|
||||
}
|
||||
|
||||
setEntryDraft('');
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_12%_0%,rgba(191,219,254,0.42),transparent_36%),linear-gradient(180deg,#f8fafc_0%,#edf4fb_56%,#e7eef7_100%)] text-brand-dark">
|
||||
<div className="mx-auto w-full max-w-2xl px-4 pb-12 pt-8 sm:px-6">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div className="max-w-lg">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-brand-dark/40">
|
||||
{focusEntryCopy.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-brand-dark">
|
||||
{focusEntryCopy.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-7 text-brand-dark/62">
|
||||
{focusEntryCopy.description}
|
||||
</p>
|
||||
</div>
|
||||
<PlanPill
|
||||
plan={planTier}
|
||||
onClick={() => {
|
||||
if (!isPro) {
|
||||
openPaywall();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main className="mt-8 space-y-5">
|
||||
<section className="overflow-hidden rounded-[2rem] border border-black/5 bg-white/78 p-5 shadow-[0_24px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl sm:p-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.title}</p>
|
||||
<p className="text-sm text-brand-dark/58">{focusEntryCopy.helper}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label className="min-w-0 flex-1 space-y-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.14em] text-brand-dark/40">
|
||||
{focusEntryCopy.inputLabel}
|
||||
</span>
|
||||
<input
|
||||
ref={entryInputRef}
|
||||
value={entryDraft}
|
||||
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||
placeholder={focusEntryCopy.inputPlaceholder}
|
||||
className="h-12 w-full rounded-[1rem] border border-slate-200/88 bg-white px-4 text-[15px] text-brand-dark outline-none transition focus:border-brand-primary/38 focus:ring-2 focus:ring-brand-primary/12"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{startHref ? (
|
||||
<Link href={startHref} className={cn(startButtonClassName, 'sm:w-[164px]')}>
|
||||
{focusEntryCopy.startNow}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => entryInputRef.current?.focus()}
|
||||
className={cn(startButtonClassName, 'sm:w-[164px]')}
|
||||
>
|
||||
{focusEntryCopy.startNow}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = selectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.goal)}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-3 py-1.5 text-sm transition',
|
||||
isActive
|
||||
? 'border-brand-primary/26 bg-brand-primary/10 text-brand-dark'
|
||||
: 'border-slate-200/84 bg-white/72 text-brand-dark/68 hover:bg-white',
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200/80 pt-4">
|
||||
<p className="text-xs text-brand-dark/54">{focusEntryCopy.ritualMeta}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManageSheetOpen(true)}
|
||||
disabled={!canManagePlan}
|
||||
className="text-sm font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:text-brand-dark/34"
|
||||
>
|
||||
{focusEntryCopy.manageBlocks}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewItems.length > 0 ? (
|
||||
<div className="space-y-3 border-t border-slate-200/80 pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-brand-dark">{focusEntryCopy.previewTitle}</p>
|
||||
<p className="text-xs leading-6 text-brand-dark/54">
|
||||
{focusEntryCopy.previewDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{previewItems.map((item) => {
|
||||
const isSelected = selectedPlanItemId === null && trimmedEntryGoal === item.title;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlanItem(item)}
|
||||
className={cn(
|
||||
previewButtonClassName,
|
||||
isSelected && 'border-brand-primary/24 bg-brand-primary/8',
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-[15px] font-medium',
|
||||
isSelected ? 'text-brand-dark' : 'text-brand-dark/78',
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{source === 'unavailable' && !isLoading ? (
|
||||
<p className="border-t border-slate-200/80 pt-4 text-xs text-brand-dark/54">
|
||||
{focusEntryCopy.apiUnavailableNote}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<p className="text-xs text-brand-dark/54">
|
||||
{isLoading ? focusEntryCopy.reviewFallback : reviewLine}
|
||||
</p>
|
||||
<Link
|
||||
href="/stats"
|
||||
className="text-xs font-medium text-brand-primary transition hover:text-brand-primary/82"
|
||||
>
|
||||
{focusEntryCopy.reviewLinkLabel}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && source === 'api' ? <p className="px-1 text-xs text-rose-500">{error}</p> : null}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FocusPlanManageSheet
|
||||
isOpen={manageSheetOpen}
|
||||
planItems={planItems}
|
||||
selectedPlanItemId={selectedPlanItemId}
|
||||
editingState={editingState}
|
||||
isSaving={isSaving}
|
||||
canAddMore={canAddMore}
|
||||
isPro={isPro}
|
||||
inputRef={inputRef}
|
||||
onClose={() => {
|
||||
if (!isSaving) {
|
||||
setManageSheetOpen(false);
|
||||
setEditingState(null);
|
||||
}
|
||||
}}
|
||||
onAddBlock={handleAddBlock}
|
||||
onDraftChange={handleManageDraftChange}
|
||||
onSelect={handleSelectPlanItem}
|
||||
onEdit={handleEditRow}
|
||||
onDelete={(itemId) => {
|
||||
void handleDeleteRow(itemId);
|
||||
}}
|
||||
onSave={() => {
|
||||
void handleSaveEdit();
|
||||
}}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
|
||||
{paywallSource ? (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copy.modal.closeAriaLabel}
|
||||
onClick={() => setPaywallSource(null)}
|
||||
className="absolute inset-0 bg-slate-950/48 backdrop-blur-[2px]"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]">
|
||||
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42">
|
||||
{focusEntryCopy.paywallLead}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-white/62">
|
||||
{focusEntryCopy.paywallBody}
|
||||
</p>
|
||||
<PaywallSheetContent
|
||||
onStartPro={() => {
|
||||
setPlan('pro');
|
||||
setPaywallSource(null);
|
||||
}}
|
||||
onClose={() => setPaywallSource(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
130
src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx
Normal file
130
src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import type { KeyboardEvent, RefObject } from 'react';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface FocusPlanListRowProps {
|
||||
title: string;
|
||||
isCurrent: boolean;
|
||||
isSelected?: boolean;
|
||||
isEditing: boolean;
|
||||
draftValue: string;
|
||||
isSaving: boolean;
|
||||
isBusy: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
placeholder?: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const FocusPlanListRow = ({
|
||||
title,
|
||||
isCurrent,
|
||||
isSelected = false,
|
||||
isEditing,
|
||||
draftValue,
|
||||
isSaving,
|
||||
isBusy,
|
||||
inputRef,
|
||||
placeholder = '예: 제안서 첫 문단 다듬기',
|
||||
onDraftChange,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: FocusPlanListRowProps) => {
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void onSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3 sm:flex-nowrap">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-w-[220px] flex-1 rounded-[14px] bg-slate-100/95 px-3.5 py-2.5 text-[15px] text-brand-dark outline-none ring-1 ring-slate-200/80 placeholder:text-slate-400 focus:ring-2 focus:ring-brand-primary/18"
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-[15px] font-medium text-brand-dark/58 transition hover:text-brand-dark disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={isSaving}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onSave();
|
||||
}}
|
||||
className="text-[15px] font-semibold text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={draftValue.trim().length === 0 || isSaving}
|
||||
>
|
||||
{isSaving ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3.5 sm:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-left text-[15px] transition',
|
||||
isSelected
|
||||
? 'font-semibold text-brand-dark'
|
||||
: isCurrent
|
||||
? 'font-semibold text-brand-dark/88'
|
||||
: 'font-medium text-brand-dark/78',
|
||||
isBusy ? 'cursor-default' : 'hover:text-brand-dark',
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="text-[15px] font-medium text-brand-primary transition hover:text-brand-primary/82 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={isBusy}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onDelete();
|
||||
}}
|
||||
className="text-[15px] font-medium text-rose-500 transition hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={isBusy}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx
Normal file
155
src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import type { RefObject } from 'react';
|
||||
import type { FocusPlanItem } from '@/entities/focus-plan';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { Modal } from '@/shared/ui';
|
||||
import { FocusPlanListRow } from './FocusPlanListRow';
|
||||
|
||||
export type FocusPlanEditingState =
|
||||
| {
|
||||
mode: 'new';
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
mode: 'edit';
|
||||
itemId: string;
|
||||
value: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
interface FocusPlanManageSheetProps {
|
||||
isOpen: boolean;
|
||||
planItems: FocusPlanItem[];
|
||||
selectedPlanItemId: string | null;
|
||||
editingState: FocusPlanEditingState;
|
||||
isSaving: boolean;
|
||||
canAddMore: boolean;
|
||||
isPro: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
onClose: () => void;
|
||||
onAddBlock: () => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
onSelect: (item: FocusPlanItem) => void;
|
||||
onEdit: (item: FocusPlanItem) => void;
|
||||
onDelete: (itemId: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const manageSheetCopy = {
|
||||
title: '블록 정리',
|
||||
description: '집중에 들어간 뒤 이어갈 블록만 가볍게 남겨두세요.',
|
||||
empty: '아직 저장된 블록이 없어요.',
|
||||
addBlock: '+ 블록 추가',
|
||||
placeholder: '예: 리뷰 코멘트 2개 정리',
|
||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||
maxBlocksReached: '최대 5개까지 정리해 둘 수 있어요.',
|
||||
};
|
||||
|
||||
export const FocusPlanManageSheet = ({
|
||||
isOpen,
|
||||
planItems,
|
||||
selectedPlanItemId,
|
||||
editingState,
|
||||
isSaving,
|
||||
canAddMore,
|
||||
isPro,
|
||||
inputRef,
|
||||
onClose,
|
||||
onAddBlock,
|
||||
onDraftChange,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: FocusPlanManageSheetProps) => {
|
||||
const hasPendingEdit = editingState !== null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={manageSheetCopy.title}
|
||||
description={manageSheetCopy.description}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-[1.4rem] border border-brand-dark/10 bg-white/66">
|
||||
<div className="divide-y divide-slate-200/78">
|
||||
{planItems.length === 0 && editingState?.mode !== 'new' ? (
|
||||
<div className="px-4 py-3.5 text-[15px] text-brand-dark/46 sm:px-5">
|
||||
{manageSheetCopy.empty}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{planItems.map((item, index) => (
|
||||
<FocusPlanListRow
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
isCurrent={index === 0}
|
||||
isSelected={selectedPlanItemId === item.id}
|
||||
isEditing={editingState?.mode === 'edit' && editingState.itemId === item.id}
|
||||
draftValue={
|
||||
editingState?.mode === 'edit' && editingState.itemId === item.id
|
||||
? editingState.value
|
||||
: item.title
|
||||
}
|
||||
isSaving={isSaving}
|
||||
isBusy={isSaving || hasPendingEdit}
|
||||
inputRef={editingState?.mode === 'edit' && editingState.itemId === item.id ? inputRef : undefined}
|
||||
onDraftChange={onDraftChange}
|
||||
onSelect={() => onSelect(item)}
|
||||
onEdit={() => onEdit(item)}
|
||||
onDelete={() => onDelete(item.id)}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))}
|
||||
|
||||
{editingState?.mode === 'new' ? (
|
||||
<FocusPlanListRow
|
||||
title=""
|
||||
isCurrent={planItems.length === 0}
|
||||
isEditing
|
||||
draftValue={editingState.value}
|
||||
isSaving={isSaving}
|
||||
isBusy={isSaving}
|
||||
inputRef={inputRef}
|
||||
placeholder={manageSheetCopy.placeholder}
|
||||
onDraftChange={onDraftChange}
|
||||
onSelect={() => {}}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-1">
|
||||
{isPro && !canAddMore ? (
|
||||
<p className="text-sm text-brand-dark/46">{manageSheetCopy.maxBlocksReached}</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddBlock}
|
||||
disabled={hasPendingEdit || isSaving}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 text-[15px] font-medium text-brand-primary transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/18 disabled:cursor-not-allowed disabled:opacity-45',
|
||||
)}
|
||||
>
|
||||
<span>{manageSheetCopy.addBlock}</span>
|
||||
{!isPro && !canAddMore ? (
|
||||
<span className="rounded-full bg-brand-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-brand-primary">
|
||||
{manageSheetCopy.freeUpgradeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user