2
0

🚸 (bot) Show a popup when the redirect is blocked by browser

Allows us to show a link button to redirect the user anyway
This commit is contained in:
Baptiste Arnaud
2023-02-20 08:36:48 +01:00
parent e6ec84b77b
commit b2d1235f1b
9 changed files with 148 additions and 34 deletions

View File

@ -29,6 +29,7 @@ import { getLastChatBlockType } from '@/utils/chat'
import { executeIntegration } from '@/utils/executeIntegration' import { executeIntegration } from '@/utils/executeIntegration'
import { executeLogic } from '@/utils/executeLogic' import { executeLogic } from '@/utils/executeLogic'
import { blockCanBeRetried, parseRetryBlock } from '@/utils/inputs' import { blockCanBeRetried, parseRetryBlock } from '@/utils/inputs'
import { PopupBlockedToast } from '../PopupBlockedToast'
type ChatGroupProps = { type ChatGroupProps = {
blocks: Block[] blocks: Block[]
@ -72,6 +73,7 @@ export const ChatGroup = ({
const { scroll } = useChat() const { scroll } = useChat()
const [processedBlocks, setProcessedBlocks] = useState<Block[]>([]) const [processedBlocks, setProcessedBlocks] = useState<Block[]>([])
const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([]) const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
const [blockedPopupUrl, setBlockedPopupUrl] = useState<string>()
const insertBlockInStack = (nextBlock: Block) => { const insertBlockInStack = (nextBlock: Block) => {
setProcessedBlocks([...processedBlocks, nextBlock]) setProcessedBlocks([...processedBlocks, nextBlock])
@ -120,21 +122,25 @@ export const ChatGroup = ({
const currentBlock = [...processedBlocks].pop() const currentBlock = [...processedBlocks].pop()
if (!currentBlock) return if (!currentBlock) return
if (isLogicBlock(currentBlock)) { if (isLogicBlock(currentBlock)) {
const { nextEdgeId, linkedTypebot } = await executeLogic(currentBlock, { const { nextEdgeId, linkedTypebot, blockedPopupUrl } = await executeLogic(
isPreview, currentBlock,
apiHost, {
typebot, isPreview,
linkedTypebots, apiHost,
updateVariableValue, typebot,
updateVariables, linkedTypebots,
injectLinkedTypebot, updateVariableValue,
onNewLog, updateVariables,
createEdge, injectLinkedTypebot,
setCurrentTypebotId, onNewLog,
pushEdgeIdInLinkedTypebotQueue, createEdge,
currentTypebotId, setCurrentTypebotId,
pushParentTypebotId, pushEdgeIdInLinkedTypebotQueue,
}) currentTypebotId,
pushParentTypebotId,
}
)
if (blockedPopupUrl) setBlockedPopupUrl(blockedPopupUrl)
const isRedirecting = const isRedirecting =
currentBlock.type === LogicBlockType.REDIRECT && currentBlock.type === LogicBlockType.REDIRECT &&
currentBlock.options.isNewTab === false currentBlock.options.isNewTab === false
@ -224,6 +230,8 @@ export const ChatGroup = ({
hasGuestAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false} hasGuestAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
onDisplayNextBlock={displayNextBlock} onDisplayNextBlock={displayNextBlock}
keepShowingHostAvatar={keepShowingHostAvatar} keepShowingHostAvatar={keepShowingHostAvatar}
blockedPopupUrl={blockedPopupUrl}
onBlockedPopupLinkClick={() => setBlockedPopupUrl(undefined)}
/> />
))} ))}
</div> </div>
@ -236,6 +244,8 @@ type Props = {
hostAvatar: { isEnabled: boolean; src?: string } hostAvatar: { isEnabled: boolean; src?: string }
hasGuestAvatar: boolean hasGuestAvatar: boolean
keepShowingHostAvatar: boolean keepShowingHostAvatar: boolean
blockedPopupUrl?: string
onBlockedPopupLinkClick: () => void
onDisplayNextBlock: ( onDisplayNextBlock: (
answerContent?: InputSubmitContent, answerContent?: InputSubmitContent,
isRetry?: boolean isRetry?: boolean
@ -246,6 +256,8 @@ const ChatChunks = ({
hostAvatar, hostAvatar,
hasGuestAvatar, hasGuestAvatar,
keepShowingHostAvatar, keepShowingHostAvatar,
blockedPopupUrl,
onBlockedPopupLinkClick,
onDisplayNextBlock, onDisplayNextBlock,
}: Props) => { }: Props) => {
const [isSkipped, setIsSkipped] = useState(false) const [isSkipped, setIsSkipped] = useState(false)
@ -320,6 +332,14 @@ const ChatChunks = ({
)} )}
</CSSTransition> </CSSTransition>
)} )}
{blockedPopupUrl ? (
<div className="flex justify-end">
<PopupBlockedToast
url={blockedPopupUrl}
onLinkClick={onBlockedPopupLinkClick}
/>
</div>
) : null}
</> </>
) )
} }

View File

@ -0,0 +1,30 @@
type Props = {
url: string
onLinkClick: () => void
}
export const PopupBlockedToast = ({ url, onLinkClick }: Props) => {
return (
<div
className="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
role="alert"
>
<span className="mb-1 text-sm font-semibold text-gray-900">
Popup blocked
</span>
<div className="mb-2 text-sm font-normal">
The bot wants to open a new tab but it was blocked by your broswer. It
needs a manual approval.
</div>
<a
href={url}
target="_blank"
className="py-1 px-4 justify-center text-sm font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button"
rel="noreferrer"
onClick={onLinkClick}
>
Continue in new tab
</a>
</div>
)
}

View File

@ -7,21 +7,33 @@ import { sanitizeUrl } from 'utils'
export const executeRedirect = ( export const executeRedirect = (
block: RedirectBlock, block: RedirectBlock,
{ typebot: { variables } }: LogicState { typebot: { variables } }: LogicState
): EdgeId | undefined => { ): {
if (!block.options?.url) return block.outgoingEdgeId nextEdgeId?: EdgeId
blockedPopupUrl?: string
} => {
if (!block.options?.url) return { nextEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url)) const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
const isEmbedded = window.parent && window.location !== window.top?.location const isEmbedded = window.parent && window.location !== window.top?.location
let newWindow: Window | null = null
if (isEmbedded) { if (isEmbedded) {
if (!block.options.isNewTab) if (!block.options.isNewTab) {
return ((window.top as Window).location.href = formattedUrl) ;(window.top as Window).location.href = formattedUrl
return { nextEdgeId: block.outgoingEdgeId }
}
try { try {
window.open(formattedUrl) newWindow = window.open(formattedUrl)
} catch (err) { } catch (err) {
sendEventToParent({ redirectUrl: formattedUrl }) sendEventToParent({ redirectUrl: formattedUrl })
} }
} else { } else {
window.open(formattedUrl, block.options.isNewTab ? '_blank' : '_self') newWindow = window.open(
formattedUrl,
block.options.isNewTab ? '_blank' : '_self'
)
}
return {
nextEdgeId: block.outgoingEdgeId,
blockedPopupUrl: newWindow ? undefined : formattedUrl,
} }
return block.outgoingEdgeId
} }

View File

@ -15,6 +15,7 @@ export const executeLogic = async (
): Promise<{ ): Promise<{
nextEdgeId?: EdgeId nextEdgeId?: EdgeId
linkedTypebot?: TypebotViewerProps['typebot'] | LinkedTypebot linkedTypebot?: TypebotViewerProps['typebot'] | LinkedTypebot
blockedPopupUrl?: string
}> => { }> => {
switch (block.type) { switch (block.type) {
case LogicBlockType.SET_VARIABLE: case LogicBlockType.SET_VARIABLE:
@ -22,7 +23,7 @@ export const executeLogic = async (
case LogicBlockType.CONDITION: case LogicBlockType.CONDITION:
return { nextEdgeId: executeCondition(block, context) } return { nextEdgeId: executeCondition(block, context) }
case LogicBlockType.REDIRECT: case LogicBlockType.REDIRECT:
return { nextEdgeId: executeRedirect(block, context) } return executeRedirect(block, context)
case LogicBlockType.SCRIPT: case LogicBlockType.SCRIPT:
return { nextEdgeId: await executeScript(block, context) } return { nextEdgeId: await executeScript(block, context) }
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:

View File

@ -6,6 +6,7 @@ import { BotContext, InitialChatReply } from '@/types'
import { isNotDefined } from 'utils' import { isNotDefined } from 'utils'
import { executeClientSideAction } from '@/utils/executeClientSideActions' import { executeClientSideAction } from '@/utils/executeClientSideActions'
import { LoadingChunk } from './LoadingChunk' import { LoadingChunk } from './LoadingChunk'
import { PopupBlockedToast } from './PopupBlockedToast'
const parseDynamicTheme = ( const parseDynamicTheme = (
initialTheme: Theme, initialTheme: Theme,
@ -57,6 +58,7 @@ export const ConversationContainer = (props: Props) => {
>(props.initialChatReply.dynamicTheme) >(props.initialChatReply.dynamicTheme)
const [theme, setTheme] = createSignal(props.initialChatReply.typebot.theme) const [theme, setTheme] = createSignal(props.initialChatReply.typebot.theme)
const [isSending, setIsSending] = createSignal(false) const [isSending, setIsSending] = createSignal(false)
const [blockedPopupUrl, setBlockedPopupUrl] = createSignal<string>()
createEffect(() => { createEffect(() => {
setTheme( setTheme(
@ -112,7 +114,8 @@ export const ConversationContainer = (props: Props) => {
isNotDefined(action.lastBubbleBlockId) isNotDefined(action.lastBubbleBlockId)
) )
for (const action of actionsToExecute) { for (const action of actionsToExecute) {
await executeClientSideAction(action) const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
} }
} }
if (isNotDefined(lastChunk.input)) { if (isNotDefined(lastChunk.input)) {
@ -128,7 +131,8 @@ export const ConversationContainer = (props: Props) => {
(action) => action.lastBubbleBlockId === blockId (action) => action.lastBubbleBlockId === blockId
) )
for (const action of actionsToExecute) { for (const action of actionsToExecute) {
await executeClientSideAction(action) const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
} }
} }
} }
@ -161,6 +165,16 @@ export const ConversationContainer = (props: Props) => {
<Show when={isSending()}> <Show when={isSending()}>
<LoadingChunk theme={theme()} /> <LoadingChunk theme={theme()} />
</Show> </Show>
<Show when={blockedPopupUrl()} keyed>
{(blockedPopupUrl) => (
<div class="flex justify-end">
<PopupBlockedToast
url={blockedPopupUrl}
onLinkClick={() => setBlockedPopupUrl(undefined)}
/>
</div>
)}
</Show>
<BottomSpacer ref={bottomSpacer} /> <BottomSpacer ref={bottomSpacer} />
</div> </div>
) )

View File

@ -0,0 +1,30 @@
type Props = {
url: string
onLinkClick: () => void
}
export const PopupBlockedToast = (props: Props) => {
return (
<div
class="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
role="alert"
>
<span class="mb-1 text-sm font-semibold text-gray-900">
Popup blocked
</span>
<div class="mb-2 text-sm font-normal">
The bot wants to open a new tab but it was blocked by your broswer. It
needs a manual approval.
</div>
<a
href={props.url}
target="_blank"
class="py-1 px-4 justify-center text-sm font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button"
rel="noreferrer"
onClick={() => props.onLinkClick()}
>
Continue in new tab
</a>
</div>
)
}

View File

@ -13,7 +13,7 @@ export const GuestBubble = (props: Props) => (
style={{ 'margin-left': '50px' }} style={{ 'margin-left': '50px' }}
> >
<span <span
class="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer" class="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
data-testid="guest-bubble" data-testid="guest-bubble"
> >
{props.message} {props.message}

View File

@ -1,6 +1,13 @@
import type { RedirectOptions } from 'models' import type { RedirectOptions } from 'models'
export const executeRedirect = ({ url, isNewTab }: RedirectOptions) => { export const executeRedirect = ({
url,
isNewTab,
}: RedirectOptions): { blockedPopupUrl: string } | undefined => {
if (!url) return if (!url) return
window.open(url, isNewTab ? '_blank' : '_self') const updatedWindow = window.open(url, isNewTab ? '_blank' : '_self')
if (!updatedWindow)
return {
blockedPopupUrl: url,
}
} }

View File

@ -7,20 +7,20 @@ import type { ChatReply } from 'models'
export const executeClientSideAction = async ( export const executeClientSideAction = async (
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0] clientSideAction: NonNullable<ChatReply['clientSideActions']>[0]
) => { ): Promise<{ blockedPopupUrl: string } | void> => {
if ('chatwoot' in clientSideAction) { if ('chatwoot' in clientSideAction) {
executeChatwoot(clientSideAction.chatwoot) return executeChatwoot(clientSideAction.chatwoot)
} }
if ('googleAnalytics' in clientSideAction) { if ('googleAnalytics' in clientSideAction) {
executeGoogleAnalyticsBlock(clientSideAction.googleAnalytics) return executeGoogleAnalyticsBlock(clientSideAction.googleAnalytics)
} }
if ('scriptToExecute' in clientSideAction) { if ('scriptToExecute' in clientSideAction) {
await executeScript(clientSideAction.scriptToExecute) return executeScript(clientSideAction.scriptToExecute)
} }
if ('redirect' in clientSideAction) { if ('redirect' in clientSideAction) {
executeRedirect(clientSideAction.redirect) return executeRedirect(clientSideAction.redirect)
} }
if ('wait' in clientSideAction) { if ('wait' in clientSideAction) {
await executeWait(clientSideAction.wait) return executeWait(clientSideAction.wait)
} }
} }