feat(app): atmosphere entry shell 1차 구현
This commit is contained in:
@@ -13,6 +13,18 @@ import { focusSessionApi, type FocusSession } from '@/features/focus-session/api
|
||||
import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import {
|
||||
ATMOSPHERE_OPTIONS,
|
||||
ENTRY_DURATION_SUGGESTIONS,
|
||||
findAtmosphereOptionForSelection,
|
||||
getAtmosphereOptionById,
|
||||
getRecommendedDurationMinutes,
|
||||
getTimerPresetMetaById,
|
||||
parseDurationMinutes,
|
||||
resolveNearestTimerPreset,
|
||||
sanitizeDurationDraft,
|
||||
} from '../model/atmosphereEntry';
|
||||
import { AppAtmosphereEntryShell } from './AppAtmosphereEntryShell';
|
||||
|
||||
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;
|
||||
@@ -25,20 +37,20 @@ const REVIEW_ENTRY_PRESETS = {
|
||||
label: '숲 · 50/10 · Forest Birds',
|
||||
},
|
||||
} as const;
|
||||
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
|
||||
const DEFAULT_ATMOSPHERE =
|
||||
findAtmosphereOptionForSelection(DEFAULT_SCENE_ID, DEFAULT_SOUND_ID) ?? ATMOSPHERE_OPTIONS[0];
|
||||
|
||||
const entryCopy = {
|
||||
eyebrow: 'VibeRoom',
|
||||
title: '지금 붙잡을 한 가지',
|
||||
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
|
||||
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
microStepLabel: '지금 할 한 조각',
|
||||
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
|
||||
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
|
||||
startNow: '지금 시작',
|
||||
startLoading: '몰입 준비 중...',
|
||||
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
|
||||
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
|
||||
durationLabel: '예상 시간(분)',
|
||||
durationPlaceholder: '예: 70',
|
||||
durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.',
|
||||
startNow: '이 분위기로 들어가기',
|
||||
startLoading: '입장 준비 중...',
|
||||
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
|
||||
atmosphereBody:
|
||||
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
|
||||
resumeEyebrow: 'Resume',
|
||||
resumeRunning: '진행 중인 세션이 있어요.',
|
||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||
@@ -84,19 +96,11 @@ const entryCopy = {
|
||||
|
||||
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';
|
||||
const secondaryButtonClass =
|
||||
'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]';
|
||||
|
||||
const timerLabelById: Record<string, string> = {
|
||||
'25-5': '25/5',
|
||||
'50-10': '50/10',
|
||||
'90-20': '90/20',
|
||||
};
|
||||
|
||||
const resolveSoundLabel = (soundPresetId?: string | null) => {
|
||||
if (!soundPresetId) {
|
||||
return 'Silent';
|
||||
@@ -142,9 +146,26 @@ export const FocusDashboardWidget = () => {
|
||||
|
||||
return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null;
|
||||
}, [reviewEntryPreset]);
|
||||
const initialAtmosphere = useMemo(() => {
|
||||
return (
|
||||
findAtmosphereOptionForSelection(
|
||||
reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
|
||||
reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
|
||||
) ?? DEFAULT_ATMOSPHERE
|
||||
);
|
||||
}, [reviewEntryPresetConfig]);
|
||||
const initialDurationMinutes = useMemo(() => {
|
||||
if (reviewEntryPresetConfig) {
|
||||
return getTimerPresetMetaById(reviewEntryPresetConfig.timerPresetId).focusMinutes;
|
||||
}
|
||||
|
||||
return getRecommendedDurationMinutes(initialAtmosphere);
|
||||
}, [initialAtmosphere, reviewEntryPresetConfig]);
|
||||
|
||||
const [goalDraft, setGoalDraft] = useState('');
|
||||
const [microStepDraft, setMicroStepDraft] = useState('');
|
||||
const [durationDraft, setDurationDraft] = useState(() => String(initialDurationMinutes));
|
||||
const [selectedAtmosphereId, setSelectedAtmosphereId] = useState(initialAtmosphere.id);
|
||||
const [hasEditedDuration, setHasEditedDuration] = useState(false);
|
||||
const [isStartingSession, setIsStartingSession] = useState(false);
|
||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||
@@ -156,20 +177,35 @@ export const FocusDashboardWidget = () => {
|
||||
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
|
||||
|
||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectedAtmosphere = useMemo(
|
||||
() => getAtmosphereOptionById(selectedAtmosphereId),
|
||||
[selectedAtmosphereId],
|
||||
);
|
||||
const parsedDurationMinutes = parseDurationMinutes(durationDraft);
|
||||
const resolvedTimerPreset = useMemo(() => {
|
||||
const targetMinutes =
|
||||
parsedDurationMinutes ?? getRecommendedDurationMinutes(selectedAtmosphere);
|
||||
return resolveNearestTimerPreset(targetMinutes);
|
||||
}, [parsedDurationMinutes, selectedAtmosphere]);
|
||||
|
||||
const activeScene = useMemo(() => {
|
||||
return getSceneById(currentSession?.sceneId ?? reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
}, [currentSession?.sceneId, reviewEntryPresetConfig?.sceneId]);
|
||||
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
|
||||
}, [currentSession?.sceneId, selectedAtmosphere.sceneId]);
|
||||
|
||||
const activeRitualMeta = useMemo(() => {
|
||||
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
|
||||
const timerLabel =
|
||||
getTimerPresetMetaById(currentSession?.timerPresetId ?? DEFAULT_TIMER_ID).label;
|
||||
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
|
||||
|
||||
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
|
||||
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
|
||||
|
||||
const trimmedGoal = goalDraft.trim();
|
||||
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
|
||||
const canStart =
|
||||
trimmedGoal.length > 0 &&
|
||||
parsedDurationMinutes !== null &&
|
||||
!isStartingSession &&
|
||||
!currentSession;
|
||||
const hasEnoughWeeklyData =
|
||||
weeklySummary.last7Days.startedSessions >= 3 &&
|
||||
(weeklySummary.last7Days.completedSessions >= 2 ||
|
||||
@@ -193,7 +229,12 @@ export const FocusDashboardWidget = () => {
|
||||
const reviewTeaserSummary = isPro ? review.carryForward.keepDoing : review.snapshotSummary;
|
||||
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
|
||||
const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta;
|
||||
const entryRitualHint = reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : entryCopy.ritualHint;
|
||||
const durationHelper =
|
||||
parsedDurationMinutes === null
|
||||
? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.'
|
||||
: parsedDurationMinutes === resolvedTimerPreset.focusMinutes
|
||||
? `${entryCopy.durationHelper} 지금은 ${resolvedTimerPreset.label} 리듬으로 바로 들어가요.`
|
||||
: `${entryCopy.durationHelper} ${parsedDurationMinutes}분은 지금 ${resolvedTimerPreset.label} 리듬으로 먼저 들어가요.`;
|
||||
const isRunningSession = currentSession?.state === 'running';
|
||||
const isPausedSession = currentSession?.state === 'paused';
|
||||
|
||||
@@ -257,9 +298,30 @@ export const FocusDashboardWidget = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (label: string) => {
|
||||
setGoalDraft(label);
|
||||
goalInputRef.current?.focus();
|
||||
const resetEntryDrafts = () => {
|
||||
setGoalDraft('');
|
||||
setSelectedAtmosphereId(initialAtmosphere.id);
|
||||
setDurationDraft(String(initialDurationMinutes));
|
||||
setHasEditedDuration(false);
|
||||
};
|
||||
|
||||
const handleDurationChange = (value: string) => {
|
||||
setDurationDraft(sanitizeDurationDraft(value));
|
||||
setHasEditedDuration(true);
|
||||
};
|
||||
|
||||
const handleSelectDuration = (minutes: number) => {
|
||||
setDurationDraft(String(minutes));
|
||||
setHasEditedDuration(true);
|
||||
};
|
||||
|
||||
const handleSelectAtmosphere = (atmosphereId: string) => {
|
||||
const nextAtmosphere = getAtmosphereOptionById(atmosphereId);
|
||||
setSelectedAtmosphereId(nextAtmosphere.id);
|
||||
|
||||
if (!hasEditedDuration) {
|
||||
setDurationDraft(String(getRecommendedDurationMinutes(nextAtmosphere)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartSession = async () => {
|
||||
@@ -270,15 +332,19 @@ export const FocusDashboardWidget = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedDurationMinutes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingSession(true);
|
||||
|
||||
try {
|
||||
await focusSessionApi.startSession({
|
||||
goal: trimmedGoal,
|
||||
microStep: microStepDraft.trim() || null,
|
||||
sceneId: reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
|
||||
soundPresetId: reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
|
||||
timerPresetId: reviewEntryPresetConfig?.timerPresetId ?? DEFAULT_TIMER_ID,
|
||||
microStep: null,
|
||||
sceneId: selectedAtmosphere.sceneId,
|
||||
soundPresetId: selectedAtmosphere.soundPresetId,
|
||||
timerPresetId: resolvedTimerPreset.id,
|
||||
entryPoint: 'space-setup',
|
||||
});
|
||||
router.push('/space');
|
||||
@@ -338,8 +404,7 @@ export const FocusDashboardWidget = () => {
|
||||
setCurrentSession(null);
|
||||
setIsTakeoverSheetOpen(false);
|
||||
setSessionLookupError(null);
|
||||
setGoalDraft('');
|
||||
setMicroStepDraft('');
|
||||
resetEntryDrafts();
|
||||
setFocusGoalAfterTakeover(true);
|
||||
} catch (error) {
|
||||
setTakeoverError(
|
||||
@@ -478,131 +543,58 @@ export const FocusDashboardWidget = () => {
|
||||
) : null}
|
||||
</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(
|
||||
inputShellClass,
|
||||
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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}
|
||||
<AppAtmosphereEntryShell
|
||||
canStart={canStart}
|
||||
durationDraft={durationDraft}
|
||||
durationHelper={durationHelper}
|
||||
durationInputLabel={entryCopy.durationLabel}
|
||||
durationPlaceholder={entryCopy.durationPlaceholder}
|
||||
durationSuggestions={ENTRY_DURATION_SUGGESTIONS}
|
||||
goalDraft={goalDraft}
|
||||
goalInputRef={goalInputRef}
|
||||
goalPlaceholder={entryCopy.goalPlaceholder}
|
||||
isStartingSession={isStartingSession}
|
||||
reviewEntry={
|
||||
shouldShowWeeklyReviewTeaser ? (
|
||||
<Link
|
||||
href="/stats"
|
||||
className="block rounded-[1.6rem] border border-white/10 bg-[#0f1115]/18 px-5 py-4 backdrop-blur-lg transition hover:bg-[#0f1115]/24"
|
||||
>
|
||||
{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">
|
||||
{entryRitualHint}
|
||||
</p>
|
||||
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionLookupError ? (
|
||||
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{shouldShowWeeklyReviewTeaser ? (
|
||||
<Link
|
||||
href="/stats"
|
||||
className="block rounded-[1.6rem] border border-white/10 bg-[#0f1115]/18 px-5 py-4 backdrop-blur-lg transition hover:bg-[#0f1115]/24"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||
{entryCopy.reviewEyebrow}
|
||||
</p>
|
||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
||||
{reviewTeaserTitle}
|
||||
</p>
|
||||
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
||||
{reviewTeaserSummary}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||
{entryCopy.reviewEyebrow}
|
||||
</p>
|
||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
||||
{reviewTeaserTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] leading-[1.6] text-white/62">
|
||||
{reviewTeaserSummary}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
|
||||
{reviewTeaserCta}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
|
||||
{reviewTeaserCta}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
selectedAtmosphere={selectedAtmosphere}
|
||||
sessionLookupError={sessionLookupError}
|
||||
startButtonLabel={entryCopy.startNow}
|
||||
startButtonLoadingLabel={entryCopy.startLoading}
|
||||
atmosphereOptions={ATMOSPHERE_OPTIONS}
|
||||
atmosphereTitle={entryCopy.atmosphereTitle}
|
||||
atmosphereBody={entryCopy.atmosphereBody}
|
||||
onDurationChange={handleDurationChange}
|
||||
onGoalChange={setGoalDraft}
|
||||
onSelectAtmosphere={handleSelectAtmosphere}
|
||||
onSelectDuration={handleSelectDuration}
|
||||
onStartSession={() => {
|
||||
void handleStartSession();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user