From 45397f1780d324d8b263c66ca8ea45cb2353d02a Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Tue, 9 Jun 2026 19:00:31 +0800
Subject: [PATCH] Add trial value replay upgrade trigger
---
frontend/components/account/AccountCenter.tsx | 69 ++++++
.../__tests__/trialValueReplay.test.ts | 96 ++++++++
.../dashboard/ScanTerminalDashboard.tsx | 21 ++
frontend/lib/trial-value-replay.ts | 232 ++++++++++++++++++
4 files changed, 418 insertions(+)
create mode 100644 frontend/components/account/__tests__/trialValueReplay.test.ts
create mode 100644 frontend/lib/trial-value-replay.ts
diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx
index 5df949cd..3880c27f 100644
--- a/frontend/components/account/AccountCenter.tsx
+++ b/frontend/components/account/AccountCenter.tsx
@@ -63,6 +63,10 @@ import {
import { createAccountCopy } from "./account-copy";
import { resetWalletConnectProvider } from "./wallet";
import { useAccountPayment } from "./useAccountPayment";
+import {
+ buildTrialValueReplaySummary,
+ readTrialValueReplay,
+} from "@/lib/trial-value-replay";
// --- Main Component ---
@@ -78,6 +82,7 @@ export function AccountCenter() {
const [refreshing, setRefreshing] = useState(false);
const [copied, setCopied] = useState(false);
const [showSecondarySections, setShowSecondarySections] = useState(false);
+ const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
// ── Shared state (declared in component, written by hook via setters) ─
const [showOverlay, setShowOverlay] = useState(false);
@@ -445,6 +450,13 @@ export function AccountCenter() {
const overlayPeriodLabel = isEn
? `/ ${selectedPlanDurationDays} days`
: `/ ${selectedPlanDurationDays} 天`;
+ const trialValueReplaySummary = useMemo(
+ () => buildTrialValueReplaySummary(trialValueReplay, {
+ isEn,
+ trialExpiresAt: displayExpiryRaw,
+ }),
+ [displayExpiryRaw, isEn, trialValueReplay],
+ );
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
@@ -481,6 +493,24 @@ export function AccountCenter() {
}
}, [canOpenCheckoutOverlay, searchParams, showOverlay]);
+ useEffect(() => {
+ if (!isTrialSubscription) return;
+ setTrialValueReplay(readTrialValueReplay());
+ }, [authUserId, isTrialSubscription, showOverlay]);
+
+ const openTrialValueReplayCheckout = useCallback(() => {
+ trackAppEvent("paywall_feature_clicked", {
+ entry: "account_center",
+ feature: "trial_value_replay_upgrade",
+ user_id: authUserId || null,
+ replay_cities: trialValueReplay.citiesViewed.length,
+ replay_terminal_visits: trialValueReplay.terminalVisits,
+ replay_rows_available: trialValueReplay.rowsAvailableMax,
+ subscription_plan_code: planCode || null,
+ });
+ setShowOverlay(true);
+ }, [authUserId, planCode, trialValueReplay]);
+
// ── Referral points display ────────────────────────────
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
@@ -690,6 +720,45 @@ export function AccountCenter() {
)}
+ {isTrialSubscription && canOpenCheckoutOverlay && (
+
+
+
+
+
+ {isEn ? "Trial value replay" : "试用价值回放"}
+ {trialValueReplaySummary.hasUsageEvidence ? (
+
+ {isEn ? "Based on your trial" : "基于你的试用"}
+
+ ) : null}
+
+
+ {trialValueReplaySummary.headline}
+
+
+ {trialValueReplaySummary.bullets.map((bullet) => (
+
+
+ {bullet}
+
+ ))}
+
+
+
+
+
+
+
+ )}
+
{/* User Card */}
diff --git a/frontend/components/account/__tests__/trialValueReplay.test.ts b/frontend/components/account/__tests__/trialValueReplay.test.ts
new file mode 100644
index 00000000..56a07562
--- /dev/null
+++ b/frontend/components/account/__tests__/trialValueReplay.test.ts
@@ -0,0 +1,96 @@
+import fs from "node:fs";
+import path from "node:path";
+import { buildTrialValueReplaySummary } from "@/lib/trial-value-replay";
+
+function assert(condition: unknown, message: string) {
+ if (!condition) throw new Error(message);
+}
+
+export function runTests() {
+ const projectRoot = process.cwd();
+ const replayPath = path.join(projectRoot, "lib", "trial-value-replay.ts");
+ const accountCenter = fs.readFileSync(
+ path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
+ "utf8",
+ );
+ const dashboardSource = fs.readFileSync(
+ path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
+ "utf8",
+ );
+
+ assert(fs.existsSync(replayPath), "trial value replay helper must exist");
+ const replaySource = fs.readFileSync(replayPath, "utf8");
+ const accountReplaySurface = `${accountCenter}\n${replaySource}`;
+
+ assert(
+ replaySource.includes("TRIAL_VALUE_REPLAY_STORAGE_KEY") &&
+ replaySource.includes("recordTrialValueReplay") &&
+ replaySource.includes("readTrialValueReplay") &&
+ replaySource.includes("buildTrialValueReplaySummary"),
+ "trial value replay must persist and summarize local trial usage evidence",
+ );
+
+ assert(
+ dashboardSource.includes("recordTrialValueReplay") &&
+ dashboardSource.includes("isTrialTerminalAccess") &&
+ dashboardSource.includes("selectedRow"),
+ "terminal trial users must record real terminal usage for later value replay",
+ );
+
+ assert(
+ accountCenter.includes("buildTrialValueReplaySummary") &&
+ accountCenter.includes("trialValueReplay") &&
+ accountReplaySurface.includes("你本次试用已经") &&
+ accountReplaySurface.includes("保持访问") &&
+ accountReplaySurface.includes("Keep access"),
+ "account trial upgrade trigger must replay the user's own trial value before opening checkout",
+ );
+
+ assert(
+ accountCenter.includes('trackAppEvent("paywall_feature_clicked"') &&
+ accountCenter.includes('feature: "trial_value_replay_upgrade"'),
+ "trial value replay upgrade CTA must be tracked before checkout opens",
+ );
+
+ const zhSummary = buildTrialValueReplaySummary(
+ {
+ firstActiveAt: "2026-06-09T00:00:00.000Z",
+ lastActiveAt: "2026-06-09T00:20:00.000Z",
+ lastCityName: "Taipei",
+ lastSignalLabel: "Hot",
+ lastUserId: "user-1",
+ terminalVisits: 2,
+ rowsAvailableMax: 50,
+ signalsViewed: 2,
+ citiesViewed: ["Taipei", "New York"],
+ },
+ { isEn: false },
+ );
+
+ assert(
+ zhSummary.headline === "你本次试用已经查看 2 个天气信号。" &&
+ zhSummary.bullets.includes("已记录 2 次终端使用。") &&
+ zhSummary.bullets.includes("Pro 终端本次提供了 50 个城市机会。") &&
+ zhSummary.primaryCta === "保持访问",
+ "trial value replay summary must turn usage evidence into concrete upgrade copy",
+ );
+
+ const expirySummary = buildTrialValueReplaySummary(
+ {
+ firstActiveAt: null,
+ lastActiveAt: null,
+ lastCityName: null,
+ lastSignalLabel: null,
+ lastUserId: null,
+ terminalVisits: 0,
+ rowsAvailableMax: 0,
+ signalsViewed: 0,
+ citiesViewed: [],
+ },
+ { isEn: false, trialExpiresAt: "2099-01-01T00:00:00.000Z" },
+ );
+ assert(
+ expirySummary.bullets.some((bullet) => bullet.includes("试用剩余")),
+ "trial value replay summary must include trial time remaining when expiry is known",
+ );
+}
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index 9b119285..2dd06db7 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -80,6 +80,7 @@ import {
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
import { STATIC_CITY_LIST } from "@/lib/static-cities";
+import { recordTrialValueReplay } from "@/lib/trial-value-replay";
const TrainingDashboard = dynamic(
() =>
@@ -615,6 +616,7 @@ function PolyWeatherTerminal({
toggleLocale,
isTrialTerminalAccess,
trialSubscriptionExpiresAt,
+ trialUserId,
userLocalTime,
searchQuery,
setSearchQuery,
@@ -637,6 +639,7 @@ function PolyWeatherTerminal({
toggleLocale: () => void;
isTrialTerminalAccess: boolean;
trialSubscriptionExpiresAt: string | null;
+ trialUserId: string | null;
userLocalTime: string;
searchQuery: string;
setSearchQuery: (val: string) => void;
@@ -882,6 +885,23 @@ function PolyWeatherTerminal({
const selectedSignal = selectedRow ? getSignalState(selectedRow) : "data" as const;
const selectedLabel = selectedRow ? getSignalLabel(selectedSignal, isEn) : "";
+ useEffect(() => {
+ if (!isTrialTerminalAccess || !selectedRow) return;
+ recordTrialValueReplay({
+ userId: trialUserId,
+ cityName: rowName(selectedRow),
+ signalLabel: selectedLabel,
+ rowsAvailable: filteredRegionRows.length || rows.length,
+ });
+ }, [
+ filteredRegionRows.length,
+ isTrialTerminalAccess,
+ rows.length,
+ selectedLabel,
+ selectedRow,
+ trialUserId,
+ ]);
+
const continentGroups = useMemo(
() => buildContinentGroups(filteredRegionRows, isEn),
[filteredRegionRows, isEn]
@@ -1778,6 +1798,7 @@ function ScanTerminalScreen() {
trialSubscriptionExpiresAt={
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
}
+ trialUserId={proAccess.userId}
userLocalTime={userLocalTime}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
diff --git a/frontend/lib/trial-value-replay.ts b/frontend/lib/trial-value-replay.ts
new file mode 100644
index 00000000..ddfaafd4
--- /dev/null
+++ b/frontend/lib/trial-value-replay.ts
@@ -0,0 +1,232 @@
+"use client";
+
+export const TRIAL_VALUE_REPLAY_STORAGE_KEY = "polyweather:trial-value-replay:v1";
+const TRIAL_VALUE_REPLAY_SESSION_KEY = "polyweather:trial-value-replay:session:v1";
+const MAX_REPLAY_CITIES = 24;
+
+export type TrialValueReplaySnapshot = {
+ firstActiveAt: string | null;
+ lastActiveAt: string | null;
+ lastCityName: string | null;
+ lastSignalLabel: string | null;
+ lastUserId: string | null;
+ terminalVisits: number;
+ rowsAvailableMax: number;
+ signalsViewed: number;
+ citiesViewed: string[];
+};
+
+export type RecordTrialValueReplayInput = {
+ userId?: string | null;
+ cityName?: string | null;
+ signalLabel?: string | null;
+ rowsAvailable?: number | null;
+ activeAt?: string | null;
+};
+
+export type TrialValueReplaySummary = {
+ hasUsageEvidence: boolean;
+ headline: string;
+ bullets: string[];
+ primaryCta: string;
+};
+
+const EMPTY_REPLAY: TrialValueReplaySnapshot = {
+ firstActiveAt: null,
+ lastActiveAt: null,
+ lastCityName: null,
+ lastSignalLabel: null,
+ lastUserId: null,
+ terminalVisits: 0,
+ rowsAvailableMax: 0,
+ signalsViewed: 0,
+ citiesViewed: [],
+};
+
+function safeNowIso() {
+ return new Date().toISOString();
+}
+
+function cleanText(value: unknown) {
+ return String(value || "").trim();
+}
+
+function finiteCount(value: unknown) {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
+}
+
+function buildExpiryBullet(raw: string | null | undefined, isEn: boolean) {
+ const expiryMs = Date.parse(String(raw || ""));
+ if (!Number.isFinite(expiryMs)) return null;
+ const hoursLeft = Math.ceil((expiryMs - Date.now()) / 3_600_000);
+ if (hoursLeft <= 0) {
+ return isEn
+ ? "The trial window is closing now; upgrade to avoid losing access."
+ : "试用窗口正在结束,现在升级可避免访问中断。";
+ }
+ if (hoursLeft < 48) {
+ return isEn
+ ? `Trial access ends in ${hoursLeft}h.`
+ : `试用剩余 ${hoursLeft} 小时。`;
+ }
+ const daysLeft = Math.ceil(hoursLeft / 24);
+ return isEn
+ ? `Trial access ends in ${daysLeft} days.`
+ : `试用剩余 ${daysLeft} 天。`;
+}
+
+function uniqCities(values: unknown) {
+ if (!Array.isArray(values)) return [];
+ const seen = new Set();
+ const result: string[] = [];
+ values.forEach((value) => {
+ const city = cleanText(value);
+ const key = city.toLowerCase();
+ if (!city || seen.has(key)) return;
+ seen.add(key);
+ result.push(city);
+ });
+ return result.slice(0, MAX_REPLAY_CITIES);
+}
+
+function normalizeSnapshot(value: unknown): TrialValueReplaySnapshot {
+ const raw = typeof value === "object" && value ? value as Record : {};
+ return {
+ firstActiveAt: cleanText(raw.firstActiveAt) || null,
+ lastActiveAt: cleanText(raw.lastActiveAt) || null,
+ lastCityName: cleanText(raw.lastCityName) || null,
+ lastSignalLabel: cleanText(raw.lastSignalLabel) || null,
+ lastUserId: cleanText(raw.lastUserId) || null,
+ terminalVisits: finiteCount(raw.terminalVisits),
+ rowsAvailableMax: finiteCount(raw.rowsAvailableMax),
+ signalsViewed: finiteCount(raw.signalsViewed),
+ citiesViewed: uniqCities(raw.citiesViewed),
+ };
+}
+
+function getStorage() {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+function getSessionStorage() {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
+
+export function readTrialValueReplay(storage = getStorage()) {
+ if (!storage) return EMPTY_REPLAY;
+ try {
+ const raw = storage.getItem(TRIAL_VALUE_REPLAY_STORAGE_KEY);
+ if (!raw) return EMPTY_REPLAY;
+ return normalizeSnapshot(JSON.parse(raw));
+ } catch {
+ return EMPTY_REPLAY;
+ }
+}
+
+export function recordTrialValueReplay(input: RecordTrialValueReplayInput) {
+ const storage = getStorage();
+ if (!storage) return readTrialValueReplay(null);
+
+ const now = cleanText(input.activeAt) || safeNowIso();
+ const userId = cleanText(input.userId) || null;
+ const cityName = cleanText(input.cityName);
+ const signalLabel = cleanText(input.signalLabel);
+ const previous = readTrialValueReplay(storage);
+ const citiesViewed = uniqCities([
+ cityName,
+ ...previous.citiesViewed,
+ ]);
+ const sessionStorage = getSessionStorage();
+ const sessionKey = `${TRIAL_VALUE_REPLAY_SESSION_KEY}:${userId || "anonymous"}`;
+ const shouldCountVisit = !sessionStorage?.getItem(sessionKey);
+ if (shouldCountVisit) {
+ try {
+ sessionStorage?.setItem(sessionKey, "1");
+ } catch {}
+ }
+
+ const next: TrialValueReplaySnapshot = {
+ firstActiveAt: previous.firstActiveAt || now,
+ lastActiveAt: now,
+ lastCityName: cityName || previous.lastCityName,
+ lastSignalLabel: signalLabel || previous.lastSignalLabel,
+ lastUserId: userId || previous.lastUserId,
+ terminalVisits: previous.terminalVisits + (shouldCountVisit ? 1 : 0),
+ rowsAvailableMax: Math.max(previous.rowsAvailableMax, finiteCount(input.rowsAvailable)),
+ signalsViewed: Math.max(previous.signalsViewed, citiesViewed.length),
+ citiesViewed,
+ };
+
+ try {
+ storage.setItem(TRIAL_VALUE_REPLAY_STORAGE_KEY, JSON.stringify(next));
+ } catch {}
+ return next;
+}
+
+export function buildTrialValueReplaySummary(
+ snapshot: TrialValueReplaySnapshot,
+ options: { isEn: boolean; trialExpiresAt?: string | null },
+): TrialValueReplaySummary {
+ const { isEn } = options;
+ const cities = snapshot.citiesViewed.length;
+ const rows = snapshot.rowsAvailableMax;
+ const visits = snapshot.terminalVisits;
+ const lastCity = snapshot.lastCityName;
+ const hasUsageEvidence = Boolean(cities > 0 || visits > 0 || rows > 0);
+ const expiryBullet = buildExpiryBullet(options.trialExpiresAt, isEn);
+
+ if (!hasUsageEvidence) {
+ const fallbackBullets = isEn
+ ? [
+ "Keep settlement-source priority and live observation context available.",
+ "Keep the terminal active when the trial window closes.",
+ "Upgrade now to avoid losing the workflow you just unlocked.",
+ ]
+ : [
+ "继续保留结算源优先、实时观测和温度信号上下文。",
+ "试用结束后终端访问不会中断。",
+ "现在升级,避免刚解锁的工作流被暂停。",
+ ];
+ return {
+ hasUsageEvidence,
+ headline: isEn
+ ? "Your trial has unlocked the Pro terminal, live observations, and city-level signal views."
+ : "你本次试用已经解锁 Pro 终端、实时观测和城市级信号视图。",
+ bullets: (expiryBullet ? [expiryBullet, ...fallbackBullets] : fallbackBullets).slice(0, 3),
+ primaryCta: isEn ? "Keep access" : "保持访问",
+ };
+ }
+
+ const headline = isEn
+ ? `Your trial has already reviewed ${cities || rows || 1} weather signal${cities === 1 ? "" : "s"}.`
+ : `你本次试用已经查看 ${cities || rows || 1} 个天气信号。`;
+ const bullets = isEn
+ ? [
+ visits > 1 ? `${visits} terminal sessions recorded.` : "Your terminal workflow is already active.",
+ rows > 0 ? `${rows} city opportunities were available in the Pro terminal.` : "Live city-level signals stay unlocked on Pro.",
+ lastCity ? `Latest signal checked: ${lastCity}.` : "Keep settlement-source and observation context online.",
+ ]
+ : [
+ visits > 1 ? `已记录 ${visits} 次终端使用。` : "你的终端工作流已经启动。",
+ rows > 0 ? `Pro 终端本次提供了 ${rows} 个城市机会。` : "Pro 会继续解锁城市级实时信号。",
+ lastCity ? `最近查看:${lastCity}。` : "继续保留结算源和实时观测上下文。",
+ ];
+
+ return {
+ hasUsageEvidence,
+ headline,
+ bullets: (expiryBullet ? [expiryBullet, ...bullets] : bullets).slice(0, 3),
+ primaryCta: isEn ? "Keep access" : "保持访问",
+ };
+}