mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 22:21:40 +03:00
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:
parent
396d70ddc0
commit
4beded2438
@ -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
|
||||
|
@ -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 },
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -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()
|
||||
}
|
||||
}}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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,67 +72,58 @@ 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 {
|
||||
<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
|
||||
variant="icon"
|
||||
icon={Back}
|
||||
href={appUtils.DASHBOARD_PATH}
|
||||
className="-ml-2"
|
||||
>
|
||||
{getText('returnToDashboard')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
<ariaComponents.Text.Heading
|
||||
level={1}
|
||||
variant="custom"
|
||||
className="mb-5 self-start text-start text-4xl"
|
||||
>
|
||||
{getText('subscribeTitle')}
|
||||
</ariaComponents.Text.Heading>
|
||||
</div>
|
||||
|
||||
<div className="w-full rounded-default bg-selected-frame p-8">
|
||||
<div className="flex gap-6 overflow-auto scroll-hidden">
|
||||
{backendModule.PLANS.map(newPlan => {
|
||||
const planProps = componentForPlan.getComponentPerPlan(newPlan, getText)
|
||||
|
||||
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
|
||||
variant="icon"
|
||||
icon={Back}
|
||||
href={appUtils.DASHBOARD_PATH}
|
||||
className="-ml-2"
|
||||
>
|
||||
{getText('returnToDashboard')}
|
||||
</ariaComponents.Button>
|
||||
<aria.Heading level={1} className="mb-5 self-start text-start text-4xl">
|
||||
{getText('subscribeTitle')}
|
||||
</aria.Heading>
|
||||
</div>
|
||||
|
||||
<div className="w-full rounded-default bg-selected-frame p-8">
|
||||
<div className="flex gap-6 overflow-auto scroll-hidden">
|
||||
{backendModule.PLANS.map(newPlan => {
|
||||
const planProps = componentForPlan.getComponentPerPlan(newPlan, getText)
|
||||
|
||||
return (
|
||||
<components.Card
|
||||
key={newPlan}
|
||||
className="min-w-64 flex-1"
|
||||
features={planProps.features}
|
||||
subtitle={planProps.subtitle}
|
||||
title={planProps.title}
|
||||
submitButton={
|
||||
<planProps.submitButton
|
||||
onSubmit={async paymentMethodId => {
|
||||
await onCompleteMutation.mutateAsync({
|
||||
plan: newPlan,
|
||||
paymentMethodId,
|
||||
})
|
||||
}}
|
||||
elements={elements}
|
||||
stripe={stripeInstance}
|
||||
plan={newPlan}
|
||||
defaultOpen={newPlan === plan}
|
||||
/>
|
||||
}
|
||||
learnMore={<planProps.learnMore />}
|
||||
pricing={planProps.pricing}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<components.Card
|
||||
key={newPlan}
|
||||
className="min-w-64 flex-1"
|
||||
features={planProps.features}
|
||||
subtitle={planProps.subtitle}
|
||||
title={planProps.title}
|
||||
submitButton={
|
||||
<planProps.submitButton
|
||||
onSubmit={async paymentMethodId => {
|
||||
await onCompleteMutation.mutateAsync({
|
||||
plan: newPlan,
|
||||
paymentMethodId,
|
||||
})
|
||||
}}
|
||||
plan={newPlan}
|
||||
defaultOpen={newPlan === plan}
|
||||
/>
|
||||
}
|
||||
learnMore={<planProps.learnMore />}
|
||||
pricing={planProps.pricing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
</stripeReact.ElementsConsumer>
|
||||
</stripeReact.Elements>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -3,5 +3,4 @@
|
||||
*
|
||||
* Export all components from the subscribe folder
|
||||
*/
|
||||
export * from './SubscribeForm'
|
||||
export * from './Card'
|
||||
|
@ -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>
|
||||
)
|
||||
},
|
||||
|
101
app/ide-desktop/lib/dashboard/src/providers/StripeProvider.tsx
Normal file
101
app/ide-desktop/lib/dashboard/src/providers/StripeProvider.tsx
Normal 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)
|
||||
}
|
@ -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"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user