feat(app): weekly review teaser 진입 추가
This commit is contained in:
@@ -320,6 +320,13 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
|
||||
- Weekly Review Reframe
|
||||
- Premium Ambience System
|
||||
|
||||
### 방금 완료
|
||||
|
||||
- `Weekly Review Entry Flow` Slice 1
|
||||
- `/app` hero 아래 low-emphasis weekly review teaser 추가
|
||||
- 충분한 최근 7일 데이터가 있을 때만 `/stats` primary entry를 노출
|
||||
- 다음 구현은 `/stats -> /app` return handoff
|
||||
|
||||
---
|
||||
|
||||
## 8. 의사결정 규칙
|
||||
@@ -355,4 +362,4 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
|
||||
|
||||
현재 위치:
|
||||
|
||||
> `3. Break refinement`를 진행 중이다.
|
||||
> `3. Break refinement`를 마무리했고, `4. Weekly Review`의 entry flow 구현을 시작했다.
|
||||
|
||||
@@ -114,6 +114,10 @@ Last Updated: 2026-03-14
|
||||
- `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영
|
||||
- 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용
|
||||
- recovery는 API 집계가 아직 없을 때 limited state로 조용히 표시
|
||||
- `/app -> /stats` primary entry의 1차 연결:
|
||||
- current session이 없고 최근 7일 데이터가 충분할 때 `/app` hero 아래에 low-emphasis `Weekly Review` teaser를 노출한다
|
||||
- teaser는 `/stats`로 연결되며, hero CTA보다 한 단계 아래 시각 우선순위를 유지한다
|
||||
- `/stats -> /app` return handoff는 다음 slice로 남겨둔다
|
||||
- paywall / plan / landing 메시지 재정렬:
|
||||
- paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성
|
||||
- landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
||||
|
||||
@@ -83,6 +83,10 @@ Last Updated: 2026-03-14
|
||||
- hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다.
|
||||
- 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다.
|
||||
- recovery는 API 집계가 아직 없을 때 limited state로 표시한다.
|
||||
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
||||
- current session이 없고 최근 7일 데이터가 충분하면 hero 아래에 weekly review teaser가 보인다.
|
||||
- teaser는 `/stats`로 이동시키되, main start CTA보다 낮은 강조로 유지한다.
|
||||
- 다음 구현은 `/stats` 마지막 CTA의 `/app` return handoff다.
|
||||
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
||||
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
||||
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
||||
|
||||
16
docs/work.md
16
docs/work.md
@@ -92,22 +92,26 @@
|
||||
## 작업 4
|
||||
|
||||
- 제목: `Weekly Review Entry Flow` 구현
|
||||
- 목적:
|
||||
- 목적:
|
||||
- `15_app_stats_entry_flow_spec.md` 기준으로 `/app -> /stats -> /app` loop를 실제 제품 루프에 연결한다.
|
||||
- 변경 범위:
|
||||
- 변경 범위:
|
||||
- `/app` weekly review teaser
|
||||
- `/stats` 마지막 CTA의 `/app` return handoff
|
||||
- `/app` review-aware return state
|
||||
- 제외 범위:
|
||||
- 제외 범위:
|
||||
- `/stats`에서 바로 `/space` auto-start 금지
|
||||
- review teaser를 hero보다 강하게 노출하는 것 금지
|
||||
- planner/todo 회고 흐름으로 확장 금지
|
||||
- 완료 조건:
|
||||
- 완료 조건:
|
||||
- `/app`에서 review로 들어가는 primary path가 생긴다.
|
||||
- `/stats`를 보고 다시 `/app`으로 돌아와 다음 세션을 시작할 수 있다.
|
||||
- review가 읽고 끝나는 페이지가 아니라 next-session ritual처럼 동작한다.
|
||||
- 검증:
|
||||
- 진행 상태:
|
||||
- Slice 1 완료: `/app` hero 아래 low-emphasis weekly review teaser 추가
|
||||
- 다음 slice: `/stats` 마지막 CTA의 `/app` return handoff
|
||||
- 그 다음 slice: `/app` review-aware return state
|
||||
- 검증:
|
||||
- `/app -> /stats -> /app` 실제 브라우저 플로우 확인
|
||||
- hero와 teaser의 시각 우선순위 확인
|
||||
- 커밋 힌트:
|
||||
- 커밋 힌트:
|
||||
- feat(app): weekly-review-entry-flow
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
||||
@@ -9,6 +10,7 @@ import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { useFocusStats } from '@/features/stats';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
@@ -36,6 +38,10 @@ const entryCopy = {
|
||||
resumeMicroStepLabel: '마지막 한 조각',
|
||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||
reviewEyebrow: 'Weekly Review',
|
||||
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
||||
reviewCta: '주간 review 보기',
|
||||
reviewHelper: '다음 세션 전에 가볍게 보고 갈 수 있어요.',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
||||
};
|
||||
@@ -65,6 +71,7 @@ export const FocusDashboardWidget = () => {
|
||||
const router = useRouter();
|
||||
const { plan, isPro, setPlan } = usePlanTier();
|
||||
const { sceneAssetMap } = useMediaCatalog();
|
||||
const { review, summary: weeklySummary } = useFocusStats();
|
||||
|
||||
const [goalDraft, setGoalDraft] = useState('');
|
||||
const [microStepDraft, setMicroStepDraft] = useState('');
|
||||
@@ -89,6 +96,10 @@ export const FocusDashboardWidget = () => {
|
||||
|
||||
const trimmedGoal = goalDraft.trim();
|
||||
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
|
||||
const hasEnoughWeeklyData =
|
||||
weeklySummary.last7Days.startedSessions >= 3 &&
|
||||
(weeklySummary.last7Days.completedSessions >= 2 ||
|
||||
review.recoveryQuality.availability === 'ready');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -166,6 +177,9 @@ export const FocusDashboardWidget = () => {
|
||||
router.push('/space');
|
||||
};
|
||||
|
||||
const shouldShowWeeklyReviewTeaser =
|
||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||
<div
|
||||
@@ -226,102 +240,129 @@ export const FocusDashboardWidget = () => {
|
||||
<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(
|
||||
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">
|
||||
{entryCopy.ritualHint}
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</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}
|
||||
>
|
||||
{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>
|
||||
|
||||
{sessionLookupError ? (
|
||||
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sessionLookupError ? (
|
||||
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
|
||||
{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">
|
||||
{entryCopy.reviewTitle}
|
||||
</p>
|
||||
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
||||
{review.snapshotSummary}
|
||||
</p>
|
||||
<p className="mt-2 text-[12px] text-white/44">{entryCopy.reviewHelper}</p>
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center text-[12px] font-medium tracking-[0.04em] text-white/74">
|
||||
{entryCopy.reviewCta}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user