feat(editor): ✨ Payment input
This commit is contained in:
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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',
|
||||
},
|
||||
]
|
@ -0,0 +1 @@
|
||||
export { PaymentSettings } from './PaymentSettings'
|
@ -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} />
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
17
apps/builder/components/shared/MoreInfoTooltip.tsx
Normal file
17
apps/builder/components/shared/MoreInfoTooltip.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -44,7 +44,7 @@ export const SmartNumberInput = ({
|
||||
|
||||
return (
|
||||
<NumberInput onChange={handleValueChange} value={currentValue} {...props}>
|
||||
<NumberInputField />
|
||||
<NumberInputField placeholder={props.placeholder} />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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=
|
@ -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"]')
|
||||
|
73
apps/builder/playwright/tests/inputs/payment.spec.ts
Normal file
73
apps/builder/playwright/tests/inputs/payment.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
@ -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 = {
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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)
|
@ -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)
|
||||
|
12
packages/bot-engine/lib/stripe.ts
Normal file
12
packages/bot-engine/lib/stripe.ts
Normal 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()
|
||||
}
|
||||
})
|
@ -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"
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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} />
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { PaymentForm } from './PaymentForm'
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
21
packages/bot-engine/src/services/stripe.ts
Normal file
21
packages/bot-engine/src/services/stripe.ts
Normal 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 },
|
||||
}
|
||||
)
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
||||
|
16
yarn.lock
16
yarn.lock
@ -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==
|
||||
|
Reference in New Issue
Block a user