diff --git a/packages/frontend/core/src/desktop/pages/subscribe.tsx b/packages/frontend/core/src/desktop/pages/subscribe.tsx index be9e935f2f..c2ee81751a 100644 --- a/packages/frontend/core/src/desktop/pages/subscribe.tsx +++ b/packages/frontend/core/src/desktop/pages/subscribe.tsx @@ -1,6 +1,10 @@ import { Button, Loading } from '@affine/component'; -import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; -import { mixpanel, track } from '@affine/track'; +import { + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionVariant, +} from '@affine/graphql'; +import { track } from '@affine/track'; import { effect, fromPromise, useServices } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { useEffect, useMemo, useState } from 'react'; @@ -15,6 +19,74 @@ import { import { AuthService, SubscriptionService } from '../../modules/cloud'; import { container } from './subscribe.css'; +interface ProductTriple { + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + variant: SubscriptionVariant | null; +} + +const products = { + ai: 'ai_yearly', + pro: 'pro_yearly', + 'monthly-pro': 'pro_monthly', + believer: 'pro_lifetime', + 'oneyear-ai': 'ai_yearly_onetime', + 'oneyear-pro': 'pro_yearly_onetime', + 'onemonth-pro': 'pro_monthly_onetime', +}; + +const allowedPlan = { + ai: SubscriptionPlan.AI, + pro: SubscriptionPlan.Pro, +}; +const allowedRecurring = { + monthly: SubscriptionRecurring.Monthly, + yearly: SubscriptionRecurring.Yearly, + lifetime: SubscriptionRecurring.Lifetime, +}; + +const allowedVariant = { + earlyaccess: SubscriptionVariant.EA, + onetime: SubscriptionVariant.Onetime, +}; + +function getProductTriple(searchParams: URLSearchParams): ProductTriple { + const triple: ProductTriple = { + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + variant: null, + }; + + const productName = searchParams.get('product') as + | keyof typeof products + | null; + let plan = searchParams.get('plan') as keyof typeof allowedPlan | null; + let recurring = searchParams.get('recurring') as + | keyof typeof allowedRecurring + | null; + let variant = searchParams.get('variant') as + | keyof typeof allowedVariant + | null; + + if (productName && products[productName]) { + // @ts-expect-error safe + [plan, recurring, variant] = products[productName].split('_'); + } + + if (plan) { + triple.plan = allowedPlan[plan]; + } + + if (recurring) { + triple.recurring = allowedRecurring[recurring]; + } + if (variant) { + triple.variant = allowedVariant[variant]; + } + + return triple; +} + export const Component = () => { const { authService, subscriptionService } = useServices({ AuthService, @@ -27,24 +99,10 @@ export const Component = () => { const { jumpToSignIn, jumpToIndex } = useNavigateHelper(); const idempotencyKey = useMemo(() => nanoid(), []); - const plan = searchParams.get('plan') as string | null; - const recurring = searchParams.get('recurring') as string | null; + const { plan, recurring, variant } = getProductTriple(searchParams); + const coupon = searchParams.get('coupon'); useEffect(() => { - const allowedPlan = ['ai', 'pro']; - const allowedRecurring = ['monthly', 'yearly', 'lifetime']; - const receivedPlan = plan?.toLowerCase() ?? ''; - const receivedRecurring = recurring?.toLowerCase() ?? ''; - - const invalids = []; - if (!allowedPlan.includes(receivedPlan)) invalids.push('plan'); - if (!allowedRecurring.includes(receivedRecurring)) - invalids.push('recurring'); - if (invalids.length) { - setError(`Invalid ${invalids.join(', ')}`); - return; - } - const call = effect( switchMap(() => { return fromPromise(async signal => { @@ -66,11 +124,12 @@ export const Component = () => { setMessage('Checking subscription status...'); await subscriptionService.subscription.waitForRevalidation(signal); const subscribed = - receivedPlan === 'ai' + plan === SubscriptionPlan.AI ? !!subscriptionService.subscription.ai$.value - : receivedRecurring === 'lifetime' + : recurring === SubscriptionRecurring.Lifetime ? !!subscriptionService.subscription.isBeliever$.value : !!subscriptionService.subscription.pro$.value; + if (!subscribed) { setMessage('Creating checkout...'); @@ -78,43 +137,27 @@ export const Component = () => { const account = authService.session.account$.value; // should never reach if (!account) throw new Error('No account'); - const targetPlan = - receivedPlan === 'ai' - ? SubscriptionPlan.AI - : SubscriptionPlan.Pro; - const targetRecurring = - receivedRecurring === 'monthly' - ? SubscriptionRecurring.Monthly - : receivedRecurring === 'yearly' - ? SubscriptionRecurring.Yearly - : SubscriptionRecurring.Lifetime; track.subscriptionLanding.$.$.checkout({ control: 'pricing', - plan: targetPlan, - recurring: targetRecurring, + plan, + recurring, }); const checkout = await subscriptionService.createCheckoutSession({ idempotencyKey, - plan: targetPlan, - coupon: null, - recurring: targetRecurring, - variant: null, + plan, + recurring, + variant, + coupon, successCallbackLink: generateSubscriptionCallbackLink( account, - targetPlan, - targetRecurring + plan, + recurring ), }); setMessage('Redirecting...'); location.href = checkout; - if (plan) { - mixpanel.people.set({ - [SubscriptionPlan.AI === plan ? 'ai plan' : plan]: plan, - recurring: recurring, - }); - } } catch (err) { console.error(err); setError('Something went wrong. Please try again.'); @@ -144,6 +187,8 @@ export const Component = () => { jumpToIndex, recurring, retryKey, + variant, + coupon, ]); useEffect(() => { diff --git a/packages/frontend/core/src/modules/cloud/services/subscription.ts b/packages/frontend/core/src/modules/cloud/services/subscription.ts index 2066b0d72e..9d3fb60117 100644 --- a/packages/frontend/core/src/modules/cloud/services/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/services/subscription.ts @@ -1,4 +1,5 @@ import { type CreateCheckoutSessionInput } from '@affine/graphql'; +import { mixpanel } from '@affine/track'; import { OnEvent, Service } from '@toeverything/infra'; import { Subscription } from '../entities/subscription'; @@ -13,6 +14,22 @@ export class SubscriptionService extends Service { constructor(private readonly store: SubscriptionStore) { super(); + this.subscription.ai$ + .map(sub => !!sub) + .distinctUntilChanged() + .subscribe(ai => { + mixpanel.people.set({ + ai, + }); + }); + this.subscription.pro$ + .map(sub => !!sub) + .distinctUntilChanged() + .subscribe(pro => { + mixpanel.people.set({ + pro, + }); + }); } async createCheckoutSession(input: CreateCheckoutSessionInput) { diff --git a/packages/frontend/core/src/modules/cloud/services/user-quota.ts b/packages/frontend/core/src/modules/cloud/services/user-quota.ts index 6980856d8f..bb8b931e09 100644 --- a/packages/frontend/core/src/modules/cloud/services/user-quota.ts +++ b/packages/frontend/core/src/modules/cloud/services/user-quota.ts @@ -1,21 +1,22 @@ -import type { QuotaQuery } from '@affine/graphql'; -import { createEvent, OnEvent, Service } from '@toeverything/infra'; +import { mixpanel } from '@affine/track'; +import { OnEvent, Service } from '@toeverything/infra'; import { UserQuota } from '../entities/user-quota'; import { AccountChanged } from './auth'; -type UserQuotaInfo = NonNullable['quota']; - -export const UserQuotaChanged = createEvent('UserQuotaChanged'); - @OnEvent(AccountChanged, e => e.onAccountChanged) export class UserQuotaService extends Service { constructor() { super(); - this.quota.quota$.distinctUntilChanged().subscribe(q => { - this.eventBus.emit(UserQuotaChanged, q); - }); + this.quota.quota$ + .map(q => q?.humanReadable.name) + .distinctUntilChanged() + .subscribe(quota => { + mixpanel.people.set({ + quota, + }); + }); } quota = this.framework.createEntity(UserQuota); diff --git a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts index 6831858025..b13325a24a 100644 --- a/packages/frontend/core/src/modules/telemetry/services/telemetry.ts +++ b/packages/frontend/core/src/modules/telemetry/services/telemetry.ts @@ -1,4 +1,3 @@ -import type { QuotaQuery } from '@affine/graphql'; import { mixpanel } from '@affine/track'; import type { GlobalContextService } from '@toeverything/infra'; import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra'; @@ -9,16 +8,11 @@ import { type AuthService, } from '../../cloud'; import { AccountLoggedOut } from '../../cloud/services/auth'; -import { UserQuotaChanged } from '../../cloud/services/user-quota'; @OnEvent(ApplicationStarted, e => e.onApplicationStart) @OnEvent(AccountChanged, e => e.updateIdentity) @OnEvent(AccountLoggedOut, e => e.onAccountLoggedOut) -@OnEvent(UserQuotaChanged, e => e.onUserQuotaChanged) export class TelemetryService extends Service { - private prevQuota: NonNullable['quota'] | null = - null; - constructor( private readonly auth: AuthService, private readonly globalContextService: GlobalContextService @@ -48,17 +42,6 @@ export class TelemetryService extends Service { mixpanel.reset(); } - onUserQuotaChanged(quota: NonNullable['quota']) { - const plan = quota?.humanReadable.name; - // only set when plan is not empty and changed - if (plan !== this.prevQuota?.humanReadable.name && plan) { - mixpanel.people.set({ - plan: quota?.humanReadable.name, - }); - } - this.prevQuota = quota; - } - registerMiddlewares() { this.disposables.push( mixpanel.middleware((_event, parameters) => {