feat(space): secondary weekly review teaser 추가

This commit is contained in:
2026-03-14 20:00:38 +09:00
parent 5d3a5ac8ac
commit de95505d2f
7 changed files with 105 additions and 5 deletions

View File

@@ -331,7 +331,10 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
- `Weekly Review Entry Flow` Slice 3 - `Weekly Review Entry Flow` Slice 3
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다 - Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다
- `/stats` 마지막 CTA와 `/app` return hint가 더 구체적인 next-session handoff로 바뀐다 - `/stats` 마지막 CTA와 `/app` return hint가 더 구체적인 next-session handoff로 바뀐다
- 다음 구현은 `/space` secondary review teaser - `Weekly Review Entry Flow` Slice 4
- `/space`에서 goal complete로 setup 상태로 돌아온 직후에만 secondary review teaser가 보인다
- full review 강제 이동 없이 작은 후행 경로로 `/stats`를 연다
- 다음 구현은 weekly review의 실제 recovery 집계 연결이다
--- ---

View File

@@ -125,6 +125,10 @@ Last Updated: 2026-03-14
- Pro에서는 `/stats` carry-forward 섹션에 추천 ritual을 함께 보여준다 - Pro에서는 `/stats` carry-forward 섹션에 추천 ritual을 함께 보여준다
- `/stats` 마지막 CTA 카피가 generic start가 아니라 `가장 잘 맞은 ritual로 /app 돌아가기`로 바뀐다 - `/stats` 마지막 CTA 카피가 generic start가 아니라 `가장 잘 맞은 ritual로 /app 돌아가기`로 바뀐다
- `/app` teaser와 review return hint도 Pro에서 더 구체적인 next-session handoff 톤으로 표시된다 - `/app` teaser와 review return hint도 Pro에서 더 구체적인 next-session handoff 톤으로 표시된다
- `/space` secondary review teaser 4차 연결:
- goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 low-emphasis review teaser가 보인다
- teaser는 `주간 review 보기``/stats?review=weekly&origin=space-complete`를 연다
- 다시 시작하거나 dismiss하면 사라지며, live execution 중에는 보이지 않는다
- 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 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거

View File

@@ -92,7 +92,10 @@ Last Updated: 2026-03-14
- `Weekly Review Entry Flow`의 Pro personalized handoff까지 연결됐다. - `Weekly Review Entry Flow`의 Pro personalized handoff까지 연결됐다.
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다. - Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다.
- `/stats` 마지막 CTA와 `/app` teaser / return hint가 더 구체적인 handoff 톤으로 바뀐다. - `/stats` 마지막 CTA와 `/app` teaser / return hint가 더 구체적인 handoff 톤으로 바뀐다.
- 다음 구현은 `/space` complete 이후 secondary review teaser다. - `/space` complete 이후 secondary review teaser까지 연결됐다.
- goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 작은 review teaser가 보인다.
- full review 강제 이동 없이 `/stats`를 여는 secondary entry로만 동작한다.
- 다음 구현은 weekly review의 실제 recovery 집계 연결이다.
- 유료화 포지셔닝을 `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` 구조로 교체했다.

View File

@@ -111,7 +111,8 @@
- Slice 2 완료: `/stats` 마지막 CTA가 `/app?review=weekly&carryHint=...` handoff로 연결 - Slice 2 완료: `/stats` 마지막 CTA가 `/app?review=weekly&carryHint=...` handoff로 연결
- `/app`은 query를 받아 review-aware return hint를 먼저 보여준다 - `/app`은 query를 받아 review-aware return hint를 먼저 보여준다
- Slice 3 완료: Pro에서 추천 ritual과 더 구체적인 CTA / return hint가 연결된다 - Slice 3 완료: Pro에서 추천 ritual과 더 구체적인 CTA / return hint가 연결된다
- 다음 slice: `/space` complete 이후 secondary review teaser - Slice 4 완료: `/space` complete 이후 setup drawer 아래에 secondary review teaser가 노출된다
- 다음 작업: recovery browser QA 후 weekly review 실제 recovery 집계 연결
- 검증: - 검증:
- `/app -> /stats -> /app` 실제 브라우저 플로우 확인 - `/app -> /stats -> /app` 실제 브라우저 플로우 확인
- hero와 teaser의 시각 우선순위 확인 - hero와 teaser의 시각 우선순위 확인

View File

@@ -16,6 +16,13 @@ export const space = {
sceneLabel: '배경', sceneLabel: '배경',
timerLabel: '타이머', timerLabel: '타이머',
soundLabel: '사운드', soundLabel: '사운드',
reviewTeaserEyebrow: 'Weekly Review',
reviewTeaserTitle: '방금 끝낸 흐름까지 review에 담아둘까요?',
reviewTeaserTitlePro: '방금 끝낸 흐름까지 포함해 이번 주 리듬을 다시 볼까요?',
reviewTeaserHelper: '지금은 바로 다시 시작해도 괜찮고, 원하면 주간 review를 잠깐 보고 갈 수 있어요.',
reviewTeaserHelperPro: '방금 마친 흐름과 가장 잘 맞는 ritual을 같이 보고 다음 세션으로 이어갈 수 있어요.',
reviewTeaserCta: '주간 review 보기',
reviewTeaserDismiss: '나중에',
readyHint: '목표를 적으면 시작할 수 있어요.', readyHint: '목표를 적으면 시작할 수 있어요.',
openFocusScreen: '실행 화면 열기', openFocusScreen: '실행 화면 열기',
}, },

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import Link from 'next/link';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { SceneAssetMap } from '@/entities/media'; import type { SceneAssetMap } from '@/entities/media';
import type { SceneTheme } from '@/entities/scene'; import type { SceneTheme } from '@/entities/scene';
@@ -40,6 +41,13 @@ interface SpaceSetupDrawerWidgetProps {
onResume: () => void; onResume: () => void;
onStartFresh: () => void; onStartFresh: () => void;
}; };
reviewTeaser?: {
title: string;
summary: string;
ctaHref: string;
ctaLabel: string;
onDismiss: () => void;
};
} }
interface SummaryChipProps { interface SummaryChipProps {
@@ -88,6 +96,7 @@ export const SpaceSetupDrawerWidget = ({
onGoalChipSelect, onGoalChipSelect,
onStart, onStart,
resumeHint, resumeHint,
reviewTeaser,
}: SpaceSetupDrawerWidgetProps) => { }: SpaceSetupDrawerWidgetProps) => {
const { setup } = copy.space; const { setup } = copy.space;
const [openPopover, setOpenPopover] = useState<SelectionPopover | null>(null); const [openPopover, setOpenPopover] = useState<SelectionPopover | null>(null);
@@ -309,6 +318,38 @@ export const SpaceSetupDrawerWidget = ({
</Button> </Button>
</div> </div>
</form> </form>
{reviewTeaser ? (
<div className="mt-3 rounded-[1.25rem] border border-white/10 bg-black/16 px-3.5 py-3 backdrop-blur-md">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-[0.16em] text-white/42">
{setup.reviewTeaserEyebrow}
</p>
<p className="mt-1 text-[13px] font-medium leading-[1.5] text-white/88">
{reviewTeaser.title}
</p>
<p className="mt-1 text-[11px] leading-[1.55] text-white/56">
{reviewTeaser.summary}
</p>
</div>
<button
type="button"
onClick={reviewTeaser.onDismiss}
className="shrink-0 rounded-full border border-white/12 bg-white/[0.04] px-2 py-1 text-[10px] text-white/62 transition hover:bg-white/[0.09] hover:text-white/84"
>
{setup.reviewTeaserDismiss}
</button>
</div>
<Link
href={reviewTeaser.ctaHref}
className="mt-3 inline-flex items-center rounded-full border border-white/12 bg-white/[0.06] px-3 py-1.5 text-[11px] font-medium text-white/82 transition hover:bg-white/[0.1] hover:text-white"
>
{reviewTeaser.ctaLabel}
</Link>
</div>
) : null}
</div> </div>
</section> </section>
); );

View File

@@ -6,14 +6,17 @@ import {
preloadAssetImage, preloadAssetImage,
useMediaCatalog, useMediaCatalog,
} from "@/entities/media"; } from "@/entities/media";
import { usePlanTier } from "@/entities/plan";
import { getSceneById, SCENE_THEMES } from "@/entities/scene"; import { getSceneById, SCENE_THEMES } from "@/entities/scene";
import { GOAL_CHIPS, SOUND_PRESETS, useThoughtInbox } from "@/entities/session"; import { GOAL_CHIPS, SOUND_PRESETS, useThoughtInbox } from "@/entities/session";
import { useFocusSessionEngine } from "@/features/focus-session"; import { useFocusSessionEngine } from "@/features/focus-session";
import { useFocusStats } from "@/features/stats";
import { import {
useSoundPlayback, useSoundPlayback,
useSoundPresetSelection, useSoundPresetSelection,
} from "@/features/sound-preset"; } from "@/features/sound-preset";
import { useHudStatusLine } from "@/shared/lib/useHudStatusLine"; import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { copy } from "@/shared/i18n";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud"; import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer"; import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock"; import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
@@ -63,6 +66,8 @@ export const SpaceWorkspaceWidget = () => {
usedFallbackManifest, usedFallbackManifest,
hasResolvedManifest, hasResolvedManifest,
} = useMediaCatalog(); } = useMediaCatalog();
const { isPro } = usePlanTier();
const { review, summary: weeklySummary } = useFocusStats();
const initialSceneId = useMemo( const initialSceneId = useMemo(
() => resolveInitialSceneId(sceneQuery, undefined), () => resolveInitialSceneId(sceneQuery, undefined),
@@ -97,6 +102,7 @@ export const SpaceWorkspaceWidget = () => {
>("paused"); >("paused");
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>("space-setup"); useState<SessionEntryPoint>("space-setup");
const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false);
const { const {
selectedPresetId, selectedPresetId,
@@ -208,6 +214,27 @@ export const SpaceWorkspaceWidget = () => {
isBootstrapping, isBootstrapping,
syncCurrentSession, syncCurrentSession,
}); });
const hasEnoughWeeklyData =
weeklySummary.last7Days.startedSessions >= 3 &&
(weeklySummary.last7Days.completedSessions >= 2 ||
review.recoveryQuality.availability === "ready");
const shouldShowSecondaryReviewTeaser =
workspaceMode === "setup" &&
showReviewTeaserAfterComplete &&
hasEnoughWeeklyData;
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
? {
title: isPro
? copy.space.setup.reviewTeaserTitlePro
: copy.space.setup.reviewTeaserTitle,
summary: isPro
? review.carryForward.keepDoing
: copy.space.setup.reviewTeaserHelper,
ctaHref: "/stats?review=weekly&origin=space-complete",
ctaLabel: copy.space.setup.reviewTeaserCta,
onDismiss: () => setShowReviewTeaserAfterComplete(false),
}
: undefined;
useEffect(() => { useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) { if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
@@ -270,12 +297,17 @@ export const SpaceWorkspaceWidget = () => {
} }
onGoalChange={selection.handleGoalChange} onGoalChange={selection.handleGoalChange}
onGoalChipSelect={selection.handleGoalChipSelect} onGoalChipSelect={selection.handleGoalChipSelect}
onStart={controls.handleSetupFocusOpen} onStart={() => {
setShowReviewTeaserAfterComplete(false);
controls.handleSetupFocusOpen();
}}
reviewTeaser={secondaryReviewTeaser}
resumeHint={ resumeHint={
selection.showResumePrompt && selection.resumeGoal selection.showResumePrompt && selection.resumeGoal
? { ? {
goal: selection.resumeGoal, goal: selection.resumeGoal,
onResume: () => { onResume: () => {
setShowReviewTeaserAfterComplete(false);
selection.setGoalInput(selection.resumeGoal); selection.setGoalInput(selection.resumeGoal);
selection.setSelectedGoalId(null); selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false); selection.setShowResumePrompt(false);
@@ -285,6 +317,7 @@ export const SpaceWorkspaceWidget = () => {
); );
}, },
onStartFresh: () => { onStartFresh: () => {
setShowReviewTeaserAfterComplete(false);
selection.setGoalInput(""); selection.setGoalInput("");
selection.setSelectedGoalId(null); selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false); selection.setShowResumePrompt(false);
@@ -319,7 +352,15 @@ export const SpaceWorkspaceWidget = () => {
onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt} onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt}
onStatusMessage={pushStatusLine} onStatusMessage={pushStatusLine}
onIntentUpdate={controls.handleIntentUpdate} onIntentUpdate={controls.handleIntentUpdate}
onGoalFinish={controls.handleGoalComplete} onGoalFinish={async () => {
const didFinish = await controls.handleGoalComplete();
if (didFinish) {
setShowReviewTeaserAfterComplete(true);
}
return didFinish;
}}
onGoalUpdate={controls.handleGoalAdvance} onGoalUpdate={controls.handleGoalAdvance}
/> />
) : null} ) : null}