mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 18:18:48 -06:00
feat: website surveys (#2423)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { classNames } from "@/lib/utils";
|
||||
import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
@@ -11,6 +10,8 @@ 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,6 +18,7 @@
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { EarthIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import formbricks from "@formbricks/js";
|
||||
import formbricksApp from "@formbricks/js/app";
|
||||
|
||||
import fbsetup from "../../public/fb-setup.png";
|
||||
|
||||
@@ -30,28 +31,24 @@ 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 isUserId = window.location.href.includes("userId=true");
|
||||
const defaultAttributes = {
|
||||
language: "gu",
|
||||
};
|
||||
const userInitAttributes = { "Init Attribute 1": "eight", "Init Attribute 2": "two" };
|
||||
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
|
||||
const userInitAttributes = { language: "de", "Init Attribute 1": "eight", "Init Attribute 2": "two" };
|
||||
|
||||
const attributes = isUserId ? { ...defaultAttributes, ...userInitAttributes } : defaultAttributes;
|
||||
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
|
||||
formbricks.init({
|
||||
formbricksApp.init({
|
||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||
userId,
|
||||
attributes,
|
||||
attributes: userInitAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
// Connect next.js router to Formbricks
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||
const handleRouteChange = formbricks?.registerRouteChange;
|
||||
const handleRouteChange = formbricksApp?.registerRouteChange;
|
||||
router.events.on("routeChangeComplete", handleRouteChange);
|
||||
|
||||
return () => {
|
||||
@@ -60,18 +57,38 @@ 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>
|
||||
<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 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>
|
||||
|
||||
<button
|
||||
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
|
||||
onClick={() => setDarkMode(!darkMode)}>
|
||||
@@ -125,7 +142,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={() => {
|
||||
formbricks.reset();
|
||||
formbricksApp.reset();
|
||||
}}>
|
||||
Reset
|
||||
</button>
|
||||
@@ -140,7 +157,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={() => {
|
||||
formbricks.track("Code Action");
|
||||
formbricksApp.track("Code Action");
|
||||
}}>
|
||||
Code Action
|
||||
</button>
|
||||
@@ -184,7 +201,7 @@ export default function AppPage({}) {
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
formbricks.setAttribute("Plan", "Free");
|
||||
formbricksApp.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'
|
||||
@@ -207,7 +224,7 @@ export default function AppPage({}) {
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
formbricks.setAttribute("Plan", "Paid");
|
||||
formbricksApp.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'
|
||||
@@ -230,7 +247,7 @@ export default function AppPage({}) {
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
formbricks.setEmail("test@web.com");
|
||||
formbricksApp.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
|
||||
@@ -249,41 +266,6 @@ 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>
|
||||
|
||||
212
apps/demo/pages/website/index.tsx
Normal file
212
apps/demo/pages/website/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
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,23 +1,5 @@
|
||||
{
|
||||
"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": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"extends": "@formbricks/tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
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";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { capitalizeFirstLetter, truncate } from "@formbricks/lib/strings";
|
||||
|
||||
@@ -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 } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyType } 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<"link" | "web">("link");
|
||||
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("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 } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyType } 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: "link" | "web";
|
||||
setPreviewType: (type: "link" | "web") => void;
|
||||
previewType: TSurveyType;
|
||||
setPreviewType: (type: TSurveyType) => void;
|
||||
}
|
||||
|
||||
const previewParentContainerVariant: Variants = {
|
||||
@@ -111,6 +111,8 @@ 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
|
||||
@@ -137,7 +139,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>{previewType === "web" ? "Your web app" : "Preview"}</p>
|
||||
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
<ResetProgressButton onClick={resetQuestionProgress} />
|
||||
@@ -145,7 +147,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewType === "web" ? (
|
||||
{isAppSurvey ? (
|
||||
<Modal
|
||||
isOpen
|
||||
placement={placement}
|
||||
@@ -204,9 +206,9 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${previewType === "web" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("web")}>
|
||||
In-App survey
|
||||
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("app")}>
|
||||
App survey
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,12 @@ import Link from "next/link";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
interface TEmptyInAppSurveysProps {
|
||||
interface TEmptyAppSurveysProps {
|
||||
environment: TEnvironment;
|
||||
surveyType?: "app" | "website";
|
||||
}
|
||||
|
||||
export const EmptyInAppSurveys = ({ environment }: TEmptyInAppSurveysProps) => {
|
||||
export const EmptyAppSurveys = ({ environment, surveyType = "app" }: TEmptyAppSurveysProps) => {
|
||||
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">
|
||||
@@ -18,7 +19,9 @@ export const EmptyInAppSurveys = ({ environment }: TEmptyInAppSurveysProps) => {
|
||||
<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 app with Formbricks to run in-app surveys.</p>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Connect your {surveyType} with Formbricks to run {surveyType} 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 { EmptyInAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import { EmptyAppSurveys } 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,8 +85,10 @@ export default function ResponseTimeline({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
{(survey.type === "app" || survey.type === "website") &&
|
||||
responses.length === 0 &&
|
||||
!environment.widgetSetupCompleted ? (
|
||||
<EmptyAppSurveys environment={environment} surveyType={survey.type} />
|
||||
) : 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 } from "@formbricks/types/surveys";
|
||||
import { TSurveyQuestionSummaryMultipleChoice, TSurveyType } 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: string;
|
||||
surveyType: TSurveyType;
|
||||
}
|
||||
|
||||
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 === "web" && "User"}</div>
|
||||
<div className="col-span-1 pl-6 ">{surveyType === "app" && "User"}</div>
|
||||
</div>
|
||||
{result.others
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
)}
|
||||
{surveyType === "web" && otherValue.person && (
|
||||
{surveyType === "app" && otherValue.person && (
|
||||
<Link
|
||||
href={
|
||||
otherValue.person.id
|
||||
|
||||
@@ -23,16 +23,18 @@ 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(
|
||||
survey.type === "web" && !environment.widgetSetupCompleted
|
||||
isAppSurvey && !environment.widgetSetupCompleted
|
||||
? "Almost there! Install widget to start receiving responses."
|
||||
: "Congrats! Your survey is live.",
|
||||
{
|
||||
icon: survey.type === "web" && !environment.widgetSetupCompleted ? "🤏" : "🎉",
|
||||
icon: isAppSurvey && !environment.widgetSetupCompleted ? "🤏" : "🎉",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export const SuccessMessage = ({ environment, survey, webAppUrl, user }: Summary
|
||||
url.searchParams.delete("success");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
}, [environment, searchParams, survey]);
|
||||
}, [environment, isAppSurvey, searchParams, survey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmptyInAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import { EmptyAppSurveys } 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,8 +40,10 @@ export const SummaryList = ({
|
||||
}: SummaryListProps) => {
|
||||
return (
|
||||
<div className="mt-10 space-y-8">
|
||||
{survey.type === "web" && responseCount === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
{(survey.type === "app" || survey.type === "website") &&
|
||||
responseCount === 0 &&
|
||||
!environment.widgetSetupCompleted ? (
|
||||
<EmptyAppSurveys environment={environment} surveyType={survey.type} />
|
||||
) : fetchingSummary ? (
|
||||
<SkeletonLoader type="summary" />
|
||||
) : responseCount === 0 ? (
|
||||
|
||||
@@ -36,6 +36,7 @@ const CardStylingSettings = ({
|
||||
localProduct,
|
||||
setOpen,
|
||||
}: CardStylingSettingsProps) => {
|
||||
const isAppSurvey = surveyType === "app" || surveyType === "website";
|
||||
const cardBgColor = styling?.cardBackgroundColor?.light || COLOR_DEFAULTS.cardBackgroundColor;
|
||||
|
||||
const isLogoHidden = styling?.isLogoHidden ?? false;
|
||||
@@ -231,14 +232,14 @@ const CardStylingSettings = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!surveyType || surveyType === "web") && (
|
||||
{(!surveyType || isAppSurvey) && (
|
||||
<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 Surveys" type="gray" size="normal" />
|
||||
<Badge text="In-App and Website Surveys" type="gray" size="normal" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Add an outer border to your survey card.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { AlertCircleIcon, CheckIcon, LinkIcon, MonitorIcon, SmartphoneIcon } from "lucide-react";
|
||||
import { AlertCircleIcon, CheckIcon, EarthIcon, 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";
|
||||
@@ -14,7 +15,7 @@ import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
interface HowToSendCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey | ((TSurvey) => TSurvey)) => void;
|
||||
setLocalSurvey: (survey: TSurvey | ((TSurvey: TSurvey) => TSurvey)) => void;
|
||||
environment: TEnvironment;
|
||||
}
|
||||
|
||||
@@ -39,12 +40,48 @@ 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: "web",
|
||||
name: "In-App Survey",
|
||||
id: "website",
|
||||
name: "Website Survey",
|
||||
icon: EarthIcon,
|
||||
description: "Run targeted surveys on public websites.",
|
||||
comingSoon: false,
|
||||
alert: !widgetSetupCompleted,
|
||||
},
|
||||
{
|
||||
id: "app",
|
||||
name: "App Survey",
|
||||
icon: MonitorIcon,
|
||||
description: "Embed a survey in your web app to collect responses.",
|
||||
comingSoon: false,
|
||||
@@ -97,7 +134,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
|
||||
<hr className="py-1 text-slate-600" />
|
||||
<div className="p-3">
|
||||
<RadioGroup
|
||||
defaultValue="web"
|
||||
defaultValue="app"
|
||||
value={localSurvey.type}
|
||||
onValueChange={setSurveyType}
|
||||
className="flex flex-col space-y-3">
|
||||
|
||||
@@ -39,11 +39,13 @@ 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 === "web" ? (
|
||||
{localSurvey.type === "app" ? (
|
||||
!isUserTargetingAllowed ? (
|
||||
<TargetingCard
|
||||
key={localSurvey.segment?.id}
|
||||
@@ -89,7 +91,7 @@ export const SettingsView = ({
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
|
||||
{localSurvey.type === "web" && (
|
||||
{isWebSurvey && (
|
||||
<SurveyPlacementCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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";
|
||||
@@ -62,8 +61,6 @@ 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) {
|
||||
@@ -114,39 +111,6 @@ 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 ?? []));
|
||||
@@ -235,7 +199,9 @@ export default function SurveyEditor({
|
||||
questionId={activeQuestionId}
|
||||
product={localProduct}
|
||||
environment={environment}
|
||||
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
|
||||
previewType={
|
||||
localSurvey.type === "app" || localSurvey.type === "website" ? "modal" : "fullwidth"
|
||||
}
|
||||
languageCode={selectedLanguageCode}
|
||||
onFileUpload={async (file) => file.name}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
@@ -89,7 +90,7 @@ export const SurveyMenuBar = ({
|
||||
}, [localSurvey, survey]);
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
if (localSurvey.type !== "web") return false;
|
||||
if (localSurvey.type === "link") return false;
|
||||
|
||||
const noTriggers = !localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0];
|
||||
const noInlineTriggers =
|
||||
@@ -292,8 +293,9 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
|
||||
setIsSurveySaving(true);
|
||||
|
||||
// Create a copy of localSurvey with isDraft removed from every question
|
||||
const strippedSurvey: TSurvey = {
|
||||
let strippedSurvey: TSurvey = {
|
||||
...localSurvey,
|
||||
questions: localSurvey.questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
@@ -357,11 +359,39 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
|
||||
strippedSurvey.triggers = strippedSurvey.triggers.filter((trigger) => Boolean(trigger));
|
||||
|
||||
// if the segment has id === "temp", we create a private segment with the same filters.
|
||||
if (strippedSurvey.type === "app" && strippedSurvey.segment?.id === "temp") {
|
||||
const { filters } = strippedSurvey.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";
|
||||
setIsSurveySaving(false);
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// create a new private segment
|
||||
const newSegment = await createSegmentAction({
|
||||
environmentId: environment.id,
|
||||
filters,
|
||||
isPrivate: true,
|
||||
surveyId: strippedSurvey.id,
|
||||
title: strippedSurvey.id,
|
||||
});
|
||||
|
||||
strippedSurvey.segment = newSegment;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSurveyAction({ ...strippedSurvey });
|
||||
const udpatedSurvey = await updateSurveyAction({ ...strippedSurvey });
|
||||
|
||||
setIsSurveySaving(false);
|
||||
setLocalSurvey(strippedSurvey);
|
||||
setLocalSurvey({ ...strippedSurvey, segment: udpatedSurvey.segment });
|
||||
|
||||
toast.success("Changes saved.");
|
||||
if (shouldNavigateBack) {
|
||||
router.back();
|
||||
@@ -389,6 +419,33 @@ export const SurveyMenuBar = ({
|
||||
return;
|
||||
}
|
||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||
|
||||
// if the segment has id === "temp", we create a private segment with the same filters.
|
||||
if (localSurvey.type === "app" && localSurvey.segment?.id === "temp") {
|
||||
const { filters } = localSurvey.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";
|
||||
setIsSurveySaving(false);
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// create a new private segment
|
||||
const newSegment = await createSegmentAction({
|
||||
environmentId: environment.id,
|
||||
filters,
|
||||
isPrivate: true,
|
||||
surveyId: localSurvey.id,
|
||||
title: localSurvey.id,
|
||||
});
|
||||
|
||||
localSurvey.segment = newSegment;
|
||||
}
|
||||
|
||||
await updateSurveyAction({ ...localSurvey, status });
|
||||
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -38,7 +38,9 @@ export default function WhenToSendCard({
|
||||
propActionClasses,
|
||||
membershipRole,
|
||||
}: WhenToSendCardProps) {
|
||||
const [open, setOpen] = useState(localSurvey.type === "web" ? true : false);
|
||||
const [open, setOpen] = useState(
|
||||
localSurvey.type === "app" || localSurvey.type === "website" ? true : false
|
||||
);
|
||||
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PreviewSurveyProps {
|
||||
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
|
||||
}
|
||||
|
||||
let surveyNameTemp;
|
||||
let surveyNameTemp: string;
|
||||
|
||||
const previewParentContainerVariant: Variants = {
|
||||
expanded: {
|
||||
@@ -156,7 +156,7 @@ export const PreviewSurvey = ({
|
||||
|
||||
const onFinished = () => {
|
||||
// close modal if there are no questions left
|
||||
if (survey.type === "web" && !survey.thankYouCard.enabled) {
|
||||
if ((survey.type === "website" || survey.type === "app") && !survey.thankYouCard.enabled) {
|
||||
setIsModalOpen(false);
|
||||
setTimeout(() => {
|
||||
setQuestionId(survey.questions[0]?.id);
|
||||
@@ -165,7 +165,7 @@ export const PreviewSurvey = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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
|
||||
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 ? "web" : "link";
|
||||
const surveyType = environment?.widgetSetupCompleted ? "app" : "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 ? "web" : "link";
|
||||
const surveyType = environment?.widgetSetupCompleted ? "app" : "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: "web",
|
||||
type: "app",
|
||||
environmentId: "someEnvId1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
@@ -2653,7 +2653,7 @@ export const getExampleSurveyTemplate = (webAppUrl: string) => ({
|
||||
}) as TSurveyCTAQuestion
|
||||
),
|
||||
name: "Example survey",
|
||||
type: "web" as TSurveyType,
|
||||
type: "website" 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" | "in-app" | null) => void;
|
||||
setSelectedPathway: (pathway: "link" | "website" | null) => void;
|
||||
setCurrentStep: (currentStep: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
type PathwayOptionType = "link" | "in-app";
|
||||
type PathwayOptionType = "link" | "website";
|
||||
|
||||
export default function PathwaySelect({
|
||||
setSelectedPathway,
|
||||
@@ -29,7 +29,7 @@ export default function PathwaySelect({
|
||||
localStorage.setItem("onboardingCurrentStep", "5");
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem("onboardingPathway", "in-app");
|
||||
localStorage.setItem("onboardingPathway", "website");
|
||||
setCurrentStep(2);
|
||||
localStorage.setItem("onboardingCurrentStep", "2");
|
||||
}
|
||||
@@ -54,12 +54,12 @@ export default function PathwaySelect({
|
||||
<Image src={LinkMockup} alt="" height={350} />
|
||||
</OptionCard>
|
||||
<OptionCard
|
||||
cssId="onboarding-inapp-survey-card"
|
||||
cssId="onboarding-website-survey-card"
|
||||
size="lg"
|
||||
title="In-app Surveys"
|
||||
description="Run a survey on a website or in-app."
|
||||
title="Website Surveys"
|
||||
description="Run a survey on a website."
|
||||
onSelect={() => {
|
||||
handleSelect("in-app");
|
||||
handleSelect("website");
|
||||
}}>
|
||||
<Image src={InappMockup} alt="" height={350} />
|
||||
</OptionCard>
|
||||
|
||||
@@ -7,8 +7,11 @@ export async function GET(_: NextRequest, { params }: { params: { slug: string }
|
||||
const packageRequested = params["package"];
|
||||
|
||||
switch (packageRequested) {
|
||||
case "js-core":
|
||||
path = `../../packages/js-core/dist/index.umd.cjs`;
|
||||
case "app":
|
||||
path = `../../packages/js-core/dist/app.umd.cjs`;
|
||||
break;
|
||||
case "website":
|
||||
path = `../../packages/js-core/dist/website.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 { TJsStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
|
||||
import { TJsAppStateSync, 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: TJsStateSync = {
|
||||
const state: TJsAppStateSync = {
|
||||
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 { TJsStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js";
|
||||
import { TJsAppStateSync, 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: TJsStateSync = {
|
||||
const state: TJsAppStateSync = {
|
||||
person: { id: person.id, userId: person.userId },
|
||||
surveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TJsLegacyState, TJsState } from "@formbricks/types/js";
|
||||
import { TJsAppState, TJsLegacyState } from "@formbricks/types/js";
|
||||
|
||||
export const transformLegacySurveys = (state: TJsState): TJsLegacyState => {
|
||||
export const transformLegacySurveys = (state: TJsAppState): TJsLegacyState => {
|
||||
const updatedState: any = { ...state };
|
||||
updatedState.surveys = updatedState.surveys.map((survey) => {
|
||||
const updatedSurvey = { ...survey };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getExampleSurveyTemplate } from "@/app/(app)/environments/[environmentId]/surveys/templates/templates";
|
||||
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog";
|
||||
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/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 { TJsStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TJsAppStateSync, 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: TJsStateSync = {
|
||||
const state: TJsAppStateSync = {
|
||||
person: personData,
|
||||
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getExampleSurveyTemplate } from "@/app/(app)/environments/[environmentId]/surveys/templates/templates";
|
||||
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/in-app/sync/lib/posthog";
|
||||
import { sendFreeLimitReachedEventToPosthogBiWeekly } from "@/app/api/v1/client/[environmentId]/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 { TJsStateSync, ZJsPublicSyncInput } from "@formbricks/types/js";
|
||||
import { TJsWebsiteStateSync, ZJsWebsiteSyncInput } 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 = ZJsPublicSyncInput.safeParse({
|
||||
const syncInputValidation = ZJsWebsiteSyncInput.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 '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)
|
||||
// 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)
|
||||
);
|
||||
|
||||
// Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey.
|
||||
@@ -127,11 +127,10 @@ export async function GET(
|
||||
};
|
||||
|
||||
// Create the 'state' object with surveys, noCodeActionClasses, product, and person.
|
||||
const state: TJsStateSync = {
|
||||
const state: TJsWebsiteStateSync = {
|
||||
surveys: isInAppSurveyLimitReached ? [] : transformedSurveys,
|
||||
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
|
||||
product: updatedProduct,
|
||||
person: null,
|
||||
};
|
||||
|
||||
return responses.successResponse(
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import formbricks from "@formbricks/js";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
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\/([^/]+)\/in-app\/sync\/([^/]+)/;
|
||||
const regex = /\/api\/v1\/client\/([^/]+)\/app\/sync\/([^/]+)/;
|
||||
const match = url.match(regex);
|
||||
return match ? { environmentId: match[1], userId: match[2] } : false;
|
||||
};
|
||||
|
||||
@@ -55,6 +55,18 @@ 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,12 +24,11 @@ test.describe("JS Package Test", async () => {
|
||||
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
|
||||
await page.locator("#howToSendCardTrigger").click();
|
||||
|
||||
await expect(page.locator("#howToSendCardOption-web")).toBeVisible();
|
||||
await page.locator("#howToSendCardOption-web").click();
|
||||
await page.locator("#howToSendCardOption-web").click();
|
||||
await expect(page.locator("#howToSendCardOption-website")).toBeVisible();
|
||||
await page.locator("#howToSendCardOption-website").click();
|
||||
await page.locator("#howToSendCardOption-website").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();
|
||||
@@ -50,13 +49,13 @@ test.describe("JS Package Test", async () => {
|
||||
|
||||
test("JS Display Survey on Page", async ({ page }) => {
|
||||
let currentDir = process.cwd();
|
||||
let htmlFilePath = currentDir + "/packages/js-core/index.html";
|
||||
let htmlFilePath = currentDir + "/packages/js/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("/in-app/sync"));
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/website/sync"));
|
||||
expect(syncApi.status()).toBe(200);
|
||||
|
||||
// Formbricks Modal exists in the DOM
|
||||
@@ -74,14 +73,14 @@ test.describe("JS Package Test", async () => {
|
||||
|
||||
test("JS submits Response to Survey", async ({ page }) => {
|
||||
let currentDir = process.cwd();
|
||||
let htmlFilePath = currentDir + "/packages/js-core/index.html";
|
||||
let htmlFilePath = currentDir + "/packages/js/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("/in-app/sync"));
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/website/sync"));
|
||||
expect(syncApi.status()).toBe(200);
|
||||
|
||||
// Formbricks Modal exists in the DOM
|
||||
@@ -113,7 +112,7 @@ test.describe("JS Package Test", async () => {
|
||||
test("Admin validates Displays & Response", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
await page.getByRole("link", { name: "Website Open options Product" }).click();
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 2 Displays
|
||||
|
||||
@@ -20,12 +20,12 @@ test.describe("Onboarding Flow Test", async () => {
|
||||
await expect(page.getByText(productName)).toBeVisible();
|
||||
});
|
||||
|
||||
test("In app survey", async ({ page }) => {
|
||||
test("website 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: "In-app Surveys Run a survey" }).click();
|
||||
await page.getByRole("button", { name: "Website 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-web").click();
|
||||
await page.locator("#howToSendCardOption-website").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-core/index.html";
|
||||
let htmlFilePath = currentDir + "/packages/js/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 In App Sync has happened
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync"));
|
||||
// Formbricks Website Sync has happened
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/website/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);
|
||||
writeFileSync(filePath, htmlContent, { mode: 1 });
|
||||
return "file:///" + filePath;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
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,6 +26,7 @@
|
||||
"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",
|
||||
|
||||
@@ -239,10 +239,10 @@ model SurveyAttributeFilter {
|
||||
}
|
||||
|
||||
enum SurveyType {
|
||||
email
|
||||
link
|
||||
mobile
|
||||
web
|
||||
website
|
||||
app
|
||||
}
|
||||
|
||||
enum displayOptions {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from "fs";
|
||||
import { resolve } from "path";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
@@ -10,19 +12,24 @@ const config = () => {
|
||||
"import.meta.env.VERSION": JSON.stringify(webPackageJson.version),
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build
|
||||
minify: "terser",
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
entry: resolve(__dirname, "src/app/index.ts"),
|
||||
name: "formbricks",
|
||||
formats: ["es", "umd"],
|
||||
// the proper extensions will be added
|
||||
fileName: "index",
|
||||
formats: ["umd"],
|
||||
fileName: "app",
|
||||
},
|
||||
},
|
||||
plugins: [dts({ rollupTypes: true })],
|
||||
plugins: [
|
||||
dts({
|
||||
rollupTypes: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,20 +19,23 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"source": "src/index.ts",
|
||||
"main": "dist/index.umd.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.umd.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"./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"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode dev",
|
||||
"build": "tsc && vite build",
|
||||
"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:dev": "tsc && vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"lint": "eslint ./src --fix",
|
||||
|
||||
65
packages/js-core/src/app/index.ts
Normal file
65
packages/js-core/src/app/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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,15 +1,15 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TJsActionInput } from "@formbricks/types/js";
|
||||
|
||||
import { Config } from "./config";
|
||||
import { NetworkError, Result, err, okVoid } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
import { sync } from "./sync";
|
||||
import { getIsDebug } from "./utils";
|
||||
import { triggerSurvey } from "./widget";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
const config = Config.getInstance();
|
||||
const inAppConfig = AppConfig.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 = [] },
|
||||
} = config.get();
|
||||
} = inAppConfig.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: config.get().environmentId,
|
||||
environmentId: inAppConfig.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: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
environmentId: inAppConfig.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: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`,
|
||||
url: `${inAppConfig.get().apiHost}/api/v1/client/${inAppConfig.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: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
apiHost: inAppConfig.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 = config.get().state?.surveys;
|
||||
const activeSurveys = inAppConfig.get().state?.surveys;
|
||||
|
||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||
for (const survey of activeSurveys) {
|
||||
@@ -1,10 +1,10 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
|
||||
import { Config } from "./config";
|
||||
import { AppConfig } from "./config";
|
||||
|
||||
export const getApi = (): FormbricksAPI => {
|
||||
const config = Config.getInstance();
|
||||
const { environmentId, apiHost } = config.get();
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const { environmentId, apiHost } = inAppConfig.get();
|
||||
|
||||
if (!environmentId || !apiHost) {
|
||||
throw new Error("formbricks.init() must be called before getApi()");
|
||||
@@ -1,12 +1,12 @@
|
||||
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
|
||||
import { TJSAppConfig, TJsAppConfigUpdateInput } from "@formbricks/types/js";
|
||||
|
||||
import { Result, err, ok, wrapThrows } from "./errors";
|
||||
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
||||
|
||||
export const LOCAL_STORAGE_KEY = "formbricks-js";
|
||||
export const IN_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
|
||||
|
||||
export class Config {
|
||||
private static instance: Config | undefined;
|
||||
private config: TJsConfig | null = null;
|
||||
export class AppConfig {
|
||||
private static instance: AppConfig | undefined;
|
||||
private config: TJSAppConfig | null = null;
|
||||
|
||||
private constructor() {
|
||||
const localConfig = this.loadFromLocalStorage();
|
||||
@@ -16,14 +16,14 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): Config {
|
||||
if (!Config.instance) {
|
||||
Config.instance = new Config();
|
||||
static getInstance(): AppConfig {
|
||||
if (!AppConfig.instance) {
|
||||
AppConfig.instance = new AppConfig();
|
||||
}
|
||||
return Config.instance;
|
||||
return AppConfig.instance;
|
||||
}
|
||||
|
||||
public update(newConfig: TJsConfigUpdateInput): void {
|
||||
public update(newConfig: TJsAppConfigUpdateInput): void {
|
||||
if (newConfig) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
@@ -35,28 +35,28 @@ export class Config {
|
||||
}
|
||||
}
|
||||
|
||||
public get(): TJsConfig {
|
||||
public get(): TJSAppConfig {
|
||||
if (!this.config) {
|
||||
throw new Error("config is null, maybe the init function was not called?");
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public loadFromLocalStorage(): Result<TJsConfig, Error> {
|
||||
public loadFromLocalStorage(): Result<TJSAppConfig, Error> {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedConfig = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
const savedConfig = localStorage.getItem(IN_APP_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 TJsConfig;
|
||||
const parsedConfig = JSON.parse(savedConfig) as TJSAppConfig;
|
||||
|
||||
// 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 TJsConfig);
|
||||
return ok(JSON.parse(savedConfig) as TJSAppConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class Config {
|
||||
}
|
||||
|
||||
private saveToLocalStorage(): Result<void, Error> {
|
||||
return wrapThrows(() => localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
|
||||
return wrapThrows(() => localStorage.setItem(IN_APP_LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
|
||||
}
|
||||
|
||||
// reset the config
|
||||
@@ -72,6 +72,6 @@ export class Config {
|
||||
public resetConfig(): Result<void, Error> {
|
||||
this.config = null;
|
||||
|
||||
return wrapThrows(() => localStorage.removeItem(LOCAL_STORAGE_KEY))();
|
||||
return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
addScrollDepthListener,
|
||||
removeExitIntentListener,
|
||||
removeScrollDepthListener,
|
||||
} from "./automaticActions";
|
||||
} from "../../shared/automaticActions";
|
||||
import {
|
||||
addClickEventListener,
|
||||
addPageUrlEventListeners,
|
||||
@@ -18,8 +18,8 @@ export const addEventListeners = (): void => {
|
||||
addExpiryCheckListener();
|
||||
addPageUrlEventListeners();
|
||||
addClickEventListener();
|
||||
addExitIntentListener();
|
||||
addScrollDepthListener();
|
||||
addExitIntentListener("app");
|
||||
addScrollDepthListener("app");
|
||||
};
|
||||
|
||||
export const addCleanupEventListeners = (): void => {
|
||||
@@ -28,8 +28,8 @@ export const addCleanupEventListeners = (): void => {
|
||||
removeExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener();
|
||||
removeScrollDepthListener();
|
||||
removeExitIntentListener("app");
|
||||
removeScrollDepthListener("app");
|
||||
});
|
||||
areRemoveEventListenersAdded = true;
|
||||
};
|
||||
@@ -40,8 +40,8 @@ export const removeCleanupEventListeners = (): void => {
|
||||
removeExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener();
|
||||
removeScrollDepthListener();
|
||||
removeExitIntentListener("app");
|
||||
removeScrollDepthListener("app");
|
||||
});
|
||||
areRemoveEventListenersAdded = false;
|
||||
};
|
||||
@@ -50,7 +50,7 @@ export const removeAllEventListeners = (): void => {
|
||||
removeExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener();
|
||||
removeScrollDepthListener();
|
||||
removeExitIntentListener("app");
|
||||
removeScrollDepthListener("app");
|
||||
removeCleanupEventListeners();
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
|
||||
import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js";
|
||||
import { TPersonAttributes } from "@formbricks/types/people";
|
||||
|
||||
import { trackAction } from "./actions";
|
||||
import { Config, LOCAL_STORAGE_KEY } from "./config";
|
||||
import {
|
||||
ErrorHandler,
|
||||
MissingFieldError,
|
||||
@@ -13,16 +11,18 @@ import {
|
||||
err,
|
||||
okVoid,
|
||||
wrapThrows,
|
||||
} from "./errors";
|
||||
} 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";
|
||||
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 config = Config.getInstance();
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let isInitialized = false;
|
||||
@@ -32,7 +32,7 @@ export const setIsInitialized = (value: boolean) => {
|
||||
};
|
||||
|
||||
export const initialize = async (
|
||||
c: TJsConfigInput
|
||||
configInput: TJsAppConfigInput
|
||||
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
||||
if (getIsDebug()) {
|
||||
logger.configure({ logLevel: "debug" });
|
||||
@@ -43,9 +43,9 @@ export const initialize = async (
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
let existingConfig: TJsConfig | undefined;
|
||||
let existingConfig: TJSAppConfig | undefined;
|
||||
try {
|
||||
existingConfig = config.get();
|
||||
existingConfig = inAppConfig.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 (!c.environmentId) {
|
||||
if (!configInput.environmentId) {
|
||||
logger.debug("No environmentId provided");
|
||||
return err({
|
||||
code: "missing_field",
|
||||
@@ -74,7 +74,7 @@ export const initialize = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (!c.apiHost) {
|
||||
if (!configInput.apiHost) {
|
||||
logger.debug("No apiHost provided");
|
||||
|
||||
return err({
|
||||
@@ -83,18 +83,32 @@ 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 (c.attributes) {
|
||||
if (!c.userId) {
|
||||
if (configInput.attributes) {
|
||||
if (!configInput.userId) {
|
||||
// Allow setting attributes for unidentified users
|
||||
updatedAttributes = { ...c.attributes };
|
||||
updatedAttributes = { ...configInput.attributes };
|
||||
}
|
||||
// If userId is available, update attributes in backend
|
||||
else {
|
||||
const res = await updatePersonAttributes(c.apiHost, c.environmentId, c.userId, c.attributes);
|
||||
const res = await updatePersonAttributes(
|
||||
configInput.apiHost,
|
||||
configInput.environmentId,
|
||||
configInput.userId,
|
||||
configInput.attributes
|
||||
);
|
||||
if (res.ok !== true) {
|
||||
return err(res.error);
|
||||
}
|
||||
@@ -105,9 +119,9 @@ export const initialize = async (
|
||||
if (
|
||||
existingConfig &&
|
||||
existingConfig.state &&
|
||||
existingConfig.environmentId === c.environmentId &&
|
||||
existingConfig.apiHost === c.apiHost &&
|
||||
existingConfig.userId === c.userId &&
|
||||
existingConfig.environmentId === configInput.environmentId &&
|
||||
existingConfig.apiHost === configInput.apiHost &&
|
||||
existingConfig.userId === configInput.userId &&
|
||||
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
|
||||
) {
|
||||
logger.debug("Configuration fits init parameters.");
|
||||
@@ -116,29 +130,29 @@ export const initialize = async (
|
||||
|
||||
try {
|
||||
await sync({
|
||||
apiHost: c.apiHost,
|
||||
environmentId: c.environmentId,
|
||||
userId: c.userId,
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
});
|
||||
} catch (e) {
|
||||
putFormbricksInErrorState();
|
||||
}
|
||||
} else {
|
||||
logger.debug("Configuration not expired. Extending expiration.");
|
||||
config.update(existingConfig);
|
||||
inAppConfig.update(existingConfig);
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
"No valid configuration found or it has been expired. Resetting config and creating new one."
|
||||
);
|
||||
config.resetConfig();
|
||||
inAppConfig.resetConfig();
|
||||
logger.debug("Syncing.");
|
||||
|
||||
try {
|
||||
await sync({
|
||||
apiHost: c.apiHost,
|
||||
environmentId: c.environmentId,
|
||||
userId: c.userId,
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
});
|
||||
} catch (e) {
|
||||
handleErrorOnFirstInit();
|
||||
@@ -148,15 +162,15 @@ export const initialize = async (
|
||||
}
|
||||
// update attributes in config
|
||||
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
||||
config.update({
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
userId: config.get().userId,
|
||||
inAppConfig.update({
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
userId: inAppConfig.get().userId,
|
||||
state: {
|
||||
...config.get().state,
|
||||
attributes: { ...config.get().state.attributes, ...c.attributes },
|
||||
...inAppConfig.get().state,
|
||||
attributes: { ...inAppConfig.get().state.attributes, ...configInput.attributes },
|
||||
},
|
||||
expiresAt: config.get().expiresAt,
|
||||
expiresAt: inAppConfig.get().expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,12 +189,12 @@ export const initialize = async (
|
||||
|
||||
const handleErrorOnFirstInit = () => {
|
||||
// put formbricks in error state (by creating a new config) and throw error
|
||||
const initialErrorConfig: Partial<TJsConfig> = {
|
||||
const initialErrorConfig: Partial<TJSAppConfig> = {
|
||||
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(LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||
wrapThrows(() => localStorage.setItem(IN_APP_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||
throw new Error("Could not initialize formbricks");
|
||||
};
|
||||
|
||||
@@ -207,8 +221,8 @@ export const deinitalize = (): void => {
|
||||
export const putFormbricksInErrorState = (): void => {
|
||||
logger.debug("Putting formbricks in error state");
|
||||
// change formbricks status to error
|
||||
config.update({
|
||||
...config.get(),
|
||||
inAppConfig.update({
|
||||
...inAppConfig.get(),
|
||||
status: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
});
|
||||
221
packages/js-core/src/app/lib/noCodeActions.ts
Normal file
221
packages/js-core/src/app/lib/noCodeActions.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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,49 +1,20 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TPersonAttributes, TPersonUpdateInput } from "@formbricks/types/people";
|
||||
|
||||
import { Config } from "./config";
|
||||
import {
|
||||
AttributeAlreadyExistsError,
|
||||
MissingPersonError,
|
||||
NetworkError,
|
||||
Result,
|
||||
err,
|
||||
ok,
|
||||
okVoid,
|
||||
} from "./errors";
|
||||
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { AppConfig } from "./config";
|
||||
import { deinitalize, initialize } from "./initialize";
|
||||
import { Logger } from "./logger";
|
||||
import { closeSurvey } from "./widget";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export const updatePersonAttribute = async (
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<Result<void, NetworkError | MissingPersonError>> => {
|
||||
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 { apiHost, environmentId, userId } = inAppConfig.get();
|
||||
|
||||
const input: TPersonUpdateInput = {
|
||||
attributes: {
|
||||
@@ -63,7 +34,7 @@ export const updatePersonAttribute = async (
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: `Error updating person with userId ${userId}`,
|
||||
url: `${config.get().apiHost}/api/v1/client/${environmentId}/people/${userId}`,
|
||||
url: `${inAppConfig.get().apiHost}/api/v1/client/${environmentId}/people/${userId}`,
|
||||
responseMessage: res.error.message,
|
||||
});
|
||||
}
|
||||
@@ -85,7 +56,7 @@ export const updatePersonAttributes = async (
|
||||
const updatedAttributes = { ...attributes };
|
||||
|
||||
try {
|
||||
const existingAttributes = config.get()?.state?.attributes;
|
||||
const existingAttributes = inAppConfig.get()?.state?.attributes;
|
||||
if (existingAttributes) {
|
||||
for (const [key, value] of Object.entries(existingAttributes)) {
|
||||
if (updatedAttributes[key] === value) {
|
||||
@@ -130,19 +101,12 @@ export const updatePersonAttributes = async (
|
||||
};
|
||||
|
||||
export const isExistingAttribute = (key: string, value: string): boolean => {
|
||||
if (config.get().state.attributes[key] === value) {
|
||||
if (inAppConfig.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
|
||||
@@ -163,18 +127,18 @@ export const setPersonAttribute = async (
|
||||
|
||||
if (result.ok) {
|
||||
// udpdate attribute in config
|
||||
config.update({
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
userId: config.get().userId,
|
||||
inAppConfig.update({
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
userId: inAppConfig.get().userId,
|
||||
state: {
|
||||
...config.get().state,
|
||||
...inAppConfig.get().state,
|
||||
attributes: {
|
||||
...config.get().state.attributes,
|
||||
...inAppConfig.get().state.attributes,
|
||||
[key]: value.toString(),
|
||||
},
|
||||
},
|
||||
expiresAt: config.get().expiresAt,
|
||||
expiresAt: inAppConfig.get().expiresAt,
|
||||
});
|
||||
return okVoid();
|
||||
}
|
||||
@@ -184,17 +148,17 @@ export const setPersonAttribute = async (
|
||||
|
||||
export const logoutPerson = async (): Promise<void> => {
|
||||
deinitalize();
|
||||
config.resetConfig();
|
||||
inAppConfig.resetConfig();
|
||||
};
|
||||
|
||||
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
|
||||
logger.debug("Resetting state & getting new state from backend");
|
||||
closeSurvey();
|
||||
const syncParams = {
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
userId: config.get().userId,
|
||||
attributes: config.get().state.attributes,
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
userId: inAppConfig.get().userId,
|
||||
attributes: inAppConfig.get().state.attributes,
|
||||
};
|
||||
await logoutPerson();
|
||||
try {
|
||||
114
packages/js-core/src/app/lib/sync.ts
Normal file
114
packages/js-core/src/app/lib/sync.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,20 +1,19 @@
|
||||
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 { Config } from "./config";
|
||||
import { ErrorHandler } from "./errors";
|
||||
import { ErrorHandler } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
import { putFormbricksInErrorState } from "./initialize";
|
||||
import { Logger } from "./logger";
|
||||
import { filterPublicSurveys, sync } from "./sync";
|
||||
import { getDefaultLanguageCode, getLanguageCode } from "./utils";
|
||||
import { sync } from "./sync";
|
||||
|
||||
const containerId = "formbricks-web-container";
|
||||
const containerId = "formbricks-app-container";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
const errorHandler = ErrorHandler.getInstance();
|
||||
let isSurveyRunning = false;
|
||||
@@ -53,8 +52,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
|
||||
}
|
||||
|
||||
const product = config.get().state.product;
|
||||
const attributes = config.get().state.attributes;
|
||||
const product = inAppConfig.get().state.product;
|
||||
const attributes = inAppConfig.get().state.attributes;
|
||||
|
||||
const isMultiLanguageSurvey = survey.languages.length > 1;
|
||||
let languageCode = "default";
|
||||
@@ -70,12 +69,12 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
languageCode = displayLanguage;
|
||||
}
|
||||
|
||||
const surveyState = new SurveyState(survey.id, null, null, config.get().userId);
|
||||
const surveyState = new SurveyState(survey.id, null, null, inAppConfig.get().userId);
|
||||
|
||||
const responseQueue = new ResponseQueue(
|
||||
{
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
retryAttempts: 2,
|
||||
onResponseSendingFailed: () => {
|
||||
setIsError(true);
|
||||
@@ -129,70 +128,31 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
setIsResponseSendingFinished = f;
|
||||
},
|
||||
onDisplay: async () => {
|
||||
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 { userId } = inAppConfig.get();
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
environmentId: inAppConfig.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 } = 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
const { userId } = inAppConfig.get();
|
||||
surveyState.updateUserId(userId);
|
||||
|
||||
if (userId) {
|
||||
surveyState.updateUserId(userId);
|
||||
}
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
responseQueue.add({
|
||||
data: responseUpdate.data,
|
||||
@@ -208,8 +168,8 @@ const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
onClose: closeSurvey,
|
||||
onFileUpload: async (file: File, params) => {
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
});
|
||||
|
||||
return await api.client.storage.uploadFile(file, params);
|
||||
@@ -227,25 +187,13 @@ 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: config.get().apiHost,
|
||||
environmentId: config.get().environmentId,
|
||||
userId: config.get().userId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
userId: inAppConfig.get().userId,
|
||||
},
|
||||
true
|
||||
);
|
||||
@@ -272,7 +220,7 @@ const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurv
|
||||
resolve(window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().apiHost}/api/packages/surveys`;
|
||||
script.src = `${inAppConfig.get().apiHost}/api/packages/surveys`;
|
||||
script.async = true;
|
||||
script.onload = () => resolve(window.formbricksSurveys);
|
||||
script.onerror = (error) => {
|
||||
@@ -1,81 +0,0 @@
|
||||
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,61 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
72
packages/js-core/src/shared/automaticActions.ts
Normal file
72
packages/js-core/src/shared/automaticActions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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,11 +1,14 @@
|
||||
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];
|
||||
}[] = [];
|
||||
@@ -15,10 +18,11 @@ 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 });
|
||||
this.queue.push({ command, checkInitialized, commandArgs: args, packageType });
|
||||
|
||||
if (!this.running) {
|
||||
this.commandPromise = new Promise((resolve) => {
|
||||
@@ -44,7 +48,9 @@ export class CommandQueue {
|
||||
|
||||
// make sure formbricks is initialized
|
||||
if (currentItem.checkInitialized) {
|
||||
const initResult = checkInitialized();
|
||||
// call different function based on package type
|
||||
const initResult =
|
||||
currentItem.packageType === "website" ? checkInitializedWebsite() : checkInitializedInApp();
|
||||
|
||||
if (initResult && initResult.ok !== true) {
|
||||
errorHandler.handle(initResult.error);
|
||||
47
packages/js-core/src/website/index.ts
Normal file
47
packages/js-core/src/website/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
43
packages/js-core/src/website/lib/actions.ts
Normal file
43
packages/js-core/src/website/lib/actions.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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();
|
||||
};
|
||||
31
packages/js-core/src/website/lib/common.ts
Normal file
31
packages/js-core/src/website/lib/common.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { WebsiteConfig } from "./config";
|
||||
import { deinitalize, initialize } from "./initialize";
|
||||
import { closeSurvey } from "./widget";
|
||||
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export const resetWebsiteConfig = async (): Promise<void> => {
|
||||
deinitalize();
|
||||
websiteConfig.resetConfig();
|
||||
};
|
||||
|
||||
export const resetConfig = async (): Promise<Result<void, NetworkError>> => {
|
||||
logger.debug("Resetting state & getting new state from backend");
|
||||
closeSurvey();
|
||||
|
||||
const syncParams = {
|
||||
environmentId: websiteConfig.get().environmentId,
|
||||
apiHost: websiteConfig.get().apiHost,
|
||||
};
|
||||
|
||||
await resetWebsiteConfig();
|
||||
try {
|
||||
await initialize(syncParams);
|
||||
return okVoid();
|
||||
} catch (e) {
|
||||
return err(e as NetworkError);
|
||||
}
|
||||
};
|
||||
77
packages/js-core/src/website/lib/config.ts
Normal file
77
packages/js-core/src/website/lib/config.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { TJsWebsiteConfig, TJsWebsiteConfigUpdateInput } from "@formbricks/types/js";
|
||||
|
||||
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
||||
|
||||
export const WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
|
||||
|
||||
export class WebsiteConfig {
|
||||
private static instance: WebsiteConfig | undefined;
|
||||
private config: TJsWebsiteConfig | null = null;
|
||||
|
||||
private constructor() {
|
||||
const localConfig = this.loadFromLocalStorage();
|
||||
|
||||
if (localConfig.ok) {
|
||||
this.config = localConfig.value;
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): WebsiteConfig {
|
||||
if (!WebsiteConfig.instance) {
|
||||
WebsiteConfig.instance = new WebsiteConfig();
|
||||
}
|
||||
return WebsiteConfig.instance;
|
||||
}
|
||||
|
||||
public update(newConfig: TJsWebsiteConfigUpdateInput): void {
|
||||
if (newConfig) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...newConfig,
|
||||
status: newConfig.status || "success",
|
||||
};
|
||||
|
||||
this.saveToLocalStorage();
|
||||
}
|
||||
}
|
||||
|
||||
public get(): TJsWebsiteConfig {
|
||||
if (!this.config) {
|
||||
throw new Error("config is null, maybe the init function was not called?");
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public loadFromLocalStorage(): Result<TJsWebsiteConfig, Error> {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedConfig = localStorage.getItem(WEBSITE_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 TJsWebsiteConfig;
|
||||
|
||||
// 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 TJsWebsiteConfig);
|
||||
}
|
||||
}
|
||||
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
}
|
||||
|
||||
private saveToLocalStorage(): Result<void, Error> {
|
||||
return wrapThrows(() => localStorage.setItem(WEBSITE_LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
|
||||
}
|
||||
|
||||
// reset the config
|
||||
|
||||
public resetConfig(): Result<void, Error> {
|
||||
this.config = null;
|
||||
|
||||
return wrapThrows(() => localStorage.removeItem(WEBSITE_LOCAL_STORAGE_KEY))();
|
||||
}
|
||||
}
|
||||
56
packages/js-core/src/website/lib/eventListeners.ts
Normal file
56
packages/js-core/src/website/lib/eventListeners.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
addExitIntentListener,
|
||||
addScrollDepthListener,
|
||||
removeExitIntentListener,
|
||||
removeScrollDepthListener,
|
||||
} from "../../shared/automaticActions";
|
||||
import {
|
||||
addClickEventListener,
|
||||
addPageUrlEventListeners,
|
||||
removeClickEventListener,
|
||||
removePageUrlEventListeners,
|
||||
} from "./noCodeActions";
|
||||
import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync";
|
||||
|
||||
let areRemoveEventListenersAdded = false;
|
||||
|
||||
export const addEventListeners = (): void => {
|
||||
addExpiryCheckListener();
|
||||
addPageUrlEventListeners();
|
||||
addClickEventListener();
|
||||
addExitIntentListener("website");
|
||||
addScrollDepthListener("website");
|
||||
};
|
||||
|
||||
export const addCleanupEventListeners = (): void => {
|
||||
if (areRemoveEventListenersAdded) return;
|
||||
window.addEventListener("beforeunload", () => {
|
||||
removeExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener("website");
|
||||
removeScrollDepthListener("website");
|
||||
});
|
||||
areRemoveEventListenersAdded = true;
|
||||
};
|
||||
|
||||
export const removeCleanupEventListeners = (): void => {
|
||||
if (!areRemoveEventListenersAdded) return;
|
||||
window.removeEventListener("beforeunload", () => {
|
||||
removeExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener("website");
|
||||
removeScrollDepthListener("website");
|
||||
});
|
||||
areRemoveEventListenersAdded = false;
|
||||
};
|
||||
|
||||
export const removeAllEventListeners = (): void => {
|
||||
removeExpiryCheckListener();
|
||||
removePageUrlEventListeners();
|
||||
removeClickEventListener();
|
||||
removeExitIntentListener("website");
|
||||
removeScrollDepthListener("website");
|
||||
removeCleanupEventListeners();
|
||||
};
|
||||
202
packages/js-core/src/website/lib/initialize.ts
Normal file
202
packages/js-core/src/website/lib/initialize.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { TJSAppConfig, TJsWebsiteConfig, TJsWebsiteConfigInput } from "@formbricks/types/js";
|
||||
|
||||
import {
|
||||
ErrorHandler,
|
||||
MissingFieldError,
|
||||
MissingPersonError,
|
||||
NetworkError,
|
||||
NotInitializedError,
|
||||
Result,
|
||||
err,
|
||||
okVoid,
|
||||
wrapThrows,
|
||||
} from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { trackAction } from "./actions";
|
||||
import { WEBSITE_LOCAL_STORAGE_KEY, WebsiteConfig } from "./config";
|
||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||
import { checkPageUrl } from "./noCodeActions";
|
||||
import { sync } from "./sync";
|
||||
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
|
||||
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
export const setIsInitialized = (value: boolean) => {
|
||||
isInitialized = value;
|
||||
};
|
||||
|
||||
export const initialize = async (
|
||||
configInput: TJsWebsiteConfigInput
|
||||
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
||||
if (getIsDebug()) {
|
||||
logger.configure({ logLevel: "debug" });
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
logger.debug("Already initialized, skipping initialization.");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
let existingConfig: TJsWebsiteConfig | undefined;
|
||||
try {
|
||||
existingConfig = websiteConfig.get();
|
||||
logger.debug("Found existing configuration.");
|
||||
} catch (e) {
|
||||
logger.debug("No existing configuration found.");
|
||||
}
|
||||
|
||||
// formbricks is in error state, skip initialization
|
||||
if (existingConfig?.status === "error") {
|
||||
logger.debug("Formbricks was set to an error state.");
|
||||
if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) {
|
||||
logger.debug("Error state is not expired, skipping initialization");
|
||||
return okVoid();
|
||||
} else {
|
||||
logger.debug("Error state is expired. Continue with initialization.");
|
||||
}
|
||||
}
|
||||
|
||||
ErrorHandler.getInstance().printStatus();
|
||||
|
||||
logger.debug("Start initialize");
|
||||
|
||||
if (!configInput.environmentId) {
|
||||
logger.debug("No environmentId provided");
|
||||
return err({
|
||||
code: "missing_field",
|
||||
field: "environmentId",
|
||||
});
|
||||
}
|
||||
|
||||
if (!configInput.apiHost) {
|
||||
logger.debug("No apiHost provided");
|
||||
|
||||
return err({
|
||||
code: "missing_field",
|
||||
field: "apiHost",
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Adding widget container to DOM");
|
||||
addWidgetContainer();
|
||||
|
||||
// let updatedAttributes: TPersonAttributes | null = null;
|
||||
// if (configInput.attributes) {
|
||||
// updatedAttributes = { ...configInput.attributes };
|
||||
// }
|
||||
|
||||
if (
|
||||
existingConfig &&
|
||||
existingConfig.state &&
|
||||
existingConfig.environmentId === configInput.environmentId &&
|
||||
existingConfig.apiHost === configInput.apiHost &&
|
||||
existingConfig.expiresAt
|
||||
) {
|
||||
logger.debug("Configuration fits init parameters.");
|
||||
if (existingConfig.expiresAt < new Date()) {
|
||||
logger.debug("Configuration expired.");
|
||||
|
||||
try {
|
||||
await sync({
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
});
|
||||
} catch (e) {
|
||||
putFormbricksInErrorState();
|
||||
}
|
||||
} else {
|
||||
logger.debug("Configuration not expired. Extending expiration.");
|
||||
websiteConfig.update(existingConfig);
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
"No valid configuration found or it has been expired. Resetting config and creating new one."
|
||||
);
|
||||
websiteConfig.resetConfig();
|
||||
logger.debug("Syncing.");
|
||||
|
||||
try {
|
||||
await sync({
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
});
|
||||
} catch (e) {
|
||||
handleErrorOnFirstInit();
|
||||
}
|
||||
|
||||
if (configInput.attributes) {
|
||||
const currentWebsiteConfig = websiteConfig.get();
|
||||
|
||||
websiteConfig.update({
|
||||
environmentId: currentWebsiteConfig.environmentId,
|
||||
apiHost: currentWebsiteConfig.apiHost,
|
||||
state: {
|
||||
...websiteConfig.get().state,
|
||||
attributes: { ...websiteConfig.get().state.attributes, ...configInput.attributes },
|
||||
},
|
||||
expiresAt: websiteConfig.get().expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// and track the new session event
|
||||
await trackAction("New Session");
|
||||
}
|
||||
|
||||
logger.debug("Adding event listeners");
|
||||
addEventListeners();
|
||||
addCleanupEventListeners();
|
||||
|
||||
setIsInitialized(true);
|
||||
logger.debug("Initialized");
|
||||
|
||||
// check page url if initialized after page load
|
||||
|
||||
checkPageUrl();
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
const handleErrorOnFirstInit = () => {
|
||||
// put formbricks in error state (by creating a new config) and throw error
|
||||
const initialErrorConfig: Partial<TJSAppConfig> = {
|
||||
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(WEBSITE_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||
throw new Error("Could not initialize formbricks");
|
||||
};
|
||||
|
||||
export const checkInitialized = (): Result<void, NotInitializedError> => {
|
||||
logger.debug("Check if initialized");
|
||||
if (!isInitialized || !ErrorHandler.initialized) {
|
||||
return err({
|
||||
code: "not_initialized",
|
||||
message: "Formbricks not initialized. Call initialize() first.",
|
||||
});
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const deinitalize = (): void => {
|
||||
logger.debug("Deinitializing");
|
||||
removeWidgetContainer();
|
||||
setIsSurveyRunning(false);
|
||||
removeAllEventListeners();
|
||||
setIsInitialized(false);
|
||||
};
|
||||
|
||||
export const putFormbricksInErrorState = (): void => {
|
||||
logger.debug("Putting formbricks in error state");
|
||||
// change formbricks status to error
|
||||
websiteConfig.update({
|
||||
...websiteConfig.get(),
|
||||
status: "error",
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
});
|
||||
deinitalize();
|
||||
};
|
||||
@@ -2,19 +2,28 @@ 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 { Config } from "./config";
|
||||
import { ErrorHandler, InvalidMatchTypeError, NetworkError, Result, err, match, ok, okVoid } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
import { WebsiteConfig } from "./config";
|
||||
import { triggerSurvey } from "./widget";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const websiteConfig = WebsiteConfig.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 } = config.get();
|
||||
const { state } = websiteConfig.get();
|
||||
const { noCodeActionClasses = [], surveys = [] } = state ?? {};
|
||||
|
||||
const actionsWithPageUrl: TActionClass[] = noCodeActionClasses.filter((action) => {
|
||||
@@ -148,7 +157,7 @@ const evaluateNoCodeConfig = (
|
||||
};
|
||||
|
||||
export const checkClickMatch = (event: MouseEvent) => {
|
||||
const { state } = config.get();
|
||||
const { state } = websiteConfig.get();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import { diffInDays } from "@formbricks/lib/utils/datetime";
|
||||
import { TJsState, TJsStateSync, TJsSyncParams } from "@formbricks/types/js";
|
||||
import { TJsWebsiteState, TJsWebsiteSyncParams } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Config } from "./config";
|
||||
import { NetworkError, Result, err, ok } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
import { getIsDebug } from "./utils";
|
||||
import { NetworkError, Result, err, ok } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { WebsiteConfig } from "./config";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let syncIntervalId: number | null = null;
|
||||
|
||||
const syncWithBackend = async (
|
||||
{ apiHost, environmentId, userId }: TJsSyncParams,
|
||||
{ apiHost, environmentId }: TJsWebsiteSyncParams,
|
||||
noCache: boolean
|
||||
): Promise<Result<TJsStateSync, NetworkError>> => {
|
||||
): Promise<Result<TJsWebsiteState, NetworkError>> => {
|
||||
try {
|
||||
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/in-app/sync`;
|
||||
const baseUrl = `${apiHost}/api/v1/client/${environmentId}/website/sync`;
|
||||
const urlSuffix = `?version=${import.meta.env.VERSION}`;
|
||||
|
||||
let fetchOptions: RequestInit = {};
|
||||
@@ -28,30 +28,8 @@ const syncWithBackend = async (
|
||||
}
|
||||
|
||||
// if user id is not available
|
||||
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 url = baseUrl + urlSuffix;
|
||||
// public survey
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -66,16 +44,13 @@ const syncWithBackend = async (
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const { data: state } = data;
|
||||
|
||||
return ok(state as TJsStateSync);
|
||||
return ok((await response.json()).data as TJsWebsiteState);
|
||||
} catch (e) {
|
||||
return err(e as NetworkError);
|
||||
}
|
||||
};
|
||||
|
||||
export const sync = async (params: TJsSyncParams, noCache = false): Promise<void> => {
|
||||
export const sync = async (params: TJsWebsiteSyncParams, noCache = false): Promise<void> => {
|
||||
try {
|
||||
const syncResult = await syncWithBackend(params, noCache);
|
||||
|
||||
@@ -83,39 +58,28 @@ export const sync = async (params: TJsSyncParams, noCache = false): Promise<void
|
||||
throw syncResult.error;
|
||||
}
|
||||
|
||||
let oldState: TJsState | undefined;
|
||||
let oldState: TJsWebsiteState | undefined;
|
||||
try {
|
||||
oldState = config.get().state;
|
||||
oldState = websiteConfig.get().state;
|
||||
} catch (e) {
|
||||
// ignore error
|
||||
}
|
||||
let state: TJsState = {
|
||||
|
||||
let state: TJsWebsiteState = {
|
||||
surveys: syncResult.value.surveys as TSurvey[],
|
||||
noCodeActionClasses: syncResult.value.noCodeActionClasses,
|
||||
product: syncResult.value.product,
|
||||
attributes: syncResult.value.person?.attributes || {},
|
||||
displays: oldState?.displays || [],
|
||||
};
|
||||
|
||||
if (!params.userId) {
|
||||
// unidentified user
|
||||
// set the displays and filter out surveys
|
||||
state = {
|
||||
...state,
|
||||
displays: oldState?.displays || [],
|
||||
};
|
||||
state = filterPublicSurveys(state);
|
||||
state = filterPublicSurveys(state);
|
||||
|
||||
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(", "));
|
||||
}
|
||||
const surveyNames = state.surveys.map((s) => s.name);
|
||||
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
|
||||
|
||||
config.update({
|
||||
websiteConfig.update({
|
||||
apiHost: params.apiHost,
|
||||
environmentId: params.environmentId,
|
||||
userId: params.userId,
|
||||
state,
|
||||
expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future
|
||||
});
|
||||
@@ -125,7 +89,7 @@ export const sync = async (params: TJsSyncParams, noCache = false): Promise<void
|
||||
}
|
||||
};
|
||||
|
||||
export const filterPublicSurveys = (state: TJsState): TJsState => {
|
||||
export const filterPublicSurveys = (state: TJsWebsiteState): TJsWebsiteState => {
|
||||
const { displays, product } = state;
|
||||
|
||||
let { surveys } = state;
|
||||
@@ -179,20 +143,19 @@ export const addExpiryCheckListener = (): void => {
|
||||
syncIntervalId = window.setInterval(async () => {
|
||||
try {
|
||||
// check if the config has not expired yet
|
||||
if (config.get().expiresAt && new Date(config.get().expiresAt) >= new Date()) {
|
||||
if (websiteConfig.get().expiresAt && new Date(websiteConfig.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,
|
||||
apiHost: websiteConfig.get().apiHost,
|
||||
environmentId: websiteConfig.get().environmentId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Error during expiry check: ${e}`);
|
||||
logger.debug("Extending config and try again later.");
|
||||
const existingConfig = config.get();
|
||||
config.update(existingConfig);
|
||||
const existingConfig = websiteConfig.get();
|
||||
websiteConfig.update(existingConfig);
|
||||
}
|
||||
}, updateInterval);
|
||||
}
|
||||
257
packages/js-core/src/website/lib/widget.ts
Normal file
257
packages/js-core/src/website/lib/widget.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import SurveyState from "@formbricks/lib/surveyState";
|
||||
import { TJSWebsiteStateDisplay } from "@formbricks/types/js";
|
||||
import { TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getDefaultLanguageCode, getLanguageCode } from "../../shared/utils";
|
||||
import { WebsiteConfig } from "./config";
|
||||
import { filterPublicSurveys } from "./sync";
|
||||
|
||||
const containerId = "formbricks-website-container";
|
||||
|
||||
const websiteConfig = WebsiteConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let isSurveyRunning = false;
|
||||
let setIsError = (_: boolean) => {};
|
||||
let setIsResponseSendingFinished = (_: boolean) => {};
|
||||
|
||||
export const setIsSurveyRunning = (value: boolean) => {
|
||||
isSurveyRunning = value;
|
||||
};
|
||||
|
||||
const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
||||
const randomNum = Math.floor(Math.random() * 100) + 1;
|
||||
return randomNum <= displayPercentage;
|
||||
};
|
||||
|
||||
export const triggerSurvey = async (survey: TSurvey, action?: string): Promise<void> => {
|
||||
// Check if the survey should be displayed based on displayPercentage
|
||||
if (survey.displayPercentage) {
|
||||
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
|
||||
if (!shouldDisplaySurvey) {
|
||||
logger.debug("Survey display skipped based on displayPercentage.");
|
||||
return; // skip displaying the survey
|
||||
}
|
||||
}
|
||||
await renderWidget(survey, action);
|
||||
};
|
||||
|
||||
const renderWidget = async (survey: TSurvey, action?: string) => {
|
||||
if (isSurveyRunning) {
|
||||
logger.debug("A survey is already running. Skipping.");
|
||||
return;
|
||||
}
|
||||
setIsSurveyRunning(true);
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
|
||||
}
|
||||
|
||||
const product = websiteConfig.get().state.product;
|
||||
const attributes = websiteConfig.get().state.attributes;
|
||||
|
||||
const isMultiLanguageSurvey = survey.languages.length > 1;
|
||||
let languageCode = "default";
|
||||
|
||||
if (isMultiLanguageSurvey && attributes) {
|
||||
const displayLanguage = getLanguageCode(survey, attributes);
|
||||
//if survey is not available in selected language, survey wont be shown
|
||||
if (!displayLanguage) {
|
||||
logger.debug("Survey not available in specified language.");
|
||||
setIsSurveyRunning(true);
|
||||
return;
|
||||
}
|
||||
languageCode = displayLanguage;
|
||||
}
|
||||
|
||||
const surveyState = new SurveyState(survey.id, null, null);
|
||||
|
||||
const responseQueue = new ResponseQueue(
|
||||
{
|
||||
apiHost: websiteConfig.get().apiHost,
|
||||
environmentId: websiteConfig.get().environmentId,
|
||||
retryAttempts: 2,
|
||||
onResponseSendingFailed: () => {
|
||||
setIsError(true);
|
||||
},
|
||||
onResponseSendingFinished: () => {
|
||||
setIsResponseSendingFinished(true);
|
||||
},
|
||||
},
|
||||
surveyState
|
||||
);
|
||||
const productOverwrites = survey.productOverwrites ?? {};
|
||||
const clickOutside = productOverwrites.clickOutsideClose ?? product.clickOutsideClose;
|
||||
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
|
||||
const placement = productOverwrites.placement ?? product.placement;
|
||||
const isBrandingEnabled = product.inAppSurveyBranding;
|
||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
|
||||
const getStyling = () => {
|
||||
// allow style overwrite is disabled from the product
|
||||
if (!product.styling.allowStyleOverwrite) {
|
||||
return product.styling;
|
||||
}
|
||||
|
||||
// allow style overwrite is enabled from the product
|
||||
if (product.styling.allowStyleOverwrite) {
|
||||
// survey style overwrite is disabled
|
||||
if (!survey.styling?.overwriteThemeStyling) {
|
||||
return product.styling;
|
||||
}
|
||||
|
||||
// survey style overwrite is enabled
|
||||
return survey.styling;
|
||||
}
|
||||
|
||||
return product.styling;
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
formbricksSurveys.renderSurveyModal({
|
||||
survey: survey,
|
||||
isBrandingEnabled: isBrandingEnabled,
|
||||
clickOutside,
|
||||
darkOverlay,
|
||||
languageCode,
|
||||
placement,
|
||||
styling: getStyling(),
|
||||
getSetIsError: (f: (value: boolean) => void) => {
|
||||
setIsError = f;
|
||||
},
|
||||
getSetIsResponseSendingFinished: (f: (value: boolean) => void) => {
|
||||
setIsResponseSendingFinished = f;
|
||||
},
|
||||
onDisplay: async () => {
|
||||
const localDisplay: TJSWebsiteStateDisplay = {
|
||||
createdAt: new Date(),
|
||||
surveyId: survey.id,
|
||||
responded: false,
|
||||
};
|
||||
|
||||
const existingDisplays = websiteConfig.get().state.displays;
|
||||
const displays = existingDisplays ? [...existingDisplays, localDisplay] : [localDisplay];
|
||||
const previousConfig = websiteConfig.get();
|
||||
|
||||
let state = filterPublicSurveys({
|
||||
...previousConfig.state,
|
||||
displays,
|
||||
});
|
||||
|
||||
websiteConfig.update({
|
||||
...previousConfig,
|
||||
state,
|
||||
});
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: websiteConfig.get().apiHost,
|
||||
environmentId: websiteConfig.get().environmentId,
|
||||
});
|
||||
const res = await api.client.display.create({
|
||||
surveyId: survey.id,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Could not create display");
|
||||
}
|
||||
|
||||
const { id } = res.data;
|
||||
|
||||
surveyState.updateDisplayId(id);
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
},
|
||||
onResponse: (responseUpdate: TResponseUpdate) => {
|
||||
const displays = websiteConfig.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 = websiteConfig.get();
|
||||
let state = filterPublicSurveys({
|
||||
...previousConfig.state,
|
||||
displays,
|
||||
});
|
||||
websiteConfig.update({
|
||||
...previousConfig,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
|
||||
responseQueue.add({
|
||||
data: responseUpdate.data,
|
||||
ttc: responseUpdate.ttc,
|
||||
finished: responseUpdate.finished,
|
||||
language: languageCode === "default" ? getDefaultLanguageCode(survey) : languageCode,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
action,
|
||||
},
|
||||
});
|
||||
},
|
||||
onClose: closeSurvey,
|
||||
onFileUpload: async (file: File, params) => {
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: websiteConfig.get().apiHost,
|
||||
environmentId: websiteConfig.get().environmentId,
|
||||
});
|
||||
|
||||
return await api.client.storage.uploadFile(file, params);
|
||||
},
|
||||
onRetry: () => {
|
||||
setIsError(false);
|
||||
responseQueue.processQueue();
|
||||
},
|
||||
});
|
||||
}, survey.delay * 1000);
|
||||
};
|
||||
|
||||
export const closeSurvey = async (): Promise<void> => {
|
||||
// remove container element from DOM
|
||||
removeWidgetContainer();
|
||||
addWidgetContainer();
|
||||
|
||||
const state = websiteConfig.get().state;
|
||||
const updatedState = filterPublicSurveys(state);
|
||||
websiteConfig.update({
|
||||
...websiteConfig.get(),
|
||||
state: updatedState,
|
||||
});
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
};
|
||||
|
||||
export const addWidgetContainer = (): void => {
|
||||
const containerElement = document.createElement("div");
|
||||
containerElement.id = containerId;
|
||||
document.body.appendChild(containerElement);
|
||||
};
|
||||
|
||||
export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(containerId)?.remove();
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.formbricksSurveys) {
|
||||
resolve(window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${websiteConfig.get().apiHost}/api/packages/surveys`;
|
||||
script.async = true;
|
||||
script.onload = () => resolve(window.formbricksSurveys);
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
reject(error);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/js-library.json",
|
||||
"include": ["src", "package.json"],
|
||||
"include": ["src", "package.json", "../types/surveys.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
36
packages/js-core/website.vite.config.ts
Normal file
36
packages/js-core/website.vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from "fs";
|
||||
import { resolve } from "path";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
import webPackageJson from "../../apps/web/package.json";
|
||||
|
||||
const config = () => {
|
||||
return defineConfig({
|
||||
define: {
|
||||
"import.meta.env.VERSION": JSON.stringify(webPackageJson.version),
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build
|
||||
minify: "terser",
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/website/index.ts"),
|
||||
name: "formbricks",
|
||||
formats: ["umd"],
|
||||
fileName: "website",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
rollupTypes: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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/js-core");
|
||||
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/api/packages/website");
|
||||
var e = document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t, e),
|
||||
setTimeout(function () {
|
||||
formbricks.init({
|
||||
environmentId: "cluqpv56n00lbxl3f8xvytyog",
|
||||
environmentId: "clvc0nye3003bubfl568et5f8",
|
||||
apiHost: "http://localhost:3000",
|
||||
});
|
||||
}, 500);
|
||||
@@ -18,15 +18,16 @@
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"source": "src/index.ts",
|
||||
"main": "dist/index.umd.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.umd.cjs",
|
||||
"types": "./dist/index.d.ts"
|
||||
"./app": {
|
||||
"import": "./dist/app.js",
|
||||
"require": "./dist/app.cjs",
|
||||
"types": "./dist/app.d.ts"
|
||||
},
|
||||
"./website": {
|
||||
"import": "./dist/website.js",
|
||||
"require": "./dist/website.cjs",
|
||||
"types": "./dist/website.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -39,6 +40,7 @@
|
||||
},
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"devDependencies": {
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"terser": "^5.30.3",
|
||||
|
||||
84
packages/js/src/app.ts
Normal file
84
packages/js/src/app.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { TFormbricksApp } from "@formbricks/js-core/app";
|
||||
import { TFormbricksWebsite } from "@formbricks/js-core/website";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricks: TFormbricksApp | TFormbricksWebsite;
|
||||
}
|
||||
}
|
||||
|
||||
let sdkLoadingPromise: Promise<void> | null = null;
|
||||
let isErrorLoadingSdk = false;
|
||||
|
||||
async function loadSDK(apiHost: string) {
|
||||
if (!window.formbricks) {
|
||||
const res = await fetch(`${apiHost}/api/packages/app`);
|
||||
if (!res.ok) throw new Error("Failed to load Formbricks App SDK");
|
||||
const sdkScript = await res.text();
|
||||
const scriptTag = document.createElement("script");
|
||||
scriptTag.innerHTML = sdkScript;
|
||||
document.head.appendChild(scriptTag);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.formbricks) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error("Formbricks SDK loading timed out"));
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type FormbricksAppMethods = {
|
||||
[K in keyof TFormbricksApp]: TFormbricksApp[K] extends Function ? K : never;
|
||||
}[keyof TFormbricksApp];
|
||||
|
||||
const formbricksProxyHandler: ProxyHandler<TFormbricksApp> = {
|
||||
get(_target, prop, _receiver) {
|
||||
return async (...args: any[]) => {
|
||||
if (!window.formbricks && !sdkLoadingPromise && !isErrorLoadingSdk) {
|
||||
const { apiHost } = args[0];
|
||||
sdkLoadingPromise = loadSDK(apiHost).catch((error) => {
|
||||
console.error(`🧱 Formbricks - Error loading SDK: ${error}`);
|
||||
sdkLoadingPromise = null;
|
||||
isErrorLoadingSdk = true;
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
if (isErrorLoadingSdk) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sdkLoadingPromise) {
|
||||
await sdkLoadingPromise;
|
||||
}
|
||||
|
||||
if (!window.formbricks) {
|
||||
throw new Error("Formbricks App SDK is not available");
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (typeof window.formbricks[prop as FormbricksAppMethods] !== "function") {
|
||||
console.error(`🧱 Formbricks App SDK does not support method ${String(prop)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return (window.formbricks[prop as FormbricksAppMethods] as Function)(...args);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const formbricksApp: TFormbricksApp = new Proxy({} as TFormbricksApp, formbricksProxyHandler);
|
||||
export default formbricksApp;
|
||||
@@ -1,89 +1,2 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricks: any;
|
||||
}
|
||||
}
|
||||
|
||||
let sdkLoadingPromise: Promise<void> | null = null;
|
||||
let isErrorLoadingSdk = false;
|
||||
|
||||
async function loadSDK(apiHost: string) {
|
||||
if (!window.formbricks) {
|
||||
const res = await fetch(`${apiHost}/api/packages/js-core`);
|
||||
if (!res.ok) throw new Error("Failed to load Formbricks SDK");
|
||||
const sdkScript = await res.text();
|
||||
const scriptTag = document.createElement("script");
|
||||
scriptTag.innerHTML = sdkScript;
|
||||
document.head.appendChild(scriptTag);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.formbricks) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error("Formbricks SDK loading timed out"));
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const formbricksProxyHandler: ProxyHandler<any> = {
|
||||
get(_target, prop, _receiver) {
|
||||
return async (...args: any[]) => {
|
||||
if (!window.formbricks && !sdkLoadingPromise && !isErrorLoadingSdk) {
|
||||
// This happens most likely when the user calls a method before `formbricks.init`
|
||||
|
||||
if (prop !== "init") {
|
||||
console.error("🧱 Formbricks - You need to call formbricks.init before calling any other method");
|
||||
return;
|
||||
}
|
||||
|
||||
// still need to check if the apiHost is passed
|
||||
if (!args[0]) {
|
||||
console.error("🧱 Formbricks - You need to pass the apiHost as the first argument");
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiHost } = args[0];
|
||||
sdkLoadingPromise = loadSDK(apiHost).catch((error) => {
|
||||
console.error(`🧱 Formbricks - Error loading SDK: ${error}`);
|
||||
sdkLoadingPromise = null;
|
||||
isErrorLoadingSdk = true;
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
if (isErrorLoadingSdk) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sdkLoadingPromise) {
|
||||
await sdkLoadingPromise;
|
||||
}
|
||||
|
||||
if (!window.formbricks) {
|
||||
throw new Error("Formbricks SDK is not available");
|
||||
}
|
||||
|
||||
if (typeof window.formbricks[prop] !== "function") {
|
||||
console.error(`🧱 Formbricks - SDK does not support method ${String(prop)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.formbricks[prop](...args);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const formbricks = new Proxy({}, formbricksProxyHandler);
|
||||
|
||||
export default formbricks;
|
||||
export { default as formbricksApp } from "./app";
|
||||
export { default as formbricksWebsite } from "./website";
|
||||
|
||||
75
packages/js/src/website.ts
Normal file
75
packages/js/src/website.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { TFormbricksWebsite } from "@formbricks/js-core/website";
|
||||
|
||||
let sdkLoadingPromise: Promise<void> | null = null;
|
||||
let isErrorLoadingSdk = false;
|
||||
|
||||
async function loadSDK(apiHost: string) {
|
||||
if (!window.formbricks) {
|
||||
const res = await fetch(`${apiHost}/api/packages/website`);
|
||||
if (!res.ok) throw new Error("Failed to load Formbricks Website SDK");
|
||||
const sdkScript = await res.text();
|
||||
const scriptTag = document.createElement("script");
|
||||
scriptTag.innerHTML = sdkScript;
|
||||
document.head.appendChild(scriptTag);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.formbricks) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error("Formbricks SDK loading timed out"));
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type FormbricksWebsiteMethods = {
|
||||
[K in keyof TFormbricksWebsite]: TFormbricksWebsite[K] extends Function ? K : never;
|
||||
}[keyof TFormbricksWebsite];
|
||||
|
||||
const formbricksProxyHandler: ProxyHandler<TFormbricksWebsite> = {
|
||||
get(_target, prop, _receiver) {
|
||||
return async (...args: any[]) => {
|
||||
if (!window.formbricks && !sdkLoadingPromise && !isErrorLoadingSdk) {
|
||||
const { apiHost } = args[0];
|
||||
sdkLoadingPromise = loadSDK(apiHost).catch((error) => {
|
||||
console.error(`🧱 Formbricks - Error loading SDK: ${error}`);
|
||||
sdkLoadingPromise = null;
|
||||
isErrorLoadingSdk = true;
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
if (isErrorLoadingSdk) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sdkLoadingPromise) {
|
||||
await sdkLoadingPromise;
|
||||
}
|
||||
|
||||
if (!window.formbricks) {
|
||||
throw new Error("Formbricks Website SDK is not available");
|
||||
}
|
||||
|
||||
if (typeof window.formbricks[prop as FormbricksWebsiteMethods] !== "function") {
|
||||
console.error(`🧱 Formbricks Website SDK does not support method ${String(prop)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return (window.formbricks[prop as FormbricksWebsiteMethods] as Function)(...args);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const formbricksWebsite: TFormbricksWebsite = new Proxy({} as TFormbricksWebsite, formbricksProxyHandler);
|
||||
export default formbricksWebsite;
|
||||
@@ -10,11 +10,12 @@ const config = () => {
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
entry: {
|
||||
app: resolve(__dirname, "src/app.ts"),
|
||||
website: resolve(__dirname, "src/website.ts"),
|
||||
},
|
||||
name: "formbricksJsWrapper",
|
||||
formats: ["es", "umd"],
|
||||
// the proper extensions will be added
|
||||
fileName: "index",
|
||||
formats: ["es", "cjs"],
|
||||
},
|
||||
},
|
||||
plugins: [dts({ rollupTypes: true })],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
import { selectDisplay } from "../../service";
|
||||
|
||||
export const mockEnvironmentId = "clqkr5961000108jyfnjmbjhi";
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
import {
|
||||
createDisplay,
|
||||
@@ -41,12 +42,6 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
|
||||
it("it should throw a ValidationError if the inputs are invalid", async () => {
|
||||
await expect(service(...args)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.person.findFirst.mockResolvedValue(mockPerson);
|
||||
});
|
||||
@@ -83,7 +78,7 @@ describe("Tests for getDisplay", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getDisplaysByPersonId, "123", 1);
|
||||
testInputValidation(getDisplaysByPersonId, "123#", 1);
|
||||
|
||||
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
|
||||
@@ -22,8 +22,9 @@ import {
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TResponse,
|
||||
TResponseFilterCriteria,
|
||||
@@ -132,13 +133,6 @@ beforeEach(() => {
|
||||
prisma.response.count.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
// utility function to test input validation for all services
|
||||
const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
|
||||
it("it should throw a ValidationError if the inputs are invalid", async () => {
|
||||
await expect(service(...args)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
};
|
||||
|
||||
describe("Tests for getResponsesByPersonId", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns all responses associated with a given person ID", async () => {
|
||||
@@ -157,7 +151,7 @@ describe("Tests for getResponsesByPersonId", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponsesByPersonId, "123", 1);
|
||||
testInputValidation(getResponsesByPersonId, "123#", 1);
|
||||
|
||||
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -189,7 +183,7 @@ describe("Tests for getResponsesBySingleUseId", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponseBySingleUseId, "123", "123");
|
||||
testInputValidation(getResponseBySingleUseId, "123#", "123#");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -291,7 +285,7 @@ describe("Tests for getResponse service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponse, "123");
|
||||
testInputValidation(getResponse, "123#");
|
||||
|
||||
it("Throws ResourceNotFoundError if no response is found", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue(null);
|
||||
@@ -337,7 +331,7 @@ describe("Tests for getAttributesFromResponses service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponsePersonAttributes, "1");
|
||||
testInputValidation(getResponsePersonAttributes, "123#");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -590,7 +584,7 @@ describe("Tests for getResponsesByEnvironmentId", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponsesByEnvironmentId, "123");
|
||||
testInputValidation(getResponsesByEnvironmentId, "123#");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -634,7 +628,7 @@ describe("Tests for updateResponse Service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(updateResponse, "123", {});
|
||||
testInputValidation(updateResponse, "123#", {});
|
||||
|
||||
it("Throws ResourceNotFoundError if no response is found", async () => {
|
||||
prisma.response.findUnique.mockResolvedValue(null);
|
||||
@@ -675,7 +669,7 @@ describe("Tests for deleteResponse service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(deleteResponse, "123");
|
||||
testInputValidation(deleteResponse, "123#");
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -713,7 +707,7 @@ describe("Tests for getResponseCountBySurveyId service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getResponseCountBySurveyId, "123");
|
||||
testInputValidation(getResponseCountBySurveyId, "123#");
|
||||
|
||||
it("Throws a generic Error for other unexpected issues", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
import { testInputValidation } from "../../vitestSetup";
|
||||
import {
|
||||
cloneSegment,
|
||||
createSegment,
|
||||
@@ -153,7 +153,7 @@ describe("Tests for getSegments service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSegments, "123");
|
||||
testInputValidation(getSegments, "123#");
|
||||
|
||||
it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -185,7 +185,7 @@ describe("Tests for getSegment service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSegment, "123");
|
||||
testInputValidation(getSegment, "123#");
|
||||
|
||||
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
|
||||
prisma.segment.findUnique.mockResolvedValue(null);
|
||||
@@ -225,7 +225,7 @@ describe("Tests for updateSegment service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(updateSegment, "123", {});
|
||||
testInputValidation(updateSegment, "123#", {});
|
||||
|
||||
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
|
||||
prisma.segment.findUnique.mockResolvedValue(null);
|
||||
@@ -265,7 +265,7 @@ describe("Tests for deleteSegment service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(deleteSegment, "123");
|
||||
testInputValidation(deleteSegment, "123#");
|
||||
|
||||
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
|
||||
prisma.segment.findUnique.mockResolvedValue(null);
|
||||
@@ -309,7 +309,7 @@ describe("Tests for cloneSegment service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(cloneSegment, "123", "123");
|
||||
testInputValidation(cloneSegment, "123#", "123#");
|
||||
|
||||
it("Throws a ResourceNotFoundError error if the user segment does not exist", async () => {
|
||||
prisma.segment.findUnique.mockResolvedValue(null);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { productCache } from "../product/cache";
|
||||
import { getProductByEnvironmentId } from "../product/service";
|
||||
import { responseCache } from "../response/cache";
|
||||
import { segmentCache } from "../segment/cache";
|
||||
import { createSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service";
|
||||
import { createSegment, deleteSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service";
|
||||
import { transformSegmentFiltersToAttributeFilters } from "../segment/utils";
|
||||
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
|
||||
import { diffInDays, formatDateFields } from "../utils/datetime";
|
||||
@@ -416,7 +416,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const { triggers, environmentId, segment, languages, ...surveyData } = updatedSurvey;
|
||||
const { triggers, environmentId, segment, languages, type, ...surveyData } = updatedSurvey;
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
@@ -470,19 +470,42 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
data.triggers = processTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
|
||||
}
|
||||
|
||||
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
|
||||
if (segment) {
|
||||
// parse the segment filters:
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
throw new InvalidInputError("Invalid user segment filters");
|
||||
if (type === "app") {
|
||||
// parse the segment filters:
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
throw new InvalidInputError("Invalid user segment filters");
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSegment(segment.id, segment);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Error updating survey");
|
||||
}
|
||||
} else {
|
||||
if (segment.isPrivate) {
|
||||
await deleteSegment(segment.id);
|
||||
} else {
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
disconnect: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSegment(segment.id, segment);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Error updating survey");
|
||||
}
|
||||
segmentCache.revalidate({
|
||||
id: segment.id,
|
||||
environmentId: segment.environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
surveyData.updatedAt = new Date();
|
||||
@@ -490,6 +513,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
data = {
|
||||
...surveyData,
|
||||
...data,
|
||||
type,
|
||||
};
|
||||
|
||||
// Remove scheduled status when runOnDate is not set
|
||||
@@ -519,6 +543,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
|
||||
// @ts-expect-error
|
||||
const modifiedSurvey: TSurvey = {
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
triggers: updatedSurvey.triggers ? updatedSurvey.triggers : [], // Include triggers from updatedSurvey
|
||||
@@ -530,6 +556,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
environmentId: modifiedSurvey.environmentId,
|
||||
segmentId: modifiedSurvey.segment?.id,
|
||||
});
|
||||
|
||||
return modifiedSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -552,6 +579,26 @@ export async function deleteSurvey(surveyId: string) {
|
||||
select: selectSurvey,
|
||||
});
|
||||
|
||||
if (deletedSurvey.type === "app") {
|
||||
const deletedSegment = await prisma.segment.delete({
|
||||
where: {
|
||||
title: surveyId,
|
||||
isPrivate: true,
|
||||
environmentId_title: {
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
title: surveyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedSegment) {
|
||||
segmentCache.revalidate({
|
||||
id: deletedSegment.id,
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
responseCache.revalidate({
|
||||
surveyId,
|
||||
environmentId: deletedSurvey.environmentId,
|
||||
@@ -611,7 +658,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
attributeFilters: undefined,
|
||||
};
|
||||
|
||||
if (surveyBody.type === "web" && data.thankYouCard) {
|
||||
if ((surveyBody.type === "website" || surveyBody.type === "app") && data.thankYouCard) {
|
||||
data.thankYouCard.buttonLabel = undefined;
|
||||
data.thankYouCard.buttonLink = undefined;
|
||||
}
|
||||
@@ -636,10 +683,46 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
select: selectSurvey,
|
||||
});
|
||||
|
||||
// if the survey created is an "app" survey, we also create a private segment for it.
|
||||
if (survey.type === "app") {
|
||||
const newSegment = await createSegment({
|
||||
environmentId,
|
||||
surveyId: survey.id,
|
||||
filters: [],
|
||||
title: survey.id,
|
||||
isPrivate: true,
|
||||
});
|
||||
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: newSegment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: newSegment.id,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
|
||||
// @ts-expect-error
|
||||
const transformedSurvey: TSurvey = {
|
||||
...survey,
|
||||
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
|
||||
segment: null,
|
||||
...(survey.segment && {
|
||||
segment: {
|
||||
...survey.segment,
|
||||
surveys: survey.segment.surveys.map((survey) => survey.id),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await subscribeTeamMembersToSurveyResponses(environmentId, survey.id);
|
||||
@@ -821,7 +904,7 @@ export const getSyncSurveys = async (
|
||||
let surveys: TSurvey[] | TLegacySurvey[] = await getSurveys(environmentId);
|
||||
|
||||
// filtered surveys for running and web
|
||||
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
|
||||
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
|
||||
|
||||
// if no surveys are left, return an empty array
|
||||
if (surveys.length === 0) {
|
||||
@@ -1035,6 +1118,8 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
|
||||
// @ts-expect-error
|
||||
const modifiedSurvey: TSurvey = {
|
||||
...prismaSurvey, // Properties from prismaSurvey
|
||||
triggers: prismaSurvey.triggers.map((trigger) => trigger.actionClass.name),
|
||||
@@ -1072,6 +1157,8 @@ export const getSurveysBySegmentId = async (segmentId: string): Promise<TSurvey[
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
|
||||
// @ts-expect-error
|
||||
const transformedSurvey: TSurvey = {
|
||||
...surveyPrisma,
|
||||
triggers: surveyPrisma.triggers.map((trigger) => trigger.actionClass.name),
|
||||
|
||||
@@ -73,6 +73,9 @@ export const mockProduct: TProduct = {
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
styling: {
|
||||
allowStyleOverwrite: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockDisplay = {
|
||||
@@ -102,6 +105,7 @@ export const mockUser: TUser = {
|
||||
weeklySummary: {},
|
||||
unsubscribedTeamIds: [],
|
||||
},
|
||||
role: "other",
|
||||
};
|
||||
|
||||
export const mockPrismaPerson: Prisma.PersonGetPayload<{
|
||||
@@ -207,8 +211,27 @@ export const mockTeamOutput: TTeam = {
|
||||
},
|
||||
};
|
||||
|
||||
export const mockSyncSurveyOutput: SurveyMock = {
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
productOverwrites: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
segment: null,
|
||||
segmentId: null,
|
||||
resultShareKey: null,
|
||||
inlineTriggers: null,
|
||||
languages: mockSurveyLanguages,
|
||||
...baseSurveyProperties,
|
||||
};
|
||||
|
||||
export const mockSurveyOutput: SurveyMock = {
|
||||
type: "web",
|
||||
type: "website",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
@@ -227,7 +250,7 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
};
|
||||
|
||||
export const createSurveyInput: TSurveyInput = {
|
||||
type: "web",
|
||||
type: "website",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [mockActionClass.name],
|
||||
@@ -235,7 +258,7 @@ export const createSurveyInput: TSurveyInput = {
|
||||
};
|
||||
|
||||
export const updateSurveyInput: TSurvey = {
|
||||
type: "web",
|
||||
type: "website",
|
||||
status: "inProgress",
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [mockActionClass.name],
|
||||
@@ -257,3 +280,8 @@ export const mockTransformedSurveyOutput = {
|
||||
...mockSurveyOutput,
|
||||
triggers: mockSurveyOutput.triggers.map((trigger) => trigger.actionClass.name),
|
||||
};
|
||||
|
||||
export const mockTransformedSyncSurveyOutput = {
|
||||
...mockSyncSurveyOutput,
|
||||
triggers: mockSurveyOutput.triggers.map((trigger) => trigger.actionClass.name),
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { prisma } from "../../__mocks__/database";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
import { testInputValidation } from "../../vitestSetup";
|
||||
import {
|
||||
createSurvey,
|
||||
deleteSurvey,
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
mockPrismaPerson,
|
||||
mockProduct,
|
||||
mockSurveyOutput,
|
||||
mockSyncSurveyOutput,
|
||||
mockTeamOutput,
|
||||
mockTransformedSurveyOutput,
|
||||
mockTransformedSyncSurveyOutput,
|
||||
mockUser,
|
||||
updateSurveyInput,
|
||||
} from "./__mock__/survey.mock";
|
||||
@@ -52,7 +54,7 @@ describe("Tests for getSurvey", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSurvey, "123");
|
||||
testInputValidation(getSurvey, "123#");
|
||||
|
||||
it("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -88,7 +90,7 @@ describe("Tests for getSurveysByActionClassId", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSurveysByActionClassId, "123");
|
||||
testInputValidation(getSurveysByActionClassId, "123#");
|
||||
|
||||
it("should throw an error if there is an unknown error", async () => {
|
||||
const mockErrorMessage = "Unknown error occurred";
|
||||
@@ -115,7 +117,7 @@ describe("Tests for getSurveys", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSurveysByActionClassId, "123");
|
||||
testInputValidation(getSurveysByActionClassId, "123#");
|
||||
|
||||
it("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
@@ -150,7 +152,7 @@ describe("Tests for updateSurvey", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(updateSurvey, "123");
|
||||
testInputValidation(updateSurvey, "123#");
|
||||
|
||||
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
|
||||
prisma.survey.findUnique.mockRejectedValueOnce(
|
||||
@@ -189,7 +191,7 @@ describe("Tests for deleteSurvey", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(deleteSurvey, "123");
|
||||
testInputValidation(deleteSurvey, "123#");
|
||||
|
||||
it("should throw an error if there is an unknown error", async () => {
|
||||
const mockErrorMessage = "Unknown error occurred";
|
||||
@@ -236,7 +238,7 @@ describe("Tests for createSurvey", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(createSurvey, "123", createSurveyInput);
|
||||
testInputValidation(createSurvey, "123#", createSurveyInput);
|
||||
|
||||
it("should throw an error if there is an unknown error", async () => {
|
||||
const mockErrorMessage = "Unknown error occurred";
|
||||
@@ -261,7 +263,7 @@ describe("Tests for duplicateSurvey", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(duplicateSurvey, "123", "123");
|
||||
testInputValidation(duplicateSurvey, "123#", "123#");
|
||||
|
||||
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
|
||||
prisma.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
|
||||
@@ -276,21 +278,26 @@ describe("Tests for duplicateSurvey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getSyncedSurveys", () => {
|
||||
describe("Tests for getSyncSurveys", () => {
|
||||
describe("Happy Path", () => {
|
||||
beforeEach(() => {
|
||||
prisma.product.findFirst.mockResolvedValueOnce(mockProduct);
|
||||
prisma.product.findFirst.mockResolvedValueOnce({
|
||||
...mockProduct,
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
logo: null,
|
||||
});
|
||||
prisma.display.findMany.mockResolvedValueOnce([mockDisplay]);
|
||||
prisma.attributeClass.findMany.mockResolvedValueOnce([mockAttributeClass]);
|
||||
});
|
||||
|
||||
it("Returns synced surveys", async () => {
|
||||
prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
|
||||
prisma.survey.findMany.mockResolvedValueOnce([mockSyncSurveyOutput]);
|
||||
prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson);
|
||||
const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", {
|
||||
version: "1.7.0",
|
||||
});
|
||||
expect(surveys).toEqual([mockTransformedSurveyOutput]);
|
||||
expect(surveys).toEqual([mockTransformedSyncSurveyOutput]);
|
||||
});
|
||||
|
||||
it("Returns an empty array if no surveys are found", async () => {
|
||||
@@ -304,7 +311,7 @@ describe("Tests for getSyncedSurveys", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSyncSurveys, "123", {});
|
||||
testInputValidation(getSyncSurveys, "123#", {});
|
||||
|
||||
it("does not find a Product", async () => {
|
||||
prisma.product.findFirst.mockResolvedValueOnce(null);
|
||||
@@ -340,7 +347,7 @@ describe("Tests for getSurveyCount service", () => {
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSurveyCount, "123");
|
||||
testInputValidation(getSurveyCount, "123#");
|
||||
|
||||
it("Throws a generic Error for other unexpected issues", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
|
||||
@@ -376,7 +376,7 @@ export const getMonthlyTeamResponseCount = async (teamId: string): Promise<numbe
|
||||
where: {
|
||||
AND: [
|
||||
{ survey: { environmentId: { in: environmentIds } } },
|
||||
{ survey: { type: "web" } },
|
||||
{ survey: { type: { in: ["app", "website"] } } },
|
||||
{ createdAt: { gte: firstDayOfMonth } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/nextjs.json",
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"exclude": ["dist", "build", "node_modules", "../../packages/types/surveys.d.ts"],
|
||||
"compilerOptions": {
|
||||
"downlevelIteration": true,
|
||||
"baseUrl": ".",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "preact/hooks";
|
||||
// @ts-expect-error
|
||||
import { JSXInternal } from "preact/src/jsx";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -89,7 +90,6 @@ export default function FileInput({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// @ts-expect-error
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
@@ -97,7 +97,6 @@ export default function FileInput({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// @ts-expect-error
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
|
||||
if (!allowMultipleFiles && files.length > 1) {
|
||||
@@ -109,6 +108,7 @@ export default function FileInput({
|
||||
const validFiles = files.filter((file) =>
|
||||
allowedFileExtensions && allowedFileExtensions.length > 0
|
||||
? allowedFileExtensions.includes(
|
||||
// @ts-expect-error
|
||||
file.type.substring(file.type.lastIndexOf("/") + 1) as TAllowedFileExtension
|
||||
)
|
||||
: true
|
||||
@@ -119,6 +119,7 @@ export default function FileInput({
|
||||
|
||||
for (const file of validFiles) {
|
||||
if (maxSizeInMB) {
|
||||
// @ts-expect-error
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
const bufferBytes = fileBuffer.byteLength;
|
||||
@@ -129,7 +130,9 @@ export default function FileInput({
|
||||
} else {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// @ts-expect-error
|
||||
const response = await onFileUpload(file, { allowedFileExtensions, surveyId });
|
||||
// @ts-expect-error
|
||||
setSelectedFiles([...selectedFiles, file]);
|
||||
|
||||
uploadedUrls.push(response);
|
||||
@@ -145,7 +148,9 @@ export default function FileInput({
|
||||
} else {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// @ts-expect-error
|
||||
const response = await onFileUpload(file, { allowedFileExtensions, surveyId });
|
||||
// @ts-expect-error
|
||||
setSelectedFiles([...selectedFiles, file]);
|
||||
|
||||
uploadedUrls.push(response);
|
||||
|
||||
@@ -13,7 +13,8 @@ interface AutoCloseProps {
|
||||
export function AutoCloseWrapper({ survey, onClose, children }: AutoCloseProps) {
|
||||
const [countDownActive, setCountDownActive] = useState(true);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const showAutoCloseProgressBar = countDownActive && survey.type === "web";
|
||||
const isAppSurvey = survey.type === "app" || survey.type === "website";
|
||||
const showAutoCloseProgressBar = countDownActive && isAppSurvey;
|
||||
|
||||
const startCountdown = () => {
|
||||
if (!survey.autoClose) return;
|
||||
|
||||
@@ -5,15 +5,6 @@ import { h, render } from "preact";
|
||||
|
||||
import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbricksSurveys";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricksSurveys: {
|
||||
renderSurveyInline: (props: SurveyInlineProps) => void;
|
||||
renderSurveyModal: (props: SurveyModalProps) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const renderSurveyInline = (props: SurveyInlineProps) => {
|
||||
addStylesToDom();
|
||||
addCustomThemeToDom({ styling: props.styling });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/js-library.json",
|
||||
"include": ["src"],
|
||||
"include": ["src", "../types/surveys.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2021",
|
||||
|
||||
@@ -12,32 +12,45 @@ const ZSurveyWithTriggers = ZSurvey.extend({
|
||||
|
||||
export type TSurveyWithTriggers = z.infer<typeof ZSurveyWithTriggers>;
|
||||
|
||||
export const ZJSStateDisplay = z.object({
|
||||
export const ZJSWebsiteStateDisplay = z.object({
|
||||
createdAt: z.date(),
|
||||
surveyId: z.string().cuid(),
|
||||
responded: z.boolean(),
|
||||
});
|
||||
|
||||
export type TJSStateDisplay = z.infer<typeof ZJSStateDisplay>;
|
||||
export type TJSWebsiteStateDisplay = z.infer<typeof ZJSWebsiteStateDisplay>;
|
||||
|
||||
export const ZJsStateSync = z.object({
|
||||
export const ZJsAppStateSync = z.object({
|
||||
person: ZPersonClient.nullish(),
|
||||
surveys: z.union([z.array(ZSurvey), z.array(ZLegacySurvey)]),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
});
|
||||
|
||||
export type TJsStateSync = z.infer<typeof ZJsStateSync>;
|
||||
export type TJsAppStateSync = z.infer<typeof ZJsAppStateSync>;
|
||||
|
||||
export const ZJsState = z.object({
|
||||
export const ZJsWebsiteStateSync = ZJsAppStateSync.omit({ person: true });
|
||||
|
||||
export type TJsWebsiteStateSync = z.infer<typeof ZJsWebsiteStateSync>;
|
||||
|
||||
export const ZJsAppState = z.object({
|
||||
attributes: ZPersonAttributes,
|
||||
surveys: z.array(ZSurvey),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
displays: z.array(ZJSStateDisplay).optional(),
|
||||
});
|
||||
|
||||
export type TJsState = z.infer<typeof ZJsState>;
|
||||
export type TJsAppState = z.infer<typeof ZJsAppState>;
|
||||
|
||||
export const ZJsWebsiteState = z.object({
|
||||
surveys: z.array(ZSurvey),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
displays: z.array(ZJSWebsiteStateDisplay),
|
||||
attributes: ZPersonAttributes.optional(),
|
||||
});
|
||||
|
||||
export type TJsWebsiteState = z.infer<typeof ZJsWebsiteState>;
|
||||
|
||||
export const ZJsLegacyState = z.object({
|
||||
person: ZPerson.nullable().or(z.object({})),
|
||||
@@ -45,25 +58,17 @@ export const ZJsLegacyState = z.object({
|
||||
surveys: z.array(ZSurveyWithTriggers),
|
||||
noCodeActionClasses: z.array(ZActionClass),
|
||||
product: ZProduct,
|
||||
displays: z.array(ZJSStateDisplay).optional(),
|
||||
displays: z.array(ZJSWebsiteStateDisplay).optional(),
|
||||
});
|
||||
|
||||
export type TJsLegacyState = z.infer<typeof ZJsLegacyState>;
|
||||
|
||||
export const ZJsPublicSyncInput = z.object({
|
||||
export const ZJsWebsiteSyncInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
version: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TJsPublicSyncInput = z.infer<typeof ZJsPublicSyncInput>;
|
||||
|
||||
export const ZJsSyncInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
userId: z.string().optional().optional(),
|
||||
jsVersion: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TJsSyncInput = z.infer<typeof ZJsSyncInput>;
|
||||
export type TJsWebsiteSyncInput = z.infer<typeof ZJsWebsiteSyncInput>;
|
||||
|
||||
export const ZJsSyncLegacyInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
@@ -74,37 +79,66 @@ export const ZJsSyncLegacyInput = z.object({
|
||||
|
||||
export type TJsSyncLegacyInput = z.infer<typeof ZJsSyncLegacyInput>;
|
||||
|
||||
export const ZJsConfig = z.object({
|
||||
export const ZJsWebsiteConfig = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
userId: z.string().optional(),
|
||||
state: ZJsState,
|
||||
state: ZJsWebsiteState,
|
||||
expiresAt: z.date(),
|
||||
status: z.enum(["success", "error"]).optional(),
|
||||
});
|
||||
|
||||
export type TJsConfig = z.infer<typeof ZJsConfig>;
|
||||
export type TJsWebsiteConfig = z.infer<typeof ZJsWebsiteConfig>;
|
||||
|
||||
export const ZJsConfigUpdateInput = z.object({
|
||||
export const ZJSAppConfig = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
userId: z.string().optional(),
|
||||
state: ZJsState,
|
||||
userId: z.string(),
|
||||
state: ZJsAppState,
|
||||
expiresAt: z.date(),
|
||||
status: z.enum(["success", "error"]).optional(),
|
||||
});
|
||||
|
||||
export type TJsConfigUpdateInput = z.infer<typeof ZJsConfigUpdateInput>;
|
||||
export type TJSAppConfig = z.infer<typeof ZJSAppConfig>;
|
||||
|
||||
export const ZJsConfigInput = z.object({
|
||||
export const ZJsWebsiteConfigUpdateInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
state: ZJsWebsiteState,
|
||||
expiresAt: z.date(),
|
||||
status: z.enum(["success", "error"]).optional(),
|
||||
});
|
||||
|
||||
export type TJsWebsiteConfigUpdateInput = z.infer<typeof ZJsWebsiteConfigUpdateInput>;
|
||||
|
||||
export const ZJsAppConfigUpdateInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
userId: z.string(),
|
||||
state: ZJsAppState,
|
||||
expiresAt: z.date(),
|
||||
status: z.enum(["success", "error"]).optional(),
|
||||
});
|
||||
|
||||
export type TJsAppConfigUpdateInput = z.infer<typeof ZJsAppConfigUpdateInput>;
|
||||
|
||||
export const ZJsWebsiteConfigInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
|
||||
userId: z.string().optional(),
|
||||
attributes: ZPersonAttributes.optional(),
|
||||
});
|
||||
|
||||
export type TJsConfigInput = z.infer<typeof ZJsConfigInput>;
|
||||
export type TJsWebsiteConfigInput = z.infer<typeof ZJsWebsiteConfigInput>;
|
||||
|
||||
export const ZJsAppConfigInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
|
||||
userId: z.string(),
|
||||
attributes: ZPersonAttributes.optional(),
|
||||
});
|
||||
|
||||
export type TJsAppConfigInput = z.infer<typeof ZJsAppConfigInput>;
|
||||
|
||||
export const ZJsPeopleUserIdInput = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
@@ -137,13 +171,21 @@ export const ZJsActionInput = z.object({
|
||||
|
||||
export type TJsActionInput = z.infer<typeof ZJsActionInput>;
|
||||
|
||||
export const ZJsSyncParams = z.object({
|
||||
export const ZJsWesbiteActionInput = ZJsActionInput.omit({ userId: true });
|
||||
|
||||
export type TJsWesbiteActionInput = z.infer<typeof ZJsWesbiteActionInput>;
|
||||
|
||||
export const ZJsAppSyncParams = z.object({
|
||||
environmentId: z.string().cuid(),
|
||||
apiHost: z.string(),
|
||||
userId: z.string().optional(),
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export type TJsSyncParams = z.infer<typeof ZJsSyncParams>;
|
||||
export type TJsAppSyncParams = z.infer<typeof ZJsAppSyncParams>;
|
||||
|
||||
export const ZJsWebsiteSyncParams = ZJsAppSyncParams.omit({ userId: true });
|
||||
|
||||
export type TJsWebsiteSyncParams = z.infer<typeof ZJsWebsiteSyncParams>;
|
||||
|
||||
const ZJsSettingsSurvey = ZSurvey.pick({
|
||||
id: true,
|
||||
@@ -174,3 +216,7 @@ export const ZJsSettings = z.object({
|
||||
});
|
||||
|
||||
export type TSettings = z.infer<typeof ZJsSettings>;
|
||||
|
||||
export const ZJsPackageType = z.union([z.literal("app"), z.literal("website")]);
|
||||
|
||||
export type TJsPackageType = z.infer<typeof ZJsPackageType>;
|
||||
|
||||
@@ -275,6 +275,7 @@ export type TBaseFilter = {
|
||||
connector: TSegmentConnector;
|
||||
resource: TSegmentFilter | TBaseFilters;
|
||||
};
|
||||
|
||||
export type TBaseFilters = TBaseFilter[];
|
||||
|
||||
// here again, we refine the filters to make sure that the filters are valid
|
||||
@@ -301,15 +302,15 @@ const refineFilters = (filters: TBaseFilters): boolean => {
|
||||
// The filters can be nested, so we need to use z.lazy to define the type
|
||||
// more on recusrsive types -> https://zod.dev/?id=recursive-types
|
||||
|
||||
// TODO: Figure out why this is not working, and then remove the ts-ignore
|
||||
// @ts-ignore
|
||||
export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
|
||||
.lazy(() =>
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string().cuid2(),
|
||||
connector: ZSegmentConnector,
|
||||
resource: z.union([ZSegmentFilter, ZSegmentFilters]),
|
||||
})
|
||||
)
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().cuid2(),
|
||||
connector: ZSegmentConnector,
|
||||
resource: z.union([ZSegmentFilter, z.lazy(() => ZSegmentFilters)]),
|
||||
})
|
||||
)
|
||||
.refine(refineFilters, {
|
||||
message: "Invalid filters applied",
|
||||
|
||||
10
packages/types/surveys.d.ts
vendored
Normal file
10
packages/types/surveys.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SurveyInlineProps, SurveyModalProps } from "./formbricksSurveys";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
formbricksSurveys: {
|
||||
renderSurveyInline: (props: SurveyInlineProps) => void;
|
||||
renderSurveyModal: (props: SurveyModalProps) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -443,7 +443,7 @@ export const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "r
|
||||
|
||||
export type TSurveyDisplayOption = z.infer<typeof ZSurveyDisplayOption>;
|
||||
|
||||
export const ZSurveyType = z.enum(["web", "email", "link", "mobile"]);
|
||||
export const ZSurveyType = z.enum(["link", "app", "website"]);
|
||||
|
||||
export type TSurveyType = z.infer<typeof ZSurveyType>;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Code, Link2Icon } from "lucide-react";
|
||||
import { Code, EarthIcon, Link2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -57,14 +57,23 @@ export const SurveyCard = ({
|
||||
: `/environments/${environment.id}/surveys/${survey.id}/summary`;
|
||||
}, [survey.status, survey.id, environment.id]);
|
||||
|
||||
const SurveyTypeIndicator = ({ type }: { type: string }) => (
|
||||
const SurveyTypeIndicator = ({ type }: { type: TSurvey["type"] }) => (
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-600">
|
||||
{type === "web" ? (
|
||||
{type === "app" && (
|
||||
<>
|
||||
<Code className="h-4 w-4" />
|
||||
<span> In-app</span>
|
||||
<span>App</span>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{type === "website" && (
|
||||
<>
|
||||
<EarthIcon className="h-4 w-4" />
|
||||
<span> Website</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "link" && (
|
||||
<>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
<span> Link</span>
|
||||
|
||||
@@ -32,7 +32,8 @@ const statusOptions: TFilterOption[] = [
|
||||
];
|
||||
const typeOptions: TFilterOption[] = [
|
||||
{ label: "Link", value: "link" },
|
||||
{ label: "In-app", value: "web" },
|
||||
{ label: "App", value: "app" },
|
||||
{ label: "Website", value: "website" },
|
||||
];
|
||||
|
||||
const sortOptions: TSortOption[] = [
|
||||
@@ -99,7 +100,7 @@ export const SurveyFilters = ({
|
||||
};
|
||||
|
||||
const handleTypeChange = (value: string) => {
|
||||
if (value === "link" || value === "web") {
|
||||
if (value === "link" || value === "app" || value === "website") {
|
||||
if (type.includes(value)) {
|
||||
setSurveyFilters((prev) => ({ ...prev, type: prev.type.filter((v) => v !== value) }));
|
||||
} else {
|
||||
|
||||
@@ -56,28 +56,8 @@ const SaveAsNewSegmentModal: React.FC<SaveAsNewSegmentModalProps> = ({
|
||||
const handleSaveSegment: SubmitHandler<SaveAsNewSegmentModalForm> = async (data) => {
|
||||
if (!segment || !segment?.filters.length) return;
|
||||
|
||||
try {
|
||||
// if the segment is private, update it to add title, description and make it public
|
||||
// otherwise, create a new segment
|
||||
|
||||
const createSegment = async () => {
|
||||
setIsLoading(true);
|
||||
if (!!segment && segment?.isPrivate) {
|
||||
const updatedSegment = await onUpdateSegment(segment.environmentId, segment.id, {
|
||||
...segment,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
isPrivate: false,
|
||||
filters: segment?.filters,
|
||||
});
|
||||
|
||||
toast.success("Segment updated successfully");
|
||||
setSegment(updatedSegment);
|
||||
|
||||
setIsSegmentEditorOpen(false);
|
||||
handleReset();
|
||||
return;
|
||||
}
|
||||
|
||||
const createdSegment = await onCreateSegment({
|
||||
environmentId: localSurvey.environmentId,
|
||||
surveyId: localSurvey.id,
|
||||
@@ -93,6 +73,44 @@ const SaveAsNewSegmentModal: React.FC<SaveAsNewSegmentModalProps> = ({
|
||||
setIsLoading(false);
|
||||
toast.success("Segment created successfully");
|
||||
handleReset();
|
||||
};
|
||||
|
||||
const updateSegment = async () => {
|
||||
if (!!segment && segment?.isPrivate) {
|
||||
const updatedSegment = await onUpdateSegment(segment.environmentId, segment.id, {
|
||||
...segment,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
isPrivate: false,
|
||||
filters: segment?.filters,
|
||||
});
|
||||
|
||||
toast.success("Segment updated successfully");
|
||||
setSegment(updatedSegment);
|
||||
|
||||
setIsSegmentEditorOpen(false);
|
||||
handleReset();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// if the segment is private, update it to add title, description and make it public
|
||||
// otherwise, create a new segment
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (!!segment) {
|
||||
if (segment.id === "temp") {
|
||||
await createSegment();
|
||||
} else {
|
||||
await updateSegment();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await createSegment();
|
||||
return;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message);
|
||||
setIsLoading(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user