## Summary - Closes [#75](#75) - Companion for [chat_backend#34](ai_ml_operations/chat_backend#34) (self-delete API) - Account Billing: Upgrade / Change plan / Cancel CTAs (Stripe portal–first), plan picker when multiple public selectable plans exist, cancel confirmation with period-end messaging, complimentary users get no fake cancel - Usage card Upgrade scrolls/focuses Billing; analytics for upgrade/change/cancel intents - Danger Zone: email-confirmed `DELETE /user/` then logout → `/signin/` ## Test plan - [ ] `npm test -- --watchAll=false --testPathPattern='BillingSection|DeleteAccountSection'` - [ ] Paid Stripe user: Upgrade / Change plan / Cancel → portal (or checkout for higher selectable plan) - [ ] Cancel confirm shows period-end access copy; scheduled cancel notice when `cancel_at_period_end` - [ ] Backer/admin: complimentary message, no Cancel/Upgrade - [ ] Delete account: confirm email → soft-delete → signed out at `/signin/`Reviewed-on: #76
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import { hasAnalyticsConsent, isAnalyticsEnvironment } from './analyticsConsent';
|
|
import type { Account } from '../data';
|
|
|
|
type QueuedCall =
|
|
| { type: 'track'; eventName: string; data?: Record<string, unknown> }
|
|
| { type: 'identify'; userInfo: Record<string, unknown> };
|
|
|
|
const queue: QueuedCall[] = [];
|
|
|
|
/**
|
|
* Named product events for Tianji (consent-gated). Page views stay on tracker.js.
|
|
*
|
|
* | Event | When |
|
|
* |-------|------|
|
|
* | Login Success / Failed | Password or SSO sign-in |
|
|
* | Sign Up Started / Success / Failed | Self-serve registration |
|
|
* | Logout | Sign out |
|
|
* | Checkout Started | Stripe Checkout session created |
|
|
* | Payment Success / Canceled | Billing return URLs |
|
|
* | Conversation Created | New chat id assigned over WS |
|
|
* | Message Sent | User submits prompt (no content) |
|
|
* | ToS Acknowledged | POST acknowledge_tos succeeds |
|
|
* | Billing Portal Opened | Account manage / portal CTAs |
|
|
* | Subscription Upgrade Started | Upgrade intent (#75) |
|
|
* | Plan Change Started | Change-plan intent (#75) |
|
|
* | Subscription Cancel Started | Cancel intent (#75) |
|
|
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
|
*/
|
|
export const AnalyticsEvents = {
|
|
LOGIN_SUCCESS: 'Login Success',
|
|
LOGIN_FAILED: 'Login Failed',
|
|
SIGNUP_STARTED: 'Sign Up Started',
|
|
SIGNUP_SUCCESS: 'Sign Up Success',
|
|
SIGNUP_FAILED: 'Sign Up Failed',
|
|
LOGOUT: 'Logout',
|
|
CHECKOUT_STARTED: 'Checkout Started',
|
|
PAYMENT_SUCCESS: 'Payment Success',
|
|
PAYMENT_CANCELED: 'Payment Canceled',
|
|
CONVERSATION_CREATED: 'Conversation Created',
|
|
MESSAGE_SENT: 'Message Sent',
|
|
TOS_ACKNOWLEDGED: 'ToS Acknowledged',
|
|
BILLING_PORTAL_OPENED: 'Billing Portal Opened',
|
|
SUBSCRIPTION_UPGRADE_STARTED: 'Subscription Upgrade Started',
|
|
PLAN_CHANGE_STARTED: 'Plan Change Started',
|
|
SUBSCRIPTION_CANCEL_STARTED: 'Subscription Cancel Started',
|
|
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
|
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
|
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
|
} as const;
|
|
|
|
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
|
|
|
/** Custom events + identify require consent. Automatic page views do not. */
|
|
const isEventTrackingEnabled = (): boolean =>
|
|
isAnalyticsEnvironment() && hasAnalyticsConsent();
|
|
|
|
const runQueuedCall = (call: QueuedCall): void => {
|
|
if (!window.tianji) return;
|
|
|
|
if (call.type === 'track') {
|
|
window.tianji.track(call.eventName, call.data);
|
|
return;
|
|
}
|
|
|
|
window.tianji.identify(call.userInfo);
|
|
};
|
|
|
|
export const flushAnalyticsQueue = (): void => {
|
|
if (!isEventTrackingEnabled() || !window.tianji) return;
|
|
|
|
while (queue.length > 0) {
|
|
const call = queue.shift();
|
|
if (call) runQueuedCall(call);
|
|
}
|
|
};
|
|
|
|
export const trackEvent = (
|
|
eventName: AnalyticsEventName | string,
|
|
data?: Record<string, unknown>,
|
|
): void => {
|
|
if (!isEventTrackingEnabled()) return;
|
|
|
|
const payload = data ? { ...data } : undefined;
|
|
|
|
if (window.tianji?.track) {
|
|
window.tianji.track(eventName, payload);
|
|
return;
|
|
}
|
|
|
|
queue.push({ type: 'track', eventName, data: payload });
|
|
};
|
|
|
|
export const identifyUser = (userInfo: Record<string, unknown>): void => {
|
|
if (!isEventTrackingEnabled()) return;
|
|
|
|
if (window.tianji?.identify) {
|
|
window.tianji.identify(userInfo);
|
|
return;
|
|
}
|
|
|
|
queue.push({ type: 'identify', userInfo: { ...userInfo } });
|
|
};
|
|
|
|
/** Identify with stable non-sensitive traits only (no names / prompts). */
|
|
export const identifyAccount = (account: Account): void => {
|
|
const traits: Record<string, unknown> = {
|
|
userId: account.email,
|
|
};
|
|
if (account.company?.id != null) {
|
|
traits.companyId = account.company.id;
|
|
}
|
|
if (account.is_company_manager) {
|
|
traits.isCompanyManager = true;
|
|
}
|
|
identifyUser(traits);
|
|
};
|