Add trial value replay upgrade trigger
This commit is contained in:
@@ -63,6 +63,10 @@ import {
|
|||||||
import { createAccountCopy } from "./account-copy";
|
import { createAccountCopy } from "./account-copy";
|
||||||
import { resetWalletConnectProvider } from "./wallet";
|
import { resetWalletConnectProvider } from "./wallet";
|
||||||
import { useAccountPayment } from "./useAccountPayment";
|
import { useAccountPayment } from "./useAccountPayment";
|
||||||
|
import {
|
||||||
|
buildTrialValueReplaySummary,
|
||||||
|
readTrialValueReplay,
|
||||||
|
} from "@/lib/trial-value-replay";
|
||||||
|
|
||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ export function AccountCenter() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
const [showSecondarySections, setShowSecondarySections] = useState(false);
|
||||||
|
const [trialValueReplay, setTrialValueReplay] = useState(() => readTrialValueReplay());
|
||||||
|
|
||||||
// ── Shared state (declared in component, written by hook via setters) ─
|
// ── Shared state (declared in component, written by hook via setters) ─
|
||||||
const [showOverlay, setShowOverlay] = useState(false);
|
const [showOverlay, setShowOverlay] = useState(false);
|
||||||
@@ -445,6 +450,13 @@ export function AccountCenter() {
|
|||||||
const overlayPeriodLabel = isEn
|
const overlayPeriodLabel = isEn
|
||||||
? `/ ${selectedPlanDurationDays} days`
|
? `/ ${selectedPlanDurationDays} days`
|
||||||
: `/ ${selectedPlanDurationDays} 天`;
|
: `/ ${selectedPlanDurationDays} 天`;
|
||||||
|
const trialValueReplaySummary = useMemo(
|
||||||
|
() => buildTrialValueReplaySummary(trialValueReplay, {
|
||||||
|
isEn,
|
||||||
|
trialExpiresAt: displayExpiryRaw,
|
||||||
|
}),
|
||||||
|
[displayExpiryRaw, isEn, trialValueReplay],
|
||||||
|
);
|
||||||
const referral = backend?.referral;
|
const referral = backend?.referral;
|
||||||
const referralCode = String(referral?.code || "").trim();
|
const referralCode = String(referral?.code || "").trim();
|
||||||
const appliedReferralCode = String(referral?.applied_code || "").trim();
|
const appliedReferralCode = String(referral?.applied_code || "").trim();
|
||||||
@@ -481,6 +493,24 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
}, [canOpenCheckoutOverlay, searchParams, showOverlay]);
|
}, [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 ────────────────────────────
|
// ── Referral points display ────────────────────────────
|
||||||
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
const referralRewardPointsRaw = Number(referral?.reward_points ?? 3500);
|
||||||
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
const referralRewardPoints = Number.isFinite(referralRewardPointsRaw)
|
||||||
@@ -690,6 +720,45 @@ export function AccountCenter() {
|
|||||||
</div>
|
</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 */}
|
{/* 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="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">
|
<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";
|
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||||
|
import { recordTrialValueReplay } from "@/lib/trial-value-replay";
|
||||||
|
|
||||||
const TrainingDashboard = dynamic(
|
const TrainingDashboard = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -615,6 +616,7 @@ function PolyWeatherTerminal({
|
|||||||
toggleLocale,
|
toggleLocale,
|
||||||
isTrialTerminalAccess,
|
isTrialTerminalAccess,
|
||||||
trialSubscriptionExpiresAt,
|
trialSubscriptionExpiresAt,
|
||||||
|
trialUserId,
|
||||||
userLocalTime,
|
userLocalTime,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -637,6 +639,7 @@ function PolyWeatherTerminal({
|
|||||||
toggleLocale: () => void;
|
toggleLocale: () => void;
|
||||||
isTrialTerminalAccess: boolean;
|
isTrialTerminalAccess: boolean;
|
||||||
trialSubscriptionExpiresAt: string | null;
|
trialSubscriptionExpiresAt: string | null;
|
||||||
|
trialUserId: string | null;
|
||||||
userLocalTime: string;
|
userLocalTime: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (val: string) => void;
|
setSearchQuery: (val: string) => void;
|
||||||
@@ -882,6 +885,23 @@ function PolyWeatherTerminal({
|
|||||||
const selectedSignal = selectedRow ? getSignalState(selectedRow) : "data" as const;
|
const selectedSignal = selectedRow ? getSignalState(selectedRow) : "data" as const;
|
||||||
const selectedLabel = selectedRow ? getSignalLabel(selectedSignal, isEn) : "";
|
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(
|
const continentGroups = useMemo(
|
||||||
() => buildContinentGroups(filteredRegionRows, isEn),
|
() => buildContinentGroups(filteredRegionRows, isEn),
|
||||||
[filteredRegionRows, isEn]
|
[filteredRegionRows, isEn]
|
||||||
@@ -1778,6 +1798,7 @@ function ScanTerminalScreen() {
|
|||||||
trialSubscriptionExpiresAt={
|
trialSubscriptionExpiresAt={
|
||||||
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
|
proAccess.subscriptionTotalExpiresAt || proAccess.subscriptionExpiresAt
|
||||||
}
|
}
|
||||||
|
trialUserId={proAccess.userId}
|
||||||
userLocalTime={userLocalTime}
|
userLocalTime={userLocalTime}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
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