Refactor Stripe integration (#10191)

This PR improves intergration with Stripe functionality:

1. Moves Stripe into a separate provider
2. Add bakn card component extracted into a separate component
This commit is contained in:
Sergei Garin 2024-06-06 17:36:41 +03:00 committed by GitHub
parent 396d70ddc0
commit 4beded2438
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 365 additions and 225 deletions

View File

@ -80,7 +80,11 @@ export const Form = React.forwardRef(function Form<
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
const formMutation = reactQuery.useMutation({
mutationKey: ['FormSubmit', testId, id],
// We use template literals to make the mutation key more readable in the devtools
// This mutation exists only for debug purposes - React Query dev tools record the mutation,
// the result, and the variables(form fields).
// In general, prefer using object literals for the mutation key.
mutationKey: ['Form submission', `testId: ${testId}`, `id: ${id}`],
mutationFn: async (fieldValues: TFieldValues) => {
try {
await onSubmit(fieldValues, innerForm)
@ -102,6 +106,10 @@ export const Form = React.forwardRef(function Form<
: errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage'))
innerForm.setError('root.submit', { message })
// We need to throw the error to make the mutation fail
// eslint-disable-next-line no-restricted-syntax
throw error
}
},
onError: onSubmitFailed,
@ -132,20 +140,30 @@ export const Form = React.forwardRef(function Form<
register: (name, options) => {
const registered = register(name, options)
const onChange: types.UseFormRegisterReturn<Schema, TFieldValues>['onChange'] = value => {
/**
* Maps the value to the event object.
*/
function mapValueOnEvent(value: unknown) {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return registered.onChange(value)
return value
} else {
return registered.onChange({ target: { event: value } })
return { target: { value } }
}
}
const onChange: types.UseFormRegisterReturn<Schema, TFieldValues>['onChange'] = value =>
registered.onChange(mapValueOnEvent(value))
const onBlur: types.UseFormRegisterReturn<Schema, TFieldValues>['onBlur'] = value =>
registered.onBlur(mapValueOnEvent(value))
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
isInvalid: !!formState.errors[name],
onChange,
onBlur,
}
return result

View File

@ -44,9 +44,7 @@ export const FIELD_STYLES = twv.tv({
base: 'flex flex-col gap-0.5 items-start',
variants: {
fullWidth: { true: 'w-full' },
isInvalid: {
true: { label: 'text-danger' },
},
isInvalid: { true: { label: 'text-danger' } },
},
slots: {
labelContainer: 'contents',
@ -55,6 +53,7 @@ export const FIELD_STYLES = twv.tv({
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
error: text.TEXT_STYLE({ variant: 'body', color: 'danger' }),
},
defaultVariants: { fullWidth: true },
})
/**

View File

@ -53,7 +53,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
variant = 'submit',
size = 'medium',
testId = 'form-submit-button',
formnovalidate,
formnovalidate = false,
loading = false,
children,
rounded = 'large',
@ -64,19 +64,22 @@ export function Submit(props: SubmitProps): React.JSX.Element {
const dialogContext = ariaComponents.useDialogContext()
const { formState } = form
const isLoading = loading || formState.isSubmitting
const type = formnovalidate || isLoading ? 'button' : 'submit'
return (
<ariaComponents.Button
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
rounded={rounded}
type={formnovalidate === true ? 'button' : 'submit'}
type={type}
variant={variant}
size={size}
loading={loading || formState.isSubmitting}
loading={isLoading}
testId={testId}
onPress={() => {
if (formnovalidate === true) {
if (formnovalidate) {
dialogContext?.close()
}
}}

View File

@ -135,9 +135,11 @@ export interface UseFormRegisterReturn<
Schema,
TFieldValues
>,
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onChange'> {
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onBlur' | 'onChange'> {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onChange: <Value>(value: Value) => Promise<boolean | void> | void
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
readonly onBlur: <Value>(value: Value) => Promise<boolean | void> | void
readonly isDisabled?: boolean
readonly isRequired?: boolean
readonly isInvalid?: boolean

View File

@ -0,0 +1,155 @@
/**
* @file
*
* A modal for adding a payment method.
*/
import * as React from 'react'
import * as stripeReact from '@stripe/react-stripe-js'
import type * as stripeJs from '@stripe/stripe-js'
import * as reactQuery from '@tanstack/react-query'
import * as stripeProvider from '#/providers/StripeProvider'
import * as text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
/**
* Props for {@link AddPaymentMethodModal}.
*/
export interface AddPaymentMethodModalProps {
readonly title: string
readonly submitText: string
readonly onSubmit: (paymentMethodId: stripeJs.PaymentMethod['id']) => Promise<void> | void
}
/**
* A modal for adding a payment method.
*/
export default function AddPaymentMethodModal(props: AddPaymentMethodModalProps) {
const { title, onSubmit, submitText } = props
return (
<ariaComponents.Dialog title={title}>
<stripeProvider.StripeProvider>
{({ stripe, elements }) => (
<AddPaymentMethodForm
onSubmit={onSubmit}
elements={elements}
stripeInstance={stripe}
submitText={submitText}
/>
)}
</stripeProvider.StripeProvider>
</ariaComponents.Dialog>
)
}
/**
* Props for {@link AddPaymentMethodForm}.
*/
export interface AddPaymentMethodFormProps {
readonly stripeInstance: stripeJs.Stripe
readonly elements: stripeJs.StripeElements
readonly submitText: string
readonly onSubmit?: (paymentMethodId: stripeJs.PaymentMethod['id']) => Promise<void> | void
}
/**
* A form for adding a payment method.
*/
export function AddPaymentMethodForm(props: AddPaymentMethodFormProps) {
const { stripeInstance, elements, onSubmit, submitText } = props
const { getText } = text.useText()
const [cardElement, setCardElement] = React.useState<stripeJs.StripeCardElement | null>(() =>
elements.getElement(stripeReact.CardElement)
)
const dialogContext = ariaComponents.useDialogContext()
const subscribeMutation = reactQuery.useMutation({
mutationFn: async () => {
if (!cardElement) {
throw new Error('Unexpected error')
} else {
return stripeInstance
.createPaymentMethod({ type: 'card', card: cardElement })
.then(result => {
if (result.error) {
throw new Error(result.error.message)
} else {
return result
}
})
}
},
onSuccess: async paymentMethod => {
await onSubmit?.(paymentMethod.paymentMethod.id)
cardElement?.clear()
},
})
const form = ariaComponents.Form.useForm({
schema: ariaComponents.Form.schema.object({
card: ariaComponents.Form.schema
.object(
{
complete: ariaComponents.Form.schema.boolean(),
error: ariaComponents.Form.schema
.object({ message: ariaComponents.Form.schema.string() })
.nullish(),
},
{ message: getText('arbitraryFieldRequired') }
)
.nullable()
.refine(
data => data?.error == null,
data => ({ message: data?.error?.message ?? getText('arbitraryFieldRequired') })
),
}),
})
return (
<ariaComponents.Form
method="dialog"
form={form}
onSubmit={() => subscribeMutation.mutateAsync()}
>
<ariaComponents.Form.Field name="card" fullWidth label={getText('BankCardLabel')}>
<stripeReact.CardElement
options={{
classes: {
empty: '',
base: 'border border-gray-300 rounded-md p-3 transition-[outline] w-full',
focus: 'outline outline-2 outline-primary',
complete: 'border-blue-500 outline-blue-500',
invalid: 'border-red-500 outline-red-500',
},
}}
onEscape={() => dialogContext?.close()}
onReady={element => {
setCardElement(element)
element.focus()
}}
onChange={event => {
if (event.error?.message != null) {
form.setError('card', { message: event.error.message })
} else {
form.clearErrors('card')
}
form.setValue('card', event)
}}
onBlur={() => form.trigger('card')}
/>
</ariaComponents.Form.Field>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit loading={cardElement == null}>
{submitText}
</ariaComponents.Form.Submit>
</ariaComponents.Form>
)
}

View File

@ -1,13 +1,10 @@
/** @file A page in which the currently active payment plan can be changed. */
import * as React from 'react'
import * as stripeReact from '@stripe/react-stripe-js'
import * as stripe from '@stripe/stripe-js/pure'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router-dom'
import Back from 'enso-assets/arrow_left.svg'
import * as load from 'enso-common/src/load'
import * as appUtils from '#/appUtils'
@ -16,7 +13,6 @@ import * as navigateHooks from '#/hooks/navigateHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as backendModule from '#/services/Backend'
@ -54,33 +50,6 @@ export function Subscribe() {
const plan = searchParams.get('plan')
const { data: stripeInstance } = reactQuery.useSuspenseQuery({
queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY],
staleTime: Infinity,
queryFn: async () => {
const stripeKey = process.env.ENSO_CLOUD_STRIPE_KEY
if (stripeKey == null) {
throw new Error('Stripe key not found')
} else {
return load
.loadScript('https://js.stripe.com/v3/')
.then(script =>
stripe.loadStripe(stripeKey).finally(() => {
script.remove()
})
)
.then(maybeStripeInstance => {
if (maybeStripeInstance == null) {
throw new Error('Stripe instance not found')
} else {
return maybeStripeInstance
}
})
}
},
})
const onCompleteMutation = reactQuery.useMutation({
mutationFn: async (mutationData: CreateCheckoutSessionMutation) => {
const { id } = await backend.createCheckoutSession({
@ -103,13 +72,6 @@ export function Subscribe() {
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-hover-bg text-xs text-primary">
<stripeReact.Elements stripe={stripeInstance}>
<stripeReact.ElementsConsumer>
{({ elements }) => {
if (elements == null) {
return null
} else {
return (
<div className="mx-auto mt-16 flex w-full min-w-96 max-w-[1400px] flex-col items-start justify-center p-12">
<div className="flex flex-col items-start">
<ariaComponents.Button
@ -120,9 +82,14 @@ export function Subscribe() {
>
{getText('returnToDashboard')}
</ariaComponents.Button>
<aria.Heading level={1} className="mb-5 self-start text-start text-4xl">
<ariaComponents.Text.Heading
level={1}
variant="custom"
className="mb-5 self-start text-start text-4xl"
>
{getText('subscribeTitle')}
</aria.Heading>
</ariaComponents.Text.Heading>
</div>
<div className="w-full rounded-default bg-selected-frame p-8">
@ -145,8 +112,6 @@ export function Subscribe() {
paymentMethodId,
})
}}
elements={elements}
stripe={stripeInstance}
plan={newPlan}
defaultOpen={newPlan === plan}
/>
@ -159,11 +124,6 @@ export function Subscribe() {
</div>
</div>
</div>
)
}
}}
</stripeReact.ElementsConsumer>
</stripeReact.Elements>
</div>
)
}

View File

@ -1,105 +0,0 @@
/**
* @file
*
* A form for subscribing to a plan.
*/
import * as React from 'react'
import * as stripeReact from '@stripe/react-stripe-js'
import type * as stripeJs from '@stripe/stripe-js'
import * as reactQuery from '@tanstack/react-query'
import * as text from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
/**
*
*/
export interface SubscribeFormProps {
readonly stripe: stripeJs.Stripe
readonly elements: stripeJs.StripeElements
readonly onSubmit: (paymentMethodId: stripeJs.PaymentMethod['id']) => Promise<void>
}
/**
* A form for subscribing to a plan.
*/
export function SubscribeForm(props: SubscribeFormProps) {
const { stripe, elements, onSubmit } = props
const { getText } = text.useText()
const cardElement = elements.getElement(stripeReact.CardElement)
const [cardElementState, setCardElementState] =
React.useState<stripeJs.StripeElementChangeEvent | null>(null)
const subscribeMutation = reactQuery.useMutation({
mutationFn: async () => {
if (!cardElement) {
throw new Error('Unexpected error')
} else {
return stripe.createPaymentMethod({ type: 'card', card: cardElement }).then(result => {
if (result.error) {
throw new Error(result.error.message)
} else {
return result
}
})
}
},
onSuccess: async paymentMethod => {
await onSubmit(paymentMethod.paymentMethod.id)
cardElement?.clear()
},
})
return (
<aria.Form
className="flex flex-col items-start gap-2"
onSubmit={event => {
event.preventDefault()
subscribeMutation.mutate()
}}
>
<div className="flex w-full flex-col gap-2">
<aria.TextField isInvalid={cardElementState?.error != null} isRequired>
<aria.Label className="mb-1 ml-0.5 block text-sm">{getText('BankCardLabel')}</aria.Label>
<stripeReact.CardElement
options={{
classes: {
base: 'border border-gray-300 rounded-md p-3 transition-[outline]',
empty: '',
focus: 'outline outline-2 outline-primary',
complete: 'border-blue-500 outline-blue-500',
invalid: 'border-red-500 outline-red-500',
},
}}
onChange={setCardElementState}
/>
<aria.FieldError className="text-sm text-red-500">
{cardElementState?.error?.message}
</aria.FieldError>
</aria.TextField>
</div>
{subscribeMutation.error && (
<ariaComponents.Alert variant="error" size="medium">
{subscribeMutation.error.message}
</ariaComponents.Alert>
)}
<ariaComponents.Button
type="submit"
variant="submit"
loading={subscribeMutation.isPending}
isDisabled={
subscribeMutation.isPending || cardElementState == null || cardElementState.error != null
}
className="mt-1 px-4 py-1.5 text-sm"
>
{getText('subscribeSubmit')}
</ariaComponents.Button>
</aria.Form>
)
}

View File

@ -3,5 +3,4 @@
*
* Export all components from the subscribe folder
*/
export * from './SubscribeForm'
export * from './Card'

View File

@ -5,8 +5,6 @@
*/
import * as React from 'react'
import type * as stripeJs from '@stripe/stripe-js'
import OpenInNewTabIcon from 'enso-assets/open.svg'
import type * as text from '#/text'
@ -15,20 +13,17 @@ import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import AddPaymentMethodModal from '#/modals/AddPaymentMethodModal'
import * as backendModule from '#/services/Backend'
import * as string from '#/utilities/string'
import * as constants from '../constants'
import * as components from './components'
/**
* The props for the submit button.
*/
interface SubmitButtonProps {
readonly onSubmit: (paymentMethodId: string) => Promise<void>
readonly elements: stripeJs.StripeElements
readonly stripe: stripeJs.Stripe
readonly plan: backendModule.Plan
readonly defaultOpen?: boolean
}
@ -84,7 +79,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
},
pricing: 'soloPlanPricing',
submitButton: props => {
const { onSubmit, elements, stripe, defaultOpen = false, plan } = props
const { onSubmit, defaultOpen = false, plan } = props
const { getText } = textProvider.useText()
return (
@ -93,9 +88,11 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
{getText('subscribe')}
</ariaComponents.Button>
<ariaComponents.Dialog title={getText('upgradeTo', string.capitalizeFirst(plan))}>
<components.SubscribeForm onSubmit={onSubmit} elements={elements} stripe={stripe} />
</ariaComponents.Dialog>
<AddPaymentMethodModal
title={getText('upgradeTo', getText(plan))}
onSubmit={onSubmit}
submitText={getText('subscribeSubmit')}
/>
</ariaComponents.DialogTrigger>
)
},
@ -124,7 +121,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
title: constants.PLAN_TO_TEXT_ID['team'],
subtitle: 'teamPlanSubtitle',
submitButton: props => {
const { onSubmit, elements, stripe, defaultOpen = false, plan } = props
const { onSubmit, defaultOpen = false, plan } = props
const { getText } = textProvider.useText()
return (
@ -133,9 +130,11 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
{getText('subscribe')}
</ariaComponents.Button>
<ariaComponents.Dialog title={getText('upgradeTo', string.capitalizeFirst(plan))}>
<components.SubscribeForm onSubmit={onSubmit} elements={elements} stripe={stripe} />
</ariaComponents.Dialog>
<AddPaymentMethodModal
title={getText('upgradeTo', getText(plan))}
onSubmit={onSubmit}
submitText={getText('subscribeSubmit')}
/>
</ariaComponents.DialogTrigger>
)
},

View File

@ -0,0 +1,101 @@
/**
* @file
*
* A component that provides a Stripe context.
*/
import * as React from 'react'
import * as stripeReact from '@stripe/react-stripe-js'
import type * as stripeTypes from '@stripe/stripe-js'
import * as stripe from '@stripe/stripe-js/pure'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
/**
* Props for a {@link StripeProvider}.
*/
export interface StripeProviderProps {
readonly children: React.ReactNode | ((props: StripeProviderRenderProps) => React.ReactNode)
}
/**
* Render props for children of a {@link StripeProvider}.
*/
export interface StripeProviderRenderProps {
readonly stripe: stripeTypes.Stripe
readonly elements: stripeTypes.StripeElements
}
export const stripeQuery = reactQuery.queryOptions({
queryKey: ['stripe', process.env.ENSO_CLOUD_STRIPE_KEY] as const,
staleTime: Infinity,
gcTime: Infinity,
queryFn: async ({ queryKey }) => {
const stripeKey = queryKey[1]
if (stripeKey == null) {
throw new Error('Stripe key not found')
} else {
return stripe.loadStripe(stripeKey).then(maybeStripeInstance => {
if (maybeStripeInstance == null) {
throw new Error('Stripe instance not found')
} else {
return maybeStripeInstance
}
})
}
},
})
/**
* A component that provides a Stripe context.
*/
export function StripeProvider(props: StripeProviderProps) {
const { children } = props
const stripeInstance = useStripeLoader()
return (
<stripeReact.Elements stripe={stripeInstance.data}>
<stripeReact.ElementsConsumer>
{({ elements }) => {
if (elements == null) {
// This should never happen since we always pass the `stripe` instance to the `Elements` component
// instead of passing a promise that resolves to the `stripe` instance.
// and the fetching is handled by the `<Suspense />` component.
// This is just a safeguard.
return null
} else {
return typeof children === 'function'
? children({ stripe: stripeInstance.data, elements })
: children
}
}}
</stripeReact.ElementsConsumer>
</stripeReact.Elements>
)
}
/**
* Hook that gets the Stripe instance and elements from the Stripe context.
*/
export function useStripe() {
const stripeInstance = stripeReact.useStripe()
const elements = stripeReact.useElements()
invariant(
stripeInstance != null && elements != null,
'Stripe instance not found. Make sure you are using the `StripeProvider` component.'
)
return { stripe: stripeInstance, elements }
}
/**
* Hook that loads the Stripe instance using React Suspense.
* @returns The Stripe instance.
*/
export function useStripeLoader() {
return reactQuery.useSuspenseQuery(stripeQuery)
}

View File

@ -612,7 +612,7 @@
"tryAgain": "Try again",
"arbitraryFieldRequired": "This field is required",
"arbitraryFieldRequired": "Please fill out this field",
"arbitraryFieldInvalid": "This field is invalid",
"arbitraryFieldTooShort": "This field is too short",
"arbitraryFieldTooLong": "This field is too long",
@ -635,5 +635,14 @@
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
"ContactSalesButtonLabel": "Contact Us",
"setOrgNameTitle": "Set your organization name"
"setOrgNameTitle": "Set your organization name",
"free": "Free",
"freePlan": "Free Plan",
"solo": "Solo",
"soloPlan": "Solo Plan",
"team": "Team",
"teamPlan": "Team Plan",
"enterprise": "Organization",
"enterprisePlan": "Organization"
}