Skip to content

Commit

Permalink
feat(core): simplify subscribe page param (#8518)
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Oct 17, 2024
1 parent b7fac5a commit 7dae5c5
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 70 deletions.
133 changes: 89 additions & 44 deletions packages/frontend/core/src/desktop/pages/subscribe.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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 => {
Expand All @@ -66,55 +124,40 @@ 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...');

try {
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.');
Expand Down Expand Up @@ -144,6 +187,8 @@ export const Component = () => {
jumpToIndex,
recurring,
retryKey,
variant,
coupon,
]);

useEffect(() => {
Expand Down
17 changes: 17 additions & 0 deletions packages/frontend/core/src/modules/cloud/services/subscription.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down
19 changes: 10 additions & 9 deletions packages/frontend/core/src/modules/cloud/services/user-quota.ts
Original file line number Diff line number Diff line change
@@ -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<QuotaQuery['currentUser']>['quota'];

export const UserQuotaChanged = createEvent<UserQuotaInfo>('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);
Expand Down
17 changes: 0 additions & 17 deletions packages/frontend/core/src/modules/telemetry/services/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<QuotaQuery['currentUser']>['quota'] | null =
null;

constructor(
private readonly auth: AuthService,
private readonly globalContextService: GlobalContextService
Expand Down Expand Up @@ -48,17 +42,6 @@ export class TelemetryService extends Service {
mixpanel.reset();
}

onUserQuotaChanged(quota: NonNullable<QuotaQuery['currentUser']>['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) => {
Expand Down

0 comments on commit 7dae5c5

Please sign in to comment.