feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)

TASK-birth-sky-polish-20260928.
- P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner.
- P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」.
- P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover.
- P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 00:03:46 +08:00
co-authored by Claude Opus 5.5
parent 1a4f77584b
commit dec36bce01
30 changed files with 670 additions and 165 deletions
+14 -2
View File
@@ -5,6 +5,7 @@ import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "reac
import { composerDraftSnapshot } from "@/lib/composer-draft";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { tryOpenRedeemPaywall } from "@/lib/redeem-paywall-once";
import { useQueuedMessage } from "@/hooks/use-queued-message";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
@@ -150,6 +151,9 @@ export type ConsultationRunParams = {
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
refreshAccount: () => Promise<void>;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
/** Set once the redeem paywall has been shown on this page (TASK-birth-sky-polish-20260928 P4). */
onboardingPaywallShown?: MutableRefObject<boolean>;
setOnboardingPaywallOpen?: (open: boolean) => void;
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise<void>;
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
};
@@ -209,9 +213,17 @@ export function useConsultationRun(params: ConsultationRunParams) {
continueInNewChat,
refreshAccount,
openAccountDialog,
onboardingPaywallShown,
setOnboardingPaywallOpen,
openRectificationFromHomepage,
openRectificationSession,
} = params;
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({
uiPreview: uiPreview.current,
shown: onboardingPaywallShown,
open: setOnboardingPaywallOpen,
});
const queued = useQueuedMessage();
function enqueueQueuedDraft(incoming: string) {
@@ -614,7 +626,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription) {
openAccountDialog("billing", { source: "insufficient-credits" });
if (!openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" });
return false;
}
@@ -854,7 +866,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
// it is open (BUG-1074): same status semantics either way.
const rejectRun = (status: number, message: string, code?: string): never => {
if (status === 401) window.location.assign("/login");
if (status === 402) openAccountDialog("billing", { source: "insufficient-credits" });
if (status === 402 && !openRedeemPaywallOnce()) openAccountDialog("billing", { source: "insufficient-credits" });
throw new ConsultationResponseError(status, message, code);
};
if (!response.ok) {