Compare commits
1 Commits
feature/ve
...
shubham/du
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a8897ca7 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
7
.github/workflows/cron-weeklySummary.yml
vendored
@@ -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:
|
||||
|
||||
1
.github/workflows/kamal-deploy.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/kamal-setup.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*"
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 'Free'
|
||||
@@ -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 'Paid'
|
||||
@@ -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 'THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING'
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'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 'Reset' 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 'New Session'. 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 'Exit Intent'. 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 '50% Scroll'. You can also
|
||||
scroll down to trigger the 50% scroll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 webpage’s 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>
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" })(
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
@@ -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 it’s 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 you’re 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, [it’s 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.
|
||||
|
||||
Let’s 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 Deloitte’s 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. HotJar’s 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 HotJar’s 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.
|
||||
|
||||
Let’s 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.
|
||||
|
||||
Here’s 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. It’s 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>;
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
@@ -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 it’s 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, we’ll 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**: Let’s see how Formbricks compares side-by-side to Hotjar.
|
||||
|
||||
| Factors | Formbricks | Hotjar |
|
||||
| --- | --- | --- |
|
||||
| Surveys & Forms | 🟢 | 🟡🟢 |
|
||||
| Heatmaps & Recordings | 🔴 | 🟢 |
|
||||
| Integrations | 🟡🟢 | 🟡🟢 |
|
||||
| Pricing | 🟢 | 🟡🟢 |
|
||||
| Privacy and Compliance | 🟢 | 🟡 |
|
||||
| Extensibility | 🟢 | 🟡 |
|
||||
|
||||
Now, we’ll 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 you’re 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 you’re 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, let’s 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 don’t 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 it’s only **available on its Enterprise Plan**.
|
||||
|
||||
**Pricing:** SurveyMonkey’s paid plans begin at $25/month whereas paid plans for Hotjar surveys begin at **$47.2/month**. However, don’t forget that SurveyMonkey is a full-fledged survey software, so you’ll 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 Deloitte’s 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, we’ll 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 it’s 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, Hotjar’s 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. Hotjar’s 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**: Let’s see how they compare side-by-side.
|
||||
|
||||
| Factors | Lucky Orange | Hotjar |
|
||||
| --- | --- | --- |
|
||||
| Surveys & Forms | 🟢 | 🟢 |
|
||||
| Heatmaps & Recordings | 🟡🟢 | 🟢 |
|
||||
| Integrations | 🟡 | 🟢 |
|
||||
| Privacy and Compliance | 🟢 | 🟢 |
|
||||
| Extensibility | 🟡 | 🟢 |
|
||||
| Pricing | 🟡 | 🟢 |
|
||||
|
||||
Next, we’ll 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 Hotjar’s 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, it’s 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. It’s 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>;
|
||||
|
Before Width: | Height: | Size: 34 KiB |
@@ -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 = {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
133
kamal/deploy.yml
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
81
packages/js-core/src/index.ts
Normal 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;
|
||||
@@ -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) {
|
||||
@@ -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()");
|
||||
61
packages/js-core/src/lib/automaticActions.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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))();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||