diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 5df949cd..3880c27f 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -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() { )} + {isTrialSubscription && canOpenCheckoutOverlay && ( +
+
+
+
+ + {isEn ? "Trial value replay" : "试用价值回放"} + {trialValueReplaySummary.hasUsageEvidence ? ( + + {isEn ? "Based on your trial" : "基于你的试用"} + + ) : null} +
+

+ {trialValueReplaySummary.headline} +

+
+ {trialValueReplaySummary.bullets.map((bullet) => ( +
+ + {bullet} +
+ ))} +
+
+
+ +
+
+
+ )} + {/* User Card */}
diff --git a/frontend/components/account/__tests__/trialValueReplay.test.ts b/frontend/components/account/__tests__/trialValueReplay.test.ts new file mode 100644 index 00000000..56a07562 --- /dev/null +++ b/frontend/components/account/__tests__/trialValueReplay.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 9b119285..2dd06db7 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -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} diff --git a/frontend/lib/trial-value-replay.ts b/frontend/lib/trial-value-replay.ts new file mode 100644 index 00000000..ddfaafd4 --- /dev/null +++ b/frontend/lib/trial-value-replay.ts @@ -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(); + 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 : {}; + 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" : "保持访问", + }; +}