Compare commits

..

1 Commits

Author SHA1 Message Date
ShubhamPalriwala
b8a8897ca7 fix: duplicate team dropdown in navbar 2024-04-19 12:00:23 +05:30
169 changed files with 5333 additions and 7311 deletions

View File

@@ -1,12 +1,11 @@
name: Cron - Report usage to Stripe
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
# schedule:
# This will run the job at 20:00 UTC every day of every month.
# - cron: "0 20 * * *"
schedule:
# This will run the job at 20:00 UTC every day of every month.
- cron: "0 20 * * *"
jobs:
cron-reportUsageToStripe:
env:

View File

@@ -1,12 +1,11 @@
name: Cron - Survey status update
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
# schedule:
# Runs “At 00:00.” (see https://crontab.guru)
# - cron: "0 0 * * *"
schedule:
# Runs “At 00:00.” (see https://crontab.guru)
- cron: "0 0 * * *"
jobs:
cron-weeklySummary:
env:

View File

@@ -1,12 +1,11 @@
name: Cron - Weekly summary
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
# schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
# - cron: "0 8 * * 1"
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
jobs:
cron-weeklySummary:
env:

View File

@@ -81,7 +81,6 @@ jobs:
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- name: Checkout code

View File

@@ -78,7 +78,6 @@ jobs:
DB_USER: ${{ secrets.DB_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_NAME: ${{ secrets.DB_NAME }}
REDIS_URL: ${{ secrets.REDIS_URL }}
steps:
- name: Checkout code

View File

@@ -1,3 +1,4 @@
import { classNames } from "@/lib/utils";
import {
ClockIcon,
CogIcon,
@@ -10,8 +11,6 @@ import {
UsersIcon,
} from "lucide-react";
import { classNames } from "../lib/utils";
const navigation = [
{ name: "Home", href: "#", icon: HomeIcon, current: true },
{ name: "History", href: "#", icon: ClockIcon, current: false },

View File

@@ -18,7 +18,6 @@
"react-dom": "18.2.0"
},
"devDependencies": {
"eslint-config-formbricks": "workspace:*",
"@formbricks/tsconfig": "workspace:*"
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -1,9 +1,8 @@
import { EarthIcon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import formbricksApp from "@formbricks/js/app";
import formbricks from "@formbricks/js";
import fbsetup from "../../public/fb-setup.png";
@@ -31,24 +30,28 @@ export default function AppPage({}) {
window.history.replaceState({}, "", newUrl);
}
};
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
const userInitAttributes = { language: "de", "Init Attribute 1": "eight", "Init Attribute 2": "two" };
const isUserId = window.location.href.includes("userId=true");
const defaultAttributes = {
language: "gu",
};
const userInitAttributes = { "Init Attribute 1": "eight", "Init Attribute 2": "two" };
formbricksApp.init({
const attributes = isUserId ? { ...defaultAttributes, ...userInitAttributes } : defaultAttributes;
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
userId,
attributes: userInitAttributes,
attributes,
});
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricksApp?.registerRouteChange;
const handleRouteChange = formbricks?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
@@ -57,38 +60,18 @@ export default function AppPage({}) {
}
});
const removeFormbricksContainer = () => {
document.getElementById("formbricks-modal-container")?.remove();
document.getElementById("formbricks-app-container")?.remove();
localStorage.removeItem("formbricks-js-app");
};
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col justify-between md:flex-row">
<div className="flex items-center gap-2">
<button
className="rounded-lg bg-[#038178] p-2 text-white focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-1"
onClick={() => {
removeFormbricksContainer();
window.location.href = "/website";
}}>
<div className="flex items-center gap-2">
<EarthIcon className="h-10 w-10" />
<span>Website Demo</span>
</div>
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your in-app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
<button
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => setDarkMode(!darkMode)}>
@@ -142,7 +125,7 @@ export default function AppPage({}) {
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksApp.reset();
formbricks.reset();
}}>
Reset
</button>
@@ -157,7 +140,7 @@ export default function AppPage({}) {
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksApp.track("Code Action");
formbricks.track("Code Action");
}}>
Code Action
</button>
@@ -201,7 +184,7 @@ export default function AppPage({}) {
<div>
<button
onClick={() => {
formbricksApp.setAttribute("Plan", "Free");
formbricks.setAttribute("Plan", "Free");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Free&apos;
@@ -224,7 +207,7 @@ export default function AppPage({}) {
<div>
<button
onClick={() => {
formbricksApp.setAttribute("Plan", "Paid");
formbricks.setAttribute("Plan", "Paid");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Plan to &apos;Paid&apos;
@@ -247,7 +230,7 @@ export default function AppPage({}) {
<div>
<button
onClick={() => {
formbricksApp.setEmail("test@web.com");
formbricks.setEmail("test@web.com");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Set Email
@@ -266,6 +249,41 @@ export default function AppPage({}) {
</p>
</div>
</div>
<div className="p-6">
{router.query.userId === "true" ? (
<div>
<button
onClick={() => {
window.location.href = "/app";
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Deactivate User Identification
</button>
</div>
) : (
<div>
<button
onClick={() => {
window.location.href = "/app?userId=true";
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
Activate User Identification
</button>
</div>
)}
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button activates/deactivates{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
target="_blank"
className="underline dark:text-blue-500">
user identification
</a>{" "}
with the userId &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,212 +0,0 @@
import { MonitorIcon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import formbricksWebsite from "@formbricks/js/website";
import fbsetup from "../../public/fb-setup.png";
declare const window: any;
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
const router = useRouter();
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
useEffect(() => {
// enable Formbricks debug mode by adding formbricksDebug=true GET parameter
const addFormbricksDebugParam = () => {
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has("formbricksDebug")) {
urlParams.set("formbricksDebug", "true");
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
};
addFormbricksDebugParam();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const defaultAttributes = {
language: "de",
};
formbricksWebsite.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
attributes: defaultAttributes,
});
}
// Connect next.js router to Formbricks
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
const handleRouteChange = formbricksWebsite?.registerRouteChange;
router.events.on("routeChangeComplete", handleRouteChange);
return () => {
router.events.off("routeChangeComplete", handleRouteChange);
};
}
});
const removeFormbricksContainer = () => {
document.getElementById("formbricks-modal-container")?.remove();
document.getElementById("formbricks-website-container")?.remove();
localStorage.removeItem("formbricks-js-website");
};
return (
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex items-center gap-2">
<button
className="rounded-lg bg-[#038178] p-2 text-white focus:outline-none focus:ring-2 focus:ring-slate-900 focus:ring-offset-1"
onClick={() => {
removeFormbricksContainer();
window.location.href = "/app";
}}>
<div className="flex items-center gap-2">
<MonitorIcon className="h-10 w-10" />
<span>App Demo</span>
</div>
</button>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks Website Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
</div>
<button
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => setDarkMode(!darkMode)}>
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "}
<strong>reinitialized</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.reset();
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.track("New Session");
}}>
Track New Session
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;New Session&apos;. You will
find it in the Actions Tab.
</p>
</div>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.track("Exit Intent");
}}>
Track Exit Intent
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;Exit Intent&apos;. You can also
move your mouse to the top of the browser to trigger the exit intent.
</p>
</div>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.track("50% Scroll");
}}>
Track 50% Scroll
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;50% Scroll&apos;. You can also
scroll down to trigger the 50% scroll.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,23 @@
{
"extends": "@formbricks/tsconfig/nextjs.json",
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -1,5 +0,0 @@
export async function GET(_: Request, { params }: { params: { surveyId: string } }) {
const surveyId = params.surveyId;
// redirect to Formbricks Cloud
return Response.redirect(`https://app.formbricks.com/s/${surveyId}`, 301);
}

View File

@@ -30,8 +30,6 @@ Adds an Actions for a given User by their User ID
<Properties>
<Property name="userId" type="string">
The id of the user for whom the action is being created.
Note: A user with this ID must exist in your environment in Formbricks.
</Property>
<Property name="name" type="string">
The name of the Action being created.
@@ -65,7 +63,7 @@ Adds an Actions for a given User by their User ID
```json {{ title: '200 Success' }}
{
"data": {}
"data": {}
}
```
@@ -79,13 +77,6 @@ Adds an Actions for a given User by their User ID
}
```
```json {{ title: '500 Internal Server Error' }}
{
"code": "internal_server_error",
"message": "Unable to handle the request: Database operation failed",
"details": {}
}
```
</CodeGroup>
</Col>

View File

@@ -32,8 +32,7 @@ The API requests are authorized with a personal API key. This API key gives you
/>
<Note>
### Store API key safely!
Anyone who has your API key has full control over your account. For security
### Store API key safely Anyone who has your API key has full control over your account. For security
reasons, you cannot view the API key again.
</Note>

View File

@@ -1,81 +0,0 @@
import { TellaVideo } from "@/components/docs/TellaVideo";
export const metadata = {
title: "Embed Surveys in Your Web Page",
description: "Embed Formbricks surveys seamlessly into your website or web application using an iframe.",
};
#### Embed Surveys
# Embed Surveys in Your Web Page
Embedding Formbricks surveys directly into your web pages allows you to integrate interactive surveys without redirecting users to a separate survey site. This method ensures a seamless integration and maintains the aesthetic continuity of your website or application.
## How to Use it?
<TellaVideo tellaVideoIdentifier="clvavyy2f00000fjr0mple922"/>
1. Create and publish a link survey.
2. Open survey summary page and click on **share** button on the top right.
3. In the survey share modal, click on **Embed survey** button.
4. Navigate to **Embed in a Web Page** tab and click on Copy code
5. Paste the copied iframe code into the HTML of your web page where you want the survey to appear.
### Example of Embedding a Survey
<Col>
<CodeGroup title="Example Embedding Code">
```html
<div style="position: relative; height:100vh; max-height:100vh; overflow:auto;">
<iframe
src="https://app.formbricks.com/s/<your-surveyId>"
frameborder="0"
style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>
```
</CodeGroup>
</Col>
## Iframe Events
The iframe fires a **formbricksSurveyCompleted** event when a user finishes a survey within the embedded iframe. This event can be captured through a message listener in your webpage's JavaScript
### How to Use it?
1. Embed the Formbricks survey on your webpage using the iframe method as described above.
2. Add an event listener to your webpages JavaScript that listens for `message` events from the iframe.
3. Check if the received message indicates that the survey is completed by comparing the `event.data` with the value `formbricksSurveyCompleted`.
<Note>
It is important to verify the origin of the message to ensure it comes from the iframe containing your
survey, enhancing the security of your event handling.
</Note>
4. Implement your custom actions within the callback function based on the survey completion.
### Example of Handling Survey Completion Events
<Col>
<CodeGroup title="Example Code for Event Listener">
```javascript
window.addEventListener("message", (event) => {
// Replace 'https://app.formbricks.com' with the actual web app url
if (event.origin === "https://app.formbricks.com" && event.data === "formbricksSurveyCompleted") {
console.log("Survey completed!");
// Implement your custom actions here
}
});
```
</CodeGroup>
</Col>

View File

@@ -210,7 +210,6 @@ export const navigation: Array<NavGroup> = [
{ title: "Source Tracking", href: "/docs/link-surveys/source-tracking" },
{ title: "Hidden Fields", href: "/docs/link-surveys/hidden-fields" },
{ title: "Start At Question", href: "/docs/link-surveys/start-at-question" },
{ title: "Embed Surveys", href: "/docs/link-surveys/embed-surveys" },
],
},
{

View File

@@ -1,18 +0,0 @@
import React from "react";
export function TellaVideo({ tellaVideoIdentifier }: { tellaVideoIdentifier: string }) {
return (
<div>
<iframe
className="aspect-video"
style={{
width: "100%",
height: "100%",
border: 0,
}}
src={`https://www.tella.tv/video/${tellaVideoIdentifier}/embed?b=0&title=0&a=1&loop=0&autoPlay=true&t=0&muted=1&wt=0`}
allowFullScreen={true}
title="Tella Video Help"></iframe>
</div>
);
}

View File

@@ -212,6 +212,18 @@ const nextConfig = {
},
];
},
async rewrites() {
return {
fallback: [
// These rewrites are checked after both pages/public files
// and dynamic routes are checked
{
source: "/:path*",
destination: `https://app.formbricks.com/s/:path*`,
},
],
};
},
};
export default withPlausibleProxy({ customDomain: "https://plausible.formbricks.com" })(

View File

@@ -0,0 +1,252 @@
import AuthorBox from "@/components/shared/AuthorBox";
import LayoutMdx from "@/components/shared/LayoutMdx";
import Image from "next/image";
import Formbricks from "./formbricks-best-open-source-hotjar-alternative.webp";
import FullStory from "./fullstory-comprehensive-analytics-tool.webp";
import Smartlook from "./g2-crowd-award-winner-Smartlook.webp";
import Header from "./header-best-hotjar-alternatives-2024-incl-open-source-solutions.webp";
import LuckyOrange from "./lucky-orange-best-analytics-tool-2024.webp";
import MouseFlow from "./mouseflow-best-hotjar-alternatives-2024.webp";
export const meta = {
title: "Best HotJar Alternatives 2024 incl. Open Source",
description:
"Looking for HotJar alternatives? We curated a list of the best HotJar alternatives going into 2024 for you.",
date: "2023-12-29",
publishedTime: "2023-12-29T12:00:00",
authors: ["Johannes Dancker"],
section: "Feedback Apps",
tags: ["Feedback Apps", "Formbricks", "Smartlook", "Lucky Orange", "Fullstory", "Mouseflow"],
ogImage: "/blog/hotjar.jpg"
};
<Image src={Header} alt="Get the best HotJar features with these 5 tools." className="w-full rounded-lg" />
<AuthorBox
name="Johannes"
title="Co-Founder and CEO"
date="December 29th, 2023"
duration="10"
author={"Johannes"}
/>
HotJar is a popular product experience insights platform that provides you with valuable data and insights into how users interact with your websites. It offers features such as heatmaps and recordings, surveys, and funnels to help you get these insights.
But while its a staple in user behavior analytics, this article introduces a wide range of alternatives just waiting to be discovered. These options are just for you, whether you're a budget-conscious blogger or an enterprise giant.
As we discuss these options, the next section will guide you through the essential criteria to consider when comparing them to HotJar. These criteria will empower you to pinpoint the perfect fit for your specific requirements.
## How we compare HotJar alternatives 👇
We'll categorize the criteria into three main factors to improve your choice.
1. **Feature depth**
- **Surveys & Forms**: Can you gather users voices through polls and surveys to understand your audience better?
- **Heatmaps & Recordings**: Do you want basic click maps or detailed session replays with visitor insights?
- **Integrations**: Does it work well with your existing third-party analytics tools?
2. **Pricing**:
- **Freemium Plans**
- **Premium Plans**
3. **Privacy**: Is it fully GDPR, CCPA, or HIPAA-compliant?
4. **Extensibility**: How extensible and customizable are each of the tools?
Now, we will explore these options based on the factors mentioned above.
## 5 Free HotJar Alternatives in 2024
Let's have a look at the best HotJar alternatives in 2024, including open source options - all of which start free!
### Formbricks - The Open Source HotJar Ask Alternative
<Image
src={Formbricks}
alt="Formbricks is a free and open source survey software for in-app micro surveys. Ask any user segment at any point in the user journey."
className="w-full rounded-lg"
/>
[Formbricks](https://formbricks.com/) is an open source micro-survey solution designed to gather specific user feedback at the perfect moment in the journey. It allows you to create and deploy **targeted surveys within your app or on public websites** without disrupting the user experience.
It's super good at one thing: making your forms and survey experiences awesome. It shows you why people abandon your forms, which questions cause churn, and how to fix them to get more people to finish. No heatmaps or fancy recordings, just laser focus on surveys.
Formbricks compares to HotJar based on the aforementioned factors:
**Feature depth**:
- **Surveys & Forms**: Formbricks specializes in in-product micro-surveys for SaaS and digital products. With Formbricks, you're better equipped to understand user behavior, improve your product, and make data-driven decisions. You can seamlessly integrate surveys into web, mobile, and desktop applications. If forms and surveys are your primary concern, this is the best tool for you.
- **Heatmaps & Recordings**: Formbricks focuses on forms and surveys. No fancy website heatmaps here for now, but you get detailed insights into individual form fields and how users interact with them. If youre looking for open source heatmaps, [OpenReplay](https://openreplay.com/) might be worth checking out.
- **Integrations:** HotJar plays well with lots of other tools, while Formbricks is still young in this aspect. But it works with the most popular ones, like Zapier, Make.com, Airtable, Notion, Slack, etc. The Formbricks team and [open source community](/community) are working on adding more all the time.
**Pricing**: Both tools have free plans, but HotJar's paid plans can get expensive. Formbricks is generally cheaper, especially if you only care about targeted surveys. Formbricks has a very generous free plan to get started easily. Paid plans begin at $30 per month for link surveys and $0.15 per submission for web and in-app surveys, **after your survey submission exceeds 250 submissions.** If you self-host Formbricks, [its completely free.](/pricing)
**Privacy:** Formbricks Cloud is hosted in Germany and has full GDPR as well as CCPA compliance. Since Formbricks is easily self-hostable, keeping full control over your data is smooth.
**Extensibility:** Unlike HotJar, Formbricks is an open source solution. It provides APIs that allow you to build anything on top, below, and around it as per your customization needs - your imagination is the limit.
Lets see how Formbricks compares side-by-side with HotJar.
| Factors | Formbricks | HotJar |
| ---------------------- | ---------- | ------ |
| Surveys & Forms | 🟢 | 🟢 |
| Heatmaps & Recordings | 🔴 | 🟢 |
| Integrations | 🟡🟢 | 🟢 |
| Pricing | 🟢 | 🟡🟢 |
| Privacy and compliance | 🟢 | 🟡 |
| Extensibility | 🟢 | 🟡 |
### Smartlook - G2 Crowd Award Winner
<Image
src={Smartlook}
alt="Smartlook is a comprehensive product analytics and visual user insights tool designed to help businesses gain deep insights into user behavior on their websites or mobile applications."
className="w-full rounded-lg"
/>
Smartlook, which was recently acquired by Cisco, is a comprehensive product analytics and visual user insights tool designed to help businesses gain deep insights into user behavior on their websites or mobile applications. It offers a range of features that enable organizations to understand, analyze, and optimize the user experience.
Smartlook has also received numerous awards and recognition, including the G2 Crowd Awards for **Top 100 Software Products** and **Best Products for Marketers**, as well as being named on Deloittes Technology Fast 50 in Central Europe.
Below is an overview of how Smartlook compares with HotJar.
**Features**:
- **Surveys**: Both platforms include survey features, but Smartlook does not offer standalone surveys, unlike HotJar. Instead, you can create surveys through its integration with Survicate (for a deeper look into Survicate, go [here](https://formbricks.com/blog/best-feedback-app-and-how-to-use-them)).
- **Heatmaps & Recordings**: They both offer heatmap and recording features. Although Smartlook provides a more comprehensive insight into recordings by combining them with funnel analysis, this will help you pinpoint the exact recordings you need.
- **Integrations:** Both platforms work well with many external tools. However, if you're a big enterprise seeking a broader selection of tools, HotJar is the better choice.
**Pricing**: Compared to HotJar, Smartlook is relatively more expensive. Its pro plan provides only 30 heatmaps a month and three months of storage. HotJars equivalent business plan offers unlimited heatmaps with 12 months of data storage.
**Privacy and Compliance:** Smartlook stores all data on EU servers. If you collect personal data with Smartlook, GDPR applies. Smartlook is also fully CCPA-compliant.
**Extensibility:** Smartlook, like HotJar, offers practical methods for programmatically accessing information on different resources. The API empowers you to analyze visitor data more comprehensively and delve deeper into the values captured by Smartlook. However, it may not offer the precise level of control and flexibility needed for intricate integrations or custom workflows.
| Factors | Smartlook | HotJar |
| ---------------------- | --------- | ------ |
| Surveys & Forms | 🟡🟢 | 🟢 |
| Heatmaps & Recordings | 🟢 | 🟢 |
| Integrations | 🟢 | 🟢 |
| Pricing | 🔴 | 🟢 |
| Privacy and compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟡 |
### Lucky Orange
<Image
src={LuckyOrange}
alt="Lucky Orange is a tool for web analytics and conversion optimization. It helps businesses understand how users behave on their websites."
className="w-full rounded-lg"
/>
Lucky Orange is a tool for web analytics and conversion optimization. It helps businesses understand how users behave on their websites. Its features include **surveys**, **session recordings**, **live view,** and **conversion funnels**. These features move beyond vanity metrics, aiming to uncover the reasons behind visitors' actions on your website.
**Feature depth**:
- **Surveys:** Like HotJar, Lucky Orange offers survey features, but in a more limited fashion. You can choose from four survey types that suit your needs. They include **multiple-choice**, **like-or-dislike**, **rating**, and **open-ended** surveys. You can also customize how your survey is triggered based on your users location on your website and their devices, or if you want a delay before your survey is triggered.
- **Heatmaps & Recordings:** Both Lucky Orange and HotJar offer session recordings; however, while this feature is on par with HotJars features like filtering, some users still report that the [session viewer crashes when watching a desktop session on the mobile screen](https://www.g2.com/products/lucky-orange/reviews/lucky-orange-review-7862805).
- **Integrations**: Lucky Orange offers a smaller but growing selection of integrations compared to HotJar, focusing on essentials like Google Analytics, CMS platforms, and marketing automation tools.
**Pricing**: Lucky Orange offers pricing plans suitable for businesses of different sizes, including a 7-day free trial; as of the time of writing this article, they begin at $32 per month. Each of these plans is based on sessions but only has 60-day data storage, unlike Hojar, which provides data storage for 365 days.
**Privacy & Compliance:** Lucky Orange tools, like HotJar, are fully CCPA and GDPR-compliant. This means that it does not store sensitive information, ensuring a secure and trustworthy environment for user data.
**Extensibility:** Lucky Orange currently works with fewer outside tools than HotJar. This might be a drawback for large companies. However, they're planning to add support for a public API in the future. This means you'll be able to build on top of it.
| Factors | Lucky Orange | HotJar |
| ---------------------- | ------------ | ------ |
| Surveys & Forms | 🟢 | 🟢 |
| Heatmaps & Recordings | 🟡🟢 | 🟢 |
| Integrations | 🟡 | 🟢 |
| Pricing | 🟡 | 🟢 |
| Privacy and compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟢 |
### FullStory
<Image
src={FullStory}
alt="FullStory is a comprehensive user experience analytics platform that enables businesses to gain detailed insights into user interactions with their websites and applications."
className="w-full rounded-lg"
/>
FullStory is a comprehensive user experience analytics platform that enables businesses to gain detailed insights into user interactions with their websites and applications. Through features such as session recordings, dynamic heatmaps, and advanced analytics, FullStory provides a nuanced understanding of user behavior.
Lets see how FullStory compares to HotJar in terms of the factors we mentioned earlier:
**Features:**
- **Surveys:** Both FullStory and HotJar offer survey features, with FullStory utilizing integration with third-party tools like Survicate and SurveyMonkey for versatility and in-depth feedback.
FullStory wins here if you are looking for a versatile tool to integrate with other existing third-party survey tools. However, if you want an all-in-one solution, HotJar is your go-to solution.
- **Heatmaps & Recordings:** FullStory's interactive heatmaps and detailed visuals help you better understand how users interact with your website, improving the analysis of page activities. This feature is similar to what HotJar offers.
However, a notable difference is that in FullStory, you can't save a session to watch later. So, if you find a recording interesting and want to see it again, you'll need to search for it manually when you want to revisit it.
- **Integrations:** Like HotJar, FullStory integrates seamlessly with various third-party tools, enhancing its versatility and allowing users to integrate it into their existing tech stack.
**Pricing:** FullStory does not have a free plan, and the price for paid plans is available upon request from the sales team. Although their pricing page states that you get a 14-day free trial for their business plan.
**Privacy & Compliance:** You are in full control of what data FullStory captures and saves. FullStory is not just GDPR and CCPA-compliant but also holds a SOC 2 Type II attestation and a SOC 3 report.
**Extensibility:** FullStory also provides several APIs, like HotJar, including a Webhooks API that enables developers to build on top of its functionality and integrate it into their workflows. However, they might not provide the level of control and flexibility required for more complex integrations or custom workflows.
Heres how FullStory and HotJar compare side by side:
| Factors | FullStory | HotJar |
| ---------------------- | --------- | ------ |
| Surveys & Forms | 🟢 | 🟢 |
| Heatmaps & Recordings | 🟢 | 🟢 |
| Integrations | 🟢 | 🟢 |
| Pricing | 🟡 | 🟢 |
| Privacy and compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟡 |
### Mouseflow
<Image
src={MouseFlow}
alt="Mouseflow is a web analytics tool designed to provide insights into user behavior on websites."
className="w-full rounded-lg"
/>
Mouseflow is a web analytics tool designed to provide insights into user behavior on websites. It offers features such as session recordings, heatmaps, surveys, and funnel analysis to help businesses optimize user experiences and conversions.
**Feature depth**
- **Surveys**: Mouseflow provides you with a funnel-like analysis for in-depth form analytics, which is not available on HotJar. With Mouseflow, you can replay sessions from visitors who dropped out or succeeded in completing the form. It also helps you analyze how users interact with each of your form fields.
- **Heatmaps & Recordings:** Mouseflow, like Hojar, provides heatmaps and session recordings to visualize user interactions and behaviors, aiding in the analysis of website engagement.
However, HotJar samples the data you collect daily. That is, you are allowed to review just a small fraction of the whole set of data you receive daily. For example, if you have 3000 recordings per month, you are allowed to record just 100 daily sessions on standard plans.
- **Integrations:** Mouseflow, like HotJar, integrates with about 58 third-party tools, including other analytics, eCommerce, CMS, and marketing platforms in your stack.
**Pricing**: Mouseflow's pricing is tiered based on usage and additional features. It offers a range of plans to accommodate businesses of different sizes.
Its free plan comes with 500 recordings per month, unlimited page views, and a month of storage, all for one website. Paid plans begin at $31/month, however, if you are an enterprise, you can contact their sales team to create a customized plan.
**Privacy & Compliance:** Mouseflow is committed to data protection. Its compliant with GDPR, CCPA, CPRA, and VCDPA.
**Extensibility:** Like HotJar, Mouseflow's API and Webhooks enable developers to build custom integrations, connecting them to virtually any platform or tool imaginable. However, they might not provide the level of control and flexibility required for more complex integrations or custom workflows.
| Factors | Mouseflow | HotJar |
| ---------------------- | --------- | ------ |
| Surveys & Forms | 🟢 | 🟡🟢 |
| Heatmaps & Recordings | 🟢 | 🟡🟢 |
| Integrations | 🟢 | 🟢 |
| Pricing | 🟢 | 🟢 |
| Privacy and compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟡 |
## So, which option is the better fit for you?
If you're seeking a comprehensive solution encompassing heatmaps, recordings, surveys, and a strong focus on privacy, MouseFlow emerges as a prime choice.
On the other hand, if your primary emphasis is on highly targeted surveys, [Formbricks](http://www.formbricks.com/) stands out as the optimal solution.
What's even more noteworthy is that it is the sole open-source solution for website surveys available. This translates to not just being completely free to use if you self-host, but also offering the freedom for modification due to its extensibility and the liberty to seamlessly integrate with your preferred tools.
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;

View File

@@ -1,345 +0,0 @@
import AuthorBox from "@/components/shared/AuthorBox";
import LayoutMdx from "@/components/shared/LayoutMdx";
import Image from "next/image";
import Formbricks from "./formbricks-best-open-source-hotjar-alternative.webp";
import FullStory from "./fullstory-comprehensive-analytics-tool.webp";
import Smartlook from "./g2-crowd-award-winner-Smartlook.webp";
import Header from "./header-best-hotjar-alternatives-2024-incl-open-source-solutions.webp";
import LuckyOrange from "./lucky-orange-best-analytics-tool-2024.webp";
import MouseFlow from "./mouseflow-best-hotjar-alternatives-2024.webp";
import SurveyMonkey from "./surveyMonkey-the-worlds-most-popular-free-online-survey-tool.webp";
export const meta = {
title: "Best Hotjar Alternatives 2024",
description:
"Discover 2024's best Hotjar alternatives with advanced website surveys and user behavior tools. Elevate your website insights and user experience today!",
date: "2023-04-22",
publishedTime: "2024-04-22T12:00:00",
authors: ["Johannes"],
section: "Feedback Apps",
tags: ["Feedback Apps", "Formbricks", "SurveyMonkey", "Smartlook", "Lucky Orange", "Fullstory", "Mouseflow"],
ogImage: "/blog/hotjar.jpg"
};
<Image src={Header} alt="Get the best HotJar features with these 5 tools." className="w-full rounded-lg" />
<AuthorBox name="Johannes" title="Co-Founder" date="December 29th, 2023" duration="4" author={"Johannes"} />
Hotjar is a popular product experience insights platform that provides valuable data and insights into how users interact with your websites. It offers features such as heatmaps and recordings, surveys, and funnels to help you get these insights.
But while its a staple in user behavior analytics, this article introduces 6 of the best Hotjar alternatives just waiting to be discovered. Whether you're a budget-conscious blogger or an enterprise giant, there is an option in this list perfect for your needs 🤓
To have a framework to compare the different options, the following section will guide you through the essential criteria to consider when comparing them to Hotjar. These criteria will empower you to pinpoint the perfect fit for your requirements.
## How We Compare & Group Hotjar Alternatives
There are many reasons why users seek alternatives to Hotjar. To make choosingt the best one for you easy, we'll categorize the criteria into three main factors:
1. **Feature depth**
- **Website Surveys & Forms**: Does the tool help you gather users voices through polls and surveys to understand your audience better?
- **Heatmaps & Recordings**: Do you want a tool for click maps and session replays with visitor insights?
- **Integrations**: Does the tool work well with your current tech stack?
2. **Privacy**: Is the tool fully GDPR, CCPA, or HIPAA compliant? What about data ownership?
3. **Extensibility**: Do they provide API services for extensibility and customization?
4. **Pricing: Freemium and Premium Plans**
Moving forward, well group these Hotjar alternatives into two different categories: first, for **website** **survey tools**, and second, for **behavioral analysis tools**.
### Two Hotjar Ask Alternatives For Website Surveys
1. Formbricks
2. SurveyMonkey
### Four Hotjar Alternatives For User Behavior Analysis
1. Smartlook
2. Lucky Orange
3. FullStory
4. MouseFlow
Enough setting up, let's dive right in! 🤿
## Hotjar Ask Alternatives For Targeted Website Surveys
<Image
src={Formbricks}
alt="Formbricks is a free and open source survey software for in-app micro surveys. Ask any user segment at any point in the user journey."
className="w-full rounded-lg"
/>
### Formbricks - The Open Source Hotjar Alternative
[Formbricks](http://formbricks.com/) is open-source survey software designed to gather specific feedback at the perfect moment in the user or customer journey. Formbricks allows you to create and deploy targeted surveys within your app, website, or links without disrupting your user experience.
Formbricks is super good at one thing: making your surveys and survey experiences awesome. We show you why people abandon your surveys, which questions cause churn, and how to fix them to get more people to finish. No heatmaps or fancy recordings, just laser focus on surveys.
### Formbricks vs Hotjar
**TL;DR**: Lets see how Formbricks compares side-by-side to Hotjar.
| Factors | Formbricks | Hotjar |
| --- | --- | --- |
| Surveys & Forms | 🟢 | 🟡🟢 |
| Heatmaps & Recordings | 🔴 | 🟢 |
| Integrations | 🟡🟢 | 🟡🟢 |
| Pricing | 🟢 | 🟡🟢 |
| Privacy and Compliance | 🟢 | 🟡 |
| Extensibility | 🟢 | 🟡 |
Now, well go into the details.
**Feature depth**
- **Surveys & Forms**: Formbricks offers a diverse range of survey templates, including options like [Product-Market fit](https://formbricks.com/docs/best-practices/pmf-survey), [Improving Newsletter Content](https://formbricks.com/docs/best-practices/improve-email-content), and NPS surveys. These surveys can be seamlessly to your website using the Formbricks SDK.
To learn more about setting up the Formbricks widget, visit our guide on [How to set up the Formbricks widget](https://formbricks.com/docs/getting-started/quickstart-in-app-survey).
Furthermore, Formbricks provides a comprehensive analytics dashboard that assists in optimizing survey conversion rates. This dashboard helps gather user data, analyze drop-offs, and track the number of users who viewed your surveys. The level of analytics provided by Formbricks is comparable to Hotjar's offerings.
- **Heatmaps & Recordings**: Formbricks focuses on surveys. No fancy website heatmaps here for now, but you get detailed insights into individual form fields and how users interact with them. If youre looking for open source heatmaps, OpenReplay might be worth checking out.
- **Integrations:** Formbricks integrates with popular tools like Zapier, Make.com, Airtable, Notion, and Slack, and these integrations are all available **on its Free Plan**. The Formbricks team and open-source community are continuously adding more integrations.
Hotjar, on the other hand, offers integrations with many tools for their surveys, but these are only available on their Business Plan. The Basic plan offers just one integration (HubSpot).
**Privacy:** Formbricks takes data privacy very seriously. Hence, we take on considerable additional effort to collect as little data as necessary and handle it safely and securely.
Formbricks is easily self-hostable and if you do so you have full control over the data you collect. This removes a huge chunk of privacy compliance and security reviews because the data never leaves your servers. Our privacy policy does not apply here, since no data is ever processed by Formbricks (the company).
We also offer Formbricks as a managed service in the Cloud. Formbricks Cloud is hosted by a German entity (GmbH) in Germany and comes with full GDPR and CCPA compliance. Learn more about how we handle private data on our Cloud in the [Privacy Policy](https://formbricks.com/privacy-policy). We also provide a [guide on how to create a GDPR compliant](https://formbricks.com/gdpr-guide) survey as well as [GDPR FAQs](https://formbricks.com/gdpr).
**Extensibility:** Formbricks stands out for its open-source nature and extensive API access, **available on all plans**. This unique feature allows users to customize and enhance their experience without limitations. In contrast, Hotjar's API access is restricted to its Scale plan, which comes at a higher price point of **$128/month** with as little as 500 sessions / day.
**Pricing**: Both tools have free plans, but Formbricks gives you more value on a free plan than Hotjar. Ultimately, Formbricks offers a lot more value for your money, especially if youre mostly interested in running surveys.
If you decide to [self-host](https://formbricks.com/docs/self-hosting/deployment) Formbricks on your servers, the community edition **is completely free including Branding Removal.** If you require the Enterprise Edition because you need Team Roles, Advanced Targeting or Multi-language Surveys, [please reach out.](mailto:hola@formbricks.com)
### SurveyMonkey
<Image
src={SurveyMonkey}
alt="SurveyMonkey is the world's most popular online survey tool. It is one the top alternatives to Hotjar."
className="w-full rounded-lg"
/>
SurveyMonkey prides itself on being the global leader in online surveys and forms, and rightfully so, as it has been around for over 20 years. It is an online survey software that helps you create and run professional online surveys.
According to their website, SurveyMonkey provides answers to more than 20 million questions every day, helping organizations of all sizes build products people love, create winning marketing strategies, delight their customers, and cultivate an engaged and happy workforce.
### SurveyMonkey vs Hotjar
**TL:DR**: How SurveyMonkey compares side-by-side to Hotjar.
| Factors | SurveyMonkey | Hotjar |
| --- | --- | --- |
| Surveys & Forms | 🟡🟢 | 🟡🟢 |
| Heatmaps & Recordings | 🔴 | 🟢 |
| Integrations | 🟡🟢 | 🟡🟢 |
| Privacy and Compliance | 🟢 | 🟢 |
| Extensibility | 🟡🟢 | 🟡🟢 |
| Pricing | 🟢 | 🟡 |
Now, lets dive into the details.
**Feature depth:**
- **Surveys & Forms:** SurveyMonkey provides over 150 survey templates cutting across different categories ranging from customer feedback, human resources, and events, among many others. However, most of its survey templates and features, like adding a custom logo, logic skips, and analysis features, are only available on paid plans.
Another feature that gives SurveyMonkey an edge over competitors is their recently rolled out Build with AI feature; if you dont know where or how to start building your survey, all you have to do is enter a prompt into the text area stating what you need a survey for.
- **Heatmaps & Recordings:** SurveyMonkey does not offer heatmaps or recording features, unlike Hotjar. However, if you need feedback on an image, they provide a click map feature, which is available on paid plans.
- **Integrations:** SurveyMonkey integrates with popular apps like Office 365, Google Drive, and Slack, but these integrations are only available on the **Team plans**.
**Privacy:** Like Hotjar, SurveyMonkey complies with GDPR and CCPA regulations, although its HIPAA-compliant features are only available on its enterprise plans.
**Extensibility:** SurveyMonkey provides an API to integrate survey data into your mobile and web applications. But its only **available on its Enterprise Plan**.
**Pricing:** SurveyMonkeys paid plans begin at $25/month whereas paid plans for Hotjar surveys begin at **$47.2/month**. However, dont forget that SurveyMonkey is a full-fledged survey software, so youll get more value for your money if you are concerned about only surveys.
## 4 Hotjar Alternatives For User Behavior Analysis
### Smartlook - G2 Crowd Award Winner
<Image
src={Smartlook}
alt="Smartlook is a comprehensive product analytics and visual user insights tool designed to help businesses gain deep insights into user behavior on their websites or mobile applications."
className="w-full rounded-lg"
/>
[Smartlook](https://smartlook.com/), which was recently acquired by [Cisco](https://cisco.com/), is a comprehensive product analytics and visual user insights tool designed to help businesses gain deep insights into user behavior on their websites or mobile applications. Like Hotjar, it offers a range of features, including heatmaps, surveys, and session recordings, that enable organizations to understand, analyze, and optimize the user experience.
It has received numerous awards and recognition, including the G2 Crowd Awards for **Top 100 Software Products** and **Best Products for Marketers**, as well as being named on Deloittes Technology Fast 50 in Central Europe.
### Smartlook vs Hotjar
**TL;DR**: How Smartlook compares side-by-side to Hotjar.
| Factors | Smartlook | Hotjar |
| --- | --- | --- |
| Surveys & Forms | 🟡🟢 | 🟢 |
| Heatmaps & Recordings | 🟢 | 🟢 |
| Integrations | 🟢 | 🟢 |
| Privacy and Compliance | 🟢 | 🟢 |
| Extensibility | 🟡🟢 | 🟡 |
| Pricing | 🔴 | 🟢 |
Next, well into the details.
**Feature depth**:
- **Surveys**: Smartlook also offers survey features in addition to user behavior analysis tools. However, it does not offer standalone surveys, unlike Hotjar. Instead, you can create surveys through its integration with Survicate.
For a deeper look into Survicate, check out our article on best feedback apps.
- **Heatmaps & Recordings**: Similar to Hotjar, Smartlook provides comprehensive heatmap and recording features. Your recordings are displayed on a dashboard with versatile filtering capabilities and customizable user information. You can also save recordings to a vault session in case the amount of time designated for them to remain in your account expires due to your purchase plan.
Smartlook provides three types of heatmap overlays for heatmaps: click and move, and scroll overlays to help you see how your users use your website.
- **Integrations:** Both platforms work well with many external tools. However, if you're a big enterprise seeking a broader selection of tools, Hotjar is the better choice, but it's worth noting that with Hotjar, only Hubspot and Microsoft Teams integrations are available on free plans.
**Privacy and Compliance**: Smartlook is privacy compliant just like Hotjar. Smartlook stores all data on EU servers. If you collect personal data with Smartlook, GDPR applies. Smartlook is also fully CCPA compliant.
**Extensibility:** Smartlook, like Hotjar, provides a REST API you can build on top of, and its available across all plans; however, there are rate limits across the plans. Its free plan accepts only twenty (20) requests per hour; its paid plan accepts one hundred (100) requests per hour, and its REST API add-on accepts one thousand (1,000) requests per hour.
On the contrary, Hotjars API integration for heatmaps and recordings is only available on its scale plan at **$170.4/month**.
**Pricing**: Compared to Hotjar, Smartlook is relatively more expensive. Its pro plan provides only 30 heatmaps a month and three months of storage. Hotjars equivalent business plan offers unlimited heatmaps with 12 months of data storage
### Lucky Orange
<Image
src={LuckyOrange}
alt="Lucky Orange is a tool for web analytics and conversion optimization. It helps businesses understand how users behave on their websites."
className="w-full rounded-lg"
/>
Lucky Orange is a tool for web analytics and conversion optimization. It helps businesses understand how users behave on their websites. Its features include surveys, session recordings, live viewing, and conversion funnels. These features move beyond vanity metrics, aiming to uncover the reasons behind visitors' actions on your website.
### LuckyOrange vs Hotjar
**TL:DR**: Lets see how they compare side-by-side.
| Factors | Lucky Orange | Hotjar |
| --- | --- | --- |
| Surveys & Forms | 🟢 | 🟢 |
| Heatmaps & Recordings | 🟡🟢 | 🟢 |
| Integrations | 🟡 | 🟢 |
| Privacy and Compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟢 |
| Pricing | 🟡 | 🟢 |
Next, well take a look at the details.
**Feature depth**:
- **Surveys:** Like Hotjar, Lucky Orange offers survey features. You can choose from four survey types that suit your needs. They include Multiple Choice, Like or Dislike, rating, and open-ended surveys.
You can also customize how your survey is triggered based on your users location on your website and their devices, or if you want a delay before your survey is triggered. There are also advanced settings with which you can customize your survey.
- **Heatmaps & Recordings:** Both Lucky Orange and Hotjar offer session recordings; however, while this feature is on par with Hotjars features like filtering, some users still report that the [session viewer crashes when watching a desktop session on the mobile screen](https://www.g2.com/products/lucky-orange/reviews/lucky-orange-review-7862805).
- **Integrations**: Lucky Orange offers a smaller but growing selection of integrations compared to Hotjar, focusing on essentials like Google Analytics, CMS platforms, and marketing automation tools.
**Privacy & Compliance**: Lucky Orange tools, like Hotjar, are fully CCPA and GDPR-compliant. This means that it does not store sensitive information, ensuring a secure and trustworthy environment for user data.
**Extensibility:** Lucky Orange currently works with fewer third-party tools than Hotjar. This might be a drawback for large companies. However, they're planning to add support for a public API in the future. This means you'll be able to build on top of it.
**Pricing**: Lucky Orange offers pricing plans suitable for businesses of different sizes, including a 7-day free trial; as of the time of writing this article, they begin at $32 per month. Each of these plans is based on sessions but only has 60-day data storage, unlike Hojar, which provides data storage for 365 days.
### FullStory
<Image
src={FullStory}
alt="FullStory is a comprehensive user experience analytics platform that enables businesses to gain detailed insights into user interactions with their websites and applications."
className="w-full rounded-lg"
/>
FullStory is a comprehensive user experience analytics platform that enables businesses to gain detailed insights into user interactions with their websites and applications. Through features such as session recordings, dynamic heatmaps, and advanced analytics, FullStory provides a nuanced understanding of user behavior.
### FullStory vs Hotjar
**TL;DR**: FullStory vs Hotjar side by side.
| Factors | FullStory | Hotjar |
| --- | --- | --- |
| Surveys & Forms | 🟢 | 🟢 |
| Heatmaps & Recordings | 🟢 | 🟢 |
| Integrations | 🟢 | 🟢 |
| Pricing | 🟡 | 🟢 |
| Privacy and Compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟡 |
**Features depth**:
- **Surveys:** Both FullStory and Hotjar offer survey features, with FullStory utilizing integration with third-party tools like Survicate and SurveyMonkey for versatility and in-depth feedback.
FullStory wins here if you are looking for a versatile tool to integrate with other existing third-party survey tools. However, if you want an all-in-one solution, Hotjar is your go-to solution.>
- **Heatmaps & Recordings:** FullStory's interactive heatmaps and detailed visuals help you better understand how users interact with your website, improving the analysis of page activities. This feature is similar to what Hotjar offers.
However, a notable difference is that in FullStory, you can't save a session to watch later. So, if you find a recording interesting and want to see it again, you'll need to search for it manually when you want to revisit it.
- **Integrations:** Like Hotjar, FullStory integrates seamlessly with various third-party tools, enhancing its versatility and allowing users to integrate it into their existing tech stack.
**Privacy & Compliance:** You are in full control of what data FullStory captures and saves. FullStory is not just GDPR and CCPA-compliant but also holds a SOC 2 Type II attestation and a SOC 3 report.
**Extensibility:** FullStory also provides several APIs, like Hotjar, including a Webhooks API that enables developers to build on top of its functionality and integrate it into their workflows. However, its worth noting that this is available on paid plans.
**Pricing:** FullStory does not have a free plan, and the price for paid plans is available upon request from the sales team. Although their pricing page states that you get a 14-day free trial for their business plan.
### Mouseflow
<Image
src={MouseFlow}
alt="Mouseflow is a web analytics tool designed to provide insights into user behavior on websites."
className="w-full rounded-lg"
/>
Mouseflow is a web analytics tool designed to provide insights into user behavior on websites. It offers features such as session recordings, heatmaps, surveys, and funnel analysis to help businesses optimize user experiences and conversions.
### MouseFlow vs Hotjar
**TL;DR**: MouseFlow side-by-side with Hotjar.
| Factors | Mouseflow | Hotjar |
| --- | --- | --- |
| Surveys & Forms | 🟢 | 🟡🟢 |
| Heatmaps & Recordings | 🟢 | 🟡🟢 |
| Integrations | 🟢 | 🟢 |
| Pricing | 🟢 | 🟢 |
| Privacy and Compliance | 🟢 | 🟢 |
| Extensibility | 🟡 | 🟡 |
Feature depth
- **Surveys**: Mouseflow provides you with a funnel-like analysis for in-depth form analytics, which is not available on Hotjar. With Mouseflow, you can replay sessions from visitors who dropped out or succeeded in completing the form. It also helps you analyze how users interact with each of your form fields.
- **Heatmaps & Recordings:** Mouseflow, like Hojar, provides heatmaps and session recordings to visualize user interactions and behaviors, aiding in the analysis of website engagement.
However, Hotjar samples the data you collect daily. That is, you are allowed to review just a small fraction of the whole set of data you receive daily. For example, if you have 3000 recordings per month, you are allowed to record just 100 daily sessions on standard plans.
- **Integrations:** Mouseflow, like Hotjar, integrates with about 58 third-party tools, including other analytics, eCommerce, CMS, and marketing platforms in your stack.
**Privacy & Compliance:** Mouseflow is committed to data protection. Its compliant with GDPR, CCPA, CPRA, and VCDPA.
**Extensibility**: Like Hotjar, Mouseflow's open REST API and Webhooks enable developers to build custom integrations, connecting them to virtually any platform or tool imaginable. However, the API is only available on its Growth plan, starting at $109/month.
**Pricing:** Mouseflow's pricing is tiered based on usage and additional features. It offers a range of plans to accommodate businesses of different sizes.
Its free plan comes with 500 recordings per month, unlimited page views, and a month of storage, all for one website. Paid plans begin at $31/month, however, if you are an enterprise, you can contact their sales team to create a customized plan.
## So, which option is the better fit for you?
If you're seeking a comprehensive solution encompassing heatmaps, recordings, surveys, and a strong focus on privacy, MouseFlow emerges as a prime choice.
On the other hand, if your primary emphasis is on forms and surveys, [Formbricks](http://www.formbricks.com/) stands out as the optimal Hotjar alternative. What's even more noteworthy is that it is the sole open-source solution for website surveys available. This translates to not just being completely free to use if you self-host, but also offering the freedom for modification due to its extensibility and the liberty to seamlessly integrate with your preferred tools.
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;

View File

@@ -4,7 +4,7 @@ import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js/app";
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
type UsageAttributesUpdaterProps = {

View File

@@ -28,7 +28,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import formbricks from "@formbricks/js/app";
import formbricks from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { capitalizeFirstLetter, truncate } from "@formbricks/lib/strings";
@@ -152,7 +152,7 @@ export default function Navigation({
},
{
name: "Settings",
href: `/environments/${environment.id}/settings/product`,
href: `/environments/${environment.id}/settings/profile`,
icon: SettingsIcon,
current: pathname?.includes("/settings"),
hidden: false,
@@ -362,31 +362,6 @@ export default function Navigation({
<DropdownMenuSeparator />
{/* Environment Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<p>{capitalizeFirstLetter(environment?.type)}</p>
<p className=" block text-xs text-slate-500">Environment</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={environment?.type}
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
<DropdownMenuRadioItem value="production" className="cursor-pointer">
Production
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="development" className="cursor-pointer">
Development
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Product Switch */}
<DropdownMenuSub>
@@ -465,6 +440,31 @@ export default function Navigation({
</DropdownMenuPortal>
</DropdownMenuSub>
{/* Environment Switch */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div>
<p>{capitalizeFirstLetter(environment?.type)}</p>
<p className=" block text-xs text-slate-500">Environment</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={environment?.type}
onValueChange={(v) => handleEnvironmentChange(v as "production" | "development")}>
<DropdownMenuRadioItem value="production" className="cursor-pointer">
Production
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="development" className="cursor-pointer">
Development
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
{dropdownNavigation.map((item) => (
<DropdownMenuGroup key={item.title}>
<DropdownMenuSeparator />

View File

@@ -11,7 +11,7 @@ import toast from "react-hot-toast";
import { COLOR_DEFAULTS, PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Switch } from "@formbricks/ui/Switch";
@@ -29,7 +29,7 @@ type ThemeStylingProps = {
export const ThemeStyling = ({ product, environmentId, colors }: ThemeStylingProps) => {
const router = useRouter();
const [localProduct, setLocalProduct] = useState(product);
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [previewSurveyType, setPreviewSurveyType] = useState<"link" | "web">("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [styling, setStyling] = useState(product.styling);

View File

@@ -6,7 +6,7 @@ import { Variants, motion } from "framer-motion";
import { useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { ClientLogo } from "@formbricks/ui/ClientLogo";
import { ResetProgressButton } from "@formbricks/ui/ResetProgressButton";
import { SurveyInline } from "@formbricks/ui/Survey";
@@ -15,8 +15,8 @@ interface ThemeStylingPreviewSurveyProps {
survey: TSurvey;
setQuestionId: (_: string) => void;
product: TProduct;
previewType: TSurveyType;
setPreviewType: (type: TSurveyType) => void;
previewType: "link" | "web";
setPreviewType: (type: "link" | "web") => void;
}
const previewParentContainerVariant: Variants = {
@@ -111,8 +111,6 @@ export const ThemeStylingPreviewSurvey = ({
const onFileUpload = async (file: File) => file.name;
const isAppSurvey = previewType === "app" || previewType === "website";
return (
<div className="flex h-full w-full flex-col items-center justify-items-center">
<motion.div
@@ -139,7 +137,7 @@ export const ThemeStylingPreviewSurvey = ({
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
<p>{previewType === "web" ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} />
@@ -147,7 +145,7 @@ export const ThemeStylingPreviewSurvey = ({
</div>
</div>
{isAppSurvey ? (
{previewType === "web" ? (
<Modal
isOpen
placement={placement}
@@ -206,9 +204,9 @@ export const ThemeStylingPreviewSurvey = ({
</div>
<div
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
App survey
className={`${previewType === "web" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("web")}>
In-App survey
</div>
</div>
</div>

View File

@@ -52,9 +52,6 @@ export function EditAvatar({ session, environmentId }: { session: Session; envir
toast.error("Avatar update failed. Please try again.");
} finally {
setIsLoading(false);
if (inputRef.current) {
inputRef.current.value = "";
}
}
};

View File

@@ -4,12 +4,11 @@ import Link from "next/link";
import { TEnvironment } from "@formbricks/types/environment";
import { Button } from "@formbricks/ui/Button";
interface TEmptyAppSurveysProps {
interface TEmptyInAppSurveysProps {
environment: TEnvironment;
surveyType?: "app" | "website";
}
export const EmptyAppSurveys = ({ environment, surveyType = "app" }: TEmptyAppSurveysProps) => {
export const EmptyInAppSurveys = ({ environment }: TEmptyInAppSurveysProps) => {
return (
<div className="flex w-full items-center justify-center gap-8 bg-slate-100 py-12">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-slate-200 bg-white">
@@ -19,9 +18,7 @@ export const EmptyAppSurveys = ({ environment, surveyType = "app" }: TEmptyAppSu
<div className="flex flex-col">
<h1 className="text-xl font-semibold text-slate-900">You&apos;re not plugged in yet!</h1>
<p className="mt-2 text-sm text-slate-600">
Connect your {surveyType} with Formbricks to run {surveyType} surveys.
</p>
<p className="mt-2 text-sm text-slate-600">Connect your app with Formbricks to run in-app surveys.</p>
<Link className="mt-2" href={`/environments/${environment.id}/settings/setup`}>
<Button variant="darkCTA" size="sm" className="flex w-[120px] justify-center">

View File

@@ -1,6 +1,6 @@
"use client";
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { EmptyInAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { useEffect, useRef, useState } from "react";
import { getMembershipByUserIdTeamIdAction } from "@formbricks/lib/membership/hooks/actions";
@@ -85,10 +85,8 @@ export default function ResponseTimeline({
return (
<div className="space-y-4">
{(survey.type === "app" || survey.type === "website") &&
responses.length === 0 &&
!environment.widgetSetupCompleted ? (
<EmptyAppSurveys environment={environment} surveyType={survey.type} />
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
<EmptyInAppSurveys environment={environment} />
) : isFetchingFirstPage ? (
<SkeletonLoader type="response" />
) : responseCount === 0 ? (

View File

@@ -2,7 +2,7 @@ import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { TSurveyQuestionSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys";
import { TSurveyQuestionSummaryMultipleChoice } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { Button } from "@formbricks/ui/Button";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
@@ -13,7 +13,7 @@ import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface MultipleChoiceSummaryProps {
questionSummary: TSurveyQuestionSummaryMultipleChoice;
environmentId: string;
surveyType: TSurveyType;
surveyType: string;
}
export const MultipleChoiceSummary = ({
@@ -69,7 +69,7 @@ export const MultipleChoiceSummary = ({
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6 ">Other values found</div>
<div className="col-span-1 pl-6 ">{surveyType === "app" && "User"}</div>
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.person && (
{surveyType === "web" && otherValue.person && (
<Link
href={
otherValue.person.id

View File

@@ -23,18 +23,16 @@ export const SuccessMessage = ({ environment, survey, webAppUrl, user }: Summary
const [showLinkModal, setShowLinkModal] = useState(false);
const [confetti, setConfetti] = useState(false);
const isAppSurvey = survey.type === "app" || survey.type === "website";
useEffect(() => {
const newSurveyParam = searchParams?.get("success");
if (newSurveyParam && survey && environment) {
setConfetti(true);
toast.success(
isAppSurvey && !environment.widgetSetupCompleted
survey.type === "web" && !environment.widgetSetupCompleted
? "Almost there! Install widget to start receiving responses."
: "Congrats! Your survey is live.",
{
icon: isAppSurvey && !environment.widgetSetupCompleted ? "🤏" : "🎉",
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
duration: 5000,
position: "bottom-right",
}
@@ -47,7 +45,7 @@ export const SuccessMessage = ({ environment, survey, webAppUrl, user }: Summary
url.searchParams.delete("success");
window.history.replaceState({}, "", url.toString());
}
}, [environment, isAppSurvey, searchParams, survey]);
}, [environment, searchParams, survey]);
return (
<>

View File

@@ -1,4 +1,4 @@
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { EmptyInAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
@@ -40,10 +40,8 @@ export const SummaryList = ({
}: SummaryListProps) => {
return (
<div className="mt-10 space-y-8">
{(survey.type === "app" || survey.type === "website") &&
responseCount === 0 &&
!environment.widgetSetupCompleted ? (
<EmptyAppSurveys environment={environment} surveyType={survey.type} />
{survey.type === "web" && responseCount === 0 && !environment.widgetSetupCompleted ? (
<EmptyInAppSurveys environment={environment} />
) : fetchingSummary ? (
<SkeletonLoader type="summary" />
) : responseCount === 0 ? (

View File

@@ -36,7 +36,6 @@ const CardStylingSettings = ({
localProduct,
setOpen,
}: CardStylingSettingsProps) => {
const isAppSurvey = surveyType === "app" || surveyType === "website";
const cardBgColor = styling?.cardBackgroundColor?.light || COLOR_DEFAULTS.cardBackgroundColor;
const isLogoHidden = styling?.isLogoHidden ?? false;
@@ -232,14 +231,14 @@ const CardStylingSettings = ({
</div>
)}
{(!surveyType || isAppSurvey) && (
{(!surveyType || surveyType === "web") && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center gap-2">
<Switch checked={isHighlightBorderAllowed} onCheckedChange={setIsHighlightBorderAllowed} />
<div className="flex flex-col">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-700">Add highlight border</h3>
<Badge text="In-App and Website Surveys" type="gray" size="normal" />
<Badge text="In-App Surveys" type="gray" size="normal" />
</div>
<p className="text-xs text-slate-500">Add an outer border to your survey card.</p>
</div>

View File

@@ -1,13 +1,12 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { AlertCircleIcon, CheckIcon, EarthIcon, LinkIcon, MonitorIcon, SmartphoneIcon } from "lucide-react";
import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon, SmartphoneIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { Label } from "@formbricks/ui/Label";
@@ -15,7 +14,7 @@ import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
interface HowToSendCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey | ((TSurvey: TSurvey) => TSurvey)) => void;
setLocalSurvey: (survey: TSurvey | ((TSurvey) => TSurvey)) => void;
environment: TEnvironment;
}
@@ -40,48 +39,12 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
enabled: type === "link" ? true : prevSurvey.thankYouCard.enabled,
},
}));
// if the type is "app" and the local survey does not already have a segment, we create a new temporary segment
if (type === "app" && !localSurvey.segment) {
const tempSegment: TSegment = {
id: "temp",
isPrivate: true,
title: localSurvey.id,
environmentId: environment.id,
surveys: [localSurvey.id],
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
description: "",
};
setLocalSurvey((prevSurvey) => ({
...prevSurvey,
segment: tempSegment,
}));
}
// if the type is anything other than "app" and the local survey has a temporary segment, we remove it
if (type !== "app" && localSurvey.segment?.id === "temp") {
setLocalSurvey((prevSurvey) => ({
...prevSurvey,
segment: null,
}));
}
};
const options = [
{
id: "website",
name: "Website Survey",
icon: EarthIcon,
description: "Run targeted surveys on public websites.",
comingSoon: false,
alert: !widgetSetupCompleted,
},
{
id: "app",
name: "App Survey",
id: "web",
name: "In-App Survey",
icon: MonitorIcon,
description: "Embed a survey in your web app to collect responses.",
comingSoon: false,
@@ -134,7 +97,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
<hr className="py-1 text-slate-600" />
<div className="p-3">
<RadioGroup
defaultValue="app"
defaultValue="web"
value={localSurvey.type}
onValueChange={setSurveyType}
className="flex flex-col space-y-3">

View File

@@ -39,13 +39,11 @@ export const SettingsView = ({
isUserTargetingAllowed = false,
isFormbricksCloud,
}: SettingsViewProps) => {
const isWebSurvey = localSurvey.type === "website" || localSurvey.type === "app";
return (
<div className="mt-12 space-y-3 p-5">
<HowToSendCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} environment={environment} />
{localSurvey.type === "app" ? (
{localSurvey.type === "web" ? (
!isUserTargetingAllowed ? (
<TargetingCard
key={localSurvey.segment?.id}
@@ -91,7 +89,7 @@ export const SettingsView = ({
environmentId={environment.id}
/>
{isWebSurvey && (
{localSurvey.type === "web" && (
<SurveyPlacementCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}

View File

@@ -10,6 +10,7 @@ import { SurveyMenuBar } from "@/app/(app)/environments/[environmentId]/surveys/
import { PreviewSurvey } from "@/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey";
import { useCallback, useEffect, useRef, useState } from "react";
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { useDocumentVisibility } from "@formbricks/lib/useDocumentVisibility";
@@ -61,6 +62,8 @@ export default function SurveyEditor({
const [styling, setStyling] = useState(localSurvey?.styling);
const [localStylingChanges, setLocalStylingChanges] = useState<TSurveyStyling | null>(null);
const createdSegmentRef = useRef(false);
const fetchLatestProduct = useCallback(async () => {
const latestProduct = await refetchProduct(localProduct.id);
if (latestProduct) {
@@ -111,6 +114,39 @@ export default function SurveyEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type, survey?.questions]);
const handleCreateSegment = async () => {
if (!localSurvey) return;
try {
const createdSegment = await createSegmentAction({
title: localSurvey.id,
description: "",
environmentId: environment.id,
surveyId: localSurvey.id,
filters: [],
isPrivate: true,
});
const localSurveyClone = structuredClone(localSurvey);
localSurveyClone.segment = createdSegment;
setLocalSurvey(localSurveyClone);
} catch (err) {
// set the ref to false to retry during the next render
createdSegmentRef.current = false;
}
};
useEffect(() => {
if (!localSurvey || localSurvey.type !== "web" || !!localSurvey.segment || createdSegmentRef.current) {
return;
}
createdSegmentRef.current = true;
handleCreateSegment();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey]);
useEffect(() => {
if (!localSurvey?.languages) return;
const enabledLanguageCodes = extractLanguageCodes(getEnabledLanguages(localSurvey.languages ?? []));
@@ -199,9 +235,7 @@ export default function SurveyEditor({
questionId={activeQuestionId}
product={localProduct}
environment={environment}
previewType={
localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"
}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
onFileUpload={async (file) => file.name}
/>

View File

@@ -1,17 +1,29 @@
"use client";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { isSurveyValid } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
import {
isCardValid,
isSurveyLogicCyclic,
validateQuestion,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
import { isEqual } from "lodash";
import { AlertTriangleIcon, ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyEditorTabs } from "@formbricks/types/surveys";
import { ZSegmentFilters } from "@formbricks/types/segment";
import {
TI18nString,
TSurvey,
TSurveyEditorTabs,
TSurveyQuestionType,
ZSurveyInlineTriggers,
surveyHasBothTriggers,
} from "@formbricks/types/surveys";
import { AlertDialog } from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -53,7 +65,7 @@ export const SurveyMenuBar = ({
const [isSurveySaving, setIsSurveySaving] = useState(false);
const cautionText = "This survey received responses, make changes with caution.";
const faultyQuestions: string[] = [];
let faultyQuestions: string[] = [];
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
@@ -77,7 +89,7 @@ export const SurveyMenuBar = ({
}, [localSurvey, survey]);
const containsEmptyTriggers = useMemo(() => {
if (localSurvey.type === "link") return false;
if (localSurvey.type !== "web") return false;
const noTriggers = !localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0];
const noInlineTriggers =
@@ -116,46 +128,240 @@ export const SurveyMenuBar = ({
}
};
const handleSegmentWithIdTemp = async () => {
if (localSurvey.segment && localSurvey.type === "app" && localSurvey.segment?.id === "temp") {
const { filters } = localSurvey.segment;
// create a new private segment
const newSegment = await createSegmentAction({
environmentId: localSurvey.environmentId,
filters,
isPrivate: true,
surveyId: localSurvey.id,
title: localSurvey.id,
});
return newSegment;
const validateSurvey = (survey: TSurvey) => {
const existingQuestionIds = new Set();
faultyQuestions = [];
if (survey.questions.length === 0) {
toast.error("Please add at least one question");
return;
}
if (survey.welcomeCard.enabled) {
if (!isCardValid(survey.welcomeCard, "start", survey.languages)) {
faultyQuestions.push("start");
}
}
if (survey.thankYouCard.enabled) {
if (!isCardValid(survey.thankYouCard, "end", survey.languages)) {
faultyQuestions.push("end");
}
}
let pin = survey?.pin;
if (pin !== null && pin!.toString().length !== 4) {
toast.error("PIN must be a four digit number.");
return;
}
for (let index = 0; index < survey.questions.length; index++) {
const question = survey.questions[index];
const isFirstQuestion = index === 0;
const isValid = validateQuestion(question, survey.languages, isFirstQuestion);
if (!isValid) {
faultyQuestions.push(question.id);
}
}
// if there are any faulty questions, the user won't be allowed to save the survey
if (faultyQuestions.length > 0) {
setInvalidQuestions(faultyQuestions);
setSelectedLanguageCode("default");
toast.error("Please fill all required fields.");
return false;
}
for (const question of survey.questions) {
const existingLogicConditions = new Set();
if (existingQuestionIds.has(question.id)) {
toast.error("There are 2 identical question IDs. Please update one.");
return false;
}
existingQuestionIds.add(question.id);
if (
question.type === TSurveyQuestionType.MultipleChoiceSingle ||
question.type === TSurveyQuestionType.MultipleChoiceMulti
) {
const haveSameChoices =
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
question.choices.some((element, index) =>
question.choices
.slice(index + 1)
.some(
(nextElement) =>
nextElement.label[selectedLanguageCode]?.trim() ===
element.label[selectedLanguageCode].trim()
)
);
if (haveSameChoices) {
toast.error("You have empty or duplicate choices.");
return false;
}
}
if (question.type === TSurveyQuestionType.Matrix) {
const hasDuplicates = (labels: TI18nString[]) => {
const flattenedLabels = labels
.map((label) => Object.keys(label).map((lang) => `${lang}:${label[lang].trim().toLowerCase()}`))
.flat();
return new Set(flattenedLabels).size !== flattenedLabels.length;
};
// Function to check for empty labels in each language
const hasEmptyLabels = (labels: TI18nString[]) => {
return labels.some((label) => Object.values(label).some((value) => value.trim() === ""));
};
if (hasEmptyLabels(question.rows) || hasEmptyLabels(question.columns)) {
toast.error("Empty row or column labels in one or more languages");
setInvalidQuestions([question.id]);
return false;
}
if (hasDuplicates(question.rows)) {
toast.error("You have duplicate row labels.");
return false;
}
if (hasDuplicates(question.columns)) {
toast.error("You have duplicate column labels.");
return false;
}
}
for (const logic of question.logic || []) {
const validFields = ["condition", "destination", "value"].filter(
(field) => logic[field] !== undefined
).length;
if (validFields < 2) {
setInvalidQuestions([question.id]);
toast.error("Incomplete logic jumps detected: Fill or remove them in the Questions tab.");
return false;
}
if (question.required && logic.condition === "skipped") {
toast.error("A logic condition is missing: Please update or delete it in the Questions tab.");
return false;
}
const thisLogic = `${logic.condition}-${logic.value}`;
if (existingLogicConditions.has(thisLogic)) {
setInvalidQuestions([question.id]);
toast.error(
"There are two competing logic conditons: Please update or delete one in the Questions tab."
);
return false;
}
existingLogicConditions.add(thisLogic);
}
}
if (
survey.redirectUrl &&
!survey.redirectUrl.includes("https://") &&
!survey.redirectUrl.includes("http://")
) {
toast.error("Please enter a valid URL for redirecting respondents.");
return false;
}
return true;
};
const handleSurveySave = async (shouldNavigateBack = false) => {
const saveSurveyAction = async (shouldNavigateBack = false) => {
if (localSurvey.questions.length === 0) {
toast.error("Please add at least one question.");
return;
}
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
if (questionWithEmptyFallback) {
toast.error("Fallback missing");
return;
}
if (isSurveyLogicCyclic(localSurvey.questions)) {
toast.error("Cyclic logic detected. Please fix it before saving.");
return;
}
setIsSurveySaving(true);
try {
if (
!isSurveyValid(
localSurvey,
faultyQuestions,
setInvalidQuestions,
selectedLanguageCode,
setSelectedLanguageCode
)
) {
setIsSurveySaving(false);
return;
}
localSurvey.triggers = localSurvey.triggers.filter((trigger) => Boolean(trigger));
localSurvey.questions = localSurvey.questions.map((question) => {
// Create a copy of localSurvey with isDraft removed from every question
const strippedSurvey: TSurvey = {
...localSurvey,
questions: localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
});
const segment = (await handleSegmentWithIdTemp()) ?? null;
await updateSurveyAction({ ...localSurvey, segment });
}),
};
if (!validateSurvey(localSurvey)) {
setIsSurveySaving(false);
setLocalSurvey(localSurvey);
return;
}
// validate the user segment filters
const localSurveySegment = {
id: strippedSurvey.segment?.id,
filters: strippedSurvey.segment?.filters,
title: strippedSurvey.segment?.title,
description: strippedSurvey.segment?.description,
};
const surveySegment = {
id: survey.segment?.id,
filters: survey.segment?.filters,
title: survey.segment?.title,
description: survey.segment?.description,
};
// if the non-private segment in the survey and the strippedSurvey are different, don't save
if (!strippedSurvey.segment?.isPrivate && !isEqual(localSurveySegment, surveySegment)) {
toast.error("Please save the audience filters before saving the survey");
setIsSurveySaving(false);
return;
}
if (!!strippedSurvey.segment?.filters?.length) {
const parsedFilters = ZSegmentFilters.safeParse(strippedSurvey.segment.filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
"Invalid targeting: Please check your audience filters";
setIsSurveySaving(false);
toast.error(errMsg);
return;
}
}
// if inlineTriggers are present validate with zod
if (!!strippedSurvey.inlineTriggers) {
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(strippedSurvey.inlineTriggers);
if (!parsedInlineTriggers.success) {
toast.error("Invalid Custom Actions: Please check your custom actions");
return;
}
}
// validate that both triggers and inlineTriggers are not present
if (surveyHasBothTriggers(strippedSurvey)) {
setIsSurveySaving(false);
toast.error("Survey cannot have both custom and saved actions, please remove one.");
return;
}
strippedSurvey.triggers = strippedSurvey.triggers.filter((trigger) => Boolean(trigger));
try {
await updateSurveyAction({ ...strippedSurvey });
setIsSurveySaving(false);
setLocalSurvey(strippedSurvey);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
@@ -169,28 +375,21 @@ export const SurveyMenuBar = ({
};
const handleSurveyPublish = async () => {
setIsSurveyPublishing(true);
try {
if (
!isSurveyValid(
localSurvey,
faultyQuestions,
setInvalidQuestions,
selectedLanguageCode,
setSelectedLanguageCode
)
) {
setIsSurveyPublishing(true);
if (isSurveyLogicCyclic(localSurvey.questions)) {
toast.error("Cyclic logic detected. Please fix it before saving.");
setIsSurveyPublishing(false);
return;
}
if (!validateSurvey(localSurvey)) {
setIsSurveyPublishing(false);
return;
}
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
const segment = (await handleSegmentWithIdTemp()) ?? null;
await updateSurveyAction({
...localSurvey,
status,
segment,
});
setIsSurveyPublishing(false);
await updateSurveyAction({ ...localSurvey, status });
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
} catch (error) {
toast.error("An error occured while publishing the survey.");
@@ -257,7 +456,7 @@ export const SurveyMenuBar = ({
variant={localSurvey.status === "draft" ? "secondary" : "darkCTA"}
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSurveySave()}>
onClick={() => saveSurveyAction()}>
Save
</Button>
{localSurvey.status === "draft" && audiencePrompt && (
@@ -293,7 +492,7 @@ export const SurveyMenuBar = ({
setConfirmDialogOpen(false);
router.back();
}}
onConfirm={() => handleSurveySave(true)}
onConfirm={() => saveSurveyAction(true)}
/>
</div>
</>

View File

@@ -5,7 +5,6 @@ import { useState } from "react";
import toast from "react-hot-toast";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
@@ -46,15 +45,10 @@ export default function UpdateQuestionId({
}
};
const isButtonDisabled = () => {
if (currentValue === question.id || currentValue.trim() === "") return true;
else return false;
};
return (
<div>
<Label htmlFor="questionId">Question ID</Label>
<div className="mt-2 inline-flex w-full space-x-2">
<div className="mt-2 inline-flex w-full">
<Input
id="questionId"
name="questionId"
@@ -62,12 +56,10 @@ export default function UpdateQuestionId({
onChange={(e) => {
setCurrentValue(e.target.value);
}}
disabled={localSurvey.status !== "draft" && !question.isDraft}
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
onBlur={saveAction}
disabled={!(localSurvey.status === "draft" || question.isDraft)}
className={isInputInvalid ? "border-red-300 focus:border-red-300" : ""}
/>
<Button variant="darkCTA" size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
Save
</Button>
</div>
</div>
);

View File

@@ -38,9 +38,7 @@ export default function WhenToSendCard({
propActionClasses,
membershipRole,
}: WhenToSendCardProps) {
const [open, setOpen] = useState(
localSurvey.type === "app" || localSurvey.type === "website" ? true : false
);
const [open, setOpen] = useState(localSurvey.type === "web" ? true : false);
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);

View File

@@ -1,13 +1,9 @@
// extend this object in order to add more validation rules
import { isEqual } from "lodash";
import { toast } from "react-hot-toast";
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
import { ZSegmentFilters } from "@formbricks/types/segment";
import {
TI18nString,
TSurvey,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyLanguage,
@@ -17,12 +13,9 @@ import {
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyQuestionType,
TSurveyQuestions,
TSurveyThankYouCard,
TSurveyWelcomeCard,
ZSurveyInlineTriggers,
surveyHasBothTriggers,
} from "@formbricks/types/surveys";
// Utility function to check if label is valid for all required languages
@@ -273,231 +266,3 @@ export const isSurveyLogicCyclic = (questions: TSurveyQuestions) => {
return false;
};
export const isSurveyValid = (
survey: TSurvey,
faultyQuestions: string[],
setInvalidQuestions: (questions: string[]) => void,
selectedLanguageCode: string,
setSelectedLanguageCode: (languageCode: string) => void
) => {
const existingQuestionIds = new Set();
// Ensuring at least one question is added to the survey.
if (survey.questions.length === 0) {
toast.error("Please add at least one question");
return false;
}
// Checking the validity of the welcome and thank-you cards if they are enabled.
if (survey.welcomeCard.enabled) {
if (!isCardValid(survey.welcomeCard, "start", survey.languages)) {
faultyQuestions.push("start");
}
}
if (survey.thankYouCard.enabled) {
if (!isCardValid(survey.thankYouCard, "end", survey.languages)) {
faultyQuestions.push("end");
}
}
// Verifying that any provided PIN is exactly four digits long.
const pin = survey.pin;
if (pin && pin.toString().length !== 4) {
toast.error("PIN must be a four digit number.");
return false;
}
// Assessing each question for completeness and correctness,
for (let index = 0; index < survey.questions.length; index++) {
const question = survey.questions[index];
const isFirstQuestion = index === 0;
const isValid = validateQuestion(question, survey.languages, isFirstQuestion);
if (!isValid) {
faultyQuestions.push(question.id);
}
}
// if there are any faulty questions, the user won't be allowed to save the survey
if (faultyQuestions.length > 0) {
setInvalidQuestions(faultyQuestions);
setSelectedLanguageCode("default");
toast.error("Please fill all required fields.");
return false;
}
for (const question of survey.questions) {
const existingLogicConditions = new Set();
if (existingQuestionIds.has(question.id)) {
toast.error("There are 2 identical question IDs. Please update one.");
return false;
}
existingQuestionIds.add(question.id);
if (
question.type === TSurveyQuestionType.MultipleChoiceSingle ||
question.type === TSurveyQuestionType.MultipleChoiceMulti
) {
const haveSameChoices =
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
question.choices.some((element, index) =>
question.choices
.slice(index + 1)
.some(
(nextElement) =>
nextElement.label[selectedLanguageCode]?.trim() === element.label[selectedLanguageCode].trim()
)
);
if (haveSameChoices) {
toast.error("You have empty or duplicate choices.");
return false;
}
}
if (question.type === TSurveyQuestionType.Matrix) {
const hasDuplicates = (labels: TI18nString[]) => {
const flattenedLabels = labels
.map((label) => Object.keys(label).map((lang) => `${lang}:${label[lang].trim().toLowerCase()}`))
.flat();
return new Set(flattenedLabels).size !== flattenedLabels.length;
};
// Function to check for empty labels in each language
const hasEmptyLabels = (labels: TI18nString[]) => {
return labels.some((label) => Object.values(label).some((value) => value.trim() === ""));
};
if (hasEmptyLabels(question.rows) || hasEmptyLabels(question.columns)) {
toast.error("Empty row or column labels in one or more languages");
setInvalidQuestions([question.id]);
return false;
}
if (hasDuplicates(question.rows)) {
toast.error("You have duplicate row labels.");
return false;
}
if (hasDuplicates(question.columns)) {
toast.error("You have duplicate column labels.");
return false;
}
}
for (const logic of question.logic || []) {
const validFields = ["condition", "destination", "value"].filter(
(field) => logic[field] !== undefined
).length;
if (validFields < 2) {
setInvalidQuestions([question.id]);
toast.error("Incomplete logic jumps detected: Fill or remove them in the Questions tab.");
return false;
}
if (question.required && logic.condition === "skipped") {
toast.error("A logic condition is missing: Please update or delete it in the Questions tab.");
return false;
}
const thisLogic = `${logic.condition}-${logic.value}`;
if (existingLogicConditions.has(thisLogic)) {
setInvalidQuestions([question.id]);
toast.error(
"There are two competing logic conditons: Please update or delete one in the Questions tab."
);
return false;
}
existingLogicConditions.add(thisLogic);
}
}
// Checking the validity of redirection URLs to ensure they are properly formatted.
if (
survey.redirectUrl &&
!survey.redirectUrl.includes("https://") &&
!survey.redirectUrl.includes("http://")
) {
toast.error("Please enter a valid URL for redirecting respondents.");
return false;
}
// validate the user segment filters
const localSurveySegment = {
id: survey.segment?.id,
filters: survey.segment?.filters,
title: survey.segment?.title,
description: survey.segment?.description,
};
const surveySegment = {
id: survey.segment?.id,
filters: survey.segment?.filters,
title: survey.segment?.title,
description: survey.segment?.description,
};
// if the non-private segment in the survey and the strippedSurvey are different, don't save
if (!survey.segment?.isPrivate && !isEqual(localSurveySegment, surveySegment)) {
toast.error("Please save the audience filters before saving the survey");
return false;
}
if (!!survey.segment?.filters?.length) {
const parsedFilters = ZSegmentFilters.safeParse(survey.segment.filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
"Invalid targeting: Please check your audience filters";
toast.error(errMsg);
return false;
}
}
// if inlineTriggers are present validate with zod
if (!!survey.inlineTriggers) {
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(survey.inlineTriggers);
if (!parsedInlineTriggers.success) {
toast.error("Invalid Custom Actions: Please check your custom actions");
return false;
}
}
// validate that both triggers and inlineTriggers are not present
if (surveyHasBothTriggers(survey)) {
toast.error("Survey cannot have both custom and saved actions, please remove one.");
return false;
}
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
if (questionWithEmptyFallback) {
toast.error("Fallback missing");
return false;
}
// Detecting any cyclic dependencies in survey logic.
if (isSurveyLogicCyclic(survey.questions)) {
toast.error("Cyclic logic detected. Please fix it before saving.");
return false;
}
if (survey.type === "app" && survey.segment?.id === "temp") {
const { filters } = survey.segment;
const parsedFilters = ZSegmentFilters.safeParse(filters);
if (!parsedFilters.success) {
const errMsg =
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
"Invalid targeting: Please check your audience filters";
toast.error(errMsg);
return;
}
}
return true;
};

View File

@@ -28,7 +28,7 @@ interface PreviewSurveyProps {
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
}
let surveyNameTemp: string;
let surveyNameTemp;
const previewParentContainerVariant: Variants = {
expanded: {
@@ -156,7 +156,7 @@ export const PreviewSurvey = ({
const onFinished = () => {
// close modal if there are no questions left
if ((survey.type === "website" || survey.type === "app") && !survey.thankYouCard.enabled) {
if (survey.type === "web" && !survey.thankYouCard.enabled) {
setIsModalOpen(false);
setTimeout(() => {
setQuestionId(survey.questions[0]?.id);
@@ -165,7 +165,7 @@ export const PreviewSurvey = ({
}
};
// this useEffect is for refreshing the survey preview only if user is switching between templates on survey templates page and hence we are checking for survey.id === "someUniqeId1" which is a common Id for all templates
// this useEffect is fo refreshing the survey preview only if user is switching between templates on survey templates page and hence we are checking for survey.id === "someUniqeId1" which is a common Id for all templates
useEffect(() => {
if (survey.name !== surveyNameTemp && survey.id === "someUniqueId1") {
resetQuestionProgress();

View File

@@ -30,7 +30,7 @@ export default function SurveyStarter({
const newSurveyFromTemplate = async (template: TTemplate) => {
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "app" : "link";
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const augmentedTemplate: TSurveyInput = {
...template.preset,
type: surveyType,

View File

@@ -67,7 +67,7 @@ export const TemplateList = ({
const addSurvey = async (activeTemplate) => {
setLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "app" : "link";
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const augmentedTemplate: TSurveyInput = {
...activeTemplate.preset,
type: surveyType,

View File

@@ -2602,7 +2602,7 @@ export const minimalSurvey: TSurvey = {
createdAt: new Date(),
updatedAt: new Date(),
name: "Minimal Survey",
type: "app",
type: "web",
environmentId: "someEnvId1",
createdBy: null,
status: "draft",
@@ -2653,7 +2653,7 @@ export const getExampleSurveyTemplate = (webAppUrl: string) => ({
}) as TSurveyCTAQuestion
),
name: "Example survey",
type: "website" as TSurveyType,
type: "web" as TSurveyType,
autoComplete: 2,
triggers: ["New Session"],
status: "inProgress" as TSurveyStatus,

View File

@@ -6,12 +6,12 @@ import Image from "next/image";
import { OptionCard } from "@formbricks/ui/OptionCard";
interface PathwaySelectProps {
setSelectedPathway: (pathway: "link" | "website" | null) => void;
setSelectedPathway: (pathway: "link" | "in-app" | null) => void;
setCurrentStep: (currentStep: number) => void;
isFormbricksCloud: boolean;
}
type PathwayOptionType = "link" | "website";
type PathwayOptionType = "link" | "in-app";
export default function PathwaySelect({
setSelectedPathway,
@@ -29,7 +29,7 @@ export default function PathwaySelect({
localStorage.setItem("onboardingCurrentStep", "5");
}
} else {
localStorage.setItem("onboardingPathway", "website");
localStorage.setItem("onboardingPathway", "in-app");
setCurrentStep(2);
localStorage.setItem("onboardingCurrentStep", "2");
}
@@ -54,12 +54,12 @@ export default function PathwaySelect({
<Image src={LinkMockup} alt="" height={350} />
</OptionCard>
<OptionCard
cssId="onboarding-website-survey-card"
cssId="onboarding-inapp-survey-card"
size="lg"
title="Website Surveys"
description="Run a survey on a website."
title="In-app Surveys"
description="Run a survey on a website or in-app."
onSelect={() => {
handleSelect("website");
handleSelect("in-app");
}}>
<Image src={InappMockup} alt="" height={350} />
</OptionCard>

View File

@@ -183,31 +183,24 @@ export const SignupForm = ({
<IsPasswordValid password={password} setIsValid={setIsValid} />
</div>
)}
{showLogin && (
<Button
type="submit"
variant="darkCTA"
className="w-full justify-center"
loading={signingUp}
disabled={formRef.current ? !isButtonEnabled || !isValid : !isButtonEnabled}>
Continue with Email
</Button>
)}
{!showLogin && (
<Button
type="button"
onClick={() => {
<Button
onClick={(e: any) => {
e.preventDefault();
if (!showLogin) {
setShowLogin(true);
setButtonEnabled(false);
// Add a slight delay before focusing the input field to ensure it's visible
setTimeout(() => nameRef.current?.focus(), 100);
}}
variant="darkCTA"
className="w-full justify-center">
Continue with Email
</Button>
)}
} else if (formRef.current) {
formRef.current.requestSubmit();
}
}}
variant="darkCTA"
className="w-full justify-center"
loading={signingUp}
disabled={formRef.current ? !isButtonEnabled || !isValid : !isButtonEnabled}>
Continue with Email
</Button>
</form>
)}
{googleOAuthEnabled && (

View File

@@ -7,11 +7,8 @@ export async function GET(_: NextRequest, { params }: { params: { slug: string }
const packageRequested = params["package"];
switch (packageRequested) {
case "app":
path = `../../packages/js-core/dist/app.umd.cjs`;
break;
case "website":
path = `../../packages/js-core/dist/website.umd.cjs`;
case "js-core":
path = `../../packages/js-core/dist/index.umd.cjs`;
break;
case "surveys":
path = `../../packages/surveys/dist/index.umd.cjs`;

View File

@@ -9,7 +9,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TJsAppStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { TJsStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
interface Context {
params: {
@@ -87,7 +87,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
}
// return state
const state: TJsAppStateSync = {
const state: TJsStateSync = {
person: { id: person.id, userId: person.userId },
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),

View File

@@ -9,7 +9,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TJsAppStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { TJsStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
interface Context {
params: {
@@ -86,7 +86,7 @@ export async function POST(req: Request, context: Context): Promise<Response> {
}
// return state
const state: TJsAppStateSync = {
const state: TJsStateSync = {
person: { id: person.id, userId: person.userId },
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),

View File

@@ -1,6 +1,6 @@
import { TJsAppState, TJsLegacyState } from "@formbricks/types/js";
import { TJsLegacyState, TJsState } from "@formbricks/types/js";
export const transformLegacySurveys = (state: TJsAppState): TJsLegacyState => {
export const transformLegacySurveys = (state: TJsState): TJsLegacyState => {
const updatedState: any = { ...state };
updatedState.surveys = updatedState.surveys.map((survey) => {
const updatedSurvey = { ...survey };

View File

@@ -101,9 +101,7 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
surveys = await getSyncSurveys(environmentId, (person as TPerson).id);
} else {
surveys = await getSurveys(environmentId);
surveys = surveys.filter(
(survey) => (survey.type === "app" || survey.type === "website") && survey.status === "inProgress"
);
surveys = surveys.filter((survey) => survey.type === "web" && survey.status === "inProgress");
}
surveys = transformLegacySurveys(surveys);

View File

@@ -1,5 +1,5 @@
import { getExampleSurveyTemplate } from "@/app/(app)/environments/[environmentId]/surveys/templates/templates";
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/app/sync/lib/posthog";
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest, userAgent } from "next/server";
@@ -25,7 +25,7 @@ import { updateUser } from "@formbricks/lib/user/service";
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TJsStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
@@ -190,7 +190,7 @@ export async function GET(
};
// return state
const state: TJsAppStateSync = {
const state: TJsStateSync = {
person: personData,
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),

View File

@@ -8,17 +8,12 @@ export const sendFreeLimitReachedEventToPosthogBiWeekly = async (
): Promise<string> =>
unstable_cache(
async () => {
try {
await capturePosthogEnvironmentEvent(environmentId, "free limit reached", {
plan,
});
return "success";
} catch (error) {
console.error(error);
throw error;
}
await capturePosthogEnvironmentEvent(environmentId, "free limit reached", {
plan,
});
return "success";
},
[`sendFreeLimitReachedEventToPosthogBiWeekly-${plan}-${environmentId}`],
[`posthog-${plan}-limitReached-${environmentId}`],
{
revalidate: 60 * 60 * 24 * 15, // 15 days
}

View File

@@ -1,5 +1,5 @@
import { getExampleSurveyTemplate } from "@/app/(app)/environments/[environmentId]/surveys/templates/templates";
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/app/sync/lib/posthog";
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { NextRequest } from "next/server";
@@ -17,7 +17,7 @@ import { createSurvey, getSurveys, transformToLegacySurvey } from "@formbricks/l
import { getMonthlyTeamResponseCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js";
import { TJsStateSync, ZJsPublicSyncInput } from "@formbricks/types/js";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
@@ -35,7 +35,7 @@ export async function GET(
searchParams.get("version") === "undefined" || searchParams.get("version") === null
? undefined
: searchParams.get("version");
const syncInputValidation = ZJsWebsiteSyncInput.safeParse({
const syncInputValidation = ZJsPublicSyncInput.safeParse({
environmentId: params.environmentId,
});
@@ -87,16 +87,16 @@ export async function GET(
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// Common filter condition for selecting surveys that are in progress, are of type 'website' and have no active segment filtering.
const filteredSurveys = surveys.filter(
(survey) => survey.status === "inProgress" && survey.type === "website"
// TODO: Find out if this required anymore. Most likely not.
// && (!survey.segment || survey.segment.filters.length === 0)
// Common filter condition for selecting surveys that are in progress, are of type 'web' and have no active segment filtering.
let filteredSurveys = surveys.filter(
(survey) =>
survey.status === "inProgress" &&
survey.type === "web" &&
(!survey.segment || survey.segment.filters.length === 0)
);
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
@@ -127,10 +127,11 @@ export async function GET(
};
// Create the 'state' object with surveys, noCodeActionClasses, product, and person.
const state: TJsWebsiteStateSync = {
const state: TJsStateSync = {
surveys: isInAppSurveyLimitReached ? [] : transformedSurveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product: updatedProduct,
person: null,
};
return responses.successResponse(

View File

@@ -31,9 +31,6 @@ export async function POST(req: Request, context: Context): Promise<Response> {
);
}
// remove userId from attributes because it is not allowed to be updated
const { userId: userIdAttr, ...updatedAttributes } = inputValidation.data.attributes;
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
@@ -43,10 +40,11 @@ export async function POST(req: Request, context: Context): Promise<Response> {
}
// Check if the person is already up to date
const updatedAtttributes = inputValidation.data.attributes;
const oldAttributes = person.attributes;
let isUpToDate = true;
for (const key in updatedAttributes) {
if (updatedAttributes[key] !== oldAttributes[key]) {
for (const key in updatedAtttributes) {
if (updatedAtttributes[key] !== oldAttributes[key]) {
isUpToDate = false;
break;
}

View File

@@ -67,7 +67,7 @@ export async function PUT(request: Request, { params }: { params: { surveyId: st
transformErrorToDetails(inputValidation.error)
);
}
return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId }));
return responses.successResponse(await updateSurvey(inputValidation.data));
} catch (error) {
return handleErrorResponse(error);
}

View File

@@ -104,7 +104,7 @@ export async function POST(request: Request) {
return Response.json(user);
} catch (e) {
if (e.message === "User with this email already exists") {
if (e.code === "P2002") {
return Response.json(
{
error: "user with this email address already exists",

View File

@@ -1,5 +1,5 @@
import { FormbricksAPI } from "@formbricks/api";
import formbricks from "@formbricks/js/app";
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
export const formbricksEnabled =

View File

@@ -21,7 +21,7 @@ export const isWebAppRoute = (url: string): boolean =>
export const isSyncWithUserIdentificationEndpoint = (
url: string
): { environmentId: string; userId: string } | false => {
const regex = /\/api\/v1\/client\/([^/]+)\/app\/sync\/([^/]+)/;
const regex = /\/api\/v1\/client\/([^/]+)\/in-app\/sync\/([^/]+)/;
const match = url.match(regex);
return match ? { environmentId: match[1], userId: match[2] } : false;
};

View File

@@ -76,10 +76,7 @@ export default function LinkSurvey({
}, [survey, startAt]);
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
let surveyState = useMemo(() => {
return new SurveyState(survey.id, singleUseId, responseId, userId);
}, [survey.id, singleUseId, responseId, userId]);
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId, userId));
const prefillResponseData: TResponseData | undefined = prefillAnswer
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer, languageCode)
: undefined;
@@ -98,6 +95,7 @@ export default function LinkSurvey({
// when response of current question is processed successfully
setIsResponseSendingFinished(true);
},
setSurveyState: setSurveyState,
},
surveyState
),
@@ -151,7 +149,6 @@ export default function LinkSurvey({
if (!surveyState.isResponseFinished() && hasFinishedSingleUseResponse) {
return <SurveyLinkUsed singleUseMessage={survey.singleUse} />;
}
if (survey.verifyEmail && emailVerificationStatus !== "verified") {
if (emailVerificationStatus === "fishy") {
return <VerifyEmail survey={survey} isErrorComponent={true} languageCode={languageCode} />;
@@ -227,8 +224,9 @@ export default function LinkSurvey({
}
const { id } = res.data;
surveyState.updateDisplayId(id);
responseQueue.updateSurveyState(surveyState);
const newSurveyState = surveyState.copy();
newSurveyState.updateDisplayId(id);
setSurveyState(newSurveyState);
}
}}
onResponse={(responseUpdate: TResponseUpdate) => {

View File

@@ -35,10 +35,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.next();
}
let ip =
request.headers.get("cf-connecting-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
request.ip;
let ip = request.ip ?? request.headers.get("x-real-ip");
const forwardedFor = request.headers.get("x-forwarded-for");
if (!ip && forwardedFor) {
ip = forwardedFor.split(",").at(0) ?? null;
}
if (ip) {
try {

View File

@@ -55,18 +55,6 @@ const nextConfig = {
},
],
},
async rewrites() {
return [
{
source: "/api/v1/client/:environmentId/in-app/sync",
destination: "/api/v1/client/:environmentId/website/sync",
},
{
source: "/api/v1/client/:environmentId/in-app/sync/:userId",
destination: "/api/v1/client/:environmentId/app/sync/:userId",
},
];
},
async redirects() {
return [
{

View File

@@ -24,11 +24,12 @@ test.describe("JS Package Test", async () => {
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-website")).toBeVisible();
await page.locator("#howToSendCardOption-website").click();
await page.locator("#howToSendCardOption-website").click();
await expect(page.locator("#howToSendCardOption-web")).toBeVisible();
await page.locator("#howToSendCardOption-web").click();
await page.locator("#howToSendCardOption-web").click();
await expect(page.getByText("Survey Trigger")).toBeVisible();
// await page.getByText("Survey Trigger").click();
await page.getByRole("combobox").click();
await page.getByLabel("New Session").click();
@@ -49,13 +50,13 @@ test.describe("JS Package Test", async () => {
test("JS Display Survey on Page", async ({ page }) => {
let currentDir = process.cwd();
let htmlFilePath = currentDir + "/packages/js/index.html";
let htmlFilePath = currentDir + "/packages/js-core/index.html";
let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId);
await page.goto(htmlFile);
// Formbricks In App Sync has happened
const syncApi = await page.waitForResponse((response) => response.url().includes("/website/sync"));
const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync"));
expect(syncApi.status()).toBe(200);
// Formbricks Modal exists in the DOM
@@ -73,14 +74,14 @@ test.describe("JS Package Test", async () => {
test("JS submits Response to Survey", async ({ page }) => {
let currentDir = process.cwd();
let htmlFilePath = currentDir + "/packages/js/index.html";
let htmlFilePath = currentDir + "/packages/js-core/index.html";
let htmlFile = "file:///" + htmlFilePath;
await page.goto(htmlFile);
// Formbricks In App Sync has happened
const syncApi = await page.waitForResponse((response) => response.url().includes("/website/sync"));
const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync"));
expect(syncApi.status()).toBe(200);
// Formbricks Modal exists in the DOM
@@ -112,7 +113,7 @@ test.describe("JS Package Test", async () => {
test("Admin validates Displays & Response", async ({ page }) => {
await login(page, email, password);
await page.getByRole("link", { name: "Website Open options Product" }).click();
await page.getByRole("link", { name: "In-app Open options Product" }).click();
(await page.waitForSelector("text=Responses")).isVisible();
// Survey should have 2 Displays

View File

@@ -14,19 +14,18 @@ test.describe("Onboarding Flow Test", async () => {
await page.getByRole("button", { name: "Link Surveys Create a new" }).click();
await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
await page.getByRole("button", { name: "Continue to Settings" }).click();
await page.getByRole("button", { name: "Publish" }).click();
await page.getByRole("button", { name: "Save" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await expect(page.getByText(productName)).toBeVisible();
});
test("website survey", async ({ page }) => {
test("In app survey", async ({ page }) => {
const { name, email, password } = users.onboarding[1];
await signUpAndLogin(page, name, email, password);
await page.waitForURL("/onboarding");
await expect(page).toHaveURL("/onboarding");
await page.getByRole("button", { name: "Website Surveys Run a survey" }).click();
await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
await page.getByRole("button", { name: "Skip" }).click();
await page.getByRole("button", { name: "Skip" }).click();

View File

@@ -32,7 +32,7 @@ test.describe("Survey Create & Submit Response", async () => {
// Save & Publish Survey
await page.getByRole("button", { name: "Continue to Settings" }).click();
await page.locator("#howToSendCardTrigger").click();
await page.locator("#howToSendCardOption-website").click();
await page.locator("#howToSendCardOption-web").click();
await page.getByRole("button", { name: "Custom Actions" }).click();
await expect(page.locator("#codeAction")).toBeVisible();

View File

@@ -64,7 +64,7 @@ export const finishOnboarding = async (page: Page, deleteExampleSurvey: boolean
await expect(page.getByText("My Product")).toBeVisible();
let currentDir = process.cwd();
let htmlFilePath = currentDir + "/packages/js/index.html";
let htmlFilePath = currentDir + "/packages/js-core/index.html";
const environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
@@ -75,8 +75,8 @@ export const finishOnboarding = async (page: Page, deleteExampleSurvey: boolean
let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId);
await page.goto(htmlFile);
// Formbricks Website Sync has happened
const syncApi = await page.waitForResponse((response) => response.url().includes("/website/sync"));
// Formbricks In App Sync has happened
const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync"));
expect(syncApi.status()).toBe(200);
await page.goto("/");
@@ -96,7 +96,7 @@ export const replaceEnvironmentIdInHtml = (filePath: string, environmentId: stri
let htmlContent = readFileSync(filePath, "utf-8");
htmlContent = htmlContent.replace(/environmentId: ".*?"/, `environmentId: "${environmentId}"`);
writeFileSync(filePath, htmlContent, { mode: 1 });
writeFileSync(filePath, htmlContent);
return "file:///" + filePath;
};

View File

@@ -1,7 +1,6 @@
# Kamal Setup
1. Initiate a Linux instance & Get it's
1. Initiate a Linux instance & Get it's
- Public IP address
- SSH credentials
@@ -14,50 +13,43 @@
4. If the above returns a non-zero error code, run the below:
```sh
eval `ssh-agent -s`
ssh-add <path-to-your-key>.pem
```
```sh
eval `ssh-agent -s`
ssh-add <path-to-your-key>.pem
```
5. Now test the SSH status again:
```sh
kamal lock status
```
```sh
kamal lock status
```
This should now run successfully & exit with 0.
This should now run successfully & exit with 0.
6. Generate a Classic Personal Access Token for `container:write` & `container:read` for your Image Registry (DockerHub, GHCR, etc.) & add it to your environment variables (.env file). Also update the Registry details in the `deploy.yml`.
7. If your SSH user is a non-root user, run the below command to add the user to the docker group:
```sh
sudo usermod -aG docker ${USER}
```
```sh
sudo usermod -aG docker ${USER}
```
> Note: The above needs to be ran on the Cloud VM. There is an open Issue on Kamal for the same [here](https://github.com/basecamp/kamal/issues/405)
> Note: The above needs to be ran on the Cloud VM. There is an open Issue on Kamal for the same [here](https://github.com/basecamp/kamal/issues/405)
> Run the below for SSL config the first time
```sh
sudo mkdir -p /letsencrypt && sudo touch /letsencrypt/acme.json && sudo chmod 600 /letsencrypt/acme.json
```
> Run this command to create the private bridge network used by kamal to reference containers on one instance
```sh
docker network create -d bridge private
```
8. Make sure you have docker buildx locally on your machine where you run the kamal CLI from!
9. Voila! You are all set to deploy your application to the cloud with Kamal! 🚀
```sh
kamal setup -c kamal/deploy.yml
```
```sh
kamal setup -c kamal/deploy.yml
```
This will setup the cloud VM with all the necessary tools & dependencies to run your application.
This will setup the cloud VM with all the necessary tools & dependencies to run your application.
> Make sure to run `kamal env push` before a `kamal deploy` to push the latest environment variables to the cloud VM.
@@ -65,29 +57,28 @@ docker network create -d bridge private
- If you run into an error such as:
```sh
failed to solve: cannot copy to non-directory:
```
```sh
failed to solve: cannot copy to non-directory:
```
Then simply run `pnpm clean` & try again.
Then simply run `pnpm clean` & try again.
- Make sure your Database accepts connection from the cloud VM. You can do this by adding the VM's IP address to the `Allowed Hosts` in your Database settings.
- If you get an error such as:
```sh
Lock failed: failed to acquire lock: lockfile already exists
```
```sh
Lock failed: failed to acquire lock: lockfile already exists
```
Then simply run `kamal lock release -c kamal/deploy.yml` & try again.
Then simply run `kamal lock release -c kamal/deploy.yml` & try again.
- If you run into:
```sh
No config found
```
```sh
No config found
```
Then simply add the following at the end of the command: `-c kamal/deploy.yml`
Then simply add the following at the end of the command: `-c kamal/deploy.yml`
For further details, refer to the [Kamal Documentation](https://kamal-deploy.org/docs/configuration) or reach out to us on our [Discord](https://formbricks.com/discord)

View File

@@ -9,22 +9,17 @@ servers:
web: # Use a named role, so it can be used as entrypoint by Traefik
hosts:
- 18.196.187.144
- 18.196.172.27
- 35.157.124.188
- 18.199.207.103
- ec2-18-194-217-29.eu-central-1.compute.amazonaws.com
- ec2-3-64-56-61.eu-central-1.compute.amazonaws.com
- ec2-3-122-60-81.eu-central-1.compute.amazonaws.com
labels:
traefik.http.routers.formbricks-kamal.entrypoints: web
options:
network: "private"
traefik.http.routers.formbricks-kamal.entrypoints: websecure
traefik.http.routers.formbricks-kamal.rule: Host(`app.formbricks.com`)
traefik.http.routers.formbricks-kamal.tls.certresolver: letsencrypt
# Credentials for your image host.
registry:
# Specify the registry server, if you're not using Docker Hub
server: ghcr.io
username: mattinannt
# Always use an access token rather than real password when possible.
password:
- KAMAL_REGISTRY_PASSWORD
@@ -32,8 +27,11 @@ registry:
# Inject ENV variables into containers (secrets come from .env).
# Remember to run `kamal env push` after making changes!
env:
# clear:
# DB_HOST: 192.168.0.2
clear:
REDIS_HTTP_URL: http://formbricks-kamal-webdis:7379
REDIS_URL: redis://default:password@172.31.40.79:6379
REDIS_HTTP_URL: http://172.31.40.79:7379
secret:
- IS_FORMBRICKS_CLOUD
- WEBAPP_URL
@@ -99,7 +97,6 @@ env:
- DB_PASSWORD
- DB_NAME
- SENTRY_AUTH_TOKEN
- REDIS_URL
# Use a different ssh user than root
ssh:
@@ -118,39 +115,123 @@ builder:
- NEXT_PUBLIC_SENTRY_DSN
- ASSET_PREFIX_URL
- SENTRY_AUTH_TOKEN
multiarch: false
cache:
type: registry
options: mode=max,image-manifest=true,oci-mediatypes=true
# secrets:
# - GITHUB_TOKEN
# remote:
# arch: amd64
# host: ssh://app@192.168.0.1
traefik:
options:
publish:
- "443:443"
volume:
- "/letsencrypt/acme.json:/letsencrypt/acme.json" # To save the configuration file.
args:
entryPoints.web.address: ":80"
options:
network: "private"
entryPoints.websecure.address: ":443"
entryPoints.web.http.redirections.entryPoint.to: websecure
entryPoints.web.http.redirections.entryPoint.scheme: https
entryPoints.web.http.redirections.entrypoint.permanent: true
entrypoints.websecure.http.tls: true
entrypoints.websecure.http.tls.domains[0].main: "app.formbricks.com"
entrypoints.websecure.http.tls.domains[0].sans: "*.formbricks.com"
certificatesResolvers.letsencrypt.acme.email: "hola@formbricks.com"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesresolvers.letsencrypt.acme.dnschallenge.provider: cloudflare
env:
secret:
- CLOUDFLARE_DNS_API_TOKEN
- CLOUDFLARE_EMAIL
# Use accessory services (secrets come from .env).
accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# port: 3306
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
redis:
image: redis:7.0
host: 18.196.187.144
port: "172.31.40.79:6379:6379"
directories:
- data:/data
webdis:
image: nicolas/webdis:0.1.22
roles:
- web
env:
secret:
- REDIS_URL
host: 18.196.187.144
cmd: >
/bin/sh -c '
REDIS_HOST=$(echo $REDIS_URL | awk -F "[:/]" "{print \$4}") &&
sh -c "
wget -O /usr/local/bin/webdis.json https://github.com/nicolasff/webdis/raw/0.1.22/webdis.json &&
sed -i "s/\"redis_host\":.*/\"redis_host\": \"$REDIS_HOST\",/" /usr/local/bin/webdis.json &&
sed -i "s/\"logfile\":.*/\"logfile\": \"\/dev\/stderr\"/" /usr/local/bin/webdis.json &&
/usr/local/bin/webdis /usr/local/bin/webdis.json'
port: 7379
options:
network: "private"
awk '/\"redis_host\":/ {print \"\\t\\\"redis_host\\\": \\\"172.31.40.79\\\",\"; next} /\"logfile\":/ {print \"\\t\\\"logfile\\\": \\\"/dev/stderr\\\"\"; next} {print}' /usr/local/bin/webdis.json > /usr/local/bin/webdis_modified.json &&
mv /usr/local/bin/webdis_modified.json /usr/local/bin/webdis.json &&
/usr/local/bin/webdis /usr/local/bin/webdis.json"
port: "172.31.40.79:7379:7379"
directories:
- data:/data
pgbouncer:
image: edoburu/pgbouncer:latest
host: 18.196.187.144
port: "172.31.40.79:5432:5432"
env:
clear:
LISTEN_PORT: "5432"
POOL_MODE: "transaction"
MAX_CLIENT_CONN: "300"
DEFAULT_POOL_SIZE: "100"
AUTH_TYPE: "scram-sha-256"
secret:
- DB_USER
- DB_PASSWORD
- DB_HOST
- DB_NAME
# Configure custom arguments for Traefik
# traefik:
# args:
# accesslog: true
# accesslog.format: json
healthcheck:
path: /health
port: 3000
max_attempts: 15
interval: 20s
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
# asset_path: /rails/public/assets
# Configure rolling deploys by setting a wait time between batches of restarts.
# boot:
# limit: 10 # Can also specify as a percentage of total hosts, such as "25%"
# wait: 2
# Configure the role used to determine the primary_host. This host takes
# deploy locks, runs health checks during the deploy, and follow logs, etc.
#
# Caution: there's no support for role renaming yet, so be careful to cleanup
# the previous role on the deployed hosts.
# primary_role: web
# Controls if we abort when see a role with no hosts. Disabling this may be
# useful for more complex deploy configurations.
#
# allow_empty_roles: false

View File

@@ -6,35 +6,48 @@ async function main() {
await prisma.$transaction(
async (tx) => {
// get all the persons that have an attribute class with the name "userId"
const userIdAttributeClasses = await tx.attributeClass.findMany({
const personsWithUserIdAttribute = await tx.person.findMany({
where: {
name: "userId",
attributes: {
some: {
attributeClass: {
name: "userId",
},
},
},
},
include: {
attributes: {
include: { person: true },
include: { attributeClass: true },
},
},
});
for (let attributeClass of userIdAttributeClasses) {
for (let attribute of attributeClass.attributes) {
if (attribute.person.userId) {
continue;
}
await tx.person.update({
where: {
id: attribute.personId,
},
data: {
userId: attribute.value,
},
});
for (let person of personsWithUserIdAttribute) {
// If the person already has a userId, skip it
if (person.userId) {
continue;
}
}
console.log("Migrated userIds to the person table.");
const userIdAttributeValue = person.attributes.find((attribute) => {
if (attribute.attributeClass.name === "userId") {
return attribute;
}
});
if (!userIdAttributeValue) {
continue;
}
await tx.person.update({
where: {
id: person.id,
},
data: {
userId: userIdAttributeValue.value,
},
});
}
// Delete all attributeClasses with the name "userId"
await tx.attributeClass.deleteMany({
@@ -42,8 +55,6 @@ async function main() {
name: "userId",
},
});
console.log("Deleted attributeClasses with the name 'userId'.");
},
{
timeout: 60000 * 3, // 3 minutes

View File

@@ -1,144 +0,0 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.$transaction(
async (tx) => {
// Retrieve all surveys of type "web" with necessary fields for efficient processing
const webSurveys = await tx.survey.findMany({
where: { type: "web" },
select: {
id: true,
segment: {
select: {
id: true,
isPrivate: true,
},
},
},
});
const linkSurveysWithSegment = await tx.survey.findMany({
where: {
type: "link",
segmentId: {
not: null,
},
},
include: {
segment: true,
},
});
const updateOperations = [];
const segmentDeletionIds = [];
const surveyTitlesForDeletion = [];
if (webSurveys?.length > 0) {
for (const webSurvey of webSurveys) {
const latestResponse = await tx.response.findFirst({
where: { surveyId: webSurvey.id },
orderBy: { createdAt: "desc" },
select: { personId: true },
});
const newType = latestResponse?.personId ? "app" : "website";
updateOperations.push(
tx.survey.update({
where: { id: webSurvey.id },
data: { type: newType },
})
);
if (newType === "website") {
if (webSurvey.segment) {
if (webSurvey.segment.isPrivate) {
segmentDeletionIds.push(webSurvey.segment.id);
} else {
updateOperations.push(
tx.survey.update({
where: { id: webSurvey.id },
data: {
segment: { disconnect: true },
},
})
);
}
}
surveyTitlesForDeletion.push(webSurvey.id);
}
}
await Promise.all(updateOperations);
if (segmentDeletionIds.length > 0) {
await tx.segment.deleteMany({
where: {
id: { in: segmentDeletionIds },
},
});
}
if (surveyTitlesForDeletion.length > 0) {
await tx.segment.deleteMany({
where: {
title: { in: surveyTitlesForDeletion },
isPrivate: true,
},
});
}
}
if (linkSurveysWithSegment?.length > 0) {
const linkSurveySegmentDeletionIds = [];
const linkSurveySegmentUpdateOperations = [];
for (const linkSurvey of linkSurveysWithSegment) {
const { segment } = linkSurvey;
if (segment) {
linkSurveySegmentUpdateOperations.push(
tx.survey.update({
where: {
id: linkSurvey.id,
},
data: {
segment: {
disconnect: true,
},
},
})
);
if (segment.isPrivate) {
linkSurveySegmentDeletionIds.push(segment.id);
}
}
}
await Promise.all(linkSurveySegmentUpdateOperations);
if (linkSurveySegmentDeletionIds.length > 0) {
await tx.segment.deleteMany({
where: {
id: { in: linkSurveySegmentDeletionIds },
},
});
}
}
},
{
timeout: 50000,
}
);
}
main()
.catch((e: Error) => {
console.error("Error during migration: ", e.message);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,16 +0,0 @@
/*
Warnings:
- The values [email,mobile] on the enum `SurveyType` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "SurveyType_new" AS ENUM ('link', 'web', 'website', 'app');
ALTER TABLE "Survey" ALTER COLUMN "type" DROP DEFAULT;
ALTER TABLE "Survey" ALTER COLUMN "type" TYPE "SurveyType_new" USING ("type"::text::"SurveyType_new");
ALTER TYPE "SurveyType" RENAME TO "SurveyType_old";
ALTER TYPE "SurveyType_new" RENAME TO "SurveyType";
DROP TYPE "SurveyType_old";
ALTER TABLE "Survey" ALTER COLUMN "type" SET DEFAULT 'web';
COMMIT;

View File

@@ -26,7 +26,6 @@
"data-migration:v1.6": "ts-node ./data-migrations/20240207041922_advanced_targeting/data-migration.ts",
"data-migration:styling": "ts-node ./data-migrations/20240320090315_add_form_styling/data-migration.ts",
"data-migration:v1.7": "pnpm data-migration:mls && pnpm data-migration:styling",
"data-migration:website-surveys": "ts-node ./data-migrations/20240410111624_adds_website_and_inapp_survey/data-migration.ts",
"data-migration:mls": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration.ts",
"data-migration:mls-fix": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration-fix.ts",
"data-migration:mls-range-fix": "ts-node ./data-migrations/20240318050527_add_languages_and_survey_languages/data-migration-range-fix.ts",

View File

@@ -7,8 +7,7 @@ datasource db {
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-1.0.x"] // rhel-openssl-1.0.x is the target for AWS Lambda / Vercel
provider = "prisma-client-js"
}
// generator dbml {
@@ -240,10 +239,10 @@ model SurveyAttributeFilter {
}
enum SurveyType {
email
link
mobile
web
website
app
}
enum displayOptions {

View File

@@ -2,12 +2,12 @@
<script type="text/javascript">
!(function () {
var t = document.createElement("script");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/api/packages/website");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/api/packages/js-core");
var e = document.getElementsByTagName("script")[0];
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "clvc0nye3003bubfl568et5f8",
environmentId: "cluqpv56n00lbxl3f8xvytyog",
apiHost: "http://localhost:3000",
});
}, 500);

View File

@@ -19,23 +19,20 @@
"files": [
"dist"
],
"source": "src/index.ts",
"main": "dist/index.umd.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
"./app": {
"import": "./dist/app.js",
"require": "./dist/app.umd.cjs",
"types": "./dist/app.d.ts"
},
"./website": {
"import": "./dist/website.js",
"require": "./dist/website.umd.cjs",
"types": "./dist/website.d.ts"
".": {
"import": "./dist/index.js",
"require": "./dist/index.umd.cjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"dev": "vite build --watch --mode dev",
"build:app": "tsc && vite build --config app.vite.config.ts",
"build:website": "tsc && vite build --config website.vite.config.ts",
"build": "pnpm build:app && pnpm build:website",
"build": "tsc && vite build",
"build:dev": "tsc && vite build --mode dev",
"go": "vite build --watch --mode dev",
"lint": "eslint ./src --fix",

View File

@@ -1,65 +0,0 @@
import { TJsAppConfigInput } from "@formbricks/types/js";
import { CommandQueue } from "../shared/commandQueue";
import { ErrorHandler } from "../shared/errors";
import { Logger } from "../shared/logger";
import { trackAction } from "./lib/actions";
import { getApi } from "./lib/api";
import { initialize } from "./lib/initialize";
import { checkPageUrl } from "./lib/noCodeActions";
import { logoutPerson, resetPerson, setPersonAttribute } from "./lib/person";
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = async (initConfig: TJsAppConfigInput) => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, "app", initialize, initConfig);
await queue.wait();
};
const setEmail = async (email: string): Promise<void> => {
setAttribute("email", email);
await queue.wait();
};
const setAttribute = async (key: string, value: any): Promise<void> => {
queue.add(true, "app", setPersonAttribute, key, value);
await queue.wait();
};
const logout = async (): Promise<void> => {
queue.add(true, "app", logoutPerson);
await queue.wait();
};
const reset = async (): Promise<void> => {
queue.add(true, "app", resetPerson);
await queue.wait();
};
const track = async (name: string, properties: any = {}): Promise<void> => {
queue.add<any>(true, "app", trackAction, name, properties);
await queue.wait();
};
const registerRouteChange = async (): Promise<void> => {
queue.add(true, "app", checkPageUrl);
await queue.wait();
};
const formbricks = {
init,
setEmail,
setAttribute,
track,
logout,
reset,
registerRouteChange,
getApi,
};
export type TFormbricksApp = typeof formbricks;
export default formbricks as TFormbricksApp;

View File

@@ -1,221 +0,0 @@
import type { TActionClass } from "@formbricks/types/actionClasses";
import type { TActionClassPageUrlRule } from "@formbricks/types/actionClasses";
import { TSurveyInlineTriggers } from "@formbricks/types/surveys";
import {
ErrorHandler,
InvalidMatchTypeError,
NetworkError,
Result,
err,
match,
ok,
okVoid,
} from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { trackAction } from "./actions";
import { AppConfig } from "./config";
import { triggerSurvey } from "./widget";
const inAppConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
logger.debug(`Checking page url: ${window.location.href}`);
const { state } = inAppConfig.get();
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
const { innerHtml, cssSelector, pageUrl } = action.noCodeConfig || {};
return pageUrl && !innerHtml && !cssSelector;
});
const surveysWithInlineTriggers = surveys.filter((survey) => {
const { pageUrl, cssSelector, innerHtml } = survey.inlineTriggers?.noCodeConfig || {};
return pageUrl && !cssSelector && !innerHtml;
});
if (actionsWithPageUrl.length > 0) {
for (const event of actionsWithPageUrl) {
if (!event.noCodeConfig?.pageUrl) {
continue;
}
const {
noCodeConfig: { pageUrl },
} = event;
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
if (match.ok !== true) return err(match.error);
if (match.value === false) continue;
const trackResult = await trackAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
}
}
if (surveysWithInlineTriggers.length > 0) {
surveysWithInlineTriggers.forEach((survey) => {
const { noCodeConfig } = survey.inlineTriggers ?? {};
const { pageUrl } = noCodeConfig ?? {};
if (pageUrl) {
const match = checkUrlMatch(window.location.href, pageUrl.value, pageUrl.rule);
if (match.ok !== true) return err(match.error);
if (match.value === false) return;
triggerSurvey(survey);
}
});
}
return okVoid();
};
let arePageUrlEventListenersAdded = false;
const checkPageUrlWrapper = () => checkPageUrl();
const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
events.forEach((event) => window.addEventListener(event, checkPageUrlWrapper));
arePageUrlEventListenersAdded = true;
};
export const removePageUrlEventListeners = (): void => {
if (typeof window === "undefined" || !arePageUrlEventListenersAdded) return;
events.forEach((event) => window.removeEventListener(event, checkPageUrlWrapper));
arePageUrlEventListenersAdded = false;
};
export function checkUrlMatch(
url: string,
pageUrlValue: string,
pageUrlRule: TActionClassPageUrlRule
): Result<boolean, InvalidMatchTypeError> {
switch (pageUrlRule) {
case "exactMatch":
return ok(url === pageUrlValue);
case "contains":
return ok(url.includes(pageUrlValue));
case "startsWith":
return ok(url.startsWith(pageUrlValue));
case "endsWith":
return ok(url.endsWith(pageUrlValue));
case "notMatch":
return ok(url !== pageUrlValue);
case "notContains":
return ok(!url.includes(pageUrlValue));
default:
return err({
code: "invalid_match_type",
message: "Invalid match type",
});
}
}
const evaluateNoCodeConfig = (
targetElement: HTMLElement,
action: TActionClass | TSurveyInlineTriggers
): boolean => {
const innerHtml = action.noCodeConfig?.innerHtml?.value;
const cssSelectors = action.noCodeConfig?.cssSelector?.value;
const pageUrl = action.noCodeConfig?.pageUrl?.value;
const pageUrlRule = action.noCodeConfig?.pageUrl?.rule;
if (!innerHtml && !cssSelectors && !pageUrl) {
return false;
}
if (innerHtml && targetElement.innerHTML !== innerHtml) {
return false;
}
if (cssSelectors) {
// Split selectors that start with a . or # including the . or #
const individualSelectors = cssSelectors.split(/\s*(?=[.#])/);
for (let selector of individualSelectors) {
if (!targetElement.matches(selector)) {
return false;
}
}
}
if (pageUrl && pageUrlRule) {
const urlMatch = checkUrlMatch(window.location.href, pageUrl, pageUrlRule);
if (!urlMatch.ok || !urlMatch.value) {
return false;
}
}
return true;
};
export const checkClickMatch = (event: MouseEvent) => {
const { state } = inAppConfig.get();
if (!state) {
return;
}
const { noCodeActionClasses } = state;
if (!noCodeActionClasses) {
return;
}
const targetElement = event.target as HTMLElement;
noCodeActionClasses.forEach((action: TActionClass) => {
const isMatch = evaluateNoCodeConfig(targetElement, action);
if (isMatch) {
trackAction(action.name).then((res) => {
match(
res,
(_value: unknown) => {},
(err: any) => {
errorHandler.handle(err);
}
);
});
}
});
// check for the inline triggers as well
const activeSurveys = state.surveys;
if (!activeSurveys || activeSurveys.length === 0) {
return;
}
activeSurveys.forEach((survey) => {
const { inlineTriggers } = survey;
if (inlineTriggers) {
const isMatch = evaluateNoCodeConfig(targetElement, inlineTriggers);
if (isMatch) {
triggerSurvey(survey);
}
}
});
};
let isClickEventListenerAdded = false;
const checkClickMatchWrapper = (e: MouseEvent) => checkClickMatch(e);
export const addClickEventListener = (): void => {
if (typeof window === "undefined" || isClickEventListenerAdded) return;
document.addEventListener("click", checkClickMatchWrapper);
isClickEventListenerAdded = true;
};
export const removeClickEventListener = (): void => {
if (!isClickEventListenerAdded) return;
document.removeEventListener("click", checkClickMatchWrapper);
isClickEventListenerAdded = false;
};

View File

@@ -1,114 +0,0 @@
import { TJsAppState, TJsAppStateSync, TJsAppSyncParams } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
import { NetworkError, Result, err, ok } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { AppConfig } from "./config";
const config = AppConfig.getInstance();
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
const syncWithBackend = async (
{ apiHost, environmentId, userId }: TJsAppSyncParams,
noCache: boolean
): Promise<Result<TJsAppStateSync, NetworkError>> => {
try {
let fetchOptions: RequestInit = {};
if (noCache || getIsDebug()) {
fetchOptions.cache = "no-cache";
logger.debug("No cache option set for sync");
}
const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}?version=${import.meta.env.VERSION}`;
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
const data = await response.json();
const { data: state } = data;
return ok(state as TJsAppStateSync);
} catch (e) {
return err(e as NetworkError);
}
};
export const sync = async (params: TJsAppSyncParams, noCache = false): Promise<void> => {
try {
const syncResult = await syncWithBackend(params, noCache);
if (syncResult?.ok !== true) {
throw syncResult.error;
}
let state: TJsAppState = {
surveys: syncResult.value.surveys as TSurvey[],
noCodeActionClasses: syncResult.value.noCodeActionClasses,
product: syncResult.value.product,
attributes: syncResult.value.person?.attributes || {},
};
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
config.update({
apiHost: params.apiHost,
environmentId: params.environmentId,
userId: params.userId,
state,
expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future
});
} catch (error) {
console.error(`Error during sync: ${error}`);
throw error;
}
};
export const addExpiryCheckListener = (): void => {
const updateInterval = 1000 * 30; // every 30 seconds
// add event listener to check sync with backend on regular interval
if (typeof window !== "undefined" && syncIntervalId === null) {
syncIntervalId = window.setInterval(async () => {
try {
// check if the config has not expired yet
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
});
} catch (e) {
console.error(`Error during expiry check: ${e}`);
logger.debug("Extending config and try again later.");
const existingConfig = config.get();
config.update(existingConfig);
}
}, updateInterval);
}
};
export const removeExpiryCheckListener = (): void => {
if (typeof window !== "undefined" && syncIntervalId !== null) {
window.clearInterval(syncIntervalId);
syncIntervalId = null;
}
};

View File

@@ -0,0 +1,81 @@
import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbricksSurveys";
import { TJsConfigInput } from "@formbricks/types/js";
import { trackAction } from "./lib/actions";
import { getApi } from "./lib/api";
import { CommandQueue } from "./lib/commandQueue";
import { ErrorHandler } from "./lib/errors";
import { initialize } from "./lib/initialize";
import { Logger } from "./lib/logger";
import { checkPageUrl } from "./lib/noCodeActions";
import { logoutPerson, resetPerson, setPersonAttribute, setPersonUserId } from "./lib/person";
declare global {
interface Window {
formbricksSurveys: {
renderSurveyInline: (props: SurveyInlineProps) => void;
renderSurveyModal: (props: SurveyModalProps) => void;
};
}
}
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = async (initConfig: TJsConfigInput) => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, initialize, initConfig);
await queue.wait();
};
const setUserId = async (): Promise<void> => {
queue.add(true, setPersonUserId);
await queue.wait();
};
const setEmail = async (email: string): Promise<void> => {
setAttribute("email", email);
await queue.wait();
};
const setAttribute = async (key: string, value: any): Promise<void> => {
queue.add(true, setPersonAttribute, key, value);
await queue.wait();
};
const logout = async (): Promise<void> => {
queue.add(true, logoutPerson);
await queue.wait();
};
const reset = async (): Promise<void> => {
queue.add(true, resetPerson);
await queue.wait();
};
const track = async (name: string, properties: any = {}): Promise<void> => {
queue.add<any>(true, trackAction, name, properties);
await queue.wait();
};
const registerRouteChange = async (): Promise<void> => {
queue.add(true, checkPageUrl);
await queue.wait();
};
const formbricks = {
init,
setUserId,
setEmail,
setAttribute,
track,
logout,
reset,
registerRouteChange,
getApi,
};
export type FormbricksType = typeof formbricks;
export default formbricks as FormbricksType;

View File

@@ -1,15 +1,15 @@
import { FormbricksAPI } from "@formbricks/api";
import { TJsActionInput } from "@formbricks/types/js";
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { AppConfig } from "./config";
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { sync } from "./sync";
import { getIsDebug } from "./utils";
import { triggerSurvey } from "./widget";
const logger = Logger.getInstance();
const inAppConfig = AppConfig.getInstance();
const config = Config.getInstance();
const intentsToNotCreateOnApp = ["Exit Intent (Desktop)", "50% Scroll"];
@@ -17,7 +17,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
const {
userId,
state: { surveys = [] },
} = inAppConfig.get();
} = config.get();
// if surveys have a inline triggers, we need to check the name of the action in the code action config
surveys.forEach(async (survey) => {
@@ -31,7 +31,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
});
const input: TJsActionInput = {
environmentId: inAppConfig.get().environmentId,
environmentId: config.get().environmentId,
userId,
name,
};
@@ -41,8 +41,8 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
logger.debug(`Sending action "${name}" to backend`);
const api = new FormbricksAPI({
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.action.create({
...input,
@@ -54,7 +54,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
code: "network_error",
message: `Error tracking action ${name}`,
status: 500,
url: `${inAppConfig.get().apiHost}/api/v1/client/${inAppConfig.get().environmentId}/actions`,
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`,
responseMessage: res.error.message,
});
}
@@ -65,8 +65,8 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
if (getIsDebug()) {
await sync(
{
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId,
},
true
@@ -77,7 +77,7 @@ export const trackAction = async (name: string): Promise<Result<void, NetworkErr
logger.debug(`Formbricks: Action "${name}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = inAppConfig.get().state?.surveys;
const activeSurveys = config.get().state?.surveys;
if (!!activeSurveys && activeSurveys.length > 0) {
for (const survey of activeSurveys) {

View File

@@ -1,10 +1,10 @@
import { FormbricksAPI } from "@formbricks/api";
import { AppConfig } from "./config";
import { Config } from "./config";
export const getApi = (): FormbricksAPI => {
const inAppConfig = AppConfig.getInstance();
const { environmentId, apiHost } = inAppConfig.get();
const config = Config.getInstance();
const { environmentId, apiHost } = config.get();
if (!environmentId || !apiHost) {
throw new Error("formbricks.init() must be called before getApi()");

View File

@@ -0,0 +1,61 @@
import { trackAction } from "./actions";
import { err } from "./errors";
let exitIntentListenerAdded = false;
let exitIntentListenerWrapper = async function (e: MouseEvent) {
if (e.clientY <= 0) {
const trackResult = await trackAction("Exit Intent (Desktop)");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined" && !exitIntentListenerAdded) {
document.querySelector("body")!.addEventListener("mouseleave", exitIntentListenerWrapper);
exitIntentListenerAdded = true;
}
};
export const removeExitIntentListener = (): void => {
if (exitIntentListenerAdded) {
document.removeEventListener("mouseleave", exitIntentListenerWrapper);
exitIntentListenerAdded = false;
}
};
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
let scrollDepthListenerWrapper = async () => {
const scrollPosition = window.scrollY;
const windowSize = window.innerHeight;
const bodyHeight = document.documentElement.scrollHeight;
if (scrollPosition === 0) {
scrollDepthTriggered = false;
}
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
scrollDepthTriggered = true;
const trackResult = await trackAction("50% Scroll");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
window.addEventListener("load", () => {
window.addEventListener("scroll", scrollDepthListenerWrapper);
});
scrollDepthListenerAdded = true;
}
};
export const removeScrollDepthListener = (): void => {
if (scrollDepthListenerAdded) {
window.removeEventListener("scroll", scrollDepthListenerWrapper);
scrollDepthListenerAdded = false;
}
};

View File

@@ -1,14 +1,11 @@
import { wrapThrowsAsync } from "@formbricks/types/errorHandlers";
import { TJsPackageType } from "@formbricks/types/js";
import { checkInitialized as checkInitializedInApp } from "../app/lib/initialize";
import { checkInitialized as checkInitializedWebsite } from "../website/lib/initialize";
import { ErrorHandler, Result } from "./errors";
import { checkInitialized } from "./initialize";
export class CommandQueue {
private queue: {
command: (args: any) => Promise<Result<void, any>> | Result<void, any> | Promise<void>;
packageType: TJsPackageType;
checkInitialized: boolean;
commandArgs: any[any];
}[] = [];
@@ -18,11 +15,10 @@ export class CommandQueue {
public add<A>(
checkInitialized: boolean = true,
packageType: TJsPackageType,
command: (...args: A[]) => Promise<Result<void, any>> | Result<void, any> | Promise<void>,
...args: A[]
) {
this.queue.push({ command, checkInitialized, commandArgs: args, packageType });
this.queue.push({ command, checkInitialized, commandArgs: args });
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
@@ -48,9 +44,7 @@ export class CommandQueue {
// make sure formbricks is initialized
if (currentItem.checkInitialized) {
// call different function based on package type
const initResult =
currentItem.packageType === "website" ? checkInitializedWebsite() : checkInitializedInApp();
const initResult = checkInitialized();
if (initResult && initResult.ok !== true) {
errorHandler.handle(initResult.error);

View File

@@ -1,12 +1,12 @@
import { TJSAppConfig, TJsAppConfigUpdateInput } from "@formbricks/types/js";
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
import { Result, err, ok, wrapThrows } from "../../shared/errors";
import { Result, err, ok, wrapThrows } from "./errors";
export const IN_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
export const LOCAL_STORAGE_KEY = "formbricks-js";
export class AppConfig {
private static instance: AppConfig | undefined;
private config: TJSAppConfig | null = null;
export class Config {
private static instance: Config | undefined;
private config: TJsConfig | null = null;
private constructor() {
const localConfig = this.loadFromLocalStorage();
@@ -16,14 +16,14 @@ export class AppConfig {
}
}
static getInstance(): AppConfig {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
static getInstance(): Config {
if (!Config.instance) {
Config.instance = new Config();
}
return AppConfig.instance;
return Config.instance;
}
public update(newConfig: TJsAppConfigUpdateInput): void {
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
this.config = {
...this.config,
@@ -35,28 +35,28 @@ export class AppConfig {
}
}
public get(): TJSAppConfig {
public get(): TJsConfig {
if (!this.config) {
throw new Error("config is null, maybe the init function was not called?");
}
return this.config;
}
public loadFromLocalStorage(): Result<TJSAppConfig, Error> {
public loadFromLocalStorage(): Result<TJsConfig, Error> {
if (typeof window !== "undefined") {
const savedConfig = localStorage.getItem(IN_APP_LOCAL_STORAGE_KEY);
const savedConfig = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedConfig) {
// TODO: validate config
// This is a hack to get around the fact that we don't have a proper
// way to validate the config yet.
const parsedConfig = JSON.parse(savedConfig) as TJSAppConfig;
const parsedConfig = JSON.parse(savedConfig) as TJsConfig;
// check if the config has expired
if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) {
return err(new Error("Config in local storage has expired"));
}
return ok(JSON.parse(savedConfig) as TJSAppConfig);
return ok(JSON.parse(savedConfig) as TJsConfig);
}
}
@@ -64,7 +64,7 @@ export class AppConfig {
}
private saveToLocalStorage(): Result<void, Error> {
return wrapThrows(() => localStorage.setItem(IN_APP_LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
return wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
}
// reset the config
@@ -72,6 +72,6 @@ export class AppConfig {
public resetConfig(): Result<void, Error> {
this.config = null;
return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))();
return wrapThrows(() => localStorage.removeItem(LOCAL_STORAGE_KEY))();
}
}

View File

@@ -3,7 +3,7 @@ import {
addScrollDepthListener,
removeExitIntentListener,
removeScrollDepthListener,
} from "../../shared/automaticActions";
} from "./automaticActions";
import {
addClickEventListener,
addPageUrlEventListeners,
@@ -18,8 +18,8 @@ export const addEventListeners = (): void => {
addExpiryCheckListener();
addPageUrlEventListeners();
addClickEventListener();
addExitIntentListener("app");
addScrollDepthListener("app");
addExitIntentListener();
addScrollDepthListener();
};
export const addCleanupEventListeners = (): void => {
@@ -28,8 +28,8 @@ export const addCleanupEventListeners = (): void => {
removeExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener("app");
removeScrollDepthListener("app");
removeExitIntentListener();
removeScrollDepthListener();
});
areRemoveEventListenersAdded = true;
};
@@ -40,8 +40,8 @@ export const removeCleanupEventListeners = (): void => {
removeExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener("app");
removeScrollDepthListener("app");
removeExitIntentListener();
removeScrollDepthListener();
});
areRemoveEventListenersAdded = false;
};
@@ -50,7 +50,7 @@ export const removeAllEventListeners = (): void => {
removeExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener("app");
removeScrollDepthListener("app");
removeExitIntentListener();
removeScrollDepthListener();
removeCleanupEventListeners();
};

View File

@@ -1,6 +1,8 @@
import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js";
import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
import { TPersonAttributes } from "@formbricks/types/people";
import { trackAction } from "./actions";
import { Config, LOCAL_STORAGE_KEY } from "./config";
import {
ErrorHandler,
MissingFieldError,
@@ -11,18 +13,16 @@ import {
err,
okVoid,
wrapThrows,
} from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { trackAction } from "./actions";
import { AppConfig, IN_APP_LOCAL_STORAGE_KEY } from "./config";
} from "./errors";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { Logger } from "./logger";
import { checkPageUrl } from "./noCodeActions";
import { updatePersonAttributes } from "./person";
import { sync } from "./sync";
import { getIsDebug } from "./utils";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const inAppConfig = AppConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
let isInitialized = false;
@@ -32,7 +32,7 @@ export const setIsInitialized = (value: boolean) => {
};
export const initialize = async (
configInput: TJsAppConfigInput
c: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (getIsDebug()) {
logger.configure({ logLevel: "debug" });
@@ -43,9 +43,9 @@ export const initialize = async (
return okVoid();
}
let existingConfig: TJSAppConfig | undefined;
let existingConfig: TJsConfig | undefined;
try {
existingConfig = inAppConfig.get();
existingConfig = config.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
@@ -66,7 +66,7 @@ export const initialize = async (
logger.debug("Start initialize");
if (!configInput.environmentId) {
if (!c.environmentId) {
logger.debug("No environmentId provided");
return err({
code: "missing_field",
@@ -74,7 +74,7 @@ export const initialize = async (
});
}
if (!configInput.apiHost) {
if (!c.apiHost) {
logger.debug("No apiHost provided");
return err({
@@ -83,32 +83,18 @@ export const initialize = async (
});
}
if (!configInput.userId) {
logger.debug("No userId provided");
return err({
code: "missing_field",
field: "userId",
});
}
logger.debug("Adding widget container to DOM");
addWidgetContainer();
let updatedAttributes: TPersonAttributes | null = null;
if (configInput.attributes) {
if (!configInput.userId) {
if (c.attributes) {
if (!c.userId) {
// Allow setting attributes for unidentified users
updatedAttributes = { ...configInput.attributes };
updatedAttributes = { ...c.attributes };
}
// If userId is available, update attributes in backend
else {
const res = await updatePersonAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
const res = await updatePersonAttributes(c.apiHost, c.environmentId, c.userId, c.attributes);
if (res.ok !== true) {
return err(res.error);
}
@@ -119,9 +105,9 @@ export const initialize = async (
if (
existingConfig &&
existingConfig.state &&
existingConfig.environmentId === configInput.environmentId &&
existingConfig.apiHost === configInput.apiHost &&
existingConfig.userId === configInput.userId &&
existingConfig.environmentId === c.environmentId &&
existingConfig.apiHost === c.apiHost &&
existingConfig.userId === c.userId &&
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
) {
logger.debug("Configuration fits init parameters.");
@@ -130,29 +116,29 @@ export const initialize = async (
try {
await sync({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
putFormbricksInErrorState();
}
} else {
logger.debug("Configuration not expired. Extending expiration.");
inAppConfig.update(existingConfig);
config.update(existingConfig);
}
} else {
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
inAppConfig.resetConfig();
config.resetConfig();
logger.debug("Syncing.");
try {
await sync({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
});
} catch (e) {
handleErrorOnFirstInit();
@@ -162,15 +148,15 @@ export const initialize = async (
}
// update attributes in config
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
inAppConfig.update({
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId: inAppConfig.get().userId,
config.update({
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().userId,
state: {
...inAppConfig.get().state,
attributes: { ...inAppConfig.get().state.attributes, ...configInput.attributes },
...config.get().state,
attributes: { ...config.get().state.attributes, ...c.attributes },
},
expiresAt: inAppConfig.get().expiresAt,
expiresAt: config.get().expiresAt,
});
}
@@ -189,12 +175,12 @@ export const initialize = async (
const handleErrorOnFirstInit = () => {
// put formbricks in error state (by creating a new config) and throw error
const initialErrorConfig: Partial<TJSAppConfig> = {
const initialErrorConfig: Partial<TJsConfig> = {
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
};
// can't use config.update here because the config is not yet initialized
wrapThrows(() => localStorage.setItem(IN_APP_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
throw new Error("Could not initialize formbricks");
};
@@ -221,8 +207,8 @@ export const deinitalize = (): void => {
export const putFormbricksInErrorState = (): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
inAppConfig.update({
...inAppConfig.get(),
config.update({
...config.get(),
status: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
});

View File

@@ -2,28 +2,19 @@ import type { TActionClass } from "@formbricks/types/actionClasses";
import type { TActionClassPageUrlRule } from "@formbricks/types/actionClasses";
import { TSurveyInlineTriggers } from "@formbricks/types/surveys";
import {
ErrorHandler,
InvalidMatchTypeError,
NetworkError,
Result,
err,
match,
ok,
okVoid,
} from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { trackAction } from "./actions";
import { WebsiteConfig } from "./config";
import { Config } from "./config";
import { ErrorHandler, InvalidMatchTypeError, NetworkError, Result, err, match, ok, okVoid } from "./errors";
import { Logger } from "./logger";
import { triggerSurvey } from "./widget";
const websiteConfig = WebsiteConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
export const checkPageUrl = async (): Promise<Result<void, InvalidMatchTypeError | NetworkError>> => {
logger.debug(`Checking page url: ${window.location.href}`);
const { state } = websiteConfig.get();
const { state } = config.get();
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
@@ -157,7 +148,7 @@ const evaluateNoCodeConfig = (
};
export const checkClickMatch = (event: MouseEvent) => {
const { state } = websiteConfig.get();
const { state } = config.get();
if (!state) {
return;
}

View File

@@ -1,20 +1,49 @@
import { FormbricksAPI } from "@formbricks/api";
import { TPersonAttributes, TPersonUpdateInput } from "@formbricks/types/people";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { AppConfig } from "./config";
import { Config } from "./config";
import {
AttributeAlreadyExistsError,
MissingPersonError,
NetworkError,
Result,
err,
ok,
okVoid,
} from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { closeSurvey } from "./widget";
const inAppConfig = AppConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
export const updatePersonAttribute = async (
key: string,
value: string
): Promise<Result<void, NetworkError | MissingPersonError>> => {
const { apiHost, environmentId, userId } = inAppConfig.get();
const { apiHost, environmentId, userId } = config.get();
if (!userId) {
const previousConfig = config.get();
if (key === "language") {
config.update({
...previousConfig,
state: {
...previousConfig.state,
attributes: {
...previousConfig.state.attributes,
language: value,
},
},
});
return okVoid();
}
return err({
code: "missing_person",
message: "Unable to update attribute. User identification deactivated. No userId set.",
});
}
const input: TPersonUpdateInput = {
attributes: {
@@ -34,7 +63,7 @@ export const updatePersonAttribute = async (
code: "network_error",
status: 500,
message: `Error updating person with userId ${userId}`,
url: `${inAppConfig.get().apiHost}/api/v1/client/${environmentId}/people/${userId}`,
url: `${config.get().apiHost}/api/v1/client/${environmentId}/people/${userId}`,
responseMessage: res.error.message,
});
}
@@ -56,7 +85,7 @@ export const updatePersonAttributes = async (
const updatedAttributes = { ...attributes };
try {
const existingAttributes = inAppConfig.get()?.state?.attributes;
const existingAttributes = config.get()?.state?.attributes;
if (existingAttributes) {
for (const [key, value] of Object.entries(existingAttributes)) {
if (updatedAttributes[key] === value) {
@@ -101,21 +130,23 @@ export const updatePersonAttributes = async (
};
export const isExistingAttribute = (key: string, value: string): boolean => {
if (inAppConfig.get().state.attributes[key] === value) {
if (config.get().state.attributes[key] === value) {
return true;
}
return false;
};
export const setPersonUserId = async (): Promise<
Result<void, NetworkError | MissingPersonError | AttributeAlreadyExistsError>
> => {
console.error("'setUserId' is no longer supported. Please set the userId in the init call instead.");
return okVoid();
};
export const setPersonAttribute = async (
key: string,
value: any
): Promise<Result<void, NetworkError | MissingPersonError>> => {
if (key === "userId") {
logger.error("Setting userId is no longer supported. Please set the userId in the init call instead.");
return okVoid();
}
logger.debug("Setting attribute: " + key + " to value: " + value);
// check if attribute already exists with this value
if (isExistingAttribute(key, value.toString())) {
@@ -127,18 +158,18 @@ export const setPersonAttribute = async (
if (result.ok) {
// udpdate attribute in config
inAppConfig.update({
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId: inAppConfig.get().userId,
config.update({
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().userId,
state: {
...inAppConfig.get().state,
...config.get().state,
attributes: {
...inAppConfig.get().state.attributes,
...config.get().state.attributes,
[key]: value.toString(),
},
},
expiresAt: inAppConfig.get().expiresAt,
expiresAt: config.get().expiresAt,
});
return okVoid();
}
@@ -148,17 +179,17 @@ export const setPersonAttribute = async (
export const logoutPerson = async (): Promise<void> => {
deinitalize();
inAppConfig.resetConfig();
config.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const syncParams = {
environmentId: inAppConfig.get().environmentId,
apiHost: inAppConfig.get().apiHost,
userId: inAppConfig.get().userId,
attributes: inAppConfig.get().state.attributes,
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().userId,
attributes: config.get().state.attributes,
};
await logoutPerson();
try {

View File

@@ -1,23 +1,23 @@
import { diffInDays } from "@formbricks/lib/utils/datetime";
import { TJsWebsiteState, TJsWebsiteSyncParams } from "@formbricks/types/js";
import { TJsState, TJsStateSync, TJsSyncParams } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
import { NetworkError, Result, err, ok } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { WebsiteConfig } from "./config";
import { Config } from "./config";
import { NetworkError, Result, err, ok } from "./errors";
import { Logger } from "./logger";
import { getIsDebug } from "./utils";
const websiteConfig = WebsiteConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
let syncIntervalId: number | null = null;
const syncWithBackend = async (
{ apiHost, environmentId }: TJsWebsiteSyncParams,
{ apiHost, environmentId, userId }: TJsSyncParams,
noCache: boolean
): Promise<Result<TJsWebsiteState, NetworkError>> => {
): Promise<Result<TJsStateSync, NetworkError>> => {
try {
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/website/sync`;
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
const urlSuffix = `?version=${import.meta.env.VERSION}`;
let fetchOptions: RequestInit = {};
@@ -28,8 +28,30 @@ const syncWithBackend = async (
}
// if user id is not available
const url = baseUrl + urlSuffix;
// public survey
if (!userId) {
const url = baseUrl + urlSuffix;
// public survey
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const jsonRes = await response.json();
return err({
code: "network_error",
status: response.status,
message: "Error syncing with backend",
url,
responseMessage: jsonRes.message,
});
}
return ok((await response.json()).data as TJsState);
}
// userId is available, call the api with the `userId` param
const url = `${baseUrl}/${userId}${urlSuffix}`;
const response = await fetch(url, fetchOptions);
if (!response.ok) {
@@ -44,13 +66,16 @@ const syncWithBackend = async (
});
}
return ok((await response.json()).data as TJsWebsiteState);
const data = await response.json();
const { data: state } = data;
return ok(state as TJsStateSync);
} catch (e) {
return err(e as NetworkError);
}
};
export const sync = async (params: TJsWebsiteSyncParams, noCache = false): Promise<void> => {
export const sync = async (params: TJsSyncParams, noCache = false): Promise<void> => {
try {
const syncResult = await syncWithBackend(params, noCache);
@@ -58,28 +83,39 @@ export const sync = async (params: TJsWebsiteSyncParams, noCache = false): Promi
throw syncResult.error;
}
let oldState: TJsWebsiteState | undefined;
let oldState: TJsState | undefined;
try {
oldState = websiteConfig.get().state;
oldState = config.get().state;
} catch (e) {
// ignore error
}
let state: TJsWebsiteState = {
let state: TJsState = {
surveys: syncResult.value.surveys as TSurvey[],
noCodeActionClasses: syncResult.value.noCodeActionClasses,
product: syncResult.value.product,
displays: oldState?.displays || [],
attributes: syncResult.value.person?.attributes || {},
};
state = filterPublicSurveys(state);
if (!params.userId) {
// unidentified user
// set the displays and filter out surveys
state = {
...state,
displays: oldState?.displays || [],
};
state = filterPublicSurveys(state);
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
} else {
const surveyNames = state.surveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
}
websiteConfig.update({
config.update({
apiHost: params.apiHost,
environmentId: params.environmentId,
userId: params.userId,
state,
expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future
});
@@ -89,7 +125,7 @@ export const sync = async (params: TJsWebsiteSyncParams, noCache = false): Promi
}
};
export const filterPublicSurveys = (state: TJsWebsiteState): TJsWebsiteState => {
export const filterPublicSurveys = (state: TJsState): TJsState => {
const { displays, product } = state;
let { surveys } = state;
@@ -143,19 +179,20 @@ export const addExpiryCheckListener = (): void => {
syncIntervalId = window.setInterval(async () => {
try {
// check if the config has not expired yet
if (websiteConfig.get().expiresAt && new Date(websiteConfig.get().expiresAt) >= new Date()) {
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
return;
}
logger.debug("Config has expired. Starting sync.");
await sync({
apiHost: websiteConfig.get().apiHost,
environmentId: websiteConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
});
} catch (e) {
console.error(`Error during expiry check: ${e}`);
logger.debug("Extending config and try again later.");
const existingConfig = websiteConfig.get();
websiteConfig.update(existingConfig);
const existingConfig = config.get();
config.update(existingConfig);
}
}, updateInterval);
}

View File

@@ -1,19 +1,20 @@
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import SurveyState from "@formbricks/lib/surveyState";
import { TJSStateDisplay } from "@formbricks/types/js";
import { TResponseUpdate } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { ErrorHandler } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
import { AppConfig } from "./config";
import { Config } from "./config";
import { ErrorHandler } from "./errors";
import { putFormbricksInErrorState } from "./initialize";
import { sync } from "./sync";
import { Logger } from "./logger";
import { filterPublicSurveys, sync } from "./sync";
import { getDefaultLanguageCode, getLanguageCode } from "./utils";
const containerId = "formbricks-app-container";
const containerId = "formbricks-web-container";
const inAppConfig = AppConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let isSurveyRunning = false;
@@ -52,8 +53,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
}
const product = inAppConfig.get().state.product;
const attributes = inAppConfig.get().state.attributes;
const product = config.get().state.product;
const attributes = config.get().state.attributes;
const isMultiLanguageSurvey = survey.languages.length > 1;
let languageCode = "default";
@@ -69,12 +70,12 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
languageCode = displayLanguage;
}
const surveyState = new SurveyState(survey.id, null, null, inAppConfig.get().userId);
const surveyState = new SurveyState(survey.id, null, null, config.get().userId);
const responseQueue = new ResponseQueue(
{
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
retryAttempts: 2,
onResponseSendingFailed: () => {
setIsError(true);
@@ -128,31 +129,70 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
setIsResponseSendingFinished = f;
},
onDisplay: async () => {
const { userId } = inAppConfig.get();
const { userId } = config.get();
// if config does not have a person, we store the displays in local storage
if (!userId) {
const localDisplay: TJSStateDisplay = {
createdAt: new Date(),
surveyId: survey.id,
responded: false,
};
const existingDisplays = config.get().state.displays;
const displays = existingDisplays ? [...existingDisplays, localDisplay] : [localDisplay];
const previousConfig = config.get();
let state = filterPublicSurveys({
...previousConfig.state,
displays,
});
config.update({
...previousConfig,
state,
});
}
const api = new FormbricksAPI({
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.display.create({
surveyId: survey.id,
userId,
});
if (!res.ok) {
throw new Error("Could not create display");
}
const { id } = res.data;
surveyState.updateDisplayId(id);
responseQueue.updateSurveyState(surveyState);
},
onResponse: (responseUpdate: TResponseUpdate) => {
const { userId } = inAppConfig.get();
surveyState.updateUserId(userId);
const { userId } = config.get();
// if user is unidentified, update the display in local storage if not already updated
if (!userId) {
const displays = config.get().state.displays;
const lastDisplay = displays && displays[displays.length - 1];
if (!lastDisplay) {
throw new Error("No lastDisplay found");
}
if (!lastDisplay.responded) {
lastDisplay.responded = true;
const previousConfig = config.get();
let state = filterPublicSurveys({
...previousConfig.state,
displays,
});
config.update({
...previousConfig,
state,
});
}
}
if (userId) {
surveyState.updateUserId(userId);
}
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
data: responseUpdate.data,
@@ -168,8 +208,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
onClose: closeSurvey,
onFileUpload: async (file: File, params) => {
const api = new FormbricksAPI({
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
return await api.client.storage.uploadFile(file, params);
@@ -187,13 +227,25 @@ export const closeSurvey = async (): Promise<void> => {
removeWidgetContainer();
addWidgetContainer();
// if unidentified user, refilter the surveys
if (!config.get().userId) {
const state = config.get().state;
const updatedState = filterPublicSurveys(state);
config.update({
...config.get(),
state: updatedState,
});
setIsSurveyRunning(false);
return;
}
// for identified users we sync to get the latest surveys
try {
await sync(
{
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
userId: inAppConfig.get().userId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId: config.get().userId,
},
true
);
@@ -220,7 +272,7 @@ const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurv
resolve(window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${inAppConfig.get().apiHost}/api/packages/surveys`;
script.src = `${config.get().apiHost}/api/packages/surveys`;
script.async = true;
script.onload = () => resolve(window.formbricksSurveys);
script.onerror = (error) => {

View File

@@ -1,72 +0,0 @@
import { TJsPackageType } from "@formbricks/types/js";
import { trackAction as trackInAppAction } from "../app/lib/actions";
import { trackAction as trackWebsiteAction } from "../website/lib/actions";
import { err } from "./errors";
let exitIntentListenerAdded = false;
let exitIntentListenerWrapper = async function (e: MouseEvent, packageType: TJsPackageType) {
if (e.clientY <= 0) {
const trackResult =
packageType === "app"
? await trackInAppAction("Exit Intent (Desktop)")
: await trackWebsiteAction("Exit Intent (Desktop)");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
export const addExitIntentListener = (packageType: TJsPackageType): void => {
if (typeof document !== "undefined" && !exitIntentListenerAdded) {
document
.querySelector("body")!
.addEventListener("mouseleave", (e) => exitIntentListenerWrapper(e, packageType));
exitIntentListenerAdded = true;
}
};
export const removeExitIntentListener = (packageType: TJsPackageType): void => {
if (exitIntentListenerAdded) {
document.removeEventListener("mouseleave", (e) => exitIntentListenerWrapper(e, packageType));
exitIntentListenerAdded = false;
}
};
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
let scrollDepthListenerWrapper = async (packageType: TJsPackageType) => {
const scrollPosition = window.scrollY;
const windowSize = window.innerHeight;
const bodyHeight = document.documentElement.scrollHeight;
if (scrollPosition === 0) {
scrollDepthTriggered = false;
}
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
scrollDepthTriggered = true;
const trackResult =
packageType === "app" ? await trackInAppAction("50% Scroll") : await trackWebsiteAction("50% Scroll");
if (trackResult.ok !== true) {
return err(trackResult.error);
}
}
};
export const addScrollDepthListener = (packageType: TJsPackageType): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
window.addEventListener("load", () => {
window.addEventListener("scroll", () => scrollDepthListenerWrapper(packageType));
});
scrollDepthListenerAdded = true;
}
};
export const removeScrollDepthListener = (packageType: TJsPackageType): void => {
if (scrollDepthListenerAdded) {
window.removeEventListener("scroll", () => scrollDepthListenerWrapper(packageType));
scrollDepthListenerAdded = false;
}
};

View File

@@ -1,47 +0,0 @@
import { TJsWebsiteConfigInput } from "@formbricks/types/js";
// Shared imports
import { CommandQueue } from "../shared/commandQueue";
import { ErrorHandler } from "../shared/errors";
import { Logger } from "../shared/logger";
// Website package specific imports
import { trackAction } from "./lib/actions";
import { resetConfig } from "./lib/common";
import { initialize } from "./lib/initialize";
import { checkPageUrl } from "./lib/noCodeActions";
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = async (initConfig: TJsWebsiteConfigInput) => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, "website", initialize, initConfig);
await queue.wait();
};
const reset = async (): Promise<void> => {
queue.add(true, "website", resetConfig);
await queue.wait();
};
const track = async (name: string, properties: any = {}): Promise<void> => {
queue.add<any>(true, "website", trackAction, name, properties);
await queue.wait();
};
const registerRouteChange = async (): Promise<void> => {
queue.add(true, "website", checkPageUrl);
await queue.wait();
};
const formbricks = {
init,
track,
reset,
registerRouteChange,
};
export type TFormbricksWebsite = typeof formbricks;
export default formbricks as TFormbricksWebsite;

View File

@@ -1,43 +0,0 @@
import { NetworkError, Result, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { WebsiteConfig } from "./config";
import { triggerSurvey } from "./widget";
const logger = Logger.getInstance();
const websiteConfig = WebsiteConfig.getInstance();
export const trackAction = async (name: string): Promise<Result<void, NetworkError>> => {
const {
state: { surveys = [] },
} = websiteConfig.get();
// if surveys have a inline triggers, we need to check the name of the action in the code action config
surveys.forEach(async (survey) => {
const { inlineTriggers } = survey;
const { codeConfig } = inlineTriggers ?? {};
if (name === codeConfig?.identifier) {
await triggerSurvey(survey);
return;
}
});
logger.debug(`Formbricks: Action "${name}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = websiteConfig.get().state?.surveys;
if (!!activeSurveys && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger === name) {
await triggerSurvey(survey, name);
}
}
}
} else {
logger.debug("No active surveys to display");
}
return okVoid();
};

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