2
0

build: 🏗️ Import typebot-js source

This commit is contained in:
Baptiste Arnaud
2022-03-10 17:47:59 +01:00
parent 31298e39c1
commit d134a265cd
34 changed files with 3321 additions and 64 deletions

View File

@ -28,7 +28,7 @@
"autoprefixer": "^10.4.2",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.6",
"rollup": "^2.67.2",
"rollup": "^2.70.0",
"rollup-plugin-dts": "^4.1.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",

View File

@ -0,0 +1,2 @@
*.js
*.html

View File

@ -0,0 +1,12 @@
module.exports = {
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "prettier"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
],
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["off"],
},
};

View File

@ -0,0 +1,10 @@
.github
src
tests
.eslintignore
.eslintrc.js
jest.config.js
rollup.config.js
tsconfig.json
examples
__mocks__

View File

@ -0,0 +1,37 @@
# Typebot JS library
[![NPM](https://flat.badgen.net/npm/v/typebot-js)](https://www.npmjs.com/package/typebot-js) [![Bundle](https://flat.badgen.net/bundlephobia/minzip/typebot-js)](https://bundlephobia.com/result?p=typebot-js@latest) [![Build Status](https://travis-ci.com/plausible/typebot-js.svg?branch=master)](https://travis-ci.com/plausible/typebot-js)
Frontend library to embed typebots from [Typebot](https://www.typebot.io/).
## Installation
To install, simply run:
```bash
npm install typebot-js
yarn add typebot-js
```
## Usage
It exposes 3 functions:
```ts
initContainer();
initPopup();
initBubble();
```
You can configure them directly in the "Share" tab of your typebot.
Example:
```ts
import { initContainer } from "typebot-js";
const plausible = initContainer("container-id", {
publishId: "my-app.com",
});
```

View File

@ -0,0 +1 @@
module.exports = {};

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>TITLE</title>
<style type="text/css">
body {
margin: 0;
}
</style>
<script src="../dist/index.umd.min.js"></script>
</head>
<body>
<script>
const { open } = Typebot.initBubble({
publishId: "feedback-form",
button: {
color: "green",
iconUrl: "https://image.flaticon.com/icons/png/512/5138/5138352.png",
},
proactiveMessage: {
avatarUrl:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80",
textContent: "Hey what's up?",
delay: 1000,
rememberClose: true,
},
});
</script>
<div style="width: 100%; height: 300vh; background-color: aquamarine">
<button onclick="(()=>{open()})()" style="width: 200px; height: 60px">
OPEN CHAT
</button>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html style="overflow-y: scroll">
<head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>TITLE</title>
<style type="text/css">
html {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
margin: 0;
}
</style>
</head>
<body>
<div style="width: 100%; height: 300vh; background-color: aquamarine">
<div id="typebot-container" style="width: 1000px; height: 600px"></div>
<div id="typebot-container-2" style="width: 1000px; height: 600px"></div>
</div>
<div id="typebot-container-3" style="width: 1000px; height: 600px"></div>
<script src="../dist/index.umd.min.js"></script>
<script>
Typebot.initContainer("typebot-container", { publishId: "jamhouse" });
Typebot.initContainer("typebot-container-2", {
publishId: "lead-generation",
});
Typebot.initContainer("typebot-container-3", {
publishId: "feedback-form",
loadWhenVisible: false,
});
</script>
</body>
</html>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<title>TITLE</title>
<style type="text/css">
body {
margin: 0;
}
</style>
</head>
<body>
<script src="../dist/index.umd.min.js"></script>
<script>
const { open } = Typebot.initPopup({
publishId: "feedback-form",
delay: 1000,
});
</script>
<div style="width: 100%; height: 300vh; background-color: aquamarine">
<button onclick="(()=>{open()})()">CLICK</button>
</div>
</body>
</html>

View File

@ -0,0 +1,7 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
moduleNameMapper: {
"\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js",
},
};

View File

@ -0,0 +1,29 @@
{
"name": "typebot-js",
"version": "2.1.3",
"main": "dist/index.js",
"unpkg": "dist/index.umd.min.js",
"license": "MIT",
"scripts": {
"build": "yarn lint && rollup -c",
"lint": "eslint src --ext .ts && eslint tests --ext .ts",
"test": "yarn jest"
},
"devDependencies": {
"@rollup/plugin-typescript": "^8.3.1",
"@types/jest": "^27.4.1",
"@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/parser": "^5.14.0",
"eslint": "<8.0.0",
"eslint-plugin-functional": "^4.2.0",
"eslint-plugin-jest": "^26.1.1",
"eslint-plugin-prettier": "^4.0.0",
"jest": "^27.5.1",
"rollup": "^2.70.0",
"rollup-plugin-styles": "^4.0.0",
"rollup-plugin-terser": "^7.0.2",
"ts-jest": "^27.1.3",
"ts-loader": "^9.2.7",
"typescript": "^4.6.2"
}
}

View File

@ -0,0 +1,26 @@
import typescript from "@rollup/plugin-typescript";
import { terser } from "rollup-plugin-terser";
import styles from "rollup-plugin-styles";
export default [
// ES Modules
{
input: "src/index.ts",
output: {
file: "dist/index.js",
format: "cjs",
},
plugins: [typescript({ tsconfig: "./tsconfig.json" }), styles()],
},
// UMD
{
input: "src/index.ts",
output: {
file: "dist/index.umd.min.js",
format: "umd",
name: "Typebot",
},
plugins: [typescript({ tsconfig: "./tsconfig.json" }), terser(), styles()],
},
];

View File

@ -0,0 +1,40 @@
import { ButtonParams } from "../../types";
export const createButton = (params?: ButtonParams): HTMLButtonElement => {
const button = document.createElement("button");
button.id = "typebot-bubble-button";
button.style.backgroundColor = params?.color ?? "#0042DA";
button.appendChild(createButtonIcon(params?.iconUrl));
button.appendChild(createCloseIcon());
return button;
};
const createButtonIcon = (src?: string): SVGElement | HTMLImageElement => {
if (!src) return createDefaultIcon();
const icon = document.createElement("img");
icon.classList.add("icon");
icon.src = src;
return icon;
};
const createDefaultIcon = (): SVGElement => {
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 41 19");
icon.style.width = "63%";
icon.innerHTML = typebotLogoSvgTextContent();
icon.classList.add("icon");
return icon;
};
const createCloseIcon = (): SVGElement => {
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 512 512");
icon.innerHTML = closeSvgPath;
icon.classList.add("close-icon");
return icon;
};
const typebotLogoSvgTextContent = () =>
`<rect x="40.29" y="0.967773" width="6.83761" height="30.7692" rx="3.4188" transform="rotate(90 40.29 0.967773)"></rect> <path fill-rule="evenodd" clip-rule="evenodd" d="M3.70884 7.80538C5.597 7.80538 7.12765 6.27473 7.12765 4.38658C7.12765 2.49842 5.597 0.967773 3.70884 0.967773C1.82069 0.967773 0.290039 2.49842 0.290039 4.38658C0.290039 6.27473 1.82069 7.80538 3.70884 7.80538Z" fill="white"></path> <rect x="0.290039" y="18.0615" width="6.83761" height="30.7692" rx="3.4188" transform="rotate(-90 0.290039 18.0615)" fill="white"></rect> <path fill-rule="evenodd" clip-rule="evenodd" d="M36.8712 11.2239C34.9831 11.2239 33.4524 12.7546 33.4524 14.6427C33.4524 16.5309 34.9831 18.0615 36.8712 18.0615C38.7594 18.0615 40.29 16.5309 40.29 14.6427C40.29 12.7546 38.7594 11.2239 36.8712 11.2239Z" fill="white"></path>`;
export const closeSvgPath = `<path d="M278.6 256l68.2-68.2c6.2-6.2 6.2-16.4 0-22.6-6.2-6.2-16.4-6.2-22.6 0L256 233.4l-68.2-68.2c-6.2-6.2-16.4-6.2-22.6 0-3.1 3.1-4.7 7.2-4.7 11.3 0 4.1 1.6 8.2 4.7 11.3l68.2 68.2-68.2 68.2c-3.1 3.1-4.7 7.2-4.7 11.3 0 4.1 1.6 8.2 4.7 11.3 6.2 6.2 16.4 6.2 22.6 0l68.2-68.2 68.2 68.2c6.2 6.2 16.4 6.2 22.6 0 6.2-6.2 6.2-16.4 0-22.6L278.6 256z"></path>`;

View File

@ -0,0 +1,28 @@
import { createIframe } from "../../iframe";
import { IframeParams } from "../../types";
export const createIframeContainer = (
params: IframeParams
): HTMLIFrameElement => {
const iframe = createIframe({ ...params, loadWhenVisible: true });
return iframe;
};
export const openIframe = (
bubble: HTMLDivElement,
iframe: HTMLIFrameElement
): void => {
loadTypebotIfFirstOpen(iframe);
bubble.classList.add("iframe-opened");
bubble.classList.remove("message-opened");
};
export const closeIframe = (bubble: HTMLDivElement): void => {
bubble.classList.remove("iframe-opened");
};
export const loadTypebotIfFirstOpen = (iframe: HTMLIFrameElement): void => {
if (!iframe.dataset.src) return;
iframe.src = iframe.dataset.src;
iframe.removeAttribute("data-src");
};

View File

@ -0,0 +1,134 @@
import {
BubbleActions,
BubbleParams,
localStorageKeys,
ProactiveMessageParams,
} from "../../types";
import { createButton } from "./button";
import {
closeIframe,
createIframeContainer,
loadTypebotIfFirstOpen,
openIframe,
} from "./iframe";
import {
createProactiveMessage,
openProactiveMessage,
} from "./proactiveMessage";
import "./style.css";
export const initBubble = (params: BubbleParams): BubbleActions => {
if (document.readyState !== "complete") {
window.addEventListener("load", () => initBubble(params));
return { close: () => {}, open: () => {} };
}
const existingBubble = document.getElementById("typebot-bubble") as
| HTMLDivElement
| undefined;
if (existingBubble) existingBubble.remove();
const { bubbleElement, proactiveMessageElement, iframeElement } =
createBubble(params);
!document.body
? (window.onload = () => document.body.appendChild(bubbleElement))
: document.body.appendChild(bubbleElement);
return getBubbleActions(
bubbleElement,
iframeElement,
proactiveMessageElement
);
};
const createBubble = (
params: BubbleParams
): {
bubbleElement: HTMLDivElement;
iframeElement: HTMLIFrameElement;
proactiveMessageElement?: HTMLDivElement;
} => {
const bubbleElement = document.createElement("div");
bubbleElement.id = "typebot-bubble";
const buttonElement = createButton(params.button);
bubbleElement.appendChild(buttonElement);
const proactiveMessageElement =
params.proactiveMessage && !hasBeenClosed()
? addProactiveMessage(params.proactiveMessage, bubbleElement)
: undefined;
const iframeElement = createIframeContainer(params);
buttonElement.addEventListener("click", () =>
onBubbleButtonClick(bubbleElement, iframeElement)
);
if (proactiveMessageElement)
proactiveMessageElement.addEventListener("click", () =>
onProactiveMessageClick(bubbleElement, iframeElement)
);
bubbleElement.appendChild(iframeElement);
return { bubbleElement, proactiveMessageElement, iframeElement };
};
const onBubbleButtonClick = (
bubble: HTMLDivElement,
iframe: HTMLIFrameElement
): void => {
loadTypebotIfFirstOpen(iframe);
bubble.classList.toggle("iframe-opened");
bubble.classList.remove("message-opened");
};
const onProactiveMessageClick = (
bubble: HTMLDivElement,
iframe: HTMLIFrameElement
): void => {
loadTypebotIfFirstOpen(iframe);
bubble.classList.add("iframe-opened");
bubble.classList.remove("message-opened");
};
export const getBubbleActions = (
bubbleElement?: HTMLDivElement,
iframeElement?: HTMLIFrameElement,
proactiveMessageElement?: HTMLDivElement
): BubbleActions => {
const existingBubbleElement =
bubbleElement ??
(document.querySelector("#typebot-bubble") as HTMLDivElement);
const existingIframeElement =
iframeElement ??
(existingBubbleElement.querySelector(
".typebot-iframe"
) as HTMLIFrameElement);
const existingProactiveMessage =
proactiveMessageElement ??
document.querySelector("#typebot-bubble .proactive-message");
return {
openProactiveMessage: existingProactiveMessage
? () => {
openProactiveMessage(existingBubbleElement);
}
: undefined,
open: () => {
openIframe(existingBubbleElement, existingIframeElement);
},
close: () => {
closeIframe(existingBubbleElement);
},
};
};
const addProactiveMessage = (
proactiveMessage: ProactiveMessageParams,
bubbleElement: HTMLDivElement
) => {
const proactiveMessageElement = createProactiveMessage(
proactiveMessage,
bubbleElement
);
bubbleElement.appendChild(proactiveMessageElement);
return proactiveMessageElement;
};
const hasBeenClosed = () => {
const closeDecisionFromStorage = localStorage.getItem(
localStorageKeys.rememberClose
);
return closeDecisionFromStorage ? true : false;
};

View File

@ -0,0 +1,66 @@
import { localStorageKeys, ProactiveMessageParams } from "../../types";
import { closeSvgPath } from "./button";
const createProactiveMessage = (
params: ProactiveMessageParams,
bubble: HTMLDivElement
): HTMLDivElement => {
const container = document.createElement("div");
container.classList.add("proactive-message");
if (params.delay !== undefined) setOpenTimeout(bubble, params);
if (params.avatarUrl) container.appendChild(createAvatar(params.avatarUrl));
if (params.rememberClose) setRememberCloseInStorage();
container.appendChild(createTextElement(params.textContent));
container.appendChild(createCloseButton(bubble));
return container;
};
const setOpenTimeout = (
bubble: HTMLDivElement,
params: ProactiveMessageParams
) => {
setTimeout(() => {
openProactiveMessage(bubble);
}, params.delay);
};
const createAvatar = (avatarUrl: string): HTMLImageElement => {
const element = document.createElement("img");
element.src = avatarUrl;
return element;
};
const createTextElement = (text: string): HTMLParagraphElement => {
const element = document.createElement("p");
element.innerHTML = text;
return element;
};
const createCloseButton = (bubble: HTMLDivElement): HTMLButtonElement => {
const button = document.createElement("button");
button.classList.add("close-button");
button.innerHTML = `<svg viewBox="0 0 512 512">${closeSvgPath}</svg>`;
button.addEventListener("click", (e) => onCloseButtonClick(e, bubble));
return button;
};
const openProactiveMessage = (bubble: HTMLDivElement): void => {
bubble.classList.add("message-opened");
};
const onCloseButtonClick = (
e: Event,
proactiveMessageElement: HTMLDivElement
) => {
e.stopPropagation();
closeProactiveMessage(proactiveMessageElement);
};
const closeProactiveMessage = (bubble: HTMLDivElement): void => {
bubble.classList.remove("message-opened");
};
const setRememberCloseInStorage = () =>
localStorage.setItem(localStorageKeys.rememberClose, "true");
export { createProactiveMessage, openProactiveMessage, closeProactiveMessage };

View File

@ -0,0 +1,185 @@
#typebot-bubble {
z-index: 99999;
position: fixed;
}
#typebot-bubble > button {
padding: 0px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 99999;
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
border-radius: 100%;
background-color: rgb(230, 114, 0);
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 6px 0px,
rgba(0, 0, 0, 0.16) 0px 2px 32px 0px;
border: medium none;
}
#typebot-bubble button:hover {
filter: brightness(0.95);
}
#typebot-bubble button:active {
filter: brightness(0.75);
}
#typebot-bubble > button > .icon {
width: 80%;
transition: opacity 500ms ease-out 0s, transform 500ms ease-out 0s;
fill: white;
}
#typebot-bubble > button > img.icon {
border-radius: 100%;
}
#typebot-bubble.iframe-opened > button > .icon {
transform: rotate(90deg) scale(0);
opacity: 0;
}
#typebot-bubble > button > .close-icon {
position: absolute;
width: 80%;
height: 80%;
transform: rotate(-90deg) scale(0);
opacity: 0;
transition: opacity 500ms ease-out 0s, transform 500ms ease-out 0s;
fill: white;
}
#typebot-bubble.iframe-opened > button > .close-icon {
transform: rotate(90deg) scale(1);
opacity: 1;
}
#typebot-bubble > iframe {
visibility: hidden;
opacity: 0;
display: flex;
border-radius: 10px;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 99999;
border-radius: 10px;
position: fixed;
transform: translate(0, 100px);
transition: opacity 500ms ease-out 0s, transform 500ms ease-out 0s;
box-shadow: rgba(0, 0, 0, 0.16) 0px 5px 40px;
width: 400px;
max-height: 680px;
inset: auto 20px 90px auto;
height: calc(100% - 160px);
background-color: white;
}
#typebot-bubble.iframe-opened > iframe {
transform: translate(0, 0);
visibility: visible;
opacity: 1;
}
.typebot-chat-button.active .typebot-chat-icon {
transform: rotate(90deg) scale(0);
opacity: 0;
}
.typebot-chat-button:not(.active) .typebot-chat-close {
transform: rotate(-90deg) scale(0);
opacity: 0;
}
.typebot-iframe-container:not(.active) {
opacity: 0;
transform: translate(0, 100px);
}
.typebot-iframe-container.active {
opacity: 1;
transform: translate(0, 0);
}
/* Proactive message */
#typebot-bubble > .proactive-message {
font-size: 18px;
color: #303235;
opacity: 0;
visibility: hidden;
transform: translate(0, 10px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
cursor: pointer;
font-weight: 300;
bottom: 90px;
right: 20px;
z-index: 99999;
position: fixed;
max-width: 280px;
background-color: white;
box-shadow: 0 3px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 16px;
display: flex;
align-items: center;
border-radius: 8px;
}
#typebot-bubble.message-opened > .proactive-message {
opacity: 1;
visibility: visible;
transform: translate(0, 0);
}
#typebot-bubble > .proactive-message > .close-button {
position: absolute;
top: -15px;
right: -7px;
width: 30px;
height: 30px;
background-color: rgb(237, 242, 247);
border-radius: 100%;
border: medium none;
outline: currentcolor none medium;
fill: #4a5568;
padding: 0px;
cursor: pointer;
padding: 2px;
}
#typebot-bubble > .proactive-message > img {
margin-right: 8px;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 100%;
object-fit: cover;
}
@media screen and (max-width: 450px) {
#typebot-bubble > .proactive-message {
max-width: 200px;
font-size: 15px;
bottom: 70px;
right: 10px;
}
#typebot-bubble > button {
width: 50px !important;
height: 50px !important;
bottom: 10px !important;
right: 10px !important;
}
#typebot-bubble > iframe {
inset: 0 0 auto auto;
width: 100%;
height: calc(100% - 70px);
max-height: none;
}
}

View File

@ -0,0 +1,37 @@
import { createIframe } from "../../iframe";
import { IframeParams } from "../../types";
export const initContainer = (
containerId: string,
iframeParams: IframeParams
): HTMLElement | undefined => {
const { loadWhenVisible } = iframeParams;
const containerElement = document.getElementById(containerId);
if (!containerElement) return;
if (containerElement.children[0])
return containerElement.children[0] as HTMLIFrameElement;
const lazy = loadWhenVisible ?? true;
const iframeElement = createIframe({
...iframeParams,
loadWhenVisible: lazy,
});
if (lazy) observeOnScroll(iframeElement);
containerElement.appendChild(iframeElement);
return iframeElement;
};
const observeOnScroll = (iframeElement: HTMLIFrameElement) => {
const observer = new IntersectionObserver(
(entries) => {
if (entries.pop()?.isIntersecting === true) lazyLoadSrc(iframeElement);
},
{ threshold: [0] }
);
observer.observe(iframeElement);
};
const lazyLoadSrc = (iframeElement: HTMLIFrameElement) => {
if (!iframeElement.dataset.src) return;
iframeElement.src = iframeElement.dataset.src;
iframeElement.removeAttribute("data-src");
};

View File

@ -0,0 +1,87 @@
import { createIframe } from "../../iframe";
import { PopupActions, PopupParams } from "../../types";
import "./style.css";
export const initPopup = (params: PopupParams): PopupActions => {
if (document.readyState !== "complete") {
window.addEventListener("load", () => initPopup(params));
return { close: () => {}, open: () => {} };
}
const existingPopup = document.getElementById("typebot-popup");
if (existingPopup) existingPopup.remove();
const popupElement = createPopup(params);
!document.body
? (window.onload = () => document.body.append(popupElement))
: document.body.append(popupElement);
return {
open: () => openPopup(popupElement),
close: () => closePopup(popupElement),
};
};
const createPopup = (params: PopupParams): HTMLElement => {
const { delay } = params;
const overlayElement = createOverlayElement(delay);
listenForOutsideClicks(overlayElement);
const iframeElement = createIframe({
...params,
loadWhenVisible: true,
});
overlayElement.appendChild(iframeElement);
return overlayElement;
};
const createOverlayElement = (delay: number | undefined) => {
const overlayElement = document.createElement("div");
overlayElement.id = "typebot-popup";
if (delay !== undefined) setShowTimeout(overlayElement, delay);
return overlayElement;
};
export const openPopup = (popupElement: HTMLElement): void => {
const iframe = popupElement.children[0] as HTMLIFrameElement;
if (iframe.dataset.src) lazyLoadSrc(iframe);
document.body.style.overflowY = "hidden";
popupElement.classList.add("opened");
};
export const closePopup = (popupElement: HTMLElement): void => {
document.body.style.overflowY = "auto";
popupElement.classList.remove("opened");
};
const listenForOutsideClicks = (popupElement: HTMLDivElement) =>
popupElement.addEventListener("click", (e) => onPopupClick(e, popupElement));
const onPopupClick = (e: Event, popupElement: HTMLDivElement) => {
e.preventDefault();
const clickedElement = e.target as HTMLElement;
if (clickedElement.tagName !== "iframe") closePopup(popupElement);
};
const setShowTimeout = (overlayElement: HTMLDivElement, delay: number) => {
setTimeout(() => {
openPopup(overlayElement);
}, delay);
};
const lazyLoadSrc = (iframe: HTMLIFrameElement) => {
iframe.src = iframe.dataset.src as string;
iframe.removeAttribute("data-src");
};
export const getPopupActions = (
popupElement?: HTMLDivElement
): PopupActions => {
const existingPopupElement =
popupElement ??
(document.querySelector("#typebot-popup") as HTMLDivElement);
return {
open: () => {
openPopup(existingPopupElement);
},
close: () => {
closePopup(existingPopupElement);
},
};
};

View File

@ -0,0 +1,26 @@
#typebot-popup {
position: fixed;
top: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
visibility: hidden;
opacity: 0;
transition: opacity 200ms;
z-index: 99999;
}
#typebot-popup.opened {
opacity: 1;
visibility: visible;
}
#typebot-popup > iframe {
width: 100%;
height: 600px;
max-width: 800px;
border-radius: 10px;
}

View File

@ -0,0 +1,58 @@
import { DataFromTypebot, IframeCallbacks, IframeParams } from "../types";
import "./style.css";
export const createIframe = ({
backgroundColor,
viewerHost = "https://typebot-viewer.vercel.app",
...iframeParams
}: IframeParams): HTMLIFrameElement => {
const { publishId, loadWhenVisible, hiddenVariables } = iframeParams;
const iframeUrl = `${viewerHost}/${publishId}${parseQueryParams(
hiddenVariables
)}`;
const iframe = document.createElement("iframe");
iframe.setAttribute(loadWhenVisible ? "data-src" : "src", iframeUrl);
iframe.setAttribute("data-id", iframeParams.publishId);
const randomThreeLettersId = Math.random().toString(36).substring(7);
const uniqueId = `${publishId}-${randomThreeLettersId}`;
iframe.setAttribute("id", uniqueId);
if (backgroundColor) iframe.style.backgroundColor = backgroundColor;
iframe.classList.add("typebot-iframe");
const { onNewVariableValue, onVideoPlayed } = iframeParams;
listenForTypebotMessages({ onNewVariableValue, onVideoPlayed });
return iframe;
};
const parseQueryParams = (starterVariables?: {
[key: string]: string | undefined;
}): string => {
return parseHostnameQueryParam() + parseStarterVariables(starterVariables);
};
const parseHostnameQueryParam = () => {
return `?hn=${window.location.hostname}`;
};
const parseStarterVariables = (starterVariables?: {
[key: string]: string | undefined;
}) =>
starterVariables
? `&${Object.keys(starterVariables)
.filter((key) => starterVariables[key])
.map((key) => `${key}=${starterVariables[key]}`)
.join("&")}`
: "";
export const listenForTypebotMessages = (callbacks: IframeCallbacks) => {
window.addEventListener("message", (event) => {
const data = event.data as { from?: "typebot" } & DataFromTypebot;
if (data.from === "typebot") processMessage(event.data, callbacks);
});
};
const processMessage = (data: DataFromTypebot, callbacks: IframeCallbacks) => {
if (data.redirectUrl) window.open(data.redirectUrl);
if (data.newVariableValue && callbacks.onNewVariableValue)
callbacks.onNewVariableValue(data.newVariableValue);
if (data.videoPlayed && callbacks.onVideoPlayed) callbacks.onVideoPlayed();
};

View File

@ -0,0 +1,6 @@
.typebot-iframe {
width: 100%;
height: 100%;
border: none;
border-radius: inherit;
}

View File

@ -0,0 +1,13 @@
import { initContainer } from "./embedTypes/container";
import { initPopup, getPopupActions } from "./embedTypes/popup";
import { initBubble, getBubbleActions } from "./embedTypes/chat";
export {
initContainer,
initPopup,
initBubble,
getPopupActions,
getBubbleActions,
};
export * from "./types";

View File

@ -0,0 +1,60 @@
export type IframeParams = {
publishId: string;
viewerHost?: string;
backgroundColor?: string;
hiddenVariables?: { [key: string]: string | undefined };
customDomain?: string;
loadWhenVisible?: boolean;
} & IframeCallbacks;
export type IframeCallbacks = {
onNewVariableValue?: (v: Variable) => void;
onVideoPlayed?: () => void;
};
export type PopupParams = {
delay?: number;
} & IframeParams;
export type PopupActions = {
open: () => void;
close: () => void;
};
export type BubbleParams = {
button?: ButtonParams;
proactiveMessage?: ProactiveMessageParams;
} & IframeParams;
export type ButtonParams = {
color?: string;
iconUrl?: string;
};
export type ProactiveMessageParams = {
avatarUrl?: string;
textContent: string;
delay?: number;
rememberClose?: boolean;
};
export type BubbleActions = {
open: () => void;
close: () => void;
openProactiveMessage?: () => void;
};
export type Variable = {
name: string;
value: string;
};
export type DataFromTypebot = {
redirectUrl?: string;
newVariableValue?: Variable;
videoPlayed?: boolean;
};
export const localStorageKeys = {
rememberClose: "rememberClose",
};

View File

@ -0,0 +1,40 @@
import * as Typebot from "../../src";
beforeEach(() => {
document.body.innerHTML = "";
});
it("should have the corresponding custom color", () => {
expect.assertions(1);
Typebot.initBubble({
button: { color: "#222222" },
publishId: "typebot-id",
});
const buttonElement = document.querySelector(
"#typebot-bubble > button"
) as HTMLElement;
expect(buttonElement.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
it("should have the default svg icon", () => {
expect.assertions(1);
Typebot.initBubble({
publishId: "typebot-id",
});
const buttonIconElement = document.querySelector(
"#typebot-bubble > button > .icon"
) as HTMLElement;
expect(buttonIconElement.tagName).toBe("svg");
});
it("should have the corresponding custom icon", () => {
expect.assertions(1);
Typebot.initBubble({
button: { iconUrl: "https://web.com/icon.png" },
publishId: "typebot-id",
});
const buttonIconElement = document.querySelector(
"#typebot-bubble > button > .icon"
) as HTMLImageElement;
expect(buttonIconElement.src).toBe("https://web.com/icon.png");
});

View File

@ -0,0 +1,91 @@
import * as Typebot from "../../src";
beforeEach(() => {
document.body.innerHTML = "";
});
describe("openBubble", () => {
it("should add the opened bubble", () => {
expect.assertions(3);
const { open } = Typebot.initBubble({
publishId: "typebot-id",
});
const bubble = document.getElementById("typebot-bubble") as HTMLDivElement;
expect(bubble.classList.contains("iframe-opened")).toBe(false);
open();
expect(bubble.classList.contains("iframe-opened")).toBe(true);
expect(open).not.toThrow();
});
it("should hide the proactive message", () => {
expect.assertions(2);
const { open, openProactiveMessage } = Typebot.initBubble({
publishId: "typebot-id",
proactiveMessage: {
textContent: "Hi click here!",
avatarUrl: "https://website.com/my-avatar.png",
},
});
const bubble = document.getElementById("typebot-bubble") as HTMLDivElement;
if (openProactiveMessage) openProactiveMessage();
expect(bubble.classList.contains("message-opened")).toBe(true);
open();
expect(bubble.classList.contains("message-opened")).toBe(false);
});
});
describe("closeBubble", () => {
it("should remove the corresponding class", () => {
expect.assertions(2);
const { close, open } = Typebot.initBubble({
publishId: "typebot-id",
});
open();
const bubble = document.getElementById("typebot-bubble") as HTMLDivElement;
expect(bubble.classList.contains("iframe-opened")).toBe(true);
close();
expect(bubble.classList.contains("iframe-opened")).toBe(false);
});
});
describe("openProactiveMessage", () => {
it("should add the opened className", () => {
expect.assertions(1);
const { openProactiveMessage } = Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
},
publishId: "typebot-id",
});
const bubble = document.getElementById("typebot-bubble") as HTMLDivElement;
if (openProactiveMessage) openProactiveMessage();
expect(bubble.classList.contains("message-opened")).toBe(true);
});
it("shouldn't be returned if no message", () => {
expect.assertions(1);
const { openProactiveMessage } = Typebot.initBubble({
publishId: "typebot-id",
});
expect(openProactiveMessage).toBeUndefined();
});
});
describe("Request commands afterwards", () => {
it("should return defined commands", () => {
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
},
publishId: "typebot-id",
});
const { close, open, openProactiveMessage } = Typebot.getBubbleActions();
expect(close).toBeDefined();
expect(open).toBeDefined();
expect(openProactiveMessage).toBeDefined();
open();
const bubble = document.getElementById("typebot-bubble") as HTMLDivElement;
expect(bubble.classList.contains("iframe-opened")).toBe(true);
});
});

View File

@ -0,0 +1,30 @@
import * as Typebot from "../../src";
describe("initBubble", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("should initialize a bubble embed", () => {
expect.assertions(2);
Typebot.initBubble({ publishId: "typebot-id" });
const bubbleElement = document.getElementById("typebot-bubble");
const frame = document.getElementsByTagName("iframe")[0];
expect(frame).toBeDefined();
expect(bubbleElement).toBeDefined();
});
it("should overwrite bubble if exists", () => {
expect.assertions(2);
Typebot.initBubble({
publishId: "typebot-id",
hiddenVariables: { var1: "test" },
});
Typebot.initBubble({ publishId: "typebot-id2" });
const frames = document.getElementsByTagName("iframe");
expect(frames).toHaveLength(1);
expect(frames[0].dataset.src).toBe(
"https://typebot-viewer.vercel.app/typebot-id2?hn=localhost"
);
});
});

View File

@ -0,0 +1,104 @@
import * as Typebot from "../../src";
beforeEach(() => {
document.body.innerHTML = "";
});
it("should create the message", () => {
expect.assertions(2);
Typebot.initBubble({
proactiveMessage: { textContent: "Hi click here!" },
publishId: "typebot-id",
});
const paragraphElement = document.querySelector(
"#typebot-bubble > .proactive-message > p"
);
const closeButton = document.querySelector(
"#typebot-bubble > .proactive-message > .close-button"
);
expect(paragraphElement?.textContent).toBe("Hi click here!");
expect(closeButton).toBeTruthy();
});
it("should have the corresponding avatar", () => {
expect.assertions(1);
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
avatarUrl: "https://website.com/my-avatar.png",
},
publishId: "typebot-id",
});
const avatarElement = document.querySelector(
"#typebot-bubble > .proactive-message > img"
) as HTMLImageElement;
expect(avatarElement.src).toBe("https://website.com/my-avatar.png");
});
it("shouldn't have opened class if delay not defined", () => {
expect.assertions(1);
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
},
publishId: "typebot-id",
});
const bubble = document.querySelector("#typebot-bubble") as HTMLDivElement;
expect(bubble.classList.contains("message-opened")).toBe(false);
});
it("should show almost immediately if delay is 0", async () => {
expect.assertions(1);
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
delay: 0,
},
publishId: "typebot-id",
});
const bubble = document.querySelector("#typebot-bubble") as HTMLDivElement;
await new Promise((r) => setTimeout(r, 1));
expect(bubble.classList.contains("message-opened")).toBe(true);
});
it("show after the corresponding delay", async () => {
expect.assertions(2);
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
delay: 1000,
},
publishId: "typebot-id",
});
const bubble = document.querySelector("#typebot-bubble") as HTMLDivElement;
expect(bubble.classList.contains("message-opened")).toBe(false);
await new Promise((r) => setTimeout(r, 1000));
expect(bubble.classList.contains("message-opened")).toBe(true);
});
it("should remember close decision if set to true", () => {
expect.assertions(2);
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
delay: 1000,
},
publishId: "typebot-id",
});
const rememberCloseDecisionFromStorage = localStorage.getItem(
Typebot.localStorageKeys.rememberClose
);
expect(rememberCloseDecisionFromStorage).toBeNull();
Typebot.initBubble({
proactiveMessage: {
textContent: "Hi click here!",
delay: 1000,
rememberClose: true,
},
publishId: "typebot-id",
});
const refreshedRememberCloseDecisionFromStorage = localStorage.getItem(
Typebot.localStorageKeys.rememberClose
);
expect(refreshedRememberCloseDecisionFromStorage).toBe("true");
});

View File

@ -0,0 +1,86 @@
import { initContainer } from "../src/embedTypes/container";
const observe = jest.fn();
const unobserve = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
window.IntersectionObserver = jest.fn(() => ({
observe,
unobserve,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
})) as any;
describe("initContainer", () => {
beforeEach(() => (document.body.innerHTML = ``));
it("should initialize a valid typebot container", () => {
expect.assertions(3);
const containerId = "container-id";
document.body.innerHTML = `<div id="${containerId}"></div>`;
const iframe = initContainer(containerId, { publishId: "typebot-id" });
const container = document.getElementById(containerId);
expect(container?.children).toHaveLength(1);
expect(container?.children[0].tagName).toBe("IFRAME");
expect(iframe).toBeDefined();
});
it("should return undefined if container doesn't exist", () => {
expect.assertions(1);
const containerId = "container-id";
const iframe = initContainer(containerId, { publishId: "typebot-id" });
expect(iframe).toBeUndefined();
});
it("should return exisiting container", () => {
expect.assertions(1);
const containerId = "container-id";
document.body.innerHTML = `<div id="${containerId}"></div>`;
const iframe1 = initContainer(containerId, { publishId: "typebot-id" });
const iframe2 = initContainer(containerId, { publishId: "typebot-id" });
expect(iframe1?.id).toBe(iframe2?.id);
});
it("should create multiple containers correctly", () => {
expect.assertions(5);
const firstId = "container-1";
const secondId = "container-2";
document.body.innerHTML = `<div id="${firstId}"></div><div id="${secondId}"></div>`;
const firstIframeElement = initContainer(firstId, {
publishId: "typebot-id",
});
const secondIframeElement = initContainer(firstId, {
publishId: "typebot-id",
});
const thirdIframeElement = initContainer(secondId, {
publishId: "typebot-id",
});
expect(firstIframeElement).toBeDefined();
expect(secondIframeElement).toBeDefined();
expect(thirdIframeElement).toBeDefined();
expect(firstIframeElement?.id).toBe(secondIframeElement?.id);
expect(firstIframeElement?.id).not.toBe(thirdIframeElement?.id);
});
it("should be lazy loading by default", () => {
expect.assertions(2);
const containerId = "container";
document.body.innerHTML = `<div id="${containerId}"></div>`;
const iframe = initContainer(containerId, {
publishId: "typebot-id",
}) as HTMLIFrameElement;
expect(iframe.dataset.src).toBeDefined();
expect(iframe.src).toBeFalsy();
});
it("shouldn't be lazy if setting param to false", () => {
expect.assertions(2);
const containerId = "container";
document.body.innerHTML = `<div id="${containerId}"></div>`;
const iframe = initContainer(containerId, {
publishId: "typebot-id",
loadWhenVisible: false,
}) as HTMLIFrameElement;
expect(iframe.dataset.src).toBeUndefined();
expect(iframe.src).toBeTruthy();
});
});

View File

@ -0,0 +1,132 @@
import { createIframe } from "../src/iframe";
describe("createIframe", () => {
it("should create a valid iframe element", () => {
expect.assertions(3);
const iframeElement = createIframe({
publishId: "typebot-id",
});
expect(iframeElement.tagName).toBe("IFRAME");
expect(iframeElement.getAttribute("data-id")).toBe("typebot-id");
expect(iframeElement.getAttribute("src")).toBe(
"https://typebot-viewer.vercel.app/typebot-id?hn=localhost"
);
});
it("should parse the right src prop if custom domain and starterVariables", () => {
expect.assertions(1);
const iframes = [
createIframe({
publishId: "typebot-id",
hiddenVariables: { var1: "value1", var2: "value2", var3: undefined },
}),
];
expect(iframes[0].getAttribute("src")).toBe(
"https://typebot-viewer.vercel.app/typebot-id?hn=localhost&var1=value1&var2=value2"
);
});
it("should have a custom background color if defined", () => {
expect.assertions(1);
const iframeElement = createIframe({
publishId: "typebot-id",
backgroundColor: "green",
});
expect(iframeElement.style.backgroundColor).toBe("green");
});
it("should have a lazy loading behavior if defined", () => {
expect.assertions(2);
const iframeElement = createIframe({
publishId: "typebot-id",
loadWhenVisible: true,
});
expect(iframeElement.getAttribute("data-src")).toBe(
"https://typebot-viewer.vercel.app/typebot-id?hn=localhost"
);
expect(iframeElement.getAttribute("src")).toBeFalsy();
});
it("should redirect on event", async () => {
expect.assertions(1);
createIframe({
publishId: "typebot-id",
});
window.open = jest.fn();
window.postMessage(
{
from: "typebot",
redirectUrl: "https://google.fr",
},
"*"
);
await new Promise((r) => setTimeout(r, 1));
expect(window.open).toHaveBeenCalledWith("https://google.fr");
});
it("should trigger var callback on var event", async () => {
expect.assertions(2);
let n, v;
createIframe({
publishId: "typebot-id",
onNewVariableValue: ({ name, value }) => {
v = value;
n = name;
},
});
window.postMessage(
{
from: "typebot",
newVariableValue: { name: "varName", value: "varValue" },
},
"*"
);
await new Promise((r) => setTimeout(r, 1));
expect(n).toBe("varName");
expect(v).toBe("varValue");
});
it("should notify when video played", async () => {
expect.assertions(1);
let hit = false;
createIframe({
publishId: "typebot-id",
onVideoPlayed: () => {
hit = true;
},
});
window.postMessage(
{
from: "typebot",
videoPlayed: true,
},
"*"
);
await new Promise((r) => setTimeout(r, 1));
expect(hit).toBe(true);
});
it("shouldn't execute callbacks if event from other than typebot", async () => {
expect.assertions(3);
let n, v;
createIframe({
publishId: "typebot-id",
onNewVariableValue: ({ name, value }) => {
v = value;
n = name;
},
});
window.open = jest.fn();
window.postMessage(
{
redirectUrl: "https://google.fr",
newVariableValue: { name: "varName", value: "varValue" },
},
"*"
);
await new Promise((r) => setTimeout(r, 1));
expect(window.open).not.toHaveBeenCalled();
expect(n).toBeUndefined();
expect(v).toBeUndefined();
});
});

View File

@ -0,0 +1,111 @@
import { getPopupActions, initPopup } from "../src/embedTypes/popup";
describe("initPopup", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("should return the popupElement with lazy iframe", () => {
expect.assertions(2);
initPopup({ publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
const iframeElement = popupElement?.children[0] as HTMLIFrameElement;
expect(popupElement).toBeTruthy();
expect(iframeElement.dataset.src).toBeDefined();
});
it("should overwrite if exists", () => {
expect.assertions(2);
initPopup({ publishId: "typebot-id", hiddenVariables: { test1: "yo" } });
initPopup({ publishId: "typebot-id2" });
const elements = document.getElementsByTagName("iframe");
expect(elements).toHaveLength(1);
expect(elements[0].dataset.src).toBe(
"https://typebot-viewer.vercel.app/typebot-id2?hn=localhost"
);
});
it("shouldn't have opened classname if no delay", () => {
expect.assertions(1);
initPopup({ publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
expect(popupElement?.classList.contains("opened")).toBe(false);
});
it("should have the opened classname after the delay", async () => {
expect.assertions(2);
initPopup({ delay: 500, publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
expect(popupElement?.classList.contains("opened")).toBe(false);
await new Promise((r) => setTimeout(r, 1000));
expect(popupElement?.classList.contains("opened")).toBe(true);
});
});
describe("openPopup", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("should add opened className and lazy load", () => {
expect.assertions(5);
const { open } = initPopup({ publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
expect(popupElement?.children[0].getAttribute("data-src")).toBeTruthy();
open();
expect(popupElement?.classList.contains("opened")).toBe(true);
expect(document.body.style.overflowY).toBe("hidden");
expect(popupElement?.children[0].getAttribute("data-src")).toBeFalsy();
expect(open).not.toThrow();
});
it("should still work if initializing a second time", () => {
expect.assertions(2);
initPopup({ publishId: "typebot-id" });
const { open } = initPopup({ publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
open();
expect(popupElement?.classList.contains("opened")).toBe(true);
expect(document.body.style.overflowY).toBe("hidden");
});
});
describe("closePopup", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("shouldn remove opened className", () => {
expect.assertions(2);
const { close } = initPopup({ publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
close();
expect(popupElement?.classList.contains("opened")).toBe(false);
expect(document.body.style.overflowY).toBe("auto");
});
it("should still work if initializing a second time", () => {
expect.assertions(2);
initPopup({ publishId: "typebot-id" });
const { close } = initPopup({ publishId: "typebot-id" });
const popupElement = document.getElementById("typebot-popup");
close();
expect(popupElement?.classList.contains("opened")).toBe(false);
expect(document.body.style.overflowY).toBe("auto");
});
});
describe("Request commands afterwards", () => {
it("should return defined commands", () => {
initPopup({
publishId: "typebot-id",
});
const { close, open } = getPopupActions();
expect(close).toBeDefined();
expect(open).toBeDefined();
open();
const popup = document.getElementById("typebot-popup") as HTMLDivElement;
expect(popup.classList.contains("opened")).toBe(true);
});
});

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"declaration": true,
"moduleResolution": "Node",
"declarationDir": ".",
"strict": true
},
"include": ["./src/**/*.ts"],
"exclude": ["./tests/**/*"]
}