233 lines
7.7 KiB
TypeScript
233 lines
7.7 KiB
TypeScript
"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" : "保持访问",
|
|
};
|
|
}
|