2
0

feat(editor): Team workspaces

This commit is contained in:
Baptiste Arnaud
2022-05-13 15:22:44 -07:00
parent 6c2986590b
commit f0fdf08b00
132 changed files with 3354 additions and 1228 deletions

View File

@ -1,24 +0,0 @@
import { Flex } from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import React from 'react'
export const AccountHeader = () => (
<Flex w="full" borderBottomWidth="1px" justify="center">
<Flex
justify="space-between"
alignItems="center"
h="16"
maxW="1000px"
flex="1"
>
<NextChakraLink
className="w-24"
href="/typebots"
data-testid="authenticated"
>
<TypebotLogo w="30px" />
</NextChakraLink>
</Flex>
</Flex>
)

View File

@ -1,70 +0,0 @@
import {
Stack,
Heading,
HStack,
Button,
Text,
Input,
useToast,
} from '@chakra-ui/react'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useUser } from 'contexts/UserContext'
import { Plan } from 'db'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
import { redeemCoupon } from 'services/coupons'
import { SubscriptionTag } from './SubscriptionTag'
export const BillingSection = () => {
const { reload } = useRouter()
const [isLoading, setIsLoading] = useState(false)
const { user } = useUser()
const toast = useToast({
position: 'top-right',
})
const handleCouponCodeRedeem = async (e: React.FormEvent) => {
e.preventDefault()
const target = e.target as typeof e.target & {
coupon: { value: string }
}
setIsLoading(true)
const { data, error } = await redeemCoupon(target.coupon.value)
if (error) toast({ title: error.name, description: error.message })
else {
toast({ description: data?.message })
setTimeout(reload, 1000)
}
setIsLoading(false)
}
return (
<Stack direction="row" spacing="10" justifyContent={'space-between'}>
<Heading as="h2" fontSize="xl">
Billing
</Heading>
<Stack spacing="6" w="400px">
<HStack>
<Text>Your subscription</Text>
<SubscriptionTag plan={user?.plan} />
</HStack>
{user?.stripeId && (
<Button as={NextChakraLink} href="/api/stripe/customer-portal">
Manage my subscription
</Button>
)}
{user?.plan === Plan.FREE && <UpgradeButton />}
{user?.plan === Plan.FREE && (
<HStack as="form" onSubmit={handleCouponCodeRedeem}>
<Input name="coupon" placeholder="Coupon code..." />
<Button type="submit" isLoading={isLoading}>
Redeem
</Button>
</HStack>
)}
</Stack>
</Stack>
)
}

View File

@ -1,137 +0,0 @@
import {
Stack,
Heading,
HStack,
Avatar,
Button,
FormControl,
FormLabel,
Input,
Tooltip,
Flex,
Text,
InputRightElement,
InputGroup,
} from '@chakra-ui/react'
import { UploadIcon } from 'assets/icons'
import { UploadButton } from 'components/shared/buttons/UploadButton'
import { useUser } from 'contexts/UserContext'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
export const PersonalInfoForm = () => {
const {
user,
updateUser,
saveUser,
hasUnsavedChanges,
isSaving,
isOAuthProvider,
} = useUser()
const [reloadParam, setReloadParam] = useState('')
const [isApiTokenVisible, setIsApiTokenVisible] = useState(false)
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 })
}
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
return (
<Stack direction="row" spacing="10" justifyContent={'space-between'}>
<Heading as="h2" fontSize="xl">
Personal info
</Heading>
<Stack spacing="6" w="400px">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`public/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>
)}
<FormControl>
<FormLabel htmlFor="name">API token</FormLabel>
<InputGroup>
<Input
id="token"
value={user?.apiToken ?? ''}
type={isApiTokenVisible ? 'text' : 'password'}
/>
<InputRightElement mr="3">
<Button size="xs" onClick={toggleTokenVisibility}>
{isApiTokenVisible ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
</Stack>
</Stack>
)
}

View File

@ -1,22 +0,0 @@
import { Tag } from '@chakra-ui/react'
import { Plan } from 'db'
export const SubscriptionTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.FREE: {
return <Tag>Free plan</Tag>
}
case Plan.LIFETIME: {
return <Tag colorScheme="yellow">Lifetime plan</Tag>
}
case Plan.OFFERED: {
return <Tag colorScheme="yellow">Offered</Tag>
}
case Plan.PRO: {
return <Tag colorScheme="orange">Pro plan</Tag>
}
default: {
return <Tag>Free plan</Tag>
}
}
}

View File

@ -7,23 +7,40 @@ import {
Text,
HStack,
Flex,
Avatar,
SkeletonCircle,
Skeleton,
Button,
useDisclosure,
} from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { LogOutIcon, SettingsIcon } from 'assets/icons'
import {
ChevronLeftIcon,
HardDriveIcon,
LogOutIcon,
PlusIcon,
SettingsIcon,
} from 'assets/icons'
import { signOut } from 'next-auth/react'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
export const DashboardHeader = () => {
const { user } = useUser()
const { workspace, workspaces, switchWorkspace, createWorkspace } =
useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const handleLogOut = () => {
localStorage.removeItem('workspaceId')
signOut()
}
const handleCreateNewWorkspace = () =>
createWorkspace(user?.name ?? undefined)
return (
<Flex w="full" borderBottomWidth="1px" justify="center">
<Flex
@ -40,34 +57,72 @@ export const DashboardHeader = () => {
>
<TypebotLogo w="30px" />
</NextChakraLink>
<Menu>
<MenuButton>
<HStack>
<Skeleton isLoaded={user !== undefined}>
<Text>{user?.name}</Text>
</Skeleton>
<SkeletonCircle isLoaded={user !== undefined}>
<Avatar
boxSize="35px"
name={user?.name ?? undefined}
src={user?.image ?? undefined}
/>
</SkeletonCircle>
</HStack>
</MenuButton>
<MenuList>
<MenuItem
as={NextChakraLink}
href="/account"
icon={<SettingsIcon />}
>
My account
</MenuItem>
<MenuItem onClick={handleLogOut} icon={<LogOutIcon />}>
Log out
</MenuItem>
</MenuList>
</Menu>
<HStack>
{user && workspace && (
<WorkspaceSettingsModal
isOpen={isOpen}
onClose={onClose}
user={user}
workspace={workspace}
/>
)}
<Button leftIcon={<SettingsIcon />} onClick={onOpen}>
Settings & Members
</Button>
<Menu placement="bottom-end">
<MenuButton as={Button} variant="outline" px="2">
<HStack>
<SkeletonCircle
isLoaded={workspace !== undefined}
alignItems="center"
display="flex"
boxSize="20px"
>
<EmojiOrImageIcon
boxSize="20px"
icon={workspace?.icon}
defaultIcon={HardDriveIcon}
/>
</SkeletonCircle>
{workspace && (
<Text noOfLines={0} maxW="200px">
{workspace.name}
</Text>
)}
<ChevronLeftIcon transform="rotate(-90deg)" />
</HStack>
</MenuButton>
<MenuList>
{workspaces
?.filter((w) => w.id !== workspace?.id)
.map((workspace) => (
<MenuItem
key={workspace.id}
onClick={() => switchWorkspace(workspace.id)}
>
<HStack>
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="16px"
defaultIcon={HardDriveIcon}
/>
<Text>{workspace.name}</Text>
</HStack>
</MenuItem>
))}
<MenuItem onClick={handleCreateNewWorkspace} icon={<PlusIcon />}>
New workspace
</MenuItem>
<MenuItem
onClick={handleLogOut}
icon={<LogOutIcon />}
color="orange.500"
>
Log out
</MenuItem>
</MenuList>
</Menu>
</HStack>
</Flex>
</Flex>
)

View File

@ -19,15 +19,14 @@ import {
TypebotInDashboard,
useTypebots,
} from 'services/typebots'
import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
import { BackButton } from './FolderContent/BackButton'
import { CreateBotButton } from './FolderContent/CreateBotButton'
import { CreateFolderButton } from './FolderContent/CreateFolderButton'
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
import { SharedTypebotsButton } from './FolderContent/SharedTypebotsButton'
import { TypebotButton } from './FolderContent/TypebotButton'
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
import { OnboardingModal } from './OnboardingModal'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = { folder: DashboardFolder | null }
@ -35,6 +34,7 @@ const dragDistanceTolerance = 20
export const FolderContent = ({ folder }: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const {
setDraggedTypebot,
@ -60,6 +60,7 @@ export const FolderContent = ({ folder }: Props) => {
isLoading: isFolderLoading,
mutate: mutateFolders,
} = useFolders({
workspaceId: workspace?.id,
parentId: folder?.id,
onError: (error) => {
toast({ title: "Couldn't fetch folders", description: error.message })
@ -71,22 +72,13 @@ export const FolderContent = ({ folder }: Props) => {
isLoading: isTypebotLoading,
mutate: mutateTypebots,
} = useTypebots({
workspaceId: workspace?.id,
folderId: folder?.id,
onError: (error) => {
toast({ title: "Couldn't fetch typebots", description: error.message })
},
})
const { totalSharedTypebots } = useSharedTypebotsCount({
userId: folder === null ? user?.id : undefined,
onError: (error) => {
toast({
title: "Couldn't fetch shared typebots",
description: error.message,
})
},
})
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
if (!typebots) return
const { error } = await patchTypebot(typebotId, {
@ -97,9 +89,9 @@ export const FolderContent = ({ folder }: Props) => {
}
const handleCreateFolder = async () => {
if (!folders) return
if (!folders || !workspace) return
setIsCreatingFolder(true)
const { error, data: newFolder } = await createFolder({
const { error, data: newFolder } = await createFolder(workspace.id, {
parentFolderId: folder?.id ?? null,
})
setIsCreatingFolder(false)
@ -164,7 +156,7 @@ export const FolderContent = ({ folder }: Props) => {
return (
<Flex w="full" flex="1" justify="center">
{typebots && user && folder === null && (
{typebots && !isTypebotLoading && user && folder === null && (
<OnboardingModal totalTypebots={typebots.length} />
)}
<Stack w="1000px" spacing={6}>
@ -185,7 +177,6 @@ export const FolderContent = ({ folder }: Props) => {
isLoading={isTypebotLoading}
isFirstBot={typebots?.length === 0 && folder === null}
/>
{totalSharedTypebots > 0 && <SharedTypebotsButton />}
{isFolderLoading && <ButtonSkeleton />}
{folders &&
folders.map((folder) => (

View File

@ -2,18 +2,18 @@ import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
import { FolderPlusIcon } from 'assets/icons'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { isFreePlan } from 'services/user'
import { isFreePlan } from 'services/workspace'
type Props = { isLoading: boolean; onClick: () => void }
export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const { isOpen, onOpen, onClose } = useDisclosure()
const handleClick = () => {
if (isFreePlan(user)) return onOpen()
if (isFreePlan(workspace)) return onOpen()
onClick()
}
return (
@ -24,7 +24,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
>
<HStack>
<Text>Create a folder</Text>
{isFreePlan(user) && <Tag colorScheme="orange">Pro</Tag>}
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
</HStack>
<UpgradeModal
isOpen={isOpen}

View File

@ -1,40 +0,0 @@
import React from 'react'
import { Button, Flex, Text, VStack, WrapItem } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { UsersIcon } from 'assets/icons'
export const SharedTypebotsButton = () => {
const router = useRouter()
const handleTypebotClick = () => router.push(`/typebots/shared`)
return (
<Button
as={WrapItem}
onClick={handleTypebotClick}
display="flex"
flexDir="column"
variant="outline"
color="gray.800"
w="225px"
h="270px"
mr={{ sm: 6 }}
mb={6}
rounded="lg"
whiteSpace="normal"
cursor="pointer"
>
<VStack spacing="4">
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
>
<UsersIcon fontSize="50" color="orange.300" />
</Flex>
<Text>Shared with me</Text>
</VStack>
</Button>
)
}

View File

@ -20,7 +20,7 @@ import { deleteTypebot, importTypebot, getTypebot } from 'services/typebots'
import { Typebot } from 'models'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
import { useDebounce } from 'use-debounce'
import { TypebotIcon } from 'components/shared/TypebotHeader/TypebotIcon'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useUser } from 'contexts/UserContext'
import { Plan } from 'db'
@ -157,7 +157,7 @@ export const TypebotButton = ({
alignItems="center"
fontSize={'4xl'}
>
{<TypebotIcon icon={typebot.icon} boxSize={'35px'} />}
{<EmojiOrImageIcon icon={typebot.icon} boxSize={'35px'} />}
</Flex>
<Text textAlign="center">{typebot.name}</Text>
</VStack>

View File

@ -24,6 +24,7 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
const confettiCanon = useRef<confetti.CreateTypes>()
const [chosenCategories, setChosenCategories] = useState<string[]>([])
const [openedOnce, setOpenedOnce] = useState(false)
const toast = useToast({
position: 'top-right',
@ -37,12 +38,16 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
}, [])
useEffect(() => {
if (openedOnce) return
const isNewUser =
user &&
new Date(user?.createdAt as unknown as string).toDateString() ===
new Date().toDateString() &&
totalTypebots === 0
if (isNewUser) onOpen()
if (isNewUser) {
onOpen()
setOpenedOnce(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user])

View File

@ -0,0 +1,53 @@
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
export const BillingForm = () => {
const { workspace } = useWorkspace()
return (
<Stack spacing="6">
<HStack>
<Text>Workspace subscription: </Text>
<PlanTag plan={workspace?.plan} />
</HStack>
{workspace?.stripeId && (
<>
<Text>
To manage your subscription and download invoices, head over to your
Stripe portal:
</Text>
<Button
as={NextChakraLink}
href={`/api/stripe/customer-portal?workspaceId=${workspace.id}`}
isExternal
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
>
Stripe Portal
</Button>
</>
)}
</Stack>
)
}
const PlanTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.TEAM: {
return <Tag colorScheme="purple">Team</Tag>
}
case Plan.LIFETIME:
case Plan.OFFERED:
case Plan.PRO: {
return <Tag colorScheme="orange">Personal Pro</Tag>
}
default: {
return <Tag colorScheme="gray">Free</Tag>
}
}
}

View File

@ -0,0 +1,118 @@
import {
HStack,
Input,
Button,
Menu,
MenuButton,
MenuList,
Stack,
MenuItem,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import { FormEvent, useState } from 'react'
import { Member, sendInvitation } from 'services/workspace'
type Props = {
workspaceId: string
onNewMember: (member: Member) => void
onNewInvitation: (invitation: WorkspaceInvitation) => void
isLoading: boolean
isLocked: boolean
}
export const AddMemberForm = ({
workspaceId,
onNewMember,
onNewInvitation,
isLoading,
isLocked,
}: Props) => {
const [invitationEmail, setInvitationEmail] = useState('')
const [invitationRole, setInvitationRole] = useState<WorkspaceRole>(
WorkspaceRole.MEMBER
)
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
const handleInvitationSubmit = async (e: FormEvent) => {
e.preventDefault()
setIsSendingInvitation(true)
const { data } = await sendInvitation({
email: invitationEmail,
type: invitationRole,
workspaceId,
})
if (data?.member) onNewMember(data.member)
if (data?.invitation) onNewInvitation(data.invitation)
setInvitationEmail('')
setIsSendingInvitation(false)
}
return (
<HStack as="form" onSubmit={handleInvitationSubmit} pb="4">
<Input
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
isDisabled={isLocked}
/>
{!isLocked && (
<WorkspaceRoleMenuButton
role={invitationRole}
onChange={setInvitationRole}
/>
)}
<Button
colorScheme={'blue'}
isLoading={isSendingInvitation}
flexShrink={0}
type="submit"
isDisabled={isLoading || isLocked}
>
Invite
</Button>
</HStack>
)
}
const WorkspaceRoleMenuButton = ({
role,
onChange,
}: {
role: WorkspaceRole
onChange: (role: WorkspaceRole) => void
}) => {
return (
<Menu placement="bottom-end" isLazy matchWidth>
<MenuButton
flexShrink={0}
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
>
{convertWorkspaceRoleToReadable(role)}
</MenuButton>
<MenuList minW={0}>
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
<MenuItem onClick={() => onChange(WorkspaceRole.ADMIN)}>
{convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)}
</MenuItem>
<MenuItem onClick={() => onChange(WorkspaceRole.MEMBER)}>
{convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)}
</MenuItem>
</Stack>
</MenuList>
</Menu>
)
}
export const convertWorkspaceRoleToReadable = (role: WorkspaceRole) => {
switch (role) {
case WorkspaceRole.ADMIN:
return 'Admin'
case WorkspaceRole.MEMBER:
return 'Member'
}
}

View File

@ -0,0 +1,109 @@
import {
Avatar,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Stack,
Tag,
Text,
} from '@chakra-ui/react'
import { WorkspaceRole } from 'db'
import React from 'react'
import { convertWorkspaceRoleToReadable } from './AddMemberForm'
type Props = {
image?: string
name?: string
email: string
role: WorkspaceRole
isGuest?: boolean
isMe?: boolean
canEdit: boolean
onDeleteClick: () => void
onSelectNewRole: (role: WorkspaceRole) => void
}
export const MemberItem = ({
email,
name,
image,
role,
isGuest = false,
isMe = false,
canEdit,
onDeleteClick,
onSelectNewRole,
}: Props) => {
const handleAdminClick = () => onSelectNewRole(WorkspaceRole.ADMIN)
const handleMemberClick = () => onSelectNewRole(WorkspaceRole.MEMBER)
return (
<Menu placement="bottom-end" isLazy>
<MenuButton _hover={{ backgroundColor: 'gray.100' }} borderRadius="md">
<MemberIdentityContent
email={email}
name={name}
image={image}
isGuest={isGuest}
tag={convertWorkspaceRoleToReadable(role)}
/>
</MenuButton>
{!isMe && canEdit && (
<MenuList shadow="lg">
<MenuItem onClick={handleAdminClick}>
{convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)}
</MenuItem>
<MenuItem onClick={handleMemberClick}>
{convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)}
</MenuItem>
<MenuItem color="red.500" onClick={onDeleteClick}>
Remove
</MenuItem>
</MenuList>
)}
</Menu>
)
}
export const MemberIdentityContent = ({
name,
tag,
isGuest = false,
image,
email,
}: {
name?: string
tag?: string
image?: string
isGuest?: boolean
email: string
}) => (
<HStack justifyContent="space-between" maxW="full" p="2">
<HStack minW={0} spacing="4">
<Avatar name={name} src={image} size="sm" />
<Stack spacing={0} minW="0">
{name && (
<Text textAlign="left" fontSize="15px">
{name}
</Text>
)}
<Text
color="gray.500"
fontSize={name ? '14px' : 'inherit'}
noOfLines={0}
>
{email}
</Text>
</Stack>
</HStack>
<HStack flexShrink={0}>
{isGuest && (
<Tag color="gray.400" data-testid="tag">
Pending
</Tag>
)}
<Tag data-testid="tag">{tag}</Tag>
</HStack>
</HStack>
)

View File

@ -0,0 +1,132 @@
import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react'
import { UnlockPlanInfo } from 'components/shared/Info'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
import React from 'react'
import {
deleteInvitation,
deleteMember,
Member,
updateInvitation,
updateMember,
useMembers,
} from 'services/workspace'
import { AddMemberForm } from './AddMemberForm'
import { MemberItem } from './MemberItem'
export const MembersList = () => {
const { user } = useUser()
const { workspace, canEdit } = useWorkspace()
const { members, invitations, isLoading, mutate } = useMembers({
workspaceId: workspace?.id,
})
const handleDeleteMemberClick = (memberId: string) => async () => {
if (!workspace || !members || !invitations) return
await deleteMember(workspace.id, memberId)
mutate({
members: members.filter((m) => m.userId !== memberId),
invitations,
})
}
const handleSelectNewRole =
(memberId: string) => async (role: WorkspaceRole) => {
if (!workspace || !members || !invitations) return
await updateMember(workspace.id, { userId: memberId, role })
mutate({
members: members.map((m) =>
m.userId === memberId ? { ...m, role } : m
),
invitations,
})
}
const handleDeleteInvitationClick = (id: string) => async () => {
if (!workspace || !members || !invitations) return
await deleteInvitation({ workspaceId: workspace.id, id })
mutate({
invitations: invitations.filter((i) => i.id !== id),
members,
})
}
const handleSelectNewInvitationRole =
(id: string) => async (type: WorkspaceRole) => {
if (!workspace || !members || !invitations) return
await updateInvitation({ workspaceId: workspace.id, id, type })
mutate({
invitations: invitations.map((i) => (i.id === id ? { ...i, type } : i)),
members,
})
}
const handleNewInvitation = (invitation: WorkspaceInvitation) => {
if (!members || !invitations) return
mutate({
members,
invitations: [...invitations, invitation],
})
}
const handleNewMember = (member: Member) => {
if (!members || !invitations) return
mutate({
members: [...members, member],
invitations,
})
}
return (
<Stack w="full">
{workspace?.plan !== Plan.TEAM && (
<UnlockPlanInfo
contentLabel={
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
}
plan={Plan.TEAM}
/>
)}
{workspace?.id && canEdit && (
<AddMemberForm
workspaceId={workspace.id}
onNewInvitation={handleNewInvitation}
onNewMember={handleNewMember}
isLoading={isLoading}
isLocked={workspace.plan !== Plan.TEAM}
/>
)}
{members?.map((member) => (
<MemberItem
key={member.userId}
email={member.email ?? ''}
image={member.image ?? undefined}
name={member.name ?? undefined}
role={member.role}
isMe={member.userId === user?.id}
onDeleteClick={handleDeleteMemberClick(member.userId)}
onSelectNewRole={handleSelectNewRole(member.userId)}
canEdit={canEdit}
/>
))}
{invitations?.map((invitation) => (
<MemberItem
key={invitation.email}
email={invitation.email ?? ''}
role={invitation.type}
onDeleteClick={handleDeleteInvitationClick(invitation.id)}
onSelectNewRole={handleSelectNewInvitationRole(invitation.id)}
isGuest
canEdit={canEdit}
/>
))}
{isLoading && (
<HStack py="4">
<SkeletonCircle boxSize="32px" />
<SkeletonText width="200px" noOfLines={2} />
</HStack>
)}
</Stack>
)
}

View File

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

View File

@ -0,0 +1,127 @@
import {
Stack,
HStack,
Avatar,
Button,
FormControl,
FormLabel,
Input,
Tooltip,
Flex,
Text,
InputRightElement,
InputGroup,
} from '@chakra-ui/react'
import { UploadIcon } from 'assets/icons'
import { UploadButton } from 'components/shared/buttons/UploadButton'
import { useUser } from 'contexts/UserContext'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
export const MyAccountForm = () => {
const {
user,
updateUser,
saveUser,
hasUnsavedChanges,
isSaving,
isOAuthProvider,
} = useUser()
const [reloadParam, setReloadParam] = useState('')
const [isApiTokenVisible, setIsApiTokenVisible] = useState(false)
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 })
}
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
return (
<Stack spacing="6" w="full">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`public/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>
)}
<FormControl>
<FormLabel htmlFor="name">API token</FormLabel>
<InputGroup>
<Input
id="token"
value={user?.apiToken ?? ''}
type={isApiTokenVisible ? 'text' : 'password'}
/>
<InputRightElement mr="3">
<Button size="xs" onClick={toggleTokenVisibility}>
{isApiTokenVisible ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
</Stack>
)
}

View File

@ -12,8 +12,6 @@ import { useUser } from 'contexts/UserContext'
import { GraphNavigation } from 'db'
import React, { useEffect, useState } from 'react'
export const EditorSection = () => <EditorSettings />
export const EditorSettings = () => {
const { user, saveUser } = useUser()
const [value, setValue] = useState<string>(
@ -44,7 +42,7 @@ export const EditorSettings = () => {
return (
<Stack spacing={6}>
<Heading size="md">Navigation</Heading>
<Heading size="md">Editor Navigation</Heading>
<RadioGroup onChange={setValue} value={value}>
<HStack spacing={4} w="full" align="stretch">
{options.map((option) => (

View File

@ -0,0 +1,45 @@
import { Stack, FormControl, FormLabel, Flex } from '@chakra-ui/react'
import { EditableEmojiOrImageIcon } from 'components/shared/EditableEmojiOrImageIcon'
import { Input } from 'components/shared/Textbox'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
export const WorkspaceSettingsForm = () => {
const { workspace, updateWorkspace } = useWorkspace()
const handleNameChange = (name: string) => {
if (!workspace?.id) return
updateWorkspace(workspace?.id, { name })
}
const handleChangeIcon = (icon: string) => {
if (!workspace?.id) return
updateWorkspace(workspace?.id, { icon })
}
return (
<Stack spacing="6" w="full">
<FormControl>
<FormLabel>Icon</FormLabel>
<Flex>
<EditableEmojiOrImageIcon
icon={workspace?.icon}
onChangeIcon={handleChangeIcon}
boxSize="40px"
/>
</Flex>
</FormControl>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
{workspace && (
<Input
id="name"
withVariableButton={false}
defaultValue={workspace?.name}
onChange={handleNameChange}
/>
)}
</FormControl>
</Stack>
)
}

View File

@ -0,0 +1,157 @@
import {
Modal,
ModalOverlay,
ModalContent,
Stack,
Text,
Button,
Avatar,
Flex,
} from '@chakra-ui/react'
import {
CreditCardIcon,
HardDriveIcon,
SettingsIcon,
UsersIcon,
} from 'assets/icons'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { User, Workspace } from 'db'
import { useState } from 'react'
import { BillingForm } from './BillingForm'
import { MembersList } from './MembersList'
import { MyAccountForm } from './MyAccountForm'
import { EditorSettings } from './UserSettingsForm'
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
type Props = {
isOpen: boolean
user: User
workspace: Workspace
onClose: () => void
}
type SettingsTab =
| 'my-account'
| 'user-settings'
| 'workspace-settings'
| 'members'
| 'billing'
export const WorkspaceSettingsModal = ({
isOpen,
user,
workspace,
onClose,
}: Props) => {
const { canEdit } = useWorkspace()
const [selectedTab, setSelectedTab] = useState<SettingsTab>('my-account')
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent h="600px" flexDir="row">
<Stack spacing={8} w="250px" py="6" borderRightWidth={1} h="full">
<Stack>
<Text pl="4" color="gray.500">
{user.email}
</Text>
<Button
variant={selectedTab === 'my-account' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('my-account')}
leftIcon={
<Avatar
name={user.name ?? undefined}
src={user.image ?? undefined}
boxSize="15px"
/>
}
size="sm"
justifyContent="flex-start"
pl="4"
>
My account
</Button>
<Button
variant={selectedTab === 'user-settings' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('user-settings')}
leftIcon={<SettingsIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Preferences
</Button>
</Stack>
<Stack>
<Text pl="4" color="gray.500">
Workspace
</Text>
{canEdit && (
<Button
variant={
selectedTab === 'workspace-settings' ? 'solid' : 'ghost'
}
onClick={() => setSelectedTab('workspace-settings')}
leftIcon={
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="15px"
defaultIcon={HardDriveIcon}
/>
}
size="sm"
justifyContent="flex-start"
pl="4"
>
Settings
</Button>
)}
<Button
variant={selectedTab === 'members' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('members')}
leftIcon={<UsersIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Members
</Button>
{canEdit && (
<Button
variant={selectedTab === 'billing' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('billing')}
leftIcon={<CreditCardIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Billing
</Button>
)}
</Stack>
</Stack>
<Flex flex="1" p="10">
<SettingsContent tab={selectedTab} />
</Flex>
</ModalContent>
</Modal>
)
}
const SettingsContent = ({ tab }: { tab: SettingsTab }) => {
switch (tab) {
case 'my-account':
return <MyAccountForm />
case 'user-settings':
return <EditorSettings />
case 'workspace-settings':
return <WorkspaceSettingsForm />
case 'members':
return <MembersList />
case 'billing':
return <BillingForm />
default:
return null
}
}

View File

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

View File

@ -5,7 +5,7 @@ import {
ModalContent,
ModalOverlay,
} from '@chakra-ui/react'
import { EditorSettings } from 'components/account/EditorSection'
import { EditorSettings } from 'components/dashboard/WorkspaceSettingsModal/UserSettingsForm'
import React from 'react'
type Props = {

View File

@ -8,10 +8,10 @@ import {
} from '@chakra-ui/react'
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { GeneralSettings } from 'models'
import React from 'react'
import { isFreePlan } from 'services/user'
import { isFreePlan } from 'services/workspace'
type Props = {
generalSettings: GeneralSettings
@ -23,8 +23,8 @@ export const GeneralSettingsForm = ({
onGeneralSettingsChange,
}: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure()
const { user } = useUser()
const isUserFreePlan = isFreePlan(user)
const { workspace } = useWorkspace()
const isUserFreePlan = isFreePlan(workspace)
const handleSwitchChange = () => {
if (generalSettings?.isBrandingEnabled && isUserFreePlan) return
onGeneralSettingsChange({

View File

@ -13,16 +13,17 @@ import { TrashIcon } from 'assets/icons'
import { UpgradeButton } from 'components/shared/buttons/UpgradeButton'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { parseDefaultPublicId } from 'services/typebots'
import { isFreePlan } from 'services/user'
import { isFreePlan } from 'services/workspace'
import { isDefined, isNotDefined } from 'utils'
import { CustomDomainsDropdown } from './customDomain/CustomDomainsDropdown'
import { EditableUrl } from './EditableUrl'
import { integrationsList } from './integrations/EmbedButton'
export const ShareContent = () => {
const { user } = useUser()
const { workspace } = useWorkspace()
const { typebot, updateOnBothTypebots } = useTypebot()
const toast = useToast({
position: 'top-right',
@ -83,7 +84,7 @@ export const ShareContent = () => {
/>
</HStack>
)}
{isFreePlan(user) ? (
{isFreePlan(workspace) ? (
<UpgradeButton colorScheme="gray">
<Text mr="2">Add my domain</Text>{' '}
<Tag colorScheme="orange">Pro</Tag>

View File

@ -23,7 +23,7 @@ const hostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
type CustomDomainModalProps = {
userId: string
workspaceId: string
isOpen: boolean
onClose: () => void
domain?: string
@ -31,7 +31,7 @@ type CustomDomainModalProps = {
}
export const CustomDomainModal = ({
userId,
workspaceId,
isOpen,
onClose,
onNewDomain,
@ -67,7 +67,7 @@ export const CustomDomainModal = ({
const onAddDomainClick = async () => {
if (!hostnameRegex.test(inputValue)) return
setIsLoading(true)
const { error } = await createCustomDomain(userId, {
const { error } = await createCustomDomain(workspaceId, {
name: inputValue,
})
setIsLoading(false)

View File

@ -16,6 +16,7 @@ import React, { useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { CustomDomainModal } from './CustomDomainModal'
import { deleteCustomDomain, useCustomDomains } from 'services/user'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = Omit<MenuButtonProps, 'type'> & {
currentCustomDomain?: string
@ -29,26 +30,26 @@ export const CustomDomainsDropdown = ({
}: Props) => {
const [isDeleting, setIsDeleting] = useState('')
const { isOpen, onOpen, onClose } = useDisclosure()
const { user } = useUser()
const { customDomains, mutate } = useCustomDomains({
userId: user?.id,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const { workspace } = useWorkspace()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { customDomains, mutate } = useCustomDomains({
workspaceId: workspace?.id,
onError: (error) =>
toast({ title: error.name, description: error.message }),
})
const handleMenuItemClick = (customDomain: string) => () =>
onCustomDomainSelect(customDomain)
const handleDeleteDomainClick =
(domainName: string) => async (e: React.MouseEvent) => {
if (!user) return
if (!workspace) return
e.stopPropagation()
setIsDeleting(domainName)
const { error } = await deleteCustomDomain(user.id, domainName)
const { error } = await deleteCustomDomain(workspace.id, domainName)
setIsDeleting('')
if (error) return toast({ title: error.name, description: error.message })
mutate({
@ -59,11 +60,11 @@ export const CustomDomainsDropdown = ({
}
const handleNewDomain = (domain: string) => {
if (!user) return
if (!workspace) return
mutate({
customDomains: [
...(customDomains ?? []),
{ name: domain, ownerId: user?.id },
{ name: domain, workspaceId: workspace?.id },
],
})
handleMenuItemClick(domain)()
@ -71,9 +72,9 @@ export const CustomDomainsDropdown = ({
return (
<Menu isLazy placement="bottom-start" matchWidth>
{user?.id && (
{workspace?.id && (
<CustomDomainModal
userId={user.id}
workspaceId={workspace.id}
isOpen={isOpen}
onClose={onClose}
onNewDomain={handleNewDomain}

View File

@ -12,10 +12,10 @@ import {
} from '@chakra-ui/react'
import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
import React, { useEffect, useMemo, useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { useRouter } from 'next/router'
import { CredentialsType } from 'models'
import { deleteCredentials, useCredentials } from 'services/user'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = Omit<MenuButtonProps, 'type'> & {
type: CredentialsType
@ -36,13 +36,13 @@ export const CredentialsDropdown = ({
...props
}: Props) => {
const router = useRouter()
const { user } = useUser()
const { workspace } = useWorkspace()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { credentials, mutate } = useCredentials({
userId: user?.id,
workspaceId: workspace?.id,
})
const [isDeleting, setIsDeleting] = useState<string>()
@ -84,9 +84,9 @@ export const CredentialsDropdown = ({
const handleDeleteDomainClick =
(credentialsId: string) => async (e: React.MouseEvent) => {
e.stopPropagation()
if (!user?.id) return
if (!workspace?.id) return
setIsDeleting(credentialsId)
const { error } = await deleteCredentials(user?.id, credentialsId)
const { error } = await deleteCredentials(workspace.id, credentialsId)
setIsDeleting(undefined)
if (error) return toast({ title: error.name, description: error.message })
onCredentialsSelect(undefined)

View File

@ -4,22 +4,31 @@ import {
chakra,
PopoverTrigger,
PopoverContent,
Flex,
} from '@chakra-ui/react'
import React from 'react'
import { ImageUploadContent } from '../ImageUploadContent'
import { TypebotIcon } from './TypebotIcon'
import { EmojiOrImageIcon } from './EmojiOrImageIcon'
import { ImageUploadContent } from './ImageUploadContent'
type Props = { icon?: string | null; onChangeIcon: (icon: string) => void }
type Props = {
icon?: string | null
onChangeIcon: (icon: string) => void
boxSize?: string
}
export const EditableTypebotIcon = ({ icon, onChangeIcon }: Props) => {
export const EditableEmojiOrImageIcon = ({
icon,
onChangeIcon,
boxSize,
}: Props) => {
return (
<Popover isLazy>
{({ onClose }) => (
<>
<Tooltip label="Change icon">
<chakra.span
<Flex
cursor="pointer"
px="2"
p="2"
rounded="md"
_hover={{ bgColor: 'gray.100' }}
transition="background-color 0.2s"
@ -27,10 +36,14 @@ export const EditableTypebotIcon = ({ icon, onChangeIcon }: Props) => {
>
<PopoverTrigger>
<chakra.span>
<TypebotIcon icon={icon} emojiFontSize="2xl" />
<EmojiOrImageIcon
icon={icon}
emojiFontSize="2xl"
boxSize={boxSize}
/>
</chakra.span>
</PopoverTrigger>
</chakra.span>
</Flex>
</Tooltip>
<PopoverContent p="2">
<ImageUploadContent

View File

@ -1,17 +1,19 @@
import { ToolIcon } from 'assets/icons'
import React from 'react'
import { chakra, Image } from '@chakra-ui/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 TypebotIcon = ({
export const EmojiOrImageIcon = ({
icon,
boxSize = '25px',
emojiFontSize,
defaultIcon = ToolIcon,
}: Props) => {
return (
<>
@ -22,7 +24,7 @@ export const TypebotIcon = ({
boxSize={boxSize}
objectFit={icon.endsWith('.svg') ? undefined : 'cover'}
alt="typebot icon"
rounded="md"
rounded="10%"
/>
) : (
<chakra.span role="img" fontSize={emojiFontSize}>
@ -30,7 +32,7 @@ export const TypebotIcon = ({
</chakra.span>
)
) : (
<ToolIcon boxSize={boxSize} />
defaultIcon({ boxSize })
)}
</>
)

View File

@ -1,7 +1,7 @@
import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react'
import { useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React, { useMemo } from 'react'
import { AnswersCount } from 'services/analytics'
import {
@ -9,7 +9,7 @@ import {
computeSourceCoordinates,
computeDropOffPath,
} from 'services/graph'
import { isFreePlan } from 'services/user'
import { isFreePlan } from 'services/workspace'
import { byId, isDefined } from 'utils'
type Props = {
@ -23,11 +23,11 @@ export const DropOffEdge = ({
blockId,
onUnlockProPlanClick,
}: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const { sourceEndpoints, blocksCoordinates, graphPosition } = useGraph()
const { publishedTypebot } = useTypebot()
const isUserOnFreePlan = isFreePlan(user)
const isUserOnFreePlan = isFreePlan(workspace)
const totalAnswers = useMemo(
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,

View File

@ -15,6 +15,7 @@ import {
import { GoogleLogo } from 'assets/logos'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { Info } from 'components/shared/Info'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
import { getGoogleSheetsConsentScreenUrl } from 'services/integrations'
@ -25,6 +26,7 @@ type Props = {
}
export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
const { workspace } = useWorkspace()
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
<ModalOverlay />
@ -54,7 +56,8 @@ export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
variant="outline"
href={getGoogleSheetsConsentScreenUrl(
window.location.href,
stepId
stepId,
workspace?.id
)}
mx="auto"
>

View File

@ -1,10 +1,8 @@
import { Stack, useDisclosure, Text } from '@chakra-ui/react'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { Input, Textarea } from 'components/shared/Textbox'
import { useTypebot } from 'contexts/TypebotContext'
import { CredentialsType, SendEmailOptions } from 'models'
import React, { useEffect, useState } from 'react'
import { isDefined } from 'utils'
import React, { useState } from 'react'
import { SmtpConfigModal } from './SmtpConfigModal'
type Props = {
@ -13,16 +11,9 @@ type Props = {
}
export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
const { owner } = useTypebot()
const { isOpen, onOpen, onClose } = useDisclosure()
const [refreshCredentialsKey, setRefreshCredentialsKey] = useState(0)
useEffect(() => {
if (isDefined(options.replyTo) || !owner?.email) return
handleReplyToChange(owner.email)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleCredentialsSelect = (credentialsId?: string) => {
setRefreshCredentialsKey(refreshCredentialsKey + 1)
onOptionsChange({
@ -95,7 +86,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
<Input
onChange={handleReplyToChange}
defaultValue={options.replyTo}
placeholder={owner?.email ?? 'email@gmail.com'}
placeholder={'email@gmail.com'}
/>
</Stack>
<Stack>

View File

@ -16,6 +16,7 @@ import { createCredentials } from 'services/user'
import { testSmtpConfig } from 'services/integrations'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'
import { useWorkspace } from 'contexts/WorkspaceContext'
type Props = {
isOpen: boolean
@ -29,6 +30,7 @@ export const SmtpConfigModal = ({
onClose,
}: Props) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [isCreating, setIsCreating] = useState(false)
const toast = useToast({
position: 'top-right',
@ -40,7 +42,7 @@ export const SmtpConfigModal = ({
})
const handleCreateClick = async () => {
if (!user?.email) return
if (!user?.email || !workspace?.id) return
setIsCreating(true)
const { error: testSmtpError } = await testSmtpConfig(
smtpConfig,
@ -53,10 +55,11 @@ export const SmtpConfigModal = ({
description: "We couldn't send the test email with your configuration",
})
}
const { data, error } = await createCredentials(user.id, {
const { data, error } = await createCredentials({
data: smtpConfig,
name: smtpConfig.from.email as string,
type: CredentialsType.SMTP,
workspaceId: workspace.id,
})
setIsCreating(false)
if (error) return toast({ title: error.name, description: error.message })

View File

@ -23,10 +23,13 @@ export const TypebotLinkSettingsForm = ({
return (
<Stack>
<TypebotsDropdown
typebotId={options.typebotId}
onSelectTypebotId={handleTypebotIdChange}
/>
{typebot && (
<TypebotsDropdown
typebotId={options.typebotId}
onSelectTypebotId={handleTypebotIdChange}
currentWorkspaceId={typebot.workspaceId as string}
/>
)}
<BlocksDropdown
blocks={
typebot &&

View File

@ -9,16 +9,22 @@ import { byId } from 'utils'
type Props = {
typebotId?: string
currentWorkspaceId: string
onSelectTypebotId: (typebotId: string | 'current') => void
}
export const TypebotsDropdown = ({ typebotId, onSelectTypebotId }: Props) => {
export const TypebotsDropdown = ({
typebotId,
onSelectTypebotId,
currentWorkspaceId,
}: Props) => {
const { query } = useRouter()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { typebots, isLoading } = useTypebots({
workspaceId: currentWorkspaceId,
allFolders: true,
onError: (e) => toast({ title: e.name, description: e.message }),
})

View File

@ -7,6 +7,7 @@ import {
Text,
useDisclosure,
} from '@chakra-ui/react'
import { Plan } from 'db'
import React from 'react'
import { UpgradeModal } from './modals/UpgradeModal'
import { LimitReached } from './modals/UpgradeModal/UpgradeModal'
@ -22,14 +23,16 @@ export const PublishFirstInfo = (props: AlertProps) => (
<Info {...props}>You need to publish your typebot first</Info>
)
export const UnlockProPlanInfo = ({
export const UnlockPlanInfo = ({
contentLabel,
buttonLabel,
buttonLabel = 'More info',
type,
plan = Plan.PRO,
}: {
contentLabel: string
buttonLabel: string
buttonLabel?: string
type?: LimitReached
plan: Plan
}) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
@ -44,10 +47,10 @@ export const UnlockProPlanInfo = ({
<AlertIcon />
<Text>{contentLabel}</Text>
</HStack>
<Button colorScheme="blue" onClick={onOpen}>
<Button colorScheme="blue" onClick={onOpen} flexShrink={0} ml="2">
{buttonLabel}
</Button>
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} />
<UpgradeModal isOpen={isOpen} onClose={onClose} type={type} plan={plan} />
</Alert>
)
}

View File

@ -0,0 +1,13 @@
import { Heading, Text, VStack } from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import React from 'react'
export const MaintenancePage = () => (
<VStack h="100vh" justify="center">
<TypebotLogo />
<Heading>
The tool is under maintenance for an exciting new feature! 🤩
</Heading>
<Text>Please come back again in 10 minutes.</Text>
</VStack>
)

View File

@ -1,13 +1,16 @@
import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React, { useEffect, useState } from 'react'
import { isCloudProdInstance } from 'services/utils'
import { planToReadable } from 'services/workspace'
import { initBubble } from 'typebot-js'
export const SupportBubble = () => {
const { typebot } = useTypebot()
const { user } = useUser()
const { workspace } = useWorkspace()
const [localTypebotId, setLocalTypebotId] = useState(typebot?.id)
const [localUserId, setLocalUserId] = useState(user?.id)
@ -33,7 +36,7 @@ export const SupportBubble = () => {
Email: user?.email ?? undefined,
'Typebot ID': typebot?.id,
'Avatar URL': user?.image ?? undefined,
Plan: planToReadable(user?.plan),
Plan: planToReadable(workspace?.plan),
},
})
}
@ -42,17 +45,3 @@ export const SupportBubble = () => {
return <></>
}
const planToReadable = (plan?: Plan) => {
if (!plan) return
switch (plan) {
case 'FREE':
return 'Free'
case 'LIFETIME':
return 'Lifetime'
case 'OFFERED':
return 'Offered'
case 'PRO':
return 'Pro'
}
}

View File

@ -10,11 +10,15 @@ import {
MenuList,
SkeletonCircle,
SkeletonText,
Text,
Tag,
Flex,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext'
import { CollaborationType } from 'db'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { CollaborationType, WorkspaceRole } from 'db'
import React, { FormEvent, useState } from 'react'
import {
deleteCollaborator,
@ -27,21 +31,19 @@ import {
deleteInvitation,
sendInvitation,
} from 'services/typebots/invitations'
import {
CollaboratorIdentityContent,
CollaboratorItem,
} from './CollaboratorButton'
import { CollaboratorItem } from './CollaboratorButton'
export const CollaborationList = () => {
const { user } = useUser()
const { typebot, owner } = useTypebot()
const { currentRole, workspace } = useWorkspace()
const { typebot } = useTypebot()
const [invitationType, setInvitationType] = useState<CollaborationType>(
CollaborationType.READ
)
const [invitationEmail, setInvitationEmail] = useState('')
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
const isOwner = user?.email === owner?.email
const hasFullAccess =
(currentRole && currentRole !== WorkspaceRole.GUEST) || false
const toast = useToast({
position: 'top-right',
@ -66,12 +68,12 @@ export const CollaborationList = () => {
} = useInvitations({
typebotId: typebot?.id,
onError: (e) =>
toast({ title: "Couldn't fetch collaborators", description: e.message }),
toast({ title: "Couldn't fetch invitations", description: e.message }),
})
const handleChangeInvitationCollabType =
(email: string) => async (type: CollaborationType) => {
if (!typebot || !isOwner) return
if (!typebot || !hasFullAccess) return
const { error } = await updateInvitation(typebot?.id, email, {
email,
typebotId: typebot.id,
@ -85,7 +87,7 @@ export const CollaborationList = () => {
})
}
const handleDeleteInvitation = (email: string) => async () => {
if (!typebot || !isOwner) return
if (!typebot || !hasFullAccess) return
const { error } = await deleteInvitation(typebot?.id, email)
if (error) return toast({ title: error.name, description: error.message })
mutateInvitations({
@ -95,7 +97,7 @@ export const CollaborationList = () => {
const handleChangeCollaborationType =
(userId: string) => async (type: CollaborationType) => {
if (!typebot || !isOwner) return
if (!typebot || !hasFullAccess) return
const { error } = await updateCollaborator(typebot?.id, userId, {
userId,
type,
@ -109,7 +111,7 @@ export const CollaborationList = () => {
})
}
const handleDeleteCollaboration = (userId: string) => async () => {
if (!typebot || !isOwner) return
if (!typebot || !hasFullAccess) return
const { error } = await deleteCollaborator(typebot?.id, userId)
if (error) return toast({ title: error.name, description: error.message })
mutateCollaborators({
@ -119,7 +121,7 @@ export const CollaborationList = () => {
const handleInvitationSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!typebot || !isOwner) return
if (!typebot || !hasFullAccess) return
setIsSendingInvitation(true)
const { error } = await sendInvitation(typebot.id, {
email: invitationEmail,
@ -133,60 +135,57 @@ export const CollaborationList = () => {
setInvitationEmail('')
}
const hasNobody =
(collaborators ?? []).length > 0 ||
((invitations ?? []).length > 0 &&
!isInvitationsLoading &&
!isCollaboratorsLoading)
return (
<Stack spacing={2}>
{isOwner && (
<HStack
as="form"
onSubmit={handleInvitationSubmit}
pt="4"
px="4"
pb={hasNobody ? '0' : '4'}
>
<Input
size="sm"
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
/>
<Stack spacing={4} py="4">
<HStack as="form" onSubmit={handleInvitationSubmit} px="4">
<Input
size="sm"
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
isDisabled={!hasFullAccess}
/>
{hasFullAccess && (
<CollaborationTypeMenuButton
type={invitationType}
onChange={setInvitationType}
/>
<Button
size="sm"
colorScheme="blue"
isLoading={isSendingInvitation}
flexShrink={0}
type="submit"
>
Invite
</Button>
</HStack>
)}
{owner && (collaborators ?? []).length > 0 && (
<CollaboratorIdentityContent
email={owner.email ?? ''}
name={owner.name ?? undefined}
image={owner.image ?? undefined}
tag="Owner"
/>
)}
<Button
size="sm"
colorScheme="blue"
isLoading={isSendingInvitation}
flexShrink={0}
type="submit"
isDisabled={!hasFullAccess}
>
Invite
</Button>
</HStack>
{workspace && (
<Flex py="2" px="4" justifyContent="space-between">
<HStack minW={0}>
<EmojiOrImageIcon icon={workspace.icon} />
<Text fontSize="15px" noOfLines={0}>
Everyone at {workspace.name}
</Text>
</HStack>
<Tag>
{convertCollaborationTypeEnumToReadable(
CollaborationType.FULL_ACCESS
)}
</Tag>
</Flex>
)}
{invitations?.map(({ email, type }) => (
<CollaboratorItem
key={email}
email={email}
type={type}
isOwner={isOwner}
isOwner={hasFullAccess}
onDeleteClick={handleDeleteInvitation(email)}
onChangeCollaborationType={handleChangeInvitationCollabType(email)}
isGuest
@ -199,7 +198,7 @@ export const CollaborationList = () => {
image={user.image ?? undefined}
name={user.name ?? undefined}
type={type}
isOwner={isOwner}
isOwner={hasFullAccess}
onDeleteClick={handleDeleteCollaboration(userId ?? '')}
onChangeCollaborationType={handleChangeCollaborationType(userId)}
/>
@ -253,5 +252,7 @@ export const convertCollaborationTypeEnumToReadable = (
return 'Can view'
case CollaborationType.WRITE:
return 'Can edit'
case CollaborationType.FULL_ACCESS:
return 'Full access'
}
}

View File

@ -15,8 +15,8 @@ import { useRouter } from 'next/router'
import React from 'react'
import { isNotDefined } from 'utils'
import { PublishButton } from '../buttons/PublishButton'
import { EditableEmojiOrImageIcon } from '../EditableEmojiOrImageIcon'
import { CollaborationMenuButton } from './CollaborationMenuButton'
import { EditableTypebotIcon } from './EditableTypebotIcons'
import { EditableTypebotName } from './EditableTypebotName'
export const headerHeight = 56
@ -123,7 +123,7 @@ export const TypebotHeader = () => {
}
/>
<HStack spacing={1}>
<EditableTypebotIcon
<EditableEmojiOrImageIcon
icon={typebot?.icon}
onChangeIcon={handleChangeIcon}
/>

View File

@ -1,20 +1,28 @@
import { useEffect, useState } from 'react'
import {
Alert,
AlertIcon,
Heading,
Modal,
ModalBody,
ModalCloseButton,
Text,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Stack,
ListItem,
UnorderedList,
ListIcon,
chakra,
Tooltip,
ListProps,
Button,
HStack,
} from '@chakra-ui/react'
import { PricingCard } from './PricingCard'
import { ActionButton } from './ActionButton'
import { pay } from 'services/stripe'
import { useUser } from 'contexts/UserContext'
import { Plan } from 'db'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { TypebotLogo } from 'assets/logos'
import { CheckIcon } from 'assets/icons'
export enum LimitReached {
BRAND = 'Remove branding',
@ -26,10 +34,16 @@ type UpgradeModalProps = {
type?: LimitReached
isOpen: boolean
onClose: () => void
plan?: Plan
}
export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => {
export const UpgradeModal = ({
onClose,
isOpen,
plan = Plan.PRO,
}: UpgradeModalProps) => {
const { user } = useUser()
const { workspace } = useWorkspace()
const [payLoading, setPayLoading] = useState(false)
const [currency, setCurrency] = useState<'usd' | 'eur'>('usd')
@ -39,64 +53,133 @@ export const UpgradeModal = ({ type, onClose, isOpen }: UpgradeModalProps) => {
)
}, [])
let limitLabel
switch (type) {
case LimitReached.BRAND: {
limitLabel = "You can't hide Typebot brand on the Basic plan"
break
}
case LimitReached.CUSTOM_DOMAIN: {
limitLabel = "You can't add your domain with the Basic plan"
break
}
case LimitReached.FOLDER: {
limitLabel = "You can't create folders with the basic plan"
}
}
const handlePayClick = async () => {
if (!user) return
if (!user || !workspace) return
setPayLoading(true)
await pay(user, currency)
await pay({
user,
currency,
plan: plan === Plan.TEAM ? 'team' : 'pro',
workspaceId: workspace.id,
})
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Upgrade to Pro plan</ModalHeader>
<ModalCloseButton />
<ModalBody as={Stack} spacing={6} alignItems="center">
{limitLabel && (
<Alert status="warning" rounded="md">
<AlertIcon />
{limitLabel}
</Alert>
<ModalBody as={Stack} pt="10">
{plan === Plan.PRO ? (
<PersonalProPlanContent currency={currency} />
) : (
<TeamPlanContent currency={currency} />
)}
<PricingCard
data={{
price: currency === 'eur' ? '25€' : '$30',
name: 'Pro plan',
features: [
</ModalBody>
<ModalFooter>
<HStack>
<Button colorScheme="gray" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handlePayClick}
isLoading={payLoading}
colorScheme="blue"
>
Upgrade
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const PersonalProPlanContent = ({ currency }: { currency: 'eur' | 'usd' }) => {
return (
<Stack spacing="4">
<TypebotLogo boxSize="30px" />
<Heading fontSize="2xl">
Upgrade to <chakra.span color="orange.400">Personal Pro</chakra.span>{' '}
plan
</Heading>
<Text>For solo creator who want to do even more.</Text>
<Heading>
{currency === 'eur' ? '39€' : '$39'}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">Everything in Personal, plus:</Text>
<FeatureList
features={[
'Branding removed',
'View incomplete submissions',
'In-depth drop off analytics',
'Unlimited custom domains',
'Organize typebots in folders',
'Unlimited uploads',
]}
/>
</Stack>
)
}
const TeamPlanContent = ({ currency }: { currency: 'eur' | 'usd' }) => {
return (
<Stack spacing="4">
<TypebotLogo boxSize="30px" />
<Heading fontSize="2xl">
Upgrade to <chakra.span color="purple.400">Team</chakra.span> plan
</Heading>
<Text>For teams to build typebots together in one spot.</Text>
<Heading>
{currency === 'eur' ? '99€' : '$99'}
<chakra.span fontSize="md">/ month</chakra.span>
</Heading>
<Text fontWeight="bold">
<Tooltip
label={
<FeatureList
features={[
'Branding removed',
'View incomplete submissions',
'In-depth drop off analytics',
'Custom domains',
'Organize typebots in folders',
'Unlimited uploads',
'Custom Google Analytics events',
],
}}
button={
<ActionButton onClick={handlePayClick} isLoading={payLoading}>
Upgrade now
</ActionButton>
}
/>
</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
]}
spacing="0"
/>
}
hasArrow
placement="top"
>
<chakra.span textDecoration="underline" cursor="pointer">
Everything in Pro
</chakra.span>
</Tooltip>
, plus:
</Text>
<FeatureList
features={[
'Unlimited team members',
'Collaborative workspace',
'Custom roles',
]}
/>
</Stack>
)
}
const FeatureList = ({
features,
...props
}: { features: string[] } & ListProps) => (
<UnorderedList listStyleType="none" spacing={2} {...props}>
{features.map((feat) => (
<ListItem key={feat}>
<ListIcon as={CheckIcon} />
{feat}
</ListItem>
))}
</UnorderedList>
)

View File

@ -8,6 +8,7 @@ import {
} from '@chakra-ui/react'
import { ToolIcon, TemplateIcon, DownloadIcon } from 'assets/icons'
import { useUser } from 'contexts/UserContext'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Typebot } from 'models'
import { useRouter } from 'next/router'
import React, { useState } from 'react'
@ -16,6 +17,7 @@ import { ImportTypebotFromFileButton } from './ImportTypebotFromFileButton'
import { TemplatesModal } from './TemplatesModal'
export const CreateNewTypebotButtons = () => {
const { workspace } = useWorkspace()
const { user } = useUser()
const router = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure()
@ -29,15 +31,16 @@ export const CreateNewTypebotButtons = () => {
})
const handleCreateSubmit = async (typebot?: Typebot) => {
if (!user) return
if (!user || !workspace) return
setIsLoading(true)
const folderId = router.query.folderId?.toString() ?? null
const { error, data } = typebot
? await importTypebot(
{
...typebot,
ownerId: user.id,
folderId,
workspaceId: workspace.id,
ownerId: user.id,
theme: {
...typebot.theme,
chat: {
@ -46,10 +49,11 @@ export const CreateNewTypebotButtons = () => {
},
},
},
user.plan
workspace.plan
)
: await createTypebot({
folderId,
workspaceId: workspace.id,
})
if (error) toast({ description: error.message })
if (data)