From 4f718cffbfc203c962fbc7e95afc37fc204984b3 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Thu, 18 Jul 2024 04:20:21 +0000 Subject: [PATCH] feat(core): subscribe changed notification and typeform link (#7522) --- .../general-setting/billing/index.tsx | 44 ++++- .../general-setting/plans/actions.tsx | 28 +++- .../plans/ai/actions/cancel.tsx | 28 +++- .../plans/ai/actions/subscribe.tsx | 12 +- .../general-setting/plans/plan-card.tsx | 15 +- .../affine/subscription-landing/index.tsx | 21 +-- .../affine/subscription-landing/notify.css.ts | 35 ++++ .../affine/subscription-landing/notify.tsx | 131 +++++++++++++++ .../hooks/affine/use-subscription-notify.tsx | 150 ++++++++++++++++++ .../core/src/layouts/workspace-layout.tsx | 2 + .../frontend/core/src/pages/subscribe.tsx | 31 ++-- packages/frontend/i18n/src/resources/en.json | 13 ++ 12 files changed, 471 insertions(+), 39 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/subscription-landing/notify.css.ts create mode 100644 packages/frontend/core/src/components/affine/subscription-landing/notify.tsx create mode 100644 packages/frontend/core/src/hooks/affine/use-subscription-notify.tsx diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index 011f0d27b..f7992269e 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -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 = () => { )} + + {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 ( + + + + + + ); +}; + const BelieverIdentifier = ({ onOpenPlans }: { onOpenPlans?: () => void }) => { const t = useI18n(); const subscriptionService = useService(SubscriptionService); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx index 6d58ed354..1184aab4c 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/actions.tsx @@ -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 ( <> diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx index 9bcbb5e98..87bc77f94 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/cancel.tsx @@ -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 ( + + + + + ); +}; + +const isDesktop = environment.isDesktop; +export const useUpgradeNotify = () => { + const t = useI18n(); + const prevNotifyIdRef = useRef(null); + + return useCallback( + (link: string) => { + prevNotifyIdRef.current && notify.dismiss(prevNotifyIdRef.current); + const id = notify( + { + title: ( + + {t['com.affine.payment.upgrade-success-notify.title']()} + + ), + message: t['com.affine.payment.upgrade-success-notify.content'](), + alignMessage: 'title', + icon: null, + footer: ( + 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(null); + + return useCallback( + (link: string) => { + prevNotifyIdRef.current && notify.dismiss(prevNotifyIdRef.current); + const id = notify( + { + title: ( + + {t['com.affine.payment.downgraded-notify.title']()} + + ), + message: t['com.affine.payment.downgraded-notify.content'](), + alignMessage: 'title', + icon: null, + footer: ( + notify.dismiss(id)} + onConfirm={() => notify.dismiss(id)} + /> + ), + }, + { duration: 24 * 60 * 60 * 1000 } + ); + prevNotifyIdRef.current = id; + }, + [t] + ); +}; diff --git a/packages/frontend/core/src/hooks/affine/use-subscription-notify.tsx b/packages/frontend/core/src/hooks/affine/use-subscription-notify.tsx new file mode 100644 index 000000000..5e0f30b5f --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-subscription-notify.tsx @@ -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]); +}; diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index e9ffd59fa..4eeca95a1 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -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(); diff --git a/packages/frontend/core/src/pages/subscribe.tsx b/packages/frontend/core/src/pages/subscribe.tsx index 3884304eb..bf1db523e 100644 --- a/packages/frontend/core/src/pages/subscribe.tsx +++ b/packages/frontend/core/src/pages/subscribe.tsx @@ -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; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 24c9ece06..ae54497c7 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1087,6 +1087,19 @@ "com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support.", "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",