feat(app): weekly review return handoff 연결
This commit is contained in:
@@ -325,7 +325,10 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
|
|||||||
- `Weekly Review Entry Flow` Slice 1
|
- `Weekly Review Entry Flow` Slice 1
|
||||||
- `/app` hero 아래 low-emphasis weekly review teaser 추가
|
- `/app` hero 아래 low-emphasis weekly review teaser 추가
|
||||||
- 충분한 최근 7일 데이터가 있을 때만 `/stats` primary entry를 노출
|
- 충분한 최근 7일 데이터가 있을 때만 `/stats` primary entry를 노출
|
||||||
- 다음 구현은 `/stats -> /app` return handoff
|
- `Weekly Review Entry Flow` Slice 2
|
||||||
|
- `/stats` 마지막 CTA가 `/app?review=weekly&carryHint=...` handoff로 연결
|
||||||
|
- `/app`은 review-aware return hint를 먼저 보여주고, goal 입력은 그대로 사용자가 결정한다
|
||||||
|
- 다음 구현은 Pro personalized handoff
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ Last Updated: 2026-03-14
|
|||||||
- `/app -> /stats` primary entry의 1차 연결:
|
- `/app -> /stats` primary entry의 1차 연결:
|
||||||
- current session이 없고 최근 7일 데이터가 충분할 때 `/app` hero 아래에 low-emphasis `Weekly Review` teaser를 노출한다
|
- current session이 없고 최근 7일 데이터가 충분할 때 `/app` hero 아래에 low-emphasis `Weekly Review` teaser를 노출한다
|
||||||
- teaser는 `/stats`로 연결되며, hero CTA보다 한 단계 아래 시각 우선순위를 유지한다
|
- teaser는 `/stats`로 연결되며, hero CTA보다 한 단계 아래 시각 우선순위를 유지한다
|
||||||
- `/stats -> /app` return handoff는 다음 slice로 남겨둔다
|
- `/stats -> /app` handoff의 2차 연결:
|
||||||
|
- `/stats` 마지막 CTA는 `/app?review=weekly&carryHint=...&entryPreset=forest-50-10`으로 연결된다
|
||||||
|
- `/app`은 이 query를 받아 hero 위에 review-aware return hint를 노출한다
|
||||||
|
- goal과 microStep은 자동 입력하지 않고, 방향만 가볍게 제안한다
|
||||||
- paywall / plan / landing 메시지 재정렬:
|
- paywall / plan / landing 메시지 재정렬:
|
||||||
- paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성
|
- paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성
|
||||||
- landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
- landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ Last Updated: 2026-03-14
|
|||||||
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
||||||
- current session이 없고 최근 7일 데이터가 충분하면 hero 아래에 weekly review teaser가 보인다.
|
- current session이 없고 최근 7일 데이터가 충분하면 hero 아래에 weekly review teaser가 보인다.
|
||||||
- teaser는 `/stats`로 이동시키되, main start CTA보다 낮은 강조로 유지한다.
|
- teaser는 `/stats`로 이동시키되, main start CTA보다 낮은 강조로 유지한다.
|
||||||
- 다음 구현은 `/stats` 마지막 CTA의 `/app` return handoff다.
|
- `/stats` 마지막 CTA의 `/app` return handoff가 연결됐다.
|
||||||
|
- carry-forward CTA는 `/app?review=weekly&carryHint=...`로 돌아온다.
|
||||||
|
- `/app`은 review-aware return hint를 먼저 보여주되, goal은 사용자가 직접 입력하게 유지한다.
|
||||||
|
- 다음 구현은 Pro personalized handoff다.
|
||||||
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
||||||
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
||||||
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
||||||
|
|||||||
@@ -108,8 +108,9 @@
|
|||||||
- review가 읽고 끝나는 페이지가 아니라 next-session ritual처럼 동작한다.
|
- review가 읽고 끝나는 페이지가 아니라 next-session ritual처럼 동작한다.
|
||||||
- 진행 상태:
|
- 진행 상태:
|
||||||
- Slice 1 완료: `/app` hero 아래 low-emphasis weekly review teaser 추가
|
- Slice 1 완료: `/app` hero 아래 low-emphasis weekly review teaser 추가
|
||||||
- 다음 slice: `/stats` 마지막 CTA의 `/app` return handoff
|
- Slice 2 완료: `/stats` 마지막 CTA가 `/app?review=weekly&carryHint=...` handoff로 연결
|
||||||
- 그 다음 slice: `/app` review-aware return state
|
- `/app`은 query를 받아 review-aware return hint를 먼저 보여준다
|
||||||
|
- 다음 slice: Pro personalized handoff
|
||||||
- 검증:
|
- 검증:
|
||||||
- `/app -> /stats -> /app` 실제 브라우저 플로우 확인
|
- `/app -> /stats -> /app` 실제 브라우저 플로우 확인
|
||||||
- hero와 teaser의 시각 우선순위 확인
|
- hero와 teaser의 시각 우선순위 확인
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface WeeklyReviewSection {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReviewCarryHint = 'smaller' | 'closure' | 'start' | 'steady';
|
||||||
|
|
||||||
export interface WeeklyReviewViewModel {
|
export interface WeeklyReviewViewModel {
|
||||||
periodLabel: string;
|
periodLabel: string;
|
||||||
snapshotTitle: string;
|
snapshotTitle: string;
|
||||||
@@ -77,6 +79,7 @@ export interface WeeklyReviewViewModel {
|
|||||||
recoveryQuality: WeeklyReviewSection;
|
recoveryQuality: WeeklyReviewSection;
|
||||||
completionQuality: WeeklyReviewSection;
|
completionQuality: WeeklyReviewSection;
|
||||||
carryForward: {
|
carryForward: {
|
||||||
|
hintKey: ReviewCarryHint;
|
||||||
keepDoing: string;
|
keepDoing: string;
|
||||||
tryNext: string;
|
tryNext: string;
|
||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
@@ -151,21 +154,32 @@ const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['c
|
|||||||
? copy.stats.reviewCarryKeep(summary.last7Days.bestDayLabel)
|
? copy.stats.reviewCarryKeep(summary.last7Days.bestDayLabel)
|
||||||
: copy.stats.reviewCarryKeepGeneric;
|
: copy.stats.reviewCarryKeepGeneric;
|
||||||
|
|
||||||
|
let hintKey: ReviewCarryHint = 'steady';
|
||||||
let tryNext: string = copy.stats.reviewCarryTryDefault;
|
let tryNext: string = copy.stats.reviewCarryTryDefault;
|
||||||
|
|
||||||
if (summary.last7Days.carriedOverCount >= 2) {
|
if (summary.last7Days.carriedOverCount >= 2) {
|
||||||
|
hintKey = 'smaller';
|
||||||
tryNext = copy.stats.reviewCarryTrySmaller;
|
tryNext = copy.stats.reviewCarryTrySmaller;
|
||||||
} else if (completionRate < 0.45) {
|
} else if (completionRate < 0.45) {
|
||||||
|
hintKey = 'closure';
|
||||||
tryNext = copy.stats.reviewCarryTryClosure;
|
tryNext = copy.stats.reviewCarryTryClosure;
|
||||||
} else if (summary.last7Days.startedSessions <= 3) {
|
} else if (summary.last7Days.startedSessions <= 3) {
|
||||||
|
hintKey = 'start';
|
||||||
tryNext = copy.stats.reviewCarryTryStart;
|
tryNext = copy.stats.reviewCarryTryStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
review: 'weekly',
|
||||||
|
carryHint: hintKey,
|
||||||
|
entryPreset: 'forest-50-10',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
hintKey,
|
||||||
keepDoing,
|
keepDoing,
|
||||||
tryNext,
|
tryNext,
|
||||||
ctaLabel: copy.stats.reviewCarryCta,
|
ctaLabel: copy.stats.reviewCarryCta,
|
||||||
ctaHref: '/app',
|
ctaHref: `/app?${params.toString()}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
||||||
import { usePlanTier } from '@/entities/plan';
|
import { usePlanTier } from '@/entities/plan';
|
||||||
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
||||||
@@ -10,7 +10,7 @@ import { SOUND_PRESETS } from '@/entities/session';
|
|||||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||||
import { PlanPill } from '@/features/plan-pill';
|
import { PlanPill } from '@/features/plan-pill';
|
||||||
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
||||||
import { useFocusStats } 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';
|
||||||
|
|
||||||
@@ -42,6 +42,16 @@ const entryCopy = {
|
|||||||
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?',
|
||||||
reviewCta: '주간 review 보기',
|
reviewCta: '주간 review 보기',
|
||||||
reviewHelper: '다음 세션 전에 가볍게 보고 갈 수 있어요.',
|
reviewHelper: '다음 세션 전에 가볍게 보고 갈 수 있어요.',
|
||||||
|
reviewReturnEyebrow: '방금 본 review 기준',
|
||||||
|
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.',
|
||||||
|
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.',
|
||||||
|
reviewReturnTitleClosure: '이번엔 어디서 닫을지 먼저 정해보세요.',
|
||||||
|
reviewReturnTitleStart: '이번 주는 시작 횟수 하나를 더 만드는 게 먼저예요.',
|
||||||
|
reviewReturnBodySteady: 'goal은 직접 정하되, 지금처럼 가볍게 들어가는 리듬을 유지해 보세요.',
|
||||||
|
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.',
|
||||||
|
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.',
|
||||||
|
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.',
|
||||||
|
reviewReturnRitualLabel: '추천 ritual · 숲 · 50/10 · Forest Birds',
|
||||||
paywallLead: 'Calm Session OS PRO',
|
paywallLead: 'Calm Session OS PRO',
|
||||||
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
||||||
};
|
};
|
||||||
@@ -67,8 +77,31 @@ const resolveSoundLabel = (soundPresetId?: string | null) => {
|
|||||||
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
|
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reviewCarryCopyByHint: Record<
|
||||||
|
ReviewCarryHint,
|
||||||
|
{ title: string; body: string }
|
||||||
|
> = {
|
||||||
|
steady: {
|
||||||
|
title: entryCopy.reviewReturnTitleSteady,
|
||||||
|
body: entryCopy.reviewReturnBodySteady,
|
||||||
|
},
|
||||||
|
smaller: {
|
||||||
|
title: entryCopy.reviewReturnTitleSmaller,
|
||||||
|
body: entryCopy.reviewReturnBodySmaller,
|
||||||
|
},
|
||||||
|
closure: {
|
||||||
|
title: entryCopy.reviewReturnTitleClosure,
|
||||||
|
body: entryCopy.reviewReturnBodyClosure,
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
title: entryCopy.reviewReturnTitleStart,
|
||||||
|
body: entryCopy.reviewReturnBodyStart,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const FocusDashboardWidget = () => {
|
export const FocusDashboardWidget = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { plan, isPro, setPlan } = usePlanTier();
|
const { plan, isPro, setPlan } = usePlanTier();
|
||||||
const { sceneAssetMap } = useMediaCatalog();
|
const { sceneAssetMap } = useMediaCatalog();
|
||||||
const { review, summary: weeklySummary } = useFocusStats();
|
const { review, summary: weeklySummary } = useFocusStats();
|
||||||
@@ -100,6 +133,22 @@ export const FocusDashboardWidget = () => {
|
|||||||
weeklySummary.last7Days.startedSessions >= 3 &&
|
weeklySummary.last7Days.startedSessions >= 3 &&
|
||||||
(weeklySummary.last7Days.completedSessions >= 2 ||
|
(weeklySummary.last7Days.completedSessions >= 2 ||
|
||||||
review.recoveryQuality.availability === 'ready');
|
review.recoveryQuality.availability === 'ready');
|
||||||
|
const reviewSource = searchParams.get('review');
|
||||||
|
const reviewCarryHint = searchParams.get('carryHint');
|
||||||
|
const reviewEntryPreset = searchParams.get('entryPreset');
|
||||||
|
const normalizedReviewCarryHint: ReviewCarryHint | null =
|
||||||
|
reviewCarryHint === 'steady' ||
|
||||||
|
reviewCarryHint === 'smaller' ||
|
||||||
|
reviewCarryHint === 'closure' ||
|
||||||
|
reviewCarryHint === 'start'
|
||||||
|
? reviewCarryHint
|
||||||
|
: null;
|
||||||
|
const isReviewReturn =
|
||||||
|
reviewSource === 'weekly' && normalizedReviewCarryHint !== null;
|
||||||
|
const reviewReturnCopy =
|
||||||
|
normalizedReviewCarryHint !== null ? reviewCarryCopyByHint[normalizedReviewCarryHint] : null;
|
||||||
|
const reviewReturnRitualLabel =
|
||||||
|
reviewEntryPreset === 'forest-50-10' ? entryCopy.reviewReturnRitualLabel : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -178,7 +227,7 @@ export const FocusDashboardWidget = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shouldShowWeeklyReviewTeaser =
|
const shouldShowWeeklyReviewTeaser =
|
||||||
!isCheckingSession && !currentSession && hasEnoughWeeklyData;
|
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||||
@@ -241,6 +290,23 @@ export const FocusDashboardWidget = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{reviewReturnCopy ? (
|
||||||
|
<div className="rounded-[1.55rem] border border-white/10 bg-[#0f1115]/16 px-5 py-4 backdrop-blur-lg">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
|
||||||
|
{entryCopy.reviewReturnEyebrow}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[1rem] font-medium tracking-[-0.02em] text-white/90">
|
||||||
|
{reviewReturnCopy.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
|
||||||
|
{reviewReturnCopy.body}
|
||||||
|
</p>
|
||||||
|
{reviewReturnRitualLabel ? (
|
||||||
|
<p className="mt-3 text-[12px] text-white/46">{reviewReturnRitualLabel}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className={goalCardClass}>
|
<div className={goalCardClass}>
|
||||||
<div className="space-y-3 text-center">
|
<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]">
|
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
|
||||||
|
|||||||
Reference in New Issue
Block a user