@@ -25,10 +25,12 @@ import { useApiTokens } from '../hooks/useApiTokens'
|
||||
import { ApiTokenFromServer } from '../types'
|
||||
import { parseTimeSince } from '@/helpers/parseTimeSince'
|
||||
import { deleteApiTokenQuery } from '../queries/deleteApiTokenQuery'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = { user: User }
|
||||
|
||||
export const ApiTokensList = ({ user }: Props) => {
|
||||
const scopedT = useScopedI18n('account.apiTokens')
|
||||
const { showToast } = useToast()
|
||||
const { apiTokens, isLoading, mutate } = useApiTokens({
|
||||
userId: user.id,
|
||||
@@ -55,13 +57,10 @@ export const ApiTokensList = ({ user }: Props) => {
|
||||
|
||||
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>
|
||||
<Heading fontSize="2xl">{scopedT('heading')}</Heading>
|
||||
<Text>{scopedT('description')}</Text>
|
||||
<Flex justifyContent="flex-end">
|
||||
<Button onClick={onCreateOpen}>Create</Button>
|
||||
<Button onClick={onCreateOpen}>{scopedT('createButton.label')}</Button>
|
||||
<CreateTokenModal
|
||||
userId={user.id}
|
||||
isOpen={isCreateOpen}
|
||||
@@ -74,8 +73,8 @@ export const ApiTokensList = ({ user }: Props) => {
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th w="130px">Created</Th>
|
||||
<Th>{scopedT('table.nameHeader')}</Th>
|
||||
<Th w="130px">{scopedT('table.createdHeader')}</Th>
|
||||
<Th w="0" />
|
||||
</Tr>
|
||||
</Thead>
|
||||
@@ -91,7 +90,7 @@ export const ApiTokensList = ({ user }: Props) => {
|
||||
variant="outline"
|
||||
onClick={() => setDeletingId(token.id)}
|
||||
>
|
||||
Delete
|
||||
{scopedT('deleteButton.label')}
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -119,11 +118,14 @@ export const ApiTokensList = ({ user }: Props) => {
|
||||
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?
|
||||
{scopedT('deleteConfirmationMessage', {
|
||||
tokenName: (
|
||||
<strong>{apiTokens?.find(byId(deletingId))?.name}</strong>
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
confirmButtonLabel="Delete"
|
||||
confirmButtonLabel={scopedT('deleteButton.label')}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
|
||||
@@ -10,61 +10,65 @@ import Image from 'next/image'
|
||||
import lightModeIllustration from 'public/images/light-mode.png'
|
||||
import darkModeIllustration from 'public/images/dark-mode.png'
|
||||
import systemModeIllustration from 'public/images/system-mode.png'
|
||||
|
||||
const appearanceData = [
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
image: lightModeIllustration,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
image: darkModeIllustration,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
image: systemModeIllustration,
|
||||
},
|
||||
]
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
defaultValue: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const AppearanceRadioGroup = ({ defaultValue, onChange }: Props) => (
|
||||
<RadioGroup onChange={onChange} defaultValue={defaultValue}>
|
||||
<HStack spacing={4} w="full" align="stretch">
|
||||
{appearanceData.map((option) => (
|
||||
<VStack
|
||||
key={option.value}
|
||||
as="label"
|
||||
htmlFor={option.label}
|
||||
cursor="pointer"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
w="full"
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
pb={6}
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Image
|
||||
src={option.image}
|
||||
alt="Theme preview"
|
||||
style={{ borderRadius: '0.250rem' }}
|
||||
placeholder="blur"
|
||||
/>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">{option.label}</Text>
|
||||
</Stack>
|
||||
</VStack>
|
||||
export const AppearanceRadioGroup = ({ defaultValue, onChange }: Props) => {
|
||||
const scopedT = useScopedI18n('account.preferences.appearance')
|
||||
|
||||
<Radio value={option.value} id={option.label} />
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
)
|
||||
const appearanceData = [
|
||||
{
|
||||
value: 'light',
|
||||
label: scopedT('lightLabel'),
|
||||
image: lightModeIllustration,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: scopedT('darkLabel'),
|
||||
image: darkModeIllustration,
|
||||
},
|
||||
{
|
||||
value: 'system',
|
||||
label: scopedT('systemLabel'),
|
||||
image: systemModeIllustration,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<RadioGroup onChange={onChange} defaultValue={defaultValue}>
|
||||
<HStack spacing={4} w="full" align="stretch">
|
||||
{appearanceData.map((option) => (
|
||||
<VStack
|
||||
key={option.value}
|
||||
as="label"
|
||||
htmlFor={option.label}
|
||||
cursor="pointer"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
w="full"
|
||||
spacing={2}
|
||||
justifyContent="space-between"
|
||||
pb={6}
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Image
|
||||
src={option.image}
|
||||
alt="Theme preview"
|
||||
style={{ borderRadius: '0.250rem' }}
|
||||
placeholder="blur"
|
||||
/>
|
||||
<Stack>
|
||||
<Text fontWeight="bold">{option.label}</Text>
|
||||
</Stack>
|
||||
</VStack>
|
||||
|
||||
<Radio value={option.value} id={option.label} />
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CopyButton } from '@/components/CopyButton'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -31,6 +32,7 @@ export const CreateTokenModal = ({
|
||||
onClose,
|
||||
onNewToken,
|
||||
}: Props) => {
|
||||
const scopedT = useScopedI18n('account.apiTokens.createModal')
|
||||
const [name, setName] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [newTokenValue, setNewTokenValue] = useState<string>()
|
||||
@@ -50,14 +52,14 @@ export const CreateTokenModal = ({
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{newTokenValue ? 'Token Created' : 'Create Token'}
|
||||
{newTokenValue ? scopedT('createdHeading') : scopedT('createHeading')}
|
||||
</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>
|
||||
{scopedT('copyInstruction')}
|
||||
<strong>{scopedT('securityWarning')}</strong>
|
||||
</Text>
|
||||
<InputGroup size="md">
|
||||
<Input readOnly pr="4.5rem" value={newTokenValue} />
|
||||
@@ -68,12 +70,9 @@ export const CreateTokenModal = ({
|
||||
</ModalBody>
|
||||
) : (
|
||||
<ModalBody as="form" onSubmit={createToken}>
|
||||
<Text mb="4">
|
||||
Enter a unique name for your token to differentiate it from other
|
||||
tokens.
|
||||
</Text>
|
||||
<Text mb="4">{scopedT('nameInput.label')}</Text>
|
||||
<Input
|
||||
placeholder="I.e. Zapier, Github, Make.com"
|
||||
placeholder={scopedT('nameInput.placeholder')}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</ModalBody>
|
||||
@@ -82,7 +81,7 @@ export const CreateTokenModal = ({
|
||||
<ModalFooter>
|
||||
{newTokenValue ? (
|
||||
<Button onClick={onClose} colorScheme="blue">
|
||||
Done
|
||||
{scopedT('doneButton.label')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -91,7 +90,7 @@ export const CreateTokenModal = ({
|
||||
isLoading={isSubmitting}
|
||||
onClick={createToken}
|
||||
>
|
||||
Create token
|
||||
{scopedT('createButton.label')}
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MouseIcon, LaptopIcon } from '@/components/icons'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import {
|
||||
HStack,
|
||||
Radio,
|
||||
@@ -9,22 +10,6 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { GraphNavigation } from '@typebot.io/prisma'
|
||||
|
||||
const graphNavigationData = [
|
||||
{
|
||||
value: GraphNavigation.MOUSE,
|
||||
label: 'Mouse',
|
||||
description:
|
||||
'Move by dragging the board and zoom in/out using the scroll wheel',
|
||||
icon: <MouseIcon boxSize="35px" />,
|
||||
},
|
||||
{
|
||||
value: GraphNavigation.TRACKPAD,
|
||||
label: 'Trackpad',
|
||||
description: 'Move the board using 2 fingers and zoom in/out by pinching',
|
||||
icon: <LaptopIcon boxSize="35px" />,
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
defaultValue: string
|
||||
onChange: (value: string) => void
|
||||
@@ -32,33 +17,50 @@ type Props = {
|
||||
export const GraphNavigationRadioGroup = ({
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: Props) => (
|
||||
<RadioGroup onChange={onChange} defaultValue={defaultValue}>
|
||||
<HStack spacing={4} w="full" align="stretch">
|
||||
{graphNavigationData.map((option) => (
|
||||
<VStack
|
||||
key={option.value}
|
||||
as="label"
|
||||
htmlFor={option.label}
|
||||
cursor="pointer"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
w="full"
|
||||
p="6"
|
||||
spacing={6}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<VStack spacing={6}>
|
||||
{option.icon}
|
||||
<Stack>
|
||||
<Text fontWeight="bold">{option.label}</Text>
|
||||
<Text>{option.description}</Text>
|
||||
</Stack>
|
||||
</VStack>
|
||||
}: Props) => {
|
||||
const scopedT = useScopedI18n('account.preferences.graphNavigation')
|
||||
const graphNavigationData = [
|
||||
{
|
||||
value: GraphNavigation.MOUSE,
|
||||
label: scopedT('mouse.label'),
|
||||
description: scopedT('mouse.description'),
|
||||
icon: <MouseIcon boxSize="35px" />,
|
||||
},
|
||||
{
|
||||
value: GraphNavigation.TRACKPAD,
|
||||
label: scopedT('trackpad.label'),
|
||||
description: scopedT('trackpad.description'),
|
||||
icon: <LaptopIcon boxSize="35px" />,
|
||||
},
|
||||
]
|
||||
return (
|
||||
<RadioGroup onChange={onChange} defaultValue={defaultValue}>
|
||||
<HStack spacing={4} w="full" align="stretch">
|
||||
{graphNavigationData.map((option) => (
|
||||
<VStack
|
||||
key={option.value}
|
||||
as="label"
|
||||
htmlFor={option.label}
|
||||
cursor="pointer"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
w="full"
|
||||
p="6"
|
||||
spacing={6}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<VStack spacing={6}>
|
||||
{option.icon}
|
||||
<Stack>
|
||||
<Text fontWeight="bold">{option.label}</Text>
|
||||
<Text>{option.description}</Text>
|
||||
</Stack>
|
||||
</VStack>
|
||||
|
||||
<Radio value={option.value} id={option.label} />
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
)
|
||||
<Radio value={option.value} id={option.label} />
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { ApiTokensList } from './ApiTokensList'
|
||||
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
|
||||
import { useUser } from '../hooks/useUser'
|
||||
import { TextInput } from '@/components/inputs/TextInput'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
export const MyAccountForm = () => {
|
||||
const scopedT = useScopedI18n('account.myAccount')
|
||||
const { user, updateUser } = useUser()
|
||||
const [name, setName] = useState(user?.name ?? '')
|
||||
const [email, setEmail] = useState(user?.email ?? '')
|
||||
@@ -41,10 +43,10 @@ export const MyAccountForm = () => {
|
||||
leftIcon={<UploadIcon />}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
>
|
||||
Change photo
|
||||
{scopedT('changePhotoButton.label')}
|
||||
</UploadButton>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
.jpg or.png, max 1MB
|
||||
{scopedT('changePhotoButton.specification')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
@@ -52,17 +54,17 @@ export const MyAccountForm = () => {
|
||||
<TextInput
|
||||
defaultValue={name}
|
||||
onChange={handleNameChange}
|
||||
label="Name:"
|
||||
label={scopedT('nameInput.label')}
|
||||
withVariableButton={false}
|
||||
debounceTimeout={0}
|
||||
/>
|
||||
<Tooltip label="Updating email is not available. Contact the support if you want to change it.">
|
||||
<Tooltip label={scopedT('emailInput.disabledTooltip')}>
|
||||
<span>
|
||||
<TextInput
|
||||
type="email"
|
||||
defaultValue={email}
|
||||
onChange={handleEmailChange}
|
||||
label="Email address:"
|
||||
label={scopedT('emailInput.label')}
|
||||
withVariableButton={false}
|
||||
debounceTimeout={0}
|
||||
isDisabled
|
||||
|
||||
@@ -4,8 +4,10 @@ import React, { useEffect } from 'react'
|
||||
import { GraphNavigationRadioGroup } from './GraphNavigationRadioGroup'
|
||||
import { AppearanceRadioGroup } from './AppearanceRadioGroup'
|
||||
import { useUser } from '../hooks/useUser'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
export const UserPreferencesForm = () => {
|
||||
const scopedT = useScopedI18n('account.preferences')
|
||||
const { colorMode, setColorMode } = useColorMode()
|
||||
const { user, updateUser } = useUser()
|
||||
|
||||
@@ -26,14 +28,14 @@ export const UserPreferencesForm = () => {
|
||||
return (
|
||||
<Stack spacing={12}>
|
||||
<Stack spacing={6}>
|
||||
<Heading size="md">Editor Navigation</Heading>
|
||||
<Heading size="md">{scopedT('graphNavigation.heading')}</Heading>
|
||||
<GraphNavigationRadioGroup
|
||||
defaultValue={user?.graphNavigation ?? GraphNavigation.TRACKPAD}
|
||||
onChange={changeGraphNavigation}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack spacing={6}>
|
||||
<Heading size="md">Appearance</Heading>
|
||||
<Heading size="md">{scopedT('appearance.heading')}</Heading>
|
||||
<AppearanceRadioGroup
|
||||
defaultValue={
|
||||
user?.preferredAppAppearance
|
||||
|
||||
@@ -11,12 +11,13 @@ import React from 'react'
|
||||
import { useAnswersCount } from '../hooks/useAnswersCount'
|
||||
import { StatsCards } from './StatsCards'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
import { LimitReached } from '@/features/billing/types'
|
||||
import { Graph } from '@/features/graph/components/Graph'
|
||||
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
||||
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
|
||||
import { useI18n } from '@/locales'
|
||||
|
||||
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
const t = useI18n()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const { showToast } = useToast()
|
||||
@@ -69,7 +70,7 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
<ChangePlanModal
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
type={LimitReached.ANALYTICS}
|
||||
type={t('billing.limitMessage.analytics')}
|
||||
/>
|
||||
<StatsCards stats={stats} pos="absolute" top={10} />
|
||||
</Flex>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import {
|
||||
GridProps,
|
||||
SimpleGrid,
|
||||
@@ -22,12 +23,13 @@ export const StatsCards = ({
|
||||
stats,
|
||||
...props
|
||||
}: { stats?: Stats } & GridProps) => {
|
||||
const scopedT = useScopedI18n('analytics')
|
||||
const bg = useColorModeValue('white', 'gray.900')
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing="6" {...props}>
|
||||
<Stat bgColor={bg} p="4" rounded="md" boxShadow="md">
|
||||
<StatLabel>Views</StatLabel>
|
||||
<StatLabel>{scopedT('viewsLabel')}</StatLabel>
|
||||
{stats ? (
|
||||
<StatNumber>{stats.totalViews}</StatNumber>
|
||||
) : (
|
||||
@@ -35,7 +37,7 @@ export const StatsCards = ({
|
||||
)}
|
||||
</Stat>
|
||||
<Stat bgColor={bg} p="4" rounded="md" boxShadow="md">
|
||||
<StatLabel>Starts</StatLabel>
|
||||
<StatLabel>{scopedT('startsLabel')}</StatLabel>
|
||||
{stats ? (
|
||||
<StatNumber>{stats.totalStarts}</StatNumber>
|
||||
) : (
|
||||
@@ -43,7 +45,7 @@ export const StatsCards = ({
|
||||
)}
|
||||
</Stat>
|
||||
<Stat bgColor={bg} p="4" rounded="md" boxShadow="md">
|
||||
<StatLabel>Completion rate</StatLabel>
|
||||
<StatLabel>{scopedT('completionRateLabel')}</StatLabel>
|
||||
{stats ? (
|
||||
<StatNumber>
|
||||
{computeCompletionRate(stats.totalCompleted, stats.totalStarts)}
|
||||
|
||||
152
apps/builder/src/features/auth/api/customAdapter.ts
Normal file
152
apps/builder/src/features/auth/api/customAdapter.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Forked from https://github.com/nextauthjs/adapters/blob/main/packages/prisma/src/index.ts
|
||||
import {
|
||||
PrismaClient,
|
||||
Prisma,
|
||||
WorkspaceRole,
|
||||
Session,
|
||||
} from '@typebot.io/prisma'
|
||||
import type { Adapter, AdapterUser } from 'next-auth/adapters'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { generateId } from '@typebot.io/lib'
|
||||
import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent'
|
||||
import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry'
|
||||
import { convertInvitationsToCollaborations } from '@/features/auth/helpers/convertInvitationsToCollaborations'
|
||||
import { getNewUserInvitations } from '@/features/auth/helpers/getNewUserInvitations'
|
||||
import { joinWorkspaces } from '@/features/auth/helpers/joinWorkspaces'
|
||||
import { parseWorkspaceDefaultPlan } from '@/features/workspace/helpers/parseWorkspaceDefaultPlan'
|
||||
|
||||
export function customAdapter(p: PrismaClient): Adapter {
|
||||
return {
|
||||
createUser: async (data: Omit<AdapterUser, 'id'>) => {
|
||||
if (!data.email)
|
||||
throw Error('Provider did not forward email but it is required')
|
||||
const user = { id: createId(), email: data.email as string }
|
||||
const { invitations, workspaceInvitations } = await getNewUserInvitations(
|
||||
p,
|
||||
user.email
|
||||
)
|
||||
if (
|
||||
process.env.DISABLE_SIGNUP === 'true' &&
|
||||
process.env.ADMIN_EMAIL !== user.email &&
|
||||
invitations.length === 0 &&
|
||||
workspaceInvitations.length === 0
|
||||
)
|
||||
throw Error('New users are forbidden')
|
||||
|
||||
const newWorkspaceData = {
|
||||
name: data.name ? `${data.name}'s workspace` : `My workspace`,
|
||||
plan: parseWorkspaceDefaultPlan(data.email),
|
||||
}
|
||||
const createdUser = await p.user.create({
|
||||
data: {
|
||||
...data,
|
||||
id: user.id,
|
||||
apiTokens: {
|
||||
create: { name: 'Default', token: generateId(24) },
|
||||
},
|
||||
workspaces:
|
||||
workspaceInvitations.length > 0
|
||||
? undefined
|
||||
: {
|
||||
create: {
|
||||
role: WorkspaceRole.ADMIN,
|
||||
workspace: {
|
||||
create: newWorkspaceData,
|
||||
},
|
||||
},
|
||||
},
|
||||
onboardingCategories: [],
|
||||
},
|
||||
include: {
|
||||
workspaces: { select: { workspaceId: true } },
|
||||
},
|
||||
})
|
||||
const newWorkspaceId = createdUser.workspaces.pop()?.workspaceId
|
||||
const events: TelemetryEvent[] = []
|
||||
if (newWorkspaceId) {
|
||||
events.push({
|
||||
name: 'Workspace created',
|
||||
workspaceId: newWorkspaceId,
|
||||
userId: createdUser.id,
|
||||
data: newWorkspaceData,
|
||||
})
|
||||
}
|
||||
events.push({
|
||||
name: 'User created',
|
||||
userId: createdUser.id,
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name ? (data.name as string).split(' ')[0] : undefined,
|
||||
},
|
||||
})
|
||||
await sendTelemetryEvents(events)
|
||||
if (invitations.length > 0)
|
||||
await convertInvitationsToCollaborations(p, user, invitations)
|
||||
if (workspaceInvitations.length > 0)
|
||||
await joinWorkspaces(p, user, workspaceInvitations)
|
||||
return createdUser as AdapterUser
|
||||
},
|
||||
getUser: async (id) =>
|
||||
(await p.user.findUnique({ where: { id } })) as AdapterUser,
|
||||
getUserByEmail: async (email) =>
|
||||
(await p.user.findUnique({ where: { email } })) as AdapterUser,
|
||||
async getUserByAccount(provider_providerAccountId) {
|
||||
const account = await p.account.findUnique({
|
||||
where: { provider_providerAccountId },
|
||||
select: { user: true },
|
||||
})
|
||||
return (account?.user ?? null) as AdapterUser | null
|
||||
},
|
||||
updateUser: async (data) =>
|
||||
(await p.user.update({ where: { id: data.id }, data })) as AdapterUser,
|
||||
deleteUser: async (id) =>
|
||||
(await p.user.delete({ where: { id } })) as AdapterUser,
|
||||
linkAccount: async (data) => {
|
||||
await p.account.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
provider: data.provider,
|
||||
providerAccountId: data.providerAccountId,
|
||||
refresh_token: data.refresh_token,
|
||||
access_token: data.access_token,
|
||||
expires_at: data.expires_at,
|
||||
token_type: data.token_type,
|
||||
scope: data.scope,
|
||||
id_token: data.id_token,
|
||||
session_state: data.session_state,
|
||||
oauth_token_secret: data.oauth_token_secret as string,
|
||||
oauth_token: data.oauth_token as string,
|
||||
refresh_token_expires_in: data.refresh_token_expires_in as number,
|
||||
},
|
||||
})
|
||||
},
|
||||
unlinkAccount: async (provider_providerAccountId) => {
|
||||
await p.account.delete({ where: { provider_providerAccountId } })
|
||||
},
|
||||
async getSessionAndUser(sessionToken) {
|
||||
const userAndSession = await p.session.findUnique({
|
||||
where: { sessionToken },
|
||||
include: { user: true },
|
||||
})
|
||||
if (!userAndSession) return null
|
||||
const { user, ...session } = userAndSession
|
||||
return { user, session } as { user: AdapterUser; session: Session }
|
||||
},
|
||||
createSession: (data) => p.session.create({ data }),
|
||||
updateSession: (data) =>
|
||||
p.session.update({ data, where: { sessionToken: data.sessionToken } }),
|
||||
deleteSession: (sessionToken) =>
|
||||
p.session.delete({ where: { sessionToken } }),
|
||||
createVerificationToken: (data) => p.verificationToken.create({ data }),
|
||||
async useVerificationToken(identifier_token) {
|
||||
try {
|
||||
return await p.verificationToken.delete({ where: { identifier_token } })
|
||||
} catch (error) {
|
||||
if ((error as Prisma.PrismaClientKnownRequestError).code === 'P2025')
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { Alert } from '@chakra-ui/react'
|
||||
|
||||
type Props = {
|
||||
error: string
|
||||
}
|
||||
const errors: Record<string, string> = {
|
||||
Signin: 'Try signing with a different account.',
|
||||
OAuthSignin: 'Try signing with a different account.',
|
||||
OAuthCallback: 'Try signing with a different account.',
|
||||
OAuthCreateAccount: 'Email not found. Try signing with a different provider.',
|
||||
EmailCreateAccount: 'Try signing with a different account.',
|
||||
Callback: 'Try signing with a different account.',
|
||||
OAuthAccountNotLinked:
|
||||
'To confirm your identity, sign in with the same account you used originally.',
|
||||
CredentialsSignin:
|
||||
'Sign in failed. Check the details you provided are correct.',
|
||||
default: 'An error occurred. Please try again.',
|
||||
}
|
||||
|
||||
export const SignInError = ({ error }: Props) => (
|
||||
<Alert status="error" variant="solid" rounded="md">
|
||||
{errors[error] ?? errors[error]}
|
||||
</Alert>
|
||||
)
|
||||
export const SignInError = ({ error }: Props) => {
|
||||
const scopedT = useScopedI18n('auth.error')
|
||||
const errors: Record<string, string> = {
|
||||
Signin: scopedT('default'),
|
||||
OAuthSignin: scopedT('default'),
|
||||
OAuthCallback: scopedT('default'),
|
||||
OAuthCreateAccount: scopedT('email'),
|
||||
EmailCreateAccount: scopedT('default'),
|
||||
Callback: scopedT('default'),
|
||||
OAuthAccountNotLinked: scopedT('oauthNotLinked'),
|
||||
default: scopedT('unknown'),
|
||||
}
|
||||
return (
|
||||
<Alert status="error" variant="solid" rounded="md">
|
||||
{errors[error] ?? errors[error]}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BuiltInProviderType } from 'next-auth/providers'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { SignInError } from './SignInError'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
defaultEmail?: string
|
||||
@@ -34,6 +35,7 @@ type Props = {
|
||||
export const SignInForm = ({
|
||||
defaultEmail,
|
||||
}: Props & HTMLChakraProps<'form'>) => {
|
||||
const scopedT = useScopedI18n('auth')
|
||||
const router = useRouter()
|
||||
const { status } = useSession()
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
@@ -76,8 +78,8 @@ export const SignInForm = ({
|
||||
})
|
||||
if (response?.error) {
|
||||
showToast({
|
||||
title: 'Unauthorized',
|
||||
description: 'Sign ups are disabled.',
|
||||
title: scopedT('signinErrorToast.title'),
|
||||
description: scopedT('signinErrorToast.description'),
|
||||
})
|
||||
} else {
|
||||
setIsMagicLinkSent(true)
|
||||
@@ -89,14 +91,13 @@ export const SignInForm = ({
|
||||
if (hasNoAuthProvider)
|
||||
return (
|
||||
<Text>
|
||||
You need to{' '}
|
||||
{scopedT('noProvider.preLink')}{' '}
|
||||
<TextLink
|
||||
href="https://docs.typebot.io/self-hosting/configuration"
|
||||
isExternal
|
||||
>
|
||||
configure at least one auth provider
|
||||
</TextLink>{' '}
|
||||
(Email, Google, GitHub, Facebook or Azure AD).
|
||||
{scopedT('noProvider.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
)
|
||||
return (
|
||||
@@ -106,7 +107,9 @@ export const SignInForm = ({
|
||||
<SocialLoginButtons providers={providers} />
|
||||
{providers?.email && (
|
||||
<>
|
||||
<DividerWithText mt="6">Or with your email</DividerWithText>
|
||||
<DividerWithText mt="6">
|
||||
{scopedT('orEmailLabel')}
|
||||
</DividerWithText>
|
||||
<HStack as="form" onSubmit={handleEmailSubmit}>
|
||||
<Input
|
||||
name="email"
|
||||
@@ -124,7 +127,7 @@ export const SignInForm = ({
|
||||
}
|
||||
isDisabled={isMagicLinkSent}
|
||||
>
|
||||
Submit
|
||||
{scopedT('emailSubmitButton.label')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
@@ -140,10 +143,8 @@ export const SignInForm = ({
|
||||
<HStack>
|
||||
<AlertIcon />
|
||||
<Stack spacing={1}>
|
||||
<Text fontWeight="semibold">
|
||||
A magic link email was sent. 🪄
|
||||
</Text>
|
||||
<Text fontSize="sm">Make sure to check your SPAM folder.</Text>
|
||||
<Text fontWeight="semibold">{scopedT('magicLink.title')}</Text>
|
||||
<Text fontSize="sm">{scopedT('magicLink.description')}</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
</Alert>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Seo } from '@/components/Seo'
|
||||
import { TextLink } from '@/components/TextLink'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { VStack, Heading, Text } from '@chakra-ui/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { SignInForm } from './SignInForm'
|
||||
@@ -10,26 +11,40 @@ type Props = {
|
||||
}
|
||||
|
||||
export const SignInPage = ({ type }: Props) => {
|
||||
const scopedT = useScopedI18n('auth')
|
||||
const { query } = useRouter()
|
||||
|
||||
return (
|
||||
<VStack spacing={4} h="100vh" justifyContent="center">
|
||||
<Seo title={type === 'signin' ? 'Sign In' : 'Register'} />
|
||||
<Seo
|
||||
title={
|
||||
type === 'signin'
|
||||
? scopedT('signin.heading')
|
||||
: scopedT('register.heading')
|
||||
}
|
||||
/>
|
||||
<Heading
|
||||
onClick={() => {
|
||||
throw new Error('Sentry is working')
|
||||
}}
|
||||
>
|
||||
{type === 'signin' ? 'Sign In' : 'Create an account'}
|
||||
{type === 'signin'
|
||||
? scopedT('signin.heading')
|
||||
: scopedT('register.heading')}
|
||||
</Heading>
|
||||
{type === 'signin' ? (
|
||||
<Text>
|
||||
Don't have an account?{' '}
|
||||
<TextLink href="/register">Sign up for free</TextLink>
|
||||
{scopedT('signin.noAccountLabel.preLink')}{' '}
|
||||
<TextLink href="/register">
|
||||
{scopedT('signin.noAccountLabel.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
Already have an account? <TextLink href="/signin">Sign in</TextLink>
|
||||
{scopedT('register.alreadyHaveAccountLabel.preLink')}{' '}
|
||||
<TextLink href="/signin">
|
||||
{scopedT('register.alreadyHaveAccountLabel.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
)}
|
||||
<SignInForm defaultEmail={query.g?.toString()} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { omit } from '@typebot.io/lib'
|
||||
import { AzureAdLogo } from '@/components/logos/AzureAdLogo'
|
||||
import { FacebookLogo } from '@/components/logos/FacebookLogo'
|
||||
import { GitlabLogo } from '@/components/logos/GitlabLogo'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
providers:
|
||||
@@ -23,6 +24,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
const scopedT = useScopedI18n('auth.socialLogin')
|
||||
const { query } = useRouter()
|
||||
const { status } = useSession()
|
||||
const [authLoading, setAuthLoading] =
|
||||
@@ -63,7 +65,7 @@ export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Continue with GitHub
|
||||
{scopedT('githubButton.label')}
|
||||
</Button>
|
||||
)}
|
||||
{providers?.google && (
|
||||
@@ -77,7 +79,7 @@ export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Continue with Google
|
||||
{scopedT('googleButton.label')}
|
||||
</Button>
|
||||
)}
|
||||
{providers?.facebook && (
|
||||
@@ -91,7 +93,7 @@ export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Continue with Facebook
|
||||
{scopedT('facebookButton.label')}
|
||||
</Button>
|
||||
)}
|
||||
{providers?.gitlab && (
|
||||
@@ -105,7 +107,9 @@ export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Continue with {providers.gitlab.name}
|
||||
{scopedT('gitlabButton.label', {
|
||||
gitlabProviderName: providers.gitlab.name,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{providers?.['azure-ad'] && (
|
||||
@@ -119,7 +123,9 @@ export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Continue with {providers['azure-ad'].name}
|
||||
{scopedT('azureButton.label', {
|
||||
azureProviderName: providers['azure-ad'].name,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{providers?.['custom-oauth'] && (
|
||||
@@ -131,7 +137,9 @@ export const SocialLoginButtons = ({ providers }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Continue with {providers['custom-oauth'].name}
|
||||
{scopedT('customButton.label', {
|
||||
customProviderName: providers['custom-oauth'].name,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { sendMagicLinkEmail } from '@typebot.io/emails'
|
||||
|
||||
type Props = {
|
||||
identifier: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const sendVerificationRequest = async ({ identifier, url }: Props) => {
|
||||
try {
|
||||
await sendMagicLinkEmail({ url, to: identifier })
|
||||
} catch (err) {
|
||||
throw new Error(`Email(s) could not be sent`)
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,7 @@ test('plan changes should work', async ({ page }) => {
|
||||
// Go to customer portal
|
||||
await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
page.click('text="Billing Portal"'),
|
||||
page.click('text="Billing portal"'),
|
||||
])
|
||||
await expect(page.locator('text="Add payment method"')).toBeVisible()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { Button, Link } from '@chakra-ui/react'
|
||||
|
||||
type Props = {
|
||||
@@ -7,6 +8,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const BillingPortalButton = ({ workspaceId }: Props) => {
|
||||
const scopedT = useScopedI18n('billing')
|
||||
const { showToast } = useToast()
|
||||
const { data } = trpc.billing.getBillingPortalUrl.useQuery(
|
||||
{
|
||||
@@ -22,7 +24,7 @@ export const BillingPortalButton = ({ workspaceId }: Props) => {
|
||||
)
|
||||
return (
|
||||
<Button as={Link} href={data?.billingPortalUrl} isLoading={!data}>
|
||||
Billing Portal
|
||||
{scopedT('billingPortalButton.label')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { TextLink } from '@/components/TextLink'
|
||||
import { ChangePlanForm } from './ChangePlanForm'
|
||||
import { UsageProgressBars } from './UsageProgressBars'
|
||||
import { CurrentSubscriptionSummary } from './CurrentSubscriptionSummary'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
export const BillingSettingsLayout = () => {
|
||||
const scopedT = useScopedI18n('billing')
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
|
||||
if (!workspace) return null
|
||||
@@ -24,10 +26,9 @@ export const BillingSettingsLayout = () => {
|
||||
<HStack maxW="500px">
|
||||
<StripeClimateLogo />
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
Typebot is contributing 1% of your subscription to remove CO₂ from
|
||||
the atmosphere.{' '}
|
||||
{scopedT('contribution.preLink')}{' '}
|
||||
<TextLink href="https://climate.stripe.com/5VCRAq" isExternal>
|
||||
More info.
|
||||
{scopedT('contribution.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvi
|
||||
import { useUser } from '@/features/account/hooks/useUser'
|
||||
import { StarterPlanPricingCard } from './StarterPlanPricingCard'
|
||||
import { ProPlanPricingCard } from './ProPlanPricingCard'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
workspace: Pick<Workspace, 'id' | 'stripeId' | 'plan'>
|
||||
@@ -18,6 +19,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
const scopedT = useScopedI18n('billing')
|
||||
const { user } = useUser()
|
||||
const { showToast } = useToast()
|
||||
const [preCheckoutPlan, setPreCheckoutPlan] =
|
||||
@@ -38,7 +40,7 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
onUpgradeSuccess()
|
||||
showToast({
|
||||
status: 'success',
|
||||
description: `Workspace ${plan} plan successfully updated 🎉`,
|
||||
description: scopedT('updateSuccessToast.description', { plan }),
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -123,9 +125,9 @@ export const ChangePlanForm = ({ workspace, onUpgradeSuccess }: Props) => {
|
||||
/>
|
||||
</HStack>
|
||||
<Text color="gray.500">
|
||||
Need custom limits? Specific features?{' '}
|
||||
{scopedT('customLimit.preLink')}{' '}
|
||||
<TextLink href={'https://typebot.io/enterprise-lead-form'} isExternal>
|
||||
Let's chat!
|
||||
{scopedT('customLimit.link')}
|
||||
</TextLink>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AlertInfo } from '@/components/AlertInfo'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -10,11 +11,10 @@ import {
|
||||
Button,
|
||||
HStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { LimitReached } from '../types'
|
||||
import { ChangePlanForm } from './ChangePlanForm'
|
||||
|
||||
type ChangePlanModalProps = {
|
||||
type?: LimitReached
|
||||
type?: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const ChangePlanModal = ({
|
||||
isOpen,
|
||||
type,
|
||||
}: ChangePlanModalProps) => {
|
||||
const t = useI18n()
|
||||
const { workspace, refreshWorkspace } = useWorkspace()
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
||||
@@ -32,7 +33,7 @@ export const ChangePlanModal = ({
|
||||
<ModalBody as={Stack} spacing="6" pt="10">
|
||||
{type && (
|
||||
<AlertInfo>
|
||||
You need to upgrade your plan in order to {type}
|
||||
{t('billing.upgradeLimitLabel', { type: type })}
|
||||
</AlertInfo>
|
||||
)}
|
||||
{workspace && (
|
||||
@@ -46,7 +47,7 @@ export const ChangePlanModal = ({
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button colorScheme="gray" onClick={onClose}>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PlanTag } from './PlanTag'
|
||||
import { BillingPortalButton } from './BillingPortalButton'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Workspace } from '@typebot.io/schemas'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
workspace: Pick<Workspace, 'id' | 'plan' | 'stripeId'>
|
||||
@@ -16,6 +17,7 @@ export const CurrentSubscriptionSummary = ({
|
||||
workspace,
|
||||
onCancelSuccess,
|
||||
}: Props) => {
|
||||
const scopedT = useScopedI18n('billing.currentSubscription')
|
||||
const { showToast } = useToast()
|
||||
|
||||
const { mutate: cancelSubscription, isLoading: isCancelling } =
|
||||
@@ -34,9 +36,9 @@ export const CurrentSubscriptionSummary = ({
|
||||
|
||||
return (
|
||||
<Stack spacing="4">
|
||||
<Heading fontSize="3xl">Subscription</Heading>
|
||||
<Heading fontSize="3xl">{scopedT('heading')}</Heading>
|
||||
<HStack data-testid="current-subscription">
|
||||
<Text>Current workspace subscription: </Text>
|
||||
<Text>{scopedT('subheading')} </Text>
|
||||
{isCancelling ? (
|
||||
<Spinner color="gray.500" size="xs" />
|
||||
) : (
|
||||
@@ -52,7 +54,7 @@ export const CurrentSubscriptionSummary = ({
|
||||
cancelSubscription({ workspaceId: workspace.id })
|
||||
}
|
||||
>
|
||||
Cancel my subscription
|
||||
{scopedT('cancelLink')}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
@@ -62,10 +64,7 @@ export const CurrentSubscriptionSummary = ({
|
||||
{isSubscribed && !isCancelling && (
|
||||
<>
|
||||
<Stack spacing="4">
|
||||
<Text fontSize="sm">
|
||||
Need to change payment method or billing information? Head over to
|
||||
your billing portal:
|
||||
</Text>
|
||||
<Text fontSize="sm">{scopedT('billingPortalDescription')}</Text>
|
||||
<BillingPortalButton workspaceId={workspace.id} />
|
||||
</Stack>
|
||||
</>
|
||||
|
||||
@@ -18,12 +18,14 @@ import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export const InvoicesList = ({ workspaceId }: Props) => {
|
||||
const scopedT = useScopedI18n('billing.invoices')
|
||||
const { showToast } = useToast()
|
||||
const { data, status } = trpc.billing.listInvoices.useQuery(
|
||||
{
|
||||
@@ -38,9 +40,9 @@ export const InvoicesList = ({ workspaceId }: Props) => {
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<Heading fontSize="3xl">Invoices</Heading>
|
||||
<Heading fontSize="3xl">{scopedT('heading')}</Heading>
|
||||
{data?.invoices.length === 0 && status !== 'loading' ? (
|
||||
<Text>No invoices found for this workspace.</Text>
|
||||
<Text>{scopedT('empty')}</Text>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -48,8 +50,8 @@ export const InvoicesList = ({ workspaceId }: Props) => {
|
||||
<Tr>
|
||||
<Th w="0" />
|
||||
<Th>#</Th>
|
||||
<Th>Paid at</Th>
|
||||
<Th>Subtotal</Th>
|
||||
<Th>{scopedT('paidAt')}</Th>
|
||||
<Th>{scopedT('subtotal')}</Th>
|
||||
<Th w="0" />
|
||||
</Tr>
|
||||
</Thead>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import { Tag, TagProps, ThemeTypings } from '@chakra-ui/react'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useRouter } from 'next/router'
|
||||
import React, { FormEvent, useState } from 'react'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { taxIdTypes } from '../taxIdTypes'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
export type PreCheckoutModalProps = {
|
||||
selectedSubscription:
|
||||
@@ -48,6 +49,7 @@ export const PreCheckoutModal = ({
|
||||
existingEmail,
|
||||
onClose,
|
||||
}: PreCheckoutModalProps) => {
|
||||
const scopedT = useScopedI18n('billing.preCheckoutModal')
|
||||
const { ref } = useParentModal()
|
||||
const vatValueInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const router = useRouter()
|
||||
@@ -131,7 +133,7 @@ export const PreCheckoutModal = ({
|
||||
<Stack as="form" spacing="4" onSubmit={goToCheckout}>
|
||||
<TextInput
|
||||
isRequired
|
||||
label="Company name"
|
||||
label={scopedT('companyInput.label')}
|
||||
defaultValue={customer.company}
|
||||
onChange={updateCustomerCompany}
|
||||
withVariableButton={false}
|
||||
@@ -140,17 +142,17 @@ export const PreCheckoutModal = ({
|
||||
<TextInput
|
||||
isRequired
|
||||
type="email"
|
||||
label="Email"
|
||||
label={scopedT('emailInput.label')}
|
||||
defaultValue={customer.email}
|
||||
onChange={updateCustomerEmail}
|
||||
withVariableButton={false}
|
||||
debounceTimeout={0}
|
||||
/>
|
||||
<FormControl>
|
||||
<FormLabel>Tax ID</FormLabel>
|
||||
<FormLabel>{scopedT('taxId.label')}</FormLabel>
|
||||
<HStack>
|
||||
<Select
|
||||
placeholder="ID type"
|
||||
placeholder={scopedT('taxId.placeholder')}
|
||||
items={vatCodeLabels}
|
||||
isPopoverMatchingInputWidth={false}
|
||||
onSelect={updateVatType}
|
||||
@@ -171,7 +173,7 @@ export const PreCheckoutModal = ({
|
||||
colorScheme="blue"
|
||||
isDisabled={customer.company === '' || customer.email === ''}
|
||||
>
|
||||
Go to checkout
|
||||
{scopedT('submitButton.label')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { FeaturesList } from './FeaturesList'
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
initialChatsLimitIndex?: number
|
||||
@@ -48,6 +49,8 @@ export const ProPlanPricingCard = ({
|
||||
isLoading,
|
||||
onPayClick,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('billing.pricingCard')
|
||||
const { workspace } = useWorkspace()
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
@@ -94,15 +97,15 @@ export const ProPlanPricingCard = ({
|
||||
)
|
||||
return ''
|
||||
if (workspace?.plan === Plan.PRO) {
|
||||
if (isCurrentPlan) return 'Your current plan'
|
||||
if (isCurrentPlan) return scopedT('upgradeButton.current')
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== initialChatsLimitIndex ||
|
||||
selectedStorageLimitIndex !== initialStorageLimitIndex
|
||||
)
|
||||
return 'Update'
|
||||
return t('update')
|
||||
}
|
||||
return 'Upgrade'
|
||||
return t('upgrade')
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
@@ -139,15 +142,17 @@ export const ProPlanPricingCard = ({
|
||||
fontWeight="semibold"
|
||||
style={{ marginTop: 0 }}
|
||||
>
|
||||
Most popular
|
||||
{scopedT('pro.mostPopularLabel')}
|
||||
</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>
|
||||
{scopedT('heading', {
|
||||
plan: <chakra.span color="blue.400">Pro</chakra.span>,
|
||||
})}
|
||||
</Heading>
|
||||
<Text>For agencies & growing startups.</Text>
|
||||
<Text>{scopedT('pro.description')}</Text>
|
||||
</Stack>
|
||||
<Stack spacing="4">
|
||||
<Heading>
|
||||
@@ -159,16 +164,16 @@ export const ProPlanPricingCard = ({
|
||||
) ?? NaN,
|
||||
currency
|
||||
)}
|
||||
<chakra.span fontSize="md">/ month</chakra.span>
|
||||
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
|
||||
</Heading>
|
||||
<Text fontWeight="bold">
|
||||
<Tooltip
|
||||
label={
|
||||
<FeaturesList
|
||||
features={[
|
||||
'Branding removed',
|
||||
'File upload input block',
|
||||
'Create folders',
|
||||
scopedT('starter.brandingRemoved'),
|
||||
scopedT('starter.fileUploadBlock'),
|
||||
scopedT('starter.createFolders'),
|
||||
]}
|
||||
spacing="0"
|
||||
/>
|
||||
@@ -177,14 +182,14 @@ export const ProPlanPricingCard = ({
|
||||
placement="top"
|
||||
>
|
||||
<chakra.span textDecoration="underline" cursor="pointer">
|
||||
Everything in Starter
|
||||
{scopedT('pro.everythingFromStarter')}
|
||||
</chakra.span>
|
||||
</Tooltip>
|
||||
, plus:
|
||||
{scopedT('plus')}
|
||||
</Text>
|
||||
<FeaturesList
|
||||
features={[
|
||||
'5 seats included',
|
||||
scopedT('pro.includedSeats'),
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
@@ -242,12 +247,9 @@ export const ProPlanPricingCard = ({
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
chats/mo
|
||||
{scopedT('chatsPerMonth')}
|
||||
</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>
|
||||
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
@@ -318,16 +320,14 @@ export const ProPlanPricingCard = ({
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
GB of storage
|
||||
{scopedT('storageLimit')}
|
||||
</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.
|
||||
{scopedT('storageLimitTooltip')}
|
||||
</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
'Custom domains',
|
||||
'In-depth analytics',
|
||||
scopedT('pro.customDomains'),
|
||||
scopedT('pro.analytics'),
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@typebot.io/lib/pricing'
|
||||
import { FeaturesList } from './FeaturesList'
|
||||
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
initialChatsLimitIndex?: number
|
||||
@@ -44,6 +45,8 @@ export const StarterPlanPricingCard = ({
|
||||
currency,
|
||||
onPayClick,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('billing.pricingCard')
|
||||
const { workspace } = useWorkspace()
|
||||
const [selectedChatsLimitIndex, setSelectedChatsLimitIndex] =
|
||||
useState<number>()
|
||||
@@ -89,17 +92,17 @@ export const StarterPlanPricingCard = ({
|
||||
selectedStorageLimitIndex === undefined
|
||||
)
|
||||
return ''
|
||||
if (workspace?.plan === Plan.PRO) return 'Downgrade'
|
||||
if (workspace?.plan === Plan.PRO) return t('downgrade')
|
||||
if (workspace?.plan === Plan.STARTER) {
|
||||
if (isCurrentPlan) return 'Your current plan'
|
||||
if (isCurrentPlan) return scopedT('upgradeButton.current')
|
||||
|
||||
if (
|
||||
selectedChatsLimitIndex !== initialChatsLimitIndex ||
|
||||
selectedStorageLimitIndex !== initialStorageLimitIndex
|
||||
)
|
||||
return 'Update'
|
||||
return t('update')
|
||||
}
|
||||
return 'Upgrade'
|
||||
return t('upgrade')
|
||||
}
|
||||
|
||||
const handlePayClick = async () => {
|
||||
@@ -118,9 +121,11 @@ export const StarterPlanPricingCard = ({
|
||||
<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>
|
||||
{scopedT('heading', {
|
||||
plan: <chakra.span color="orange.400">Starter</chakra.span>,
|
||||
})}
|
||||
</Heading>
|
||||
<Text>For individuals & small businesses.</Text>
|
||||
<Text>{scopedT('starter.description')}</Text>
|
||||
<Heading>
|
||||
{formatPrice(
|
||||
computePrice(
|
||||
@@ -130,11 +135,11 @@ export const StarterPlanPricingCard = ({
|
||||
) ?? NaN,
|
||||
currency
|
||||
)}
|
||||
<chakra.span fontSize="md">/ month</chakra.span>
|
||||
<chakra.span fontSize="md">{scopedT('perMonth')}</chakra.span>
|
||||
</Heading>
|
||||
<FeaturesList
|
||||
features={[
|
||||
'2 seats included',
|
||||
scopedT('starter.includedSeats'),
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
<Menu>
|
||||
@@ -194,12 +199,9 @@ export const StarterPlanPricingCard = ({
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
chats/mo
|
||||
{scopedT('chatsPerMonth')}
|
||||
</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>
|
||||
<MoreInfoTooltip>{scopedT('chatsTooltip')}</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
<HStack key="test">
|
||||
<Text>
|
||||
@@ -260,16 +262,15 @@ export const StarterPlanPricingCard = ({
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>{' '}
|
||||
GB of storage
|
||||
{scopedT('storageLimit')}
|
||||
</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.
|
||||
{scopedT('storageLimitTooltip')}
|
||||
</MoreInfoTooltip>
|
||||
</HStack>,
|
||||
'Branding removed',
|
||||
'File upload input block',
|
||||
'Create folders',
|
||||
scopedT('starter.brandingRemoved'),
|
||||
scopedT('starter.fileUploadBlock'),
|
||||
scopedT('starter.createFolders'),
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import React from 'react'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { ChangePlanModal } from './ChangePlanModal'
|
||||
import { LimitReached } from '../types'
|
||||
import { useI18n } from '@/locales'
|
||||
|
||||
type Props = { limitReachedType?: LimitReached } & ButtonProps
|
||||
type Props = { limitReachedType?: string } & ButtonProps
|
||||
|
||||
export const UpgradeButton = ({ limitReachedType, ...props }: Props) => {
|
||||
const t = useI18n()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { workspace } = useWorkspace()
|
||||
return (
|
||||
@@ -17,7 +18,7 @@ export const UpgradeButton = ({ limitReachedType, ...props }: Props) => {
|
||||
isLoading={isNotDefined(workspace)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{props.children ?? 'Upgrade'}
|
||||
{props.children ?? t('upgrade')}
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -15,12 +15,14 @@ import { parseNumberWithCommas } from '@typebot.io/lib'
|
||||
import { getChatsLimit, getStorageLimit } from '@typebot.io/lib/pricing'
|
||||
import { defaultQueryOptions, trpc } from '@/lib/trpc'
|
||||
import { storageToReadable } from '../helpers/storageToReadable'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
workspace: Workspace
|
||||
}
|
||||
|
||||
export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
const scopedT = useScopedI18n('billing.usage')
|
||||
const { data, isLoading } = trpc.billing.getUsage.useQuery(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
@@ -44,12 +46,12 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
|
||||
return (
|
||||
<Stack spacing={6}>
|
||||
<Heading fontSize="3xl">Usage</Heading>
|
||||
<Heading fontSize="3xl">{scopedT('heading')}</Heading>
|
||||
<Stack spacing={3}>
|
||||
<Flex justifyContent="space-between">
|
||||
<HStack>
|
||||
<Heading fontSize="xl" as="h3">
|
||||
Chats
|
||||
{scopedT('chats.heading')}
|
||||
</Heading>
|
||||
{chatsPercentage >= 80 && (
|
||||
<Tooltip
|
||||
@@ -58,12 +60,10 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
p="3"
|
||||
label={
|
||||
<Text>
|
||||
Your typebots are popular! You will soon reach your
|
||||
plan's chats limit. 🚀
|
||||
{scopedT('chats.alert.soonReach')}
|
||||
<br />
|
||||
<br />
|
||||
Make sure to <strong>update your plan</strong> to increase
|
||||
this limit and continue chatting with your users.
|
||||
{scopedT('chats.alert.updatePlan')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
@@ -73,7 +73,7 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text fontSize="sm" fontStyle="italic" color="gray.500">
|
||||
(resets on 1st of every month)
|
||||
{scopedT('chats.resetInfo')}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
@@ -108,7 +108,7 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
<Flex justifyContent="space-between">
|
||||
<HStack>
|
||||
<Heading fontSize="xl" as="h3">
|
||||
Storage
|
||||
{scopedT('storage.heading')}
|
||||
</Heading>
|
||||
{storagePercentage >= 80 && (
|
||||
<Tooltip
|
||||
@@ -117,13 +117,10 @@ export const UsageProgressBars = ({ workspace }: Props) => {
|
||||
p="3"
|
||||
label={
|
||||
<Text>
|
||||
Your typebots are popular! You will soon reach your
|
||||
plan's storage limit. 🚀
|
||||
{scopedT('storage.alert.soonReach')}
|
||||
<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.
|
||||
{scopedT('storage.alert.updatePlan')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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',
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { FolderPlusIcon } from '@/components/icons'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import { Plan } from '@typebot.io/prisma'
|
||||
import React from 'react'
|
||||
import { useScopedI18n } from '@/locales'
|
||||
import { useI18n, useScopedI18n } from '@/locales'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
import { LockTag } from '@/features/billing/components/LockTag'
|
||||
import { isFreePlan } from '@/features/billing/helpers/isFreePlan'
|
||||
import { LimitReached } from '@/features/billing/types'
|
||||
|
||||
type Props = { isLoading: boolean; onClick: () => void }
|
||||
|
||||
export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
const t = useI18n()
|
||||
const scopedT = useScopedI18n('folders.createFolderButton')
|
||||
const { workspace } = useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
@@ -33,7 +33,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
type={LimitReached.FOLDER}
|
||||
type={t('billing.limitMessage.folder')}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -26,10 +26,11 @@ import { useRouter } from 'next/router'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
import { isFreePlan } from '@/features/billing/helpers/isFreePlan'
|
||||
import { LimitReached } from '@/features/billing/types'
|
||||
import { parseTimeSince } from '@/helpers/parseTimeSince'
|
||||
import { useI18n } from '@/locales'
|
||||
|
||||
export const PublishButton = (props: ButtonProps) => {
|
||||
const t = useI18n()
|
||||
const warningTextColor = useColorModeValue('red.300', 'red.600')
|
||||
const { workspace } = useWorkspace()
|
||||
const { push, query } = useRouter()
|
||||
@@ -72,7 +73,7 @@ export const PublishButton = (props: ButtonProps) => {
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
type={LimitReached.FILE_INPUT}
|
||||
type={t('billing.limitMessage.fileInput')}
|
||||
/>
|
||||
<Tooltip
|
||||
placement="bottom-end"
|
||||
|
||||
@@ -21,12 +21,13 @@ import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { LockTag } from '@/features/billing/components/LockTag'
|
||||
import { UpgradeButton } from '@/features/billing/components/UpgradeButton'
|
||||
import { isProPlan } from '@/features/billing/helpers/isProPlan'
|
||||
import { LimitReached } from '@/features/billing/types'
|
||||
import { CustomDomainsDropdown } from '@/features/customDomains/components/CustomDomainsDropdown'
|
||||
import { TypebotHeader } from '@/features/editor/components/TypebotHeader'
|
||||
import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
|
||||
import { useI18n } from '@/locales'
|
||||
|
||||
export const SharePage = () => {
|
||||
const t = useI18n()
|
||||
const { workspace } = useWorkspace()
|
||||
const { typebot, updateTypebot, publishedTypebot } = useTypebot()
|
||||
const { showToast } = useToast()
|
||||
@@ -129,7 +130,7 @@ export const SharePage = () => {
|
||||
) : (
|
||||
<UpgradeButton
|
||||
colorScheme="gray"
|
||||
limitReachedType={LimitReached.CUSTOM_DOMAIN}
|
||||
limitReachedType={t('billing.limitMessage.customDomain')}
|
||||
>
|
||||
<Text mr="2">Add my domain</Text>{' '}
|
||||
<LockTag plan={Plan.PRO} />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
import { LockTag } from '@/features/billing/components/LockTag'
|
||||
import { isFreePlan } from '@/features/billing/helpers/isFreePlan'
|
||||
import { LimitReached } from '@/features/billing/types'
|
||||
import { useI18n } from '@/locales'
|
||||
|
||||
type Props = {
|
||||
generalSettings: GeneralSettings
|
||||
@@ -19,6 +19,7 @@ export const GeneralSettingsForm = ({
|
||||
generalSettings,
|
||||
onGeneralSettingsChange,
|
||||
}: Props) => {
|
||||
const t = useI18n()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { workspace } = useWorkspace()
|
||||
const isWorkspaceFreePlan = isFreePlan(workspace)
|
||||
@@ -53,7 +54,7 @@ export const GeneralSettingsForm = ({
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
type={LimitReached.BRAND}
|
||||
type={t('billing.limitMessage.brand')}
|
||||
/>
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
|
||||
Reference in New Issue
Block a user