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:*", "eslint-config-custom": "workspace:*",
"next-runtime-env": "1.6.2", "next-runtime-env": "1.6.2",
"superjson": "1.12.4", "superjson": "1.12.4",
"typescript": "5.2.2", "typescript": "5.3.2",
"zod": "3.22.4" "zod": "3.22.4"
} }
} }

View File

@@ -143,7 +143,7 @@
"editor.blocks.start.text": "Start", "editor.blocks.start.text": "Start",
"editor.editableTypebotName.tooltip.rename.label": "Rename", "editor.editableTypebotName.tooltip.rename.label": "Rename",
"editor.gettingStartedModal.editorBasics.heading": "Editor Basics", "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.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.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", "editor.gettingStartedModal.editorBasics.list.three.label": "Connect the groups together",
@@ -153,7 +153,7 @@
"editor.gettingStartedModal.seeAction.time": "5 minutes", "editor.gettingStartedModal.seeAction.time": "5 minutes",
"editor.headers.flowButton.label": "Flow", "editor.headers.flowButton.label": "Flow",
"editor.headers.helpButton.label": "Help", "editor.headers.helpButton.label": "Help",
"editor.headers.previewButton.label": "Preview", "editor.headers.previewButton.label": "Test",
"editor.headers.resultsButton.label": "Results", "editor.headers.resultsButton.label": "Results",
"editor.headers.savingSpinner.label": "Saving...", "editor.headers.savingSpinner.label": "Saving...",
"editor.headers.settingsButton.label": "Settings", "editor.headers.settingsButton.label": "Settings",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ test('should work as expected', async ({ page }) => {
'gm' '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( await expect(page.locator('audio')).toHaveAttribute(
'src', 'src',
RegExp( RegExp(

View File

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

View File

@@ -116,7 +116,7 @@ test.describe.parallel('Image bubble block', () => {
]) ])
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect(page.locator('img')).toHaveAttribute('src', unsplashImageSrc) 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.fill('[data-testid="variables-input"]', 'test')
await page.getByRole('menuitem', { name: 'Create test' }).click() 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( await expect(page.locator('span.slate-bold >> nth=0')).toHaveText(
'Bold text' 'Bold text'
) )

View File

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

View File

@@ -37,7 +37,7 @@ test.describe.parallel('Buttons input block', () => {
await page.click('text=Delete') await page.click('text=Delete')
await expect(page.locator('text=Item 2')).toBeHidden() 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 page.getByRole('button', { name: 'Item 3' }).click()
await expect(page.getByRole('button', { name: 'Item 3' })).toBeHidden() await expect(page.getByRole('button', { name: 'Item 3' })).toBeHidden()
await expect(page.getByTestId('guest-bubble')).toHaveText('Item 3') 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.fill('input[value="Click to edit"]', 'Item 2')
await page.press('input[value="Item 2"]', 'Enter') 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 3' }).click()
await page.getByRole('checkbox', { name: 'Item 1' }).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.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await page.getByRole('button', { name: 'Variable item' }).click() await page.getByRole('button', { name: 'Variable item' }).click()
await expect(page.getByTestId('guest-bubble')).toHaveText('Variable item') await expect(page.getByTestId('guest-bubble')).toHaveText('Variable item')
await expect(page.locator('text=Ok great!')).toBeVisible() 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.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect(page.locator('[data-testid="from-date"]')).toHaveAttribute( await expect(page.locator('[data-testid="from-date"]')).toHaveAttribute(
'type', 'type',
'date' 'date'

View File

@@ -19,7 +19,7 @@ test.describe('Email input block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator( page.locator(
`input[placeholder="${defaultEmailInputOptions.labels.placeholder}"]` `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.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=Click to upload`)).toBeVisible()
await expect(page.locator(`text="Skip"`)).toBeHidden() await expect(page.locator(`text="Skip"`)).toBeHidden()
await page await page

View File

@@ -19,7 +19,7 @@ test.describe('Number input block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator( page.locator(
`input[placeholder="${defaultNumberInputOptions.labels.placeholder}"]` `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 page.fill('[placeholder="john@gmail.com"]', 'test@typebot.io')
await expect(page.locator('text="Phone number:"')).toBeVisible() await expect(page.locator('text="Phone number:"')).toBeVisible()
await page.click('text=Preview') await page.click('text=Test')
await stripePaymentForm(page) await stripePaymentForm(page)
.locator(`[placeholder="1234 1234 1234 1234"]`) .locator(`[placeholder="1234 1234 1234 1234"]`)
.fill('4000000000000002') .fill('4000000000000002')

View File

@@ -19,7 +19,7 @@ test.describe('Phone input block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator( page.locator(
`input[placeholder="${defaultPhoneInputOptions.labels.placeholder}"]` `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.getByPlaceholder('Paste the image link...').fill(thirdImageSrc)
await page.getByLabel('Title:').fill('Third image') await page.getByLabel('Title:').fill('Third image')
await page.getByLabel('Description:').fill('Third description') await page.getByLabel('Description:').fill('Third description')
await page.getByRole('button', { name: 'Preview' }).click() await page.getByRole('button', { name: 'Test' }).click()
await expect( await expect(
page.getByRole('button', { page.getByRole('button', {
name: 'First image First image First description', 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.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect(page.locator(`text=Send`)).toBeHidden() await expect(page.locator(`text=Send`)).toBeHidden()
await page.getByRole('button', { name: '8' }).click() await page.getByRole('button', { name: '8' }).click()
await page.locator(`text=Send`).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.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator( page.locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]` `input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`

View File

@@ -19,7 +19,7 @@ test.describe('Url input block', () => {
await page.goto(`/typebots/${typebotId}/edit`) await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator( page.locator(
`input[placeholder="${defaultUrlInputOptions.labels.placeholder}"]` `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('Email').fill('john@email.com')
await page.getByLabel('Avatar URL').fill('https://domain.com/avatar.png') await page.getByLabel('Avatar URL').fill('https://domain.com/avatar.png')
await page.getByLabel('Phone number').fill('+33654347543') 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( await expect(
page.getByText('Chatwoot block is not supported in preview').nth(0) page.getByText('Chatwoot block is not supported in preview').nth(0)
).toBeVisible() ).toBeVisible()

View File

@@ -31,7 +31,7 @@ test.describe.parallel('Google sheets integration', () => {
'Georges' 'Georges'
) )
await page.click('text=Preview') await page.click('text=Test')
await page await page
.locator('typebot-standard') .locator('typebot-standard')
.locator('input[placeholder="Type your email..."]') .locator('input[placeholder="Type your email..."]')
@@ -76,7 +76,7 @@ test.describe.parallel('Google sheets integration', () => {
'Last name' 'Last name'
) )
await page.click('text=Preview') await page.click('text=Test')
await page await page
.locator('typebot-standard') .locator('typebot-standard')
.locator('input[placeholder="Type your email..."]') .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 page.getByRole('menuitem', { name: 'Last name' }).click()
await createNewVar(page, 'Last name') await createNewVar(page, 'Last name')
await page.click('text=Preview') await page.click('text=Test')
await page await page
.locator('typebot-standard') .locator('typebot-standard')
.locator('input[placeholder="Type your email..."]') .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('button', { name: 'Select key' }).click()
await page.getByRole('menuitem', { name: 'currency' }).click() await page.getByRole('menuitem', { name: 'currency' }).click()
await page.getByPlaceholder('Value').fill('USD') await page.getByPlaceholder('Value').fill('USD')
await page.getByRole('button', { name: 'Preview' }).click() await page.getByRole('button', { name: 'Test' }).click()
await expect( await expect(
page.getByText('Pixel is not enabled in Preview mode').nth(1) page.getByText('Pixel is not enabled in Preview mode').nth(1)
).toBeVisible() ).toBeVisible()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ export const ProjectsDropdown = ({
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
const { showToast } = useToast() const { showToast } = useToast()
const { data } = trpc.zemanticAi.listProjects.useQuery( const { data } = trpc.zemanticAI.listProjects.useQuery(
{ {
credentialsId, credentialsId,
workspaceId: workspace?.id as string, 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 page.getByLabel('Percent of users to follow A:').fill('100')
await expect(page.getByText('A 100%')).toBeVisible() await expect(page.getByText('A 100%')).toBeVisible()
await expect(page.getByText('B 0%')).toBeVisible() await expect(page.getByText('B 0%')).toBeVisible()
await page.getByRole('button', { name: 'Preview' }).click() await page.getByRole('button', { name: 'Test' }).click()
await expect( await expect(
page.locator('typebot-standard').getByText('How are you?') page.locator('typebot-standard').getByText('How are you?')
).toBeVisible() ).toBeVisible()

View File

@@ -49,7 +49,7 @@ test.describe('Condition block', () => {
await page.click('button:has-text("Greater than")', { force: true }) await page.click('button:has-text("Greater than")', { force: true })
await page.fill('input[placeholder="Type a number..."]', '20') await page.fill('input[placeholder="Type a number..."]', '20')
await page.click('text=Preview') await page.click('text=Test')
await page await page
.locator('typebot-standard') .locator('typebot-standard')
.locator('input[placeholder="Type a number..."]') .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.getByRole('menuitem', { name: 'Group #1' }).click()
await page.getByPlaceholder('Select a block').click() await page.getByPlaceholder('Select a block').click()
await page.getByRole('menuitem', { name: 'Block #2' }).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.getByPlaceholder('Type your answer...').fill('Hi there!')
await page.getByRole('button', { name: 'Send' }).click() await page.getByRole('button', { name: 'Send' }).click()
await expect( await expect(

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ export const getLinkedTypebots = authenticatedProcedure
.meta({ .meta({
openapi: { openapi: {
method: 'GET', method: 'GET',
path: '/typebots/{typebotId}/linkedTypebots', path: '/v1/typebots/{typebotId}/linkedTypebots',
protect: true, protect: true,
summary: 'Get linked typebots', summary: 'Get linked typebots',
tags: ['Typebot'], 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('input[placeholder="Select a group"]')
await page.click('text=Group #2') await page.click('text=Group #2')
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator('typebot-standard').locator('text=Second block') page.locator('typebot-standard').locator('text=Second block')
).toBeVisible() ).toBeVisible()
@@ -45,7 +45,7 @@ test('should be configurable', async ({ page }) => {
await page.getByTestId('selected-item-label').nth(1).click({ force: true }) await page.getByTestId('selected-item-label').nth(1).click({ force: true })
await page.click('button >> text=Start') 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').fill('Hello there!')
await page.locator('typebot-standard').locator('input').press('Enter') await page.locator('typebot-standard').locator('input').press('Enter')
await expect( await expect(
@@ -60,7 +60,7 @@ test('should be configurable', async ({ page }) => {
await page.getByRole('textbox').nth(1).click() await page.getByRole('textbox').nth(1).click()
await page.click('button >> text=Hello') await page.click('button >> text=Hello')
await page.click('text=Preview') await page.click('text=Test')
await expect( await expect(
page.locator('typebot-standard').locator('text=Hello world') page.locator('typebot-standard').locator('text=Hello world')
).toBeVisible() ).toBeVisible()

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ test.describe('Typebot owner', () => {
}, },
]) ])
await page.goto(`/typebots/${typebotId}/edit`) 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 expect(page.locator('text=Free user')).toBeHidden()
await page.fill( await page.fill(
'input[placeholder="colleague@company.com"]', '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=Another typebot')).toBeHidden()
await expect(page.locator('text=Guest folder')).toBeHidden() await expect(page.locator('text=Guest folder')).toBeHidden()
await page.click('text=Guest typebot') 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 page.click('text=Everyone at Guest workspace')
await expect(page.locator('text="Remove"')).toBeHidden() await expect(page.locator('text="Remove"')).toBeHidden()
await expect(page.locator('text=John Doe')).toBeVisible() 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=Another typebot')).toBeHidden()
await expect(page.locator('text=Guest folder')).toBeHidden() await expect(page.locator('text=Guest folder')).toBeHidden()
await page.click('text=Guest typebot') 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 page.click('text=Everyone at Guest workspace')
await expect(page.locator('text="Remove"')).toBeHidden() await expect(page.locator('text="Remove"')).toBeHidden()
await expect(page.locator('text=John Doe')).toBeVisible() await expect(page.locator('text=John Doe')).toBeVisible()
await page.click('text=Group #1', { force: true }) 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 ( return (
<Stack spacing={1} pt="4" pb="2"> <Stack spacing={1} pt="4">
<HStack as="form" onSubmit={handleInvitationSubmit} px="4" pb="2"> <HStack as="form" onSubmit={handleInvitationSubmit} px="4" pb="2">
<Input <Input
size="sm" 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({ .meta({
openapi: { openapi: {
method: 'POST', method: 'POST',
path: '/credentials', path: '/v1/credentials',
protect: true, protect: true,
summary: 'Create credentials', summary: 'Create credentials',
tags: ['Credentials'], tags: ['Credentials'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export const saveThemeTemplate = authenticatedProcedure
.meta({ .meta({
openapi: { openapi: {
method: 'PUT', method: 'PUT',
path: '/themeTemplates/{themeTemplateId}', path: '/v1/themeTemplates/{themeTemplateId}',
protect: true, protect: true,
summary: 'Save theme template', summary: 'Save theme template',
tags: ['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 { Flex } from '@chakra-ui/react'
import { Standard } from '@typebot.io/nextjs' import { Standard } from '@typebot.io/nextjs'
import { ThemeSideMenu } from './ThemeSideMenu' import { ThemeSideMenu } from './ThemeSideMenu'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
export const ThemePage = () => { export const ThemePage = () => {
const { typebot } = useTypebot() const { typebot, is404 } = useTypebot()
if (is404) return <TypebotNotFoundPage />
return ( return (
<Flex overflow="hidden" h="100vh" flexDir="column"> <Flex overflow="hidden" h="100vh" flexDir="column">
<Seo title={typebot?.name ? `${typebot.name} | Theme` : 'Theme'} /> <Seo title={typebot?.name ? `${typebot.name} | Theme` : 'Theme'} />

View File

@@ -7,7 +7,6 @@ import {
Heading, Heading,
HStack, HStack,
Stack, Stack,
Tag,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { ChatIcon, CodeIcon, DropletIcon, TableIcon } from '@/components/icons' import { ChatIcon, CodeIcon, DropletIcon, TableIcon } from '@/components/icons'
import { ChatTheme, GeneralTheme, ThemeTemplate } from '@typebot.io/schemas' 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' import { defaultSettings } from '@typebot.io/schemas/features/typebot/settings/constants'
export const ThemeSideMenu = () => { export const ThemeSideMenu = () => {
const { typebot, updateTypebot } = useTypebot() const { typebot, updateTypebot, currentUserMode } = useTypebot()
const updateChatTheme = (chat: ChatTheme) => const updateChatTheme = (chat: ChatTheme) =>
typebot && updateTypebot({ updates: { theme: { ...typebot.theme, chat } } }) typebot && updateTypebot({ updates: { theme: { ...typebot.theme, chat } } })
@@ -71,15 +70,12 @@ export const ThemeSideMenu = () => {
Customize the theme Customize the theme
</Heading> </Heading>
<Accordion allowMultiple> <Accordion allowMultiple>
{currentUserMode === 'write' && (
<AccordionItem> <AccordionItem>
<AccordionButton py={6}> <AccordionButton py={6}>
<HStack flex="1" pl={2}> <HStack flex="1" pl={2}>
<TableIcon /> <TableIcon />
<Heading fontSize="lg"> <Heading fontSize="lg">Templates</Heading>
<HStack>
<span>Templates</span> <Tag colorScheme="orange">New!</Tag>
</HStack>
</Heading>
</HStack> </HStack>
<AccordionIcon /> <AccordionIcon />
</AccordionButton> </AccordionButton>
@@ -96,6 +92,7 @@ export const ThemeSideMenu = () => {
)} )}
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
)}
<AccordionItem> <AccordionItem>
<AccordionButton py={6}> <AccordionButton py={6}>
<HStack flex="1" pl={2}> <HStack flex="1" pl={2}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,28 @@ import {
User, User,
Workspace, Workspace,
MemberInWorkspace, MemberInWorkspace,
Typebot,
} from '@typebot.io/prisma' } from '@typebot.io/prisma'
import { settingsSchema } from '@typebot.io/schemas'
export const isReadTypebotForbidden = async ( export const isReadTypebotForbidden = async (
typebot: { typebot: {
settings?: Typebot['settings']
collaborators: Pick<CollaboratorsOnTypebots, 'userId'>[] collaborators: Pick<CollaboratorsOnTypebots, 'userId'>[]
} & { } & {
workspace: Pick<Workspace, 'isSuspended' | 'isPastDue'> & { workspace: Pick<Workspace, 'isSuspended' | 'isPastDue'> & {
members: Pick<MemberInWorkspace, 'userId'>[] members: Pick<MemberInWorkspace, 'userId'>[]
} }
}, },
user: Pick<User, 'email' | '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.isSuspended ||
typebot.workspace.isPastDue || typebot.workspace.isPastDue ||
(env.ADMIN_EMAIL !== user.email && (env.ADMIN_EMAIL !== user.email &&
@@ -23,3 +33,5 @@ export const isReadTypebotForbidden = async (
(collaborator) => collaborator.userId === user.id (collaborator) => collaborator.userId === user.id
) && ) &&
!typebot.workspace.members.some((member) => member.userId === user.id)) !typebot.workspace.members.some((member) => member.userId === user.id))
)
}

View File

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

View File

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

View File

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

View File

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

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