feat: website surveys (#2423)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-04-23 18:51:28 +05:30
committed by GitHub
parent 4f0edcd473
commit 0e6cfbfaa5
101 changed files with 4736 additions and 3383 deletions

View File

@@ -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 },

View File

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

View File

@@ -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 &apos;Free&apos;
@@ -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 &apos;Paid&apos;
@@ -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 &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;
</p>
</div>
</div>
</div>
</div>
</div>

View 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&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</div>
</div>
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-slate-300">
On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "}
<strong>reinitialized</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.reset();
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-slate-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Reset&apos; and
try again.
</p>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.track("New Session");
}}>
Track New Session
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;New Session&apos;. You will
find it in the Actions Tab.
</p>
</div>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.track("Exit Intent");
}}>
Track Exit Intent
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;Exit Intent&apos;. You can also
move your mouse to the top of the browser to trigger the exit intent.
</p>
</div>
</div>
<div className="pt-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
onClick={() => {
formbricksWebsite.track("50% Scroll");
}}>
Track 50% Scroll
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-slate-300">
This button sends an Action to the Formbricks API called &apos;50% Scroll&apos;. You can also
scroll down to trigger the 50% scroll.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,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"]
}

View File

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

View File

@@ -28,7 +28,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import formbricks from "@formbricks/js";
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";

View File

@@ -11,7 +11,7 @@ import toast from "react-hot-toast";
import { COLOR_DEFAULTS, PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } 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);

View File

@@ -6,7 +6,7 @@ import { Variants, motion } from "framer-motion";
import { useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/product";
import { TSurvey } 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>

View File

@@ -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&apos;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">

View File

@@ -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 ? (

View File

@@ -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

View File

@@ -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 (
<>

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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">

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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`;

View File

@@ -9,7 +9,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { 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"),

View File

@@ -9,7 +9,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { 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"),

View File

@@ -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 };

View File

@@ -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"),

View File

@@ -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(

View File

@@ -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 =

View File

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

View File

@@ -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 [
{

View File

@@ -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

View File

@@ -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();

View File

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

View File

@@ -64,7 +64,7 @@ export const finishOnboarding = async (page: Page, deleteExampleSurvey: boolean
await expect(page.getByText("My Product")).toBeVisible();
let currentDir = process.cwd();
let htmlFilePath = currentDir + "/packages/js-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;
};

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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",

View File

@@ -239,10 +239,10 @@ model SurveyAttributeFilter {
}
enum SurveyType {
email
link
mobile
web
website
app
}
enum displayOptions {

View File

@@ -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,
}),
],
});
};

View File

@@ -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",

View 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;

View File

@@ -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) {

View File

@@ -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()");

View File

@@ -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))();
}
}

View File

@@ -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();
};

View File

@@ -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
});

View 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;
};

View File

@@ -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 {

View 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;
}
};

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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;
}
};

View 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;
}
};

View File

@@ -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);

View 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;

View 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();
};

View 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);
}
};

View 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))();
}
}

View 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();
};

View 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();
};

View File

@@ -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;
}

View File

@@ -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);
}

View 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);
}
});
};

View File

@@ -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,

View 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;

View File

@@ -2,12 +2,12 @@
<script type="text/javascript">
!(function () {
var t = document.createElement("script");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/api/packages/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);

View File

@@ -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
View 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;

View File

@@ -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";

View 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;

View File

@@ -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 })],

View File

@@ -1,7 +1,5 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { selectDisplay } from "../../service";
export const mockEnvironmentId = "clqkr5961000108jyfnjmbjhi";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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);

View File

@@ -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),

View File

@@ -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),
};

View File

@@ -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";

View File

@@ -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 } },
],
},

View File

@@ -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": ".",

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 });

View File

@@ -1,6 +1,6 @@
{
"extends": "@formbricks/tsconfig/js-library.json",
"include": ["src"],
"include": ["src", "../types/surveys.d.ts"],
"compilerOptions": {
"allowImportingTsExtensions": true,
"isolatedModules": true,

View File

@@ -5,7 +5,7 @@
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"moduleResolution": "node",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "ES2021",

View File

@@ -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>;

View File

@@ -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
View File

@@ -0,0 +1,10 @@
import { SurveyInlineProps, SurveyModalProps } from "./formbricksSurveys";
declare global {
interface Window {
formbricksSurveys: {
renderSurveyInline: (props: SurveyInlineProps) => void;
renderSurveyModal: (props: SurveyModalProps) => void;
};
}
}

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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