♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
9
apps/builder/src/assets/styles/codeMirror.css
Normal file
9
apps/builder/src/assets/styles/codeMirror.css
Normal file
@ -0,0 +1,9 @@
|
||||
.cm-editor {
|
||||
outline: 0px solid transparent !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
border-radius: 5px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
25
apps/builder/src/assets/styles/custom.css
Normal file
25
apps/builder/src/assets/styles/custom.css
Normal 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;
|
||||
} */
|
31
apps/builder/src/assets/styles/plate.css
Normal file
31
apps/builder/src/assets/styles/plate.css
Normal 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;
|
||||
}
|
81
apps/builder/src/assets/styles/routerProgressBar.css
Normal file
81
apps/builder/src/assets/styles/routerProgressBar.css
Normal 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);
|
||||
}
|
||||
}
|
11
apps/builder/src/assets/styles/submissionsTable.css
Normal file
11
apps/builder/src/assets/styles/submissionsTable.css
Normal 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;
|
||||
}
|
8
apps/builder/src/components/AlertInfo.tsx
Normal file
8
apps/builder/src/components/AlertInfo.tsx
Normal 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>
|
||||
)
|
146
apps/builder/src/components/CodeEditor.tsx
Normal file
146
apps/builder/src/components/CodeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
137
apps/builder/src/components/ColorPicker.tsx
Normal file
137
apps/builder/src/components/ColorPicker.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
77
apps/builder/src/components/ConfirmModal.tsx
Normal file
77
apps/builder/src/components/ConfirmModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
108
apps/builder/src/components/ContextMenu.tsx
Normal file
108
apps/builder/src/components/ContextMenu.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
25
apps/builder/src/components/CopyButton.tsx
Normal file
25
apps/builder/src/components/CopyButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
66
apps/builder/src/components/DropdownList.tsx
Normal file
66
apps/builder/src/components/DropdownList.tsx
Normal 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>
|
||||
)
|
||||
}
|
64
apps/builder/src/components/EditableEmojiOrImageIcon.tsx
Normal file
64
apps/builder/src/components/EditableEmojiOrImageIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
39
apps/builder/src/components/EmojiOrImageIcon.tsx
Normal file
39
apps/builder/src/components/EmojiOrImageIcon.tsx
Normal 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 })
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
24
apps/builder/src/components/GoogleLogo.tsx
Normal file
24
apps/builder/src/components/GoogleLogo.tsx
Normal 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>
|
||||
)
|
23
apps/builder/src/components/ImageUploadContent/GiphyLogo.tsx
Normal file
23
apps/builder/src/components/ImageUploadContent/GiphyLogo.tsx
Normal 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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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} />
|
||||
)
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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
1
apps/builder/src/components/ImageUploadContent/index.tsx
Normal file
1
apps/builder/src/components/ImageUploadContent/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { ImageUploadContent } from './ImageUploadContent'
|
16
apps/builder/src/components/MoreInfoTooltip.tsx
Normal file
16
apps/builder/src/components/MoreInfoTooltip.tsx
Normal 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>
|
||||
)
|
||||
}
|
225
apps/builder/src/components/SearchableDropdown.tsx
Normal file
225
apps/builder/src/components/SearchableDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
37
apps/builder/src/components/Seo.tsx
Normal file
37
apps/builder/src/components/Seo.tsx
Normal 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>
|
||||
)
|
||||
}
|
43
apps/builder/src/components/SupportBubble.tsx
Normal file
43
apps/builder/src/components/SupportBubble.tsx
Normal 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 <></>
|
||||
}
|
44
apps/builder/src/components/SwitchWithLabel.tsx
Normal file
44
apps/builder/src/components/SwitchWithLabel.tsx
Normal 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 && (
|
||||
<>
|
||||
<MoreInfoTooltip>{moreInfoContent}</MoreInfoTooltip>
|
||||
</>
|
||||
)}
|
||||
</FormLabel>
|
||||
<Switch isChecked={isChecked} onChange={handleChange} {...switchProps} />
|
||||
</FormControl>
|
||||
)
|
||||
}
|
107
apps/builder/src/components/TableList.tsx
Normal file
107
apps/builder/src/components/TableList.tsx
Normal 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>
|
||||
)
|
||||
}
|
37
apps/builder/src/components/TextLink.tsx
Normal file
37
apps/builder/src/components/TextLink.tsx
Normal 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>
|
||||
)
|
45
apps/builder/src/components/TypebotLogo.tsx
Normal file
45
apps/builder/src/components/TypebotLogo.tsx
Normal 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>
|
||||
)
|
47
apps/builder/src/components/UnlockPlanAlertInfo.tsx
Normal file
47
apps/builder/src/components/UnlockPlanAlertInfo.tsx
Normal 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>
|
||||
)
|
||||
}
|
264
apps/builder/src/components/VariableSearchInput.tsx
Normal file
264
apps/builder/src/components/VariableSearchInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
498
apps/builder/src/components/icons.tsx
Normal file
498
apps/builder/src/components/icons.tsx
Normal 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>
|
||||
)
|
7
apps/builder/src/components/inputs/Input.tsx
Normal file
7
apps/builder/src/components/inputs/Input.tsx
Normal 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} />
|
||||
)
|
55
apps/builder/src/components/inputs/SmartNumberInput.tsx
Normal file
55
apps/builder/src/components/inputs/SmartNumberInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
134
apps/builder/src/components/inputs/TextBox.tsx
Normal file
134
apps/builder/src/components/inputs/TextBox.tsx
Normal 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>
|
||||
)
|
||||
}
|
7
apps/builder/src/components/inputs/Textarea.tsx
Normal file
7
apps/builder/src/components/inputs/Textarea.tsx
Normal 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} />
|
||||
)
|
3
apps/builder/src/components/inputs/index.tsx
Normal file
3
apps/builder/src/components/inputs/index.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export { Input } from './Input'
|
||||
export { Textarea } from './Textarea'
|
||||
export { SmartNumberInput } from './SmartNumberInput'
|
121
apps/builder/src/features/account/UserProvider.tsx
Normal file
121
apps/builder/src/features/account/UserProvider.tsx
Normal 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)
|
49
apps/builder/src/features/account/account.spec.ts
Normal file
49
apps/builder/src/features/account/account.spec.ts
Normal 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()
|
||||
})
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ApiTokensList } from './ApiTokensList'
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { MyAccountForm } from './MyAccountForm'
|
30
apps/builder/src/features/account/hooks/useApiTokens.ts
Normal file
30
apps/builder/src/features/account/hooks/useApiTokens.ts
Normal 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,
|
||||
}
|
||||
}
|
3
apps/builder/src/features/account/index.ts
Normal file
3
apps/builder/src/features/account/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { UserProvider, useUser } from './UserProvider'
|
||||
export type { ApiTokenFromServer } from './types'
|
||||
export { MyAccountForm } from './components/MyAccountForm'
|
@ -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,
|
||||
},
|
||||
})
|
@ -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',
|
||||
})
|
@ -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,
|
||||
})
|
1
apps/builder/src/features/account/types.ts
Normal file
1
apps/builder/src/features/account/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type ApiTokenFromServer = { id: string; name: string; createdAt: string }
|
32
apps/builder/src/features/analytics/analytics.spec.ts
Normal file
32
apps/builder/src/features/analytics/analytics.spec.ts
Normal 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()
|
||||
})
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
25
apps/builder/src/features/analytics/hooks/useAnswersCount.ts
Normal file
25
apps/builder/src/features/analytics/hooks/useAnswersCount.ts
Normal 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,
|
||||
}
|
||||
}
|
2
apps/builder/src/features/analytics/index.ts
Normal file
2
apps/builder/src/features/analytics/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { AnalyticsGraphContainer } from './components/AnalyticsGraphContainer'
|
||||
export type { AnswersCount } from './types'
|
1
apps/builder/src/features/analytics/types.ts
Normal file
1
apps/builder/src/features/analytics/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type AnswersCount = { groupId: string; totalAnswers: number }
|
14
apps/builder/src/features/auth/api/getAuthenticatedUser.ts
Normal file
14
apps/builder/src/features/auth/api/getAuthenticatedUser.ts
Normal 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
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
122
apps/builder/src/features/auth/components/SignInForm.tsx
Normal file
122
apps/builder/src/features/auth/components/SignInForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
38
apps/builder/src/features/auth/components/SignInPage.tsx
Normal file
38
apps/builder/src/features/auth/components/SignInPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
110
apps/builder/src/features/auth/components/SocialLoginButtons.tsx
Normal file
110
apps/builder/src/features/auth/components/SocialLoginButtons.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
3
apps/builder/src/features/auth/components/logos/index.ts
Normal file
3
apps/builder/src/features/auth/components/logos/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { AzureAdLogo } from './AzureAdLogo'
|
||||
export { GitlabLogo } from './GitlabLogo'
|
||||
export { FacebookLogo } from './FacebookLogo'
|
15
apps/builder/src/features/auth/constants.ts
Normal file
15
apps/builder/src/features/auth/constants.ts
Normal 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(),
|
||||
}
|
3
apps/builder/src/features/auth/index.ts
Normal file
3
apps/builder/src/features/auth/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { SignInPage } from './components/SignInPage'
|
||||
export { getAuthenticatedUser } from './api/getAuthenticatedUser'
|
||||
export { mockedUser } from './constants'
|
259
apps/builder/src/features/billing/billing.spec.ts
Normal file
259
apps/builder/src/features/billing/billing.spec.ts
Normal 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()
|
||||
})
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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'
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { UsageContent } from './UsageContent'
|
@ -0,0 +1 @@
|
||||
export { BillingContent } from './BillingContent'
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const cancelSubscriptionQuery = (stripeId: string) =>
|
||||
sendRequest({
|
||||
url: `api/stripe/subscription?stripeId=${stripeId}`,
|
||||
method: 'DELETE',
|
||||
})
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const redirectToBillingPortal = ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string
|
||||
}) => sendRequest(`/api/stripe/billing-portal?workspaceId=${workspaceId}`)
|
@ -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,
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export { ChangePlanForm } from './ChangePlanForm'
|
@ -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>
|
||||
)
|
||||
}
|
14
apps/builder/src/features/billing/components/LockTag.tsx
Normal file
14
apps/builder/src/features/billing/components/LockTag.tsx
Normal 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>
|
||||
)
|
75
apps/builder/src/features/billing/components/PlanTag.tsx
Normal file
75
apps/builder/src/features/billing/components/PlanTag.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
16
apps/builder/src/features/billing/hooks/useUsage.ts
Normal file
16
apps/builder/src/features/billing/hooks/useUsage.ts
Normal 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,
|
||||
}
|
||||
}
|
8
apps/builder/src/features/billing/index.ts
Normal file
8
apps/builder/src/features/billing/index.ts
Normal 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'
|
@ -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,
|
||||
})
|
||||
}
|
25
apps/builder/src/features/billing/utils.ts
Normal file
25
apps/builder/src/features/billing/utils.ts
Normal 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)
|
@ -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>
|
||||
}
|
@ -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} />
|
||||
)
|
@ -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]
|
55
apps/builder/src/features/blocks/bubbles/embed/embed.spec.ts
Normal file
55
apps/builder/src/features/blocks/bubbles/embed/embed.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
3
apps/builder/src/features/blocks/bubbles/embed/index.ts
Normal file
3
apps/builder/src/features/blocks/bubbles/embed/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { EmbedBubbleContent } from './components/EmbedBubbleContent'
|
||||
export { EmbedUploadContent } from './components/EmbedUploadContent'
|
||||
export { EmbedBubbleIcon } from './components/EmbedBubbleIcon'
|
@ -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
Reference in New Issue
Block a user