2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,9 @@
.cm-editor {
outline: 0px solid transparent !important;
height: 100%;
}
.cm-scroller {
border-radius: 5px;
border: 1px solid #e5e7eb;
}

View File

@ -0,0 +1,25 @@
html,
body {
overscroll-behavior-x: none;
}
.grabbing * {
cursor: grabbing !important;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* * {
outline: 1px solid #f00 !important;
opacity: 1 !important;
visibility: visible !important;
} */

View File

@ -0,0 +1,31 @@
.slate-bold {
font-weight: bold;
}
.slate-italic {
font-style: oblique;
}
.slate-underline {
text-decoration: underline;
}
.slate-ToolbarButton-active {
color: blue !important;
}
.slate-ToolbarButton-active > svg {
stroke-width: 2px;
}
.slate-ToolbarButton {
color: gray;
}
.slate-a {
color: blue !important;
text-decoration: underline;
}
.slate-html-container > div {
min-height: 24px;
}

View File

@ -0,0 +1,81 @@
/* Make clicks pass-through */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: #0042da;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/* Fancy blur effect */
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #0042da, 0 0 5px #0042da;
opacity: 1;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
/* Remove these to get rid of the spinner */
#nprogress .spinner {
display: block;
position: fixed;
z-index: 1031;
top: 15px;
right: 15px;
}
#nprogress .spinner-icon {
width: 18px;
height: 18px;
box-sizing: border-box;
border: solid 2px transparent;
border-top-color: #0042da;
border-left-color: #0042da;
border-radius: 50%;
-webkit-animation: nprogress-spinner 400ms linear infinite;
animation: nprogress-spinner 400ms linear infinite;
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
}
.nprogress-custom-parent #nprogress .spinner,
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}
@-webkit-keyframes nprogress-spinner {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,11 @@
.table-wrapper {
background-image: linear-gradient(to right, white, white),
linear-gradient(to right, white, white),
linear-gradient(to right, rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0)),
linear-gradient(to left, rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0));
background-position: left center, right center, left center, right center;
background-repeat: no-repeat;
background-color: white;
background-size: 30px 100%, 30px 100%, 15px 100%, 15px 100%;
background-attachment: local, local, scroll, scroll;
}

View File

@ -0,0 +1,8 @@
import { AlertProps, Alert, AlertIcon } from '@chakra-ui/react'
export const AlertInfo = (props: AlertProps) => (
<Alert status="info" bgColor={'blue.50'} rounded="md" {...props}>
<AlertIcon />
{props.children}
</Alert>
)

View File

@ -0,0 +1,146 @@
import { Box, BoxProps, HStack } from '@chakra-ui/react'
import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { css } from '@codemirror/lang-css'
import { javascript } from '@codemirror/lang-javascript'
import { html } from '@codemirror/lang-html'
import { useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { linter, LintSource } from '@codemirror/lint'
import { VariablesButton } from '@/features/variables'
import { Variable } from 'models'
import { env } from 'utils'
const linterExtension = linter(jsonParseLinter() as unknown as LintSource)
type Props = {
value: string
lang?: 'css' | 'json' | 'js' | 'html'
isReadOnly?: boolean
debounceTimeout?: number
withVariableButton?: boolean
height?: string
onChange?: (value: string) => void
}
export const CodeEditor = ({
value,
lang,
onChange,
height = '250px',
withVariableButton = true,
isReadOnly = false,
debounceTimeout = 1000,
...props
}: Props & Omit<BoxProps, 'onChange'>) => {
const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null)
const [, setPlainTextValue] = useState(value)
const [carretPosition, setCarretPosition] = useState<number>(0)
const isVariableButtonDisplayed = withVariableButton && !isReadOnly
const debounced = useDebouncedCallback(
(value) => {
setPlainTextValue(value)
onChange && onChange(value)
},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(
() => () => {
debounced.flush()
},
[debounced]
)
useEffect(() => {
if (!editorView.current || !isReadOnly) return
editorView.current.dispatch({
changes: {
from: 0,
to: editorView.current.state.doc.length,
insert: value,
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
useEffect(() => {
const editor = initEditor(value)
return () => {
editor?.destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const initEditor = (value: string) => {
if (!editorContainer.current) return
const updateListenerExtension = EditorView.updateListener.of((update) => {
if (update.docChanged && onChange)
debounced(update.state.doc.toJSON().join('\n'))
})
const extensions = [
updateListenerExtension,
basicSetup,
EditorState.readOnly.of(isReadOnly),
]
if (lang === 'json') {
extensions.push(json())
extensions.push(linterExtension)
}
if (lang === 'css') extensions.push(css())
if (lang === 'js') extensions.push(javascript())
if (lang === 'html') extensions.push(html())
extensions.push(
EditorView.theme({
'&': { maxHeight: '500px' },
'.cm-gutter,.cm-content': { minHeight: isReadOnly ? '0' : height },
'.cm-scroller': { overflow: 'auto' },
})
)
const editor = new EditorView({
state: EditorState.create({
extensions,
}),
parent: editorContainer.current,
})
editor.dispatch({
changes: { from: 0, insert: value },
})
editorView.current = editor
return editor
}
const handleVariableSelected = (variable?: Pick<Variable, 'id' | 'name'>) => {
editorView.current?.focus()
const insert = `{{${variable?.name}}}`
editorView.current?.dispatch({
changes: {
from: carretPosition,
insert,
},
selection: { anchor: carretPosition + insert.length },
})
}
const handleKeyUp = () => {
if (!editorContainer.current) return
setCarretPosition(editorView.current?.state.selection.main.from ?? 0)
}
return (
<HStack align="flex-end" spacing={0}>
<Box
w={isVariableButtonDisplayed ? 'calc(100% - 32px)' : '100%'}
ref={editorContainer}
data-testid="code-editor"
{...props}
onKeyUp={handleKeyUp}
/>
{isVariableButtonDisplayed && (
<VariablesButton onSelectVariable={handleVariableSelected} size="sm" />
)}
</HStack>
)
}

View File

@ -0,0 +1,137 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
PopoverCloseButton,
PopoverHeader,
Center,
PopoverBody,
SimpleGrid,
Input,
Button,
Stack,
ButtonProps,
} from '@chakra-ui/react'
import React, { ChangeEvent, useEffect, useState } from 'react'
import tinyColor from 'tinycolor2'
const colorsSelection: `#${string}`[] = [
'#264653',
'#e9c46a',
'#2a9d8f',
'#7209b7',
'#023e8a',
'#ffe8d6',
'#d8f3dc',
'#4ea8de',
'#ffb4a2',
]
type Props = {
initialColor: string
onColorChange: (color: string) => void
}
export const ColorPicker = ({ initialColor, onColorChange }: Props) => {
const [color, setColor] = useState(initialColor)
useEffect(() => {
onColorChange(color)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [color])
const handleColorChange = (e: ChangeEvent<HTMLInputElement>) =>
setColor(e.target.value)
const handleClick = (color: string) => () => setColor(color)
return (
<Popover variant="picker" placement="right" isLazy>
<PopoverTrigger>
<Button
aria-label={'Pick a color'}
bgColor={color}
_hover={{ bgColor: `#${tinyColor(color).darken(10).toHex()}` }}
_active={{ bgColor: `#${tinyColor(color).darken(30).toHex()}` }}
height="22px"
width="22px"
padding={0}
borderRadius={3}
borderWidth={1}
/>
</PopoverTrigger>
<PopoverContent width="170px">
<PopoverArrow bg={color} />
<PopoverCloseButton color="white" />
<PopoverHeader
height="100px"
backgroundColor={color}
borderTopLeftRadius={5}
borderTopRightRadius={5}
color={tinyColor(color).isLight() ? 'gray.800' : 'white'}
>
<Center height="100%">{color}</Center>
</PopoverHeader>
<PopoverBody as={Stack}>
<SimpleGrid columns={5} spacing={2}>
{colorsSelection.map((c) => (
<Button
key={c}
aria-label={c}
background={c}
height="22px"
width="22px"
padding={0}
minWidth="unset"
borderRadius={3}
_hover={{ background: c }}
onClick={handleClick(c)}
/>
))}
</SimpleGrid>
<Input
borderRadius={3}
marginTop={3}
placeholder="#2a9d8f"
aria-label="Color value"
size="sm"
value={color}
onChange={handleColorChange}
/>
<NativeColorPicker
size="sm"
color={color}
onColorChange={handleColorChange}
>
Advanced picker
</NativeColorPicker>
</PopoverBody>
</PopoverContent>
</Popover>
)
}
const NativeColorPicker = ({
color,
onColorChange,
...props
}: {
color: string
onColorChange: (e: ChangeEvent<HTMLInputElement>) => void
} & ButtonProps) => {
return (
<>
<Button as="label" htmlFor="native-picker" {...props}>
{props.children}
</Button>
<Input
type="color"
display="none"
id="native-picker"
value={color}
onChange={onColorChange}
/>
</>
)
}

View File

@ -0,0 +1,77 @@
import { useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from '@chakra-ui/react'
type ConfirmDeleteModalProps = {
isOpen: boolean
onConfirm: () => Promise<unknown>
onClose: () => void
message: JSX.Element
title?: string
confirmButtonLabel: string
confirmButtonColor?: 'blue' | 'red'
}
export const ConfirmModal = ({
title,
message,
isOpen,
onClose,
confirmButtonLabel,
onConfirm,
confirmButtonColor = 'red',
}: ConfirmDeleteModalProps) => {
const [confirmLoading, setConfirmLoading] = useState(false)
const cancelRef = useRef(null)
const onConfirmClick = async () => {
setConfirmLoading(true)
try {
await onConfirm()
} catch (e) {
setConfirmLoading(false)
return setConfirmLoading(false)
}
setConfirmLoading(false)
onClose()
}
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title ?? 'Are you sure?'}
</AlertDialogHeader>
<AlertDialogBody>{message}</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button
colorScheme={confirmButtonColor}
onClick={onConfirmClick}
ml={3}
isLoading={confirmLoading}
>
{confirmButtonLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)
}

View File

@ -0,0 +1,108 @@
import * as React from 'react'
import {
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import {
useEventListener,
Portal,
Menu,
MenuButton,
PortalProps,
MenuButtonProps,
MenuProps,
} from '@chakra-ui/react'
export interface ContextMenuProps<T extends HTMLElement> {
renderMenu: () => JSX.Element | null
children: (
ref: MutableRefObject<T | null>,
isOpened: boolean
) => JSX.Element | null
menuProps?: MenuProps
portalProps?: PortalProps
menuButtonProps?: MenuButtonProps
isDisabled?: boolean
}
export function ContextMenu<T extends HTMLElement = HTMLElement>(
props: ContextMenuProps<T>
) {
const [isOpened, setIsOpened] = useState(false)
const [isRendered, setIsRendered] = useState(false)
const [isDeferredOpen, setIsDeferredOpen] = useState(false)
const [position, setPosition] = useState<[number, number]>([0, 0])
const targetRef = useRef<T>(null)
useEffect(() => {
if (isOpened) {
setTimeout(() => {
setIsRendered(true)
setTimeout(() => {
setIsDeferredOpen(true)
})
})
} else {
setIsDeferredOpen(false)
const timeout = setTimeout(() => {
setIsRendered(isOpened)
}, 1000)
return () => clearTimeout(timeout)
}
}, [isOpened])
useEventListener(
'contextmenu',
(e) => {
if (props.isDisabled) return
if (e.currentTarget === targetRef.current) {
e.preventDefault()
e.stopPropagation()
setIsOpened(true)
setPosition([e.pageX, e.pageY])
} else {
setIsOpened(false)
}
},
targetRef.current
)
const onCloseHandler = useCallback(() => {
props.menuProps?.onClose?.()
setIsOpened(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.menuProps?.onClose, setIsOpened])
return (
<>
{props.children(targetRef, isOpened)}
{isRendered && (
<Portal {...props.portalProps}>
<Menu
isOpen={isDeferredOpen}
gutter={0}
{...props.menuProps}
onClose={onCloseHandler}
>
<MenuButton
aria-hidden={true}
w={1}
h={1}
style={{
position: 'absolute',
left: position[0],
top: position[1],
cursor: 'default',
}}
{...props.menuButtonProps}
/>
{props.renderMenu()}
</Menu>
</Portal>
)}
</>
)
}

View File

@ -0,0 +1,25 @@
import React from 'react'
import { ButtonProps, Button, useClipboard } from '@chakra-ui/react'
interface CopyButtonProps extends ButtonProps {
textToCopy: string
onCopied?: () => void
}
export const CopyButton = (props: CopyButtonProps) => {
const { textToCopy, onCopied, ...buttonProps } = props
const { hasCopied, onCopy } = useClipboard(textToCopy)
return (
<Button
isDisabled={hasCopied}
onClick={() => {
onCopy()
if (onCopied) onCopied()
}}
{...buttonProps}
>
{!hasCopied ? 'Copy' : 'Copied'}
</Button>
)
}

View File

@ -0,0 +1,66 @@
import {
Button,
chakra,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
Portal,
Stack,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import React, { ReactNode } from 'react'
type Props<T> = {
currentItem?: T
onItemSelect: (item: T) => void
items: T[]
placeholder?: string
}
export const DropdownList = <T,>({
currentItem,
onItemSelect,
items,
placeholder = '',
...props
}: Props<T> & MenuButtonProps) => {
const handleMenuItemClick = (operator: T) => () => {
onItemSelect(operator)
}
return (
<Menu isLazy placement="bottom-end" matchWidth>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
colorScheme="gray"
justifyContent="space-between"
textAlign="left"
{...props}
>
<chakra.span noOfLines={1} display="block">
{(currentItem ?? placeholder) as unknown as ReactNode}
</chakra.span>
</MenuButton>
<Portal>
<MenuList maxW="500px" zIndex={1500}>
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
{items.map((item) => (
<MenuItem
key={item as unknown as string}
maxW="500px"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
onClick={handleMenuItemClick(item)}
>
{item as unknown as ReactNode}
</MenuItem>
))}
</Stack>
</MenuList>
</Portal>
</Menu>
)
}

View File

@ -0,0 +1,64 @@
import {
Popover,
Tooltip,
chakra,
PopoverTrigger,
PopoverContent,
Flex,
} from '@chakra-ui/react'
import React from 'react'
import { EmojiOrImageIcon } from './EmojiOrImageIcon'
import { ImageUploadContent } from './ImageUploadContent'
type Props = {
uploadFilePath: string
icon?: string | null
onChangeIcon: (icon: string) => void
boxSize?: string
}
export const EditableEmojiOrImageIcon = ({
uploadFilePath,
icon,
onChangeIcon,
boxSize,
}: Props) => {
return (
<Popover isLazy>
{({ onClose }) => (
<>
<Tooltip label="Change icon">
<Flex
cursor="pointer"
p="2"
rounded="md"
_hover={{ bgColor: 'gray.100' }}
transition="background-color 0.2s"
data-testid="editable-icon"
>
<PopoverTrigger>
<chakra.span>
<EmojiOrImageIcon
icon={icon}
emojiFontSize="2xl"
boxSize={boxSize}
/>
</chakra.span>
</PopoverTrigger>
</Flex>
</Tooltip>
<PopoverContent p="2">
<ImageUploadContent
filePath={uploadFilePath}
defaultUrl={icon ?? ''}
onSubmit={onChangeIcon}
isGiphyEnabled={false}
isEmojiEnabled={true}
onClose={onClose}
/>
</PopoverContent>
</>
)}
</Popover>
)
}

View File

@ -0,0 +1,39 @@
import { ToolIcon } from '@/components/icons'
import React from 'react'
import { chakra, IconProps, Image } from '@chakra-ui/react'
type Props = {
icon?: string | null
emojiFontSize?: string
boxSize?: string
defaultIcon?: (props: IconProps) => JSX.Element
}
export const EmojiOrImageIcon = ({
icon,
boxSize = '25px',
emojiFontSize,
defaultIcon = ToolIcon,
}: Props) => {
return (
<>
{icon ? (
icon.startsWith('http') ? (
<Image
src={icon}
boxSize={boxSize}
objectFit={icon.endsWith('.svg') ? undefined : 'cover'}
alt="typebot icon"
rounded="10%"
/>
) : (
<chakra.span role="img" fontSize={emojiFontSize}>
{icon}
</chakra.span>
)
) : (
defaultIcon({ boxSize })
)}
</>
)
}

View File

@ -0,0 +1,24 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const GoogleLogo = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...props}>
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</Icon>
)

View File

@ -0,0 +1,23 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const GiphyLogo = (props: IconProps) => (
<Icon viewBox="0 0 163.79999999999998 35" {...props}>
<g fill="none" fillRule="evenodd">
<path d="M4 4h20v27H4z" fill="#000" />
<g fillRule="nonzero">
<path d="M0 3h4v29H0z" fill="#04ff8e" />
<path d="M24 11h4v21h-4z" fill="#8e2eff" />
<path d="M0 31h28v4H0z" fill="#00c5ff" />
<path d="M0 0h16v4H0z" fill="#fff152" />
<path d="M24 8V4h-4V0h-4v12h12V8" fill="#ff5b5b" />
<path d="M24 16v-4h4" fill="#551c99" />
</g>
<path d="M16 0v4h-4" fill="#999131" />
<path
d="M59.1 12c-2-1.9-4.4-2.4-6.2-2.4-4.4 0-7.3 2.6-7.3 8 0 3.5 1.8 7.8 7.3 7.8 1.4 0 3.7-.3 5.2-1.4v-3.5h-6.9v-6h13.3v12.1c-1.7 3.5-6.4 5.3-11.7 5.3-10.7 0-14.8-7.2-14.8-14.3S42.7 3.2 52.9 3.2c3.8 0 7.1.8 10.7 4.4zm9.1 19.2V4h7.6v27.2zm20.1-7.4v7.3h-7.7V4h13.2c7.3 0 10.9 4.6 10.9 9.9 0 5.6-3.6 9.9-10.9 9.9zm0-6.5h5.5c2.1 0 3.2-1.6 3.2-3.3 0-1.8-1.1-3.4-3.2-3.4h-5.5zM125 31.2V20.9h-9.8v10.3h-7.7V4h7.7v10.3h9.8V4h7.6v27.2zm24.2-17.9l5.9-9.3h8.7v.3l-10.8 16v10.8h-7.7V20.3L135 4.3V4h8.7z"
fill="#000"
fillRule="nonzero"
/>
</g>
</Icon>
)

View File

@ -0,0 +1,53 @@
import { Flex, Stack, Text } from '@chakra-ui/react'
import { GiphyFetch } from '@giphy/js-fetch-api'
import { Grid } from '@giphy/react-components'
import { GiphyLogo } from './GiphyLogo'
import React, { useState } from 'react'
import { env, isEmpty } from 'utils'
import { Input } from '../inputs'
type GiphySearchFormProps = {
onSubmit: (url: string) => void
}
const giphyFetch = new GiphyFetch(env('GIPHY_API_KEY') as string)
export const GiphySearchForm = ({ onSubmit }: GiphySearchFormProps) => {
const [inputValue, setInputValue] = useState('')
const fetchGifs = (offset: number) =>
giphyFetch.search(inputValue, { offset, limit: 10 })
const fetchGifsTrending = (offset: number) =>
giphyFetch.trending({ offset, limit: 10 })
return isEmpty(env('GIPHY_API_KEY')) ? (
<Text>NEXT_PUBLIC_GIPHY_API_KEY is missing in environment</Text>
) : (
<Stack>
<Flex align="center">
<Input
flex="1"
autoFocus
placeholder="Search..."
onChange={setInputValue}
withVariableButton={false}
/>
<GiphyLogo w="100px" />
</Flex>
<Flex overflowY="scroll" maxH="400px">
<Grid
key={inputValue}
onGifClick={(gif, e) => {
e.preventDefault()
onSubmit(gif.images.downsized.url)
}}
fetchGifs={inputValue === '' ? fetchGifsTrending : fetchGifs}
width={475}
columns={3}
className="my-4"
/>
</Flex>
</Stack>
)
}

View File

@ -0,0 +1,149 @@
import { useState } from 'react'
import { Button, Flex, HStack, Stack } from '@chakra-ui/react'
import { UploadButton } from './UploadButton'
import { GiphySearchForm } from './GiphySearchForm'
import { Input } from '../inputs/Input'
import { EmojiSearchableList } from './emoji/EmojiSearchableList'
type Props = {
filePath: string
includeFileName?: boolean
defaultUrl?: string
isEmojiEnabled?: boolean
isGiphyEnabled?: boolean
onSubmit: (url: string) => void
onClose?: () => void
}
export const ImageUploadContent = ({
filePath,
includeFileName,
defaultUrl,
onSubmit,
isEmojiEnabled = false,
isGiphyEnabled = true,
onClose,
}: Props) => {
const [currentTab, setCurrentTab] = useState<
'link' | 'upload' | 'giphy' | 'emoji'
>(isEmojiEnabled ? 'emoji' : 'upload')
const handleSubmit = (url: string) => {
onSubmit(url)
onClose && onClose()
}
return (
<Stack>
<HStack>
{isEmojiEnabled && (
<Button
variant={currentTab === 'emoji' ? 'solid' : 'ghost'}
onClick={() => setCurrentTab('emoji')}
size="sm"
>
Emoji
</Button>
)}
<Button
variant={currentTab === 'upload' ? 'solid' : 'ghost'}
onClick={() => setCurrentTab('upload')}
size="sm"
>
Upload
</Button>
<Button
variant={currentTab === 'link' ? 'solid' : 'ghost'}
onClick={() => setCurrentTab('link')}
size="sm"
>
Embed link
</Button>
{isGiphyEnabled && (
<Button
variant={currentTab === 'giphy' ? 'solid' : 'ghost'}
onClick={() => setCurrentTab('giphy')}
size="sm"
>
Giphy
</Button>
)}
</HStack>
<BodyContent
filePath={filePath}
includeFileName={includeFileName}
tab={currentTab}
onSubmit={handleSubmit}
defaultUrl={defaultUrl}
/>
</Stack>
)
}
const BodyContent = ({
includeFileName,
filePath,
tab,
defaultUrl,
onSubmit,
}: {
includeFileName?: boolean
filePath: string
tab: 'upload' | 'link' | 'giphy' | 'emoji'
defaultUrl?: string
onSubmit: (url: string) => void
}) => {
switch (tab) {
case 'upload':
return (
<UploadFileContent
filePath={filePath}
includeFileName={includeFileName}
onNewUrl={onSubmit}
/>
)
case 'link':
return <EmbedLinkContent defaultUrl={defaultUrl} onNewUrl={onSubmit} />
case 'giphy':
return <GiphyContent onNewUrl={onSubmit} />
case 'emoji':
return <EmojiSearchableList onEmojiSelected={onSubmit} />
}
}
type ContentProps = { onNewUrl: (url: string) => void }
const UploadFileContent = ({
filePath,
includeFileName,
onNewUrl,
}: ContentProps & { filePath: string; includeFileName?: boolean }) => (
<Flex justify="center" py="2">
<UploadButton
filePath={filePath}
onFileUploaded={onNewUrl}
includeFileName={includeFileName}
colorScheme="blue"
>
Choose an image
</UploadButton>
</Flex>
)
const EmbedLinkContent = ({
defaultUrl,
onNewUrl,
}: ContentProps & { defaultUrl?: string }) => (
<Stack py="2">
<Input
placeholder={'Paste the image link...'}
onChange={onNewUrl}
defaultValue={defaultUrl ?? ''}
/>
</Stack>
)
const GiphyContent = ({ onNewUrl }: ContentProps) => (
<GiphySearchForm onSubmit={onNewUrl} />
)

View File

@ -0,0 +1,58 @@
import { compressFile } from '@/utils/helpers'
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
import { ChangeEvent, useState } from 'react'
import { uploadFiles } from 'utils'
type UploadButtonProps = {
filePath: string
includeFileName?: boolean
onFileUploaded: (url: string) => void
} & ButtonProps
export const UploadButton = ({
filePath,
includeFileName,
onFileUploaded,
...props
}: UploadButtonProps) => {
const [isUploading, setIsUploading] = useState(false)
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target?.files) return
setIsUploading(true)
const file = e.target.files[0]
const urls = await uploadFiles({
files: [
{
file: await compressFile(file),
path: `public/${filePath}${includeFileName ? `/${file.name}` : ''}`,
},
],
})
if (urls.length && urls[0]) onFileUploaded(urls[0])
setIsUploading(false)
}
return (
<>
<chakra.input
data-testid="file-upload-input"
type="file"
id="file-input"
display="none"
onChange={handleInputChange}
accept=".jpg, .jpeg, .png, .svg, .gif"
/>
<Button
as="label"
size="sm"
htmlFor="file-input"
cursor="pointer"
isLoading={isUploading}
{...props}
>
{props.children}
</Button>
</>
)
}

View File

@ -0,0 +1,199 @@
import emojis from './emojiList.json'
import emojiTagsData from 'emojilib'
import {
Stack,
SimpleGrid,
GridItem,
Button,
Input as ClassicInput,
Text,
} from '@chakra-ui/react'
import { useState, ChangeEvent, useEffect, useRef } from 'react'
const emojiTags = emojiTagsData as Record<string, string[]>
const people = emojis['Smileys & Emotion'].concat(emojis['People & Body'])
const nature = emojis['Animals & Nature']
const food = emojis['Food & Drink']
const activities = emojis['Activities']
const travel = emojis['Travel & Places']
const objects = emojis['Objects']
const symbols = emojis['Symbols']
const flags = emojis['Flags']
export const EmojiSearchableList = ({
onEmojiSelected,
}: {
onEmojiSelected: (emoji: string) => void
}) => {
const scrollContainer = useRef<HTMLDivElement>(null)
const bottomElement = useRef<HTMLDivElement>(null)
const [isSearching, setIsSearching] = useState(false)
const [filteredPeople, setFilteredPeople] = useState(people)
const [filteredAnimals, setFilteredAnimals] = useState(nature)
const [filteredFood, setFilteredFood] = useState(food)
const [filteredTravel, setFilteredTravel] = useState(travel)
const [filteredActivities, setFilteredActivities] = useState(activities)
const [filteredObjects, setFilteredObjects] = useState(objects)
const [filteredSymbols, setFilteredSymbols] = useState(symbols)
const [filteredFlags, setFilteredFlags] = useState(flags)
const [totalDisplayedCategories, setTotalDisplayedCategories] = useState(1)
useEffect(() => {
if (!bottomElement.current) return
const observer = new IntersectionObserver(handleObserver, {
root: scrollContainer.current,
})
if (bottomElement.current) observer.observe(bottomElement.current)
return () => {
observer.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bottomElement.current])
const handleObserver = (entities: IntersectionObserverEntry[]) => {
const target = entities[0]
if (target.isIntersecting) setTotalDisplayedCategories((c) => c + 1)
}
const handleSearchChange = async (e: ChangeEvent<HTMLInputElement>) => {
const searchValue = e.target.value
if (searchValue.length <= 2 && isSearching) return resetEmojiList()
setIsSearching(true)
setTotalDisplayedCategories(8)
const byTag = (emoji: string) =>
emojiTags[emoji].find((tag) => tag.includes(searchValue))
setFilteredPeople(people.filter(byTag))
setFilteredAnimals(nature.filter(byTag))
setFilteredFood(food.filter(byTag))
setFilteredTravel(travel.filter(byTag))
setFilteredActivities(activities.filter(byTag))
setFilteredObjects(objects.filter(byTag))
setFilteredSymbols(symbols.filter(byTag))
setFilteredFlags(flags.filter(byTag))
}
const resetEmojiList = () => {
setTotalDisplayedCategories(1)
setIsSearching(false)
setFilteredPeople(people)
setFilteredAnimals(nature)
setFilteredFood(food)
setFilteredTravel(travel)
setFilteredActivities(activities)
setFilteredObjects(objects)
setFilteredSymbols(symbols)
setFilteredFlags(flags)
}
return (
<Stack>
<ClassicInput placeholder="Search..." onChange={handleSearchChange} />
<Stack ref={scrollContainer} overflow="scroll" maxH="350px" spacing={4}>
{filteredPeople.length > 0 && (
<Stack>
<Text fontSize="sm" pl="2">
People
</Text>
<EmojiGrid emojis={filteredPeople} onEmojiClick={onEmojiSelected} />
</Stack>
)}
{filteredAnimals.length > 0 && totalDisplayedCategories >= 2 && (
<Stack>
<Text fontSize="sm" pl="2">
Animals & Nature
</Text>
<EmojiGrid
emojis={filteredAnimals}
onEmojiClick={onEmojiSelected}
/>
</Stack>
)}
{filteredFood.length > 0 && totalDisplayedCategories >= 3 && (
<Stack>
<Text fontSize="sm" pl="2">
Food & Drink
</Text>
<EmojiGrid emojis={filteredFood} onEmojiClick={onEmojiSelected} />
</Stack>
)}
{filteredTravel.length > 0 && totalDisplayedCategories >= 4 && (
<Stack>
<Text fontSize="sm" pl="2">
Travel & Places
</Text>
<EmojiGrid emojis={filteredTravel} onEmojiClick={onEmojiSelected} />
</Stack>
)}
{filteredActivities.length > 0 && totalDisplayedCategories >= 5 && (
<Stack>
<Text fontSize="sm" pl="2">
Activities
</Text>
<EmojiGrid
emojis={filteredActivities}
onEmojiClick={onEmojiSelected}
/>
</Stack>
)}
{filteredObjects.length > 0 && totalDisplayedCategories >= 6 && (
<Stack>
<Text fontSize="sm" pl="2">
Objects
</Text>
<EmojiGrid
emojis={filteredObjects}
onEmojiClick={onEmojiSelected}
/>
</Stack>
)}
{filteredSymbols.length > 0 && totalDisplayedCategories >= 7 && (
<Stack>
<Text fontSize="sm" pl="2">
Symbols
</Text>
<EmojiGrid
emojis={filteredSymbols}
onEmojiClick={onEmojiSelected}
/>
</Stack>
)}
{filteredFlags.length > 0 && totalDisplayedCategories >= 8 && (
<Stack>
<Text fontSize="sm" pl="2">
Flags
</Text>
<EmojiGrid emojis={filteredFlags} onEmojiClick={onEmojiSelected} />
</Stack>
)}
<div ref={bottomElement} />
</Stack>
</Stack>
)
}
const EmojiGrid = ({
emojis,
onEmojiClick,
}: {
emojis: string[]
onEmojiClick: (emoji: string) => void
}) => {
const handleClick = (emoji: string) => () => onEmojiClick(emoji)
return (
<SimpleGrid spacing={0} columns={7}>
{emojis.map((emoji) => (
<GridItem
as={Button}
onClick={handleClick(emoji)}
variant="ghost"
size="sm"
fontSize="xl"
key={emoji}
>
{emoji}
</GridItem>
))}
</SimpleGrid>
)
}

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1,16 @@
import { Tooltip, chakra } from '@chakra-ui/react'
import { HelpCircleIcon } from './icons'
type Props = {
children: React.ReactNode
}
export const MoreInfoTooltip = ({ children }: Props) => {
return (
<Tooltip label={children} hasArrow rounded="md" p="3" placement="top">
<chakra.span cursor="pointer">
<HelpCircleIcon />
</chakra.span>
</Tooltip>
)
}

View File

@ -0,0 +1,225 @@
import {
useDisclosure,
useOutsideClick,
Flex,
Popover,
PopoverTrigger,
Input,
PopoverContent,
Button,
InputProps,
HStack,
} from '@chakra-ui/react'
import { Variable } from 'models'
import { useState, useRef, useEffect, ChangeEvent } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env, isDefined } from 'utils'
import { VariablesButton } from '@/features/variables'
type Props = {
selectedItem?: string
items: string[]
debounceTimeout?: number
withVariableButton?: boolean
onValueChange?: (value: string) => void
} & InputProps
export const SearchableDropdown = ({
selectedItem,
items,
withVariableButton = false,
debounceTimeout = 1000,
onValueChange,
...inputProps
}: Props) => {
const [carretPosition, setCarretPosition] = useState<number>(0)
const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(selectedItem ?? '')
const debounced = useDebouncedCallback(
// eslint-disable-next-line @typescript-eslint/no-empty-function
onValueChange ? onValueChange : () => {},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
const [filteredItems, setFilteredItems] = useState([
...items
.filter((item) =>
item.toLowerCase().includes((selectedItem ?? '').toLowerCase())
)
.slice(0, 50),
])
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
number | undefined
>()
const dropdownRef = useRef(null)
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
const inputRef = useRef<HTMLInputElement>(null)
useEffect(
() => () => {
debounced.flush()
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
useEffect(() => {
if (filteredItems.length > 0) return
setFilteredItems([
...items
.filter((item) =>
item.toLowerCase().includes((selectedItem ?? '').toLowerCase())
)
.slice(0, 50),
])
if (inputRef.current === document.activeElement) onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])
useOutsideClick({
ref: dropdownRef,
handler: onClose,
})
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!isOpen) onOpen()
setInputValue(e.target.value)
debounced(e.target.value)
if (e.target.value === '') {
setFilteredItems([...items.slice(0, 50)])
return
}
setFilteredItems([
...items
.filter((item) =>
item.toLowerCase().includes((inputValue ?? '').toLowerCase())
)
.slice(0, 50),
])
}
const handleItemClick = (item: string) => () => {
setInputValue(item)
debounced(item)
setKeyboardFocusIndex(undefined)
onClose()
}
const handleVariableSelected = (variable?: Variable) => {
if (!inputRef.current || !variable) return
const cursorPosition = carretPosition
const textBeforeCursorPosition = inputRef.current.value.substring(
0,
cursorPosition
)
const textAfterCursorPosition = inputRef.current.value.substring(
cursorPosition,
inputRef.current.value.length
)
const newValue =
textBeforeCursorPosition +
`{{${variable.name}}}` +
textAfterCursorPosition
setInputValue(newValue)
debounced(newValue)
inputRef.current.focus()
setTimeout(() => {
if (!inputRef.current) return
inputRef.current.selectionStart = inputRef.current.selectionEnd =
carretPosition + `{{${variable.name}}}`.length
setCarretPosition(inputRef.current.selectionStart)
}, 100)
}
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (inputRef.current?.selectionStart)
setCarretPosition(inputRef.current.selectionStart)
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
handleItemClick(filteredItems[keyboardFocusIndex])()
return setKeyboardFocusIndex(undefined)
}
if (e.key === 'ArrowDown') {
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
if (keyboardFocusIndex === filteredItems.length - 1) return
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
}
if (e.key === 'ArrowUp') {
if (keyboardFocusIndex === undefined) return
if (keyboardFocusIndex === 0) return setKeyboardFocusIndex(undefined)
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
setKeyboardFocusIndex(keyboardFocusIndex - 1)
}
}
return (
<Flex ref={dropdownRef} w="full">
<Popover
isOpen={isOpen}
initialFocusRef={inputRef}
matchWidth
offset={[0, 0]}
isLazy
>
<PopoverTrigger>
<HStack spacing={0} align={'flex-end'} w="full">
<Input
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onClick={onOpen}
type="text"
onKeyUp={handleKeyUp}
{...inputProps}
/>
{withVariableButton && (
<VariablesButton
onSelectVariable={handleVariableSelected}
onClick={onClose}
/>
)}
</HStack>
</PopoverTrigger>
<PopoverContent
maxH="35vh"
overflowY="scroll"
role="menu"
w="inherit"
shadow="lg"
>
{filteredItems.length > 0 && (
<>
{filteredItems.map((item, idx) => {
return (
<Button
ref={(el) => (itemsRef.current[idx] = el)}
minH="40px"
key={idx}
onClick={handleItemClick(item)}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
role="menuitem"
variant="ghost"
bgColor={
keyboardFocusIndex === idx ? 'gray.200' : 'transparent'
}
justifyContent="flex-start"
>
{item}
</Button>
)
})}
</>
)}
</PopoverContent>
</Popover>
</Flex>
)
}

View File

@ -0,0 +1,37 @@
import Head from 'next/head'
export const Seo = ({
title,
currentUrl = 'https://app.typebot.io',
description = 'Create and publish conversational forms that collect 4 times more answers and feel native to your product',
imagePreviewUrl = 'https://app.typebot.io/site-preview.png',
}: {
title: string
description?: string
currentUrl?: string
imagePreviewUrl?: string
}) => {
const formattedTitle = `${title} | Typebot`
return (
<Head>
<title>{formattedTitle}</title>
<meta name="title" content={title} />
<meta property="og:title" content={title} />
<meta property="twitter:title" content={title} />
<meta property="twitter:url" content={currentUrl} />
<meta property="og:url" content={currentUrl} />
<meta name="description" content={description} />
<meta property="twitter:description" content={description} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imagePreviewUrl} />
<meta property="twitter:image" content={imagePreviewUrl} />
<meta property="og:type" content="website" />
<meta property="twitter:card" content="summary_large_image" />
</Head>
)
}

View File

@ -0,0 +1,43 @@
import { useTypebot } from '@/features/editor'
import { useUser } from '@/features/account'
import { useWorkspace } from '@/features/workspace'
import React, { useEffect, useState } from 'react'
import { initBubble } from 'typebot-js'
import { isCloudProdInstance } from '@/utils/helpers'
import { planToReadable } from '@/features/billing'
export const SupportBubble = () => {
const { typebot } = useTypebot()
const { user } = useUser()
const { workspace } = useWorkspace()
const [localTypebotId, setLocalTypebotId] = useState(typebot?.id)
const [localUserId, setLocalUserId] = useState(user?.id)
useEffect(() => {
if (
isCloudProdInstance() &&
(localTypebotId !== typebot?.id || localUserId !== user?.id)
) {
setLocalTypebotId(typebot?.id)
setLocalUserId(user?.id)
initBubble({
url: `https://viewer.typebot.io/typebot-support`,
backgroundColor: '#ffffff',
button: {
color: '#0042DA',
},
hiddenVariables: {
'User ID': user?.id,
'First name': user?.name?.split(' ')[0] ?? undefined,
Email: user?.email ?? undefined,
'Typebot ID': typebot?.id,
'Avatar URL': user?.image ?? undefined,
Plan: planToReadable(workspace?.plan),
},
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, typebot])
return <></>
}

View File

@ -0,0 +1,44 @@
import {
FormControl,
FormLabel,
HStack,
Switch,
SwitchProps,
} from '@chakra-ui/react'
import React, { useState } from 'react'
import { MoreInfoTooltip } from './MoreInfoTooltip'
type SwitchWithLabelProps = {
label: string
initialValue: boolean
moreInfoContent?: string
onCheckChange: (isChecked: boolean) => void
} & SwitchProps
export const SwitchWithLabel = ({
label,
initialValue,
moreInfoContent,
onCheckChange,
...switchProps
}: SwitchWithLabelProps) => {
const [isChecked, setIsChecked] = useState(initialValue)
const handleChange = () => {
setIsChecked(!isChecked)
onCheckChange(!isChecked)
}
return (
<FormControl as={HStack} justifyContent="space-between">
<FormLabel mb="0">
{label}
{moreInfoContent && (
<>
&nbsp;<MoreInfoTooltip>{moreInfoContent}</MoreInfoTooltip>
</>
)}
</FormLabel>
<Switch isChecked={isChecked} onChange={handleChange} {...switchProps} />
</FormControl>
)
}

View File

@ -0,0 +1,107 @@
import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { TrashIcon, PlusIcon } from '@/components/icons'
import cuid from 'cuid'
import React, { useState } from 'react'
type ItemWithId<T> = T & { id: string }
export type TableListItemProps<T> = {
item: T
debounceTimeout?: number
onItemChange: (item: T) => void
}
type Props<T> = {
initialItems: ItemWithId<T>[]
addLabel?: string
debounceTimeout?: number
onItemsChange: (items: ItemWithId<T>[]) => void
Item: (props: TableListItemProps<T>) => JSX.Element
ComponentBetweenItems?: (props: unknown) => JSX.Element
}
export const TableList = <T,>({
initialItems,
onItemsChange,
addLabel = 'Add',
debounceTimeout,
Item,
ComponentBetweenItems,
}: Props<T>) => {
const [items, setItems] = useState(initialItems)
const [showDeleteIndex, setShowDeleteIndex] = useState<number | null>(null)
const createItem = () => {
const id = cuid()
const newItem = { id } as ItemWithId<T>
setItems([...items, newItem])
onItemsChange([...items, newItem])
}
const updateItem = (itemIndex: number, updates: Partial<T>) => {
const newItems = items.map((item, idx) =>
idx === itemIndex ? { ...item, ...updates } : item
)
setItems(newItems)
onItemsChange(newItems)
}
const deleteItem = (itemIndex: number) => () => {
const newItems = [...items]
newItems.splice(itemIndex, 1)
setItems([...newItems])
onItemsChange([...newItems])
}
const handleMouseEnter = (itemIndex: number) => () =>
setShowDeleteIndex(itemIndex)
const handleCellChange = (itemIndex: number) => (item: T) =>
updateItem(itemIndex, item)
const handleMouseLeave = () => setShowDeleteIndex(null)
return (
<Stack spacing="4">
{items.map((item, itemIndex) => (
<Box key={item.id}>
{itemIndex !== 0 && ComponentBetweenItems && (
<ComponentBetweenItems />
)}
<Flex
pos="relative"
onMouseEnter={handleMouseEnter(itemIndex)}
onMouseLeave={handleMouseLeave}
mt={itemIndex !== 0 && ComponentBetweenItems ? 4 : 0}
>
<Item
item={item}
onItemChange={handleCellChange(itemIndex)}
debounceTimeout={debounceTimeout}
/>
<Fade in={showDeleteIndex === itemIndex}>
<IconButton
icon={<TrashIcon />}
aria-label="Remove cell"
onClick={deleteItem(itemIndex)}
pos="absolute"
left="-15px"
top="-15px"
size="sm"
shadow="md"
/>
</Fade>
</Flex>
</Box>
))}
<Button
leftIcon={<PlusIcon />}
onClick={createItem}
flexShrink={0}
colorScheme="blue"
>
{addLabel}
</Button>
</Stack>
)
}

View File

@ -0,0 +1,37 @@
import Link, { LinkProps } from 'next/link'
import React from 'react'
import { chakra, HStack, TextProps } from '@chakra-ui/react'
import { ExternalLinkIcon } from '@/components/icons'
type TextLinkProps = LinkProps & TextProps & { isExternal?: boolean }
export const TextLink = ({
children,
href,
shallow,
replace,
scroll,
prefetch,
isExternal,
...textProps
}: TextLinkProps) => (
<Link
href={href}
shallow={shallow}
replace={replace}
scroll={scroll}
prefetch={prefetch}
target={isExternal ? '_blank' : undefined}
>
<chakra.span textDecor="underline" display="inline-block" {...textProps}>
{isExternal ? (
<HStack spacing={1}>
<chakra.span>{children}</chakra.span>
<ExternalLinkIcon />
</HStack>
) : (
children
)}
</chakra.span>
</Link>
)

View File

@ -0,0 +1,45 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const TypebotLogo = ({
isDark,
...props
}: { isDark?: boolean } & IconProps) => (
<Icon w="50px" h="50px" viewBox="0 0 800 800" {...props}>
<rect
width="800"
height="800"
rx="80"
fill={isDark ? 'white' : '#0042DA'}
/>
<rect
x="650"
y="293"
width="85.4704"
height="384.617"
rx="20"
transform="rotate(90 650 293)"
fill="#FF8E20"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M192.735 378.47C216.337 378.47 235.47 359.337 235.47 335.735C235.47 312.133 216.337 293 192.735 293C169.133 293 150 312.133 150 335.735C150 359.337 169.133 378.47 192.735 378.47Z"
fill="#FF8E20"
/>
<rect
x="150"
y="506.677"
width="85.4704"
height="384.617"
rx="20"
transform="rotate(-90 150 506.677)"
fill={isDark ? '#0042DA' : 'white'}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M607.265 421.206C583.663 421.206 564.53 440.34 564.53 463.942C564.53 487.544 583.663 506.677 607.265 506.677C630.867 506.677 650 487.544 650 463.942C650 440.34 630.867 421.206 607.265 421.206Z"
fill={isDark ? '#0042DA' : 'white'}
/>
</Icon>
)

View File

@ -0,0 +1,47 @@
import {
Alert,
AlertIcon,
AlertProps,
Button,
HStack,
Text,
useDisclosure,
} from '@chakra-ui/react'
import React from 'react'
import { ChangePlanModal, LimitReached } from '@/features/billing'
export const UnlockPlanAlertInfo = ({
contentLabel,
buttonLabel = 'More info',
type,
...props
}: {
contentLabel: React.ReactNode
buttonLabel?: string
type?: LimitReached
} & AlertProps) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<Alert
status="info"
rounded="md"
justifyContent="space-between"
flexShrink={0}
{...props}
>
<HStack>
<AlertIcon />
<Text>{contentLabel}</Text>
</HStack>
<Button
colorScheme={props.status === 'warning' ? 'orange' : 'blue'}
onClick={onOpen}
flexShrink={0}
ml="2"
>
{buttonLabel}
</Button>
<ChangePlanModal isOpen={isOpen} onClose={onClose} type={type} />
</Alert>
)
}

View File

@ -0,0 +1,264 @@
import {
useDisclosure,
useOutsideClick,
Flex,
Popover,
PopoverTrigger,
Input,
PopoverContent,
Button,
InputProps,
IconButton,
HStack,
} from '@chakra-ui/react'
import { EditIcon, PlusIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import cuid from 'cuid'
import { Variable } from 'models'
import React, { useState, useRef, ChangeEvent, useEffect } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { byId, env, isDefined, isNotDefined } from 'utils'
type Props = {
initialVariableId?: string
debounceTimeout?: number
isDefaultOpen?: boolean
onSelectVariable: (
variable: Pick<Variable, 'id' | 'name'> | undefined
) => void
} & InputProps
export const VariableSearchInput = ({
initialVariableId,
onSelectVariable,
isDefaultOpen,
debounceTimeout = 1000,
...inputProps
}: Props) => {
const { onOpen, onClose, isOpen } = useDisclosure()
const { typebot, createVariable, deleteVariable, updateVariable } =
useTypebot()
const variables = typebot?.variables ?? []
const [inputValue, setInputValue] = useState(
variables.find(byId(initialVariableId))?.name ?? ''
)
const debounced = useDebouncedCallback(
(value) => {
const variable = variables.find((v) => v.name === value)
if (variable) onSelectVariable(variable)
},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
const [filteredItems, setFilteredItems] = useState<Variable[]>(
variables ?? []
)
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState<
number | undefined
>()
const dropdownRef = useRef(null)
const inputRef = useRef(null)
const createVariableItemRef = useRef<HTMLButtonElement | null>(null)
const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
useOutsideClick({
ref: dropdownRef,
handler: onClose,
})
useEffect(() => {
if (isDefaultOpen) onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(
() => () => {
debounced.flush()
},
[debounced]
)
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
debounced(e.target.value)
onOpen()
if (e.target.value === '') {
onSelectVariable(undefined)
setFilteredItems([...variables.slice(0, 50)])
return
}
setFilteredItems([
...variables
.filter((item) =>
item.name.toLowerCase().includes((e.target.value ?? '').toLowerCase())
)
.slice(0, 50),
])
}
const handleVariableNameClick = (variable: Variable) => () => {
setInputValue(variable.name)
onSelectVariable(variable)
setKeyboardFocusIndex(undefined)
onClose()
}
const handleCreateNewVariableClick = () => {
if (!inputValue || inputValue === '') return
const id = 'v' + cuid()
onSelectVariable({ id, name: inputValue })
createVariable({ id, name: inputValue })
onClose()
}
const handleDeleteVariableClick =
(variable: Variable) => (e: React.MouseEvent) => {
e.stopPropagation()
deleteVariable(variable.id)
setFilteredItems(filteredItems.filter((item) => item.id !== variable.id))
if (variable.name === inputValue) {
setInputValue('')
debounced('')
}
}
const handleRenameVariableClick =
(variable: Variable) => (e: React.MouseEvent) => {
e.stopPropagation()
const name = prompt('Rename variable', variable.name)
if (!name) return
updateVariable(variable.id, { name })
setFilteredItems(
filteredItems.map((item) =>
item.id === variable.id ? { ...item, name } : item
)
)
}
const isCreateVariableButtonDisplayed =
(inputValue?.length ?? 0) > 0 &&
isNotDefined(variables.find((v) => v.name === inputValue))
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && isDefined(keyboardFocusIndex)) {
if (keyboardFocusIndex === 0 && isCreateVariableButtonDisplayed)
handleCreateNewVariableClick()
else handleVariableNameClick(filteredItems[keyboardFocusIndex])()
return setKeyboardFocusIndex(undefined)
}
if (e.key === 'ArrowDown') {
if (keyboardFocusIndex === undefined) return setKeyboardFocusIndex(0)
if (keyboardFocusIndex >= filteredItems.length) return
itemsRef.current[keyboardFocusIndex + 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
return setKeyboardFocusIndex(keyboardFocusIndex + 1)
}
if (e.key === 'ArrowUp') {
if (keyboardFocusIndex === undefined) return
if (keyboardFocusIndex <= 0) return setKeyboardFocusIndex(undefined)
itemsRef.current[keyboardFocusIndex - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
return setKeyboardFocusIndex(keyboardFocusIndex - 1)
}
return setKeyboardFocusIndex(undefined)
}
return (
<Flex ref={dropdownRef} w="full">
<Popover
isOpen={isOpen}
initialFocusRef={inputRef}
matchWidth
isLazy
offset={[0, 2]}
>
<PopoverTrigger>
<Input
data-testid="variables-input"
ref={inputRef}
value={inputValue}
onChange={onInputChange}
onClick={onOpen}
onKeyUp={handleKeyUp}
placeholder={inputProps.placeholder ?? 'Select a variable'}
{...inputProps}
/>
</PopoverTrigger>
<PopoverContent
maxH="35vh"
overflowY="scroll"
role="menu"
w="inherit"
shadow="lg"
>
{isCreateVariableButtonDisplayed && (
<Button
ref={createVariableItemRef}
role="menuitem"
minH="40px"
onClick={handleCreateNewVariableClick}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="flex-start"
leftIcon={<PlusIcon />}
bgColor={keyboardFocusIndex === 0 ? 'gray.200' : 'transparent'}
>
Create "{inputValue}"
</Button>
)}
{filteredItems.length > 0 && (
<>
{filteredItems.map((item, idx) => {
const indexInList = isCreateVariableButtonDisplayed
? idx + 1
: idx
return (
<Button
ref={(el) => (itemsRef.current[idx] = el)}
role="menuitem"
minH="40px"
key={idx}
onClick={handleVariableNameClick(item)}
fontSize="16px"
fontWeight="normal"
rounded="none"
colorScheme="gray"
variant="ghost"
justifyContent="space-between"
bgColor={
keyboardFocusIndex === indexInList
? 'gray.200'
: 'transparent'
}
>
{item.name}
<HStack>
<IconButton
icon={<EditIcon />}
aria-label="Rename variable"
size="xs"
onClick={handleRenameVariableClick(item)}
/>
<IconButton
icon={<TrashIcon />}
aria-label="Remove variable"
size="xs"
onClick={handleDeleteVariableClick(item)}
/>
</HStack>
</Button>
)
})}
</>
)}
</PopoverContent>
</Popover>
</Flex>
)
}

View File

@ -0,0 +1,498 @@
import { IconProps, Icon } from '@chakra-ui/react'
const featherIconsBaseProps: IconProps = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: '2px',
strokeLinecap: 'round',
strokeLinejoin: 'round',
}
// 99% of these icons are from Feather icons (https://feathericons.com/)
export const SettingsIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</Icon>
)
export const LogOutIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</Icon>
)
export const ChevronLeftIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="15 18 9 12 15 6"></polyline>
</Icon>
)
export const PlusIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</Icon>
)
export const FolderIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</Icon>
)
export const MoreVerticalIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</Icon>
)
export const GlobeIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</Icon>
)
export const ToolIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
</Icon>
)
export const FolderPlusIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</Icon>
)
export const TextIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="4 7 4 4 20 4 20 7"></polyline>
<line x1="9" y1="20" x2="15" y2="20"></line>
<line x1="12" y1="4" x2="12" y2="20"></line>
</Icon>
)
export const ImageIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</Icon>
)
export const CalendarIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</Icon>
)
export const FlagIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>
<line x1="4" y1="22" x2="4" y2="15"></line>
</Icon>
)
export const BoldIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path>
</Icon>
)
export const ItalicIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="19" y1="4" x2="10" y2="4"></line>
<line x1="14" y1="20" x2="5" y2="20"></line>
<line x1="15" y1="4" x2="9" y2="20"></line>
</Icon>
)
export const UnderlineIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"></path>
<line x1="4" y1="21" x2="20" y2="21"></line>
</Icon>
)
export const LinkIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</Icon>
)
export const SaveIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</Icon>
)
export const CheckIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="20 6 9 17 4 12"></polyline>
</Icon>
)
export const ChatIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</Icon>
)
export const TrashIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</Icon>
)
export const LayoutIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</Icon>
)
export const CodeIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</Icon>
)
export const PencilIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M12 19l7-7 3 3-7 7-3-3z"></path>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
<path d="M2 2l7.586 7.586"></path>
<circle cx="11" cy="11" r="2"></circle>
</Icon>
)
export const EditIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</Icon>
)
export const UploadIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="16 16 12 12 8 16"></polyline>
<line x1="12" y1="12" x2="12" y2="21"></line>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
<polyline points="16 16 12 12 8 16"></polyline>
</Icon>
)
export const DownloadIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</Icon>
)
export const NumberIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
</Icon>
)
export const EmailIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="4"></circle>
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"></path>
</Icon>
)
export const PhoneIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
</Icon>
)
export const CheckSquareIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</Icon>
)
export const FilterIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</Icon>
)
export const UserIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</Icon>
)
export const ExpandIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</Icon>
)
export const ExternalLinkIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</Icon>
)
export const FilmIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
<line x1="7" y1="2" x2="7" y2="22"></line>
<line x1="17" y1="2" x2="17" y2="22"></line>
<line x1="2" y1="12" x2="22" y2="12"></line>
<line x1="2" y1="7" x2="7" y2="7"></line>
<line x1="2" y1="17" x2="7" y2="17"></line>
<line x1="17" y1="17" x2="22" y2="17"></line>
<line x1="17" y1="7" x2="22" y2="7"></line>
</Icon>
)
export const WebhookIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</Icon>
)
export const GripIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="9" r="1"></circle>
<circle cx="19" cy="9" r="1"></circle>
<circle cx="5" cy="9" r="1"></circle>
<circle cx="12" cy="15" r="1"></circle>
<circle cx="19" cy="15" r="1"></circle>
<circle cx="5" cy="15" r="1"></circle>
</Icon>
)
export const LockedIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</Icon>
)
export const UnlockedIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
</Icon>
)
export const UndoIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M3 7v6h6"></path>
<path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path>
</Icon>
)
export const RedoIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 7v6h-6"></path>
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"></path>
</Icon>
)
export const FileIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</Icon>
)
export const EyeIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</Icon>
)
export const SendEmailIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</Icon>
)
export const GithubIcon = (props: IconProps) => (
<Icon viewBox="0 0 512 512" {...props}>
<title>{'Logo Github'}</title>
<path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z" />
</Icon>
)
export const UsersIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</Icon>
)
export const AlignLeftTextIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="17" y1="10" x2="3" y2="10"></line>
<line x1="21" y1="6" x2="3" y2="6"></line>
<line x1="21" y1="14" x2="3" y2="14"></line>
<line x1="17" y1="18" x2="3" y2="18"></line>
</Icon>
)
export const BoxIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</Icon>
)
export const HelpCircleIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</Icon>
)
export const CopyIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</Icon>
)
export const TemplateIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</Icon>
)
export const MinusIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="5" y1="12" x2="19" y2="12"></line>
</Icon>
)
export const LaptopIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path
d="M3.2 14.2222V4C3.2 2.89543 4.09543 2 5.2 2H18.8C19.9046 2 20.8 2.89543 20.8 4V14.2222M3.2 14.2222H20.8M3.2 14.2222L1.71969 19.4556C1.35863 20.7321 2.31762 22 3.64418 22H20.3558C21.6824 22 22.6414 20.7321 22.2803 19.4556L20.8 14.2222"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M11 19L13 19"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Icon>
)
export const MouseIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path
d="M12 2V2C16.4183 2 20 5.58172 20 10V14C20 18.4183 16.4183 22 12 22V22C7.58172 22 4 18.4183 4 14V10C4 5.58172 7.58172 2 12 2V2ZM12 2V9"
stroke="currentColor"
strokeLinecap="round"
/>
</Icon>
)
export const HardDriveIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="22" y1="12" x2="2" y2="12"></line>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
<line x1="6" y1="16" x2="6.01" y2="16"></line>
<line x1="10" y1="16" x2="10.01" y2="16"></line>
</Icon>
)
export const CreditCardIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</Icon>
)
export const PlayIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</Icon>
)
export const StarIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</Icon>
)
export const BuoyIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="4.93" y1="4.93" x2="9.17" y2="9.17"></line>
<line x1="14.83" y1="14.83" x2="19.07" y2="19.07"></line>
<line x1="14.83" y1="9.17" x2="19.07" y2="4.93"></line>
<line x1="14.83" y1="9.17" x2="18.36" y2="5.64"></line>
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"></line>
</Icon>
)
export const EyeOffIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</Icon>
)
export const AlertIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</Icon>
)
export const CloudOffIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M22.61 16.95A5 5 0 0 0 18 10h-1.26a8 8 0 0 0-7.05-6M5 5a8 8 0 0 0 4 15h9a5 5 0 0 0 1.7-.3"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</Icon>
)

View File

@ -0,0 +1,7 @@
import { Input as ChakraInput } from '@chakra-ui/react'
import React from 'react'
import { TextBox, TextBoxProps } from './TextBox'
export const Input = (props: Omit<TextBoxProps, 'TextBox'>) => (
<TextBox TextBox={ChakraInput} {...props} />
)

View File

@ -0,0 +1,55 @@
import {
NumberInputProps,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
} from '@chakra-ui/react'
import { useEffect, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
export const SmartNumberInput = ({
value,
onValueChange,
debounceTimeout = 1000,
...props
}: {
value?: number
debounceTimeout?: number
onValueChange: (value?: number) => void
} & NumberInputProps) => {
const [currentValue, setCurrentValue] = useState(value?.toString() ?? '')
const debounced = useDebouncedCallback(
onValueChange,
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(
() => () => {
debounced.flush()
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const handleValueChange = (value: string) => {
setCurrentValue(value)
if (value.endsWith('.') || value.endsWith(',')) return
if (value === '') return debounced(undefined)
const newValue = parseFloat(value)
if (isNaN(newValue)) return
debounced(newValue)
}
return (
<NumberInput onChange={handleValueChange} value={currentValue} {...props}>
<NumberInputField placeholder={props.placeholder} />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
)
}

View File

@ -0,0 +1,134 @@
import {
ComponentWithAs,
FormControl,
FormLabel,
HStack,
InputProps,
TextareaProps,
} from '@chakra-ui/react'
import { Variable } from 'models'
import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce'
import { env } from 'utils'
import { VariablesButton } from '../../features/variables/components/VariablesButton'
import { MoreInfoTooltip } from '../MoreInfoTooltip'
export type TextBoxProps = {
onChange: (value: string) => void
TextBox:
| ComponentWithAs<'textarea', TextareaProps>
| ComponentWithAs<'input', InputProps>
withVariableButton?: boolean
debounceTimeout?: number
label?: string
moreInfoTooltip?: string
} & Omit<InputProps & TextareaProps, 'onChange'>
export const TextBox = ({
onChange,
TextBox,
withVariableButton = true,
debounceTimeout = 1000,
label,
moreInfoTooltip,
...props
}: TextBoxProps) => {
const textBoxRef = useRef<(HTMLInputElement & HTMLTextAreaElement) | null>(
null
)
const [carretPosition, setCarretPosition] = useState<number>(0)
const [value, setValue] = useState(props.defaultValue ?? '')
const [isTouched, setIsTouched] = useState(false)
const debounced = useDebouncedCallback(
(value) => {
onChange(value)
},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)
useEffect(() => {
if (props.defaultValue !== value && value === '' && !isTouched)
setValue(props.defaultValue ?? '')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.defaultValue])
useEffect(
() => () => {
debounced.flush()
},
[debounced]
)
const handleChange = (
e: ChangeEvent<HTMLInputElement & HTMLTextAreaElement>
) => {
setIsTouched(true)
setValue(e.target.value)
debounced(e.target.value)
}
const handleVariableSelected = (variable?: Variable) => {
if (!textBoxRef.current || !variable) return
setIsTouched(true)
const cursorPosition = carretPosition
const textBeforeCursorPosition = textBoxRef.current.value.substring(
0,
cursorPosition
)
const textAfterCursorPosition = textBoxRef.current.value.substring(
cursorPosition,
textBoxRef.current.value.length
)
const newValue =
textBeforeCursorPosition +
`{{${variable.name}}}` +
textAfterCursorPosition
setValue(newValue)
debounced(newValue)
textBoxRef.current.focus()
setTimeout(() => {
if (!textBoxRef.current) return
textBoxRef.current.selectionStart = textBoxRef.current.selectionEnd =
carretPosition + `{{${variable.name}}}`.length
setCarretPosition(textBoxRef.current.selectionStart)
}, 100)
}
const handleKeyUp = () => {
if (!textBoxRef.current?.selectionStart) return
setCarretPosition(textBoxRef.current.selectionStart)
}
const Input = (
<TextBox
ref={textBoxRef}
value={value}
onKeyUp={handleKeyUp}
onClick={handleKeyUp}
onChange={handleChange}
bgColor={'white'}
{...props}
/>
)
return (
<FormControl isRequired={props.isRequired}>
{label && (
<FormLabel>
{label}{' '}
{moreInfoTooltip && (
<MoreInfoTooltip>{moreInfoTooltip}</MoreInfoTooltip>
)}
</FormLabel>
)}
{withVariableButton ? (
<HStack spacing={0} align={'flex-end'}>
{Input}
<VariablesButton onSelectVariable={handleVariableSelected} />
</HStack>
) : (
Input
)}
</FormControl>
)
}

View File

@ -0,0 +1,7 @@
import { Textarea as ChakraTextarea } from '@chakra-ui/react'
import React from 'react'
import { TextBox, TextBoxProps } from './TextBox'
export const Textarea = (props: Omit<TextBoxProps, 'TextBox'>) => (
<TextBox TextBox={ChakraTextarea} {...props} />
)

View File

@ -0,0 +1,3 @@
export { Input } from './Input'
export { Textarea } from './Textarea'
export { SmartNumberInput } from './SmartNumberInput'

View File

@ -0,0 +1,121 @@
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/router'
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { isDefined, isNotDefined } from 'utils'
import { dequal } from 'dequal'
import { User } from 'db'
import { setUser as setSentryUser } from '@sentry/nextjs'
import { useToast } from '@/hooks/useToast'
import { updateUserQuery } from './queries/updateUserQuery'
const userContext = createContext<{
user?: User
isLoading: boolean
isSaving: boolean
hasUnsavedChanges: boolean
isOAuthProvider: boolean
currentWorkspaceId?: string
updateUser: (newUser: Partial<User>) => void
saveUser: (newUser?: Partial<User>) => Promise<void>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const UserProvider = ({ children }: { children: ReactNode }) => {
const router = useRouter()
const { data: session, status } = useSession()
const [user, setUser] = useState<User | undefined>()
const { showToast } = useToast()
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>()
const [isSaving, setIsSaving] = useState(false)
const isOAuthProvider = useMemo(
() => (session?.providerType as boolean | undefined) ?? false,
[session?.providerType]
)
const hasUnsavedChanges = useMemo(
() => !dequal(session?.user, user),
[session?.user, user]
)
useEffect(() => {
if (isDefined(user) || isNotDefined(session)) return
setCurrentWorkspaceId(
localStorage.getItem('currentWorkspaceId') ?? undefined
)
const parsedUser = session.user as User
setUser(parsedUser)
if (parsedUser?.id) setSentryUser({ id: parsedUser.id })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session])
useEffect(() => {
if (!router.isReady) return
if (status === 'loading') return
if (!user && status === 'unauthenticated' && !isSigningIn())
router.replace({
pathname: '/signin',
query:
router.pathname !== '/typebots'
? {
redirectPath: router.asPath,
}
: undefined,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, router])
const isSigningIn = () => ['/signin', '/register'].includes(router.pathname)
const updateUser = (newUser: Partial<User>) => {
if (isNotDefined(user)) return
setUser({ ...user, ...newUser })
}
const saveUser = async (newUser?: Partial<User>) => {
if (isNotDefined(user)) return
setIsSaving(true)
if (newUser) updateUser(newUser)
const { error } = await updateUserQuery(user.id, { ...user, ...newUser })
if (error) showToast({ title: error.name, description: error.message })
await refreshUser()
setIsSaving(false)
}
return (
<userContext.Provider
value={{
user,
isSaving,
isLoading: status === 'loading',
hasUnsavedChanges,
isOAuthProvider,
currentWorkspaceId,
updateUser,
saveUser,
}}
>
{children}
</userContext.Provider>
)
}
export const refreshUser = async () => {
await fetch('/api/auth/session?update')
reloadSession()
}
const reloadSession = () => {
const event = new Event('visibilitychange')
document.dispatchEvent(event)
}
export const useUser = () => useContext(userContext)

View File

@ -0,0 +1,49 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { userId } from 'utils/playwright/databaseSetup'
test.describe.configure({ mode: 'parallel' })
test('should display user info properly', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
const saveButton = page.locator('button:has-text("Save")')
await expect(saveButton).toBeHidden()
expect(
page.locator('input[type="email"]').getAttribute('disabled')
).toBeDefined()
await page.fill('#name', 'John Doe')
expect(saveButton).toBeVisible()
await page.setInputFiles('input[type="file"]', getTestAsset('avatar.jpg'))
await expect(page.locator('img >> nth=1')).toHaveAttribute(
'src',
new RegExp(
`${process.env.S3_ENDPOINT}${
process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''
}/${process.env.S3_BUCKET}/public/users/${userId}/avatar`,
'gm'
)
)
await page.click('text="Preferences"')
await expect(page.locator('text=Trackpad')).toBeVisible()
})
test('should be able to create and delete api tokens', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await expect(page.locator('text=Github')).toBeVisible()
await page.click('text="Create"')
await expect(page.locator('button >> text="Create token"')).toBeDisabled()
await page.fill('[placeholder="I.e. Zapier, Github, Make.com"]', 'CLI')
await expect(page.locator('button >> text="Create token"')).toBeEnabled()
await page.click('button >> text="Create token"')
await expect(page.locator('text=Please copy your token')).toBeVisible()
await expect(page.locator('button >> text="Copy"')).toBeVisible()
await page.click('button >> text="Done"')
await expect(page.locator('text=CLI')).toBeVisible()
await page.click('text="Delete" >> nth=2')
await expect(page.locator('strong >> text="Github"')).toBeVisible()
await page.click('button >> text="Delete" >> nth=-1')
await expect(page.locator('button >> text="Delete" >> nth=-1')).toBeEnabled()
await expect(page.locator('text="Github"')).toBeHidden()
})

View File

@ -0,0 +1,130 @@
import {
TableContainer,
Table,
Thead,
Tr,
Th,
Tbody,
Td,
Button,
Text,
Heading,
Checkbox,
Skeleton,
Stack,
Flex,
useDisclosure,
} from '@chakra-ui/react'
import { ConfirmModal } from '@/components/ConfirmModal'
import { useToast } from '@/hooks/useToast'
import { User } from 'db'
import React, { useState } from 'react'
import { byId, isDefined } from 'utils'
import { CreateTokenModal } from './CreateTokenModal'
import { useApiTokens } from '../../../hooks/useApiTokens'
import { ApiTokenFromServer } from '../../../types'
import { timeSince } from '@/utils/helpers'
import { deleteApiTokenQuery } from '../../../queries/deleteApiTokenQuery'
type Props = { user: User }
export const ApiTokensList = ({ user }: Props) => {
const { showToast } = useToast()
const { apiTokens, isLoading, mutate } = useApiTokens({
userId: user.id,
onError: (e) =>
showToast({ title: 'Failed to fetch tokens', description: e.message }),
})
const {
isOpen: isCreateOpen,
onOpen: onCreateOpen,
onClose: onCreateClose,
} = useDisclosure()
const [deletingId, setDeletingId] = useState<string>()
const refreshListWithNewToken = (token: ApiTokenFromServer) => {
if (!apiTokens) return
mutate({ apiTokens: [token, ...apiTokens] })
}
const deleteToken = async (tokenId?: string) => {
if (!apiTokens || !tokenId) return
const { error } = await deleteApiTokenQuery({ userId: user.id, tokenId })
if (!error) mutate({ apiTokens: apiTokens.filter((t) => t.id !== tokenId) })
}
return (
<Stack spacing={4}>
<Heading fontSize="2xl">API tokens</Heading>
<Text>
These tokens allow other apps to control your whole account and
typebots. Be careful!
</Text>
<Flex justifyContent="flex-end">
<Button onClick={onCreateOpen}>Create</Button>
<CreateTokenModal
userId={user.id}
isOpen={isCreateOpen}
onNewToken={refreshListWithNewToken}
onClose={onCreateClose}
/>
</Flex>
<TableContainer>
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th w="130px">Created</Th>
<Th w="0" />
</Tr>
</Thead>
<Tbody>
{apiTokens?.map((token) => (
<Tr key={token.id}>
<Td>{token.name}</Td>
<Td>{timeSince(token.createdAt)} ago</Td>
<Td>
<Button
size="xs"
colorScheme="red"
variant="outline"
onClick={() => setDeletingId(token.id)}
>
Delete
</Button>
</Td>
</Tr>
))}
{isLoading &&
Array.from({ length: 3 }).map((_, idx) => (
<Tr key={idx}>
<Td>
<Checkbox isDisabled />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<ConfirmModal
isOpen={isDefined(deletingId)}
onConfirm={() => deleteToken(deletingId)}
onClose={() => setDeletingId(undefined)}
message={
<Text>
The token <strong>{apiTokens?.find(byId(deletingId))?.name}</strong>{' '}
will be permanently deleted, are you sure you want to continue?
</Text>
}
confirmButtonLabel="Delete"
/>
</Stack>
)
}

View File

@ -0,0 +1,101 @@
import { CopyButton } from '@/components/CopyButton'
import { createApiTokenQuery } from '../../../queries/createApiTokenQuery'
import { ApiTokenFromServer } from '../../../types'
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Stack,
Input,
ModalFooter,
Button,
Text,
InputGroup,
InputRightElement,
} from '@chakra-ui/react'
import React, { FormEvent, useState } from 'react'
type Props = {
userId: string
isOpen: boolean
onNewToken: (token: ApiTokenFromServer) => void
onClose: () => void
}
export const CreateTokenModal = ({
userId,
isOpen,
onClose,
onNewToken,
}: Props) => {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [newTokenValue, setNewTokenValue] = useState<string>()
const createToken = async (e: FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
const { data } = await createApiTokenQuery(userId, { name })
if (data?.apiToken) {
setNewTokenValue(data.apiToken.token)
onNewToken(data.apiToken)
}
setIsSubmitting(false)
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{newTokenValue ? 'Token Created' : 'Create Token'}
</ModalHeader>
<ModalCloseButton />
{newTokenValue ? (
<ModalBody as={Stack} spacing="4">
<Text>
Please copy your token and store it in a safe place.{' '}
<strong>For security reasons we cannot show it again.</strong>
</Text>
<InputGroup size="md">
<Input readOnly pr="4.5rem" value={newTokenValue} />
<InputRightElement width="4.5rem">
<CopyButton h="1.75rem" size="sm" textToCopy={newTokenValue} />
</InputRightElement>
</InputGroup>
</ModalBody>
) : (
<ModalBody as="form" onSubmit={createToken}>
<Text mb="4">
Enter a unique name for your token to differentiate it from other
tokens.
</Text>
<Input
placeholder="I.e. Zapier, Github, Make.com"
onChange={(e) => setName(e.target.value)}
/>
</ModalBody>
)}
<ModalFooter>
{newTokenValue ? (
<Button onClick={onClose} colorScheme="blue">
Done
</Button>
) : (
<Button
colorScheme="blue"
isDisabled={name.length === 0}
isLoading={isSubmitting}
onClick={createToken}
>
Create token
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

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

View File

@ -0,0 +1,110 @@
import {
Stack,
HStack,
Avatar,
Button,
FormControl,
FormLabel,
Input,
Tooltip,
Flex,
Text,
} from '@chakra-ui/react'
import { UploadIcon } from '@/components/icons'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
import { ApiTokensList } from './ApiTokensList'
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
import { useUser } from '@/features/account'
export const MyAccountForm = () => {
const {
user,
updateUser,
saveUser,
hasUnsavedChanges,
isSaving,
isOAuthProvider,
} = useUser()
const [reloadParam, setReloadParam] = useState('')
const handleFileUploaded = async (url: string) => {
setReloadParam(Date.now().toString())
updateUser({ image: url })
}
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ name: e.target.value })
}
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ email: e.target.value })
}
return (
<Stack spacing="6" w="full" overflowY="scroll">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`users/${user?.id}/avatar`}
leftIcon={<UploadIcon />}
onFileUploaded={handleFileUploaded}
>
Change photo
</UploadButton>
<Text color="gray.500" fontSize="sm">
.jpg or.png, max 1MB
</Text>
</Stack>
</HStack>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
<Input id="name" value={user?.name ?? ''} onChange={handleNameChange} />
</FormControl>
{isDefined(user?.email) && (
<Tooltip
label="Updating email is not available."
placement="left"
hasArrow
>
<FormControl>
<FormLabel
htmlFor="email"
color={isOAuthProvider ? 'gray.500' : 'current'}
>
Email address
</FormLabel>
<Input
id="email"
type="email"
isDisabled
value={user?.email ?? ''}
onChange={handleEmailChange}
/>
</FormControl>
</Tooltip>
)}
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
{user && <ApiTokensList user={user} />}
</Stack>
)
}

View File

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

View File

@ -0,0 +1,30 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
import { ApiTokenFromServer } from '../types'
type ServerResponse = {
apiTokens: ApiTokenFromServer[]
}
export const useApiTokens = ({
userId,
onError,
}: {
userId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<ServerResponse, Error>(
userId ? `/api/users/${userId}/api-tokens` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
if (error) onError(error)
return {
apiTokens: data?.apiTokens,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,3 @@
export { UserProvider, useUser } from './UserProvider'
export type { ApiTokenFromServer } from './types'
export { MyAccountForm } from './components/MyAccountForm'

View File

@ -0,0 +1,14 @@
import { sendRequest } from 'utils'
import { ApiTokenFromServer } from '../types'
export const createApiTokenQuery = (
userId: string,
{ name }: { name: string }
) =>
sendRequest<{ apiToken: ApiTokenFromServer & { token: string } }>({
url: `/api/users/${userId}/api-tokens`,
method: 'POST',
body: {
name,
},
})

View File

@ -0,0 +1,14 @@
import { ApiToken } from 'db'
import { sendRequest } from 'utils'
export const deleteApiTokenQuery = ({
userId,
tokenId,
}: {
userId: string
tokenId: string
}) =>
sendRequest<{ apiToken: ApiToken }>({
url: `/api/users/${userId}/api-tokens/${tokenId}`,
method: 'DELETE',
})

View File

@ -0,0 +1,9 @@
import { User } from 'db'
import { sendRequest } from 'utils'
export const updateUserQuery = async (id: string, user: User) =>
sendRequest({
url: `/api/users/${id}`,
method: 'PUT',
body: user,
})

View File

@ -0,0 +1 @@
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }

View File

@ -0,0 +1,32 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import {
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { starterWorkspaceId } from 'utils/playwright/databaseSetup'
test('analytics are not available for non-pro workspaces', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/results/submissionHeader.json'),
{
id: typebotId,
workspaceId: starterWorkspaceId,
}
)
await injectFakeResults({ typebotId, count: 10 })
await page.goto(`/typebots/${typebotId}/results/analytics`)
const firstDropoffBox = page.locator('text="%" >> nth=0')
await firstDropoffBox.hover()
await expect(
page.locator('text="Unlock Drop-off rate by upgrading to Pro plan"')
).toBeVisible()
await firstDropoffBox.click()
await expect(
page.locator(
'text="You need to upgrade your plan in order to unlock in-depth analytics"'
)
).toBeVisible()
})

View File

@ -0,0 +1,63 @@
import { Flex, Spinner, useDisclosure } from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { useTypebot } from '@/features/editor'
import { Stats } from 'models'
import React from 'react'
import { useAnswersCount } from '../hooks/useAnswersCount'
import {
Graph,
GraphProvider,
GroupsCoordinatesProvider,
} from '@/features/graph'
import { ChangePlanModal, LimitReached } from '@/features/billing'
import { StatsCards } from './StatsCards'
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { typebot, publishedTypebot } = useTypebot()
const { showToast } = useToast()
const { answersCounts } = useAnswersCount({
typebotId: publishedTypebot && typebot?.id,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
return (
<Flex
w="full"
pos="relative"
bgColor="gray.50"
h="full"
justifyContent="center"
>
{publishedTypebot && answersCounts && stats ? (
<GraphProvider isReadOnly>
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
<Graph
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
answersCounts={[
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
...answersCounts?.slice(1),
]}
/>
</GroupsCoordinatesProvider>
</GraphProvider>
) : (
<Flex
justify="center"
align="center"
boxSize="full"
bgColor="rgba(255, 255, 255, 0.5)"
>
<Spinner color="gray" />
</Flex>
)}
<ChangePlanModal
onClose={onClose}
isOpen={isOpen}
type={LimitReached.ANALYTICS}
/>
<StatsCards stats={stats} pos="absolute" top={10} />
</Flex>
)
}

View File

@ -0,0 +1,46 @@
import {
GridProps,
SimpleGrid,
Skeleton,
Stat,
StatLabel,
StatNumber,
} from '@chakra-ui/react'
import { Stats } from 'models'
import React from 'react'
export const StatsCards = ({
stats,
...props
}: { stats?: Stats } & GridProps) => {
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing="6" {...props}>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Views</StatLabel>
{stats ? (
<StatNumber>{stats.totalViews}</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Starts</StatLabel>
{stats ? (
<StatNumber>{stats.totalStarts}</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Completion rate</StatLabel>
{stats ? (
<StatNumber>
{Math.round((stats.totalCompleted / stats.totalStarts) * 100)}%
</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
</SimpleGrid>
)
}

View File

@ -0,0 +1,25 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { AnswersCount } from '../types'
export const useAnswersCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ answersCounts: AnswersCount[] },
Error
>(
typebotId ? `/api/typebots/${typebotId}/results/answers/count` : null,
fetcher
)
if (error) onError(error)
return {
answersCounts: data?.answersCounts,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,2 @@
export { AnalyticsGraphContainer } from './components/AnalyticsGraphContainer'
export type { AnswersCount } from './types'

View File

@ -0,0 +1 @@
export type AnswersCount = { groupId: string; totalAnswers: number }

View File

@ -0,0 +1,14 @@
import { setUser } from '@sentry/nextjs'
import { User } from 'db'
import { NextApiRequest } from 'next'
import { getSession } from 'next-auth/react'
export const getAuthenticatedUser = async (
req: NextApiRequest
): Promise<User | undefined> => {
const session = await getSession({ req })
if (!session?.user || !('id' in session.user)) return
const user = session.user as User
setUser({ id: user.id, email: user.email ?? undefined })
return session?.user as User
}

View File

@ -0,0 +1,31 @@
import {
FlexProps,
Flex,
Box,
Divider,
Text,
useColorModeValue,
} from '@chakra-ui/react'
import React from 'react'
export const DividerWithText = (props: FlexProps) => {
const { children, ...flexProps } = props
return (
<Flex align="center" color="gray.300" {...flexProps}>
<Box flex="1">
<Divider borderColor="currentcolor" />
</Box>
<Text
as="span"
px="3"
color={useColorModeValue('gray.600', 'gray.400')}
fontWeight="medium"
>
{children}
</Text>
<Box flex="1">
<Divider borderColor="currentcolor" />
</Box>
</Flex>
)
}

View File

@ -0,0 +1,122 @@
import {
Button,
HTMLChakraProps,
Input,
Stack,
HStack,
Text,
Spinner,
} from '@chakra-ui/react'
import React, { ChangeEvent, FormEvent, useEffect } from 'react'
import { useState } from 'react'
import {
ClientSafeProvider,
getProviders,
LiteralUnion,
signIn,
useSession,
} from 'next-auth/react'
import { DividerWithText } from './DividerWithText'
import { SocialLoginButtons } from './SocialLoginButtons'
import { useRouter } from 'next/router'
import { BuiltInProviderType } from 'next-auth/providers'
import { useToast } from '@/hooks/useToast'
import { TextLink } from '@/components/TextLink'
type Props = {
defaultEmail?: string
}
export const SignInForm = ({
defaultEmail,
}: Props & HTMLChakraProps<'form'>) => {
const router = useRouter()
const { status } = useSession()
const [authLoading, setAuthLoading] = useState(false)
const [isLoadingProviders, setIsLoadingProviders] = useState(true)
const [emailValue, setEmailValue] = useState(defaultEmail ?? '')
const { showToast } = useToast()
const [providers, setProviders] =
useState<
Record<LiteralUnion<BuiltInProviderType, string>, ClientSafeProvider>
>()
const hasNoAuthProvider =
!isLoadingProviders && Object.keys(providers ?? {}).length === 0
useEffect(() => {
if (status === 'authenticated')
router.replace({ pathname: '/typebots', query: router.query })
;(async () => {
const providers = await getProviders()
setProviders(providers ?? undefined)
setIsLoadingProviders(false)
})()
}, [status, router])
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) =>
setEmailValue(e.target.value)
const handleEmailSubmit = async (e: FormEvent) => {
e.preventDefault()
setAuthLoading(true)
const response = await signIn('email', {
email: emailValue,
redirect: false,
})
response?.error
? showToast({
title: 'Unauthorized',
description: 'Sign ups are disabled.',
})
: showToast({
status: 'success',
title: 'Success!',
description: 'Check your inbox to sign in',
})
setAuthLoading(false)
}
if (isLoadingProviders) return <Spinner />
if (hasNoAuthProvider)
return (
<Text>
You need to{' '}
<TextLink
href="https://docs.typebot.io/self-hosting/configuration"
isExternal
>
configure at least one auth provider
</TextLink>{' '}
(Email, Google, GitHub, Facebook or Azure AD).
</Text>
)
return (
<Stack spacing="4" w="330px">
<SocialLoginButtons providers={providers} />
{providers?.email && (
<>
<DividerWithText mt="6">Or with your email</DividerWithText>
<HStack as="form" onSubmit={handleEmailSubmit}>
<Input
name="email"
type="email"
autoComplete="email"
placeholder="email@company.com"
required
value={emailValue}
onChange={handleEmailChange}
/>
<Button
type="submit"
isLoading={
['loading', 'authenticated'].includes(status) || authLoading
}
>
Submit
</Button>
</HStack>
</>
)}
</Stack>
)
}

View File

@ -0,0 +1,38 @@
import { Seo } from '@/components/Seo'
import { TextLink } from '@/components/TextLink'
import { VStack, Heading, Text } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { SignInForm } from './SignInForm'
type Props = {
type: 'signin' | 'signup'
defaultEmail?: string
}
export const SignInPage = ({ type }: Props) => {
const { query } = useRouter()
return (
<VStack spacing={4} h="100vh" justifyContent="center">
<Seo title={type === 'signin' ? 'Sign In' : 'Register'} />
<Heading
onClick={() => {
throw new Error('Sentry is working')
}}
>
{type === 'signin' ? 'Sign In' : 'Create an account'}
</Heading>
{type === 'signin' ? (
<Text>
Don't have an account?{' '}
<TextLink href="/register">Sign up for free</TextLink>
</Text>
) : (
<Text>
Already have an account? <TextLink href="/signin">Sign in</TextLink>
</Text>
)}
<SignInForm defaultEmail={query.g?.toString()} />
</VStack>
)
}

View File

@ -0,0 +1,110 @@
import { Stack, Button } from '@chakra-ui/react'
import { GithubIcon } from '@/components/icons'
import {
ClientSafeProvider,
LiteralUnion,
signIn,
useSession,
} from 'next-auth/react'
import { useRouter } from 'next/router'
import React from 'react'
import { stringify } from 'qs'
import { BuiltInProviderType } from 'next-auth/providers'
import { GoogleLogo } from '@/components/GoogleLogo'
import { AzureAdLogo, FacebookLogo, GitlabLogo } from './logos'
type Props = {
providers:
| Record<LiteralUnion<BuiltInProviderType, string>, ClientSafeProvider>
| undefined
}
export const SocialLoginButtons = ({ providers }: Props) => {
const { query } = useRouter()
const { status } = useSession()
const handleGitHubClick = async () =>
signIn('github', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleGoogleClick = async () =>
signIn('google', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleFacebookClick = async () =>
signIn('facebook', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleGitlabClick = async () =>
signIn('gitlab', {
callbackUrl: `/typebots?${stringify(query)}`,
})
const handleAzureAdClick = async () =>
signIn('azure-ad', {
callbackUrl: `/typebots?${stringify(query)}`,
})
return (
<Stack>
{providers?.github && (
<Button
leftIcon={<GithubIcon />}
onClick={handleGitHubClick}
data-testid="github"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with GitHub
</Button>
)}
{providers?.google && (
<Button
leftIcon={<GoogleLogo />}
onClick={handleGoogleClick}
data-testid="google"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with Google
</Button>
)}
{providers?.facebook && (
<Button
leftIcon={<FacebookLogo />}
onClick={handleFacebookClick}
data-testid="facebook"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with Facebook
</Button>
)}
{providers?.gitlab && (
<Button
leftIcon={<GitlabLogo />}
onClick={handleGitlabClick}
data-testid="gitlab"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with {providers.gitlab.name}
</Button>
)}
{providers?.['azure-ad'] && (
<Button
leftIcon={<AzureAdLogo />}
onClick={handleAzureAdClick}
data-testid="azure-ad"
isLoading={['loading', 'authenticated'].includes(status)}
variant="outline"
>
Continue with {providers['azure-ad'].name}
</Button>
)}
</Stack>
)
}

View File

@ -0,0 +1,31 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const AzureAdLogo = (props: IconProps) => {
return (
<Icon
id="svg1035"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 374.5 377.3"
{...props}
>
<g id="layer1" transform="translate(-39.022 -78.115)">
<g id="g1016" transform="translate(-63.947 -88.179)">
<path
id="path1008"
fill='#00bef2'
d="M290 166.3c.4 0 .8.5 1.4 1.4.5.8 42.6 51.3 93.6 112.2 51 60.9 92.6 111 92.4 111.3-.1.3-40.7 33.6-90.2 73.9s-91.6 74.6-93.5 76.2c-3.3 2.7-3.5 2.8-4.7 1.6-.7-.7-42.9-35.2-93.8-76.7S102.8 390.5 103 390c.2-.5 42-50.4 93.1-111s92.9-110.7 93.1-111.5c.2-.8.5-1.2.8-1.2z"
/>
<path
id="path923"
fill="#fff"
stroke="#fff"
strokeWidth="1.2357"
strokeLinecap="round"
strokeLinejoin="round"
d="M283.1 483.6c-5.8-2.1-12.8-8.1-15.7-13.7-3.6-6.9-3.3-17.7.7-26.3 3.1-6.4 3.1-6.6 1.1-8.1-1.1-.8-14.4-8.2-29.4-16.3-15-8.1-28.1-15.2-29-15.7-1.2-.7-3.2 0-6.8 2.3-11.7 7.4-23.9 6.6-33.5-2.3-6.9-6.4-8.9-10.9-8.9-20.1 0-8.9 1.8-13.5 7.5-19.2 7.7-7.7 18-10.3 27.9-7 5.4 1.8 5.5 1.8 8.9-.8 4-3 36.1-32.3 51.6-47l10.7-10.2-3.2-6.7c-6.5-13.5-3.2-28.5 8.2-37.5 6.2-4.9 10.8-6.4 19.7-6.4 20.8 0 35.3 21.8 27.5 41.3-2.1 5.4-2.1 5.5-.1 8.8 1.7 2.9 30.6 37.8 45.9 55.6 2.7 3.1 5.7 5.6 6.7 5.6s4.4-1 7.6-2.2c14.9-5.9 30.6.7 36.8 15.5 4 9.5.5 22.3-8 30-6 5.4-10.4 7.1-18.4 7.1-5.6 0-7.7-.6-13.6-3.8-4.4-2.4-7.8-3.6-9.2-3.2-2.4.6-39.3 25.9-47.5 32.5-5 4.1-5.4 5.6-2.8 11.7 2.5 6 2.2 15.4-.6 21.3-3.1 6.5-10.8 13-17.5 15-6.8 1.9-10.9 1.9-16.6-.2zm1.7-110.2v-57l-3.2-4.4c-1.8-2.4-3.5-4.4-3.8-4.4-1.3 0-65.9 58.7-65.9 59.9 0 .3 1 3.3 2.2 6.5 1.2 3.3 2.1 8 2 10.7-.1 2.7-.1 5.7-.1 6.7.1 2.3 21.7 16.1 54.1 34.8 8.9 5.2 12 6.5 13.1 5.6 1.3-1.1 1.6-12.2 1.6-58.4zm27.4 50.4c42.8-26.9 50.8-32.3 51.3-34.3.3-1.2.7-5.9.8-10.6l.3-8.4-21.8-25.9c-23.4-27.7-32-37.1-34-37.1-.7 0-4.2 2-7.8 4.4l-6.6 4.4.3 56.9c.3 51 .7 59.6 2.6 59.6.2.1 7-4 14.9-9z"
/>
</g>
</g>
</Icon>
)
}

View File

@ -0,0 +1,11 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const FacebookLogo = (props: IconProps) => (
<Icon viewBox="0 0 14222 14222" {...props}>
<circle cx="7111" cy="7112" r="7111" fill="#1977f3" />
<path
d="M9879 9168l315-2056H8222V5778c0-562 275-1111 1159-1111h897V2917s-814-139-1592-139c-1624 0-2686 984-2686 2767v1567H4194v2056h1806v4969c362 57 733 86 1111 86s749-30 1111-86V9168z"
fill="#fff"
/>
</Icon>
)

View File

@ -0,0 +1,13 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const GitlabLogo = (props: IconProps) => (
<Icon viewBox="0 0 256 236" {...props}>
<path d="M128.075 236.075l47.104-144.97H80.97l47.104 144.97z" fill="#E24329" />
<path d="M128.075 236.074L80.97 91.104H14.956l113.119 144.97z" fill="#FC6D26" />
<path d="M14.956 91.104L.642 135.16a9.752 9.752 0 0 0 3.542 10.903l123.891 90.012-113.12-144.97z" fill="#FCA326" />
<path d="M14.956 91.105H80.97L52.601 3.79c-1.46-4.493-7.816-4.492-9.275 0l-28.37 87.315z" fill="#E24329" />
<path d="M128.075 236.074l47.104-144.97h66.015l-113.12 144.97z" fill="#FC6D26" />
<path d="M241.194 91.104l14.314 44.056a9.752 9.752 0 0 1-3.543 10.903l-123.89 90.012 113.119-144.97z" fill="#FCA326" />
<path d="M241.194 91.105h-66.015l28.37-87.315c1.46-4.493 7.816-4.492 9.275 0l28.37 87.315z" fill="#E24329" />
</Icon>
)

View File

@ -0,0 +1,3 @@
export { AzureAdLogo } from './AzureAdLogo'
export { GitlabLogo } from './GitlabLogo'
export { FacebookLogo } from './FacebookLogo'

View File

@ -0,0 +1,15 @@
import { User } from 'db'
export const mockedUser: User = {
id: 'userId',
name: 'John Doe',
email: 'user@email.com',
company: null,
createdAt: new Date(),
emailVerified: null,
graphNavigation: 'TRACKPAD',
image: 'https://avatars.githubusercontent.com/u/16015833?v=4',
lastActivityAt: new Date(),
onboardingCategories: [],
updatedAt: new Date(),
}

View File

@ -0,0 +1,3 @@
export { SignInPage } from './components/SignInPage'
export { getAuthenticatedUser } from './api/getAuthenticatedUser'
export { mockedUser } from './constants'

View File

@ -0,0 +1,259 @@
import {
addSubscriptionToWorkspace,
createClaimableCustomPlan,
} from '@/test/utils/databaseActions'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { Plan } from 'db'
import {
createTypebots,
createWorkspaces,
deleteWorkspaces,
injectFakeResults,
} from 'utils/playwright/databaseActions'
const usageWorkspaceId = cuid()
const usageTypebotId = cuid()
const planChangeWorkspaceId = cuid()
const enterpriseWorkspaceId = cuid()
test.beforeAll(async () => {
await createWorkspaces([
{
id: usageWorkspaceId,
name: 'Usage Workspace',
plan: Plan.STARTER,
},
{
id: planChangeWorkspaceId,
name: 'Plan Change Workspace',
},
{
id: enterpriseWorkspaceId,
name: 'Enterprise Workspace',
},
])
await createTypebots([{ id: usageTypebotId, workspaceId: usageWorkspaceId }])
})
test.afterAll(async () => {
await deleteWorkspaces([
usageWorkspaceId,
planChangeWorkspaceId,
enterpriseWorkspaceId,
])
})
test('should display valid usage', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 10,000"')).toBeVisible()
await expect(page.locator('text="/ 10 GB"')).toBeVisible()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/5)' })
).toBeVisible()
await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace')
await page.click('text="Custom workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 100,000"')).toBeVisible()
await expect(page.locator('text="/ 50 GB"')).toBeVisible()
await expect(page.getByText('Upgrade to Starter')).toBeHidden()
await expect(page.getByText('Upgrade to Pro')).toBeHidden()
await expect(page.getByText('Need custom limits?')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/20)' })
).toBeVisible()
await page.click('text=Custom workspace', { force: true })
await page.click('text=Custom workspace')
await page.click('text="Free workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 300"')).toBeVisible()
await expect(page.locator('text="Storage"')).toBeHidden()
await page.getByText('Members', { exact: true }).click()
await expect(
page.getByRole('heading', { name: 'Members (1/1)' })
).toBeVisible()
await page.click('text=Free workspace', { force: true })
await injectFakeResults({
count: 10,
typebotId: usageTypebotId,
fakeStorage: 1100 * 1024 * 1024,
})
await page.click('text=Free workspace')
await page.click('text="Usage Workspace"')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('text="10" >> nth=0')).toBeVisible()
await expect(page.locator('[role="progressbar"] >> nth=0')).toHaveAttribute(
'aria-valuenow',
'1'
)
await expect(page.locator('text="1.07 GB"')).toBeVisible()
await expect(page.locator('[role="progressbar"] >> nth=1')).toHaveAttribute(
'aria-valuenow',
'54'
)
await injectFakeResults({
typebotId: usageTypebotId,
count: 1090,
fakeStorage: 1200 * 1024 * 1024,
})
await page.click('text="Settings"')
await page.click('text="Billing & Usage"')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="1,100"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('text="2.25 GB"')).toBeVisible()
await expect(page.locator('[aria-valuenow="55"]')).toBeVisible()
await expect(page.locator('[aria-valuenow="112"]')).toBeVisible()
})
test('plan changes should work', async ({ page }) => {
test.setTimeout(80000)
// Upgrade to STARTER
await page.goto('/typebots')
await page.click('text=Pro workspace')
await page.click('text=Plan Change Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Upgrade >> nth=0')
await page.waitForNavigation()
expect(page.url()).toContain('https://checkout.stripe.com')
await expect(page.locator('text=$73.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$30.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=$4.00 >> nth=0')).toBeVisible()
await expect(page.locator('text=user@email.com')).toBeVisible()
await addSubscriptionToWorkspace(
planChangeWorkspaceId,
[
{
price: process.env.STRIPE_STARTER_PRICE_ID,
quantity: 1,
},
],
{ plan: Plan.STARTER, additionalChatsIndex: 0, additionalStorageIndex: 0 }
)
// Update plan with additional quotas
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="/ 2,000"')).toBeVisible()
await expect(page.locator('text="/ 2 GB"')).toBeVisible()
await expect(page.locator('button >> text="2,000"')).toBeVisible()
await expect(page.locator('button >> text="2"')).toBeVisible()
await page.click('button >> text="2,000"')
await page.click('button >> text="3,500"')
await page.click('button >> text="2"')
await page.click('button >> text="4"')
await expect(page.locator('text="$73"')).toBeVisible()
await page.click('button >> text=Update')
await expect(
page.locator(
'text="Workspace STARTER plan successfully updated 🎉" >> nth=0'
)
).toBeVisible()
await page.click('text="Members"')
await page.click('text="Billing & Usage"')
await expect(page.locator('text="$73"')).toBeVisible()
await expect(page.locator('text="/ 3,500"')).toBeVisible()
await expect(page.locator('text="/ 4 GB"')).toBeVisible()
await expect(page.locator('button >> text="3,500"')).toBeVisible()
await expect(page.locator('button >> text="4"')).toBeVisible()
// Upgrade to PRO
await page.click('button >> text="10,000"')
await page.click('button >> text="14,000"')
await page.click('button >> text="10"')
await page.click('button >> text="12"')
await expect(page.locator('text="$133"')).toBeVisible()
await page.click('button >> text=Upgrade')
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
).toBeVisible()
// Go to customer portal
await Promise.all([
page.waitForNavigation(),
page.click('text="Billing Portal"'),
])
await expect(page.locator('text="Add payment method"')).toBeVisible()
// Cancel subscription
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: ProCancel my subscription'
)
await page.click('button >> text="Cancel my subscription"')
await expect(page.locator('[data-testid="current-subscription"]')).toHaveText(
'Current workspace subscription: Free'
)
// Upgrade again to PRO
await page.getByRole('button', { name: 'Upgrade' }).nth(1).click()
await expect(
page.locator('text="Workspace PRO plan successfully updated 🎉" >> nth=0')
).toBeVisible({ timeout: 20 * 1000 })
})
test('should display invoices', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeHidden()
await page.click('text=Pro workspace', { force: true })
await page.click('text=Pro workspace')
await page.click('text=Plan Change Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(3)
await expect(page.locator('text="$39.00"')).toBeVisible()
})
test('custom plans should work', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Pro workspace')
await page.click('text=Enterprise Workspace')
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.getByTestId('current-subscription')).toHaveText(
'Current workspace subscription: Free'
)
await createClaimableCustomPlan({
currency: 'usd',
price: 239,
workspaceId: enterpriseWorkspaceId,
chatsLimit: 100000,
storageLimit: 50,
seatsLimit: 10,
name: 'Acme custom plan',
description: 'Description of the deal',
})
await page.goto('/api/stripe/custom-plan-checkout')
await expect(page.getByRole('list').getByText('$239.00')).toBeVisible()
await expect(page.getByText('Subscribe to Acme custom plan')).toBeVisible()
await expect(page.getByText('Description of the deal')).toBeVisible()
})

View File

@ -0,0 +1,49 @@
import { HStack, Stack, Text } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import React from 'react'
import { CurrentSubscriptionContent } from './CurrentSubscriptionContent'
import { InvoicesList } from './InvoicesList'
import { UsageContent } from './UsageContent/UsageContent'
import { StripeClimateLogo } from '../StripeClimateLogo'
import { TextLink } from '@/components/TextLink'
import { ChangePlanForm } from '../ChangePlanForm'
export const BillingContent = () => {
const { workspace, refreshWorkspace } = useWorkspace()
if (!workspace) return null
return (
<Stack spacing="10" w="full">
<UsageContent workspace={workspace} />
<Stack spacing="2">
<CurrentSubscriptionContent
plan={workspace.plan}
stripeId={workspace.stripeId}
onCancelSuccess={() =>
refreshWorkspace({
plan: Plan.FREE,
additionalChatsIndex: 0,
additionalStorageIndex: 0,
})
}
/>
<HStack maxW="500px">
<StripeClimateLogo />
<Text fontSize="xs" color="gray.500">
Typebot is contributing 1% of your subscription to remove CO from
the atmosphere.{' '}
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
More info.
</TextLink>
</Text>
</HStack>
{workspace.plan !== Plan.CUSTOM &&
workspace.plan !== Plan.LIFETIME &&
workspace.plan !== Plan.OFFERED && <ChangePlanForm />}
</Stack>
{workspace.stripeId && <InvoicesList workspace={workspace} />}
</Stack>
)
}

View File

@ -0,0 +1,91 @@
import {
Text,
HStack,
Link,
Spinner,
Stack,
Button,
Heading,
} from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { Plan } from 'db'
import React, { useState } from 'react'
import { cancelSubscriptionQuery } from './queries/cancelSubscriptionQuery'
import { PlanTag } from '../PlanTag'
type CurrentSubscriptionContentProps = {
plan: Plan
stripeId?: string | null
onCancelSuccess: () => void
}
export const CurrentSubscriptionContent = ({
plan,
stripeId,
onCancelSuccess,
}: CurrentSubscriptionContentProps) => {
const [isCancelling, setIsCancelling] = useState(false)
const [isRedirectingToBillingPortal, setIsRedirectingToBillingPortal] =
useState(false)
const { showToast } = useToast()
const cancelSubscription = async () => {
if (!stripeId) return
setIsCancelling(true)
const { error } = await cancelSubscriptionQuery(stripeId)
if (error) {
showToast({ description: error.message })
return
}
onCancelSuccess()
setIsCancelling(false)
}
const isSubscribed = (plan === Plan.STARTER || plan === Plan.PRO) && stripeId
return (
<Stack spacing="2">
<Heading fontSize="3xl">Subscription</Heading>
<HStack data-testid="current-subscription">
<Text>Current workspace subscription: </Text>
{isCancelling ? (
<Spinner color="gray.500" size="xs" />
) : (
<>
<PlanTag plan={plan} />
{isSubscribed && (
<Link
as="button"
color="gray.500"
textDecor="underline"
fontSize="sm"
onClick={cancelSubscription}
>
Cancel my subscription
</Link>
)}
</>
)}
</HStack>
{isSubscribed && !isCancelling && (
<>
<Stack spacing="1">
<Text fontSize="sm">
Need to change payment method or billing information? Head over to
your billing portal:
</Text>
<Button
as={Link}
href={`/api/stripe/billing-portal?stripeId=${stripeId}`}
onClick={() => setIsRedirectingToBillingPortal(true)}
isLoading={isRedirectingToBillingPortal}
>
Billing Portal
</Button>
</Stack>
</>
)}
</Stack>
)
}

View File

@ -0,0 +1,97 @@
import {
Stack,
Heading,
Checkbox,
Skeleton,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
IconButton,
Text,
} from '@chakra-ui/react'
import { DownloadIcon, FileIcon } from '@/components/icons'
import { Workspace } from 'db'
import Link from 'next/link'
import React from 'react'
import { useInvoicesQuery } from './queries/useInvoicesQuery'
type Props = {
workspace: Workspace
}
export const InvoicesList = ({ workspace }: Props) => {
const { invoices, isLoading } = useInvoicesQuery(workspace.stripeId)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Invoices</Heading>
{invoices.length === 0 && !isLoading ? (
<Text>No invoices found for this workspace.</Text>
) : (
<TableContainer>
<Table>
<Thead>
<Tr>
<Th w="0" />
<Th>#</Th>
<Th>Paid at</Th>
<Th>Subtotal</Th>
<Th w="0" />
</Tr>
</Thead>
<Tbody>
{invoices?.map((invoice) => (
<Tr key={invoice.id}>
<Td>
<FileIcon />
</Td>
<Td>{invoice.id}</Td>
<Td>{new Date(invoice.date * 1000).toDateString()}</Td>
<Td>{getFormattedPrice(invoice.amount, invoice.currency)}</Td>
<Td>
<IconButton
as={Link}
size="xs"
icon={<DownloadIcon />}
variant="outline"
href={invoice.url}
target="_blank"
aria-label={'Download invoice'}
/>
</Td>
</Tr>
))}
{isLoading &&
Array.from({ length: 3 }).map((_, idx) => (
<Tr key={idx}>
<Td>
<Checkbox isDisabled />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
<Td>
<Skeleton h="5px" />
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
)}
</Stack>
)
}
const getFormattedPrice = (amount: number, currency: string) => {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
})
return formatter.format(amount / 100)
}

View File

@ -0,0 +1,157 @@
import {
Stack,
Flex,
Heading,
Progress,
Text,
Skeleton,
HStack,
Tooltip,
} from '@chakra-ui/react'
import { AlertIcon } from '@/components/icons'
import { Plan, Workspace } from 'db'
import React from 'react'
import { getChatsLimit, getStorageLimit, parseNumberWithCommas } from 'utils'
import { storageToReadable } from './helpers'
import { useUsage } from '../../../hooks/useUsage'
type Props = {
workspace: Workspace
}
export const UsageContent = ({ workspace }: Props) => {
const { data, isLoading } = useUsage(workspace.id)
const totalChatsUsed = data?.totalChatsUsed ?? 0
const totalStorageUsed = data?.totalStorageUsed ?? 0
const workspaceChatsLimit = getChatsLimit(workspace)
const workspaceStorageLimit = getStorageLimit(workspace)
const workspaceStorageLimitGigabites =
workspaceStorageLimit * 1024 * 1024 * 1024
const chatsPercentage = Math.round(
(totalChatsUsed / workspaceChatsLimit) * 100
)
const storagePercentage = Math.round(
(totalStorageUsed / workspaceStorageLimitGigabites) * 100
)
return (
<Stack spacing={6}>
<Heading fontSize="3xl">Usage</Heading>
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
Chats
</Heading>
{chatsPercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
Your typebots are popular! You will soon reach your plan's
chats limit. 🚀
<br />
<br />
Make sure to <strong>update your plan</strong> to increase
this limit and continue chatting with your users.
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
<Text fontSize="sm" fontStyle="italic" color="gray.500">
(resets on 1st of every month)
</Text>
</HStack>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{parseNumberWithCommas(totalChatsUsed)}
</Skeleton>
<Text>
/{' '}
{workspaceChatsLimit === -1
? 'Unlimited'
: parseNumberWithCommas(workspaceChatsLimit)}
</Text>
</HStack>
</Flex>
<Progress
h="5px"
value={chatsPercentage}
rounded="full"
hasStripe
isIndeterminate={isLoading}
colorScheme={totalChatsUsed >= workspaceChatsLimit ? 'red' : 'blue'}
/>
</Stack>
{workspace.plan !== Plan.FREE && (
<Stack spacing={3}>
<Flex justifyContent="space-between">
<HStack>
<Heading fontSize="xl" as="h3">
Storage
</Heading>
{storagePercentage >= 80 && (
<Tooltip
placement="top"
rounded="md"
p="3"
label={
<Text>
Your typebots are popular! You will soon reach your plan's
storage limit. 🚀
<br />
<br />
Make sure to <strong>update your plan</strong> in order to
continue collecting uploaded files. You can also{' '}
<strong>delete files</strong> to free up space.
</Text>
}
>
<span>
<AlertIcon color="orange.500" />
</span>
</Tooltip>
)}
</HStack>
<HStack>
<Skeleton
fontWeight="bold"
isLoaded={!isLoading}
h={isLoading ? '5px' : 'auto'}
>
{storageToReadable(totalStorageUsed)}
</Skeleton>
<Text>/ {workspaceStorageLimit} GB</Text>
</HStack>
</Flex>
<Progress
value={storagePercentage}
h="5px"
colorScheme={
totalStorageUsed >= workspaceStorageLimitGigabites
? 'red'
: 'blue'
}
rounded="full"
hasStripe
isIndeterminate={isLoading}
/>
</Stack>
)}
</Stack>
)
}

View File

@ -0,0 +1,7 @@
export const storageToReadable = (bytes: number) => {
if (bytes == 0) {
return '0'
}
const e = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B'
}

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const cancelSubscriptionQuery = (stripeId: string) =>
sendRequest({
url: `api/stripe/subscription?stripeId=${stripeId}`,
method: 'DELETE',
})

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const redirectToBillingPortal = ({
workspaceId,
}: {
workspaceId: string
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)

View File

@ -0,0 +1,24 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
type Invoice = {
id: string
url: string
date: number
currency: string
amount: number
}
export const useInvoicesQuery = (stripeId?: string | null) => {
const { data, error } = useSWR<{ invoices: Invoice[] }, Error>(
stripeId ? `/api/stripe/invoices?stripeId=${stripeId}` : null,
fetcher,
{
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
}
)
return {
invoices: data?.invoices ?? [],
isLoading: !error && !data,
}
}

View File

@ -0,0 +1,98 @@
import { Stack, HStack, Text } from '@chakra-ui/react'
import { useUser } from '@/features/account'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { ProPlanContent } from './ProPlanContent'
import { upgradePlanQuery } from '../../queries/upgradePlanQuery'
import { useCurrentSubscriptionInfo } from '../../hooks/useCurrentSubscriptionInfo'
import { StarterPlanContent } from './StarterPlanContent'
import { TextLink } from '@/components/TextLink'
import { useToast } from '@/hooks/useToast'
export const ChangePlanForm = () => {
const { user } = useUser()
const { workspace, refreshWorkspace } = useWorkspace()
const { showToast } = useToast()
const { data, mutate: refreshCurrentSubscriptionInfo } =
useCurrentSubscriptionInfo({
stripeId: workspace?.stripeId,
plan: workspace?.plan,
})
const handlePayClick = async ({
plan,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
}: {
plan: 'STARTER' | 'PRO'
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => {
if (
!user ||
!workspace ||
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
const response = await upgradePlanQuery({
stripeId: workspace.stripeId ?? undefined,
user,
plan,
workspaceId: workspace.id,
additionalChats: selectedChatsLimitIndex,
additionalStorage: selectedStorageLimitIndex,
})
if (typeof response === 'object' && response?.error) {
showToast({ description: response.error.message })
return
}
refreshCurrentSubscriptionInfo({
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
refreshWorkspace({
plan,
additionalChatsIndex: selectedChatsLimitIndex,
additionalStorageIndex: selectedStorageLimitIndex,
})
showToast({
status: 'success',
description: `Workspace ${plan} plan successfully updated 🎉`,
})
}
return (
<Stack spacing={6}>
<HStack alignItems="stretch" spacing="4" w="full">
<StarterPlanContent
initialChatsLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalChatsIndex : 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.STARTER ? data?.additionalStorageIndex : 0
}
onPayClick={(props) =>
handlePayClick({ ...props, plan: Plan.STARTER })
}
/>
<ProPlanContent
initialChatsLimitIndex={
workspace?.plan === Plan.PRO ? data?.additionalChatsIndex : 0
}
initialStorageLimitIndex={
workspace?.plan === Plan.PRO ? data?.additionalStorageIndex : 0
}
onPayClick={(props) => handlePayClick({ ...props, plan: Plan.PRO })}
/>
</HStack>
<Text color="gray.500">
Need custom limits? Specific features?{' '}
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal>
Let's chat!
</TextLink>
</Text>
</Stack>
)
}

View File

@ -0,0 +1,342 @@
import {
Stack,
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
Tooltip,
Flex,
Tag,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { useEffect, useState } from 'react'
import {
chatsLimit,
getChatsLimit,
getStorageLimit,
storageLimit,
parseNumberWithCommas,
formatPrice,
computePrice,
} from 'utils'
import { FeaturesList } from './components/FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
type ProPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => Promise<void>
}
export const ProPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
onPayClick,
}: ProPlanContentProps) => {
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>()
const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const workspaceStorageLimit = workspace
? getStorageLimit(workspace)
: undefined
const isCurrentPlan =
chatsLimit[Plan.PRO].totalIncluded +
chatsLimit[Plan.PRO].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.PRO].totalIncluded +
storageLimit[Plan.PRO].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
const getButtonLabel = () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return ''
if (workspace?.plan === Plan.PRO) {
if (isCurrentPlan) return 'Your current plan'
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
)
return 'Update'
}
return 'Upgrade'
}
const handlePayClick = async () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
setIsPaying(true)
await onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
setIsPaying(false)
}
return (
<Flex
p="6"
pos="relative"
h="full"
flexDir="column"
flex="1"
flexShrink={0}
borderWidth="1px"
borderColor="blue.500"
rounded="lg"
>
<Flex justifyContent="center">
<Tag
pos="absolute"
top="-10px"
colorScheme="blue"
variant="solid"
fontWeight="semibold"
style={{ marginTop: 0 }}
>
Most popular
</Tag>
</Flex>
<Stack justifyContent="space-between" h="full">
<Stack spacing="4" mt={2}>
<Heading fontSize="2xl">
Upgrade to <chakra.span color="blue.400">Pro</chakra.span>
</Heading>
<Text>For agencies & growing startups.</Text>
</Stack>
<Stack spacing="4">
<Heading>
{formatPrice(
computePrice(
Plan.PRO,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
) ?? NaN
)}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeaturesList
features={[
'Branding removed',
'File upload input block',
'Create folders',
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
Everything in Starter
</chakra.span>
</Tooltip>
, plus:
</Text>
<FeaturesList
features={[
'5 seats included',
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount *
selectedChatsLimitIndex
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(chatsLimit.PRO.totalIncluded)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.PRO.totalIncluded +
chatsLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
chats/mo
</Text>
<MoreInfoTooltip>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives.
</MoreInfoTooltip>
</HStack>,
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedStorageLimitIndex === undefined}
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount *
selectedStorageLimitIndex
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(0)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(1)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(2)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(3)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem
onClick={() => setSelectedStorageLimitIndex(4)}
>
{parseNumberWithCommas(
storageLimit.PRO.totalIncluded +
storageLimit.PRO.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
GB of storage
</Text>
<MoreInfoTooltip>
You accumulate storage for every file that your user upload
into your bot. If you delete the result, it will free up the
space.
</MoreInfoTooltip>
</HStack>,
'Custom domains',
'In-depth analytics',
]}
/>
<Button
colorScheme="blue"
variant="outline"
onClick={handlePayClick}
isLoading={isPaying}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
</Button>
</Stack>
</Stack>
</Flex>
)
}

View File

@ -0,0 +1,285 @@
import {
Stack,
Heading,
chakra,
HStack,
Menu,
MenuButton,
Button,
MenuList,
MenuItem,
Text,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import { useEffect, useState } from 'react'
import {
chatsLimit,
getChatsLimit,
getStorageLimit,
storageLimit,
parseNumberWithCommas,
computePrice,
formatPrice,
} from 'utils'
import { FeaturesList } from './components/FeaturesList'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
type StarterPlanContentProps = {
initialChatsLimitIndex?: number
initialStorageLimitIndex?: number
onPayClick: (props: {
selectedChatsLimitIndex: number
selectedStorageLimitIndex: number
}) => Promise<void>
}
export const StarterPlanContent = ({
initialChatsLimitIndex,
initialStorageLimitIndex,
onPayClick,
}: StarterPlanContentProps) => {
const { workspace } = useWorkspace()
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
useState<number>()
const [selectedStorageLimitIndex, setSelectedStorageLimitIndex] =
useState<number>()
const [isPaying, setIsPaying] = useState(false)
useEffect(() => {
if (
selectedChatsLimitIndex === undefined &&
initialChatsLimitIndex !== undefined
)
setSelectedChatsLimitIndex(initialChatsLimitIndex)
if (
selectedStorageLimitIndex === undefined &&
initialStorageLimitIndex !== undefined
)
setSelectedStorageLimitIndex(initialStorageLimitIndex)
}, [
initialChatsLimitIndex,
initialStorageLimitIndex,
selectedChatsLimitIndex,
selectedStorageLimitIndex,
])
const workspaceChatsLimit = workspace ? getChatsLimit(workspace) : undefined
const workspaceStorageLimit = workspace
? getStorageLimit(workspace)
: undefined
const isCurrentPlan =
chatsLimit[Plan.STARTER].totalIncluded +
chatsLimit[Plan.STARTER].increaseStep.amount *
(selectedChatsLimitIndex ?? 0) ===
workspaceChatsLimit &&
storageLimit[Plan.STARTER].totalIncluded +
storageLimit[Plan.STARTER].increaseStep.amount *
(selectedStorageLimitIndex ?? 0) ===
workspaceStorageLimit
const getButtonLabel = () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return ''
if (workspace?.plan === Plan.PRO) return 'Downgrade'
if (workspace?.plan === Plan.STARTER) {
if (isCurrentPlan) return 'Your current plan'
if (
selectedChatsLimitIndex !== initialChatsLimitIndex ||
selectedStorageLimitIndex !== initialStorageLimitIndex
)
return 'Update'
}
return 'Upgrade'
}
const handlePayClick = async () => {
if (
selectedChatsLimitIndex === undefined ||
selectedStorageLimitIndex === undefined
)
return
setIsPaying(true)
await onPayClick({
selectedChatsLimitIndex,
selectedStorageLimitIndex,
})
setIsPaying(false)
}
return (
<Stack spacing={6} p="6" rounded="lg" borderWidth="1px" flex="1" h="full">
<Stack spacing="4">
<Heading fontSize="2xl">
Upgrade to <chakra.span color="orange.400">Starter</chakra.span>
</Heading>
<Text>For individuals & small businesses.</Text>
<Heading>
{formatPrice(
computePrice(
Plan.STARTER,
selectedChatsLimitIndex ?? 0,
selectedStorageLimitIndex ?? 0
) ?? NaN
)}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<FeaturesList
features={[
'2 seats included',
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedChatsLimitIndex === undefined}
>
{selectedChatsLimitIndex !== undefined
? parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount *
selectedChatsLimitIndex
)
: undefined}
</MenuButton>
<MenuList>
{selectedChatsLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(0)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(1)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(2)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(3)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedChatsLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedChatsLimitIndex(4)}>
{parseNumberWithCommas(
chatsLimit.STARTER.totalIncluded +
chatsLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
chats/mo
</Text>
<MoreInfoTooltip>
A chat is counted whenever a user starts a discussion. It is
independant of the number of messages he sends and receives.
</MoreInfoTooltip>
</HStack>,
<HStack key="test">
<Text>
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronLeftIcon transform="rotate(-90deg)" />}
size="sm"
isLoading={selectedStorageLimitIndex === undefined}
>
{selectedStorageLimitIndex !== undefined
? parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount *
selectedStorageLimitIndex
)
: undefined}
</MenuButton>
<MenuList>
{selectedStorageLimitIndex !== 0 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(0)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 1 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(1)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 2 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(2)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 2
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 3 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(3)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 3
)}
</MenuItem>
)}
{selectedStorageLimitIndex !== 4 && (
<MenuItem onClick={() => setSelectedStorageLimitIndex(4)}>
{parseNumberWithCommas(
storageLimit.STARTER.totalIncluded +
storageLimit.STARTER.increaseStep.amount * 4
)}
</MenuItem>
)}
</MenuList>
</Menu>{' '}
GB of storage
</Text>
<MoreInfoTooltip>
You accumulate storage for every file that your user upload into
your bot. If you delete the result, it will free up the space.
</MoreInfoTooltip>
</HStack>,
'Branding removed',
'File upload input block',
'Create folders',
]}
/>
</Stack>
<Button
colorScheme="orange"
variant="outline"
onClick={handlePayClick}
isLoading={isPaying}
isDisabled={isCurrentPlan}
>
{getButtonLabel()}
</Button>
</Stack>
)
}

View File

@ -0,0 +1,21 @@
import {
ListProps,
UnorderedList,
Flex,
ListItem,
ListIcon,
} from '@chakra-ui/react'
import { CheckIcon } from '@/components/icons'
type FeaturesListProps = { features: (string | JSX.Element)[] } & ListProps
export const FeaturesList = ({ features, ...props }: FeaturesListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat, idx) => (
<Flex as={ListItem} key={idx} alignItems="center">
<ListIcon as={CheckIcon} />
{feat}
</Flex>
))}
</UnorderedList>
)

View File

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

View File

@ -0,0 +1,56 @@
import { AlertInfo } from '@/components/AlertInfo'
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalOverlay,
Stack,
Button,
HStack,
} from '@chakra-ui/react'
import { ChangePlanForm } from './ChangePlanForm'
export enum LimitReached {
BRAND = 'remove branding',
CUSTOM_DOMAIN = 'add custom domains',
FOLDER = 'create folders',
FILE_INPUT = 'use file input blocks',
ANALYTICS = 'unlock in-depth analytics',
}
type ChangePlanModalProps = {
type?: LimitReached
isOpen: boolean
onClose: () => void
}
export const ChangePlanModal = ({
onClose,
isOpen,
type,
}: ChangePlanModalProps) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalBody as={Stack} spacing="6" pt="10">
{type && (
<AlertInfo>
You need to upgrade your plan in order to {type}
</AlertInfo>
)}
<ChangePlanForm />
</ModalBody>
<ModalFooter>
<HStack>
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,14 @@
import { Tag, TagProps } from '@chakra-ui/react'
import { LockedIcon } from '@/components/icons'
import { Plan } from 'db'
import { planColorSchemes } from './PlanTag'
export const LockTag = ({ plan, ...props }: { plan?: Plan } & TagProps) => (
<Tag
colorScheme={plan ? planColorSchemes[plan] : 'gray'}
data-testid={`${plan?.toLowerCase()}-lock-tag`}
{...props}
>
<LockedIcon />
</Tag>
)

View File

@ -0,0 +1,75 @@
import { Tag, TagProps, ThemeTypings } from '@chakra-ui/react'
import { Plan } from 'db'
export const planColorSchemes: Record<Plan, ThemeTypings['colorSchemes']> = {
[Plan.LIFETIME]: 'purple',
[Plan.PRO]: 'blue',
[Plan.OFFERED]: 'orange',
[Plan.STARTER]: 'orange',
[Plan.FREE]: 'gray',
[Plan.CUSTOM]: 'yellow',
}
export const PlanTag = ({
plan,
...props
}: { plan: Plan } & TagProps): JSX.Element => {
switch (plan) {
case Plan.LIFETIME: {
return (
<Tag
colorScheme={planColorSchemes[plan]}
data-testid="lifetime-plan-tag"
{...props}
>
Lifetime
</Tag>
)
}
case Plan.PRO: {
return (
<Tag
colorScheme={planColorSchemes[plan]}
data-testid="pro-plan-tag"
{...props}
>
Pro
</Tag>
)
}
case Plan.OFFERED:
case Plan.STARTER: {
return (
<Tag
colorScheme={planColorSchemes[plan]}
data-testid="starter-plan-tag"
{...props}
>
Starter
</Tag>
)
}
case Plan.FREE: {
return (
<Tag
colorScheme={planColorSchemes[Plan.FREE]}
data-testid="free-plan-tag"
{...props}
>
Free
</Tag>
)
}
case Plan.CUSTOM: {
return (
<Tag
colorScheme={planColorSchemes[Plan.CUSTOM]}
data-testid="free-plan-tag"
{...props}
>
Custom
</Tag>
)
}
}
}

View File

@ -0,0 +1,61 @@
import { Icon, IconProps } from '@chakra-ui/react'
export const StripeClimateLogo = (props: IconProps) => (
<Icon
width="24px"
height="24px"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<linearGradient
id="StripeClimate-gradient-a"
gradientUnits="userSpaceOnUse"
x1="16"
y1="20.6293"
x2="16"
y2="7.8394"
gradientTransform="matrix(1 0 0 -1 0 34)"
>
<stop offset="0" stopColor="#00d924" />
<stop offset="1" stopColor="#00cb1b" />
</linearGradient>
<path
d="M0 10.82h32c0 8.84-7.16 16-16 16s-16-7.16-16-16z"
fill="url(#StripeClimate-gradient-a)"
/>
<linearGradient
id="StripeClimate-gradient-b"
gradientUnits="userSpaceOnUse"
x1="24"
y1="28.6289"
x2="24"
y2="17.2443"
gradientTransform="matrix(1 0 0 -1 0 34)"
>
<stop offset=".1562" stopColor="#009c00" />
<stop offset="1" stopColor="#00be20" />
</linearGradient>
<path
d="M32 10.82c0 2.21-1.49 4.65-5.41 4.65-3.42 0-7.27-2.37-10.59-4.65 3.52-2.43 7.39-5.63 10.59-5.63C29.86 5.18 32 8.17 32 10.82z"
fill="url(#StripeClimate-gradient-b)"
/>
<linearGradient
id="StripeClimate-gradient-c"
gradientUnits="userSpaceOnUse"
x1="8"
y1="16.7494"
x2="8"
y2="29.1239"
gradientTransform="matrix(1 0 0 -1 0 34)"
>
<stop offset="0" stopColor="#ffe37d" />
<stop offset="1" stopColor="#ffc900" />
</linearGradient>
<path
d="M0 10.82c0 2.21 1.49 4.65 5.41 4.65 3.42 0 7.27-2.37 10.59-4.65-3.52-2.43-7.39-5.64-10.59-5.64C2.14 5.18 0 8.17 0 10.82z"
fill="url(#StripeClimate-gradient-c)"
/>
</Icon>
)

View File

@ -0,0 +1,28 @@
import { Button, ButtonProps, useDisclosure } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import React from 'react'
import { isNotDefined } from 'utils'
import { ChangePlanModal } from './ChangePlanModal'
import { LimitReached } from './ChangePlanModal'
type Props = { limitReachedType?: LimitReached } & ButtonProps
export const UpgradeButton = ({ limitReachedType, ...props }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
return (
<Button
colorScheme="blue"
{...props}
isLoading={isNotDefined(workspace)}
onClick={onOpen}
>
{props.children ?? 'Upgrade'}
<ChangePlanModal
isOpen={isOpen}
onClose={onClose}
type={limitReachedType}
/>
</Button>
)
}

View File

@ -0,0 +1,30 @@
import { fetcher } from '@/utils/helpers'
import { Plan } from 'db'
import useSWR from 'swr'
export const useCurrentSubscriptionInfo = ({
stripeId,
plan,
}: {
stripeId?: string | null
plan?: Plan
}) => {
const { data, mutate } = useSWR<
{
additionalChatsIndex: number
additionalStorageIndex: number
},
Error
>(
stripeId && (plan === Plan.STARTER || plan === Plan.PRO)
? `/api/stripe/subscription?stripeId=${stripeId}`
: null,
fetcher
)
return {
data: !stripeId
? { additionalChatsIndex: 0, additionalStorageIndex: 0 }
: data,
mutate,
}
}

View File

@ -0,0 +1,16 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
export const useUsage = (workspaceId?: string) => {
const { data, error } = useSWR<
{ totalChatsUsed: number; totalStorageUsed: number },
Error
>(workspaceId ? `/api/workspaces/${workspaceId}/usage` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
return {
data,
isLoading: !error && !data,
}
}

View File

@ -0,0 +1,8 @@
export { ChangePlanModal, LimitReached } from './components/ChangePlanModal'
export { planToReadable, isFreePlan, isProPlan } from './utils'
export { upgradePlanQuery } from './queries/upgradePlanQuery'
export { BillingContent } from './components/BillingContent'
export { LockTag } from './components/LockTag'
export { useUsage } from './hooks/useUsage'
export { UpgradeButton } from './components/UpgradeButton'
export { PlanTag } from './components/PlanTag'

View File

@ -0,0 +1,78 @@
import { loadStripe } from '@stripe/stripe-js/pure'
import { Plan, User } from 'db'
import {
env,
guessIfUserIsEuropean,
isDefined,
isEmpty,
sendRequest,
} from 'utils'
type UpgradeProps = {
user: User
stripeId?: string
plan: Plan
workspaceId: string
additionalChats: number
additionalStorage: number
}
export const upgradePlanQuery = async ({
stripeId,
...props
}: UpgradeProps): Promise<{ newPlan?: Plan; error?: Error } | void> =>
isDefined(stripeId)
? updatePlan({ ...props, stripeId })
: redirectToCheckout(props)
const updatePlan = async ({
stripeId,
plan,
workspaceId,
additionalChats,
additionalStorage,
}: Omit<UpgradeProps, 'user'>): Promise<{ newPlan?: Plan; error?: Error }> => {
const { data, error } = await sendRequest<{ message: string }>({
method: 'PUT',
url: '/api/stripe/subscription',
body: {
workspaceId,
plan,
stripeId,
additionalChats,
additionalStorage,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
},
})
if (error || !data) return { error }
return { newPlan: plan }
}
const redirectToCheckout = async ({
user,
plan,
workspaceId,
additionalChats,
additionalStorage,
}: Omit<UpgradeProps, 'customerId'>) => {
if (isEmpty(env('STRIPE_PUBLIC_KEY')))
throw new Error('NEXT_PUBLIC_STRIPE_PUBLIC_KEY is missing in env')
const { data, error } = await sendRequest<{ sessionId: string }>({
method: 'POST',
url: '/api/stripe/subscription',
body: {
email: user.email,
currency: guessIfUserIsEuropean() ? 'eur' : 'usd',
plan,
workspaceId,
href: location.origin + location.pathname,
additionalChats,
additionalStorage,
},
})
if (error || !data) return
const stripe = await loadStripe(env('STRIPE_PUBLIC_KEY') as string)
await stripe?.redirectToCheckout({
sessionId: data?.sessionId,
})
}

View File

@ -0,0 +1,25 @@
import { Plan, Workspace } from 'db'
import { isDefined, isNotDefined } from 'utils'
export const planToReadable = (plan?: Plan) => {
if (!plan) return
switch (plan) {
case Plan.FREE:
return 'Free'
case Plan.LIFETIME:
return 'Lifetime'
case Plan.OFFERED:
return 'Offered'
case Plan.PRO:
return 'Pro'
}
}
export const isFreePlan = (workspace?: Pick<Workspace, 'plan'>) =>
isNotDefined(workspace) || workspace?.plan === Plan.FREE
export const isProPlan = (workspace?: Pick<Workspace, 'plan'>) =>
isDefined(workspace) &&
(workspace.plan === Plan.PRO ||
workspace.plan === Plan.LIFETIME ||
workspace.plan === Plan.CUSTOM)

View File

@ -0,0 +1,7 @@
import { Text } from '@chakra-ui/react'
import { EmbedBubbleBlock } from 'models'
export const EmbedBubbleContent = ({ block }: { block: EmbedBubbleBlock }) => {
if (!block.content?.url) return <Text color="gray.500">Click to edit...</Text>
return <Text>Show embed</Text>
}

View File

@ -0,0 +1,7 @@
import { LayoutIcon } from '@/components/icons'
import { IconProps } from '@chakra-ui/react'
import React from 'react'
export const EmbedBubbleIcon = (props: IconProps) => (
<LayoutIcon color="blue.500" {...props} />
)

View File

@ -0,0 +1,47 @@
import { Input, SmartNumberInput } from '@/components/inputs'
import { HStack, Stack, Text } from '@chakra-ui/react'
import { EmbedBubbleContent } from 'models'
import { sanitizeUrl } from 'utils'
type Props = {
content: EmbedBubbleContent
onSubmit: (content: EmbedBubbleContent) => void
}
export const EmbedUploadContent = ({ content, onSubmit }: Props) => {
const handleUrlChange = (url: string) => {
const iframeUrl = sanitizeUrl(
url.trim().startsWith('<iframe') ? extractUrlFromIframe(url) : url
)
onSubmit({ ...content, url: iframeUrl })
}
const handleHeightChange = (height?: number) =>
height && onSubmit({ ...content, height })
return (
<Stack p="2" spacing={6}>
<Stack>
<Input
placeholder="Paste the link or code..."
defaultValue={content?.url ?? ''}
onChange={handleUrlChange}
/>
<Text fontSize="sm" color="gray.400" textAlign="center">
Works with PDFs, iframes, websites...
</Text>
</Stack>
<HStack justify="space-between">
<Text>Height: </Text>
<SmartNumberInput
value={content?.height}
onValueChange={handleHeightChange}
/>
</HStack>
</Stack>
)
}
const extractUrlFromIframe = (iframe: string) =>
[...iframe.matchAll(/src="([^"]+)"/g)][0][1]

View File

@ -0,0 +1,55 @@
import test, { expect } from '@playwright/test'
import { BubbleBlockType, defaultEmbedBubbleContent } from 'models'
import cuid from 'cuid'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { typebotViewer } from 'utils/playwright/testHelpers'
const pdfSrc = 'https://www.orimi.com/pdf-test.pdf'
const siteSrc = 'https://app.cal.com/baptistearno/15min'
test.describe.parallel('Embed bubble block', () => {
test.describe('Content settings', () => {
test('should import and parse embed correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.EMBED,
content: defaultEmbedBubbleContent,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Click to edit...')
await page.fill('input[placeholder="Paste the link or code..."]', pdfSrc)
await expect(page.locator('text="Show embed"')).toBeVisible()
})
})
test.describe('Preview', () => {
test('should display embed correctly', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: BubbleBlockType.EMBED,
content: {
url: siteSrc,
height: 700,
},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await expect(
typebotViewer(page).locator('iframe#embed-bubble-content')
).toHaveAttribute('src', siteSrc)
})
})
})

View File

@ -0,0 +1,3 @@
export { EmbedBubbleContent } from './components/EmbedBubbleContent'
export { EmbedUploadContent } from './components/EmbedUploadContent'
export { EmbedBubbleIcon } from './components/EmbedBubbleIcon'

View File

@ -0,0 +1,21 @@
import { Box, Text, Image } from '@chakra-ui/react'
import { ImageBubbleBlock } from 'models'
export const ImageBubbleContent = ({ block }: { block: ImageBubbleBlock }) => {
const containsVariables =
block.content?.url?.includes('{{') && block.content.url.includes('}}')
return !block.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={
containsVariables ? '/images/dynamic-image.png' : block.content?.url
}
alt="Group image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}

Some files were not shown because too many files have changed in this diff Show More