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}