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

@@ -0,0 +1,12 @@
export const initStripe = (document: Document): Promise<void> =>
new Promise((resolve) => {
const existingScript = document.getElementById('stripe-script')
if (existingScript) return resolve()
const script = document.createElement('script')
script.src = 'https://js.stripe.com/v3'
script.id = 'stripe-script'
document.body.appendChild(script)
script.onload = () => {
resolve()
}
})

View File

@@ -6,6 +6,7 @@
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"dependencies": {
"@stripe/react-stripe-js": "^1.8.0",
"db": "*",
"models": "*",
"qs": "^6.10.3",
@@ -20,11 +21,17 @@
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-typescript": "^8.3.2",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.9",
"@types/react-phone-number-input": "^3.0.13",
"@types/react-scroll": "^1.8.3",
"@types/react-transition-group": "^4.4.4",
"@typescript-eslint/eslint-plugin": "^5.23.0",
"autoprefixer": "^10.4.7",
"eslint": "<8.0.0",
"eslint-config-next": "12.1.6",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.13",
"rollup": "^2.72.1",
@@ -33,14 +40,8 @@
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-terser": "^7.0.2",
"tailwindcss": "^3.0.24",
"typescript": "^4.6.4",
"@typescript-eslint/eslint-plugin": "^5.23.0",
"eslint": "<8.0.0",
"eslint-config-next": "12.1.6",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
"tslib": "^2.4.0",
"@types/qs": "^6.9.7"
"typescript": "^4.6.4"
},
"peerDependencies": {
"react": "^18.1.0"

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 },
}
)