mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
fix(analytics): restore migrated pricing funnel events (#7299)
* docs: design pricing plan exposure restoration * fix(analytics): restore migrated pricing funnel events * docs: design authenticated pricing analytics bridge * docs: plan authenticated pricing analytics bridge * feat(analytics): add authenticated pricing bridge contract * fix(analytics): harden pricing bridge validation * fix(analytics): restore complete personal pricing semantics * fix(analytics): require explicit personal audience * fix(analytics): relay authenticated pricing funnel events * test(analytics): cover localized pricing handoff * docs(analytics): correct pricing source handoff * fix(analytics): align migrated pricing compatibility contract --------- Co-authored-by: 张辉华 <zhanghuihua@refly.ai>
This commit is contained in:
committed by
GitHub
parent
1877bf607b
commit
d517a18ec7
@@ -467,6 +467,14 @@ const countryOptions = buildCountryOptions(locale);
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
// Pricing's legacy compatibility funnel counted submit intent, including
|
||||
// attempts rejected by client validation. Keep this synchronous and
|
||||
// payload-free so analytics can never delay or leak lead fields.
|
||||
if (leadSource === 'pricing_team') {
|
||||
form.dispatchEvent(new CustomEvent('pricing:enterprise-submit', {
|
||||
bubbles: true,
|
||||
}));
|
||||
}
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
|
||||
let firstInvalid = null;
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
GO_PLAN,
|
||||
HOSTED_CLOUD_CONSOLE_DOMAINS,
|
||||
PRICING_SNAPSHOT,
|
||||
type BillingInterval,
|
||||
type PlanTier,
|
||||
type PlanTierConfig,
|
||||
} from './pricing';
|
||||
|
||||
export type PricingBridgeSource = 'wallet' | 'dashboard';
|
||||
|
||||
export type PlanExposureInput = {
|
||||
planId: PlanTier;
|
||||
billingInterval: BillingInterval;
|
||||
priceUsd: string;
|
||||
creditsGrantedUsd: string;
|
||||
deployLimit: number;
|
||||
introOfferApplied: boolean;
|
||||
firstMonthEligible: boolean;
|
||||
isCurrentPlan: boolean;
|
||||
isRecommended: boolean;
|
||||
};
|
||||
|
||||
type ChangeIntervalClickInput = {
|
||||
element: 'change_interval';
|
||||
currentPlanId: PlanTier | null;
|
||||
currentBillingInterval: BillingInterval;
|
||||
targetBillingInterval: BillingInterval;
|
||||
};
|
||||
|
||||
type PlanCtaClickFields = {
|
||||
currentBillingInterval: BillingInterval | null;
|
||||
targetPlanId: PlanTier;
|
||||
targetBillingInterval: BillingInterval;
|
||||
priceUsd: string;
|
||||
creditsGrantedUsd: string;
|
||||
introOfferApplied: boolean;
|
||||
isCurrentPlan: boolean;
|
||||
isRecommended: boolean;
|
||||
};
|
||||
|
||||
type EnterpriseClickContext = {
|
||||
currentPlanId: PlanTier | null;
|
||||
currentBillingInterval: BillingInterval | null;
|
||||
};
|
||||
|
||||
type EnterpriseClickInput =
|
||||
| (EnterpriseClickContext & { element: 'request_team_access' })
|
||||
| (EnterpriseClickContext & { element: 'team_lead_submit' });
|
||||
|
||||
export type PricingClickInput =
|
||||
| (PlanCtaClickFields & {
|
||||
element: 'subscribe_now';
|
||||
currentPlanId: null;
|
||||
})
|
||||
| (PlanCtaClickFields & {
|
||||
element: 'upgrade_now';
|
||||
currentPlanId: PlanTier;
|
||||
})
|
||||
| ChangeIntervalClickInput
|
||||
| EnterpriseClickInput;
|
||||
|
||||
export type PricingBridgeEvent =
|
||||
| {
|
||||
kind: 'plan_exposure';
|
||||
eventId: string;
|
||||
eventTime: string;
|
||||
payload: PlanExposureInput;
|
||||
}
|
||||
| {
|
||||
kind: 'pricing_click';
|
||||
eventId: string;
|
||||
eventTime: string;
|
||||
payload: PricingClickInput;
|
||||
};
|
||||
|
||||
const goTier: PlanTierConfig = {
|
||||
tier: GO_PLAN.tier,
|
||||
rank: 0,
|
||||
recommended: false,
|
||||
monthly: {
|
||||
priceUsd: GO_PLAN.monthly.priceUsd,
|
||||
introPriceUsd: GO_PLAN.monthly.introPriceUsd,
|
||||
grantUsd: 0,
|
||||
},
|
||||
yearly: {
|
||||
priceUsd: GO_PLAN.yearly.priceUsd,
|
||||
discountPct: 50,
|
||||
grantUsd: 0,
|
||||
},
|
||||
deployLimit: 0,
|
||||
};
|
||||
|
||||
export const PERSONAL_PRICING_TIERS: readonly PlanTierConfig[] = [
|
||||
goTier,
|
||||
...PRICING_SNAPSHOT.tiers,
|
||||
];
|
||||
|
||||
const sourceOverrideKeys = [
|
||||
'sourceSurface',
|
||||
'source_surface',
|
||||
'workspaceTab',
|
||||
'workspace_tab',
|
||||
'pricingSource',
|
||||
'pricing_source',
|
||||
'source',
|
||||
'od_entry_source',
|
||||
] as const;
|
||||
|
||||
const sourceByPath: Readonly<Record<string, PricingBridgeSource>> = {
|
||||
'/wallet': 'wallet',
|
||||
'/dashboard': 'dashboard',
|
||||
'/cloud/wallet': 'wallet',
|
||||
'/cloud/dashboard': 'dashboard',
|
||||
};
|
||||
|
||||
function isTrustedHostedUrl(url: URL): boolean {
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
url.port.length === 0 &&
|
||||
HOSTED_CLOUD_CONSOLE_DOMAINS.some(
|
||||
(domain) =>
|
||||
url.hostname === domain || url.hostname.endsWith(`.${domain}`),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isTrustedLoopbackUrl(url: URL): boolean {
|
||||
return (
|
||||
url.protocol === 'http:' &&
|
||||
(url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
|
||||
url.port.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve only canonical Vela routes; query state never creates a surface. */
|
||||
export function resolvePricingBridgeSource(input: {
|
||||
search: URLSearchParams;
|
||||
referrer: string;
|
||||
}): PricingBridgeSource | null {
|
||||
if (sourceOverrideKeys.some((key) => input.search.has(key))) return null;
|
||||
if (!input.referrer) return null;
|
||||
|
||||
try {
|
||||
const referrer = new URL(input.referrer);
|
||||
if (
|
||||
referrer.href !== input.referrer ||
|
||||
referrer.username ||
|
||||
referrer.password ||
|
||||
referrer.hash ||
|
||||
(!isTrustedHostedUrl(referrer) && !isTrustedLoopbackUrl(referrer))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return sourceByPath[referrer.pathname] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const idMaxLength = 128;
|
||||
const maxEventsPerRequest = 8;
|
||||
const transportTimeoutMs = 3_000;
|
||||
const usdAmountPattern = /^(?:0|[1-9][0-9]{0,8})\.[0-9]{2}$/u;
|
||||
// Mirrors Zod 3.25 `z.string().datetime()` used by Vela: real calendar date,
|
||||
// UTC Z suffix, optional seconds, and arbitrary fractional-second precision.
|
||||
const velaDateTimePattern = new RegExp(
|
||||
'^((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))T([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d+)?)?Z$',
|
||||
'u',
|
||||
);
|
||||
|
||||
function isBoundedId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= idMaxLength &&
|
||||
value.trim() === value
|
||||
);
|
||||
}
|
||||
|
||||
function isPlanTier(value: unknown): value is PlanTier {
|
||||
return (
|
||||
value === 'go' ||
|
||||
value === 'plus' ||
|
||||
value === 'pro' ||
|
||||
value === 'max'
|
||||
);
|
||||
}
|
||||
|
||||
function isBillingInterval(value: unknown): value is BillingInterval {
|
||||
return value === 'monthly' || value === 'yearly';
|
||||
}
|
||||
|
||||
function isUsdAmount(value: unknown): value is string {
|
||||
return typeof value === 'string' && usdAmountPattern.test(value);
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
function isVelaDateTime(value: unknown): value is string {
|
||||
return typeof value === 'string' && velaDateTimePattern.test(value);
|
||||
}
|
||||
|
||||
function sanitizedPlanPayload(value: unknown): PlanExposureInput | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const payload = value as Record<string, unknown>;
|
||||
if (
|
||||
!isPlanTier(payload.planId) ||
|
||||
!isBillingInterval(payload.billingInterval) ||
|
||||
!isUsdAmount(payload.priceUsd) ||
|
||||
!isUsdAmount(payload.creditsGrantedUsd) ||
|
||||
!Number.isSafeInteger(payload.deployLimit) ||
|
||||
Number(payload.deployLimit) < 0 ||
|
||||
!isBoolean(payload.introOfferApplied) ||
|
||||
!isBoolean(payload.firstMonthEligible) ||
|
||||
!isBoolean(payload.isCurrentPlan) ||
|
||||
!isBoolean(payload.isRecommended)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
planId: payload.planId,
|
||||
billingInterval: payload.billingInterval,
|
||||
priceUsd: payload.priceUsd,
|
||||
creditsGrantedUsd: payload.creditsGrantedUsd,
|
||||
deployLimit: Number(payload.deployLimit),
|
||||
introOfferApplied: payload.introOfferApplied,
|
||||
firstMonthEligible: payload.firstMonthEligible,
|
||||
isCurrentPlan: payload.isCurrentPlan,
|
||||
isRecommended: payload.isRecommended,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizedPlanCtaPayload(
|
||||
payload: Record<string, unknown>,
|
||||
): PlanCtaClickFields | null {
|
||||
if (
|
||||
(payload.currentBillingInterval !== null &&
|
||||
!isBillingInterval(payload.currentBillingInterval)) ||
|
||||
!isPlanTier(payload.targetPlanId) ||
|
||||
!isBillingInterval(payload.targetBillingInterval) ||
|
||||
!isUsdAmount(payload.priceUsd) ||
|
||||
!isUsdAmount(payload.creditsGrantedUsd) ||
|
||||
!isBoolean(payload.introOfferApplied) ||
|
||||
!isBoolean(payload.isCurrentPlan) ||
|
||||
!isBoolean(payload.isRecommended)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
currentBillingInterval: payload.currentBillingInterval,
|
||||
targetPlanId: payload.targetPlanId,
|
||||
targetBillingInterval: payload.targetBillingInterval,
|
||||
priceUsd: payload.priceUsd,
|
||||
creditsGrantedUsd: payload.creditsGrantedUsd,
|
||||
introOfferApplied: payload.introOfferApplied,
|
||||
isCurrentPlan: payload.isCurrentPlan,
|
||||
isRecommended: payload.isRecommended,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizedClickPayload(value: unknown): PricingClickInput | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const payload = value as Record<string, unknown>;
|
||||
switch (payload.element) {
|
||||
case 'change_interval':
|
||||
if (
|
||||
(payload.currentPlanId !== null && !isPlanTier(payload.currentPlanId)) ||
|
||||
!isBillingInterval(payload.currentBillingInterval) ||
|
||||
!isBillingInterval(payload.targetBillingInterval)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
element: 'change_interval',
|
||||
currentPlanId: payload.currentPlanId,
|
||||
currentBillingInterval: payload.currentBillingInterval,
|
||||
targetBillingInterval: payload.targetBillingInterval,
|
||||
};
|
||||
case 'subscribe_now': {
|
||||
const fields = sanitizedPlanCtaPayload(payload);
|
||||
if (payload.currentPlanId !== null || !fields) return null;
|
||||
return { element: 'subscribe_now', currentPlanId: null, ...fields };
|
||||
}
|
||||
case 'upgrade_now': {
|
||||
const fields = sanitizedPlanCtaPayload(payload);
|
||||
if (!isPlanTier(payload.currentPlanId) || !fields) return null;
|
||||
return {
|
||||
element: 'upgrade_now',
|
||||
currentPlanId: payload.currentPlanId,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
case 'request_team_access':
|
||||
if (
|
||||
(payload.currentPlanId !== null && !isPlanTier(payload.currentPlanId)) ||
|
||||
(payload.currentBillingInterval !== null &&
|
||||
!isBillingInterval(payload.currentBillingInterval))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
element: 'request_team_access',
|
||||
currentPlanId: payload.currentPlanId,
|
||||
currentBillingInterval: payload.currentBillingInterval,
|
||||
};
|
||||
case 'team_lead_submit':
|
||||
if (
|
||||
(payload.currentPlanId !== null && !isPlanTier(payload.currentPlanId)) ||
|
||||
(payload.currentBillingInterval !== null &&
|
||||
!isBillingInterval(payload.currentBillingInterval))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
element: 'team_lead_submit',
|
||||
currentPlanId: payload.currentPlanId,
|
||||
currentBillingInterval: payload.currentBillingInterval,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizedEvent(event: PricingBridgeEvent): PricingBridgeEvent | null {
|
||||
if (
|
||||
!isBoundedId(event.eventId) ||
|
||||
!isVelaDateTime(event.eventTime)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (event.kind === 'plan_exposure') {
|
||||
const payload = sanitizedPlanPayload(event.payload);
|
||||
return payload
|
||||
? {
|
||||
kind: event.kind,
|
||||
eventId: event.eventId,
|
||||
eventTime: event.eventTime,
|
||||
payload,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (event.kind === 'pricing_click') {
|
||||
const payload = sanitizedClickPayload(event.payload);
|
||||
return payload
|
||||
? {
|
||||
kind: event.kind,
|
||||
eventId: event.eventId,
|
||||
eventTime: event.eventTime,
|
||||
payload,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveApiBase(rawValue: string): URL | null {
|
||||
const value = rawValue.trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value.endsWith('/') ? value : `${value}/`);
|
||||
if (url.username || url.password || url.search || url.hash) return null;
|
||||
const hosted = isTrustedHostedUrl(url) && url.pathname.endsWith('/');
|
||||
const loopback = isTrustedLoopbackUrl(url) && url.pathname === '/';
|
||||
return hosted || loopback ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort authenticated transport. Invalid input and failures never throw. */
|
||||
export async function postPricingBridgeEvents(input: {
|
||||
apiOrigin: string;
|
||||
sourceSurface: PricingBridgeSource;
|
||||
sessionId: string;
|
||||
events: readonly PricingBridgeEvent[];
|
||||
fetcher?: typeof fetch;
|
||||
}): Promise<boolean> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const apiBase = resolveApiBase(input.apiOrigin);
|
||||
if (
|
||||
!apiBase ||
|
||||
(input.sourceSurface !== 'wallet' &&
|
||||
input.sourceSurface !== 'dashboard') ||
|
||||
!isBoundedId(input.sessionId) ||
|
||||
!Array.isArray(input.events) ||
|
||||
input.events.length < 1 ||
|
||||
input.events.length > maxEventsPerRequest
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const events: PricingBridgeEvent[] = [];
|
||||
const eventIds = new Set<string>();
|
||||
for (const event of input.events) {
|
||||
const sanitized = sanitizedEvent(event);
|
||||
if (!sanitized || eventIds.has(sanitized.eventId)) return false;
|
||||
eventIds.add(sanitized.eventId);
|
||||
events.push(sanitized);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
timeout = setTimeout(
|
||||
() => abortController.abort(),
|
||||
transportTimeoutMs,
|
||||
);
|
||||
const response = await (input.fetcher ?? fetch)(
|
||||
new URL('api/v1/analytics/pricing-events', apiBase),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sourceSurface: input.sourceSurface,
|
||||
sessionId: input.sessionId,
|
||||
events,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
PERSONAL_PRICING_TIERS,
|
||||
postPricingBridgeEvents,
|
||||
type PlanExposureInput,
|
||||
type PricingBridgeEvent,
|
||||
type PricingBridgeSource,
|
||||
type PricingClickInput,
|
||||
} from './pricing-analytics-bridge';
|
||||
import type {
|
||||
BillingInterval,
|
||||
PlanTier,
|
||||
PlanTierConfig,
|
||||
} from './pricing';
|
||||
|
||||
export type ResolvedPricingContext = {
|
||||
authenticated: true;
|
||||
sourceSurface: PricingBridgeSource;
|
||||
currentPlanId: PlanTier | null;
|
||||
currentBillingInterval: BillingInterval | null;
|
||||
firstMonthEligible: boolean;
|
||||
};
|
||||
|
||||
type PersonalExposureInput = {
|
||||
audience: 'creator' | 'team';
|
||||
interval: BillingInterval;
|
||||
};
|
||||
|
||||
type IntervalChangeInput = Omit<PersonalExposureInput, 'interval'> & {
|
||||
currentInterval: BillingInterval;
|
||||
targetInterval: BillingInterval;
|
||||
userInitiated: boolean;
|
||||
};
|
||||
|
||||
type PlanClickInput = {
|
||||
audience: 'creator' | 'team';
|
||||
planId: string;
|
||||
interval: BillingInterval;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type CompatibilityTransport = typeof postPricingBridgeEvents;
|
||||
|
||||
type PricingCompatibilityOptions = {
|
||||
apiOrigin?: string;
|
||||
sessionId?: string;
|
||||
tiers?: readonly PlanTierConfig[];
|
||||
postEvents?: CompatibilityTransport;
|
||||
now?: () => Date;
|
||||
createEventId?: () => string;
|
||||
};
|
||||
|
||||
let fallbackEventSequence = 0;
|
||||
|
||||
function defaultEventId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
fallbackEventSequence += 1;
|
||||
return `pricing-${Date.now()}-${fallbackEventSequence}`;
|
||||
}
|
||||
|
||||
function personalPlanFacts(
|
||||
tier: PlanTierConfig,
|
||||
interval: BillingInterval,
|
||||
firstMonthEligible: boolean,
|
||||
) {
|
||||
const introOfferApplied = firstMonthEligible && interval === 'monthly';
|
||||
const priceUsd = interval === 'monthly'
|
||||
? introOfferApplied
|
||||
? tier.monthly.introPriceUsd
|
||||
: tier.monthly.priceUsd
|
||||
: tier.yearly.priceUsd;
|
||||
const creditsGrantedUsd = interval === 'monthly'
|
||||
? tier.monthly.grantUsd
|
||||
: tier.yearly.grantUsd / 12;
|
||||
|
||||
return {
|
||||
priceUsd: priceUsd.toFixed(2),
|
||||
creditsGrantedUsd: creditsGrantedUsd.toFixed(2),
|
||||
introOfferApplied,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function recommendedPlan(currentPlanId: PlanTier | null): PlanTier | null {
|
||||
if (currentPlanId === 'max') return null;
|
||||
return currentPlanId === 'pro' ? 'max' : 'pro';
|
||||
}
|
||||
|
||||
export function createPricingCompatibilityAnalytics({
|
||||
apiOrigin = '',
|
||||
sessionId = '',
|
||||
tiers = PERSONAL_PRICING_TIERS,
|
||||
postEvents = postPricingBridgeEvents,
|
||||
now = () => new Date(),
|
||||
createEventId = defaultEventId,
|
||||
}: PricingCompatibilityOptions) {
|
||||
let context: ResolvedPricingContext | null = null;
|
||||
let lastExposureSignature: string | null = null;
|
||||
|
||||
const createEvent = <T extends PricingBridgeEvent['kind']>(
|
||||
kind: T,
|
||||
payload: T extends 'plan_exposure' ? PlanExposureInput : PricingClickInput,
|
||||
): Extract<PricingBridgeEvent, { kind: T }> => ({
|
||||
kind,
|
||||
eventId: createEventId(),
|
||||
eventTime: now().toISOString(),
|
||||
payload,
|
||||
}) as Extract<PricingBridgeEvent, { kind: T }>;
|
||||
|
||||
const emit = (bridgeEvents: readonly PricingBridgeEvent[]) => {
|
||||
if (!context || bridgeEvents.length === 0) return;
|
||||
try {
|
||||
void Promise.resolve(postEvents({
|
||||
apiOrigin,
|
||||
sourceSurface: context.sourceSurface,
|
||||
sessionId,
|
||||
events: bridgeEvents,
|
||||
})).catch(() => undefined);
|
||||
} catch {
|
||||
// Compatibility delivery is best effort and must not block the action.
|
||||
}
|
||||
};
|
||||
|
||||
const exposureEvents = (
|
||||
input: PersonalExposureInput,
|
||||
): PricingBridgeEvent[] => {
|
||||
const resolved = context;
|
||||
if (!resolved) return [];
|
||||
if (input.audience === 'team') {
|
||||
lastExposureSignature = null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const signature = JSON.stringify([
|
||||
input.audience,
|
||||
input.interval,
|
||||
resolved.firstMonthEligible,
|
||||
resolved.currentPlanId,
|
||||
resolved.currentBillingInterval,
|
||||
]);
|
||||
if (lastExposureSignature === signature) return [];
|
||||
lastExposureSignature = signature;
|
||||
|
||||
return tiers.map((tier) => {
|
||||
const facts = personalPlanFacts(
|
||||
tier,
|
||||
input.interval,
|
||||
resolved.firstMonthEligible,
|
||||
);
|
||||
return createEvent('plan_exposure', {
|
||||
planId: tier.tier,
|
||||
billingInterval: input.interval,
|
||||
priceUsd: facts.priceUsd,
|
||||
creditsGrantedUsd: facts.creditsGrantedUsd,
|
||||
deployLimit: tier.deployLimit,
|
||||
introOfferApplied: facts.introOfferApplied,
|
||||
firstMonthEligible: resolved.firstMonthEligible,
|
||||
isCurrentPlan:
|
||||
resolved.currentPlanId === tier.tier &&
|
||||
resolved.currentBillingInterval === input.interval,
|
||||
isRecommended: tier.tier === recommendedPlan(resolved.currentPlanId),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const exposePlans = (input: PersonalExposureInput) => {
|
||||
emit(exposureEvents(input));
|
||||
};
|
||||
|
||||
const changeInterval = (input: IntervalChangeInput) => {
|
||||
if (!context) return;
|
||||
const bridgeEvents: PricingBridgeEvent[] = [];
|
||||
if (
|
||||
input.audience === 'creator' &&
|
||||
input.userInitiated &&
|
||||
input.currentInterval !== input.targetInterval
|
||||
) {
|
||||
bridgeEvents.push(createEvent('pricing_click', {
|
||||
element: 'change_interval',
|
||||
currentPlanId: context.currentPlanId,
|
||||
currentBillingInterval: input.currentInterval,
|
||||
targetBillingInterval: input.targetInterval,
|
||||
}));
|
||||
}
|
||||
bridgeEvents.push(...exposureEvents({
|
||||
audience: input.audience,
|
||||
interval: input.targetInterval,
|
||||
}));
|
||||
emit(bridgeEvents);
|
||||
};
|
||||
|
||||
const clickPlan = (input: PlanClickInput) => {
|
||||
if (!context || input.audience !== 'creator' || !input.enabled) return;
|
||||
const tier = tiers.find((candidate) => candidate.tier === input.planId);
|
||||
if (!tier) return;
|
||||
|
||||
const facts = personalPlanFacts(
|
||||
tier,
|
||||
input.interval,
|
||||
context.firstMonthEligible,
|
||||
);
|
||||
const common = {
|
||||
currentBillingInterval: context.currentBillingInterval,
|
||||
targetPlanId: tier.tier,
|
||||
targetBillingInterval: input.interval,
|
||||
priceUsd: facts.priceUsd,
|
||||
creditsGrantedUsd: facts.creditsGrantedUsd,
|
||||
introOfferApplied: facts.introOfferApplied,
|
||||
isCurrentPlan:
|
||||
context.currentPlanId === tier.tier &&
|
||||
context.currentBillingInterval === input.interval,
|
||||
isRecommended: tier.tier === recommendedPlan(context.currentPlanId),
|
||||
} as const;
|
||||
const payload: PricingClickInput = context.currentPlanId === null
|
||||
? {
|
||||
element: 'subscribe_now',
|
||||
currentPlanId: null,
|
||||
...common,
|
||||
}
|
||||
: {
|
||||
element: 'upgrade_now',
|
||||
currentPlanId: context.currentPlanId,
|
||||
...common,
|
||||
};
|
||||
emit([createEvent('pricing_click', payload)]);
|
||||
};
|
||||
|
||||
const enterpriseClick = (
|
||||
element: 'request_team_access' | 'team_lead_submit',
|
||||
) => {
|
||||
if (!context) return;
|
||||
const enterpriseContext = {
|
||||
currentPlanId: context.currentPlanId,
|
||||
currentBillingInterval: context.currentBillingInterval,
|
||||
} as const;
|
||||
const payload: PricingClickInput = element === 'request_team_access'
|
||||
? { element: 'request_team_access', ...enterpriseContext }
|
||||
: { element: 'team_lead_submit', ...enterpriseContext };
|
||||
emit([createEvent('pricing_click', payload)]);
|
||||
};
|
||||
|
||||
return {
|
||||
resolveContext(resolved: ResolvedPricingContext) {
|
||||
if (resolved?.authenticated === true) context = resolved;
|
||||
},
|
||||
exposePlans,
|
||||
changeInterval,
|
||||
clickPlan,
|
||||
openEnterpriseLead: () => enterpriseClick('request_team_access'),
|
||||
submitEnterpriseLead: () => enterpriseClick('team_lead_submit'),
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
* static landing-page contract.
|
||||
*/
|
||||
|
||||
export type PlanTier = 'plus' | 'pro' | 'max';
|
||||
export type PlanTier = 'go' | 'plus' | 'pro' | 'max';
|
||||
export type TeamPlanTier =
|
||||
| 'team_basic'
|
||||
| 'team_plus'
|
||||
|
||||
@@ -747,6 +747,9 @@ const jsonLd = [
|
||||
});
|
||||
}
|
||||
trackCampaignBenefit(audience);
|
||||
root.dispatchEvent(new CustomEvent('pricing:audience-changed', {
|
||||
detail: { audience },
|
||||
}));
|
||||
};
|
||||
for (const button of root.querySelectorAll('[data-audience-btn]')) {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -789,6 +792,9 @@ const jsonLd = [
|
||||
// One entry point so user clicks and subscription-state initialization
|
||||
// share the same rendering path without misreporting initialization as a click.
|
||||
const activateInterval = (interval, via, shouldTrack = true) => {
|
||||
const previousInterval = root.getAttribute('data-interval') === 'monthly'
|
||||
? 'monthly'
|
||||
: 'yearly';
|
||||
root.setAttribute('data-interval', interval);
|
||||
for (const b of root.querySelectorAll('[data-interval-btn]')) {
|
||||
const on = b.getAttribute('data-interval-btn') === interval;
|
||||
@@ -797,7 +803,9 @@ const jsonLd = [
|
||||
}
|
||||
updateTeamPlan();
|
||||
syncCtas();
|
||||
root.dispatchEvent(new CustomEvent('pricing:interval-changed', { detail: { interval } }));
|
||||
root.dispatchEvent(new CustomEvent('pricing:interval-changed', {
|
||||
detail: { interval, previousInterval, shouldTrack },
|
||||
}));
|
||||
if (shouldTrack) {
|
||||
track('ui_click', {
|
||||
area: 'pricing_controls',
|
||||
@@ -1079,6 +1087,7 @@ const jsonLd = [
|
||||
for (const btn of document.querySelectorAll('[data-open-lead-modal]')) {
|
||||
btn.addEventListener('click', () => {
|
||||
track('team_lead_open', { area: 'enterprise_card' });
|
||||
root.dispatchEvent(new CustomEvent('pricing:enterprise-open'));
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
@@ -1167,6 +1176,14 @@ const jsonLd = [
|
||||
const interval: PersonalBillingInterval = pricingRoot.getAttribute('data-interval') === 'monthly'
|
||||
? 'monthly'
|
||||
: 'yearly';
|
||||
pricingRoot.setAttribute(
|
||||
'data-current-personal-plan-id',
|
||||
pricingContext.current?.tier ?? '',
|
||||
);
|
||||
pricingRoot.setAttribute(
|
||||
'data-current-personal-billing-interval',
|
||||
pricingContext.current?.interval ?? '',
|
||||
);
|
||||
for (const cta of pricingRoot.querySelectorAll('[data-pricing-cta]')) {
|
||||
const tier = cta.getAttribute('data-tier') || '';
|
||||
if (!personalTiers.has(tier)) continue;
|
||||
@@ -1243,9 +1260,121 @@ const jsonLd = [
|
||||
} else {
|
||||
applyPersonalActions();
|
||||
}
|
||||
(pricingRoot as HTMLElement & {
|
||||
__personalPricingContext?: PersonalPricingContext;
|
||||
}).__personalPricingContext = context;
|
||||
pricingRoot.setAttribute('data-personal-pricing-context-resolved', 'true');
|
||||
pricingRoot.dispatchEvent(new CustomEvent('pricing:personal-context-resolved', {
|
||||
detail: { authenticated: true, context },
|
||||
}));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
import {
|
||||
createPricingCompatibilityAnalytics,
|
||||
} from '../../_lib/pricing-compat-analytics';
|
||||
import {
|
||||
resolvePricingBridgeSource,
|
||||
} from '../../_lib/pricing-analytics-bridge';
|
||||
import type {
|
||||
PersonalPricingContext,
|
||||
} from '../../_lib/pricing-current-plan';
|
||||
import type { BillingInterval } from '../../_lib/pricing';
|
||||
|
||||
const compatibilityRoot = document.querySelector<HTMLElement>('[data-pricing-root]');
|
||||
const sourceSurface = resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(window.location.search),
|
||||
referrer: document.referrer,
|
||||
});
|
||||
if (compatibilityRoot && sourceSurface) {
|
||||
const apiOrigin = compatibilityRoot.getAttribute('data-billing-api-origin') || '';
|
||||
const sessionId = typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `pricing-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const compatibility = createPricingCompatibilityAnalytics({
|
||||
apiOrigin,
|
||||
sessionId,
|
||||
});
|
||||
const readAudience = () =>
|
||||
compatibilityRoot.getAttribute('data-audience') === 'team'
|
||||
? 'team' as const
|
||||
: 'creator' as const;
|
||||
const readInterval = (): BillingInterval =>
|
||||
compatibilityRoot.getAttribute('data-interval') === 'monthly'
|
||||
? 'monthly'
|
||||
: 'yearly';
|
||||
const exposeVisiblePlans = () => {
|
||||
compatibility.exposePlans({
|
||||
audience: readAudience(),
|
||||
interval: readInterval(),
|
||||
});
|
||||
};
|
||||
|
||||
const resolveCompatibilityContext = (context: PersonalPricingContext | null) => {
|
||||
if (!context) return;
|
||||
compatibility.resolveContext({
|
||||
authenticated: true,
|
||||
sourceSurface,
|
||||
currentPlanId: context.current?.tier ?? null,
|
||||
currentBillingInterval: context.current?.interval ?? null,
|
||||
firstMonthEligible: context.firstMonthIntroEligible,
|
||||
});
|
||||
exposeVisiblePlans();
|
||||
};
|
||||
compatibilityRoot.addEventListener('pricing:personal-context-resolved', (event) => {
|
||||
const detail = (event as CustomEvent<{
|
||||
authenticated?: boolean;
|
||||
context?: PersonalPricingContext | null;
|
||||
}>).detail;
|
||||
if (detail?.authenticated !== true) return;
|
||||
resolveCompatibilityContext(detail.context ?? null);
|
||||
});
|
||||
resolveCompatibilityContext((compatibilityRoot as HTMLElement & {
|
||||
__personalPricingContext?: PersonalPricingContext;
|
||||
}).__personalPricingContext ?? null);
|
||||
compatibilityRoot.addEventListener('pricing:audience-changed', exposeVisiblePlans);
|
||||
compatibilityRoot.addEventListener('pricing:interval-changed', (event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{
|
||||
interval?: string;
|
||||
previousInterval?: string;
|
||||
shouldTrack?: boolean;
|
||||
}>
|
||||
).detail;
|
||||
const targetInterval = detail?.interval === 'monthly' ? 'monthly' : 'yearly';
|
||||
const currentInterval = detail?.previousInterval === 'monthly'
|
||||
? 'monthly'
|
||||
: 'yearly';
|
||||
compatibility.changeInterval({
|
||||
audience: readAudience(),
|
||||
currentInterval,
|
||||
targetInterval,
|
||||
userInitiated: detail?.shouldTrack === true,
|
||||
});
|
||||
});
|
||||
compatibilityRoot.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const cta = target.closest<HTMLElement>('[data-pricing-cta]');
|
||||
if (!cta) return;
|
||||
compatibility.clickPlan({
|
||||
audience: readAudience(),
|
||||
planId: cta.getAttribute('data-tier') || '',
|
||||
interval: readInterval(),
|
||||
enabled: !cta.hasAttribute('data-subscription-disabled'),
|
||||
});
|
||||
});
|
||||
compatibilityRoot.addEventListener(
|
||||
'pricing:enterprise-open',
|
||||
compatibility.openEnterpriseLead,
|
||||
);
|
||||
document.addEventListener(
|
||||
'pricing:enterprise-submit',
|
||||
compatibility.submitEnterpriseLead,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
PERSONAL_PRICING_TIERS,
|
||||
postPricingBridgeEvents,
|
||||
resolvePricingBridgeSource,
|
||||
type PricingBridgeEvent,
|
||||
} from '../app/_lib/pricing-analytics-bridge.ts';
|
||||
|
||||
const eventTime = '2026-08-23T12:00:00.000Z';
|
||||
|
||||
const exposureEvent: PricingBridgeEvent = {
|
||||
kind: 'plan_exposure',
|
||||
eventId: 'exposure-1',
|
||||
eventTime,
|
||||
payload: {
|
||||
planId: 'go',
|
||||
billingInterval: 'monthly',
|
||||
priceUsd: '10.00',
|
||||
creditsGrantedUsd: '0.00',
|
||||
deployLimit: 0,
|
||||
introOfferApplied: false,
|
||||
firstMonthEligible: false,
|
||||
isCurrentPlan: false,
|
||||
isRecommended: false,
|
||||
},
|
||||
};
|
||||
|
||||
test('personal compatibility catalog contains go plus pro max', () => {
|
||||
assert.deepEqual(
|
||||
PERSONAL_PRICING_TIERS.map((tier) => tier.tier),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
|
||||
const go = PERSONAL_PRICING_TIERS[0];
|
||||
assert.equal(go?.monthly.priceUsd, 10);
|
||||
assert.equal(go?.yearly.priceUsd, 60);
|
||||
assert.equal(go?.monthly.grantUsd, 0);
|
||||
assert.equal(go?.yearly.grantUsd, 0);
|
||||
assert.equal(go?.deployLimit, 0);
|
||||
assert.equal(go?.recommended, false);
|
||||
});
|
||||
|
||||
test('source resolver accepts only exact trusted wallet/dashboard routes', () => {
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer: 'https://open-design.ai/cloud/dashboard?billing=plan',
|
||||
}),
|
||||
'dashboard',
|
||||
);
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer: 'https://open-design.ai/cloud/wallet',
|
||||
}),
|
||||
'wallet',
|
||||
);
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer: 'https://vela.powerformer.net/dashboard',
|
||||
}),
|
||||
'dashboard',
|
||||
);
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer: 'http://127.0.0.1:5179/wallet',
|
||||
}),
|
||||
'wallet',
|
||||
);
|
||||
|
||||
for (const referrer of [
|
||||
'https://example.com/dashboard',
|
||||
'https://open-design.ai/cloud/dashboard-settings',
|
||||
'https://open-design.ai/cloud/wallet/../dashboard',
|
||||
'https://open-design.ai/cloud/%64ashboard',
|
||||
'https://open-design.ai.evil.example/cloud/dashboard',
|
||||
'http://localhost/dashboard',
|
||||
]) {
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer,
|
||||
}),
|
||||
null,
|
||||
referrer,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('source resolver ignores unrelated handoff state but rejects source overrides', () => {
|
||||
const unrelated = new URLSearchParams({
|
||||
od_locale: 'en',
|
||||
cloud_console_base: 'https://open-design.ai/cloud/',
|
||||
od_entry_id: 'not-forwarded',
|
||||
});
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: unrelated,
|
||||
referrer: 'https://open-design.ai/cloud/dashboard',
|
||||
}),
|
||||
'dashboard',
|
||||
);
|
||||
|
||||
for (const [key, value] of [
|
||||
['sourceSurface', 'dashboard'],
|
||||
['source_surface', 'wallet'],
|
||||
['workspaceTab', 'dashboard'],
|
||||
['pricing_source', 'wallet'],
|
||||
['source', 'workspace_dashboard'],
|
||||
['od_entry_source', 'workspace_dashboard'],
|
||||
['sourceSurface', 'unknown'],
|
||||
]) {
|
||||
assert.equal(
|
||||
resolvePricingBridgeSource({
|
||||
search: new URLSearchParams([[key, value]]),
|
||||
referrer: 'https://open-design.ai/cloud/dashboard',
|
||||
}),
|
||||
null,
|
||||
`${key}=${value}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('transport posts only the reduced authenticated bridge body', async () => {
|
||||
let capturedUrl = '';
|
||||
let capturedInit: RequestInit | undefined;
|
||||
const eventWithForbiddenFields = {
|
||||
...exposureEvent,
|
||||
registryKey: 'subscription_plan_exposure',
|
||||
eventName: 'subscription_plan_exposure',
|
||||
payload: {
|
||||
...exposureEvent.payload,
|
||||
planName: 'Go',
|
||||
autoRechargeSupported: true,
|
||||
email: 'must-not-leave@example.com',
|
||||
},
|
||||
} as PricingBridgeEvent;
|
||||
const clickEvent: PricingBridgeEvent = {
|
||||
kind: 'pricing_click',
|
||||
eventId: 'click-1',
|
||||
eventTime,
|
||||
payload: {
|
||||
element: 'subscribe_now',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: null,
|
||||
targetPlanId: 'go',
|
||||
targetBillingInterval: 'monthly',
|
||||
priceUsd: '10.00',
|
||||
creditsGrantedUsd: '0.00',
|
||||
introOfferApplied: false,
|
||||
isCurrentPlan: false,
|
||||
isRecommended: false,
|
||||
},
|
||||
};
|
||||
const enterpriseClickEvents: PricingBridgeEvent[] = [
|
||||
{
|
||||
kind: 'pricing_click',
|
||||
eventId: 'enterprise-open-1',
|
||||
eventTime,
|
||||
payload: {
|
||||
element: 'request_team_access',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'pricing_click',
|
||||
eventId: 'enterprise-submit-1',
|
||||
eventTime,
|
||||
payload: {
|
||||
element: 'team_lead_submit',
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'yearly',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = await postPricingBridgeEvents({
|
||||
apiOrigin: 'https://amr-api.open-design.ai/',
|
||||
sourceSurface: 'dashboard',
|
||||
sessionId: 'pricing-session-1',
|
||||
events: [eventWithForbiddenFields, clickEvent, ...enterpriseClickEvents],
|
||||
fetcher: async (input, init) => {
|
||||
capturedUrl = String(input);
|
||||
capturedInit = init;
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(
|
||||
capturedUrl,
|
||||
'https://amr-api.open-design.ai/api/v1/analytics/pricing-events',
|
||||
);
|
||||
assert.equal(capturedInit?.method, 'POST');
|
||||
assert.equal(capturedInit?.credentials, 'include');
|
||||
assert.equal(capturedInit?.keepalive, true);
|
||||
assert.equal(
|
||||
new Headers(capturedInit?.headers).get('content-type'),
|
||||
'application/json',
|
||||
);
|
||||
assert.ok(capturedInit?.signal instanceof AbortSignal);
|
||||
assert.deepEqual(JSON.parse(String(capturedInit?.body)), {
|
||||
sourceSurface: 'dashboard',
|
||||
sessionId: 'pricing-session-1',
|
||||
events: [exposureEvent, clickEvent, ...enterpriseClickEvents],
|
||||
});
|
||||
});
|
||||
|
||||
test('transport mirrors Vela UTC datetime syntax for event times', async () => {
|
||||
let calls = 0;
|
||||
const fetcher: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
|
||||
for (const accepted of [
|
||||
'2026-08-23T12:00Z',
|
||||
'2026-08-23T12:00:00Z',
|
||||
'2026-08-23T12:00:00.123456Z',
|
||||
'2028-02-29T23:59:59.9Z',
|
||||
]) {
|
||||
assert.equal(
|
||||
await postPricingBridgeEvents({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sourceSurface: 'dashboard',
|
||||
sessionId: 'session',
|
||||
events: [{ ...exposureEvent, eventTime: accepted }],
|
||||
fetcher,
|
||||
}),
|
||||
true,
|
||||
accepted,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rejected of [
|
||||
'2026-08-23',
|
||||
'2026-08-23T12:00:00+08:00',
|
||||
'2026-08-23T12:00:00.000+0800',
|
||||
'2026-08-23T12:00:00z',
|
||||
'2026-02-30T12:00:00Z',
|
||||
'2026-08-23T24:00:00Z',
|
||||
'not-a-time',
|
||||
]) {
|
||||
assert.equal(
|
||||
await postPricingBridgeEvents({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sourceSurface: 'dashboard',
|
||||
sessionId: 'session',
|
||||
events: [{ ...exposureEvent, eventTime: rejected }],
|
||||
fetcher,
|
||||
}),
|
||||
false,
|
||||
rejected,
|
||||
);
|
||||
}
|
||||
assert.equal(calls, 4);
|
||||
});
|
||||
|
||||
test('transport rejects invalid origins and bounded request IDs before fetch', async () => {
|
||||
let calls = 0;
|
||||
const fetcher: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const attempts = [
|
||||
{
|
||||
apiOrigin: 'https://example.com',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'http://localhost',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'http://localhost:5179/not-an-origin/',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'https://open-design.ai/?next=evil',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: '',
|
||||
events: [exposureEvent],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 's'.repeat(129),
|
||||
events: [exposureEvent],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 'session',
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 'session',
|
||||
events: [{ ...exposureEvent, eventId: 'e'.repeat(129) }],
|
||||
},
|
||||
{
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent, { ...exposureEvent }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const attempt of attempts) {
|
||||
assert.equal(
|
||||
await postPricingBridgeEvents({
|
||||
...attempt,
|
||||
sourceSurface: 'dashboard',
|
||||
fetcher,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('transport returns false for malformed runtime shapes without fetching', async () => {
|
||||
let calls = 0;
|
||||
const fetcher: typeof fetch = async () => {
|
||||
calls += 1;
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const postUnchecked = postPricingBridgeEvents as unknown as (
|
||||
input: unknown,
|
||||
) => Promise<boolean>;
|
||||
const validBase = {
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sourceSurface: 'dashboard',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
fetcher,
|
||||
};
|
||||
|
||||
for (const malformed of [
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ ...validBase, apiOrigin: 42 },
|
||||
{ ...validBase, events: null },
|
||||
{ ...validBase, events: { length: 1 } },
|
||||
{ ...validBase, events: [null] },
|
||||
{ ...validBase, events: [{ ...exposureEvent, payload: null }] },
|
||||
{ ...validBase, events: [{ kind: 'plan_exposure' }] },
|
||||
]) {
|
||||
assert.equal(await postUnchecked(malformed), false);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('transport fails open on network and endpoint failures', async () => {
|
||||
assert.equal(
|
||||
await postPricingBridgeEvents({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sourceSurface: 'wallet',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
fetcher: async () => new Response(null, { status: 401 }),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await postPricingBridgeEvents({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sourceSurface: 'wallet',
|
||||
sessionId: 'session',
|
||||
events: [exposureEvent],
|
||||
fetcher: async () => {
|
||||
throw new TypeError('network unavailable');
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
type BridgeEvent = {
|
||||
kind: 'plan_exposure' | 'pricing_click';
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type BridgeRequest = {
|
||||
sourceSurface: 'wallet' | 'dashboard';
|
||||
sessionId: string;
|
||||
events: BridgeEvent[];
|
||||
};
|
||||
|
||||
type BillingFixture = {
|
||||
membershipTier?: string;
|
||||
billingInterval?: 'monthly' | 'yearly';
|
||||
personalSubscriptionCheckoutAllowed?: boolean;
|
||||
firstMonthIntroEligible?: boolean;
|
||||
subscriptionCancelAtPeriodEnd?: boolean;
|
||||
subscriptionStatus?: string;
|
||||
subscriptionEntitlementStatus?: string;
|
||||
availableActions?: string[];
|
||||
};
|
||||
|
||||
const landingRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
let browser: Browser;
|
||||
let server: ChildProcess;
|
||||
let baseUrl: string;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createServer();
|
||||
socket.once('error', reject);
|
||||
socket.listen(0, '127.0.0.1', () => {
|
||||
const address = socket.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
socket.close();
|
||||
reject(new Error('failed to allocate a browser-test port'));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
socket.close((error) => error ? reject(error) : resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Astro is still starting.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Astro did not become ready at ${url}`);
|
||||
}
|
||||
|
||||
async function buildLandingPage(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const build = spawn('pnpm', ['exec', 'astro', 'build'], {
|
||||
cwd: landingRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '';
|
||||
build.stdout?.on('data', (chunk) => { output += String(chunk); });
|
||||
build.stderr?.on('data', (chunk) => { output += String(chunk); });
|
||||
build.once('error', reject);
|
||||
build.once('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Astro build failed (${code ?? 'signal'}):\n${output}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const port = await freePort();
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
await buildLandingPage();
|
||||
server = spawn(
|
||||
'pnpm',
|
||||
['exec', 'astro', 'preview', '--host', '127.0.0.1', '--port', String(port)],
|
||||
{ cwd: landingRoot, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
let serverOutput = '';
|
||||
server.stdout?.on('data', (chunk) => { serverOutput += String(chunk); });
|
||||
server.stderr?.on('data', (chunk) => { serverOutput += String(chunk); });
|
||||
server.once('exit', (code) => {
|
||||
if (code && code !== 0) process.stderr.write(serverOutput);
|
||||
});
|
||||
await waitForServer(`${baseUrl}/pricing/`);
|
||||
|
||||
const localChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(existsSync(localChrome) ? { executablePath: localChrome } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
if (server && !server.killed) server.kill('SIGTERM');
|
||||
});
|
||||
|
||||
async function openPricing(input: {
|
||||
billing?: BillingFixture;
|
||||
browserLocale?: string;
|
||||
sourcePath?: '/dashboard' | '/wallet' | '/not-a-pricing-source' | null;
|
||||
signedIn?: boolean;
|
||||
targetHref?: string;
|
||||
} = {}): Promise<{ page: Page; requests: BridgeRequest[]; navigations: string[] }> {
|
||||
const context = await browser.newContext({
|
||||
locale: input.browserLocale ?? 'en-US',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const requests: BridgeRequest[] = [];
|
||||
const navigations: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
||||
navigations.push(request.url());
|
||||
}
|
||||
});
|
||||
const sourcePath = input.sourcePath === undefined ? '/dashboard' : input.sourcePath;
|
||||
const targetHref = input.targetHref ?? '/pricing/';
|
||||
const signedIn = input.signedIn ?? true;
|
||||
const billing: BillingFixture = {
|
||||
personalSubscriptionCheckoutAllowed: true,
|
||||
firstMonthIntroEligible: true,
|
||||
subscriptionCancelAtPeriodEnd: false,
|
||||
availableActions: ['billing_portal'],
|
||||
...input.billing,
|
||||
};
|
||||
|
||||
await page.route('https://amr-api.open-design.ai/**', async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
const cors = {
|
||||
'Access-Control-Allow-Origin': baseUrl,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (pathname === '/api/auth/get-session') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: cors,
|
||||
body: JSON.stringify(signedIn ? { user: { id: 'user-1' } } : null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/billing/summary') {
|
||||
await route.fulfill({ status: 200, headers: cors, body: JSON.stringify(billing) });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/analytics/pricing-events') {
|
||||
requests.push(request.postDataJSON() as BridgeRequest);
|
||||
await route.fulfill({ status: 204, headers: cors, body: '' });
|
||||
return;
|
||||
}
|
||||
await route.abort();
|
||||
});
|
||||
|
||||
if (sourcePath) {
|
||||
await page.route(`${baseUrl}${sourcePath}`, async (route) => {
|
||||
const escapedTargetHref = targetHref
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `<!doctype html><a id="pricing-link" href="${escapedTargetHref}">Pricing</a>`,
|
||||
});
|
||||
});
|
||||
await page.goto(`${baseUrl}${sourcePath}`);
|
||||
await page.locator('#pricing-link').click();
|
||||
await page.waitForURL((url) => url.pathname.endsWith('/pricing/'));
|
||||
} else {
|
||||
await page.goto(`${baseUrl}/pricing/`);
|
||||
}
|
||||
return { page, requests, navigations };
|
||||
}
|
||||
|
||||
async function waitForRequests(
|
||||
requests: BridgeRequest[],
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
await assert.doesNotReject(async () => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (requests.length < count && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.ok(requests.length >= count, `expected ${count} bridge request(s), got ${requests.length}`);
|
||||
});
|
||||
}
|
||||
|
||||
function flattened(requests: BridgeRequest[]): BridgeEvent[] {
|
||||
return requests.flatMap((request) => request.events);
|
||||
}
|
||||
|
||||
describe('authenticated Pricing compatibility browser wiring', { concurrency: false }, () => {
|
||||
it('sends corrected Go Plus Pro Max context on the first trusted dashboard exposure', async (t) => {
|
||||
const { page, requests, navigations } = await openPricing({
|
||||
billing: {
|
||||
membershipTier: 'pro',
|
||||
billingInterval: 'monthly',
|
||||
firstMonthIntroEligible: false,
|
||||
},
|
||||
});
|
||||
t.after(() => page.context().close());
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector('[data-pricing-root]')?.getAttribute(
|
||||
'data-personal-pricing-context-resolved',
|
||||
) === 'true',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.referrer),
|
||||
`${baseUrl}/dashboard`,
|
||||
navigations.join(' -> '),
|
||||
);
|
||||
await waitForRequests(requests, 1);
|
||||
|
||||
assert.equal(requests[0]?.sourceSurface, 'dashboard');
|
||||
assert.ok(requests[0]?.sessionId);
|
||||
assert.deepEqual(
|
||||
requests[0]?.events.map((event) => event.payload.planId),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requests[0]?.events.map((event) => [
|
||||
event.payload.planId,
|
||||
event.payload.billingInterval,
|
||||
event.payload.firstMonthEligible,
|
||||
event.payload.isCurrentPlan,
|
||||
]),
|
||||
[
|
||||
['go', 'monthly', false, false],
|
||||
['plus', 'monthly', false, false],
|
||||
['pro', 'monthly', false, true],
|
||||
['max', 'monthly', false, false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves wallet attribution for a direct Chinese Vela locale handoff', async (t) => {
|
||||
const targetHref =
|
||||
'/zh/pricing/?od_locale=zh&cloud_console_base=' +
|
||||
encodeURIComponent('https://open-design.ai/cloud/');
|
||||
const { page, requests, navigations } = await openPricing({
|
||||
browserLocale: 'zh-CN',
|
||||
sourcePath: '/wallet',
|
||||
targetHref,
|
||||
});
|
||||
t.after(() => page.context().close());
|
||||
await waitForRequests(requests, 1);
|
||||
|
||||
assert.deepEqual(navigations, [
|
||||
`${baseUrl}/wallet`,
|
||||
`${baseUrl}${targetHref}`,
|
||||
]);
|
||||
assert.equal(
|
||||
page.url(),
|
||||
`${baseUrl}/zh/pricing/?cloud_console_base=${encodeURIComponent('https://open-design.ai/cloud/')}`,
|
||||
);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.lang), 'zh-CN');
|
||||
assert.equal(await page.evaluate(() => document.referrer), `${baseUrl}/wallet`);
|
||||
assert.equal(requests[0]?.sourceSurface, 'wallet');
|
||||
assert.deepEqual(
|
||||
requests[0]?.events.map((event) => event.payload.planId),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for direct, untrusted-route, and signed-out traffic', async (t) => {
|
||||
for (const fixture of [
|
||||
{ sourcePath: null, signedIn: true },
|
||||
{ sourcePath: '/not-a-pricing-source' as const, signedIn: true },
|
||||
{ sourcePath: '/dashboard' as const, signedIn: false },
|
||||
]) {
|
||||
const opened = await openPricing(fixture);
|
||||
t.after(() => opened.page.context().close());
|
||||
await opened.page.waitForTimeout(300);
|
||||
assert.deepEqual(opened.requests, [], JSON.stringify(fixture));
|
||||
}
|
||||
});
|
||||
|
||||
it('orders interval click before new exposures and re-exposes after Team', async (t) => {
|
||||
const { page, requests } = await openPricing();
|
||||
t.after(() => page.context().close());
|
||||
await waitForRequests(requests, 1);
|
||||
|
||||
await page.locator('[data-interval-btn="monthly"]').click();
|
||||
await waitForRequests(requests, 2);
|
||||
assert.deepEqual(
|
||||
requests[1]?.events.map((event) =>
|
||||
event.kind === 'pricing_click' ? event.payload.element : event.payload.planId,
|
||||
),
|
||||
['change_interval', 'go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
|
||||
await page.locator('[data-audience-btn="team"]').click();
|
||||
await page.locator('[data-audience-btn="creator"]').click();
|
||||
await waitForRequests(requests, 3);
|
||||
assert.deepEqual(
|
||||
requests[2]?.events.map((event) => event.payload.planId),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes disabled Personal CTAs and records invalid Enterprise submit intent', async (t) => {
|
||||
const { page, requests } = await openPricing({
|
||||
billing: { membershipTier: 'pro', billingInterval: 'yearly' },
|
||||
});
|
||||
t.after(() => page.context().close());
|
||||
await waitForRequests(requests, 1);
|
||||
|
||||
const disabledPro = page.locator('[data-pricing-cta][data-tier="pro"]');
|
||||
await assert.doesNotReject(() => disabledPro.click({ force: true }));
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(
|
||||
flattened(requests).filter((event) => event.kind === 'pricing_click').length,
|
||||
0,
|
||||
);
|
||||
|
||||
await page.locator('[data-audience-btn="team"]').click();
|
||||
await page.locator('[data-open-lead-modal]').click();
|
||||
await waitForRequests(requests, 2);
|
||||
await page.locator('#ent-form button[type="submit"]').click();
|
||||
await waitForRequests(requests, 3);
|
||||
assert.deepEqual(
|
||||
flattened(requests)
|
||||
.filter((event) => event.kind === 'pricing_click')
|
||||
.map((event) => event.payload),
|
||||
[
|
||||
{
|
||||
element: 'request_team_access',
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'yearly',
|
||||
},
|
||||
{
|
||||
element: 'team_lead_submit',
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'yearly',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createPricingCompatibilityAnalytics,
|
||||
type ResolvedPricingContext,
|
||||
} from '../app/_lib/pricing-compat-analytics.ts';
|
||||
import {
|
||||
PERSONAL_PRICING_TIERS,
|
||||
type PricingBridgeEvent,
|
||||
type postPricingBridgeEvents,
|
||||
} from '../app/_lib/pricing-analytics-bridge.ts';
|
||||
|
||||
type BridgeRequest = Parameters<typeof postPricingBridgeEvents>[0];
|
||||
type Harness = ReturnType<typeof harness>;
|
||||
|
||||
const dashboardContext: ResolvedPricingContext = {
|
||||
authenticated: true,
|
||||
sourceSurface: 'dashboard',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: null,
|
||||
firstMonthEligible: true,
|
||||
};
|
||||
|
||||
function harness() {
|
||||
const requests: BridgeRequest[] = [];
|
||||
let eventSequence = 0;
|
||||
const analytics = createPricingCompatibilityAnalytics({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 'pricing-session-1',
|
||||
tiers: PERSONAL_PRICING_TIERS,
|
||||
now: () => new Date('2026-08-23T12:00:00.000Z'),
|
||||
createEventId: () => `event-${++eventSequence}`,
|
||||
postEvents: async (request) => {
|
||||
requests.push(request);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return { analytics, requests };
|
||||
}
|
||||
|
||||
function resolve(
|
||||
testHarness: Harness,
|
||||
overrides: Partial<Omit<ResolvedPricingContext, 'authenticated'>> = {},
|
||||
) {
|
||||
testHarness.analytics.resolveContext({
|
||||
...dashboardContext,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function events(testHarness: Harness): PricingBridgeEvent[] {
|
||||
return testHarness.requests.flatMap((request) => [...request.events]);
|
||||
}
|
||||
|
||||
function exposures(testHarness: Harness) {
|
||||
return events(testHarness).filter(
|
||||
(event): event is Extract<PricingBridgeEvent, { kind: 'plan_exposure' }> =>
|
||||
event.kind === 'plan_exposure',
|
||||
);
|
||||
}
|
||||
|
||||
function clicks(testHarness: Harness) {
|
||||
return events(testHarness).filter(
|
||||
(event): event is Extract<PricingBridgeEvent, { kind: 'pricing_click' }> =>
|
||||
event.kind === 'pricing_click',
|
||||
);
|
||||
}
|
||||
|
||||
describe('migrated Pricing compatibility analytics', () => {
|
||||
it('no-ops every interaction until authenticated Vela context resolves', () => {
|
||||
const testHarness = harness();
|
||||
|
||||
testHarness.analytics.exposePlans({ audience: 'creator', interval: 'yearly' });
|
||||
testHarness.analytics.changeInterval({
|
||||
audience: 'creator',
|
||||
currentInterval: 'yearly',
|
||||
targetInterval: 'monthly',
|
||||
userInitiated: true,
|
||||
});
|
||||
testHarness.analytics.clickPlan({
|
||||
audience: 'creator',
|
||||
planId: 'go',
|
||||
interval: 'monthly',
|
||||
enabled: true,
|
||||
});
|
||||
testHarness.analytics.openEnterpriseLead();
|
||||
testHarness.analytics.submitEnterpriseLead();
|
||||
|
||||
assert.deepEqual(testHarness.requests, []);
|
||||
});
|
||||
|
||||
it('emits resolved Go Plus Pro Max yearly exposures with literal legacy facts', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
|
||||
testHarness.analytics.exposePlans({ audience: 'creator', interval: 'yearly' });
|
||||
|
||||
const captured = exposures(testHarness);
|
||||
assert.deepEqual(
|
||||
captured.map((event) => event.payload.planId),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
assert.deepEqual(captured[0], {
|
||||
kind: 'plan_exposure',
|
||||
eventId: 'event-1',
|
||||
eventTime: '2026-08-23T12:00:00.000Z',
|
||||
payload: {
|
||||
planId: 'go',
|
||||
billingInterval: 'yearly',
|
||||
priceUsd: '60.00',
|
||||
creditsGrantedUsd: '0.00',
|
||||
deployLimit: 0,
|
||||
introOfferApplied: false,
|
||||
firstMonthEligible: true,
|
||||
isCurrentPlan: false,
|
||||
isRecommended: false,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
captured.map((event) => [
|
||||
event.payload.planId,
|
||||
event.payload.priceUsd,
|
||||
event.payload.creditsGrantedUsd,
|
||||
event.payload.deployLimit,
|
||||
event.payload.isRecommended,
|
||||
]),
|
||||
[
|
||||
['go', '60.00', '0.00', 0, false],
|
||||
['plus', '168.00', '20.00', 3, false],
|
||||
['pro', '720.00', '120.00', 20, true],
|
||||
['max', '1176.00', '300.00', 50, false],
|
||||
],
|
||||
);
|
||||
assert.equal(testHarness.requests[0]?.sourceSurface, 'dashboard');
|
||||
assert.equal(testHarness.requests[0]?.sessionId, 'pricing-session-1');
|
||||
});
|
||||
|
||||
it('preserves the legacy recommendation progression for every current plan exposure', () => {
|
||||
const fixtures = [
|
||||
{ currentPlanId: null, expected: [false, false, true, false] },
|
||||
{ currentPlanId: 'go', expected: [false, false, true, false] },
|
||||
{ currentPlanId: 'plus', expected: [false, false, true, false] },
|
||||
{ currentPlanId: 'pro', expected: [false, false, false, true] },
|
||||
{ currentPlanId: 'max', expected: [false, false, false, false] },
|
||||
] as const;
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness, {
|
||||
currentPlanId: fixture.currentPlanId,
|
||||
currentBillingInterval: fixture.currentPlanId ? 'monthly' : null,
|
||||
});
|
||||
testHarness.analytics.exposePlans({ audience: 'creator', interval: 'yearly' });
|
||||
|
||||
assert.deepEqual(
|
||||
exposures(testHarness).map((event) => event.payload.isRecommended),
|
||||
fixture.expected,
|
||||
`current plan ${fixture.currentPlanId ?? 'none'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a pre-resolution render swallow the corrected first exposure', () => {
|
||||
const testHarness = harness();
|
||||
|
||||
testHarness.analytics.exposePlans({ audience: 'creator', interval: 'monthly' });
|
||||
resolve(testHarness, {
|
||||
sourceSurface: 'wallet',
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'monthly',
|
||||
firstMonthEligible: false,
|
||||
});
|
||||
testHarness.analytics.exposePlans({ audience: 'creator', interval: 'monthly' });
|
||||
|
||||
const captured = exposures(testHarness);
|
||||
assert.equal(captured.length, 4);
|
||||
assert.equal(captured[0]?.payload.introOfferApplied, false);
|
||||
assert.equal(captured[0]?.payload.firstMonthEligible, false);
|
||||
assert.equal(captured[2]?.payload.planId, 'pro');
|
||||
assert.equal(captured[2]?.payload.isCurrentPlan, true);
|
||||
assert.equal(testHarness.requests[0]?.sourceSurface, 'wallet');
|
||||
});
|
||||
|
||||
it('deduplicates the full resolved state and re-exposes after Team', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
const visible = { audience: 'creator' as const, interval: 'yearly' as const };
|
||||
|
||||
testHarness.analytics.exposePlans(visible);
|
||||
testHarness.analytics.exposePlans(visible);
|
||||
assert.equal(exposures(testHarness).length, 4);
|
||||
|
||||
resolve(testHarness, { firstMonthEligible: false });
|
||||
testHarness.analytics.exposePlans(visible);
|
||||
resolve(testHarness, { firstMonthEligible: false, currentPlanId: 'plus' });
|
||||
testHarness.analytics.exposePlans(visible);
|
||||
resolve(testHarness, {
|
||||
firstMonthEligible: false,
|
||||
currentPlanId: 'plus',
|
||||
currentBillingInterval: 'monthly',
|
||||
});
|
||||
testHarness.analytics.exposePlans(visible);
|
||||
assert.equal(exposures(testHarness).length, 16);
|
||||
|
||||
testHarness.analytics.exposePlans({ audience: 'team', interval: 'yearly' });
|
||||
testHarness.analytics.exposePlans(visible);
|
||||
assert.equal(exposures(testHarness).length, 20);
|
||||
});
|
||||
|
||||
it('sends a real interval click before the new interval exposure batch', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
testHarness.analytics.exposePlans({ audience: 'creator', interval: 'yearly' });
|
||||
testHarness.requests.length = 0;
|
||||
|
||||
testHarness.analytics.changeInterval({
|
||||
audience: 'creator',
|
||||
currentInterval: 'yearly',
|
||||
targetInterval: 'monthly',
|
||||
userInitiated: true,
|
||||
});
|
||||
|
||||
assert.equal(testHarness.requests.length, 1);
|
||||
assert.deepEqual(
|
||||
testHarness.requests[0]?.events.map((event) =>
|
||||
event.kind === 'pricing_click' ? event.payload.element : event.payload.planId,
|
||||
),
|
||||
['change_interval', 'go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
assert.deepEqual(clicks(testHarness)[0]?.payload, {
|
||||
element: 'change_interval',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: 'yearly',
|
||||
targetBillingInterval: 'monthly',
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes programmatic interval changes from click events', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
|
||||
testHarness.analytics.changeInterval({
|
||||
audience: 'creator',
|
||||
currentInterval: 'yearly',
|
||||
targetInterval: 'monthly',
|
||||
userInitiated: false,
|
||||
});
|
||||
|
||||
assert.equal(clicks(testHarness).length, 0);
|
||||
assert.deepEqual(
|
||||
exposures(testHarness).map((event) => event.payload.planId),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
});
|
||||
|
||||
it('emits exact subscribe and upgrade payloads for enabled Personal CTAs', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness, { firstMonthEligible: true });
|
||||
|
||||
testHarness.analytics.clickPlan({
|
||||
audience: 'creator',
|
||||
planId: 'go',
|
||||
interval: 'monthly',
|
||||
enabled: true,
|
||||
});
|
||||
resolve(testHarness, {
|
||||
currentPlanId: 'plus',
|
||||
currentBillingInterval: 'monthly',
|
||||
firstMonthEligible: false,
|
||||
});
|
||||
testHarness.analytics.clickPlan({
|
||||
audience: 'creator',
|
||||
planId: 'pro',
|
||||
interval: 'yearly',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(clicks(testHarness).map((event) => event.payload), [
|
||||
{
|
||||
element: 'subscribe_now',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: null,
|
||||
targetPlanId: 'go',
|
||||
targetBillingInterval: 'monthly',
|
||||
priceUsd: '5.00',
|
||||
creditsGrantedUsd: '0.00',
|
||||
introOfferApplied: true,
|
||||
isCurrentPlan: false,
|
||||
isRecommended: false,
|
||||
},
|
||||
{
|
||||
element: 'upgrade_now',
|
||||
currentPlanId: 'plus',
|
||||
currentBillingInterval: 'monthly',
|
||||
targetPlanId: 'pro',
|
||||
targetBillingInterval: 'yearly',
|
||||
priceUsd: '720.00',
|
||||
creditsGrantedUsd: '120.00',
|
||||
introOfferApplied: false,
|
||||
isCurrentPlan: false,
|
||||
isRecommended: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the legacy recommendation progression for every current plan CTA', () => {
|
||||
const fixtures = [
|
||||
{ currentPlanId: null, expected: [false, false, true, false] },
|
||||
{ currentPlanId: 'go', expected: [false, false, true, false] },
|
||||
{ currentPlanId: 'plus', expected: [false, false, true, false] },
|
||||
{ currentPlanId: 'pro', expected: [false, false, false, true] },
|
||||
{ currentPlanId: 'max', expected: [false, false, false, false] },
|
||||
] as const;
|
||||
const planIds = ['go', 'plus', 'pro', 'max'] as const;
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness, {
|
||||
currentPlanId: fixture.currentPlanId,
|
||||
currentBillingInterval: fixture.currentPlanId ? 'monthly' : null,
|
||||
});
|
||||
for (const planId of planIds) {
|
||||
testHarness.analytics.clickPlan({
|
||||
audience: 'creator',
|
||||
planId,
|
||||
interval: 'yearly',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
clicks(testHarness).map((event) => event.payload.isRecommended),
|
||||
fixture.expected,
|
||||
`current plan ${fixture.currentPlanId ?? 'none'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes disabled, Team, and unknown plan CTAs', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
|
||||
for (const input of [
|
||||
{ audience: 'creator' as const, planId: 'go', enabled: false },
|
||||
{ audience: 'team' as const, planId: 'go', enabled: true },
|
||||
{ audience: 'creator' as const, planId: 'team', enabled: true },
|
||||
{ audience: 'creator' as const, planId: 'unknown', enabled: true },
|
||||
]) {
|
||||
testHarness.analytics.clickPlan({ ...input, interval: 'yearly' });
|
||||
}
|
||||
|
||||
assert.deepEqual(testHarness.requests, []);
|
||||
});
|
||||
|
||||
it('fails closed when a Personal CTA omits its audience', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
|
||||
testHarness.analytics.clickPlan({
|
||||
planId: 'go',
|
||||
interval: 'monthly',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(testHarness.requests, []);
|
||||
});
|
||||
|
||||
it('records Enterprise submit as an immediate intent event', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness, {
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'yearly',
|
||||
});
|
||||
|
||||
testHarness.analytics.openEnterpriseLead();
|
||||
testHarness.analytics.submitEnterpriseLead();
|
||||
|
||||
assert.deepEqual(clicks(testHarness).map((event) => event.payload), [
|
||||
{
|
||||
element: 'request_team_access',
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'yearly',
|
||||
},
|
||||
{
|
||||
element: 'team_lead_submit',
|
||||
currentPlanId: 'pro',
|
||||
currentBillingInterval: 'yearly',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('records nullable Enterprise context for users without a current plan', () => {
|
||||
const testHarness = harness();
|
||||
resolve(testHarness);
|
||||
|
||||
testHarness.analytics.openEnterpriseLead();
|
||||
testHarness.analytics.submitEnterpriseLead();
|
||||
|
||||
assert.deepEqual(clicks(testHarness).map((event) => event.payload), [
|
||||
{
|
||||
element: 'request_team_access',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: null,
|
||||
},
|
||||
{
|
||||
element: 'team_lead_submit',
|
||||
currentPlanId: null,
|
||||
currentBillingInterval: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps transport failures best effort', async () => {
|
||||
const rejected = createPricingCompatibilityAnalytics({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 'pricing-session-1',
|
||||
tiers: PERSONAL_PRICING_TIERS,
|
||||
postEvents: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
});
|
||||
const synchronous = createPricingCompatibilityAnalytics({
|
||||
apiOrigin: 'https://amr-api.open-design.ai',
|
||||
sessionId: 'pricing-session-2',
|
||||
tiers: PERSONAL_PRICING_TIERS,
|
||||
postEvents: (() => {
|
||||
throw new Error('offline');
|
||||
}) as typeof postPricingBridgeEvents,
|
||||
});
|
||||
for (const analytics of [rejected, synchronous]) {
|
||||
analytics.resolveContext(dashboardContext);
|
||||
assert.doesNotThrow(() => analytics.openEnterpriseLead());
|
||||
}
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 0));
|
||||
});
|
||||
});
|
||||
@@ -1012,6 +1012,11 @@ describe("pricing contract", () => {
|
||||
assert.match(page, /data-downgrade-plan-label=\{planActionLabels\.downgrade\}/);
|
||||
assert.match(page, /data-upgrade-plan-label=\{planActionLabels\.upgrade\}/);
|
||||
assert.match(page, /loadPersonalPricingContext\(apiOrigin\)/);
|
||||
assert.match(page, /pricing:personal-context-resolved/);
|
||||
assert.match(page, /resolvePricingBridgeSource/);
|
||||
assert.match(page, /authenticated:\s*true/);
|
||||
assert.doesNotMatch(page, /pricingCompatibilityAttribution/);
|
||||
assert.doesNotMatch(page, /tiers:\s*PRICING_SNAPSHOT\.tiers/);
|
||||
assert.match(page, /resolvePersonalPlanAction\(pricingContext/);
|
||||
assert.match(page, /action\.kind === 'dual_change'/);
|
||||
assert.doesNotMatch(page, /action\.kind === 'manage_billing'/);
|
||||
@@ -1034,6 +1039,31 @@ describe("pricing contract", () => {
|
||||
assert.match(individualPlans, /\.pricing-card-cta\s*\{[^}]*border:\s*0;/s);
|
||||
});
|
||||
|
||||
it("records Pricing Enterprise submit intent before shared-form validation", async () => {
|
||||
const [page, form] = await Promise.all([
|
||||
readFile(PRICING_PAGE_PATH, "utf8"),
|
||||
readFile(
|
||||
new URL("../app/_components/enterprise-lead-form.astro", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
const submitHandler = form.slice(
|
||||
form.indexOf("form.addEventListener('submit'"),
|
||||
form.indexOf("const data = new FormData(form)"),
|
||||
);
|
||||
assert.match(
|
||||
submitHandler,
|
||||
/pricing:enterprise-submit[\s\S]*?\['email', 'team-size'/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
page.slice(
|
||||
page.indexOf("modal.addEventListener('od:lead-success'"),
|
||||
page.indexOf("});", page.indexOf("modal.addEventListener('od:lead-success'")) + 3,
|
||||
),
|
||||
/pricing:enterprise-submit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("restores account actions only on Pricing", async () => {
|
||||
const layout = await readFile(
|
||||
new URL("../app/_components/sub-page-layout.astro", import.meta.url),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# OpenDesign Authenticated Pricing Analytics Emitter Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Send migrated Pricing interactions to Vela's authenticated compatibility endpoint so the existing AMR subscription funnel resumes without changing the Pricing or checkout flow.
|
||||
|
||||
**Architecture:** A pure controller builds reduced, strictly bounded bridge records and a transport posts them to Vela with credentials and keepalive. The page activates the controller only after authenticated pricing context and a trusted wallet/dashboard source resolve; existing OpenDesign PostHog events remain independent.
|
||||
|
||||
**Tech Stack:** Astro 6, TypeScript, Node test runner, Playwright, PostHog wrapper (unchanged)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Base the work on the latest `nexu-io/open-design/main`.
|
||||
- Do not change Pricing UI, prices, entitlements, CTA destinations, checkout behavior, or existing OpenDesign analytics.
|
||||
- Only authenticated Vela sessions arriving from trusted wallet/dashboard surfaces enter the compatibility funnel.
|
||||
- Compatibility delivery is best effort and must never block navigation or form submission.
|
||||
- Never include email, company, lead free text, raw URL, or raw referrer in the bridge request.
|
||||
- Vela endpoint must deploy before this emitter.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Define the reduced bridge contract and strict source resolver
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/landing-page/app/_lib/pricing.ts`
|
||||
- Create: `apps/landing-page/app/_lib/pricing-analytics-bridge.ts`
|
||||
- Create: `apps/landing-page/tests/pricing-analytics-bridge.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GO_PLAN`, `PRICING_SNAPSHOT.tiers`, `HOSTED_CLOUD_CONSOLE_DOMAINS`
|
||||
- Produces: `PERSONAL_PRICING_TIERS`, `resolvePricingBridgeSource()`, `postPricingBridgeEvents()`, and the reduced `PricingBridgeEvent` union
|
||||
|
||||
- [ ] **Step 1: Write failing contract tests**
|
||||
|
||||
```ts
|
||||
test('personal compatibility catalog contains go plus pro max', () => {
|
||||
assert.deepEqual(PERSONAL_PRICING_TIERS.map((tier) => tier.tier), [
|
||||
'go', 'plus', 'pro', 'max',
|
||||
]);
|
||||
});
|
||||
|
||||
test('source resolver accepts only exact trusted wallet/dashboard routes', () => {
|
||||
assert.equal(resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer: 'https://open-design.ai/cloud/dashboard?billing=plan',
|
||||
}), 'dashboard');
|
||||
assert.equal(resolvePricingBridgeSource({
|
||||
search: new URLSearchParams(),
|
||||
referrer: 'https://example.com/dashboard',
|
||||
}), null);
|
||||
});
|
||||
```
|
||||
|
||||
Also assert that unknown query values, substring routes, oversized IDs, and untrusted hosts return `null`, and that the Go adapter yields price 10/60, credits 0, deploy limit 0, and `recommended=false`.
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify RED**
|
||||
|
||||
Run: `pnpm --filter @open-design/landing-page exec node --import tsx --test tests/pricing-analytics-bridge.test.ts`
|
||||
Expected: FAIL because the catalog, resolver, and transport do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal bridge module**
|
||||
|
||||
```ts
|
||||
export type PricingBridgeSource = 'wallet' | 'dashboard';
|
||||
|
||||
export type PricingBridgeEvent =
|
||||
| { kind: 'plan_exposure'; eventId: string; eventTime: string; payload: PlanExposureInput }
|
||||
| { kind: 'pricing_click'; eventId: string; eventTime: string; payload: PricingClickInput };
|
||||
|
||||
export async function postPricingBridgeEvents(input: {
|
||||
apiOrigin: string;
|
||||
sourceSurface: PricingBridgeSource;
|
||||
sessionId: string;
|
||||
events: readonly PricingBridgeEvent[];
|
||||
fetcher?: typeof fetch;
|
||||
}): Promise<boolean>;
|
||||
```
|
||||
|
||||
Build `PERSONAL_PRICING_TIERS` by adapting `GO_PLAN` and appending the existing snapshot tiers. Validate the API origin with the same hosted/loopback policy used by the checkout handoff. POST only the reduced body to `/api/v1/analytics/pricing-events` with `credentials: 'include'`, `keepalive: true`, JSON headers, and a short abort timeout. Return `false` on every failure without throwing.
|
||||
|
||||
- [ ] **Step 4: Run the focused tests and verify GREEN**
|
||||
|
||||
Run: `pnpm --filter @open-design/landing-page exec node --import tsx --test tests/pricing-analytics-bridge.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/landing-page/app/_lib/pricing.ts \
|
||||
apps/landing-page/app/_lib/pricing-analytics-bridge.ts \
|
||||
apps/landing-page/tests/pricing-analytics-bridge.test.ts
|
||||
git commit -m "feat(analytics): add authenticated pricing bridge contract"
|
||||
```
|
||||
|
||||
### Task 2: Rebuild compatibility semantics around resolved authentication context
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/landing-page/app/_lib/pricing-compat-analytics.ts`
|
||||
- Modify: `apps/landing-page/tests/pricing-compat-analytics.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PricingBridgeEvent`, `PERSONAL_PRICING_TIERS`
|
||||
- Produces: `createPricingCompatibilityAnalytics()` that emits reduced records only after `resolveContext()`
|
||||
|
||||
- [ ] **Step 1: Replace the controller assertions with failing legacy-semantics tests**
|
||||
|
||||
Add literal expectations proving:
|
||||
|
||||
```ts
|
||||
assert.deepEqual(exposures.map((event) => event.payload.planId), [
|
||||
'go', 'plus', 'pro', 'max',
|
||||
]);
|
||||
assert.equal(exposures[0].payload.creditsGrantedUsd, '0.00');
|
||||
assert.equal(exposures[0].payload.deployLimit, 0);
|
||||
```
|
||||
|
||||
Add independent tests that no exposure occurs before context resolution, a same-interval context correction emits the correct first exposure, dedupe includes eligibility/current-plan state, interval click precedes new exposures, disabled/Team CTAs are excluded, and Enterprise submit is an intent event.
|
||||
|
||||
- [ ] **Step 2: Run the controller test and verify RED**
|
||||
|
||||
Run: `pnpm --filter @open-design/landing-page exec node --import tsx --test tests/pricing-compat-analytics.test.ts`
|
||||
Expected: FAIL for missing Go, eager exposure, incomplete signature, and old transport event names.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal state machine**
|
||||
|
||||
The controller owns:
|
||||
|
||||
```ts
|
||||
type ResolvedPricingContext = {
|
||||
authenticated: true;
|
||||
sourceSurface: 'wallet' | 'dashboard';
|
||||
currentPlanId: 'go' | 'plus' | 'pro' | 'max' | null;
|
||||
currentBillingInterval: 'monthly' | 'yearly' | null;
|
||||
firstMonthEligible: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
Before `resolveContext()`, all methods no-op. After resolution, exposure signatures include audience, interval, eligibility, current plan, and current interval. Emit arrays through the bridge transport rather than `window.__odTrack`. Preserve exact old click intent and ordering.
|
||||
|
||||
- [ ] **Step 4: Run the focused controller and bridge suites**
|
||||
|
||||
Run: `pnpm --filter @open-design/landing-page exec node --import tsx --test tests/pricing-compat-analytics.test.ts tests/pricing-analytics-bridge.test.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/landing-page/app/_lib/pricing-compat-analytics.ts \
|
||||
apps/landing-page/tests/pricing-compat-analytics.test.ts
|
||||
git commit -m "fix(analytics): restore complete personal pricing semantics"
|
||||
```
|
||||
|
||||
### Task 3: Wire Pricing context, DOM interactions, and navigation-safe delivery
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/landing-page/app/pages/pricing/index.astro`
|
||||
- Modify: `apps/landing-page/app/_components/enterprise-lead-form.astro`
|
||||
- Modify: `apps/landing-page/tests/pricing-contract.test.ts`
|
||||
- Create: `apps/landing-page/tests/pricing-analytics-browser.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Vela `POST /api/v1/analytics/pricing-events`
|
||||
- Produces: real Pricing-page bridge requests after authenticated context resolution
|
||||
|
||||
- [ ] **Step 1: Write failing DOM/browser tests**
|
||||
|
||||
Use a real browser page with intercepted session, billing-summary, and pricing-events requests. Assert:
|
||||
|
||||
```ts
|
||||
assert.deepEqual(
|
||||
captured.events.filter((event) => event.kind === 'plan_exposure')
|
||||
.map((event) => event.payload.planId),
|
||||
['go', 'plus', 'pro', 'max'],
|
||||
);
|
||||
assert.equal(captured.sourceSurface, 'dashboard');
|
||||
```
|
||||
|
||||
Also assert no request for signed-out/direct/untrusted traffic; resolved current-plan fields on first exposure; interval click before exposure batch; Team-to-Personal re-exposure; disabled CTA exclusion; and `team_lead_submit` on submit intent even when validation fails.
|
||||
|
||||
- [ ] **Step 2: Run the browser test and verify RED**
|
||||
|
||||
Run: `pnpm --filter @open-design/landing-page exec node --import tsx --test tests/pricing-analytics-browser.test.ts`
|
||||
Expected: FAIL because the page still captures compatibility events locally and emits before context resolution.
|
||||
|
||||
- [ ] **Step 3: Wire the context-resolved event**
|
||||
|
||||
After `loadPersonalPricingContext(apiOrigin)` resolves, set a resolved marker and dispatch:
|
||||
|
||||
```ts
|
||||
pricingRoot.dispatchEvent(new CustomEvent('pricing:personal-context-resolved', {
|
||||
detail: { authenticated: context !== null, context },
|
||||
}));
|
||||
```
|
||||
|
||||
The compatibility script waits for this event, resolves the trusted source, creates one session ID, and sends bridge batches. Do not initialize for null context or null source.
|
||||
|
||||
- [ ] **Step 4: Restore Enterprise submit intent timing**
|
||||
|
||||
Dispatch `pricing:enterprise-submit` from the form's synchronous `submit` event before validation. Remove the compatibility dispatch from `od:lead-success`; retain the existing OpenDesign success bridge and non-PII lead analytics unchanged.
|
||||
|
||||
- [ ] **Step 5: Run browser, contract, and full landing tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-design/landing-page exec node --import tsx --test \
|
||||
tests/pricing-analytics-browser.test.ts tests/pricing-contract.test.ts
|
||||
pnpm --filter @open-design/landing-page test
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/landing-page/app/pages/pricing/index.astro \
|
||||
apps/landing-page/app/_components/enterprise-lead-form.astro \
|
||||
apps/landing-page/tests/pricing-contract.test.ts \
|
||||
apps/landing-page/tests/pricing-analytics-browser.test.ts
|
||||
git commit -m "fix(analytics): relay authenticated pricing funnel events"
|
||||
```
|
||||
|
||||
### Task 4: Validate, document dependency, and update the OpenDesign PR
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-08-23-restore-pricing-plan-exposure-design.md` only if implementation reveals a factual mismatch
|
||||
- Modify: PR #7299 body through `odc`
|
||||
|
||||
- [ ] **Step 1: Run all static and build gates**
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-design/landing-page typecheck
|
||||
pnpm --filter @open-design/landing-page build
|
||||
pnpm guard
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: zero errors and a clean worktree after committed changes.
|
||||
|
||||
- [ ] **Step 2: Verify against a locally running Vela endpoint**
|
||||
|
||||
With an authenticated fixture, trigger initial Personal exposure, interval change, Personal/Team return, plan CTA, Enterprise open, and invalid Enterprise submit. Confirm requests reach Vela and no compatibility events hit `window.__odTrack`.
|
||||
|
||||
- [ ] **Step 3: Update PR #7299 through `odc`**
|
||||
|
||||
Cross-link the Vela PR, state that Vela must deploy first, list exact restored interactions, and remove the obsolete claim that direct OpenDesign PostHog capture restores AMR.
|
||||
|
||||
- [ ] **Step 4: Request independent review**
|
||||
|
||||
Review the final `origin/main..HEAD` diff using the requesting-code-review skill. Resolve every Critical and Important issue before considering the PR ready.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Vela Authenticated Pricing Analytics Endpoint Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a narrow authenticated Vela endpoint that maps migrated OpenDesign Pricing interactions into the existing AMR subscription analytics registry.
|
||||
|
||||
**Architecture:** A dedicated module owns a strict reduced request schema, source-origin checks, bounded per-user throttling, and mapping into `AnalyticsService`. The browser never supplies registry keys or Vela common metadata; the server stamps them and reuses the existing AMR/PostHog pipeline.
|
||||
|
||||
**Tech Stack:** Hono, Zod, TypeScript, Vitest, Vela AnalyticsService/PostHog repository
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Base the work on the latest `powerformer/vela/main`.
|
||||
- Preserve the existing registry keys, AMR PostHog event names, payload schemas, dashboards, and alerts.
|
||||
- Require a valid Vela user session; never make either legacy event anonymous/public-page allowed.
|
||||
- Accept only trusted configured web origins and no PII/free-form payload fields.
|
||||
- The endpoint is best effort for callers but strict on auth, body size, timestamp freshness, enums, batch size, and rate.
|
||||
- No database schema or billing behavior changes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Define and test the reduced Pricing bridge contract
|
||||
|
||||
**Files:**
|
||||
- Create: `services/api/src/pricing-analytics.ts`
|
||||
- Create: `services/api/test/pricing-analytics.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AnalyticsCommonEvent`, `AnalyticsService`, `analyticsEventRegistry`
|
||||
- Produces: `pricingAnalyticsRequestSchema`, `mapPricingAnalyticsRequest()`, `registerPricingAnalyticsRoute()`
|
||||
|
||||
- [ ] **Step 1: Write failing schema tests**
|
||||
|
||||
```ts
|
||||
expect(pricingAnalyticsRequestSchema.safeParse({
|
||||
sourceSurface: 'dashboard',
|
||||
sessionId: 'pricing-session-1',
|
||||
events: [{
|
||||
kind: 'plan_exposure',
|
||||
eventId: 'pricing-event-1',
|
||||
eventTime: '2026-08-23T12:00:00.000Z',
|
||||
payload: {
|
||||
planId: 'go',
|
||||
billingInterval: 'monthly',
|
||||
priceUsd: '5.00',
|
||||
creditsGrantedUsd: '0.00',
|
||||
deployLimit: 0,
|
||||
introOfferApplied: true,
|
||||
firstMonthEligible: true,
|
||||
isCurrentPlan: false,
|
||||
isRecommended: false,
|
||||
},
|
||||
}],
|
||||
}).success).toBe(true);
|
||||
```
|
||||
|
||||
Add negative cases for arbitrary registry keys, unknown properties, unknown plans/elements, raw URL/referrer/email fields, more than eight events, event IDs over 128 characters, malformed times, and source surfaces outside wallet/dashboard.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run: `pnpm --filter @vela/api exec vitest run test/pricing-analytics.test.ts`
|
||||
Expected: FAIL because the bridge module does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement strict schemas**
|
||||
|
||||
Use `.strict()` at the request, event, and payload layers. Export a discriminated union:
|
||||
|
||||
```ts
|
||||
type PricingAnalyticsInput =
|
||||
| { kind: 'plan_exposure'; eventId: string; eventTime: string; payload: PlanPayload }
|
||||
| { kind: 'pricing_click'; eventId: string; eventTime: string; payload: ClickPayload };
|
||||
```
|
||||
|
||||
Allow only plan IDs `go|plus|pro|max`, intervals `monthly|yearly`, and click elements `change_interval|subscribe_now|upgrade_now|request_team_access|team_lead_submit`. Limit a request to 1–8 events and body size to 16 KiB.
|
||||
|
||||
- [ ] **Step 4: Run schema tests and verify GREEN**
|
||||
|
||||
Run: `pnpm --filter @vela/api exec vitest run test/pricing-analytics.test.ts`
|
||||
Expected: schema cases pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add services/api/src/pricing-analytics.ts services/api/test/pricing-analytics.test.ts
|
||||
git commit -m "feat(analytics): define authenticated pricing bridge contract"
|
||||
```
|
||||
|
||||
### Task 2: Map reduced inputs to exact legacy analytics records
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/api/src/pricing-analytics.ts`
|
||||
- Modify: `services/api/test/pricing-analytics.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: validated bridge request, authenticated profile, server request metadata
|
||||
- Produces: `Array<{ common: AnalyticsCommonEvent; payload: unknown }>` accepted by `AnalyticsService.ingest()`
|
||||
|
||||
- [ ] **Step 1: Write failing literal mapping tests**
|
||||
|
||||
Assert that plan exposure maps to:
|
||||
|
||||
```ts
|
||||
expect(mapped[0]).toMatchObject({
|
||||
common: {
|
||||
registryKey: 'subscription_plan_exposure',
|
||||
eventName: 'subscription_plan_exposure',
|
||||
eventType: 'view',
|
||||
platform: 'web',
|
||||
},
|
||||
payload: {
|
||||
pageName: 'workspace',
|
||||
workspaceTab: 'dashboard',
|
||||
area: 'subscription_pricing',
|
||||
entryPoint: 'open_design_entry',
|
||||
planId: 'go',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Assert that pricing click maps to `registryKey=subscription_pricing_click`, `eventName=ui_click`, `eventType=click`. Include wallet/dashboard, interval, plan CTA, Enterprise elements, zero-valued Go fields, null current-plan fields, and strict validated OpenDesign attribution.
|
||||
|
||||
- [ ] **Step 2: Run the mapping test and verify RED**
|
||||
|
||||
Run: `pnpm --filter @vela/api exec vitest run test/pricing-analytics.test.ts`
|
||||
Expected: FAIL because the mapper is missing.
|
||||
|
||||
- [ ] **Step 3: Implement the mapper**
|
||||
|
||||
Derive event name/type from `analyticsEventRegistry`, never from browser input. Stamp required nullable common fields with server-owned values, use the authenticated profile separately in `AnalyticsService.ingest`, derive locale from bounded request input or `Accept-Language`, and browser from `User-Agent`. Reject event times older than 24 hours or more than five minutes in the future before mapping.
|
||||
|
||||
- [ ] **Step 4: Validate mapped payloads through the real registry**
|
||||
|
||||
Call the same registry payload validator used by `AnalyticsService` in the test path. Mutation checks must fail for `pageName=pricing`, a missing click `workspaceTab`, an unknown element, or an invalid attribution enum.
|
||||
|
||||
- [ ] **Step 5: Run focused tests and commit**
|
||||
|
||||
```bash
|
||||
pnpm --filter @vela/api exec vitest run test/pricing-analytics.test.ts
|
||||
git add services/api/src/pricing-analytics.ts services/api/test/pricing-analytics.test.ts
|
||||
git commit -m "feat(analytics): map pricing bridge to legacy registry"
|
||||
```
|
||||
|
||||
### Task 3: Register the authenticated, origin-restricted, bounded route
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/api/src/pricing-analytics.ts`
|
||||
- Modify: `services/api/src/app.ts`
|
||||
- Create: `services/api/test/pricing-analytics-routes.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getApiProfile(headers)`, `trustedWebOrigins(config)`, `AnalyticsService`
|
||||
- Produces: `POST /api/v1/analytics/pricing-events` returning 202/400/401/403/413/429
|
||||
|
||||
- [ ] **Step 1: Write failing route tests**
|
||||
|
||||
Build a focused Hono test app with an in-memory analytics repository. Prove:
|
||||
|
||||
- missing session returns 401;
|
||||
- untrusted or missing browser Origin returns 403;
|
||||
- oversized body returns 413 before JSON parsing;
|
||||
- invalid strict payload returns 400 without issue values containing request data;
|
||||
- more than 120 accepted events per authenticated user per minute returns 429;
|
||||
- a valid request returns 202 and stores exact mapped records;
|
||||
- a duplicate event ID is forwarded as the same PostHog `$insert_id`, preserving PostHog ingestion deduplication without claiming repository-level deduplication.
|
||||
|
||||
- [ ] **Step 2: Run route tests and verify RED**
|
||||
|
||||
Run: `pnpm --filter @vela/api exec vitest run test/pricing-analytics-routes.test.ts`
|
||||
Expected: FAIL because the route is not registered.
|
||||
|
||||
- [ ] **Step 3: Implement route safeguards**
|
||||
|
||||
Register the route only when analytics is configured. Resolve the profile before body mapping. Compare the normalized `Origin` header against `trustedWebOrigins(config)`. Read at most 16 KiB using the existing bounded-body helper pattern. Add an injected per-user fixed-window limiter with 120 events/minute and periodic stale-entry pruning; production creates one limiter with the app, tests inject a deterministic clock.
|
||||
|
||||
- [ ] **Step 4: Ingest through the existing AnalyticsService**
|
||||
|
||||
```ts
|
||||
await analytics.ingest({
|
||||
profile,
|
||||
events: mapPricingAnalyticsRequest(parsed.data, requestMetadata),
|
||||
});
|
||||
return c.json({ accepted: parsed.data.events.length }, 202);
|
||||
```
|
||||
|
||||
Do not alter `anonymousAllowed` or `publicPageAllowed` on either registry entry. Preserve the browser-supplied `eventId` as the analytics record `eventId`; the existing PostHog adapter maps it to `$insert_id`, so best-effort retries keep a stable ingestion identity.
|
||||
|
||||
- [ ] **Step 5: Run focused route and existing analytics tests**
|
||||
|
||||
```bash
|
||||
pnpm --filter @vela/api exec vitest run \
|
||||
test/pricing-analytics.test.ts \
|
||||
test/pricing-analytics-routes.test.ts \
|
||||
test/analytics-events.test.ts
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/api/src/pricing-analytics.ts services/api/src/app.ts \
|
||||
services/api/test/pricing-analytics-routes.test.ts
|
||||
git commit -m "feat(api): accept authenticated pricing analytics"
|
||||
```
|
||||
|
||||
### Task 4: Prove AMR PostHog compatibility and publish the Vela PR
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/api/test/analytics-events.test.ts`
|
||||
- Create: `docs/superpowers/specs/2026-08-23-pricing-analytics-bridge-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-08-23-pricing-analytics-bridge.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: final mapped records
|
||||
- Produces: regression proof for exact PostHog event names/properties and a ready Vela PR
|
||||
|
||||
- [ ] **Step 1: Add a failing PostHog delivery regression**
|
||||
|
||||
Pass mapped plan and click records through `PostHogAnalyticsRepository` and assert:
|
||||
|
||||
```ts
|
||||
expect(batch[0].event).toBe('subscription_plan_exposure');
|
||||
expect(batch[0].properties.registry_key).toBe('subscription_plan_exposure');
|
||||
expect(batch[1].event).toBe('ui_click');
|
||||
expect(batch[1].properties.registry_key).toBe('subscription_pricing_click');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the regression and verify it passes through production mapping**
|
||||
|
||||
Run: `pnpm --filter @vela/api exec vitest run test/analytics-events.test.ts test/pricing-analytics.test.ts`
|
||||
Expected: PASS after the route mapper is complete.
|
||||
|
||||
- [ ] **Step 3: Run repository verification**
|
||||
|
||||
```bash
|
||||
pnpm --filter @vela/shared build
|
||||
pnpm --filter @vela/api typecheck
|
||||
pnpm --filter @vela/api test
|
||||
pnpm lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: zero failures.
|
||||
|
||||
- [ ] **Step 4: Create the Vela PR with `odc`**
|
||||
|
||||
Verify `nexus status --json`, `odc whoami`, and `odc agent verify codex --scope project`. Create the PR with Vela-first deployment instructions and cross-link OpenDesign PR #7299.
|
||||
|
||||
- [ ] **Step 5: Request independent review**
|
||||
|
||||
Review `origin/main..HEAD` with the requesting-code-review skill. Resolve every Critical and Important issue, rerun validation, and update the PR before handing the pair to the user.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Restore Authenticated Pricing Funnel Analytics Design
|
||||
|
||||
## Goal
|
||||
|
||||
Restore the Vela subscription funnel that was lost when the authenticated
|
||||
plan-selection modal was replaced by the shared OpenDesign `/pricing/` page.
|
||||
The restored events must continue into the existing AMR PostHog project and
|
||||
preserve the current dashboard and alert contract. The migrated Pricing UI,
|
||||
checkout handoff, prices, and entitlements remain unchanged.
|
||||
|
||||
## Confirmed Legacy Semantics
|
||||
|
||||
The retired surface lived behind Vela authentication on `/wallet` and
|
||||
`/dashboard`. It emitted `subscription_plan_exposure` for Go, Plus, Pro, and
|
||||
Max, and emitted the registry-backed `subscription_pricing_click` event for
|
||||
plan CTAs, interval changes, and the Enterprise lead interactions that existed
|
||||
on the modal. Both registry entries have `anonymousAllowed: false` and
|
||||
`publicPageAllowed: false`.
|
||||
|
||||
The Vela API stored the events in AMR PostHog as:
|
||||
|
||||
| Registry key | AMR PostHog event | Existing breakdown key |
|
||||
| --- | --- | --- |
|
||||
| `subscription_plan_exposure` | `subscription_plan_exposure` | `registry_key=subscription_plan_exposure` |
|
||||
| `subscription_pricing_click` | `ui_click` | `registry_key=subscription_pricing_click` |
|
||||
|
||||
The public Pricing page's direct OpenDesign PostHog capture is a different
|
||||
pipeline. Reusing the same JavaScript event name there does not restore the AMR
|
||||
funnel. The new bridge therefore terminates at Vela's authenticated analytics
|
||||
pipeline rather than OpenDesign PostHog.
|
||||
|
||||
## Scope
|
||||
|
||||
- Two repositories and two PRs: Vela provides the authenticated ingestion
|
||||
boundary; OpenDesign emits from the migrated Pricing interactions.
|
||||
- Only visitors with a valid Vela session and a trusted `/wallet` or
|
||||
`/dashboard` Pricing entry are included in the restored AMR funnel.
|
||||
- Anonymous or direct public Pricing visitors continue to use the existing
|
||||
OpenDesign Pricing analytics only.
|
||||
- Restore equivalent interactions that still exist: Go/Plus/Pro/Max exposure,
|
||||
enabled Personal subscribe/upgrade CTA, interval change, Enterprise lead
|
||||
open, and Enterprise lead submit intent.
|
||||
- Do not recreate the retired modal, removed controls, checkout-result events,
|
||||
or PII-bearing legacy lead payloads.
|
||||
- Do not change Pricing rendering, plan selection, checkout URLs, payment
|
||||
behavior, entitlement behavior, or existing OpenDesign analytics.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Vela: authenticated Pricing analytics endpoint
|
||||
|
||||
Add a purpose-built authenticated endpoint at
|
||||
`POST /api/v1/analytics/pricing-events`. The endpoint accepts only a small,
|
||||
discriminated Pricing request contract; it does not accept arbitrary registry
|
||||
keys, event names, common metadata, or free-form properties.
|
||||
|
||||
The endpoint:
|
||||
|
||||
1. requires the same valid Vela user session used by the public Pricing page's
|
||||
existing subscription-context request;
|
||||
2. validates the request origin against the existing OpenDesign hosted-origin
|
||||
policy and rejects anonymous calls with `401`;
|
||||
3. accepts only `plan_exposure` and `pricing_click` records, with a bounded
|
||||
batch size and bounded string fields;
|
||||
4. accepts only `sourceSurface=wallet|dashboard`, Personal plan IDs
|
||||
`go|plus|pro|max`, billing intervals `monthly|yearly`, and the legacy click
|
||||
elements that the new page actually emits;
|
||||
5. maps the reduced request into the existing analytics registry contract,
|
||||
stamping `pageName=workspace`, `workspaceTab=sourceSurface`, the authenticated
|
||||
user profile, server receive time, registry key, event name, event type, and
|
||||
normal common metadata;
|
||||
6. sends the mapped records through the existing `AnalyticsService`, so the
|
||||
primary AMR destination receives the same event names and
|
||||
`registry_key` properties as before;
|
||||
7. never accepts or forwards email, name, company, free-form lead text, URL,
|
||||
referrer text, or other PII.
|
||||
|
||||
The client supplies a bounded event ID and occurrence time so navigation-safe
|
||||
`keepalive` delivery remains idempotent. The server rejects stale, future, or
|
||||
malformed timestamps and validates every plan/click payload against the
|
||||
existing registry schema before storage.
|
||||
|
||||
### OpenDesign: Pricing compatibility emitter
|
||||
|
||||
The Pricing page gains a small client that posts reduced events to the Vela
|
||||
endpoint with credentials and `keepalive: true`. It does not synthesize Vela's
|
||||
internal analytics envelope and does not send these compatibility records to
|
||||
`window.__odTrack`.
|
||||
|
||||
The emitter activates only after all of the following are true:
|
||||
|
||||
- the Cloud Console destination is an allowlisted production Vela origin;
|
||||
- the subscription-context request confirms a valid authenticated session;
|
||||
- the inbound entry resolves to a trusted Vela `/wallet` or `/dashboard`
|
||||
surface;
|
||||
- the Personal pricing context has finished resolving, including current plan,
|
||||
current interval, and first-month eligibility.
|
||||
|
||||
The trusted surface is derived only from the browser referrer when it has an
|
||||
allowlisted OpenDesign/Vela origin and an exact `/wallet`, `/dashboard`,
|
||||
`/cloud/wallet`, or `/cloud/dashboard` path. Query parameters never select the
|
||||
source surface, and arbitrary query strings or external referrers cannot create
|
||||
new analytics dimensions.
|
||||
|
||||
Vela links directly to the matching localized Pricing route and carries
|
||||
`od_locale` alongside `cloud_console_base`. Avoiding an intermediate locale
|
||||
redirect preserves the trusted Vela referrer needed by this source check; the
|
||||
locale parameter itself is not used as analytics attribution.
|
||||
|
||||
## Event Mapping
|
||||
|
||||
### Plan exposure
|
||||
|
||||
When the Personal audience is visible, emit one `plan_exposure` input for each
|
||||
visible Personal plan: Go, Plus, Pro, and Max. Vela maps each record to
|
||||
`subscription_plan_exposure` with the legacy properties:
|
||||
|
||||
- `pageName=workspace`
|
||||
- `workspaceTab=wallet|dashboard`
|
||||
- `area=subscription_pricing`
|
||||
- `entryPoint=open_design_entry`
|
||||
- `planId`, `planName`, `billingInterval`, `priceUsd`
|
||||
- `creditsGrantedUsd`, `deployLimit`, `introOfferApplied`
|
||||
- `firstMonthEligible`, `isCurrentPlan`, `isRecommended`
|
||||
- `autoRechargeSupported=true`
|
||||
- validated optional OpenDesign campaign attribution already allowed by the
|
||||
Vela registry
|
||||
|
||||
Go uses the legacy catalog values: zero credits, zero deploy limit, and not
|
||||
recommended. Yearly credits are normalized to one month, matching the retired
|
||||
modal.
|
||||
|
||||
Exposure is deferred until authenticated pricing context resolves. Deduplication
|
||||
includes audience, interval, eligibility, current plan, and current interval,
|
||||
so a state correction cannot be swallowed. Switching to Team clears the active
|
||||
Personal exposure signature; returning to Personal creates a genuine
|
||||
re-exposure. Repeating the same resolved state does not.
|
||||
|
||||
### Pricing click
|
||||
|
||||
Vela maps `pricing_click` inputs to the existing registry key
|
||||
`subscription_pricing_click`, whose AMR PostHog event remains `ui_click`.
|
||||
|
||||
- Enabled Go/Plus/Pro/Max CTA: `subscribe_now` when no current Personal plan,
|
||||
otherwise `upgrade_now`, with current and target plan/interval fields.
|
||||
- User-initiated monthly/yearly change: `change_interval`, emitted before the
|
||||
new interval's exposures.
|
||||
- Enterprise lead open: `request_team_access`,
|
||||
`area=enterprise_contact`, `targetDestination=lead_form`.
|
||||
- Enterprise form submit intent: `team_lead_submit` before client validation or
|
||||
network submission, preserving the legacy click meaning. Existing
|
||||
`lead_submit_invalid`, `lead_submit_attempt`, `lead_submit_success`, and
|
||||
`lead_submit_failed` events continue independently in OpenDesign PostHog.
|
||||
|
||||
Disabled/current/downgrade-unavailable Personal CTAs, Team checkout CTAs,
|
||||
removed email/story/proof controls, and programmatic interval synchronization
|
||||
do not emit compatibility clicks.
|
||||
|
||||
## Failure and Privacy Behavior
|
||||
|
||||
Compatibility analytics are best effort. A timeout, `401`, validation failure,
|
||||
network error, or unavailable Vela endpoint must not block rendering, form
|
||||
validation, checkout navigation, or lead submission. Failures may be logged in
|
||||
development but must not expose request bodies or user data.
|
||||
|
||||
The endpoint is authenticated, origin-restricted, schema-restricted, and
|
||||
rate-limited. It stores no additional cookies and accepts no PII. Direct public
|
||||
traffic cannot write to the AMR funnel, preserving the original population.
|
||||
|
||||
## Rollout
|
||||
|
||||
Deploy the Vela endpoint first. The OpenDesign emitter may then be deployed
|
||||
without a compatibility window or feature migration. Until Vela is available,
|
||||
OpenDesign continues its existing local Pricing analytics and checkout behavior;
|
||||
the compatibility post simply fails open.
|
||||
|
||||
The two PRs cross-link each other and state the deployment order. The existing
|
||||
AMR dashboard and alert continue querying
|
||||
`subscription_plan_exposure` and `ui_click` with
|
||||
`registry_key=subscription_pricing_click`; no data-source migration is required.
|
||||
|
||||
## Testing and Acceptance
|
||||
|
||||
### Vela
|
||||
|
||||
- Route tests prove anonymous and untrusted-origin requests are rejected.
|
||||
- Contract tests prove arbitrary registry keys, unknown plans/elements,
|
||||
malformed/stale times, oversized batches, and free-form fields are rejected.
|
||||
- Mapping tests prove both registry entries receive exact legacy event names,
|
||||
types, common metadata, `pageName=workspace`, and the correct
|
||||
`workspaceTab`.
|
||||
- Repository tests prove the resulting PostHog payloads remain
|
||||
`subscription_plan_exposure` and `ui_click` with the legacy `registry_key`.
|
||||
|
||||
### OpenDesign
|
||||
|
||||
- Pure contract tests cover Go/Plus/Pro/Max price and grant payloads, strict
|
||||
source resolution, context-aware deduplication, click classification, and
|
||||
excluded interactions.
|
||||
- DOM/browser integration tests cover authenticated context resolution before
|
||||
first exposure, same-interval state correction, audience leave/return,
|
||||
interval control ordering, enabled/disabled CTAs, Enterprise submit intent,
|
||||
and navigation-safe endpoint delivery.
|
||||
- Existing `page_view`, `ui_click`, lead-form analytics, pricing UI, and checkout
|
||||
destinations remain unchanged.
|
||||
- Full repository gates, typechecks, unit suites, and relevant browser tests
|
||||
pass in both repositories.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update the current Feishu tracking rows for `subscription_plan_exposure` and
|
||||
`subscription_pricing_click` to state that the migrated public Pricing page
|
||||
emits them only through the authenticated Vela bridge and preserves
|
||||
`pageName=workspace` plus `workspaceTab`. No new event name or dashboard source
|
||||
is introduced.
|
||||
Reference in New Issue
Block a user