@@ -1,6 +1,10 @@
|
|||||||
import { Button, Loading } from '@affine/component';
|
import { Button, Loading } from '@affine/component';
|
||||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
import {
|
||||||
import { mixpanel, track } from '@affine/track';
|
SubscriptionPlan,
|
||||||
|
SubscriptionRecurring,
|
||||||
|
SubscriptionVariant,
|
||||||
|
} from '@affine/graphql';
|
||||||
|
import { track } from '@affine/track';
|
||||||
import { effect, fromPromise, useServices } from '@toeverything/infra';
|
import { effect, fromPromise, useServices } from '@toeverything/infra';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -15,6 +19,74 @@ import {
|
|||||||
import { AuthService, SubscriptionService } from '../../modules/cloud';
|
import { AuthService, SubscriptionService } from '../../modules/cloud';
|
||||||
import { container } from './subscribe.css';
|
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 = () => {
|
export const Component = () => {
|
||||||
const { authService, subscriptionService } = useServices({
|
const { authService, subscriptionService } = useServices({
|
||||||
AuthService,
|
AuthService,
|
||||||
@@ -27,24 +99,10 @@ export const Component = () => {
|
|||||||
const { jumpToSignIn, jumpToIndex } = useNavigateHelper();
|
const { jumpToSignIn, jumpToIndex } = useNavigateHelper();
|
||||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||||
|
|
||||||
const plan = searchParams.get('plan') as string | null;
|
const { plan, recurring, variant } = getProductTriple(searchParams);
|
||||||
const recurring = searchParams.get('recurring') as string | null;
|
const coupon = searchParams.get('coupon');
|
||||||
|
|
||||||
useEffect(() => {
|
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(
|
const call = effect(
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
return fromPromise(async signal => {
|
return fromPromise(async signal => {
|
||||||
@@ -66,11 +124,12 @@ export const Component = () => {
|
|||||||
setMessage('Checking subscription status...');
|
setMessage('Checking subscription status...');
|
||||||
await subscriptionService.subscription.waitForRevalidation(signal);
|
await subscriptionService.subscription.waitForRevalidation(signal);
|
||||||
const subscribed =
|
const subscribed =
|
||||||
receivedPlan === 'ai'
|
plan === SubscriptionPlan.AI
|
||||||
? !!subscriptionService.subscription.ai$.value
|
? !!subscriptionService.subscription.ai$.value
|
||||||
: receivedRecurring === 'lifetime'
|
: recurring === SubscriptionRecurring.Lifetime
|
||||||
? !!subscriptionService.subscription.isBeliever$.value
|
? !!subscriptionService.subscription.isBeliever$.value
|
||||||
: !!subscriptionService.subscription.pro$.value;
|
: !!subscriptionService.subscription.pro$.value;
|
||||||
|
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
setMessage('Creating checkout...');
|
setMessage('Creating checkout...');
|
||||||
|
|
||||||
@@ -78,43 +137,27 @@ export const Component = () => {
|
|||||||
const account = authService.session.account$.value;
|
const account = authService.session.account$.value;
|
||||||
// should never reach
|
// should never reach
|
||||||
if (!account) throw new Error('No account');
|
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({
|
track.subscriptionLanding.$.$.checkout({
|
||||||
control: 'pricing',
|
control: 'pricing',
|
||||||
plan: targetPlan,
|
plan,
|
||||||
recurring: targetRecurring,
|
recurring,
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkout = await subscriptionService.createCheckoutSession({
|
const checkout = await subscriptionService.createCheckoutSession({
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
plan: targetPlan,
|
plan,
|
||||||
coupon: null,
|
recurring,
|
||||||
recurring: targetRecurring,
|
variant,
|
||||||
variant: null,
|
coupon,
|
||||||
successCallbackLink: generateSubscriptionCallbackLink(
|
successCallbackLink: generateSubscriptionCallbackLink(
|
||||||
account,
|
account,
|
||||||
targetPlan,
|
plan,
|
||||||
targetRecurring
|
recurring
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
setMessage('Redirecting...');
|
setMessage('Redirecting...');
|
||||||
location.href = checkout;
|
location.href = checkout;
|
||||||
if (plan) {
|
|
||||||
mixpanel.people.set({
|
|
||||||
[SubscriptionPlan.AI === plan ? 'ai plan' : plan]: plan,
|
|
||||||
recurring: recurring,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setError('Something went wrong. Please try again.');
|
setError('Something went wrong. Please try again.');
|
||||||
@@ -144,6 +187,8 @@ export const Component = () => {
|
|||||||
jumpToIndex,
|
jumpToIndex,
|
||||||
recurring,
|
recurring,
|
||||||
retryKey,
|
retryKey,
|
||||||
|
variant,
|
||||||
|
coupon,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type CreateCheckoutSessionInput } from '@affine/graphql';
|
import { type CreateCheckoutSessionInput } from '@affine/graphql';
|
||||||
|
import { mixpanel } from '@affine/track';
|
||||||
import { OnEvent, Service } from '@toeverything/infra';
|
import { OnEvent, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { Subscription } from '../entities/subscription';
|
import { Subscription } from '../entities/subscription';
|
||||||
@@ -13,6 +14,22 @@ export class SubscriptionService extends Service {
|
|||||||
|
|
||||||
constructor(private readonly store: SubscriptionStore) {
|
constructor(private readonly store: SubscriptionStore) {
|
||||||
super();
|
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) {
|
async createCheckoutSession(input: CreateCheckoutSessionInput) {
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import type { QuotaQuery } from '@affine/graphql';
|
import { mixpanel } from '@affine/track';
|
||||||
import { createEvent, OnEvent, Service } from '@toeverything/infra';
|
import { OnEvent, Service } from '@toeverything/infra';
|
||||||
|
|
||||||
import { UserQuota } from '../entities/user-quota';
|
import { UserQuota } from '../entities/user-quota';
|
||||||
import { AccountChanged } from './auth';
|
import { AccountChanged } from './auth';
|
||||||
|
|
||||||
type UserQuotaInfo = NonNullable<QuotaQuery['currentUser']>['quota'];
|
|
||||||
|
|
||||||
export const UserQuotaChanged = createEvent<UserQuotaInfo>('UserQuotaChanged');
|
|
||||||
|
|
||||||
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
@OnEvent(AccountChanged, e => e.onAccountChanged)
|
||||||
export class UserQuotaService extends Service {
|
export class UserQuotaService extends Service {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.quota.quota$.distinctUntilChanged().subscribe(q => {
|
this.quota.quota$
|
||||||
this.eventBus.emit(UserQuotaChanged, q);
|
.map(q => q?.humanReadable.name)
|
||||||
});
|
.distinctUntilChanged()
|
||||||
|
.subscribe(quota => {
|
||||||
|
mixpanel.people.set({
|
||||||
|
quota,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
quota = this.framework.createEntity(UserQuota);
|
quota = this.framework.createEntity(UserQuota);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { QuotaQuery } from '@affine/graphql';
|
|
||||||
import { mixpanel } from '@affine/track';
|
import { mixpanel } from '@affine/track';
|
||||||
import type { GlobalContextService } from '@toeverything/infra';
|
import type { GlobalContextService } from '@toeverything/infra';
|
||||||
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
import { ApplicationStarted, OnEvent, Service } from '@toeverything/infra';
|
||||||
@@ -9,16 +8,11 @@ import {
|
|||||||
type AuthService,
|
type AuthService,
|
||||||
} from '../../cloud';
|
} from '../../cloud';
|
||||||
import { AccountLoggedOut } from '../../cloud/services/auth';
|
import { AccountLoggedOut } from '../../cloud/services/auth';
|
||||||
import { UserQuotaChanged } from '../../cloud/services/user-quota';
|
|
||||||
|
|
||||||
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
@OnEvent(ApplicationStarted, e => e.onApplicationStart)
|
||||||
@OnEvent(AccountChanged, e => e.updateIdentity)
|
@OnEvent(AccountChanged, e => e.updateIdentity)
|
||||||
@OnEvent(AccountLoggedOut, e => e.onAccountLoggedOut)
|
@OnEvent(AccountLoggedOut, e => e.onAccountLoggedOut)
|
||||||
@OnEvent(UserQuotaChanged, e => e.onUserQuotaChanged)
|
|
||||||
export class TelemetryService extends Service {
|
export class TelemetryService extends Service {
|
||||||
private prevQuota: NonNullable<QuotaQuery['currentUser']>['quota'] | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly auth: AuthService,
|
private readonly auth: AuthService,
|
||||||
private readonly globalContextService: GlobalContextService
|
private readonly globalContextService: GlobalContextService
|
||||||
@@ -48,17 +42,6 @@ export class TelemetryService extends Service {
|
|||||||
mixpanel.reset();
|
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() {
|
registerMiddlewares() {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
mixpanel.middleware((_event, parameters) => {
|
mixpanel.middleware((_event, parameters) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user