Add trial value replay upgrade trigger
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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" : "保持访问",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user