feat(core): simplify subscribe page param (#8518)

AF-1481
This commit is contained in:
forehalo 2024-10-17 07:05:00 +00:00
parent b7fac5acb8
commit 7dae5c5dd5
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
4 changed files with 116 additions and 70 deletions

View File

@ -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(() => {

View File

@ -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) {

View File

@ -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);

View File

@ -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<QuotaQuery['currentUser']>['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<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) => {