2
0

Allow user to share a flow publicly and make it duplicatable

Closes #360
This commit is contained in:
Baptiste Arnaud
2023-11-23 12:05:31 +01:00
parent 8a07392821
commit bb41226a04
130 changed files with 1150 additions and 2012 deletions

View File

@ -117,7 +117,7 @@
"eslint-config-custom": "workspace:*",
"next-runtime-env": "1.6.2",
"superjson": "1.12.4",
"typescript": "5.2.2",
"typescript": "5.3.2",
"zod": "3.22.4"
}
}

View File

@ -143,7 +143,7 @@
"editor.blocks.start.text": "Start",
"editor.editableTypebotName.tooltip.rename.label": "Rename",
"editor.gettingStartedModal.editorBasics.heading": "Editor Basics",
"editor.gettingStartedModal.editorBasics.list.four.label": "Preview your bot by clicking the preview button on the top right",
"editor.gettingStartedModal.editorBasics.list.four.label": "Preview your bot by clicking the test button on the top right",
"editor.gettingStartedModal.editorBasics.list.label": "Feel free to use the bottom-right bubble to reach out if you have any question. I usually answer within the next 24 hours. \uD83D\uDE03",
"editor.gettingStartedModal.editorBasics.list.one.label": "The left side bar contains blocks that you can drag and drop to the board.",
"editor.gettingStartedModal.editorBasics.list.three.label": "Connect the groups together",
@ -153,7 +153,7 @@
"editor.gettingStartedModal.seeAction.time": "5 minutes",
"editor.headers.flowButton.label": "Flow",
"editor.headers.helpButton.label": "Help",
"editor.headers.previewButton.label": "Preview",
"editor.headers.previewButton.label": "Test",
"editor.headers.resultsButton.label": "Results",
"editor.headers.savingSpinner.label": "Saving...",
"editor.headers.settingsButton.label": "Settings",

View File

@ -4,6 +4,10 @@ import { ButtonProps, Button, useClipboard } from '@chakra-ui/react'
interface CopyButtonProps extends ButtonProps {
textToCopy: string
onCopied?: () => void
text?: {
copy: string
copied: string
}
}
export const CopyButton = (props: CopyButtonProps) => {
@ -23,7 +27,7 @@ export const CopyButton = (props: CopyButtonProps) => {
}}
{...buttonProps}
>
{!hasCopied ? 'Copy' : 'Copied'}
{!hasCopied ? props.text?.copy ?? 'Copy' : props.text?.copied ?? 'Copied'}
</Button>
)
}

View File

@ -69,8 +69,12 @@ export const UserProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
if (!router.isReady) return
if (status === 'loading') return
const isSigningIn = () => ['/signin', '/register'].includes(router.pathname)
if (!user && status === 'unauthenticated' && !isSigningIn())
const isSignInPath = ['/signin', '/register'].includes(router.pathname)
const isPathPublicFriendly = /\/typebots\/.+\/(edit|theme|settings)/.test(
router.pathname
)
if (isSignInPath || isPathPublicFriendly) return
if (!user && status === 'unauthenticated')
router.replace({
pathname: '/signin',
query: {

View File

@ -11,7 +11,7 @@ export const getTotalAnswers = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/analytics/totalAnswersInBlocks',
path: '/v1/typebots/{typebotId}/analytics/totalAnswersInBlocks',
protect: true,
summary: 'List total answers in blocks',
tags: ['Analytics'],

View File

@ -9,7 +9,7 @@ export const getTotalVisitedEdges = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/analytics/totalVisitedEdges',
path: '/v1/typebots/{typebotId}/analytics/totalVisitedEdges',
protect: true,
summary: 'List total edges used in results',
tags: ['Analytics'],

View File

@ -20,7 +20,7 @@ const totalSteps = 5
export const OnboardingPage = () => {
const { t } = useTranslate()
const { push, replace } = useRouter()
const { replace, query } = useRouter()
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
const confettiCanon = useRef<confetti.CreateTypes>()
const { user, updateUser } = useUser()
@ -38,8 +38,8 @@ export const OnboardingPage = () => {
useEffect(() => {
if (!user?.createdAt) return
if (isNewUser === false || !env.NEXT_PUBLIC_ONBOARDING_TYPEBOT_ID)
replace('/typebots')
}, [isNewUser, replace, user?.createdAt])
replace({ pathname: '/typebots', query })
}, [isNewUser, query, replace, user?.createdAt])
const initConfettis = () => {
if (!confettiCanvaContainer.current || confettiCanon.current) return
@ -83,7 +83,7 @@ export const OnboardingPage = () => {
right="5"
variant="ghost"
size="sm"
onClick={() => push('/typebots')}
onClick={() => replace({ pathname: '/typebots', query })}
>
{t('skip')}
</Button>
@ -95,7 +95,10 @@ export const OnboardingPage = () => {
prefilledVariables={{ Name: user?.name, Email: user?.email }}
onEnd={() => {
setTimeout(() => {
push('/typebots/create', { query: { isFirstBot: true } })
replace({
pathname: '/typebots',
query: { ...query, isFirstBot: true },
})
}, 2000)
}}
onAnswer={updateUserInfo}

View File

@ -11,7 +11,7 @@ export const createCheckoutSession = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/billing/subscription/checkout',
path: '/v1/billing/subscription/checkout',
protect: true,
summary: 'Create checkout session to create a new subscription',
tags: ['Billing'],

View File

@ -11,7 +11,7 @@ export const createCustomCheckoutSession = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/billing/subscription/custom-checkout',
path: '/v1/billing/subscription/custom-checkout',
protect: true,
summary:
'Create custom checkout session to make a workspace pay for a custom plan',

View File

@ -10,7 +10,7 @@ export const getBillingPortalUrl = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/subscription/portal',
path: '/v1/billing/subscription/portal',
protect: true,
summary: 'Get Stripe billing portal URL',
tags: ['Billing'],

View File

@ -11,7 +11,7 @@ export const getSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/subscription',
path: '/v1/billing/subscription',
protect: true,
summary: 'List invoices',
tags: ['Billing'],

View File

@ -10,7 +10,7 @@ export const getUsage = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/usage',
path: '/v1/billing/usage',
protect: true,
summary: 'Get current plan usage',
tags: ['Billing'],

View File

@ -12,7 +12,7 @@ export const listInvoices = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/billing/invoices',
path: '/v1/billing/invoices',
protect: true,
summary: 'List invoices',
tags: ['Billing'],

View File

@ -14,7 +14,7 @@ export const updateSubscription = authenticatedProcedure
.meta({
openapi: {
method: 'PATCH',
path: '/billing/subscription',
path: '/v1/billing/subscription',
protect: true,
summary: 'Update subscription',
tags: ['Billing'],

View File

@ -35,7 +35,7 @@ test('should work as expected', async ({ page }) => {
'gm'
)
)
await page.getByRole('button', { name: 'Preview', exact: true }).click()
await page.getByRole('button', { name: 'Test', exact: true }).click()
await expect(page.locator('audio')).toHaveAttribute(
'src',
RegExp(

View File

@ -44,7 +44,7 @@ test.describe.parallel('Embed bubble block', () => {
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('iframe#embed-bubble-content')).toHaveAttribute(
'src',
siteSrc

View File

@ -116,7 +116,7 @@ test.describe.parallel('Image bubble block', () => {
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('img')).toHaveAttribute('src', unsplashImageSrc)
})
})

View File

@ -49,7 +49,7 @@ test.describe('Text bubble block', () => {
await page.fill('[data-testid="variables-input"]', 'test')
await page.getByRole('menuitem', { name: 'Create test' }).click()
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('span.slate-bold >> nth=0')).toHaveText(
'Bold text'
)

View File

@ -51,7 +51,7 @@ test.describe.parallel('Video bubble block', () => {
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('video').nth(1)).toHaveAttribute(
'src',
videoSrc
@ -75,7 +75,7 @@ test.describe.parallel('Video bubble block', () => {
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('iframe').nth(1)).toHaveAttribute(
'src',
'https://www.youtube.com/embed/dQw4w9WgXcQ'
@ -99,7 +99,7 @@ test.describe.parallel('Video bubble block', () => {
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('iframe').nth(1)).toHaveAttribute(
'src',
'https://player.vimeo.com/video/649301125'

View File

@ -37,7 +37,7 @@ test.describe.parallel('Buttons input block', () => {
await page.click('text=Delete')
await expect(page.locator('text=Item 2')).toBeHidden()
await page.click('text=Preview')
await page.click('text=Test')
await page.getByRole('button', { name: 'Item 3' }).click()
await expect(page.getByRole('button', { name: 'Item 3' })).toBeHidden()
await expect(page.getByTestId('guest-bubble')).toHaveText('Item 3')
@ -57,7 +57,7 @@ test.describe.parallel('Buttons input block', () => {
await page.fill('input[value="Click to edit"]', 'Item 2')
await page.press('input[value="Item 2"]', 'Enter')
await page.click('text=Preview')
await page.click('text=Test')
await page.getByRole('checkbox', { name: 'Item 3' }).click()
await page.getByRole('checkbox', { name: 'Item 1' }).click()
@ -77,7 +77,7 @@ test('Variable buttons should work', async ({ page }) => {
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await page.getByRole('button', { name: 'Variable item' }).click()
await expect(page.getByTestId('guest-bubble')).toHaveText('Variable item')
await expect(page.locator('text=Ok great!')).toBeVisible()

View File

@ -18,7 +18,7 @@ test.describe('Date input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator('[data-testid="from-date"]')).toHaveAttribute(
'type',
'date'

View File

@ -19,7 +19,7 @@ test.describe('Email input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator(
`input[placeholder="${defaultEmailInputOptions.labels.placeholder}"]`

View File

@ -21,7 +21,7 @@ test('options should work', async ({ page }) => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator(`text=Click to upload`)).toBeVisible()
await expect(page.locator(`text="Skip"`)).toBeHidden()
await page

View File

@ -19,7 +19,7 @@ test.describe('Number input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator(
`input[placeholder="${defaultNumberInputOptions.labels.placeholder}"]`

View File

@ -43,7 +43,7 @@ test.describe('Payment input block', () => {
await page.fill('[placeholder="john@gmail.com"]', 'test@typebot.io')
await expect(page.locator('text="Phone number:"')).toBeVisible()
await page.click('text=Preview')
await page.click('text=Test')
await stripePaymentForm(page)
.locator(`[placeholder="1234 1234 1234 1234"]`)
.fill('4000000000000002')

View File

@ -19,7 +19,7 @@ test.describe('Phone input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator(
`input[placeholder="${defaultPhoneInputOptions.labels.placeholder}"]`

View File

@ -54,7 +54,7 @@ test.describe.parallel('Picture choice input block', () => {
await page.getByPlaceholder('Paste the image link...').fill(thirdImageSrc)
await page.getByLabel('Title:').fill('Third image')
await page.getByLabel('Description:').fill('Third description')
await page.getByRole('button', { name: 'Preview' }).click()
await page.getByRole('button', { name: 'Test' }).click()
await expect(
page.getByRole('button', {
name: 'First image First image First description',

View File

@ -30,7 +30,7 @@ test('options should work', async ({ page }) => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(page.locator(`text=Send`)).toBeHidden()
await page.getByRole('button', { name: '8' }).click()
await page.locator(`text=Send`).click()

View File

@ -19,7 +19,7 @@ test.describe.parallel('Text input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`

View File

@ -19,7 +19,7 @@ test.describe('Url input block', () => {
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator(
`input[placeholder="${defaultUrlInputOptions.labels.placeholder}"]`

View File

@ -34,7 +34,7 @@ test.describe('Chatwoot block', () => {
await page.getByLabel('Email').fill('john@email.com')
await page.getByLabel('Avatar URL').fill('https://domain.com/avatar.png')
await page.getByLabel('Phone number').fill('+33654347543')
await page.getByRole('button', { name: 'Preview', exact: true }).click()
await page.getByRole('button', { name: 'Test', exact: true }).click()
await expect(
page.getByText('Chatwoot block is not supported in preview').nth(0)
).toBeVisible()

View File

@ -31,7 +31,7 @@ test.describe.parallel('Google sheets integration', () => {
'Georges'
)
await page.click('text=Preview')
await page.click('text=Test')
await page
.locator('typebot-standard')
.locator('input[placeholder="Type your email..."]')
@ -76,7 +76,7 @@ test.describe.parallel('Google sheets integration', () => {
'Last name'
)
await page.click('text=Preview')
await page.click('text=Test')
await page
.locator('typebot-standard')
.locator('input[placeholder="Type your email..."]')
@ -132,7 +132,7 @@ test.describe.parallel('Google sheets integration', () => {
await page.getByRole('menuitem', { name: 'Last name' }).click()
await createNewVar(page, 'Last name')
await page.click('text=Preview')
await page.click('text=Test')
await page
.locator('typebot-standard')
.locator('input[placeholder="Type your email..."]')

View File

@ -28,7 +28,7 @@ test.describe('Pixel block', () => {
await page.getByRole('button', { name: 'Select key' }).click()
await page.getByRole('menuitem', { name: 'currency' }).click()
await page.getByPlaceholder('Value').fill('USD')
await page.getByRole('button', { name: 'Preview' }).click()
await page.getByRole('button', { name: 'Test' }).click()
await expect(
page.getByText('Pixel is not enabled in Preview mode').nth(1)
).toBeVisible()

View File

@ -59,7 +59,7 @@ test.describe('Send email block', () => {
await page.click('text="Custom content?"')
await page.locator('textarea').fill('Here is my email')
await page.click('text=Preview')
await page.click('text=Test')
await page.locator('typebot-standard').locator('text=Go').click()
await expect(
page.locator('text=Emails are not sent in preview mode >> nth=0')

View File

@ -11,7 +11,7 @@ export const getResultExample = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/getResultExample',
path: '/v1/typebots/{typebotId}/webhookBlocks/{blockId}/getResultExample',
protect: true,
summary: 'Get result example',
description:

View File

@ -12,7 +12,7 @@ export const listWebhookBlocks = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/webhookBlocks',
path: '/v1/typebots/{typebotId}/webhookBlocks',
protect: true,
summary: 'List webhook blocks',
description:

View File

@ -10,7 +10,7 @@ export const subscribeWebhook = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/subscribe',
path: '/v1/typebots/{typebotId}/webhookBlocks/{blockId}/subscribe',
protect: true,
summary: 'Subscribe to webhook block',
tags: ['Webhook'],

View File

@ -10,7 +10,7 @@ export const unsubscribeWebhook = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/unsubscribe',
path: '/v1/typebots/{typebotId}/webhookBlocks/{blockId}/unsubscribe',
protect: true,
summary: 'Unsubscribe from webhook block',
tags: ['Webhook'],

View File

@ -20,7 +20,7 @@ export const ProjectsDropdown = ({
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { data } = trpc.zemanticAi.listProjects.useQuery(
const { data } = trpc.zemanticAI.listProjects.useQuery(
{
credentialsId,
workspaceId: workspace?.id as string,

View File

@ -16,7 +16,7 @@ test.describe('AB Test block', () => {
await page.getByLabel('Percent of users to follow A:').fill('100')
await expect(page.getByText('A 100%')).toBeVisible()
await expect(page.getByText('B 0%')).toBeVisible()
await page.getByRole('button', { name: 'Preview' }).click()
await page.getByRole('button', { name: 'Test' }).click()
await expect(
page.locator('typebot-standard').getByText('How are you?')
).toBeVisible()

View File

@ -49,7 +49,7 @@ test.describe('Condition block', () => {
await page.click('button:has-text("Greater than")', { force: true })
await page.fill('input[placeholder="Type a number..."]', '20')
await page.click('text=Preview')
await page.click('text=Test')
await page
.locator('typebot-standard')
.locator('input[placeholder="Type a number..."]')

View File

@ -16,7 +16,7 @@ test('should work as expected', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Group #1' }).click()
await page.getByPlaceholder('Select a block').click()
await page.getByRole('menuitem', { name: 'Block #2' }).click()
await page.getByRole('button', { name: 'Preview' }).click()
await page.getByRole('button', { name: 'Test' }).click()
await page.getByPlaceholder('Type your answer...').fill('Hi there!')
await page.getByRole('button', { name: 'Send' }).click()
await expect(

View File

@ -18,7 +18,7 @@ test.describe('Redirect block', () => {
await page.click('text=Configure...')
await page.fill('input[placeholder="Type a URL..."]', 'google.com')
await page.click('text=Preview')
await page.click('text=Test')
await page.locator('typebot-standard').locator('text=Go to URL').click()
await expect(page).toHaveURL('https://www.google.com')
await page.goBack()
@ -26,7 +26,7 @@ test.describe('Redirect block', () => {
await page.click('text=Redirect to google.com')
await page.click('text=Open in new tab')
await page.click('text=Preview')
await page.click('text=Test')
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.locator('typebot-standard').locator('text=Go to URL').click(),

View File

@ -18,7 +18,7 @@ test.describe('Script block', () => {
'window.location.href = "https://www.google.com"'
)
await page.click('text=Preview')
await page.click('text=Test')
await page.getByRole('button', { name: 'Trigger code' }).click()
await expect(page).toHaveURL('https://www.google.com')
})

View File

@ -49,7 +49,7 @@ test.describe('Set variable block', () => {
.getByRole('textbox')
.fill('1000 + {{Total}}')
await page.click('text=Preview')
await page.click('text=Test')
await page
.locator('typebot-standard')
.locator('input[placeholder="Type a number..."]')

View File

@ -32,7 +32,7 @@ export const getLinkedTypebots = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/linkedTypebots',
path: '/v1/typebots/{typebotId}/linkedTypebots',
protect: true,
summary: 'Get linked typebots',
tags: ['Typebot'],

View File

@ -35,7 +35,7 @@ test('should be configurable', async ({ page }) => {
await page.click('input[placeholder="Select a group"]')
await page.click('text=Group #2')
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator('typebot-standard').locator('text=Second block')
).toBeVisible()
@ -45,7 +45,7 @@ test('should be configurable', async ({ page }) => {
await page.getByTestId('selected-item-label').nth(1).click({ force: true })
await page.click('button >> text=Start')
await page.click('text=Preview')
await page.click('text=Test')
await page.locator('typebot-standard').locator('input').fill('Hello there!')
await page.locator('typebot-standard').locator('input').press('Enter')
await expect(
@ -60,7 +60,7 @@ test('should be configurable', async ({ page }) => {
await page.getByRole('textbox').nth(1).click()
await page.click('button >> text=Hello')
await page.click('text=Preview')
await page.click('text=Test')
await expect(
page.locator('typebot-standard').locator('text=Hello world')
).toBeVisible()

View File

@ -15,7 +15,7 @@ test.describe('Wait block', () => {
await page.click('text=Configure...')
await page.getByRole('textbox', { name: 'Seconds to wait for:' }).fill('3')
await page.click('text=Preview')
await page.click('text=Test')
await page.getByRole('button', { name: 'Wait now' }).click()
await page.waitForTimeout(1000)
await expect(

View File

@ -9,7 +9,7 @@ export const getCollaborators = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/collaborators',
path: '/v1/typebots/{typebotId}/collaborators',
protect: true,
summary: 'Get collaborators',
tags: ['Collaborators'],

View File

@ -38,7 +38,7 @@ test.describe('Typebot owner', () => {
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('button[aria-label="Show collaboration menu"]')
await page.click('button[aria-label="Open share popover"]')
await expect(page.locator('text=Free user')).toBeHidden()
await page.fill(
'input[placeholder="colleague@company.com"]',
@ -109,7 +109,7 @@ test.describe('Guest with read access', () => {
await expect(page.locator('text=Another typebot')).toBeHidden()
await expect(page.locator('text=Guest folder')).toBeHidden()
await page.click('text=Guest typebot')
await page.click('button[aria-label="Show collaboration menu"]')
await page.click('button[aria-label="Open share popover"]')
await page.click('text=Everyone at Guest workspace')
await expect(page.locator('text="Remove"')).toBeHidden()
await expect(page.locator('text=John Doe')).toBeVisible()
@ -165,11 +165,42 @@ test.describe('Guest with write access', () => {
await expect(page.locator('text=Another typebot')).toBeHidden()
await expect(page.locator('text=Guest folder')).toBeHidden()
await page.click('text=Guest typebot')
await page.click('button[aria-label="Show collaboration menu"]')
await page.click('button[aria-label="Open share popover"]')
await page.click('text=Everyone at Guest workspace')
await expect(page.locator('text="Remove"')).toBeHidden()
await expect(page.locator('text=John Doe')).toBeVisible()
await page.click('text=Group #1', { force: true })
await expect(page.locator('input[value="Group #1"]')).toBeVisible()
await expect(page.getByText('Group #1')).toBeVisible()
})
})
test.describe('Guest on public typebot', () => {
test('should have shared typebots displayed', async ({ page }) => {
const typebotId = createId()
const guestWorkspaceId = createId()
await prisma.workspace.create({
data: {
id: guestWorkspaceId,
name: 'Guest Workspace #4',
plan: Plan.FREE,
},
})
await createTypebots([
{
id: typebotId,
name: 'Guest typebot',
workspaceId: guestWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
}),
settings: {
publicShare: { isEnabled: true },
},
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await expect(page.getByText('Guest typebot')).toBeVisible()
await expect(page.getByText('Duplicate')).toBeVisible()
await expect(page.getByText('Group #1')).toBeVisible()
})
})

View File

@ -137,7 +137,7 @@ export const CollaborationList = () => {
}
return (
<Stack spacing={1} pt="4" pb="2">
<Stack spacing={1} pt="4">
<HStack as="form" onSubmit={handleInvitationSubmit} px="4" pb="2">
<Input
size="sm"

View File

@ -1,40 +0,0 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
IconButton,
Tooltip,
} from '@chakra-ui/react'
import { UsersIcon } from '@/components/icons'
import React from 'react'
import { CollaborationList } from './CollaborationList'
export const CollaborationMenuButton = ({
isLoading,
}: {
isLoading: boolean
}) => {
return (
<Popover isLazy placement="bottom-end">
<PopoverTrigger>
<span>
<Tooltip label="Invite users to collaborate">
<IconButton
isLoading={isLoading}
icon={<UsersIcon />}
aria-label="Show collaboration menu"
size="sm"
/>
</Tooltip>
</span>
</PopoverTrigger>
<PopoverContent
shadow="lg"
width="430px"
rootProps={{ style: { transform: 'scale(0)' } }}
>
<CollaborationList />
</PopoverContent>
</Popover>
)
}

View File

@ -26,7 +26,7 @@ export const createCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/credentials',
path: '/v1/credentials',
protect: true,
summary: 'Create credentials',
tags: ['Credentials'],

View File

@ -8,7 +8,7 @@ export const deleteCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/credentials/:credentialsId',
path: '/v1/credentials/:credentialsId',
protect: true,
summary: 'Delete credentials',
tags: ['Credentials'],

View File

@ -16,7 +16,7 @@ export const listCredentials = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/credentials',
path: '/v1/credentials',
protect: true,
summary: 'List workspace credentials',
tags: ['Credentials'],

View File

@ -11,7 +11,7 @@ export const createCustomDomain = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/custom-domains',
path: '/v1/custom-domains',
protect: true,
summary: 'Create custom domain',
tags: ['Custom domains'],

View File

@ -10,7 +10,7 @@ export const deleteCustomDomain = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/custom-domains/{name}',
path: '/v1/custom-domains/{name}',
protect: true,
summary: 'Delete custom domain',
tags: ['Custom domains'],

View File

@ -9,7 +9,7 @@ export const listCustomDomains = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/custom-domains',
path: '/v1/custom-domains',
protect: true,
summary: 'List custom domains',
tags: ['Custom domains'],

View File

@ -16,7 +16,7 @@ export const verifyCustomDomain = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/custom-domains/{name}/verify',
path: '/v1/custom-domains/{name}/verify',
protect: true,
summary: 'Verify domain config',
tags: ['Custom domains'],

View File

@ -16,10 +16,17 @@ import { GraphDndProvider } from '@/features/graph/providers/GraphDndProvider'
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
import { TypebotNotFoundPage } from './TypebotNotFoundPage'
export const EditorPage = () => {
const { typebot, isReadOnly } = useTypebot()
const { typebot, currentUserMode, is404 } = useTypebot()
const backgroundImage = useColorModeValue(
'radial-gradient(#c6d0e1 1px, transparent 0)',
'radial-gradient(#2f2f39 1px, transparent 0)'
)
const bgColor = useColorModeValue('#f4f5f8', 'gray.850')
if (is404) return <TypebotNotFoundPage />
return (
<EditorProvider>
<Seo title={typebot?.name ? `${typebot.name} | Editor` : 'Editor'} />
@ -30,18 +37,19 @@ export const EditorPage = () => {
flex="1"
pos="relative"
h="full"
bgColor={useColorModeValue('#f4f5f8', 'gray.850')}
backgroundImage={useColorModeValue(
'radial-gradient(#c6d0e1 1px, transparent 0)',
'radial-gradient(#2f2f39 1px, transparent 0)'
)}
bgColor={bgColor}
backgroundImage={backgroundImage}
backgroundSize="40px 40px"
backgroundPosition="-19px -19px"
>
{typebot ? (
<GraphDndProvider>
{!isReadOnly && <BlocksSideBar />}
<GraphProvider isReadOnly={isReadOnly}>
{currentUserMode === 'write' && <BlocksSideBar />}
<GraphProvider
isReadOnly={
currentUserMode === 'read' || currentUserMode === 'guest'
}
>
<GroupsCoordinatesProvider groups={typebot.groups}>
<EventsCoordinatesProvider events={typebot.events}>
<Graph flex="1" typebot={typebot} key={typebot.id} />

View File

@ -12,6 +12,7 @@ import {
import {
BuoyIcon,
ChevronLeftIcon,
PlayIcon,
RedoIcon,
UndoIcon,
} from '@/components/icons'
@ -23,7 +24,7 @@ import Link from 'next/link'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
import { useUndoShortcut } from '@/hooks/useUndoShortcut'
import { useDebouncedCallback } from 'use-debounce'
import { CollaborationMenuButton } from '@/features/collaboration/components/CollaborationMenuButton'
import { ShareTypebotButton } from '@/features/share/components/ShareTypebotButton'
import { PublishButton } from '@/features/publish/components/PublishButton'
import { headerHeight } from '../constants'
import { RightPanel, useEditor } from '../providers/EditorProvider'
@ -31,6 +32,7 @@ import { useTypebot } from '../providers/TypebotProvider'
import { SupportBubble } from '@/components/SupportBubble'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { useTranslate } from '@tolgee/react'
import { GuestTypebotHeader } from './UnauthenticatedTypebotHeader'
export const TypebotHeader = () => {
const { t } = useTranslate()
@ -45,6 +47,7 @@ export const TypebotHeader = () => {
canUndo,
canRedo,
isSavingLoading,
currentUserMode,
} = useTypebot()
const {
setRightPanel,
@ -58,6 +61,7 @@ export const TypebotHeader = () => {
setUndoShortcutTooltipOpen(false)
}, 1000)
const { isOpen, onOpen } = useDisclosure()
const headerBgColor = useColorModeValue('white', 'gray.900')
const handleNameSubmit = (name: string) =>
updateTypebot({ updates: { name } })
@ -86,6 +90,7 @@ export const TypebotHeader = () => {
: window.open('https://docs.typebot.io', '_blank')
}
if (currentUserMode === 'guest') return <GuestTypebotHeader />
return (
<Flex
w="full"
@ -95,7 +100,7 @@ export const TypebotHeader = () => {
h={`${headerHeight}px`}
zIndex={100}
pos="relative"
bgColor={useColorModeValue('white', 'gray.900')}
bgColor={headerBgColor}
flexShrink={0}
>
{isOpen && <SupportBubble autoShowDelay={0} />}
@ -203,33 +208,35 @@ export const TypebotHeader = () => {
)
</HStack>
<HStack>
<Tooltip
label={isUndoShortcutTooltipOpen ? 'Changes reverted!' : 'Undo'}
isOpen={isUndoShortcutTooltipOpen ? true : undefined}
hasArrow={isUndoShortcutTooltipOpen}
>
<IconButton
display={['none', 'flex']}
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
{currentUserMode === 'write' && (
<HStack>
<Tooltip
label={isUndoShortcutTooltipOpen ? 'Changes reverted!' : 'Undo'}
isOpen={isUndoShortcutTooltipOpen ? true : undefined}
hasArrow={isUndoShortcutTooltipOpen}
>
<IconButton
display={['none', 'flex']}
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
<Tooltip label="Redo">
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
<Tooltip label="Redo">
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
)}
<Button leftIcon={<BuoyIcon />} onClick={handleHelpClick} size="sm">
{t('editor.headers.helpButton.label')}
</Button>
@ -246,19 +253,20 @@ export const TypebotHeader = () => {
<HStack right="40px" pos="absolute" display={['none', 'flex']}>
<Flex pos="relative">
<CollaborationMenuButton isLoading={isNotDefined(typebot)} />
<ShareTypebotButton isLoading={isNotDefined(typebot)} />
</Flex>
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
<Button
colorScheme="gray"
onClick={handlePreviewClick}
isLoading={isNotDefined(typebot)}
leftIcon={<PlayIcon />}
size="sm"
>
{t('editor.headers.previewButton.label')}
</Button>
)}
<PublishButton size="sm" />
{currentUserMode === 'write' && <PublishButton size="sm" />}
</HStack>
</Flex>
)

View File

@ -0,0 +1,51 @@
import { ChevronLeftIcon } from '@/components/icons'
import { useUser } from '@/features/account/hooks/useUser'
import {
Button,
Flex,
Heading,
Link,
VStack,
Text,
Spinner,
} from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export const TypebotNotFoundPage = () => {
const { replace, asPath } = useRouter()
const { user, isLoading } = useUser()
useEffect(() => {
if (user || isLoading) return
replace({
pathname: '/signin',
query: {
redirectPath: asPath,
},
})
}, [asPath, isLoading, replace, user])
return (
<Flex justify="center" align="center" w="full" h="100vh">
{user ? (
<VStack spacing={6}>
<VStack>
<Heading>404</Heading>
<Text fontSize="xl">Typebot not found.</Text>
</VStack>
<Button
as={Link}
href="/typebots"
colorScheme="blue"
leftIcon={<ChevronLeftIcon />}
>
Dashboard
</Button>
</VStack>
) : (
<Spinner />
)}
</Flex>
)
}

View File

@ -0,0 +1,167 @@
import {
Flex,
HStack,
Button,
useColorModeValue,
Divider,
Text,
} from '@chakra-ui/react'
import { CopyIcon, PlayIcon } from '@/components/icons'
import { useRouter } from 'next/router'
import React from 'react'
import { isNotDefined } from '@typebot.io/lib'
import Link from 'next/link'
import { headerHeight } from '../constants'
import { RightPanel, useEditor } from '../providers/EditorProvider'
import { useTypebot } from '../providers/TypebotProvider'
import { useTranslate } from '@tolgee/react'
import { TypebotLogo } from '@/components/TypebotLogo'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { useUser } from '@/features/account/hooks/useUser'
export const GuestTypebotHeader = () => {
const { t } = useTranslate()
const router = useRouter()
const { user } = useUser()
const { typebot, save } = useTypebot()
const {
setRightPanel,
rightPanel,
setStartPreviewAtGroup,
setStartPreviewAtEvent,
} = useEditor()
const handlePreviewClick = async () => {
setStartPreviewAtGroup(undefined)
setStartPreviewAtEvent(undefined)
save().then()
setRightPanel(RightPanel.PREVIEW)
}
return (
<Flex
w="full"
borderBottomWidth="1px"
justify="center"
align="center"
h={`${headerHeight}px`}
zIndex={100}
pos="relative"
bgColor={useColorModeValue('white', 'gray.900')}
flexShrink={0}
>
<HStack
display={['none', 'flex']}
pos={{ base: 'absolute', xl: 'static' }}
right={{ base: 280, xl: 0 }}
>
<Button
as={Link}
href={`/typebots/${typebot?.id}/edit`}
colorScheme={router.pathname.includes('/edit') ? 'blue' : 'gray'}
variant={router.pathname.includes('/edit') ? 'outline' : 'ghost'}
size="sm"
>
{t('editor.headers.flowButton.label')}
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/theme`}
colorScheme={router.pathname.endsWith('theme') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('theme') ? 'outline' : 'ghost'}
size="sm"
>
{t('editor.headers.themeButton.label')}
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/settings`}
colorScheme={router.pathname.endsWith('settings') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('settings') ? 'outline' : 'ghost'}
size="sm"
>
{t('editor.headers.settingsButton.label')}
</Button>
</HStack>
<HStack
pos="absolute"
left="1rem"
justify="center"
align="center"
spacing="6"
>
<HStack alignItems="center" spacing={3}>
{typebot && (
<EmojiOrImageIcon icon={typebot.icon} emojiFontSize="2xl" />
)}
<Text
noOfLines={2}
maxW="150px"
overflow="hidden"
fontSize="14px"
minW="30px"
minH="20px"
>
{typebot?.name}
</Text>
</HStack>
</HStack>
<HStack
right="1rem"
pos="absolute"
display={['none', 'flex']}
spacing={4}
>
<HStack>
{typebot?.id && (
<Button
as={Link}
href={
!user
? {
pathname: `/register`,
query: {
redirectPath: `/typebots/${typebot.id}/duplicate`,
},
}
: `/typebots/${typebot.id}/duplicate`
}
leftIcon={<CopyIcon />}
isLoading={isNotDefined(typebot)}
size="sm"
>
Duplicate
</Button>
)}
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
<Button
colorScheme="blue"
onClick={handlePreviewClick}
isLoading={isNotDefined(typebot)}
leftIcon={<PlayIcon />}
size="sm"
>
Play bot
</Button>
)}
</HStack>
{!user && (
<>
<Divider orientation="vertical" h="25px" borderColor="gray.400" />
<Button
as={Link}
href={`/register`}
leftIcon={<TypebotLogo width="20px" />}
variant="outline"
size="sm"
>
Try Typebot
</Button>
</>
)}
</HStack>
</Flex>
)
}

View File

@ -207,7 +207,7 @@ test('Preview from group should work', async ({ page }) => {
page.locator('typebot-standard').locator('text="Hello this is group 2"')
).toBeVisible()
await page.click('[aria-label="Close"]')
await page.click('text="Preview"')
await page.click('text="Test"')
await expect(
page.locator('typebot-standard').locator('text="Hello this is group 1"')
).toBeVisible()

View File

@ -23,8 +23,11 @@ const initialState = {
future: [],
}
type Params = { isReadOnly?: boolean }
export const useUndo = <T extends { updatedAt: Date }>(
initialPresent?: T
initialPresent?: T,
params?: Params
): [T | undefined, Actions<T>] => {
const [history, setHistory] = useState<History<T>>(initialState)
const presentRef = useRef<T | null>(initialPresent ?? null)
@ -33,6 +36,7 @@ export const useUndo = <T extends { updatedAt: Date }>(
const canRedo = history.future.length !== 0
const undo = useCallback(() => {
if (params?.isReadOnly) return
const { past, present, future } = history
if (past.length === 0 || !present) return
@ -47,9 +51,10 @@ export const useUndo = <T extends { updatedAt: Date }>(
future: [present, ...future],
})
presentRef.current = newPresent
}, [history])
}, [history, params?.isReadOnly])
const redo = useCallback(() => {
if (params?.isReadOnly) return
const { past, present, future } = history
if (future.length === 0) return
const next = future[0]
@ -61,11 +66,12 @@ export const useUndo = <T extends { updatedAt: Date }>(
future: newFuture,
})
presentRef.current = next
}, [history])
}, [history, params?.isReadOnly])
const set = useCallback(
(newPresentArg: T | ((current: T) => T) | undefined) => {
const { past, present } = history
if (isDefined(present) && params?.isReadOnly) return
const newPresent =
typeof newPresentArg === 'function'
? newPresentArg(presentRef.current as T)
@ -92,16 +98,17 @@ export const useUndo = <T extends { updatedAt: Date }>(
})
presentRef.current = newPresent
},
[history]
[history, params?.isReadOnly]
)
const flush = useCallback(() => {
if (params?.isReadOnly) return
setHistory({
present: presentRef.current ?? undefined,
past: [],
future: [],
})
}, [])
}, [params?.isReadOnly])
return [history.present, { set, undo, redo, flush, canUndo, canRedo }]
}

View File

@ -7,6 +7,7 @@ import {
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { isDefined, omit } from '@typebot.io/lib'
import { edgesAction, EdgesActions } from './typebotActions/edges'
@ -52,7 +53,8 @@ const typebotContext = createContext<
typebot?: TypebotV6
publishedTypebot?: PublicTypebotV6
publishedTypebotVersion?: PublicTypebot['version']
isReadOnly?: boolean
currentUserMode: 'guest' | 'read' | 'write'
is404: boolean
isPublished: boolean
isSavingLoading: boolean
save: () => Promise<TypebotV6 | undefined>
@ -84,6 +86,7 @@ export const TypebotProvider = ({
}) => {
const { push } = useRouter()
const { showToast } = useToast()
const [is404, setIs404] = useState(false)
const {
data: typebotData,
@ -96,13 +99,10 @@ export const TypebotProvider = ({
retry: 0,
onError: (error) => {
if (error.data?.httpStatus === 404) {
showToast({
status: 'info',
description: "Couldn't find typebot. Redirecting...",
})
push('/typebots')
setIs404(true)
return
}
setIs404(false)
showToast({
title: 'Could not fetch typebot',
description: error.message,
@ -112,6 +112,9 @@ export const TypebotProvider = ({
},
})
},
onSuccess: () => {
setIs404(false)
},
}
)
@ -119,7 +122,10 @@ export const TypebotProvider = ({
trpc.typebot.getPublishedTypebot.useQuery(
{ typebotId: typebotId as string, migrateToLatestVersion: true },
{
enabled: isDefined(typebotId),
enabled:
isDefined(typebotId) &&
(typebotData?.currentUserMode === 'read' ||
typebotData?.currentUserMode === 'write'),
onError: (error) => {
showToast({
title: 'Could not fetch published typebot',
@ -153,11 +159,16 @@ export const TypebotProvider = ({
const typebot = typebotData?.typebot as TypebotV6
const publishedTypebot = (publishedTypebotData?.publishedTypebot ??
undefined) as PublicTypebotV6 | undefined
const isReadOnly = ['read', 'guest'].includes(
typebotData?.currentUserMode ?? 'guest'
)
const [
localTypebot,
{ redo, undo, flush, canRedo, canUndo, set: setLocalTypebot },
] = useUndo<TypebotV6>(undefined)
] = useUndo<TypebotV6>(undefined, {
isReadOnly,
})
useEffect(() => {
if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined)
@ -182,7 +193,7 @@ export const TypebotProvider = ({
const saveTypebot = useCallback(
async (updates?: Partial<TypebotV6>) => {
if (!localTypebot || !typebot || typebotData?.isReadOnly) return
if (!localTypebot || !typebot || isReadOnly) return
const typebotToSave = { ...localTypebot, ...updates }
if (dequal(omit(typebot, 'updatedAt'), omit(typebotToSave, 'updatedAt')))
return
@ -194,13 +205,7 @@ export const TypebotProvider = ({
setLocalTypebot({ ...newTypebot })
return newTypebot
},
[
localTypebot,
setLocalTypebot,
typebot,
typebotData?.isReadOnly,
updateTypebot,
]
[isReadOnly, localTypebot, setLocalTypebot, typebot, updateTypebot]
)
useAutoSave(
@ -232,7 +237,7 @@ export const TypebotProvider = ({
)
useEffect(() => {
if (!localTypebot || !typebot || typebotData?.isReadOnly) return
if (!localTypebot || !typebot || isReadOnly) return
if (!areTypebotsEqual(localTypebot, typebot)) {
window.addEventListener('beforeunload', preventUserFromRefreshing)
}
@ -240,7 +245,7 @@ export const TypebotProvider = ({
return () => {
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
}, [localTypebot, typebot, typebotData?.isReadOnly])
}, [localTypebot, typebot, isReadOnly])
const updateLocalTypebot = async ({
updates,
@ -249,7 +254,7 @@ export const TypebotProvider = ({
updates: UpdateTypebotPayload
save?: boolean
}) => {
if (!localTypebot) return
if (!localTypebot || isReadOnly) return
const newTypebot = { ...localTypebot, ...updates }
setLocalTypebot(newTypebot)
if (save) await saveTypebot(newTypebot)
@ -269,8 +274,9 @@ export const TypebotProvider = ({
typebot: localTypebot,
publishedTypebot,
publishedTypebotVersion: publishedTypebotData?.version,
isReadOnly: typebotData?.isReadOnly,
currentUserMode: typebotData?.currentUserMode ?? 'guest',
isSavingLoading: isSaving,
is404,
save: saveTypebot,
undo,
redo,

View File

@ -151,7 +151,7 @@ export const BlockNode = ({
const handleClick = (e: React.MouseEvent) => {
setFocusedGroupId(groupId)
e.stopPropagation()
if (isTextBubbleBlock(block)) setIsEditing(true)
if (isTextBubbleBlock(block) && !isReadOnly) setIsEditing(true)
setOpenedBlockId(block.id)
}

View File

@ -112,11 +112,7 @@ export const BlockNodesList = ({ blocks, groupIndex, groupRef }: Props) => {
})
return (
<Stack
spacing={1}
transition="none"
pointerEvents={isReadOnly ? 'none' : 'auto'}
>
<Stack spacing={1} transition="none">
<PlaceholderNode
isVisible={showSortPlaceholders}
isExpanded={expandedPlaceholderIndex === 0}

View File

@ -227,7 +227,6 @@ const NonMemoizedDraggableGroupNode = ({
onChange={setGroupTitle}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly ? 'none' : 'auto'}
pr="8"
>
<EditablePreview

View File

@ -27,11 +27,12 @@ import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
import { useTranslate } from '@tolgee/react'
import { env } from '@typebot.io/env'
import DomainStatusIcon from '@/features/customDomains/components/DomainStatusIcon'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
export const SharePage = () => {
const { t } = useTranslate()
const { workspace } = useWorkspace()
const { typebot, updateTypebot, publishedTypebot } = useTypebot()
const { typebot, updateTypebot, publishedTypebot, is404 } = useTypebot()
const { showToast } = useToast()
const handlePublicIdChange = async (publicId: string) => {
@ -87,6 +88,7 @@ export const SharePage = () => {
return true
}
if (is404) return <TypebotNotFoundPage />
return (
<Flex flexDir="column" pb="40">
<Seo title={typebot?.name ? `${typebot.name} | Share` : 'Share'} />

View File

@ -10,7 +10,7 @@ export const deleteResults = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/typebots/{typebotId}/results',
path: '/v1/typebots/{typebotId}/results',
protect: true,
summary: 'Delete results',
tags: ['Results'],

View File

@ -9,7 +9,7 @@ export const getResult = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/results/{resultId}',
path: '/v1/typebots/{typebotId}/results/{resultId}',
protect: true,
summary: 'Get result by id',
tags: ['Results'],

View File

@ -8,7 +8,7 @@ export const getResultLogs = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/results/{resultId}/logs',
path: '/v1/typebots/{typebotId}/results/{resultId}/logs',
protect: true,
summary: 'List result logs',
tags: ['Results'],

View File

@ -11,7 +11,7 @@ export const getResults = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/results',
path: '/v1/typebots/{typebotId}/results',
protect: true,
summary: 'List results ordered by descending creation date',
tags: ['Results'],

View File

@ -18,15 +18,20 @@ import { useMemo } from 'react'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
export const ResultsPage = () => {
const router = useRouter()
const { workspace } = useWorkspace()
const { typebot, publishedTypebot } = useTypebot()
const { typebot, publishedTypebot, is404 } = useTypebot()
const isAnalytics = useMemo(
() => router.pathname.endsWith('analytics'),
[router.pathname]
)
const bgColor = useColorModeValue(
router.pathname.endsWith('analytics') ? '#f4f5f8' : 'white',
router.pathname.endsWith('analytics') ? 'gray.850' : 'gray.900'
)
const { showToast } = useToast()
const { stats, mutate } = useStats({
@ -41,6 +46,7 @@ export const ResultsPage = () => {
})
}
if (is404) return <TypebotNotFoundPage />
return (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo
@ -55,14 +61,7 @@ export const ResultsPage = () => {
}
/>
<TypebotHeader />
<Flex
h="full"
w="full"
bgColor={useColorModeValue(
router.pathname.endsWith('analytics') ? '#f4f5f8' : 'white',
router.pathname.endsWith('analytics') ? 'gray.850' : 'gray.900'
)}
>
<Flex h="full" w="full" bgColor={bgColor}>
<Flex
pos="absolute"
zIndex={2}

View File

@ -48,7 +48,7 @@ export const ResultsTable = ({
onResultExpandIndex,
}: ResultsTableProps) => {
const background = useColorModeValue('white', colors.gray[900])
const { updateTypebot, isReadOnly } = useTypebot()
const { updateTypebot, currentUserMode } = useTypebot()
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [isTableScrolled, setIsTableScrolled] = useState(false)
const bottomElement = useRef<HTMLDivElement | null>(null)
@ -212,7 +212,7 @@ export const ResultsTable = ({
return (
<Stack maxW="1600px" px="4" overflowY="hidden" spacing={6}>
<HStack w="full" justifyContent="flex-end">
{isReadOnly ? null : (
{currentUserMode === 'write' && (
<SelectionToolbar
selectedResultsId={Object.keys(rowSelection)}
onClearSelection={() => setRowSelection({})}

View File

@ -5,10 +5,12 @@ import { getViewerUrl } from '@typebot.io/lib/getViewerUrl'
import { SettingsSideMenu } from './SettingsSideMenu'
import { TypebotHeader } from '@/features/editor/components/TypebotHeader'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
export const SettingsPage = () => {
const { typebot } = useTypebot()
const { typebot, is404 } = useTypebot()
if (is404) return <TypebotNotFoundPage />
return (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title={typebot?.name ? `${typebot.name} | Settings` : 'Settings'} />

View File

@ -0,0 +1,49 @@
import { Stack, Input, InputGroup, InputRightElement } from '@chakra-ui/react'
import React, { useMemo } from 'react'
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
import { CopyButton } from '@/components/CopyButton'
import { CollaborationList } from '@/features/collaboration/components/CollaborationList'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
export const SharePopoverContent = () => {
const { typebot, updateTypebot } = useTypebot()
const currentUrl = useMemo(() => window.location.href.split('?')[0], [])
const updateIsPublicShareEnabled = async (isEnabled: boolean) => {
await updateTypebot({
updates: {
settings: {
...typebot?.settings,
publicShare: {
...typebot?.settings.publicShare,
isEnabled,
},
},
},
save: true,
})
}
return (
<Stack spacing={4}>
<CollaborationList />
<Stack p="4" borderTopWidth={1}>
<SwitchWithRelatedSettings
label={'Make the flow publicly available'}
initialValue={typebot?.settings.publicShare?.isEnabled ?? false}
onCheckChange={updateIsPublicShareEnabled}
>
<Stack spacing={4}>
<InputGroup size="sm">
<Input type={'text'} defaultValue={currentUrl} pr="16" />
<InputRightElement width="60px">
<CopyButton size="sm" textToCopy={currentUrl} />
</InputRightElement>
</InputGroup>
</Stack>
</SwitchWithRelatedSettings>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,33 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
Button,
} from '@chakra-ui/react'
import { UsersIcon } from '@/components/icons'
import React from 'react'
import { SharePopoverContent } from './SharePopoverContent'
export const ShareTypebotButton = ({ isLoading }: { isLoading: boolean }) => {
return (
<Popover isLazy placement="bottom-end">
<PopoverTrigger>
<Button
isLoading={isLoading}
leftIcon={<UsersIcon />}
aria-label="Open share popover"
size="sm"
>
Share
</Button>
</PopoverTrigger>
<PopoverContent
shadow="lg"
width="430px"
rootProps={{ style: { transform: 'scale(0)' } }}
>
<SharePopoverContent />
</PopoverContent>
</Popover>
)
}

View File

@ -6,7 +6,6 @@ import got from 'got'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { env } from '@typebot.io/env'
// Only used for the cloud version of Typebot. It's the way it processes telemetry events and inject it to thrid-party services.
export const processTelemetryEvent = authenticatedProcedure
.meta({
openapi: {

View File

@ -10,7 +10,7 @@ export const deleteThemeTemplate = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/themeTemplates/{themeTemplateId}',
path: '/v1/themeTemplates/{themeTemplateId}',
protect: true,
summary: 'Delete a theme template',
tags: ['Theme template'],

View File

@ -10,7 +10,7 @@ export const listThemeTemplates = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/themeTemplates',
path: '/v1/themeTemplates',
protect: true,
summary: 'List theme templates',
tags: ['Theme template'],

View File

@ -10,7 +10,7 @@ export const saveThemeTemplate = authenticatedProcedure
.meta({
openapi: {
method: 'PUT',
path: '/themeTemplates/{themeTemplateId}',
path: '/v1/themeTemplates/{themeTemplateId}',
protect: true,
summary: 'Save theme template',
tags: ['Theme template'],

View File

@ -4,10 +4,12 @@ import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Flex } from '@chakra-ui/react'
import { Standard } from '@typebot.io/nextjs'
import { ThemeSideMenu } from './ThemeSideMenu'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
export const ThemePage = () => {
const { typebot } = useTypebot()
const { typebot, is404 } = useTypebot()
if (is404) return <TypebotNotFoundPage />
return (
<Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title={typebot?.name ? `${typebot.name} | Theme` : 'Theme'} />

View File

@ -7,7 +7,6 @@ import {
Heading,
HStack,
Stack,
Tag,
} from '@chakra-ui/react'
import { ChatIcon, CodeIcon, DropletIcon, TableIcon } from '@/components/icons'
import { ChatTheme, GeneralTheme, ThemeTemplate } from '@typebot.io/schemas'
@ -21,7 +20,7 @@ import { ThemeTemplates } from './ThemeTemplates'
import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
export const ThemeSideMenu = () => {
const { typebot, updateTypebot } = useTypebot()
const { typebot, updateTypebot, currentUserMode } = useTypebot()
const updateChatTheme = (chat: ChatTheme) =>
typebot && updateTypebot({ updates: { theme: { ...typebot.theme, chat } } })
@ -71,31 +70,29 @@ export const ThemeSideMenu = () => {
Customize the theme
</Heading>
<Accordion allowMultiple>
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<TableIcon />
<Heading fontSize="lg">
<HStack>
<span>Templates</span> <Tag colorScheme="orange">New!</Tag>
</HStack>
</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={12}>
{typebot && (
<ThemeTemplates
selectedTemplateId={
typebot.selectedThemeTemplateId ?? undefined
}
currentTheme={typebot.theme}
workspaceId={typebot.workspaceId}
onTemplateSelect={selectedTemplate}
/>
)}
</AccordionPanel>
</AccordionItem>
{currentUserMode === 'write' && (
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>
<TableIcon />
<Heading fontSize="lg">Templates</Heading>
</HStack>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={12}>
{typebot && (
<ThemeTemplates
selectedTemplateId={
typebot.selectedThemeTemplateId ?? undefined
}
currentTheme={typebot.theme}
workspaceId={typebot.workspaceId}
onTemplateSelect={selectedTemplate}
/>
)}
</AccordionPanel>
</AccordionItem>
)}
<AccordionItem>
<AccordionButton py={6}>
<HStack flex="1" pl={2}>

View File

@ -233,7 +233,7 @@ test.describe.parallel('Theme page', () => {
})
await page.goto(`/typebots/${typebotId}/theme`)
await expect(page.getByRole('button', { name: 'Go' })).toBeVisible()
await page.getByRole('button', { name: 'Templates New!' }).click()
await page.getByRole('button', { name: 'Templates' }).click()
await page.getByRole('button', { name: 'Save current theme' }).click()
await page.getByPlaceholder('My template').fill('My awesome theme')
await page.getByRole('button', { name: 'Save' }).click()

View File

@ -35,7 +35,7 @@ export const createTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots',
path: '/v1/typebots',
protect: true,
summary: 'Create a typebot',
tags: ['Typebot'],

View File

@ -10,7 +10,7 @@ export const deleteTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/typebots/{typebotId}',
path: '/v1/typebots/{typebotId}',
protect: true,
summary: 'Delete a typebot',
tags: ['Typebot'],

View File

@ -15,7 +15,7 @@ export const getPublishedTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/publishedTypebot',
path: '/v1/typebots/{typebotId}/publishedTypebot',
protect: true,
summary: 'Get published typebot',
tags: ['Typebot'],

View File

@ -1,16 +1,17 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { publicProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { typebotSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isReadTypebotForbidden } from '../helpers/isReadTypebotForbidden'
import { migrateTypebot } from '@typebot.io/lib/migrations/migrateTypebot'
import { CollaborationType } from '@typebot.io/prisma'
export const getTypebot = authenticatedProcedure
export const getTypebot = publicProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}',
path: '/v1/typebots/{typebotId}',
protect: true,
summary: 'Get a typebot',
tags: ['Typebot'],
@ -30,7 +31,7 @@ export const getTypebot = authenticatedProcedure
.output(
z.object({
typebot: typebotSchema,
isReadOnly: z.boolean(),
currentUserMode: z.enum(['guest', 'read', 'write']),
})
)
.query(
@ -67,10 +68,7 @@ export const getTypebot = authenticatedProcedure
return {
typebot: parsedTypebot,
isReadOnly:
existingTypebot.collaborators.find(
(collaborator) => collaborator.userId === user.id
)?.type === 'READ' ?? false,
currentUserMode: getCurrentUserMode(user, existingTypebot),
}
} catch (err) {
throw new TRPCError({
@ -81,3 +79,24 @@ export const getTypebot = authenticatedProcedure
}
}
)
const getCurrentUserMode = (
user: { id: string } | undefined,
typebot: { collaborators: { userId: string; type: CollaborationType }[] } & {
workspace: { members: { userId: string }[] }
}
) => {
const collaborator = typebot.collaborators.find((c) => c.userId === user?.id)
const isMemberOfWorkspace = typebot.workspace.members.some(
(m) => m.userId === user?.id
)
if (
collaborator?.type === 'WRITE' ||
collaborator?.type === 'FULL_ACCESS' ||
isMemberOfWorkspace
)
return 'write'
if (collaborator) return 'read'
return 'guest'
}

View File

@ -70,7 +70,7 @@ export const importTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/import',
path: '/v1/typebots/import',
protect: true,
summary: 'Import a typebot',
tags: ['Typebot'],

View File

@ -11,7 +11,7 @@ export const listTypebots = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots',
path: '/v1/typebots',
protect: true,
summary: 'List typebots',
tags: ['Typebot'],

View File

@ -19,7 +19,7 @@ export const publishTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/publish',
path: '/v1/typebots/{typebotId}/publish',
protect: true,
summary: 'Publish a typebot',
tags: ['Typebot'],

View File

@ -8,7 +8,7 @@ export const unpublishTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/unpublish',
path: '/v1/typebots/{typebotId}/unpublish',
protect: true,
summary: 'Unpublish a typebot',
tags: ['Typebot'],

View File

@ -42,7 +42,7 @@ export const updateTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'PATCH',
path: '/typebots/{typebotId}',
path: '/v1/typebots/{typebotId}',
protect: true,
summary: 'Update a typebot',
tags: ['Typebot'],

View File

@ -4,22 +4,34 @@ import {
User,
Workspace,
MemberInWorkspace,
Typebot,
} from '@typebot.io/prisma'
import { settingsSchema } from '@typebot.io/schemas'
export const isReadTypebotForbidden = async (
typebot: {
settings?: Typebot['settings']
collaborators: Pick<CollaboratorsOnTypebots, 'userId'>[]
} & {
workspace: Pick<Workspace, 'isSuspended' | 'isPastDue'> & {
members: Pick<MemberInWorkspace, 'userId'>[]
}
},
user: Pick<User, 'email' | 'id'>
) =>
typebot.workspace.isSuspended ||
typebot.workspace.isPastDue ||
(env.ADMIN_EMAIL !== user.email &&
!typebot.collaborators.some(
(collaborator) => collaborator.userId === user.id
) &&
!typebot.workspace.members.some((member) => member.userId === user.id))
user?: Pick<User, 'email' | 'id'>
) => {
const settings = typebot.settings
? settingsSchema.parse(typebot.settings)
: undefined
const isTypebotPublic = settings?.publicShare?.isEnabled === true
if (isTypebotPublic) return false
return (
!user ||
typebot.workspace.isSuspended ||
typebot.workspace.isPastDue ||
(env.ADMIN_EMAIL !== user.email &&
!typebot.collaborators.some(
(collaborator) => collaborator.userId === user.id
) &&
!typebot.workspace.members.some((member) => member.userId === user.id))
)
}

View File

@ -11,6 +11,7 @@ export const sanitizeSettings = (
mode: 'create' | 'update'
): Typebot['settings'] => ({
...settings,
publicShare: mode === 'create' ? undefined : settings.publicShare,
general:
workspacePlan === Plan.FREE || settings.general
? {

View File

@ -10,7 +10,7 @@ export const createWorkspace = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/workspaces',
path: '/v1/workspaces',
protect: true,
summary: 'Create workspace',
tags: ['Workspace'],

View File

@ -8,7 +8,7 @@ export const deleteWorkspace = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/workspaces/{workspaceId}',
path: '/v1/workspaces/{workspaceId}',
protect: true,
summary: 'Delete workspace',
tags: ['Workspace'],

View File

@ -9,7 +9,7 @@ export const getWorkspace = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/workspaces/{workspaceId}',
path: '/v1/workspaces/{workspaceId}',
protect: true,
summary: 'Get workspace',
tags: ['Workspace'],

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