feat(app): atmosphere entry shell 1차 구현
This commit is contained in:
@@ -1,9 +1,16 @@
|
|||||||
# 90. Current State
|
# 90. Current State
|
||||||
|
|
||||||
Last Updated: 2026-03-15
|
Last Updated: 2026-03-16
|
||||||
|
|
||||||
## DONE
|
## DONE
|
||||||
|
|
||||||
|
- `/app` Atmosphere Entry Shell 1차 구현:
|
||||||
|
- no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다
|
||||||
|
- `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다
|
||||||
|
- atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다
|
||||||
|
- custom duration server contract 전까지는 입력한 분 값을 가장 가까운 기본 리듬(`25/5`, `50/10`, `90/20`)으로 매핑한다
|
||||||
|
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
||||||
|
|
||||||
- `Paused Session Takeover Flow` 구현:
|
- `Paused Session Takeover Flow` 구현:
|
||||||
- `/app` paused gate에 `새 목표로 전환` 진입점을 추가했다
|
- `/app` paused gate에 `새 목표로 전환` 진입점을 추가했다
|
||||||
- takeover confirm sheet에서만 current paused session을 정리하고 single-goal start 상태로 넘어간다
|
- takeover confirm sheet에서만 current paused session을 정리하고 single-goal start 상태로 넘어간다
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
이 문서는 `/app`을 **`goal + duration + atmosphere` 중심의 premium focus entry surface**로 재설계하기 위한 기준 문서다.
|
이 문서는 `/app`을 **`goal + duration + atmosphere` 중심의 premium focus entry surface**로 재설계하기 위한 기준 문서다.
|
||||||
|
|
||||||
|
현재 상태:
|
||||||
|
|
||||||
|
- `Slice 1` no-session shell 구현 완료
|
||||||
|
- `Custom Duration Contract`와 `Weekly Review Dock Reposition`은 다음 slice로 남아 있음
|
||||||
|
|
||||||
관련 문서:
|
관련 문서:
|
||||||
|
|
||||||
- `../../product_principles.md`
|
- `../../../../../product_principles.md`
|
||||||
- `../../current_context.md`
|
- `../../../../../current_context.md`
|
||||||
- `../stats/14_weekly_review_reframe_spec.md`
|
- `../../stats/current/14_weekly_review_reframe_spec.md`
|
||||||
- `./15_app_stats_entry_flow_spec.md`
|
- `../../../flows/current/15_app_stats_entry_flow_spec.md`
|
||||||
- `./18_paused_session_reentry_spec.md`
|
- `../../../flows/current/18_paused_session_reentry_spec.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,6 +180,9 @@ Last Updated: 2026-03-16
|
|||||||
- 최대 180분
|
- 최대 180분
|
||||||
- helper:
|
- helper:
|
||||||
- `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.`
|
- `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.`
|
||||||
|
- quick duration suggestion은 허용한다.
|
||||||
|
- 예: `25`, `45`, `70`, `90`
|
||||||
|
- 단, planner처럼 보이지 않도록 아주 조용한 assistive chip이어야 한다
|
||||||
|
|
||||||
### C. Atmosphere Grid
|
### C. Atmosphere Grid
|
||||||
|
|
||||||
@@ -189,6 +197,8 @@ Last Updated: 2026-03-16
|
|||||||
- 카드명
|
- 카드명
|
||||||
- sound label
|
- sound label
|
||||||
- 1줄 description
|
- 1줄 description
|
||||||
|
- selected state
|
||||||
|
- hover / focus state
|
||||||
|
|
||||||
예시:
|
예시:
|
||||||
|
|
||||||
@@ -209,7 +219,14 @@ Last Updated: 2026-03-16
|
|||||||
- 이유:
|
- 이유:
|
||||||
- goal + duration + atmosphere가 모두 한 번에 묶여 entry action으로 읽힌다
|
- goal + duration + atmosphere가 모두 한 번에 묶여 entry action으로 읽힌다
|
||||||
|
|
||||||
### E. Weekly Review Entry
|
### E. Selection Rules
|
||||||
|
|
||||||
|
- no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다
|
||||||
|
- review handoff로 들어온 경우에는 handoff preset과 가장 가까운 atmosphere를 preselect한다
|
||||||
|
- 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다
|
||||||
|
- 사용자가 duration을 직접 수정한 뒤에는 atmosphere를 바꿔도 duration 값을 덮어쓰지 않는다
|
||||||
|
|
||||||
|
### F. Weekly Review Entry
|
||||||
|
|
||||||
이건 main stage 바깥의 quiet secondary placement로 둔다.
|
이건 main stage 바깥의 quiet secondary placement로 둔다.
|
||||||
|
|
||||||
@@ -319,6 +336,65 @@ Last Updated: 2026-03-16
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9. Visual Direction
|
||||||
|
|
||||||
|
### Primary Stage
|
||||||
|
|
||||||
|
- no-session `/app`의 주인공은 하나의 넓은 start stage다
|
||||||
|
- 이 stage 안에서:
|
||||||
|
- goal
|
||||||
|
- duration
|
||||||
|
- primary CTA
|
||||||
|
가 먼저 읽혀야 한다
|
||||||
|
- stage는 glassmorphism을 쓰더라도 dashboard 카드처럼 보이면 안 된다
|
||||||
|
- typography와 spacing으로 premium하게 보여야 한다
|
||||||
|
|
||||||
|
### Atmosphere Grid
|
||||||
|
|
||||||
|
- 아래 grid는 decoration이 아니라 실제 선택 surface다
|
||||||
|
- card는 서로 다른 장면으로 충분히 구분돼야 한다
|
||||||
|
- selected state는:
|
||||||
|
- 두꺼운 outline보다 얇은 light ring
|
||||||
|
- 미세한 scale
|
||||||
|
- title/sound contrast 상승
|
||||||
|
정도로 표현하는 편이 맞다
|
||||||
|
|
||||||
|
### Review Dock
|
||||||
|
|
||||||
|
- review는 top-right quiet dock 또는 stage 바깥의 조용한 secondary panel로 둔다
|
||||||
|
- 절대 main CTA와 같은 무게가 되면 안 된다
|
||||||
|
- “이번 주 review”는 읽히되, goal 입력을 방해하면 실패다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Slice 1 구현 원칙
|
||||||
|
|
||||||
|
이번 구현 slice는 `/app`의 **no-session shell**을 먼저 바꾸는 단계다.
|
||||||
|
|
||||||
|
포함:
|
||||||
|
|
||||||
|
- goal input
|
||||||
|
- duration input
|
||||||
|
- 12개 atmosphere grid
|
||||||
|
- selected atmosphere 기반 background 반영
|
||||||
|
- quiet review dock의 기본 위치
|
||||||
|
|
||||||
|
제외:
|
||||||
|
|
||||||
|
- paused resume gate 재설계
|
||||||
|
- `/stats` IA 변경
|
||||||
|
- server custom duration contract
|
||||||
|
|
||||||
|
### Slice 1 임시 계약
|
||||||
|
|
||||||
|
- 사용자는 분 단위 duration을 입력한다
|
||||||
|
- 하지만 server contract가 아직 preset 기반이면, 이번 slice에서는 **입력 시간에 가장 가까운 기존 focus preset**으로 임시 매핑한다
|
||||||
|
- 이 임시 매핑은 다음 slice(`Custom Duration Contract`)에서 실제 duration 연동으로 대체한다
|
||||||
|
- UI에는 이 상태가 과장 없이 드러나야 한다
|
||||||
|
- 예: `지금은 가장 가까운 기본 리듬으로 먼저 들어가요.`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 9. UI 방향
|
## 9. UI 방향
|
||||||
|
|
||||||
### 톤
|
### 톤
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Session Brief
|
# Session Brief
|
||||||
|
|
||||||
Last Updated: 2026-03-15
|
Last Updated: 2026-03-16
|
||||||
|
|
||||||
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
||||||
|
|
||||||
@@ -26,6 +26,12 @@ Last Updated: 2026-03-15
|
|||||||
- microStep은 `/app`에서 제거하고, duration은 분 단위 직접 입력으로 전환한다.
|
- microStep은 `/app`에서 제거하고, duration은 분 단위 직접 입력으로 전환한다.
|
||||||
- scene + sound 조합 카드는 `Atmosphere`로 부르며, 첫 구현은 12개 dummy grid를 사용한다.
|
- scene + sound 조합 카드는 `Atmosphere`로 부르며, 첫 구현은 12개 dummy grid를 사용한다.
|
||||||
- weekly review와 achieved-goal insight는 main stage가 아니라 quiet secondary dock로 유지한다.
|
- weekly review와 achieved-goal insight는 main stage가 아니라 quiet secondary dock로 유지한다.
|
||||||
|
- `/app` Atmosphere Entry Shell 1차 구현을 반영했다.
|
||||||
|
- no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다.
|
||||||
|
- 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다.
|
||||||
|
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
||||||
|
- duration은 우선 가장 가까운 기본 리듬으로 매핑하는 임시 계약을 사용한다.
|
||||||
|
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
||||||
|
|
||||||
- `Paused Session Takeover Flow`를 구현했다.
|
- `Paused Session Takeover Flow`를 구현했다.
|
||||||
- `/app` paused gate에 `새 목표로 전환` 액션이 추가됐다.
|
- `/app` paused gate에 `새 목표로 전환` 액션이 추가됐다.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
- goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다
|
- goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다
|
||||||
- 12개 dummy atmosphere가 4열 그리드로 배치된다
|
- 12개 dummy atmosphere가 4열 그리드로 배치된다
|
||||||
- 진행 상태:
|
- 진행 상태:
|
||||||
- 대기
|
- 구현 완료, browser QA 대기
|
||||||
- 검증:
|
- 검증:
|
||||||
- `/app` no-session browser QA
|
- `/app` no-session browser QA
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
- `70분` 같은 값이 실제 focus duration으로 반영된다
|
- `70분` 같은 값이 실제 focus duration으로 반영된다
|
||||||
- break duration이 정책 기준으로 계산된다
|
- break duration이 정책 기준으로 계산된다
|
||||||
- 진행 상태:
|
- 진행 상태:
|
||||||
- 대기
|
- 다음 작업
|
||||||
- 검증:
|
- 검증:
|
||||||
- start -> `/space` -> timer duration 확인
|
- start -> `/space` -> timer duration 확인
|
||||||
- 커밋 힌트:
|
- 커밋 힌트:
|
||||||
|
|||||||
224
src/widgets/focus-dashboard/model/atmosphereEntry.ts
Normal file
224
src/widgets/focus-dashboard/model/atmosphereEntry.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { getSceneById, type SceneTheme } from '@/entities/scene';
|
||||||
|
import { SOUND_PRESETS } from '@/entities/session';
|
||||||
|
|
||||||
|
const TIMER_PRESETS = [
|
||||||
|
{ id: '25-5', label: '25/5', focusMinutes: 25 },
|
||||||
|
{ id: '50-10', label: '50/10', focusMinutes: 50 },
|
||||||
|
{ id: '90-20', label: '90/20', focusMinutes: 90 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DURATION_SUGGESTIONS = [25, 45, 70, 90] as const;
|
||||||
|
|
||||||
|
export interface AtmosphereOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sceneId: string;
|
||||||
|
soundPresetId: string | null;
|
||||||
|
description: string;
|
||||||
|
caption: string;
|
||||||
|
scene: SceneTheme;
|
||||||
|
soundLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAtmosphereOption = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
sceneId: string,
|
||||||
|
soundPresetId: string | null,
|
||||||
|
description: string,
|
||||||
|
caption: string,
|
||||||
|
): AtmosphereOption => {
|
||||||
|
const scene = getSceneById(sceneId);
|
||||||
|
if (!scene) {
|
||||||
|
throw new Error(`Unknown scene for atmosphere option: ${sceneId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
sceneId,
|
||||||
|
soundPresetId,
|
||||||
|
description,
|
||||||
|
caption,
|
||||||
|
scene,
|
||||||
|
soundLabel:
|
||||||
|
SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ATMOSPHERE_OPTIONS: AtmosphereOption[] = [
|
||||||
|
createAtmosphereOption(
|
||||||
|
'rain-window',
|
||||||
|
'Rain Window',
|
||||||
|
'rain-window',
|
||||||
|
'rain-focus',
|
||||||
|
'비 소리 위로 조용히 문장을 붙잡기 좋은 흐름.',
|
||||||
|
'조용한 시작',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'quiet-library',
|
||||||
|
'Quiet Library',
|
||||||
|
'quiet-library',
|
||||||
|
'deep-white',
|
||||||
|
'소음 없이 길게 읽고 정리할 때 안정적인 조합.',
|
||||||
|
'길게 읽는 날',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'dawn-cafe',
|
||||||
|
'Dawn Cafe',
|
||||||
|
'dawn-cafe',
|
||||||
|
'cafe-work',
|
||||||
|
'가볍게 손을 움직이며 초안을 시작하기 좋은 온도.',
|
||||||
|
'워밍업용',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'forest-draft',
|
||||||
|
'Forest Draft',
|
||||||
|
'forest',
|
||||||
|
'forest-birds',
|
||||||
|
'딥워크 진입 전에 숨을 고르게 만드는 기본 조합.',
|
||||||
|
'기본 리듬',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'fireplace-glow',
|
||||||
|
'Fireplace Glow',
|
||||||
|
'fireplace',
|
||||||
|
'fireplace',
|
||||||
|
'밤에 닫히지 않는 일 하나를 끝까지 가져가고 싶을 때.',
|
||||||
|
'늦은 시간용',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'deep-night-desk',
|
||||||
|
'Deep Night Desk',
|
||||||
|
'city-night',
|
||||||
|
'deep-white',
|
||||||
|
'도시의 불빛은 멀리 두고 화면 안의 일만 남기는 조합.',
|
||||||
|
'몰입 유지',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'snow-light',
|
||||||
|
'Snow Light',
|
||||||
|
'snow-mountain',
|
||||||
|
'deep-white',
|
||||||
|
'머리를 식히면서 구조를 정리해야 할 때 선명한 공기.',
|
||||||
|
'정리용',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'sun-window',
|
||||||
|
'Sun Window',
|
||||||
|
'sun-window',
|
||||||
|
'silent',
|
||||||
|
'과하게 자극적이지 않게 아침 에너지를 가져오는 장면.',
|
||||||
|
'낮 시간용',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'ocean-still',
|
||||||
|
'Ocean Still',
|
||||||
|
'wave-sound',
|
||||||
|
'ocean-calm',
|
||||||
|
'넓은 생각이 필요한 기획이나 리서치에 어울리는 흐름.',
|
||||||
|
'넓게 생각하기',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'orbit-night',
|
||||||
|
'Orbit Night',
|
||||||
|
'outer-space',
|
||||||
|
'deep-white',
|
||||||
|
'길고 깊은 블록에 들어갈 때 외부 자극을 멀리 밀어낸다.',
|
||||||
|
'장시간 집중',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'rain-notes',
|
||||||
|
'Rain Notes',
|
||||||
|
'rain-window',
|
||||||
|
'deep-white',
|
||||||
|
'빗소리 대신 더 조용한 백색 소음으로 문장만 남긴 버전.',
|
||||||
|
'더 낮은 자극',
|
||||||
|
),
|
||||||
|
createAtmosphereOption(
|
||||||
|
'quiet-pages',
|
||||||
|
'Quiet Pages',
|
||||||
|
'quiet-library',
|
||||||
|
'silent',
|
||||||
|
'읽기와 쓰기 사이를 오갈 때 가장 얇은 존재감으로 머문다.',
|
||||||
|
'완전 조용함',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENTRY_DURATION_SUGGESTIONS = [...DURATION_SUGGESTIONS];
|
||||||
|
|
||||||
|
export const parseDurationMinutes = (value: string) => {
|
||||||
|
const digitsOnly = value.replace(/[^\d]/g, '');
|
||||||
|
if (!digitsOnly) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(digitsOnly);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(10, Math.min(180, parsed));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeDurationDraft = (value: string) => {
|
||||||
|
const digitsOnly = value.replace(/[^\d]/g, '');
|
||||||
|
if (!digitsOnly) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(digitsOnly);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(Math.max(10, Math.min(180, parsed)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimerPresetMetaById = (timerPresetId: string) => {
|
||||||
|
return TIMER_PRESETS.find((preset) => preset.id === timerPresetId) ?? TIMER_PRESETS[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveNearestTimerPreset = (minutes: number) => {
|
||||||
|
return TIMER_PRESETS.reduce((best, candidate) => {
|
||||||
|
const bestDiff = Math.abs(best.focusMinutes - minutes);
|
||||||
|
const candidateDiff = Math.abs(candidate.focusMinutes - minutes);
|
||||||
|
|
||||||
|
if (candidateDiff < bestDiff) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateDiff === bestDiff && candidate.focusMinutes > best.focusMinutes) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecommendedDurationMinutes = (option: AtmosphereOption) => {
|
||||||
|
return getTimerPresetMetaById(option.scene.recommendedTimerPresetId).focusMinutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAtmosphereOptionById = (id: string) => {
|
||||||
|
return ATMOSPHERE_OPTIONS.find((option) => option.id === id) ?? ATMOSPHERE_OPTIONS[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAtmosphereOptionForSelection = (
|
||||||
|
sceneId?: string | null,
|
||||||
|
soundPresetId?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!sceneId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
ATMOSPHERE_OPTIONS.find(
|
||||||
|
(option) =>
|
||||||
|
option.sceneId === sceneId &&
|
||||||
|
(soundPresetId == null || option.soundPresetId === soundPresetId),
|
||||||
|
) ??
|
||||||
|
ATMOSPHERE_OPTIONS.find((option) => option.sceneId === sceneId) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
288
src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx
Normal file
288
src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode, RefObject } from 'react';
|
||||||
|
import { getSceneCardPhotoUrl } from '@/entities/scene';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
import type { AtmosphereOption } from '../model/atmosphereEntry';
|
||||||
|
|
||||||
|
const shellCardClass =
|
||||||
|
'rounded-[2rem] border border-white/12 bg-[#0f1115]/26 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl';
|
||||||
|
const inputShellClass =
|
||||||
|
'w-full rounded-[1.5rem] 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';
|
||||||
|
|
||||||
|
interface AppAtmosphereEntryShellProps {
|
||||||
|
canStart: boolean;
|
||||||
|
durationDraft: string;
|
||||||
|
durationHelper: string;
|
||||||
|
durationInputLabel: string;
|
||||||
|
durationPlaceholder: string;
|
||||||
|
durationSuggestions: number[];
|
||||||
|
goalDraft: string;
|
||||||
|
goalInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
goalPlaceholder: string;
|
||||||
|
isStartingSession: boolean;
|
||||||
|
reviewEntry?: ReactNode;
|
||||||
|
selectedAtmosphere: AtmosphereOption;
|
||||||
|
sessionLookupError?: string | null;
|
||||||
|
startButtonLabel: string;
|
||||||
|
startButtonLoadingLabel: string;
|
||||||
|
atmosphereOptions: AtmosphereOption[];
|
||||||
|
atmosphereTitle: string;
|
||||||
|
atmosphereBody: string;
|
||||||
|
onDurationChange: (value: string) => void;
|
||||||
|
onGoalChange: (value: string) => void;
|
||||||
|
onSelectAtmosphere: (id: string) => void;
|
||||||
|
onSelectDuration: (minutes: number) => void;
|
||||||
|
onStartSession: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppAtmosphereEntryShell = ({
|
||||||
|
canStart,
|
||||||
|
durationDraft,
|
||||||
|
durationHelper,
|
||||||
|
durationInputLabel,
|
||||||
|
durationPlaceholder,
|
||||||
|
durationSuggestions,
|
||||||
|
goalDraft,
|
||||||
|
goalInputRef,
|
||||||
|
goalPlaceholder,
|
||||||
|
isStartingSession,
|
||||||
|
reviewEntry,
|
||||||
|
selectedAtmosphere,
|
||||||
|
sessionLookupError,
|
||||||
|
startButtonLabel,
|
||||||
|
startButtonLoadingLabel,
|
||||||
|
atmosphereOptions,
|
||||||
|
atmosphereTitle,
|
||||||
|
atmosphereBody,
|
||||||
|
onDurationChange,
|
||||||
|
onGoalChange,
|
||||||
|
onSelectAtmosphere,
|
||||||
|
onSelectDuration,
|
||||||
|
onStartSession,
|
||||||
|
}: AppAtmosphereEntryShellProps) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.18fr)_minmax(19rem,0.82fr)]">
|
||||||
|
<section className={cn(shellCardClass, 'px-6 py-6 md:px-8 md:py-8')}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="text-[2rem] font-light leading-[1.04] tracking-[-0.045em] text-white md:text-[2.75rem]">
|
||||||
|
이번엔 무엇을 붙잡을까요?
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-[34rem] text-[15px] leading-[1.7] text-white/68 md:text-[15.5px]">
|
||||||
|
목표는 한 줄이면 충분해요. 얼마나 붙잡을지와 어떤 분위기로 들어갈지만 정하면 바로
|
||||||
|
집중 화면으로 이어집니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-5">
|
||||||
|
<label className="block space-y-2.5">
|
||||||
|
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
||||||
|
이번 목표
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={goalInputRef}
|
||||||
|
value={goalDraft}
|
||||||
|
onChange={(event) => onGoalChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
onStartSession();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={goalPlaceholder}
|
||||||
|
className={cn(
|
||||||
|
inputShellClass,
|
||||||
|
'text-[1.12rem] font-light tracking-[-0.025em] placeholder:text-white/34 md:text-[1.34rem]',
|
||||||
|
)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||||
|
<label className="block space-y-2.5">
|
||||||
|
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
||||||
|
{durationInputLabel}
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={durationDraft}
|
||||||
|
onChange={(event) => onDurationChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
onStartSession();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder={durationPlaceholder}
|
||||||
|
className={cn(
|
||||||
|
inputShellClass,
|
||||||
|
'pr-12 text-[1.02rem] font-medium tracking-[-0.02em] placeholder:text-white/30',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-sm text-white/48">
|
||||||
|
분
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] leading-[1.6] text-white/48">{durationHelper}</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 md:max-w-[14.5rem] md:justify-end">
|
||||||
|
{durationSuggestions.map((minutes) => (
|
||||||
|
<button
|
||||||
|
key={minutes}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDuration(minutes)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border border-white/12 bg-white/[0.05] px-3 py-1.5 text-[12px] font-medium text-white/76 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white',
|
||||||
|
durationDraft === String(minutes) && 'border-white/28 bg-white/[0.13] text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{minutes}분
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pt-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/40">
|
||||||
|
Selected Atmosphere
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] font-medium text-white/84">{selectedAtmosphere.name}</p>
|
||||||
|
<p className="text-[12px] text-white/52">
|
||||||
|
{selectedAtmosphere.soundLabel} · {selectedAtmosphere.caption}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStartSession}
|
||||||
|
disabled={!canStart}
|
||||||
|
className={primaryButtonClass}
|
||||||
|
>
|
||||||
|
{isStartingSession ? startButtonLoadingLabel : startButtonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessionLookupError ? (
|
||||||
|
<p className="text-sm text-amber-100/80">{sessionLookupError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className={cn(shellCardClass, 'overflow-hidden')}>
|
||||||
|
<div
|
||||||
|
className="relative min-h-[18rem] bg-cover bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(180deg, rgba(5,10,18,0.08) 0%, rgba(5,10,18,0.74) 100%), url('${getSceneCardPhotoUrl(selectedAtmosphere.scene)}')`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),rgba(255,255,255,0)_42%)]" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-5">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/52">
|
||||||
|
Selected Atmosphere
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-[1.28rem] font-medium tracking-[-0.03em] text-white">
|
||||||
|
{selectedAtmosphere.name}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-[22rem] text-[13px] leading-[1.65] text-white/72">
|
||||||
|
{selectedAtmosphere.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/16 bg-white/[0.08] px-3 py-1 text-[11px] text-white/74">
|
||||||
|
{selectedAtmosphere.soundLabel}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/16 bg-white/[0.08] px-3 py-1 text-[11px] text-white/74">
|
||||||
|
{selectedAtmosphere.scene.recommendedTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewEntry}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||||
|
Atmosphere
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-[1.2rem] font-medium tracking-[-0.03em] text-white md:text-[1.4rem]">
|
||||||
|
{atmosphereTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-[42rem] text-[13px] leading-[1.7] text-white/60 md:text-[13.5px]">
|
||||||
|
{atmosphereBody}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-white/42 md:text-right">
|
||||||
|
총 {atmosphereOptions.length}개의 Atmosphere
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{atmosphereOptions.map((option) => {
|
||||||
|
const isSelected = option.id === selectedAtmosphere.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectAtmosphere(option.id)}
|
||||||
|
className={cn(
|
||||||
|
'group relative min-h-[12.5rem] overflow-hidden rounded-[1.65rem] border text-left transition duration-300 ease-out',
|
||||||
|
isSelected
|
||||||
|
? 'border-white/28 shadow-[0_16px_36px_rgba(2,6,23,0.22)]'
|
||||||
|
: 'border-white/10 hover:border-white/18',
|
||||||
|
)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center transition duration-500 ease-out group-hover:scale-[1.04]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(180deg, rgba(7,10,14,0.08) 0%, rgba(7,10,14,0.88) 100%), url('${getSceneCardPhotoUrl(option.scene)}')`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),rgba(255,255,255,0)_44%)]" />
|
||||||
|
<div className="relative flex h-full flex-col justify-between p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="rounded-full border border-white/14 bg-white/[0.08] px-2.5 py-1 text-[11px] text-white/72">
|
||||||
|
{option.caption}
|
||||||
|
</span>
|
||||||
|
{isSelected ? (
|
||||||
|
<span className="rounded-full border border-white/18 bg-white/[0.14] px-2.5 py-1 text-[11px] font-medium text-white">
|
||||||
|
선택됨
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[1rem] font-medium tracking-[-0.025em] text-white">
|
||||||
|
{option.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 line-clamp-2 text-[12px] leading-[1.55] text-white/66">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-white/56">
|
||||||
|
<span>{option.soundLabel}</span>
|
||||||
|
<span>{option.scene.recommendedTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-white/38">
|
||||||
|
고른 Atmosphere는 배경과 사운드가 함께 적용되고, 시간은 가장 가까운 기본 리듬으로
|
||||||
|
먼저 맞춰집니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,18 @@ import { focusSessionApi, type FocusSession } from '@/features/focus-session/api
|
|||||||
import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
|
import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
import { cn } from '@/shared/lib/cn';
|
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_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_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',
|
label: '숲 · 50/10 · Forest Birds',
|
||||||
},
|
},
|
||||||
} as const;
|
} 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 = {
|
const entryCopy = {
|
||||||
eyebrow: 'VibeRoom',
|
eyebrow: 'VibeRoom',
|
||||||
title: '지금 붙잡을 한 가지',
|
|
||||||
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
|
|
||||||
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||||
microStepLabel: '지금 할 한 조각',
|
durationLabel: '예상 시간(분)',
|
||||||
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
|
durationPlaceholder: '예: 70',
|
||||||
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
|
durationHelper: '입력한 시간은 지금 가장 가까운 기본 리듬으로 먼저 맞춰서 들어가요.',
|
||||||
startNow: '지금 시작',
|
startNow: '이 분위기로 들어가기',
|
||||||
startLoading: '몰입 준비 중...',
|
startLoading: '입장 준비 중...',
|
||||||
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
|
atmosphereTitle: '어떤 분위기에서 들어갈까요?',
|
||||||
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
|
atmosphereBody:
|
||||||
|
'배경과 사운드는 같이 움직여요. 오늘 goal에 맞는 atmosphere 하나만 고르면 바로 들어갈 수 있어요.',
|
||||||
resumeEyebrow: 'Resume',
|
resumeEyebrow: 'Resume',
|
||||||
resumeRunning: '진행 중인 세션이 있어요.',
|
resumeRunning: '진행 중인 세션이 있어요.',
|
||||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||||
@@ -84,19 +96,11 @@ const entryCopy = {
|
|||||||
|
|
||||||
const goalCardClass =
|
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';
|
'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 =
|
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';
|
'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 =
|
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]';
|
'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) => {
|
const resolveSoundLabel = (soundPresetId?: string | null) => {
|
||||||
if (!soundPresetId) {
|
if (!soundPresetId) {
|
||||||
return 'Silent';
|
return 'Silent';
|
||||||
@@ -142,9 +146,26 @@ export const FocusDashboardWidget = () => {
|
|||||||
|
|
||||||
return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null;
|
return REVIEW_ENTRY_PRESETS[reviewEntryPreset as keyof typeof REVIEW_ENTRY_PRESETS] ?? null;
|
||||||
}, [reviewEntryPreset]);
|
}, [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 [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 [isStartingSession, setIsStartingSession] = useState(false);
|
||||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||||
@@ -156,20 +177,35 @@ export const FocusDashboardWidget = () => {
|
|||||||
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
|
const [focusGoalAfterTakeover, setFocusGoalAfterTakeover] = useState(false);
|
||||||
|
|
||||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
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(() => {
|
const activeScene = useMemo(() => {
|
||||||
return getSceneById(currentSession?.sceneId ?? reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
|
return getSceneById(currentSession?.sceneId ?? selectedAtmosphere.sceneId) ?? SCENE_THEMES[0];
|
||||||
}, [currentSession?.sceneId, reviewEntryPresetConfig?.sceneId]);
|
}, [currentSession?.sceneId, selectedAtmosphere.sceneId]);
|
||||||
|
|
||||||
const activeRitualMeta = useMemo(() => {
|
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);
|
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
|
||||||
|
|
||||||
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
|
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
|
||||||
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
|
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
|
||||||
|
|
||||||
const trimmedGoal = goalDraft.trim();
|
const trimmedGoal = goalDraft.trim();
|
||||||
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
|
const canStart =
|
||||||
|
trimmedGoal.length > 0 &&
|
||||||
|
parsedDurationMinutes !== null &&
|
||||||
|
!isStartingSession &&
|
||||||
|
!currentSession;
|
||||||
const hasEnoughWeeklyData =
|
const hasEnoughWeeklyData =
|
||||||
weeklySummary.last7Days.startedSessions >= 3 &&
|
weeklySummary.last7Days.startedSessions >= 3 &&
|
||||||
(weeklySummary.last7Days.completedSessions >= 2 ||
|
(weeklySummary.last7Days.completedSessions >= 2 ||
|
||||||
@@ -193,7 +229,12 @@ export const FocusDashboardWidget = () => {
|
|||||||
const reviewTeaserSummary = isPro ? review.carryForward.keepDoing : review.snapshotSummary;
|
const reviewTeaserSummary = isPro ? review.carryForward.keepDoing : review.snapshotSummary;
|
||||||
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
|
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
|
||||||
const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta;
|
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 isRunningSession = currentSession?.state === 'running';
|
||||||
const isPausedSession = currentSession?.state === 'paused';
|
const isPausedSession = currentSession?.state === 'paused';
|
||||||
|
|
||||||
@@ -257,9 +298,30 @@ export const FocusDashboardWidget = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectSuggestion = (label: string) => {
|
const resetEntryDrafts = () => {
|
||||||
setGoalDraft(label);
|
setGoalDraft('');
|
||||||
goalInputRef.current?.focus();
|
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 () => {
|
const handleStartSession = async () => {
|
||||||
@@ -270,15 +332,19 @@ export const FocusDashboardWidget = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedDurationMinutes === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsStartingSession(true);
|
setIsStartingSession(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await focusSessionApi.startSession({
|
await focusSessionApi.startSession({
|
||||||
goal: trimmedGoal,
|
goal: trimmedGoal,
|
||||||
microStep: microStepDraft.trim() || null,
|
microStep: null,
|
||||||
sceneId: reviewEntryPresetConfig?.sceneId ?? DEFAULT_SCENE_ID,
|
sceneId: selectedAtmosphere.sceneId,
|
||||||
soundPresetId: reviewEntryPresetConfig?.soundPresetId ?? DEFAULT_SOUND_ID,
|
soundPresetId: selectedAtmosphere.soundPresetId,
|
||||||
timerPresetId: reviewEntryPresetConfig?.timerPresetId ?? DEFAULT_TIMER_ID,
|
timerPresetId: resolvedTimerPreset.id,
|
||||||
entryPoint: 'space-setup',
|
entryPoint: 'space-setup',
|
||||||
});
|
});
|
||||||
router.push('/space');
|
router.push('/space');
|
||||||
@@ -338,8 +404,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
setCurrentSession(null);
|
setCurrentSession(null);
|
||||||
setIsTakeoverSheetOpen(false);
|
setIsTakeoverSheetOpen(false);
|
||||||
setSessionLookupError(null);
|
setSessionLookupError(null);
|
||||||
setGoalDraft('');
|
resetEntryDrafts();
|
||||||
setMicroStepDraft('');
|
|
||||||
setFocusGoalAfterTakeover(true);
|
setFocusGoalAfterTakeover(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTakeoverError(
|
setTakeoverError(
|
||||||
@@ -478,112 +543,24 @@ export const FocusDashboardWidget = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<AppAtmosphereEntryShell
|
||||||
<div className={goalCardClass}>
|
canStart={canStart}
|
||||||
<div className="space-y-3 text-center">
|
durationDraft={durationDraft}
|
||||||
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
|
durationHelper={durationHelper}
|
||||||
{entryCopy.title}
|
durationInputLabel={entryCopy.durationLabel}
|
||||||
</h1>
|
durationPlaceholder={entryCopy.durationPlaceholder}
|
||||||
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
|
durationSuggestions={ENTRY_DURATION_SUGGESTIONS}
|
||||||
{entryCopy.description}
|
goalDraft={goalDraft}
|
||||||
</p>
|
goalInputRef={goalInputRef}
|
||||||
</div>
|
goalPlaceholder={entryCopy.goalPlaceholder}
|
||||||
|
isStartingSession={isStartingSession}
|
||||||
<div className="mt-8 space-y-4">
|
reviewEntry={
|
||||||
<label className="block">
|
shouldShowWeeklyReviewTeaser ? (
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{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
|
<Link
|
||||||
href="/stats"
|
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"
|
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="flex flex-col gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||||
{entryCopy.reviewEyebrow}
|
{entryCopy.reviewEyebrow}
|
||||||
@@ -591,18 +568,33 @@ export const FocusDashboardWidget = () => {
|
|||||||
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/88">
|
||||||
{reviewTeaserTitle}
|
{reviewTeaserTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
<p className="mt-2 text-[13px] leading-[1.6] text-white/62">
|
||||||
{reviewTeaserSummary}
|
{reviewTeaserSummary}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
|
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
|
<span className="inline-flex items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
|
||||||
{reviewTeaserCta}
|
{reviewTeaserCta}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user