feat(space): secondary weekly review teaser 추가
This commit is contained in:
@@ -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 집계 연결이다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
||||||
|
|||||||
@@ -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` 구조로 교체했다.
|
||||||
|
|||||||
@@ -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의 시각 우선순위 확인
|
||||||
|
|||||||
@@ -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: '실행 화면 열기',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user