Add trial value replay upgrade trigger

This commit is contained in:
2569718930@qq.com
2026-06-09 19:00:31 +08:00
parent 7b965ac60d
commit 45397f1780
4 changed files with 418 additions and 0 deletions
@@ -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() {
</div>
)}
{isTrialSubscription && canOpenCheckoutOverlay && (
<section className="lg:col-span-12 overflow-hidden rounded-2xl border border-amber-200 bg-white shadow-sm">
<div className="grid gap-0 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs font-black uppercase text-amber-700">
<Sparkles size={15} />
<span>{isEn ? "Trial value replay" : "试用价值回放"}</span>
{trialValueReplaySummary.hasUsageEvidence ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] text-amber-800">
{isEn ? "Based on your trial" : "基于你的试用"}
</span>
) : null}
</div>
<p className="max-w-3xl text-base font-bold leading-7 text-slate-950">
{trialValueReplaySummary.headline}
</p>
<div className="mt-4 grid gap-2 text-sm text-slate-600 md:grid-cols-3">
{trialValueReplaySummary.bullets.map((bullet) => (
<div key={bullet} className="flex min-w-0 items-start gap-2">
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-500" />
<span className="min-w-0 leading-5">{bullet}</span>
</div>
))}
</div>
</div>
<div className="flex items-center border-t border-amber-100 bg-amber-50 p-6 md:border-l md:border-t-0">
<button
type="button"
onClick={openTrialValueReplayCheckout}
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-600 px-5 py-3 text-sm font-black text-white shadow-sm transition hover:bg-amber-700 md:w-auto"
>
<Crown size={16} />
{trialValueReplaySummary.primaryCta}
</button>
</div>
</div>
</section>
)}
{/* User Card */}
<div className="flex flex-col items-center gap-8 rounded-2xl border border-slate-200 bg-white p-8 shadow-sm lg:col-span-8 md:flex-row">
<div className="relative">
@@ -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",
);
}
@@ -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}
+232
View File
@@ -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<string>();
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<string, unknown> : {};
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" : "保持访问",
};
}