mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 03:21:44 +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])
|
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
|
||||||
|
|
||||||
const formMutation = reactQuery.useMutation({
|
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) => {
|
mutationFn: async (fieldValues: TFieldValues) => {
|
||||||
try {
|
try {
|
||||||
await onSubmit(fieldValues, innerForm)
|
await onSubmit(fieldValues, innerForm)
|
||||||
@ -102,6 +106,10 @@ export const Form = React.forwardRef(function Form<
|
|||||||
: errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage'))
|
: errorUtils.tryGetMessage(error, getText('arbitraryFormErrorMessage'))
|
||||||
|
|
||||||
innerForm.setError('root.submit', { message })
|
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,
|
onError: onSubmitFailed,
|
||||||
@ -132,20 +140,30 @@ export const Form = React.forwardRef(function Form<
|
|||||||
register: (name, options) => {
|
register: (name, options) => {
|
||||||
const registered = 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) {
|
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
|
||||||
return registered.onChange(value)
|
return value
|
||||||
} else {
|
} 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> = {
|
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
|
||||||
...registered,
|
...registered,
|
||||||
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
|
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
|
||||||
...(registered.required != null ? { isRequired: registered.required } : {}),
|
...(registered.required != null ? { isRequired: registered.required } : {}),
|
||||||
isInvalid: !!formState.errors[name],
|
isInvalid: !!formState.errors[name],
|
||||||
onChange,
|
onChange,
|
||||||
|
onBlur,
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@ -44,9 +44,7 @@ export const FIELD_STYLES = twv.tv({
|
|||||||
base: 'flex flex-col gap-0.5 items-start',
|
base: 'flex flex-col gap-0.5 items-start',
|
||||||
variants: {
|
variants: {
|
||||||
fullWidth: { true: 'w-full' },
|
fullWidth: { true: 'w-full' },
|
||||||
isInvalid: {
|
isInvalid: { true: { label: 'text-danger' } },
|
||||||
true: { label: 'text-danger' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
slots: {
|
slots: {
|
||||||
labelContainer: 'contents',
|
labelContainer: 'contents',
|
||||||
@ -55,6 +53,7 @@ export const FIELD_STYLES = twv.tv({
|
|||||||
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
|
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
|
||||||
error: text.TEXT_STYLE({ variant: 'body', color: 'danger' }),
|
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',
|
variant = 'submit',
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
testId = 'form-submit-button',
|
testId = 'form-submit-button',
|
||||||
formnovalidate,
|
formnovalidate = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
children,
|
children,
|
||||||
rounded = 'large',
|
rounded = 'large',
|
||||||
@ -64,19 +64,22 @@ export function Submit(props: SubmitProps): React.JSX.Element {
|
|||||||
const dialogContext = ariaComponents.useDialogContext()
|
const dialogContext = ariaComponents.useDialogContext()
|
||||||
const { formState } = form
|
const { formState } = form
|
||||||
|
|
||||||
|
const isLoading = loading || formState.isSubmitting
|
||||||
|
const type = formnovalidate || isLoading ? 'button' : 'submit'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
/* This is safe because we are passing all props to the 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 */
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
|
||||||
{...(buttonProps as any)}
|
{...(buttonProps as any)}
|
||||||
rounded={rounded}
|
rounded={rounded}
|
||||||
type={formnovalidate === true ? 'button' : 'submit'}
|
type={type}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
size={size}
|
size={size}
|
||||||
loading={loading || formState.isSubmitting}
|
loading={isLoading}
|
||||||
testId={testId}
|
testId={testId}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (formnovalidate === true) {
|
if (formnovalidate) {
|
||||||
dialogContext?.close()
|
dialogContext?.close()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -135,9 +135,11 @@ export interface UseFormRegisterReturn<
|
|||||||
Schema,
|
Schema,
|
||||||
TFieldValues
|
TFieldValues
|
||||||
>,
|
>,
|
||||||
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onChange'> {
|
> extends Omit<reactHookForm.UseFormRegisterReturn<TFieldName>, 'onBlur' | 'onChange'> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||||
readonly onChange: <Value>(value: Value) => Promise<boolean | void> | void
|
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 isDisabled?: boolean
|
||||||
readonly isRequired?: boolean
|
readonly isRequired?: boolean
|
||||||
readonly isInvalid?: 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. */
|
/** @file A page in which the currently active payment plan can be changed. */
|
||||||
import * as React from 'react'
|
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 reactQuery from '@tanstack/react-query'
|
||||||
import * as router from 'react-router-dom'
|
import * as router from 'react-router-dom'
|
||||||
|
|
||||||
import Back from 'enso-assets/arrow_left.svg'
|
import Back from 'enso-assets/arrow_left.svg'
|
||||||
import * as load from 'enso-common/src/load'
|
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
import * as appUtils from '#/appUtils'
|
||||||
|
|
||||||
@ -16,7 +13,6 @@ import * as navigateHooks from '#/hooks/navigateHooks'
|
|||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import * as aria from '#/components/aria'
|
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
@ -54,33 +50,6 @@ export function Subscribe() {
|
|||||||
|
|
||||||
const plan = searchParams.get('plan')
|
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({
|
const onCompleteMutation = reactQuery.useMutation({
|
||||||
mutationFn: async (mutationData: CreateCheckoutSessionMutation) => {
|
mutationFn: async (mutationData: CreateCheckoutSessionMutation) => {
|
||||||
const { id } = await backend.createCheckoutSession({
|
const { id } = await backend.createCheckoutSession({
|
||||||
@ -103,67 +72,58 @@ export function Subscribe() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-hover-bg text-xs text-primary">
|
<div className="flex h-full w-full flex-col overflow-y-auto bg-hover-bg text-xs text-primary">
|
||||||
<stripeReact.Elements stripe={stripeInstance}>
|
<div className="mx-auto mt-16 flex w-full min-w-96 max-w-[1400px] flex-col items-start justify-center p-12">
|
||||||
<stripeReact.ElementsConsumer>
|
<div className="flex flex-col items-start">
|
||||||
{({ elements }) => {
|
<ariaComponents.Button
|
||||||
if (elements == null) {
|
variant="icon"
|
||||||
return null
|
icon={Back}
|
||||||
} else {
|
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 (
|
return (
|
||||||
<div className="mx-auto mt-16 flex w-full min-w-96 max-w-[1400px] flex-col items-start justify-center p-12">
|
<components.Card
|
||||||
<div className="flex flex-col items-start">
|
key={newPlan}
|
||||||
<ariaComponents.Button
|
className="min-w-64 flex-1"
|
||||||
variant="icon"
|
features={planProps.features}
|
||||||
icon={Back}
|
subtitle={planProps.subtitle}
|
||||||
href={appUtils.DASHBOARD_PATH}
|
title={planProps.title}
|
||||||
className="-ml-2"
|
submitButton={
|
||||||
>
|
<planProps.submitButton
|
||||||
{getText('returnToDashboard')}
|
onSubmit={async paymentMethodId => {
|
||||||
</ariaComponents.Button>
|
await onCompleteMutation.mutateAsync({
|
||||||
<aria.Heading level={1} className="mb-5 self-start text-start text-4xl">
|
plan: newPlan,
|
||||||
{getText('subscribeTitle')}
|
paymentMethodId,
|
||||||
</aria.Heading>
|
})
|
||||||
</div>
|
}}
|
||||||
|
plan={newPlan}
|
||||||
<div className="w-full rounded-default bg-selected-frame p-8">
|
defaultOpen={newPlan === plan}
|
||||||
<div className="flex gap-6 overflow-auto scroll-hidden">
|
/>
|
||||||
{backendModule.PLANS.map(newPlan => {
|
}
|
||||||
const planProps = componentForPlan.getComponentPerPlan(newPlan, getText)
|
learnMore={<planProps.learnMore />}
|
||||||
|
pricing={planProps.pricing}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
})}
|
||||||
}}
|
</div>
|
||||||
</stripeReact.ElementsConsumer>
|
</div>
|
||||||
</stripeReact.Elements>
|
</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 all components from the subscribe folder
|
||||||
*/
|
*/
|
||||||
export * from './SubscribeForm'
|
|
||||||
export * from './Card'
|
export * from './Card'
|
||||||
|
@ -5,8 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import type * as stripeJs from '@stripe/stripe-js'
|
|
||||||
|
|
||||||
import OpenInNewTabIcon from 'enso-assets/open.svg'
|
import OpenInNewTabIcon from 'enso-assets/open.svg'
|
||||||
|
|
||||||
import type * as text from '#/text'
|
import type * as text from '#/text'
|
||||||
@ -15,20 +13,17 @@ import * as textProvider from '#/providers/TextProvider'
|
|||||||
|
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
|
|
||||||
|
import AddPaymentMethodModal from '#/modals/AddPaymentMethodModal'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
import * as string from '#/utilities/string'
|
|
||||||
|
|
||||||
import * as constants from '../constants'
|
import * as constants from '../constants'
|
||||||
import * as components from './components'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The props for the submit button.
|
* The props for the submit button.
|
||||||
*/
|
*/
|
||||||
interface SubmitButtonProps {
|
interface SubmitButtonProps {
|
||||||
readonly onSubmit: (paymentMethodId: string) => Promise<void>
|
readonly onSubmit: (paymentMethodId: string) => Promise<void>
|
||||||
readonly elements: stripeJs.StripeElements
|
|
||||||
readonly stripe: stripeJs.Stripe
|
|
||||||
readonly plan: backendModule.Plan
|
readonly plan: backendModule.Plan
|
||||||
readonly defaultOpen?: boolean
|
readonly defaultOpen?: boolean
|
||||||
}
|
}
|
||||||
@ -84,7 +79,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
|||||||
},
|
},
|
||||||
pricing: 'soloPlanPricing',
|
pricing: 'soloPlanPricing',
|
||||||
submitButton: props => {
|
submitButton: props => {
|
||||||
const { onSubmit, elements, stripe, defaultOpen = false, plan } = props
|
const { onSubmit, defaultOpen = false, plan } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -93,9 +88,11 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
|||||||
{getText('subscribe')}
|
{getText('subscribe')}
|
||||||
</ariaComponents.Button>
|
</ariaComponents.Button>
|
||||||
|
|
||||||
<ariaComponents.Dialog title={getText('upgradeTo', string.capitalizeFirst(plan))}>
|
<AddPaymentMethodModal
|
||||||
<components.SubscribeForm onSubmit={onSubmit} elements={elements} stripe={stripe} />
|
title={getText('upgradeTo', getText(plan))}
|
||||||
</ariaComponents.Dialog>
|
onSubmit={onSubmit}
|
||||||
|
submitText={getText('subscribeSubmit')}
|
||||||
|
/>
|
||||||
</ariaComponents.DialogTrigger>
|
</ariaComponents.DialogTrigger>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -124,7 +121,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
|||||||
title: constants.PLAN_TO_TEXT_ID['team'],
|
title: constants.PLAN_TO_TEXT_ID['team'],
|
||||||
subtitle: 'teamPlanSubtitle',
|
subtitle: 'teamPlanSubtitle',
|
||||||
submitButton: props => {
|
submitButton: props => {
|
||||||
const { onSubmit, elements, stripe, defaultOpen = false, plan } = props
|
const { onSubmit, defaultOpen = false, plan } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -133,9 +130,11 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
|||||||
{getText('subscribe')}
|
{getText('subscribe')}
|
||||||
</ariaComponents.Button>
|
</ariaComponents.Button>
|
||||||
|
|
||||||
<ariaComponents.Dialog title={getText('upgradeTo', string.capitalizeFirst(plan))}>
|
<AddPaymentMethodModal
|
||||||
<components.SubscribeForm onSubmit={onSubmit} elements={elements} stripe={stripe} />
|
title={getText('upgradeTo', getText(plan))}
|
||||||
</ariaComponents.Dialog>
|
onSubmit={onSubmit}
|
||||||
|
submitText={getText('subscribeSubmit')}
|
||||||
|
/>
|
||||||
</ariaComponents.DialogTrigger>
|
</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",
|
"tryAgain": "Try again",
|
||||||
|
|
||||||
"arbitraryFieldRequired": "This field is required",
|
"arbitraryFieldRequired": "Please fill out this field",
|
||||||
"arbitraryFieldInvalid": "This field is invalid",
|
"arbitraryFieldInvalid": "This field is invalid",
|
||||||
"arbitraryFieldTooShort": "This field is too short",
|
"arbitraryFieldTooShort": "This field is too short",
|
||||||
"arbitraryFieldTooLong": "This field is too long",
|
"arbitraryFieldTooLong": "This field is too long",
|
||||||
@ -635,5 +635,14 @@
|
|||||||
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
|
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
|
||||||
"ContactSalesButtonLabel": "Contact Us",
|
"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