2
0

feat(editor): Payment input

This commit is contained in:
Baptiste Arnaud
2022-05-24 14:25:15 -07:00
parent 91ea637a08
commit 3a6ca3dbde
35 changed files with 1516 additions and 52 deletions

View File

@ -183,6 +183,10 @@ textarea {
box-shadow: 0 2px 6px -1px rgba(0, 0, 0, 0.1);
}
.typebot-input-error-message {
color: var(--typebot-input-color);
}
.typebot-button > .send-icon {
fill: var(--typebot-button-color);
}

View File

@ -9,6 +9,7 @@ import { ChoiceForm } from './inputs/ChoiceForm'
import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from '../../../services/variable'
import { isInputValid } from 'services/inputs'
import { PaymentForm } from './inputs/PaymentForm'
export const InputChatStep = ({
step,
@ -107,5 +108,12 @@ const Input = ({
return <DateForm options={step.options} onSubmit={onSubmit} />
case InputStepType.CHOICE:
return <ChoiceForm step={step} onSubmit={onSubmit} />
case InputStepType.PAYMENT:
return (
<PaymentForm
options={step.options}
onSuccess={() => onSubmit('Success')}
/>
)
}
}

View File

@ -0,0 +1,15 @@
import { PaymentInputOptions, PaymentProvider } from 'models'
import React from 'react'
import { StripePaymentForm } from './StripePaymentForm'
type Props = {
onSuccess: () => void
options: PaymentInputOptions
}
export const PaymentForm = ({ onSuccess, options }: Props): JSX.Element => {
switch (options.provider) {
case PaymentProvider.STRIPE:
return <StripePaymentForm onSuccess={onSuccess} options={options} />
}
}

View File

@ -0,0 +1,173 @@
import React, { FormEvent, useEffect, useState } from 'react'
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
import { Elements } from '@stripe/react-stripe-js'
import { createPaymentIntent } from 'services/stripe'
import { useTypebot } from 'contexts/TypebotContext'
import { PaymentInputOptions, Variable } from 'models'
import { SendButton } from '../SendButton'
import { useFrame } from 'react-frame-component'
import { initStripe } from '../../../../../../lib/stripe'
import { parseVariables } from 'services/variable'
type Props = {
options: PaymentInputOptions
onSuccess: () => void
}
export const StripePaymentForm = ({ options, onSuccess }: Props) => {
const {
apiHost,
isPreview,
typebot: { variables },
} = useTypebot()
const { window: frameWindow, document: frameDocument } = useFrame()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [stripe, setStripe] = useState<any>(null)
const [clientSecret, setClientSecret] = useState('')
const [amountLabel, setAmountLabel] = useState('')
useEffect(() => {
;(async () => {
const { data, error } = await createPaymentIntent({
apiHost,
isPreview,
variables,
inputOptions: options,
})
if (error || !data) return console.error(error)
await initStripe(frameDocument)
setStripe(frameWindow.Stripe(data.publicKey))
setClientSecret(data.clientSecret)
setAmountLabel(data.amountLabel)
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!stripe || !clientSecret) return <></>
return (
<Elements stripe={stripe} options={{ clientSecret }}>
<CheckoutForm
onSuccess={onSuccess}
clientSecret={clientSecret}
amountLabel={amountLabel}
options={options}
variables={variables}
viewerHost={apiHost}
/>
</Elements>
)
}
const CheckoutForm = ({
onSuccess,
clientSecret,
amountLabel,
options,
variables,
viewerHost,
}: {
onSuccess: () => void
clientSecret: string
amountLabel: string
options: PaymentInputOptions
variables: Variable[]
viewerHost: string
}) => {
const [ignoreFirstPaymentIntentCall, setIgnoreFirstPaymentIntentCall] =
useState(true)
const stripe = useStripe()
const elements = useElements()
const [message, setMessage] = useState<string>()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
if (!stripe || !clientSecret) return
if (ignoreFirstPaymentIntentCall)
return setIgnoreFirstPaymentIntentCall(false)
stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
switch (paymentIntent?.status) {
case 'succeeded':
setMessage('Payment succeeded!')
break
case 'processing':
setMessage('Your payment is processing.')
break
case 'requires_payment_method':
setMessage('Your payment was not successful, please try again.')
break
default:
setMessage('Something went wrong.')
break
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stripe, clientSecret])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setIsLoading(true)
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
// Make sure to change this to your payment completion page
return_url: viewerHost,
payment_method_data: {
billing_details: {
name: options.additionalInformation?.name
? parseVariables(variables)(options.additionalInformation?.name)
: undefined,
email: options.additionalInformation?.email
? parseVariables(variables)(options.additionalInformation?.email)
: undefined,
phone: options.additionalInformation?.phoneNumber
? parseVariables(variables)(
options.additionalInformation?.phoneNumber
)
: undefined,
},
},
},
redirect: 'if_required',
})
setIsLoading(false)
if (!error) return onSuccess()
if (error.type === 'validation_error') return
if (error?.type === 'card_error') return setMessage(error.message)
setMessage('An unexpected error occured.')
}
return (
<form
id="payment-form"
onSubmit={handleSubmit}
className="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
>
<PaymentElement id="payment-element" className="w-full" />
<SendButton
label={`${options.labels.button} ${amountLabel}`}
isDisabled={isLoading || !stripe || !elements}
isLoading={isLoading}
className="mt-4 w-full max-w-lg"
disableIcon
/>
{message && (
<div
id="payment-message"
className="typebot-input-error-message mt-4 text-center"
>
{message}
</div>
)}
</form>
)
}

View File

@ -0,0 +1 @@
export { PaymentForm } from './PaymentForm'

View File

@ -4,25 +4,55 @@ import { SendIcon } from '../../../../assets/icons'
type SendButtonProps = {
label: string
isDisabled?: boolean
isLoading?: boolean
disableIcon?: boolean
} & React.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = ({
label,
isDisabled,
isLoading,
disableIcon,
...props
}: SendButtonProps) => {
return (
<button
type="submit"
disabled={isDisabled}
disabled={isDisabled || isLoading}
{...props}
className={
'py-2 px-4 font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button ' +
'py-2 px-4 justify-center font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button ' +
props.className
}
>
<span className="hidden xs:flex">{label}</span>
<SendIcon className="send-icon flex xs:hidden" />
{isLoading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
<span className={'xs:flex ' + (disableIcon ? '' : 'hidden')}>
{label}
</span>
<SendIcon
className={'send-icon flex ' + (disableIcon ? 'hidden' : 'xs:hidden')}
/>
</button>
)
}

View File

@ -90,7 +90,7 @@ export const TextInput = ({ step, value, onChange }: TextInputProps) => {
style={{ appearance: 'auto' }}
min={step.options?.min}
max={step.options?.max}
step={step.options?.step}
step={step.options?.step ?? 'any'}
/>
)
}

View File

@ -0,0 +1,21 @@
import { PaymentInputOptions, Variable } from 'models'
import { sendRequest } from 'utils'
export const createPaymentIntent = ({
apiHost,
isPreview,
inputOptions,
variables,
}: {
inputOptions: PaymentInputOptions
apiHost: string
variables: Variable[]
isPreview: boolean
}) =>
sendRequest<{ clientSecret: string; publicKey: string; amountLabel: string }>(
{
url: `${apiHost}/api/integrations/stripe/createPaymentIntent`,
method: 'POST',
body: { inputOptions, isPreview, variables },
}
)