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

@ -5,6 +5,7 @@ import {
ChatIcon,
CheckSquareIcon,
CodeIcon,
CreditCardIcon,
EditIcon,
EmailIcon,
ExternalLinkIcon,
@ -62,6 +63,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <PhoneIcon color="orange.500" {...props} />
case InputStepType.CHOICE:
return <CheckSquareIcon color="orange.500" {...props} />
case InputStepType.PAYMENT:
return <CreditCardIcon color="orange.500" {...props} />
case LogicStepType.SET_VARIABLE:
return <EditIcon color="purple.500" {...props} />
case LogicStepType.CONDITION:

View File

@ -37,6 +37,8 @@ export const StepTypeLabel = ({ type }: Props) => {
return <Text>Phone</Text>
case InputStepType.CHOICE:
return <Text>Button</Text>
case InputStepType.PAYMENT:
return <Text>Payment</Text>
case LogicStepType.SET_VARIABLE:
return <Text>Set variable</Text>
case LogicStepType.CONDITION:

View File

@ -7,15 +7,13 @@ import {
Stack,
Image,
PopoverContent,
Tooltip,
HStack,
Text,
Flex,
} from '@chakra-ui/react'
import { ImageUploadContent } from 'components/shared/ImageUploadContent'
import { Input, Textarea } from 'components/shared/Textbox'
import { CodeEditor } from 'components/shared/CodeEditor'
import { HelpCircleIcon } from 'assets/icons'
import { MoreInfoTooltip } from 'components/shared/MoreInfoTooltip'
type Props = {
typebotName: string
@ -113,17 +111,10 @@ export const MetadataForm = ({
<Stack>
<HStack as={FormLabel} mb="0" htmlFor="head">
<Text>Custom head code:</Text>
<Tooltip
label={
'Will be pasted at the bottom of the header section, just above the closing head tag. Only `meta` and `script` tags are allowed.'
}
placement="top"
hasArrow
>
<Flex cursor="pointer">
<HelpCircleIcon />
</Flex>
</Tooltip>
<MoreInfoTooltip>
Will be pasted at the bottom of the header section, just above the
closing head tag. Only `meta` and `script` tags are allowed.
</MoreInfoTooltip>
</HStack>
<CodeEditor
id="head"

View File

@ -31,6 +31,7 @@ import { CodeSettings } from './bodies/CodeSettings'
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PaymentSettings } from './bodies/PaymentSettings'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings'
import { SendEmailSettings } from './bodies/SendEmailSettings'
@ -155,6 +156,14 @@ export const StepSettings = ({
/>
)
}
case InputStepType.PAYMENT: {
return (
<PaymentSettings
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicStepType.SET_VARIABLE: {
return (
<SetVariableSettings

View File

@ -0,0 +1,175 @@
import {
Stack,
useDisclosure,
Text,
Select,
HStack,
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
} from '@chakra-ui/react'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { DropdownList } from 'components/shared/DropdownList'
import { Input } from 'components/shared/Textbox'
import { CredentialsType, PaymentInputOptions, PaymentProvider } from 'models'
import React, { ChangeEvent, useState } from 'react'
import { currencies } from './currencies'
import { StripeConfigModal } from './StripeConfigModal'
type Props = {
options: PaymentInputOptions
onOptionsChange: (options: PaymentInputOptions) => void
}
export const PaymentSettings = ({ options, onOptionsChange }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
const handleProviderChange = (provider: PaymentProvider) => {
onOptionsChange({
...options,
provider,
})
}
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
...options,
credentialsId,
})
}
const handleAmountChange = (amount?: string) =>
onOptionsChange({
...options,
amount,
})
const handleCurrencyChange = (e: ChangeEvent<HTMLSelectElement>) =>
onOptionsChange({
...options,
currency: e.target.value,
})
const handleNameChange = (name: string) =>
onOptionsChange({
...options,
additionalInformation: { ...options.additionalInformation, name },
})
const handleEmailChange = (email: string) =>
onOptionsChange({
...options,
additionalInformation: { ...options.additionalInformation, email },
})
const handlePhoneNumberChange = (phoneNumber: string) =>
onOptionsChange({
...options,
additionalInformation: { ...options.additionalInformation, phoneNumber },
})
const handleButtonLabelChange = (button: string) =>
onOptionsChange({
...options,
labels: { button },
})
return (
<Stack spacing={4}>
<Stack>
<Text>Provider:</Text>
<DropdownList
onItemSelect={handleProviderChange}
items={Object.values(PaymentProvider)}
currentItem={options.provider}
/>
</Stack>
<Stack>
<Text>Account:</Text>
<CredentialsDropdown
type={CredentialsType.STRIPE}
currentCredentialsId={options.credentialsId}
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
refreshDropdownKey={refreshCredentialsKey}
/>
</Stack>
<HStack>
<Stack>
<Text>Price amount:</Text>
<Input
onChange={handleAmountChange}
defaultValue={options.amount}
placeholder="30.00"
/>
</Stack>
<Stack>
<Text>Currency:</Text>
<Select
placeholder="Select option"
value={options.currency.toLowerCase()}
onChange={handleCurrencyChange}
>
{currencies.map((currency) => (
<option value={currency.code.toLowerCase()} key={currency.code}>
{currency.code}
</option>
))}
</Select>
</Stack>
</HStack>
<Stack>
<Text>Button label:</Text>
<Input
onChange={handleButtonLabelChange}
defaultValue={options.labels.button}
placeholder="Pay"
/>
</Stack>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Additional information
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<Stack>
<Text>Name:</Text>
<Input
defaultValue={options.additionalInformation?.name ?? ''}
onChange={handleNameChange}
placeholder="John Smith"
/>
</Stack>
<Stack>
<Text>Email:</Text>
<Input
defaultValue={options.additionalInformation?.email ?? ''}
onChange={handleEmailChange}
placeholder="john@gmail.com"
/>
</Stack>
<Stack>
<Text>Phone number:</Text>
<Input
defaultValue={options.additionalInformation?.phoneNumber ?? ''}
onChange={handlePhoneNumberChange}
placeholder="+33XXXXXXXXX"
/>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
<StripeConfigModal
isOpen={isOpen}
onClose={onClose}
onNewCredentials={handleCredentialsSelect}
/>
</Stack>
)
}

View File

@ -0,0 +1,191 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Button,
useToast,
FormControl,
FormLabel,
Stack,
Text,
HStack,
} from '@chakra-ui/react'
import { useUser } from 'contexts/UserContext'
import { CredentialsType, StripeCredentialsData } from 'models'
import React, { useState } from 'react'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Input } from 'components/shared/Textbox'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { MoreInfoTooltip } from 'components/shared/MoreInfoTooltip'
import { ExternalLinkIcon } from 'assets/icons'
import { createCredentials } from 'services/credentials'
import { omit } from 'utils'
type Props = {
isOpen: boolean
onClose: () => void
onNewCredentials: (id: string) => void
}
export const StripeConfigModal = ({
isOpen,
onNewCredentials,
onClose,
}: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false)
const toast = useToast({
position: 'top-right',
status: 'error',
})
const [stripeConfig, setStripeConfig] = useState<
StripeCredentialsData & { name: string }
>({
name: '',
live: { publicKey: '', secretKey: '' },
test: { publicKey: '', secretKey: '' },
})
const handleNameChange = (name: string) =>
setStripeConfig({
...stripeConfig,
name,
})
const handlePublicKeyChange = (publicKey: string) =>
setStripeConfig({
...stripeConfig,
live: { ...stripeConfig.live, publicKey },
})
const handleSecretKeyChange = (secretKey: string) =>
setStripeConfig({
...stripeConfig,
live: { ...stripeConfig.live, secretKey },
})
const handleTestPublicKeyChange = (publicKey: string) =>
setStripeConfig({
...stripeConfig,
test: { ...stripeConfig.test, publicKey },
})
const handleTestSecretKeyChange = (secretKey: string) =>
setStripeConfig({
...stripeConfig,
test: { ...stripeConfig.test, secretKey },
})
const handleCreateClick = async () => {
if (!user?.email || !workspace?.id) return
setIsCreating(true)
const { data, error } = await createCredentials({
data: omit(stripeConfig, 'name'),
name: stripeConfig.name,
type: CredentialsType.STRIPE,
workspaceId: workspace.id,
})
setIsCreating(false)
if (error) return toast({ title: error.name, description: error.message })
if (!data?.credentials)
return toast({ description: "Credentials wasn't created" })
onNewCredentials(data.credentials.id)
onClose()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Connect Stripe account</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Stack as="form" spacing={4}>
<FormControl isRequired>
<FormLabel>Account name:</FormLabel>
<Input
onChange={handleNameChange}
placeholder="Typebot"
withVariableButton={false}
/>
</FormControl>
<Stack>
<FormLabel>
Test keys:{' '}
<MoreInfoTooltip>
Will be used when previewing the bot.
</MoreInfoTooltip>
</FormLabel>
<HStack>
<FormControl>
<Input
onChange={handleTestPublicKeyChange}
placeholder="pk_test_..."
withVariableButton={false}
/>
</FormControl>
<FormControl>
<Input
onChange={handleTestSecretKeyChange}
placeholder="sk_test_..."
withVariableButton={false}
/>
</FormControl>
</HStack>
</Stack>
<Stack>
<FormLabel>Live keys:</FormLabel>
<HStack>
<FormControl>
<Input
onChange={handlePublicKeyChange}
placeholder="pk_live_..."
withVariableButton={false}
/>
</FormControl>
<FormControl>
<Input
onChange={handleSecretKeyChange}
placeholder="sk_live_..."
withVariableButton={false}
/>
</FormControl>
</HStack>
</Stack>
<Text>
(You can find your keys{' '}
<NextChakraLink
href="https://dashboard.stripe.com/apikeys"
isExternal
textDecor="underline"
>
here <ExternalLinkIcon />
</NextChakraLink>
)
</Text>
</Stack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={handleCreateClick}
isDisabled={
stripeConfig.live.publicKey === '' ||
stripeConfig.name === '' ||
stripeConfig.live.secretKey === ''
}
isLoading={isCreating}
>
Connect
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,545 @@
// The STRIPE-supported currencies, sorted by code
// https://gist.github.com/chrisdavies/9e3f00889fb764013339632bd3f2a71b
export const currencies = [
{
code: 'AED',
description: 'United Arab Emirates Dirham',
},
{
code: 'AFN',
description: 'Afghan Afghani**',
},
{
code: 'ALL',
description: 'Albanian Lek',
},
{
code: 'AMD',
description: 'Armenian Dram',
},
{
code: 'ANG',
description: 'Netherlands Antillean Gulden',
},
{
code: 'AOA',
description: 'Angolan Kwanza**',
},
{
code: 'ARS',
description: 'Argentine Peso**',
},
{
code: 'AUD',
description: 'Australian Dollar',
},
{
code: 'AWG',
description: 'Aruban Florin',
},
{
code: 'AZN',
description: 'Azerbaijani Manat',
},
{
code: 'BAM',
description: 'Bosnia & Herzegovina Convertible Mark',
},
{
code: 'BBD',
description: 'Barbadian Dollar',
},
{
code: 'BDT',
description: 'Bangladeshi Taka',
},
{
code: 'BGN',
description: 'Bulgarian Lev',
},
{
code: 'BIF',
description: 'Burundian Franc',
},
{
code: 'BMD',
description: 'Bermudian Dollar',
},
{
code: 'BND',
description: 'Brunei Dollar',
},
{
code: 'BOB',
description: 'Bolivian Boliviano**',
},
{
code: 'BRL',
description: 'Brazilian Real**',
},
{
code: 'BSD',
description: 'Bahamian Dollar',
},
{
code: 'BWP',
description: 'Botswana Pula',
},
{
code: 'BZD',
description: 'Belize Dollar',
},
{
code: 'CAD',
description: 'Canadian Dollar',
},
{
code: 'CDF',
description: 'Congolese Franc',
},
{
code: 'CHF',
description: 'Swiss Franc',
},
{
code: 'CLP',
description: 'Chilean Peso**',
},
{
code: 'CNY',
description: 'Chinese Renminbi Yuan',
},
{
code: 'COP',
description: 'Colombian Peso**',
},
{
code: 'CRC',
description: 'Costa Rican Colón**',
},
{
code: 'CVE',
description: 'Cape Verdean Escudo**',
},
{
code: 'CZK',
description: 'Czech Koruna**',
},
{
code: 'DJF',
description: 'Djiboutian Franc**',
},
{
code: 'DKK',
description: 'Danish Krone',
},
{
code: 'DOP',
description: 'Dominican Peso',
},
{
code: 'DZD',
description: 'Algerian Dinar',
},
{
code: 'EGP',
description: 'Egyptian Pound',
},
{
code: 'ETB',
description: 'Ethiopian Birr',
},
{
code: 'EUR',
description: 'Euro',
},
{
code: 'FJD',
description: 'Fijian Dollar',
},
{
code: 'FKP',
description: 'Falkland Islands Pound**',
},
{
code: 'GBP',
description: 'British Pound',
},
{
code: 'GEL',
description: 'Georgian Lari',
},
{
code: 'GIP',
description: 'Gibraltar Pound',
},
{
code: 'GMD',
description: 'Gambian Dalasi',
},
{
code: 'GNF',
description: 'Guinean Franc**',
},
{
code: 'GTQ',
description: 'Guatemalan Quetzal**',
},
{
code: 'GYD',
description: 'Guyanese Dollar',
},
{
code: 'HKD',
description: 'Hong Kong Dollar',
},
{
code: 'HNL',
description: 'Honduran Lempira**',
},
{
code: 'HRK',
description: 'Croatian Kuna',
},
{
code: 'HTG',
description: 'Haitian Gourde',
},
{
code: 'HUF',
description: 'Hungarian Forint**',
},
{
code: 'IDR',
description: 'Indonesian Rupiah',
},
{
code: 'ILS',
description: 'Israeli New Sheqel',
},
{
code: 'INR',
description: 'Indian Rupee**',
},
{
code: 'ISK',
description: 'Icelandic Króna',
},
{
code: 'JMD',
description: 'Jamaican Dollar',
},
{
code: 'JPY',
description: 'Japanese Yen',
},
{
code: 'KES',
description: 'Kenyan Shilling',
},
{
code: 'KGS',
description: 'Kyrgyzstani Som',
},
{
code: 'KHR',
description: 'Cambodian Riel',
},
{
code: 'KMF',
description: 'Comorian Franc',
},
{
code: 'KRW',
description: 'South Korean Won',
},
{
code: 'KYD',
description: 'Cayman Islands Dollar',
},
{
code: 'KZT',
description: 'Kazakhstani Tenge',
},
{
code: 'LAK',
description: 'Lao Kip**',
},
{
code: 'LBP',
description: 'Lebanese Pound',
},
{
code: 'LKR',
description: 'Sri Lankan Rupee',
},
{
code: 'LRD',
description: 'Liberian Dollar',
},
{
code: 'LSL',
description: 'Lesotho Loti',
},
{
code: 'MAD',
description: 'Moroccan Dirham',
},
{
code: 'MDL',
description: 'Moldovan Leu',
},
{
code: 'MGA',
description: 'Malagasy Ariary',
},
{
code: 'MKD',
description: 'Macedonian Denar',
},
{
code: 'MNT',
description: 'Mongolian Tögrög',
},
{
code: 'MOP',
description: 'Macanese Pataca',
},
{
code: 'MRO',
description: 'Mauritanian Ouguiya',
},
{
code: 'MUR',
description: 'Mauritian Rupee**',
},
{
code: 'MVR',
description: 'Maldivian Rufiyaa',
},
{
code: 'MWK',
description: 'Malawian Kwacha',
},
{
code: 'MXN',
description: 'Mexican Peso**',
},
{
code: 'MYR',
description: 'Malaysian Ringgit',
},
{
code: 'MZN',
description: 'Mozambican Metical',
},
{
code: 'NAD',
description: 'Namibian Dollar',
},
{
code: 'NGN',
description: 'Nigerian Naira',
},
{
code: 'NIO',
description: 'Nicaraguan Córdoba**',
},
{
code: 'NOK',
description: 'Norwegian Krone',
},
{
code: 'NPR',
description: 'Nepalese Rupee',
},
{
code: 'NZD',
description: 'New Zealand Dollar',
},
{
code: 'PAB',
description: 'Panamanian Balboa**',
},
{
code: 'PEN',
description: 'Peruvian Nuevo Sol**',
},
{
code: 'PGK',
description: 'Papua New Guinean Kina',
},
{
code: 'PHP',
description: 'Philippine Peso',
},
{
code: 'PKR',
description: 'Pakistani Rupee',
},
{
code: 'PLN',
description: 'Polish Złoty',
},
{
code: 'PYG',
description: 'Paraguayan Guaraní**',
},
{
code: 'QAR',
description: 'Qatari Riyal',
},
{
code: 'RON',
description: 'Romanian Leu',
},
{
code: 'RSD',
description: 'Serbian Dinar',
},
{
code: 'RUB',
description: 'Russian Ruble',
},
{
code: 'RWF',
description: 'Rwandan Franc',
},
{
code: 'SAR',
description: 'Saudi Riyal',
},
{
code: 'SBD',
description: 'Solomon Islands Dollar',
},
{
code: 'SCR',
description: 'Seychellois Rupee',
},
{
code: 'SEK',
description: 'Swedish Krona',
},
{
code: 'SGD',
description: 'Singapore Dollar',
},
{
code: 'SHP',
description: 'Saint Helenian Pound**',
},
{
code: 'SLL',
description: 'Sierra Leonean Leone',
},
{
code: 'SOS',
description: 'Somali Shilling',
},
{
code: 'SRD',
description: 'Surinamese Dollar**',
},
{
code: 'STD',
description: 'São Tomé and Príncipe Dobra',
},
{
code: 'SVC',
description: 'Salvadoran Colón**',
},
{
code: 'SZL',
description: 'Swazi Lilangeni',
},
{
code: 'THB',
description: 'Thai Baht',
},
{
code: 'TJS',
description: 'Tajikistani Somoni',
},
{
code: 'TOP',
description: 'Tongan Paʻanga',
},
{
code: 'TRY',
description: 'Turkish Lira',
},
{
code: 'TTD',
description: 'Trinidad and Tobago Dollar',
},
{
code: 'TWD',
description: 'New Taiwan Dollar',
},
{
code: 'TZS',
description: 'Tanzanian Shilling',
},
{
code: 'UAH',
description: 'Ukrainian Hryvnia',
},
{
code: 'UGX',
description: 'Ugandan Shilling',
},
{
code: 'USD',
description: 'United States Dollar',
},
{
code: 'UYU',
description: 'Uruguayan Peso**',
},
{
code: 'UZS',
description: 'Uzbekistani Som',
},
{
code: 'VND',
description: 'Vietnamese Đồng',
},
{
code: 'VUV',
description: 'Vanuatu Vatu',
},
{
code: 'WST',
description: 'Samoan Tala',
},
{
code: 'XAF',
description: 'Central African Cfa Franc',
},
{
code: 'XCD',
description: 'East Caribbean Dollar',
},
{
code: 'XOF',
description: 'West African Cfa Franc**',
},
{
code: 'XPF',
description: 'Cfp Franc**',
},
{
code: 'YER',
description: 'Yemeni Rial',
},
{
code: 'ZAR',
description: 'South African Rand',
},
{
code: 'ZMW',
description: 'Zambian Kwacha',
},
]

View File

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

View File

@ -20,6 +20,7 @@ import {
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PaymentInputContent } from './contents/PaymentInputContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
@ -68,6 +69,9 @@ export const StepNodeContent = ({ step, indices }: Props) => {
case InputStepType.CHOICE: {
return <ItemNodesList step={step} indices={indices} />
}
case InputStepType.PAYMENT: {
return <PaymentInputContent step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableContent step={step} />
}

View File

@ -0,0 +1,20 @@
import { Text } from '@chakra-ui/react'
import { PaymentInputStep } from 'models'
type Props = {
step: PaymentInputStep
}
export const PaymentInputContent = ({ step }: Props) => {
if (
!step.options.amount ||
!step.options.credentialsId ||
!step.options.currency
)
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={0} pr="6">
Collect {step.options.amount} {step.options.currency}
</Text>
)
}

View File

@ -0,0 +1,17 @@
import { Tooltip, chakra } from '@chakra-ui/react'
import { HelpCircleIcon } from 'assets/icons'
import React from 'react'
type Props = {
children: React.ReactNode
}
export const MoreInfoTooltip = ({ children }: Props) => {
return (
<Tooltip label={children}>
<chakra.span cursor="pointer">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
)
}

View File

@ -44,7 +44,7 @@ export const SmartNumberInput = ({
return (
<NumberInput onChange={handleValueChange} value={currentValue} {...props}>
<NumberInputField />
<NumberInputField placeholder={props.placeholder} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />

View File

@ -1,13 +1,6 @@
import {
chakra,
FormLabel,
HStack,
Switch,
SwitchProps,
Tooltip,
} from '@chakra-ui/react'
import { HelpCircleIcon } from 'assets/icons'
import { FormLabel, HStack, Switch, SwitchProps } from '@chakra-ui/react'
import React, { useState } from 'react'
import { MoreInfoTooltip } from './MoreInfoTooltip'
type SwitchWithLabelProps = {
id: string
@ -38,11 +31,7 @@ export const SwitchWithLabel = ({
{label}
</FormLabel>
{moreInfoContent && (
<Tooltip label={moreInfoContent}>
<chakra.span cursor="pointer">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
<MoreInfoTooltip>{moreInfoContent}</MoreInfoTooltip>
)}
</HStack>
<Switch

View File

@ -65,6 +65,7 @@
"nprogress": "^0.2.0",
"papaparse": "^5.3.2",
"prettier": "^2.6.2",
"qs": "^6.10.3",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-draggable": "^4.4.5",
@ -73,15 +74,13 @@
"slate-history": "^0.66.0",
"slate-hyperscript": "^0.77.0",
"slate-react": "^0.79.0",
"stripe": "^9.1.0",
"styled-components": "^5.3.5",
"svg-round-corners": "^0.3.0",
"swr": "^1.3.0",
"tinycolor2": "^1.4.2",
"typebot-js": "*",
"use-debounce": "^8.0.1",
"utils": "*",
"qs": "^6.10.3"
"utils": "*"
},
"devDependencies": {
"@playwright/test": "^1.22.0",
@ -96,6 +95,7 @@
"@types/nprogress": "^0.2.0",
"@types/papaparse": "^5.3.2",
"@types/prettier": "^2.6.1",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.9",
"@types/react-table": "^7.7.12",
"@types/tinycolor2": "^1.4.3",
@ -104,7 +104,6 @@
"eslint": "<8.0.0",
"eslint-config-next": "12.1.6",
"msw": "^0.39.2",
"typescript": "^4.6.4",
"@types/qs": "^6.9.7"
"typescript": "^4.6.4"
}
}

View File

@ -5,4 +5,7 @@ SMTP_HOST=smtp.ethereal.email
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USERNAME=tobin.tillman65@ethereal.email
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
SMTP_PASSWORD=Ty9BcwCBrK6w8AG2hx
STRIPE_TEST_PUBLIC_KEY=
STRIPE_TEST_SECRET_KEY=

View File

@ -5,3 +5,8 @@ export const deleteButtonInConfirmDialog = (page: Page) =>
export const typebotViewer = (page: Page) =>
page.frameLocator('#typebot-iframe')
export const stripePaymentForm = (page: Page) =>
page
.frameLocator('#typebot-iframe')
.frameLocator('[title="Secure payment input frame"]')

View File

@ -0,0 +1,73 @@
import test, { expect } from '@playwright/test'
import {
createTypebots,
parseDefaultBlockWithStep,
} from '../../services/database'
import { defaultPaymentInputOptions, InputStepType } from 'models'
import cuid from 'cuid'
import { stripePaymentForm, typebotViewer } from '../../services/selectorUtils'
test.describe('Payment input step', () => {
test('Can configure Stripe account', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultBlockWithStep({
type: InputStepType.PAYMENT,
options: defaultPaymentInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.click('text=Select an account')
await page.click('text=Connect new')
await page.fill('[placeholder="Typebot"]', 'My Stripe Account')
await page.fill(
'[placeholder="sk_test_..."]',
process.env.STRIPE_TEST_SECRET_KEY ?? ''
)
await page.fill(
'[placeholder="sk_live_..."]',
process.env.STRIPE_TEST_SECRET_KEY ?? ''
)
await page.fill(
'[placeholder="pk_test_..."]',
process.env.STRIPE_TEST_PUBLIC_KEY ?? ''
)
await page.fill(
'[placeholder="pk_live_..."]',
process.env.STRIPE_TEST_PUBLIC_KEY ?? ''
)
await expect(page.locator('button >> text="Connect"')).toBeEnabled()
await page.click('button >> text="Connect"')
await expect(page.locator('text="Secret test key:"')).toBeHidden()
await expect(page.locator('text="My Stripe Account"')).toBeVisible()
await page.fill('[placeholder="30.00"] >> nth=-1', '30.00')
await page.click('text=Additional information')
await page.fill('[placeholder="John Smith"]', 'Baptiste')
await page.fill('[placeholder="john@gmail.com"]', 'baptiste@typebot.io')
await expect(page.locator('text="Phone number:"')).toBeVisible()
await page.click('text=Preview')
await stripePaymentForm(page)
.locator(`[placeholder="1234 1234 1234 1234"]`)
.fill('4000000000000002')
await stripePaymentForm(page)
.locator(`[placeholder="MM / YY"]`)
.fill('12 / 25')
await stripePaymentForm(page).locator(`[placeholder="CVC"]`).fill('240')
await stripePaymentForm(page).locator(`[placeholder="90210"]`).fill('90210')
await typebotViewer(page).locator(`text="Pay 30$"`).click()
await expect(
typebotViewer(page).locator(`text="Your card was declined."`)
).toBeVisible()
await stripePaymentForm(page)
.locator(`[placeholder="1234 1234 1234 1234"]`)
.fill('4242424242424242')
await typebotViewer(page).locator(`text="Pay 30$"`).click()
await expect(typebotViewer(page).locator(`text="Success"`)).toBeVisible()
})
})

View File

@ -1,5 +1,5 @@
import { User } from 'db'
import { loadStripe } from '@stripe/stripe-js'
import { loadStripe } from '@stripe/stripe-js/pure'
import { sendRequest } from 'utils'
type Props = {

View File

@ -37,6 +37,7 @@ import {
defaultEmbedBubbleContent,
ChoiceInputStep,
ConditionStep,
defaultPaymentInputOptions,
} from 'models'
import { Typebot } from 'models'
import useSWR from 'swr'
@ -320,6 +321,8 @@ const parseDefaultStepOptions = (type: StepWithOptionsType): StepOptions => {
return defaultUrlInputOptions
case InputStepType.CHOICE:
return defaultChoiceInputOptions
case InputStepType.PAYMENT:
return defaultPaymentInputOptions
case LogicStepType.SET_VARIABLE:
return defaultSetVariablesOptions
case LogicStepType.REDIRECT:

View File

@ -26,6 +26,7 @@
"react": "^18.1.0",
"react-dom": "^18.1.0",
"sanitize-html": "^2.7.0",
"stripe": "^9.1.0",
"utils": "*"
},
"devDependencies": {
@ -37,6 +38,7 @@
"@types/qs": "^6.9.7",
"@types/react": "^18.0.9",
"@types/sanitize-html": "^2.6.2",
"@types/stripe": "^8.0.417",
"@typescript-eslint/eslint-plugin": "^5.23.0",
"eslint": "<8.0.0",
"eslint-config-next": "12.1.6",

View File

@ -0,0 +1,93 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
badRequest,
decrypt,
forbidden,
initMiddleware,
methodNotAllowed,
} from 'utils'
import Stripe from 'stripe'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import { PaymentInputOptions, StripeCredentialsData, Variable } from 'models'
import prisma from 'libs/prisma'
import { parseVariables } from 'bot-engine'
const cors = initMiddleware(Cors())
const currencySymbols: { [key: string]: string } = {
USD: '$',
EUR: '€',
CRC: '₡',
GBP: '£',
ILS: '₪',
INR: '₹',
JPY: '¥',
KRW: '₩',
NGN: '₦',
PHP: '₱',
PLN: 'zł',
PYG: '₲',
THB: '฿',
UAH: '₴',
VND: '₫',
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const { inputOptions, isPreview, variables } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
inputOptions: PaymentInputOptions
isPreview: boolean
variables: Variable[]
}
if (!inputOptions.credentialsId) return forbidden(res)
const stripeKeys = await getStripeInfo(inputOptions.credentialsId)
if (!stripeKeys) return forbidden(res)
const stripe = new Stripe(
isPreview && stripeKeys?.test?.secretKey
? stripeKeys.test.secretKey
: stripeKeys.live.secretKey,
{ apiVersion: '2020-08-27' }
)
console.log(variables, inputOptions)
const amount = Math.round(
Number(parseVariables(variables)(inputOptions.amount)) * 100
)
if (isNaN(amount)) return badRequest(res)
// Create a PaymentIntent with the order amount and currency
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: inputOptions.currency,
receipt_email: inputOptions.additionalInformation?.email,
automatic_payment_methods: {
enabled: true,
},
})
return res.send({
clientSecret: paymentIntent.client_secret,
publicKey:
isPreview && stripeKeys.test?.publicKey
? stripeKeys.test.publicKey
: stripeKeys.live.publicKey,
amountLabel: `${amount / 100}${currencySymbols[inputOptions.currency]}`,
})
}
return methodNotAllowed(res)
}
const getStripeInfo = async (
credentialsId: string
): Promise<StripeCredentialsData | undefined> => {
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return decrypt(credentials.data, credentials.iv) as StripeCredentialsData
}
export default withSentry(handler)

View File

@ -15,7 +15,9 @@ export const parseSampleResult =
typebot: Pick<Typebot | PublicTypebot, 'blocks' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (currentBlockId: string): Promise<Record<string, string>> => {
async (
currentBlockId: string
): Promise<Record<string, string | boolean | undefined>> => {
const header = parseResultHeader({
blocks: [...typebot.blocks, ...linkedTypebots.flatMap((t) => t.blocks)],
variables: [
@ -83,7 +85,7 @@ const parseBlocksResultSample = (
inputSteps: InputStep[],
header: ResultHeaderCell[]
) =>
header.reduce<Record<string, string>>((steps, cell) => {
header.reduce<Record<string, string | boolean | undefined>>((steps, cell) => {
const inputStep = inputSteps.find((step) => step.id === cell.stepId)
if (isNotDefined(inputStep)) {
if (cell.variableId)

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

View File

@ -1,12 +1,16 @@
import { Credentials as CredentialsFromPrisma } from 'db'
export type Credentials = SmtpCredentials | GoogleSheetsCredentials
export type Credentials =
| SmtpCredentials
| GoogleSheetsCredentials
| StripeCredentials
export type CredentialsBase = Omit<CredentialsFromPrisma, 'data' | 'type'>
export enum CredentialsType {
GOOGLE_SHEETS = 'google sheets',
SMTP = 'smtp',
STRIPE = 'stripe',
}
export type SmtpCredentials = CredentialsBase & {
@ -19,6 +23,11 @@ export type GoogleSheetsCredentials = CredentialsBase & {
data: GoogleSheetsCredentialsData
}
export type StripeCredentials = CredentialsBase & {
type: CredentialsType.STRIPE
data: StripeCredentialsData
}
export type GoogleSheetsCredentialsData = {
refresh_token?: string | null
expiry_date?: number | null
@ -36,3 +45,14 @@ export type SmtpCredentialsData = {
port: number
from: { email?: string; name?: string }
}
export type StripeCredentialsData = {
live: {
secretKey: string
publicKey: string
}
test?: {
secretKey?: string
publicKey?: string
}
}

View File

@ -9,6 +9,7 @@ export type InputStep =
| DateInputStep
| PhoneNumberInputStep
| ChoiceInputStep
| PaymentInputStep
export enum InputStepType {
TEXT = 'text input',
@ -18,6 +19,7 @@ export enum InputStepType {
DATE = 'date input',
PHONE = 'phone number input',
CHOICE = 'choice input',
PAYMENT = 'payment input',
}
export type InputStepOptions =
@ -28,6 +30,7 @@ export type InputStepOptions =
| UrlInputOptions
| PhoneNumberInputOptions
| ChoiceInputOptions
| PaymentInputOptions
export type TextInputStep = StepBase & {
type: InputStepType.TEXT
@ -70,6 +73,18 @@ export type ButtonItem = ItemBase & {
content?: string
}
export type PaymentInputStep = StepBase & {
type: InputStepType.PAYMENT
options: PaymentInputOptions
}
export type CreditCardDetails = {
number: string
exp_month: string
exp_year: string
cvc: string
}
type OptionBase = { variableId?: string }
type InputTextOptionsBase = {
@ -115,6 +130,23 @@ export type NumberInputOptions = OptionBase &
step?: number
}
export enum PaymentProvider {
STRIPE = 'Stripe',
}
export type PaymentInputOptions = OptionBase & {
provider: PaymentProvider
amount?: string
currency: string
credentialsId?: string
additionalInformation?: {
name?: string
email?: string
phoneNumber?: string
}
labels: { button: string }
}
const defaultButtonLabel = 'Send'
export const defaultTextInputOptions: TextInputOptions = {
@ -163,3 +195,9 @@ export const defaultChoiceInputOptions: ChoiceInputOptions = {
buttonLabel: defaultButtonLabel,
isMultipleChoice: false,
}
export const defaultPaymentInputOptions: PaymentInputOptions = {
provider: PaymentProvider.STRIPE,
labels: { button: 'Pay' },
currency: 'USD',
}

View File

@ -3622,6 +3622,13 @@
eval "^0.1.8"
webpack-sources "^1.4.3"
"@stripe/react-stripe-js@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.8.0.tgz#b07f1d95e75fe93ac5c33346539ec0972298957d"
integrity sha512-WtF2mIWJKnyAaZledC48NfynFckEseCPG8tybwFgisFxHvLPKrArlLISJOi2cjIY20DkMAkSkdlEljXifJi0ZA==
dependencies:
prop-types "^15.7.2"
"@stripe/stripe-js@^1.29.0":
version "1.29.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.29.0.tgz#f41e46aee711d1eabcb3bbc77376016a250ec962"
@ -4402,6 +4409,13 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/stripe@^8.0.417":
version "8.0.417"
resolved "https://registry.yarnpkg.com/@types/stripe/-/stripe-8.0.417.tgz#b651677a9fc33be8ce8fd5bceadd7ca077214244"
integrity sha512-PTuqskh9YKNENnOHGVJBm4sM0zE8B1jZw1JIskuGAPkMB+OH236QeN8scclhYGPA4nG6zTtPXgwpXdp+HPDTVw==
dependencies:
stripe "*"
"@types/tinycolor2@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.3.tgz#ed4a0901f954b126e6a914b4839c77462d56e706"
@ -13898,7 +13912,7 @@ strip-json-comments@~2.0.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
stripe@^9.1.0:
stripe@*, stripe@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-9.1.0.tgz#9e0ff10eef1febe215e5794ac13f0111609bd750"
integrity sha512-Kpa+DoB5GCV360a6fvIrWokLhj3SXRadWAxR6duN2mIgrIkvLK0tVDd8b2OT2/QtJ6WX9EI3vLvaErzG76d+tg==