feat(core-loop): /app 진입과 /space 복구 흐름 구현
This commit is contained in:
@@ -1,566 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type FocusPlanItem,
|
||||
type FocusPlanToday,
|
||||
useFocusPlan,
|
||||
} from '@/entities/focus-plan';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { SCENE_THEMES, getSceneById } from '@/entities/scene';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useDragScroll } from '@/shared/lib/useDragScroll';
|
||||
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
|
||||
|
||||
const FREE_MAX_ITEMS = 1;
|
||||
const PRO_MAX_ITEMS = 5;
|
||||
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
||||
const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
|
||||
const DEFAULT_TIMER_ID = '50-10';
|
||||
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
|
||||
|
||||
const focusEntryCopy = {
|
||||
const entryCopy = {
|
||||
eyebrow: 'VibeRoom',
|
||||
title: '오늘의 깊은 몰입을 위한 단 하나의 목표',
|
||||
description: '지금 당장 시작할 딱 하나만 남겨두세요.',
|
||||
inputLabel: '첫 블록',
|
||||
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
helper: '아주 작게 잡아도 괜찮아요.',
|
||||
startNow: '바로 몰입하기',
|
||||
nextStep: '환경 세팅',
|
||||
manageBlocks: '내 계획에서 가져오기',
|
||||
previewTitle: '이어갈 블록',
|
||||
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
|
||||
reviewLinkLabel: 'stats',
|
||||
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
|
||||
ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
|
||||
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
|
||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||
paywallSource: 'focus-entry-manage-sheet',
|
||||
title: '지금 붙잡을 한 가지',
|
||||
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
|
||||
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
microStepLabel: '지금 할 한 조각',
|
||||
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
|
||||
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
|
||||
startNow: '지금 시작',
|
||||
startLoading: '몰입 준비 중...',
|
||||
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
|
||||
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
|
||||
resumeEyebrow: 'Resume',
|
||||
resumeRunning: '진행 중인 세션이 있어요.',
|
||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||
resumeCta: '이어서 들어가기',
|
||||
resumeMicroStepLabel: '마지막 한 조각',
|
||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
|
||||
microStepTitle: '가장 작은 첫 단계 (선택)',
|
||||
microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
|
||||
microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
|
||||
ritualTitle: '어떤 환경에서 몰입하시겠어요?',
|
||||
ritualHelper: '오늘의 무드를 선택하세요.',
|
||||
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
||||
};
|
||||
|
||||
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;
|
||||
const goalCardClass =
|
||||
'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8';
|
||||
const inputShellClass =
|
||||
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
||||
const primaryButtonClass =
|
||||
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
|
||||
|
||||
type EntrySource = 'starter' | 'plan' | 'custom';
|
||||
type DashboardStep = 'goal' | 'ritual';
|
||||
|
||||
const getVisiblePlanItems = (
|
||||
currentItem: FocusPlanItem | null,
|
||||
nextItems: FocusPlanItem[],
|
||||
limit: number,
|
||||
) => {
|
||||
return [currentItem, ...nextItems]
|
||||
.filter((item): item is FocusPlanItem => Boolean(item))
|
||||
.slice(0, limit);
|
||||
const timerLabelById: Record<string, string> = {
|
||||
'25-5': '25/5',
|
||||
'50-10': '50/10',
|
||||
'90-20': '90/20',
|
||||
};
|
||||
|
||||
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
|
||||
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
|
||||
};
|
||||
const resolveSoundLabel = (soundPresetId?: string | null) => {
|
||||
if (!soundPresetId) {
|
||||
return 'Silent';
|
||||
}
|
||||
|
||||
// Premium Glassmorphism UI Classes
|
||||
const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10';
|
||||
const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl';
|
||||
const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer';
|
||||
const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]';
|
||||
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
|
||||
};
|
||||
|
||||
export const FocusDashboardWidget = () => {
|
||||
const router = useRouter();
|
||||
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||
const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||
const { plan, isPro, setPlan } = usePlanTier();
|
||||
const { sceneAssetMap } = useMediaCatalog();
|
||||
|
||||
const [step, setStep] = useState<DashboardStep>('goal');
|
||||
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 [goalDraft, setGoalDraft] = useState('');
|
||||
const [microStepDraft, setMicroStepDraft] = useState('');
|
||||
// Use user's last preference or default to first
|
||||
const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id);
|
||||
const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
|
||||
const [selectedTimerId, setSelectedTimerId] = useState('50-10');
|
||||
|
||||
const [isStartingSession, setIsStartingSession] = useState(false);
|
||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
||||
|
||||
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const microStepInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const {
|
||||
containerRef: sceneContainerRef,
|
||||
events: sceneDragEvents,
|
||||
isDragging: isSceneDragging,
|
||||
shouldSuppressClick: shouldSuppressSceneClick,
|
||||
} = useDragScroll();
|
||||
const activeScene = useMemo(() => {
|
||||
return getSceneById(currentSession?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
}, [currentSession?.sceneId]);
|
||||
|
||||
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
|
||||
const activeRitualMeta = useMemo(() => {
|
||||
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
|
||||
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
|
||||
|
||||
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
|
||||
const planItems = useMemo(() => {
|
||||
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
|
||||
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
|
||||
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
|
||||
|
||||
const currentItem = planItems[0] ?? null;
|
||||
const shouldUseCurrentPlanDefaults =
|
||||
Boolean(currentItem) && (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId));
|
||||
const resolvedEntryDraft = shouldUseCurrentPlanDefaults && currentItem ? currentItem.title : entryDraft;
|
||||
const resolvedSelectedPlanItemId =
|
||||
shouldUseCurrentPlanDefaults && currentItem ? currentItem.id : selectedPlanItemId;
|
||||
|
||||
const hasPendingEdit = editingState !== null;
|
||||
const canAddMore = planItems.length < maxItems;
|
||||
const canManagePlan = source === 'api' && !isLoading;
|
||||
const trimmedEntryGoal = resolvedEntryDraft.trim();
|
||||
const isGoalReady = trimmedEntryGoal.length > 0;
|
||||
const trimmedGoal = goalDraft.trim();
|
||||
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingState) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}, [editingState]);
|
||||
let cancelled = false;
|
||||
|
||||
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
|
||||
const loadCurrentSession = async () => {
|
||||
setIsCheckingSession(true);
|
||||
|
||||
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('custom');
|
||||
};
|
||||
|
||||
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) => current ? { ...current, value } : current);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (!isSaving) 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');
|
||||
try {
|
||||
const session = await focusSessionApi.getCurrentSession();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCurrentSession(session);
|
||||
setSessionLookupError(null);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCurrentSession(null);
|
||||
setSessionLookupError(error instanceof Error ? error.message : entryCopy.loadFailed);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const currentRow = planItems.find((item) => item.id === editingState.itemId);
|
||||
if (!currentRow) return;
|
||||
if (currentRow.title === trimmedTitle) {
|
||||
setEditingState(null);
|
||||
return;
|
||||
}
|
||||
void loadCurrentSession();
|
||||
|
||||
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
|
||||
if (!nextPlan) return;
|
||||
setEditingState(null);
|
||||
if (resolvedSelectedPlanItemId === editingState.itemId) {
|
||||
setEntryDraft(trimmedTitle);
|
||||
setEntrySource('plan');
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPaywall = () => {
|
||||
if (!isPro) {
|
||||
setPaywallSource('app-entry-plan-pill');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRow = async (itemId: string) => {
|
||||
const nextPlan = await deleteItem(itemId);
|
||||
if (!nextPlan) return;
|
||||
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||
setEditingState(null);
|
||||
}
|
||||
if (resolvedSelectedPlanItemId === 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');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!isGoalReady) {
|
||||
entryInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (step === 'goal') setStep('ritual');
|
||||
const handleSelectSuggestion = (label: string) => {
|
||||
setGoalDraft(label);
|
||||
goalInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleStartSession = async () => {
|
||||
if (isStartingSession) return;
|
||||
if (!trimmedGoal || isStartingSession || currentSession) {
|
||||
if (!trimmedGoal) {
|
||||
goalInputRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingSession(true);
|
||||
|
||||
try {
|
||||
await focusSessionApi.startSession({
|
||||
goal: trimmedEntryGoal,
|
||||
goal: trimmedGoal,
|
||||
microStep: microStepDraft.trim() || null,
|
||||
sceneId: selectedSceneId,
|
||||
soundPresetId: selectedSoundId,
|
||||
timerPresetId: selectedTimerId,
|
||||
focusPlanItemId: resolvedSelectedPlanItemId || undefined,
|
||||
entryPoint: 'space-setup'
|
||||
sceneId: DEFAULT_SCENE_ID,
|
||||
soundPresetId: DEFAULT_SOUND_ID,
|
||||
timerPresetId: DEFAULT_TIMER_ID,
|
||||
entryPoint: 'space-setup',
|
||||
});
|
||||
router.push('/space');
|
||||
} catch (err) {
|
||||
console.error('Failed to start session', err);
|
||||
setIsStartingSession(false);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to start focus session from /app', error);
|
||||
}
|
||||
|
||||
setIsStartingSession(false);
|
||||
};
|
||||
|
||||
const handleResumeSession = () => {
|
||||
router.push('/space');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
|
||||
{/* Premium Cinematic Background */}
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-cover bg-center transition-all duration-1000 ease-out will-change-transform",
|
||||
isStartingSession ? 'scale-110 blur-2xl opacity-0' : 'scale-100 opacity-100',
|
||||
step === 'ritual' ? 'scale-105 blur-sm' : ''
|
||||
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out',
|
||||
isStartingSession ? 'scale-[1.04]' : 'scale-100',
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
|
||||
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
||||
/>
|
||||
{/* Global Gradient Overlay for text readability */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 bg-gradient-to-b from-black/20 via-black/40 to-black/60 transition-opacity duration-1000",
|
||||
step === 'ritual' ? 'opacity-80' : 'opacity-100'
|
||||
)} />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.38)_55%,rgba(2,6,23,0.5)_100%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),rgba(255,255,255,0)_42%)]" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-6 md:p-8">
|
||||
<p className="text-sm font-semibold tracking-[0.3em] text-white/50 uppercase">
|
||||
{focusEntryCopy.eyebrow}
|
||||
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
|
||||
<p className="text-sm font-semibold tracking-[0.28em] text-white/56 uppercase">
|
||||
{entryCopy.eyebrow}
|
||||
</p>
|
||||
<PlanPill
|
||||
plan={planTier}
|
||||
onClick={() => {
|
||||
if (!isPro) openPaywall();
|
||||
}}
|
||||
/>
|
||||
<PlanPill plan={plan} onClick={openPaywall} />
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
|
||||
|
||||
{/* Step 1: Goal Setup */}
|
||||
<div className={cn(
|
||||
"w-full max-w-2xl transition-all duration-700 absolute",
|
||||
step === 'goal'
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 -translate-y-8 pointer-events-none'
|
||||
)}>
|
||||
<div className="flex flex-col items-center space-y-10 text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-light tracking-tight text-white drop-shadow-lg leading-tight">
|
||||
{focusEntryCopy.title}
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-xl mx-auto space-y-8">
|
||||
<input
|
||||
ref={entryInputRef}
|
||||
value={resolvedEntryDraft}
|
||||
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
|
||||
placeholder={focusEntryCopy.inputPlaceholder}
|
||||
className={glassInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartSession}
|
||||
disabled={!isGoalReady || isStartingSession}
|
||||
className={primaryGlassBtnClass}
|
||||
>
|
||||
{focusEntryCopy.startNow}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
disabled={!isGoalReady || isStartingSession}
|
||||
className={secondaryGlassBtnClass}
|
||||
>
|
||||
{focusEntryCopy.nextStep}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions / Manage - very minimal */}
|
||||
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.goal)}
|
||||
className={cn(
|
||||
'rounded-full px-4 py-1.5 text-sm transition-all border',
|
||||
isActive
|
||||
? 'bg-white/20 border-white text-white'
|
||||
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManageSheetOpen(true)}
|
||||
disabled={!canManagePlan}
|
||||
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
|
||||
>
|
||||
{focusEntryCopy.manageBlocks}
|
||||
</button>
|
||||
</div>
|
||||
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-center justify-center px-4 pb-8 pt-4 md:px-6">
|
||||
<div className="w-full max-w-[42rem]">
|
||||
{isCheckingSession ? (
|
||||
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||
{entryCopy.resumeEyebrow}
|
||||
</p>
|
||||
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Ritual Setup */}
|
||||
<div className={cn(
|
||||
"w-full max-w-4xl transition-all duration-700 absolute",
|
||||
step === 'ritual'
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-8 pointer-events-none'
|
||||
)}>
|
||||
<div className={panelGlassClass}>
|
||||
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
||||
<div className="flex-1">
|
||||
<p className="mb-2 text-xs uppercase tracking-widest text-white/40">Today's Focus</p>
|
||||
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('goal')}
|
||||
className="rounded-full border border-white/20 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
<div className="space-y-8">
|
||||
{/* Microstep */}
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
{focusEntryCopy.microStepTitle}
|
||||
</span>
|
||||
<input
|
||||
ref={microStepInputRef}
|
||||
value={microStepDraft}
|
||||
onChange={(e) => setMicroStepDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartSession()}
|
||||
placeholder={focusEntryCopy.microStepPlaceholder}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-white/30 outline-none transition-all focus:border-white/30 focus:bg-white/10"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-white/40">{focusEntryCopy.microStepHelper}</p>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">몰입 리듬</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
|
||||
<button
|
||||
key={timer.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTimerId(timer.id)}
|
||||
className={cn(itemCardGlassClass, selectedTimerId === timer.id && itemCardGlassSelectedClass)}
|
||||
>
|
||||
<span className="text-sm font-medium">{timer.label}</span>
|
||||
</button>
|
||||
))}
|
||||
) : currentSession ? (
|
||||
<div className={cn(goalCardClass, 'space-y-5')}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||
{entryCopy.resumeEyebrow}
|
||||
</p>
|
||||
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
|
||||
{currentSession.goal}
|
||||
</h1>
|
||||
<p className="text-sm text-white/68">
|
||||
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
|
||||
</p>
|
||||
{currentSession.microStep ? (
|
||||
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
|
||||
{entryCopy.resumeMicroStepLabel}
|
||||
</p>
|
||||
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Scene */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">배경 공간</p>
|
||||
<div
|
||||
ref={sceneContainerRef}
|
||||
{...sceneDragEvents}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
|
||||
{entryCopy.resumeCta}
|
||||
</button>
|
||||
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={goalCardClass}>
|
||||
<div className="space-y-3 text-center">
|
||||
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
|
||||
{entryCopy.title}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
|
||||
{entryCopy.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<label className="block">
|
||||
<span className="sr-only">Goal</span>
|
||||
<input
|
||||
ref={goalInputRef}
|
||||
value={goalDraft}
|
||||
onChange={(event) => setGoalDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleStartSession();
|
||||
}
|
||||
}}
|
||||
placeholder={entryCopy.goalPlaceholder}
|
||||
className={cn(
|
||||
"flex gap-3 overflow-x-auto pb-2 scrollbar-none",
|
||||
isSceneDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
inputShellClass,
|
||||
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
|
||||
)}
|
||||
>
|
||||
{SCENE_THEMES.map(scene => (
|
||||
<button
|
||||
key={scene.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!shouldSuppressSceneClick) {
|
||||
setSelectedSceneId(scene.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left transition-all overflow-hidden bg-white/5 active:scale-95',
|
||||
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]',
|
||||
isSceneDragging && 'pointer-events-none'
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 transition-opacity group-hover:bg-black/20" />
|
||||
<span className="absolute bottom-2 left-2 text-sm font-medium z-10 text-white text-shadow-sm">{scene.name}</span>
|
||||
{selectedSceneId === scene.id && (
|
||||
<span className="absolute top-2 right-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm border border-white/40 text-white text-[10px]">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Sound */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">사운드</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{SOUND_PRESETS.map(sound => (
|
||||
<button
|
||||
key={sound.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSoundId(sound.id)}
|
||||
className={cn(itemCardGlassClass, "py-3", selectedSoundId === sound.id && itemCardGlassSelectedClass)}
|
||||
>
|
||||
<span className="text-sm font-medium">{sound.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
||||
{entryCopy.microStepLabel}
|
||||
</span>
|
||||
<input
|
||||
value={microStepDraft}
|
||||
onChange={(event) => setMicroStepDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleStartSession();
|
||||
}
|
||||
}}
|
||||
placeholder={entryCopy.microStepPlaceholder}
|
||||
className={cn(inputShellClass, 'text-[0.98rem] placeholder:text-white/30')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="text-sm text-white/48">{entryCopy.microStepHelper}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2.5">
|
||||
{GOAL_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = trimmedGoal === suggestion.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.label)}
|
||||
className={cn(
|
||||
'rounded-full border px-3.5 py-1.5 text-sm transition',
|
||||
isActive
|
||||
? 'border-white/32 bg-white/14 text-white'
|
||||
: 'border-white/14 bg-white/[0.04] text-white/72 hover:border-white/22 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleStartSession();
|
||||
}}
|
||||
disabled={!canStart}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{isStartingSession ? entryCopy.startLoading : entryCopy.startNow}
|
||||
</button>
|
||||
<div className="space-y-1 text-left sm:text-right">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-white/44">
|
||||
{entryCopy.ritualHint}
|
||||
</p>
|
||||
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-6 flex justify-end border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartSession}
|
||||
disabled={isStartingSession}
|
||||
className={primaryGlassBtnClass}
|
||||
>
|
||||
{isStartingSession ? '공간으로 이동 중...' : '입장하기'}
|
||||
</button>
|
||||
{sessionLookupError ? (
|
||||
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Plan Sheet & Paywall */}
|
||||
<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]"
|
||||
className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]"
|
||||
/>
|
||||
<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}
|
||||
{entryCopy.paywallLead}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-white/62">{entryCopy.paywallBody}</p>
|
||||
<PaywallSheetContent
|
||||
onStartPro={() => {
|
||||
setPlan('pro');
|
||||
|
||||
Reference in New Issue
Block a user