feat(core): subscribe changed notification and typeform link (#7522)

This commit is contained in:
CatsJuice 2024-07-18 04:20:21 +00:00
parent b9d84fe007
commit 4f718cffbf
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
12 changed files with 471 additions and 39 deletions

View File

@ -7,6 +7,7 @@ import {
} from '@affine/component/setting-components';
import { Button, IconButton } from '@affine/component/ui/button';
import { Loading } from '@affine/component/ui/loading';
import { getUpgradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { InvoicesQuery } from '@affine/graphql';
import {
@ -30,7 +31,7 @@ import {
} from '../../../../../atoms';
import { useMutation } from '../../../../../hooks/use-mutation';
import { useQuery } from '../../../../../hooks/use-query';
import { SubscriptionService } from '../../../../../modules/cloud';
import { AuthService, SubscriptionService } from '../../../../../modules/cloud';
import { mixpanel, popupWindow } from '../../../../../utils';
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
import { CancelAction, ResumeAction } from '../plans/actions';
@ -194,6 +195,8 @@ const SubscriptionSettings = () => {
<SubscriptionSettingSkeleton />
)}
<TypeFormLink />
{proSubscription !== null ? (
proSubscription?.status === SubscriptionStatus.Active && (
<>
@ -269,6 +272,45 @@ const SubscriptionSettings = () => {
);
};
const TypeFormLink = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const authService = useService(AuthService);
const pro = useLiveData(subscriptionService.subscription.pro$);
const ai = useLiveData(subscriptionService.subscription.ai$);
const account = useLiveData(authService.session.account$);
if (!account) return null;
if (!pro && !ai) return null;
const plan = [];
if (pro) plan.push(SubscriptionPlan.Pro);
if (ai) plan.push(SubscriptionPlan.AI);
const link = getUpgradeQuestionnaireLink({
name: account.info?.name,
id: account.id,
email: account.email,
recurring: pro?.recurring ?? ai?.recurring ?? SubscriptionRecurring.Yearly,
plan,
});
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button style={{ padding: '4px 12px' }}>
{t['com.affine.payment.billing-type-form.go']()}
</Button>
</a>
</SettingRow>
);
};
const BelieverIdentifier = ({ onOpenPlans }: { onOpenPlans?: () => void }) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);

View File

@ -1,11 +1,14 @@
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { mixpanel } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import { SubscriptionService } from '../../../../../modules/cloud';
import { AuthService, SubscriptionService } from '../../../../../modules/cloud';
import { useDowngradeNotify } from '../../../subscription-landing/notify';
import { ConfirmLoadingModal, DowngradeModal } from './modals';
/**
@ -24,9 +27,13 @@ export const CancelAction = ({
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const authService = useService(AuthService);
const downgradeNotify = useDowngradeNotify();
const downgrade = useAsyncCallback(async () => {
try {
const account = authService.session.account$.value;
const prevRecurring = subscription.pro$.value?.recurring;
setIsMutating(true);
await subscription.cancelSubscription(idempotencyKey);
subscription.revalidate();
@ -41,10 +48,27 @@ export const CancelAction = ({
type: subscription.pro$.value?.plan,
category: subscription.pro$.value?.recurring,
});
if (account && prevRecurring) {
downgradeNotify(
getDowngradeQuestionnaireLink({
email: account.email ?? '',
id: account.id,
name: account.info?.name ?? '',
plan: SubscriptionPlan.Pro,
recurring: prevRecurring,
})
);
}
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, onOpenChange]);
}, [
authService.session.account$.value,
subscription,
idempotencyKey,
onOpenChange,
downgradeNotify,
]);
return (
<>

View File

@ -1,6 +1,8 @@
import { Button, type ButtonProps, useConfirmModal } from '@affine/component';
import { useDowngradeNotify } from '@affine/core/components/affine/subscription-landing/notify';
import { getDowngradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { mixpanel } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
@ -14,8 +16,10 @@ export const AICancel = ({ ...btnProps }: AICancelProps) => {
const [isMutating, setMutating] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const subscription = useService(SubscriptionService).subscription;
const authService = useService(AuthService);
const { openConfirmModal } = useConfirmModal();
const downgradeNotify = useDowngradeNotify();
const cancel = useAsyncCallback(async () => {
mixpanel.track('PlanChangeStarted', {
@ -51,12 +55,32 @@ export const AICancel = ({ ...btnProps }: AICancelProps) => {
segment: 'settings panel',
control: 'plan cancel action',
});
const account = authService.session.account$.value;
const prevRecurring = subscription.ai$.value?.recurring;
if (account && prevRecurring) {
downgradeNotify(
getDowngradeQuestionnaireLink({
email: account.email,
name: account.info?.name,
id: account.id,
plan: SubscriptionPlan.AI,
recurring: prevRecurring,
})
);
}
} finally {
setMutating(false);
}
},
});
}, [openConfirmModal, t, subscription, idempotencyKey]);
}, [
subscription,
openConfirmModal,
t,
idempotencyKey,
authService.session.account$.value,
downgradeNotify,
]);
return (
<Button onClick={cancel} loading={isMutating} type="primary" {...btnProps}>

View File

@ -1,6 +1,7 @@
import { Button, type ButtonProps, Skeleton } from '@affine/component';
import { generateSubscriptionCallbackLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { mixpanel, popupWindow } from '@affine/core/utils';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
@ -19,6 +20,7 @@ export const AISubscribe = ({
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setMutating] = useState(false);
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
const authService = useService(AuthService);
const subscriptionService = useService(SubscriptionService);
const price = useLiveData(subscriptionService.prices.aiPrice$);
@ -57,7 +59,11 @@ export const AISubscribe = ({
idempotencyKey,
plan: SubscriptionPlan.AI,
coupon: null,
successCallbackLink: '/ai-upgrade-success',
successCallbackLink: generateSubscriptionCallbackLink(
authService.session.account$.value,
SubscriptionPlan.AI,
SubscriptionRecurring.Yearly
),
});
popupWindow(session);
setOpenedExternalWindow(true);
@ -65,7 +71,7 @@ export const AISubscribe = ({
} finally {
setMutating(false);
}
}, [idempotencyKey, subscriptionService]);
}, [authService, idempotencyKey, subscriptionService]);
if (!price || !price.yearlyAmount) {
return (

View File

@ -1,5 +1,6 @@
import { Button } from '@affine/component/ui/button';
import { Tooltip } from '@affine/component/ui/tooltip';
import { generateSubscriptionCallbackLink } from '@affine/core/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { popupWindow } from '@affine/core/utils';
@ -259,6 +260,7 @@ export const Upgrade = ({
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const authService = useService(AuthService);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
@ -293,13 +295,22 @@ export const Upgrade = ({
idempotencyKey,
plan: SubscriptionPlan.Pro, // Only support prod plan now.
coupon: null,
successCallbackLink: '/upgrade-success',
successCallbackLink: generateSubscriptionCallbackLink(
authService.session.account$.value,
SubscriptionPlan.Pro,
recurring
),
});
setMutating(false);
setIdempotencyKey(nanoid());
popupWindow(link);
setOpenedExternalWindow(true);
}, [subscriptionService, recurring, idempotencyKey]);
}, [
recurring,
authService.session.account$.value,
subscriptionService,
idempotencyKey,
]);
return (
<Button

View File

@ -1,10 +1,9 @@
import { AuthPageContainer } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useSubscriptionNotifyWriter } from '@affine/core/hooks/affine/use-subscription-notify';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { SubscriptionPlan } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import mixpanel from 'mixpanel-browser';
import { type ReactNode, useCallback, useEffect } from 'react';
import { type ReactNode, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import * as styles from './styles.css';
@ -58,13 +57,7 @@ const UpgradeSuccessLayout = ({
export const CloudUpgradeSuccess = () => {
const t = useI18n();
useEffect(() => {
mixpanel.track('PlanUpgradeSucceeded', {
segment: 'settings panel',
control: 'plan upgrade action',
plan: SubscriptionPlan.Pro,
});
}, []);
useSubscriptionNotifyWriter();
return (
<UpgradeSuccessLayout
title={t['com.affine.payment.upgrade-success-page.title']()}
@ -75,13 +68,7 @@ export const CloudUpgradeSuccess = () => {
export const AIUpgradeSuccess = () => {
const t = useI18n();
useEffect(() => {
mixpanel.track('PlanUpgradeSucceeded', {
segment: 'settings panel',
control: 'plan upgrade action',
plan: SubscriptionPlan.Pro,
});
}, []);
useSubscriptionNotifyWriter();
return (
<UpgradeSuccessLayout
title={t['com.affine.payment.ai-upgrade-success-page.title']()}

View File

@ -0,0 +1,35 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const notifyHeader = style({
fontWeight: 500,
fontSize: 15,
});
export const notifyFooter = style({
display: 'flex',
justifyContent: 'end',
gap: 12,
paddingTop: 8,
});
export const actionButton = style({
fontSize: cssVar('fontSm'),
fontWeight: 500,
lineHeight: '22px',
});
export const confirmButton = style({
selectors: {
'&.plain': {
color: cssVar('brandColor'),
},
},
});
export const cancelButton = style({
selectors: {
'&.plain': {
color: cssVar('textPrimaryColor'),
},
},
});

View File

@ -0,0 +1,131 @@
import { Button, notify } from '@affine/component';
import { useI18n } from '@affine/i18n';
import clsx from 'clsx';
import { useCallback, useRef } from 'react';
import {
actionButton,
cancelButton,
confirmButton,
notifyFooter,
notifyHeader,
} from './notify.css';
interface SubscriptionChangedNotifyFooterProps {
onCancel: () => void;
onConfirm?: () => void;
to: string;
okText: string;
cancelText: string;
}
const SubscriptionChangedNotifyFooter = ({
to,
okText,
cancelText,
onCancel,
onConfirm,
}: SubscriptionChangedNotifyFooterProps) => {
return (
<div className={notifyFooter}>
<Button
className={clsx(actionButton, cancelButton)}
size={'default'}
onClick={onCancel}
type="plain"
>
{cancelText}
</Button>
<a href={to} target="_blank" rel="noreferrer">
<Button
onClick={onConfirm}
className={clsx(actionButton, confirmButton)}
type="plain"
>
{okText}
</Button>
</a>
</div>
);
};
const isDesktop = environment.isDesktop;
export const useUpgradeNotify = () => {
const t = useI18n();
const prevNotifyIdRef = useRef<string | number | null>(null);
return useCallback(
(link: string) => {
prevNotifyIdRef.current && notify.dismiss(prevNotifyIdRef.current);
const id = notify(
{
title: (
<span className={notifyHeader}>
{t['com.affine.payment.upgrade-success-notify.title']()}
</span>
),
message: t['com.affine.payment.upgrade-success-notify.content'](),
alignMessage: 'title',
icon: null,
footer: (
<SubscriptionChangedNotifyFooter
to={link}
okText={
isDesktop
? t['com.affine.payment.upgrade-success-notify.ok-client']()
: t['com.affine.payment.upgrade-success-notify.ok-web']()
}
cancelText={t[
'com.affine.payment.upgrade-success-notify.later'
]()}
onCancel={() => notify.dismiss(id)}
onConfirm={() => notify.dismiss(id)}
/>
),
},
{ duration: 24 * 60 * 60 * 1000 }
);
prevNotifyIdRef.current = id;
},
[t]
);
};
export const useDowngradeNotify = () => {
const t = useI18n();
const prevNotifyIdRef = useRef<string | number | null>(null);
return useCallback(
(link: string) => {
prevNotifyIdRef.current && notify.dismiss(prevNotifyIdRef.current);
const id = notify(
{
title: (
<span className={notifyHeader}>
{t['com.affine.payment.downgraded-notify.title']()}
</span>
),
message: t['com.affine.payment.downgraded-notify.content'](),
alignMessage: 'title',
icon: null,
footer: (
<SubscriptionChangedNotifyFooter
to={link}
okText={
isDesktop
? t['com.affine.payment.downgraded-notify.ok-client']()
: t['com.affine.payment.downgraded-notify.ok-web']()
}
cancelText={t['com.affine.payment.downgraded-notify.later']()}
onCancel={() => notify.dismiss(id)}
onConfirm={() => notify.dismiss(id)}
/>
),
},
{ duration: 24 * 60 * 60 * 1000 }
);
prevNotifyIdRef.current = id;
},
[t]
);
};

View File

@ -0,0 +1,150 @@
import { useUpgradeNotify } from '@affine/core/components/affine/subscription-landing/notify';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import mixpanel from 'mixpanel-browser';
import { nanoid } from 'nanoid';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { type AuthAccountInfo } from '../../modules/cloud';
const separator = '::';
const recoverSeparator = nanoid();
const localStorageKey = 'subscription-succeed-info';
const typeFormUrl = 'https://6dxre9ihosp.typeform.com/to';
const typeFormUpgradeId = 'mUMGGQS8';
const typeFormDowngradeId = 'RvD9AoRg';
type TypeFormInfo = {
id: string;
name?: string;
email?: string;
plan: string | string[];
recurring: string;
};
const getTypeFormLink = (id: string, info: TypeFormInfo) => {
const plans = Array.isArray(info.plan) ? info.plan : [info.plan];
const product_id = plans
.map(plan => (plan === SubscriptionPlan.AI ? 'ai' : 'cloud'))
.join('-');
const product_price =
info.recurring === SubscriptionRecurring.Monthly
? 'monthly'
: info.recurring === SubscriptionRecurring.Lifetime
? 'lifeTime'
: 'annually';
return `${typeFormUrl}/${id}#email=${info.email ?? ''}&name=${info.name ?? 'Unknown'}&user_id=${info.id}&product_id=${product_id}&product_price=${product_price}`;
};
export const getUpgradeQuestionnaireLink = (info: TypeFormInfo) =>
getTypeFormLink(typeFormUpgradeId, info);
export const getDowngradeQuestionnaireLink = (info: TypeFormInfo) =>
getTypeFormLink(typeFormDowngradeId, info);
/**
* Generate subscription callback link with account info
*/
export const generateSubscriptionCallbackLink = (
account: AuthAccountInfo | null,
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
) => {
if (account === null) {
throw new Error('Account is required');
}
const baseUrl =
plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : '/upgrade-success';
let name = account?.info?.name ?? '';
if (name.includes(separator)) {
name = name.replaceAll(separator, recoverSeparator);
}
const query = [
plan,
recurring,
account.id,
account.email,
account.info?.name ?? '',
].join(separator);
return `${baseUrl}?info=${encodeURIComponent(query)}`;
};
/**
* Parse subscription callback query.info
* @returns
*/
export const parseSubscriptionCallbackLink = (query: string) => {
const [plan, recurring, id, email, rawName] =
decodeURIComponent(query).split(separator);
const name = rawName.replaceAll(recoverSeparator, separator);
return {
plan: plan as SubscriptionPlan,
recurring: recurring as SubscriptionRecurring,
account: {
id,
email,
info: {
name,
},
},
};
};
/**
* Hook to parse subscription callback link, and save to local storage and delete the query
*/
export const useSubscriptionNotifyWriter = () => {
const [searchParams] = useSearchParams();
useEffect(() => {
const query = searchParams.get('info');
if (query) {
localStorage.setItem(localStorageKey, query);
searchParams.delete('info');
}
}, [searchParams]);
};
/**
* Hook to read and parse subscription info from localStorage
*/
export const useSubscriptionNotifyReader = () => {
const upgradeNotify = useUpgradeNotify();
const readAndNotify = useCallback(() => {
const query = localStorage.getItem(localStorageKey);
if (!query) return;
try {
const { plan, recurring, account } = parseSubscriptionCallbackLink(query);
const link = getUpgradeQuestionnaireLink({
id: account.id,
email: account.email,
name: account.info?.name ?? '',
plan,
recurring,
});
upgradeNotify(link);
localStorage.removeItem(localStorageKey);
// mixpanel
mixpanel.track('PlanUpgradeSucceeded', {
segment: 'settings panel',
control: 'plan upgrade action',
plan: plan,
});
} catch (err) {
console.error('Failed to parse subscription callback link', err);
}
}, [upgradeNotify]);
useEffect(() => {
readAndNotify();
window.addEventListener('focus', readAndNotify);
return () => {
window.removeEventListener('focus', readAndNotify);
};
}, [readAndNotify]);
};

View File

@ -57,6 +57,7 @@ import {
useGlobalDNDHelper,
} from '../hooks/affine/use-global-dnd-helper';
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
import { useSubscriptionNotifyReader } from '../hooks/affine/use-subscription-notify';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
@ -164,6 +165,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
workbench,
]);
useSubscriptionNotifyReader();
useRegisterWorkspaceCommands();
useRegisterNavigationCommands();
useRegisterFindInPageCommands();

View File

@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import { generateSubscriptionCallbackLink } from '../hooks/affine/use-subscription-notify';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { AuthService, SubscriptionService } from '../modules/cloud';
import { mixpanel } from '../utils';
@ -58,21 +59,27 @@ export const Component = () => {
category: recurring,
});
try {
const account = authService.session.account$.value;
// should never reach
if (!account) throw new Error('No account');
const targetPlan =
plan?.toLowerCase() === 'ai'
? SubscriptionPlan.AI
: SubscriptionPlan.Pro;
const targetRecurring =
recurring?.toLowerCase() === 'monthly'
? SubscriptionRecurring.Monthly
: SubscriptionRecurring.Yearly;
const checkout = await subscriptionService.createCheckoutSession({
idempotencyKey,
plan:
plan?.toLowerCase() === 'ai'
? SubscriptionPlan.AI
: SubscriptionPlan.Pro,
plan: targetPlan,
coupon: null,
recurring:
recurring?.toLowerCase() === 'monthly'
? SubscriptionRecurring.Monthly
: SubscriptionRecurring.Yearly,
successCallbackLink:
plan?.toLowerCase() === 'ai'
? '/ai-upgrade-success'
: '/upgrade-success',
recurring: targetRecurring,
successCallbackLink: generateSubscriptionCallbackLink(
account,
targetPlan,
targetRecurring
),
});
setMessage('Redirecting...');
location.href = checkout;

View File

@ -1087,6 +1087,19 @@
"com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support</1>.",
"com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.",
"com.affine.payment.upgrade-success-page.title": "Upgrade Successful!",
"com.affine.payment.upgrade-success-notify.title": "Thanks for subscribing!",
"com.affine.payment.upgrade-success-notify.content": "We'd like to hear more about your use case, so that we can make AFFiNE better.",
"com.affine.payment.upgrade-success-notify.later": "Later",
"com.affine.payment.upgrade-success-notify.ok-client": "Sure, Open In Browser",
"com.affine.payment.upgrade-success-notify.ok-web": "Sure, Open In New Tab",
"com.affine.payment.downgraded-notify.title": "Sorry to see you go",
"com.affine.payment.downgraded-notify.content": "We'd like to hear more about where we fall short, so that we can make AFFiNE better.",
"com.affine.payment.downgraded-notify.later": "Later",
"com.affine.payment.downgraded-notify.ok-client": "Sure, Open In Browser",
"com.affine.payment.downgraded-notify.ok-web": "Sure, Open In New Tab",
"com.affine.payment.billing-type-form.title": "Tell Us Your Use Case",
"com.affine.payment.billing-type-form.description": "Please tell us more about your use case, to make AFFiNE better.",
"com.affine.payment.billing-type-form.go": "Go",
"com.affine.peek-view-controls.close": "Close",
"com.affine.peek-view-controls.open-doc": "Open this doc",
"com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab",