Compare commits

...

26 Commits

Author SHA1 Message Date
Neil Chauhan
41e56c0651 feat: fix and add recall from previous questions with recall string 2023-11-23 18:49:59 +05:30
Neil Chauhan
f4f55481d6 feat: add the new @mention functionality to display recall values based on dummy options 2023-11-22 18:05:28 +05:30
Matti Nannt
3f8bf4c34c chore: simplify getPersonByUserId by removing legacy person support (#1649) 2023-11-20 21:07:40 +00:00
Matti Nannt
91ceffba01 fix: personByUserId not cached properly (#1644) 2023-11-20 20:05:58 +00:00
Shaik_Asif
8c38495812 fix: typo in template (#1648) 2023-11-20 19:58:05 +00:00
Matti Nannt
c8c98499ed chore: Simplify person service by removing complex getOrCreatePerson function (#1643) 2023-11-20 17:22:11 +00:00
Shubham Palriwala
af181eabdc feat: formbricks/api package as per js package 1.2.2 (#1640)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-20 16:16:39 +00:00
Neil Chauhan
822c48ff52 fix: headline alignment issue (#1641)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-20 15:02:46 +00:00
Neil Chauhan
70d211a038 fix: thank you card headline issue FOR-1489 (#1639) 2023-11-20 08:25:15 +00:00
Dhruwang Jariwala
a77ce55a1d fix: Error screen on survey Editor refresh (#1635) 2023-11-19 12:47:44 +00:00
Matti Nannt
a376eb9b51 fix: caching issue by simplifying person service (#1636) 2023-11-17 19:15:17 +00:00
Matti Nannt
f11c47d4ca chore: make getSyncSurveys cached by default (#1609) 2023-11-17 18:29:08 +00:00
Dhruwang Jariwala
4baea07471 fix: wording on look & feel page (#1632) 2023-11-17 09:46:43 +00:00
Johannes
ff87be717c fix: add canonical URL for lp (#1633) 2023-11-17 09:31:25 +00:00
Matti Nannt
e3e595af9a chore: upgrade packages, fix edge runtime issues (#1629) 2023-11-16 21:45:48 +00:00
Matti Nannt
3dae10d665 feat: load react email serverside (#1628) 2023-11-16 19:58:06 +00:00
Dhruwang Jariwala
6727ccf1cd fix: response delete in single response card (#1624) 2023-11-16 12:58:32 +00:00
Shubham Palriwala
9242ab3a7d feat: rate limit client API endpoints (#1566) 2023-11-16 12:57:19 +00:00
Matti Nannt
e9d8de3574 fix: zapier integration not able to pull past responses (#1627) 2023-11-16 12:07:30 +00:00
Dhruwang Jariwala
0a252e5827 fix: hidden field not working with prefilling on 1st question (#1602)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-16 09:20:04 +00:00
Dhruwang Jariwala
632f6068c4 feat: test template (#1620) 2023-11-16 09:09:46 +00:00
Dhruwang Jariwala
4d280e04d1 fix: logic jumps (#1623) 2023-11-16 08:45:51 +00:00
Krishanu Saha
73bde4fda6 fix: #1357 button text color changed on nearwhite background color (#1365)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-11-15 15:47:59 +00:00
Midka
9d4e21f8a7 feat(packages/surveys): ability to customize colors & other improvements (#916)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Neil Chauhan <neilchauhan2@gmail.com>
2023-11-15 15:29:27 +00:00
Dhruwang Jariwala
3eeea7d1b2 fix: survey editor issue (#1619) 2023-11-15 13:03:28 +00:00
Dhruwang Jariwala
32268a8ec3 fix: auto focus issue (#1618) 2023-11-15 11:35:01 +00:00
141 changed files with 4406 additions and 3924 deletions

View File

@@ -1,152 +0,0 @@
import formbricks from "@formbricks/js";
import { useRouter } from "next/router";
import { FormEvent } from "react";
export default function SiginPage() {
const router = useRouter();
const submitAction = (e: FormEvent) => {
e.preventDefault();
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
formbricks.setEmail("matti@example.com");
formbricks.setUserId("123456");
formbricks.setAttribute("Plan", "Premium");
}
router.push("/app");
};
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{" "}
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
start your 14-day free trial
</a>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white px-4 py-8 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={submitAction}>
<div>
<label htmlFor="email" className="block text-sm font-medium leading-6 text-gray-900">
Email address
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900">
Password
</label>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Sign in
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with Facebook</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M20 10c0-5.523-4.477-10-10-10S0 4.477 0 10c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V10h2.54V7.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V10h2.773l-.443 2.89h-2.33v6.988C16.343 19.128 20 14.991 20 10z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with Twitter</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
</div>
<div>
<a
href="#"
className="inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0">
<span className="sr-only">Sign in with GitHub</span>
<svg className="h-5 w-5" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,202 +0,0 @@
import formbricks from "@formbricks/js";
import Image from "next/image";
import { useEffect, useState } from "react";
import fbsetup from "../../public/fb-setup.png";
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
return (
<div className="h-full 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>
<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 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 dark:text-slate-300 sm:flex sm:items-center sm:text-base">
<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-400 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-gray-600 dark:bg-gray-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-gray-300">
On formbricks.reset() a few things happen: <strong>New person is created</strong> and{" "}
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.reset();
}}>
Reset
</button>
<p className="text-xs text-slate-700 dark:text-gray-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="p-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text only</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS ID");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + Css ID</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Inner Text + CSS Class");
}}>
Inner Text
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Inner Text + CSS Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID and Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID + Class</p>
</div>
</div>
<div className="p-6">
<div>
<button
id="css-id"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("ID + Class");
}}>
ID only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">ID only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-class mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class only");
}}>
Class only
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class only</p>
</div>
</div>
<div className="p-6">
<div>
<button
className="css-1 css-2 mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
console.log("Class + Class");
}}>
Class + Class
</button>
</div>
<div>
<p className="text-xs text-slate-700 dark:text-gray-300">Class + Class</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Example on overriding packages/js colors */
.dark {
--fb-brand-color: red;
--fb-brand-text-color: white;
--fb-border-color: green;
--fb-border-color-highlight: var(--slate-500);
--fb-focus-color: red;
--fb-heading-color: yellow;
--fb-subheading-color: green;
--fb-info-text-color: orange;
--fb-signature-text-color: blue;
--fb-survey-background-color: black;
--fb-accent-background-color: rgb(13, 13, 12);
--fb-accent-background-color-selected: red;
--fb-placeholder-color: white;
--fb-shadow-color: yellow;
--fb-rating-fill: var(--yellow-300);
--fb-rating-hover: var(--yellow-500);
--fb-back-btn-border: currentColor;
--fb-submit-btn-border: transparent;
--fb-rating-selected: black;
}

View File

@@ -146,7 +146,7 @@ This set of API can be used to
{
"id": "lkjaxb73ulydzeumhd51sx9g",
"type": "openText",
"headline": "What is the main benefit your receive from My Product?",
"headline": "What is the main benefit you receive from My Product?",
"required": true
},
{

View File

@@ -134,7 +134,7 @@ export const templates: TTemplate[] = [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "What is the main benefit your receive from Formbricks?",
headline: "What is the main benefit you receive from Formbricks?",
inputType: "text",
longAnswer: true,
required: true,

View File

@@ -30,6 +30,7 @@ export default function MetaInformation({
<meta name="image" content={`https://${BASE_URL}/favicon.ico`} />
<meta property="og:image" content={`https://${BASE_URL}/social-image.png`} />
<link rel="icon" type="image/x-icon" href={`https://${BASE_URL}/favicon.ico`} />
<link rel="canonical" href="https://formbricks.com/" />
<meta name="msapplication-TileColor" content="#00C4B8" />
<meta name="msapplication-TileImage" content={`https://${BASE_URL}/favicon.ico`} />
<meta property="og:image:alt" content="Open Source Experience Management, Privacy-first" />

1
apps/web/.env Symbolic link
View File

@@ -0,0 +1 @@
../../.env

View File

@@ -9,7 +9,11 @@ interface WidgetStatusIndicatorProps {
}
export default async function WidgetStatusIndicator({ environmentId, type }: WidgetStatusIndicatorProps) {
const [environment] = await Promise.all([getEnvironment(environmentId)]);
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const stati = {
notImplemented: {

View File

@@ -27,6 +27,11 @@ export function EditFormbricksBranding({
);
const [updatingBranding, setUpdatingBranding] = useState(false);
const getTextFromType = (type) => {
if (type === "linkSurvey") return "Link Surveys";
if (type === "inAppSurvey") return "In App Surveys";
};
const toggleBranding = async () => {
try {
setUpdatingBranding(true);
@@ -52,8 +57,8 @@ export function EditFormbricksBranding({
<div className="mb-4">
<Alert>
<AlertDescription>
To remove the Formbricks branding from the <span className="font-semibold">{type} surveys</span>
, please{" "}
To remove the Formbricks branding from the&nbsp;
<span className="font-semibold">{getTextFromType(type)}</span>, please&nbsp;
{type === "linkSurvey" ? (
<span className="underline">
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade your plan.</Link>

View File

@@ -1,5 +1,6 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
@@ -32,3 +33,13 @@ export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArg
}
return await sendEmbedSurveyPreviewEmail(to, subject, html);
};
export const getEmailHtmlAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
return await getEmailTemplateHtml(surveyId);
};

View File

@@ -1,17 +1,17 @@
"use client";
import LinkTab from "./shareEmbedTabs/LinkTab";
import EmailTab from "./shareEmbedTabs/EmailTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
import { useMemo, useState } from "react";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { cn } from "@formbricks/lib/cn";
import { DialogContent, Dialog } from "@formbricks/ui/Dialog";
import { Button } from "@formbricks/ui/Button";
import { LinkIcon, EnvelopeIcon, CodeBracketIcon } from "@heroicons/react/24/outline";
import { TProduct } from "@formbricks/types/product";
import { TProfile } from "@formbricks/types/profile";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
import { CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import EmailTab from "./shareEmbedTabs/EmailTab";
import LinkTab from "./shareEmbedTabs/LinkTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
interface ShareEmbedSurveyProps {
survey: TSurvey;
@@ -43,16 +43,6 @@ export default function ShareEmbedSurvey({
const [activeId, setActiveId] = useState(tabs[0].id);
const componentMap = {
link: isSingleUseLinkSurvey ? (
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
) : (
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
),
email: <EmailTab survey={survey} surveyUrl={surveyUrl} email={email} brandColor={surveyBrandColor} />,
webpage: <WebpageTab surveyUrl={surveyUrl} />,
};
return (
<Dialog
open={open}
@@ -87,7 +77,15 @@ export default function ShareEmbedSurvey({
</div>
<div className="flex w-full grow flex-col gap-6 bg-gray-50 px-4 py-6 lg:p-6">
<div className="flex h-full overflow-y-scroll lg:h-[590px] lg:overflow-y-visible">
{componentMap[activeId]}
{isSingleUseLinkSurvey ? (
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={webAppUrl} />
) : activeId === "link" ? (
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={surveyBrandColor} />
) : activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebpageTab surveyUrl={surveyUrl} />
) : null}
</div>
<div className="mx-auto flex max-w-max rounded-md bg-slate-100 p-1 lg:hidden">
{tabs.slice(0, 2).map((tab) => (

View File

@@ -1,59 +1,55 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { AuthenticationError } from "@formbricks/types/errors";
import { sendEmailAction } from "../../actions";
import { Button } from "@formbricks/ui/Button";
import CodeBlock from "@formbricks/ui/CodeBlock";
import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid";
import {
Column,
Container,
Button as EmailButton,
Link,
Row,
Section,
Tailwind,
Text,
render,
Img,
} from "@react-email/components";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
import { getEmailHtmlAction, sendEmailAction } from "../../actions";
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
interface EmailTabProps {
survey: TSurvey;
surveyUrl: string;
surveyId: string;
email: string;
brandColor: string;
}
export default function EmailTab({ survey, surveyUrl, email, brandColor }: EmailTabProps) {
export default function EmailTab({ surveyId, email }: EmailTabProps) {
const [showEmbed, setShowEmbed] = useState(false);
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
useEffect(() => {
getData();
async function getData() {
const emailHtml = await getEmailHtmlAction(surveyId);
setEmailHtmlPreview(emailHtml);
}
});
const subject = "Formbricks Email Survey Preview";
const emailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: false });
}, []);
const previewEmailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: true });
}, []);
const sendPreviewEmail = async () => {
const sendPreviewEmail = async (html) => {
try {
await sendEmailAction({ html: previewEmailValues.html, subject, to: email });
await sendEmailAction({
html,
subject,
to: email,
});
toast.success("Email sent!");
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error("You are not authenticated to perform this action.");
return;
}
toast.error("Something went wrong. Please try again later.");
}
};
@@ -68,7 +64,7 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
aria-label="Embed survey in your website"
onClick={() => {
toast.success("Embed code copied to clipboard!");
navigator.clipboard.writeText(emailValues.html);
navigator.clipboard.writeText(emailHtml);
}}
className="shrink-0"
EndIcon={DocumentDuplicateIcon}>
@@ -76,12 +72,11 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
</Button>
) : (
<>
<Input type="email" placeholder="user@mail.com" className="h-11 grow bg-white" value={email} />
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={sendPreviewEmail}
onClick={() => sendPreviewEmail(emailHtmlPreview)}
EndIcon={EnvelopeIcon}
className="shrink-0">
Send Preview
@@ -92,7 +87,9 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
variant="darkCTA"
title="view embed code for email"
aria-label="view embed code for email"
onClick={() => setShowEmbed(!showEmbed)}
onClick={() => {
setShowEmbed(!showEmbed);
}}
EndIcon={CodeBracketIcon}
className="shrink-0">
{showEmbed ? "Hide Embed Code" : "View Embed Code"}
@@ -104,10 +101,10 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html"
showCopyToClipboard={false}>
{emailValues.html}
{emailHtml}
</CodeBlock>
) : (
<div className="">
<div>
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
@@ -118,7 +115,13 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
To : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">Subject : {subject}</div>
<div className="p-4">{previewEmailValues.Component}</div>
<div className="p-4">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
)}
@@ -126,308 +129,3 @@ export default function EmailTab({ survey, surveyUrl, email, brandColor }: Email
</div>
);
}
const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const Template = getEmailTemplate(survey, surveyUrl, brandColor, preview);
const html = render(Template, { pretty: true });
const htmlWithoutDoctype = html.replace(doctype, "");
return { Component: Template, html: htmlWithoutDoctype };
};
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case TSurveyQuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
"bg-brand-color mt-4 cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};

View File

@@ -0,0 +1,319 @@
import { cn } from "@formbricks/lib/cn";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import { isLight } from "@/app/lib/utils";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import {
Column,
Container,
Button as EmailButton,
Img,
Link,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
interface EmailTemplateProps {
survey: TSurvey;
surveyUrl: string;
brandColor: string;
}
export const getEmailTemplateHtml = async (surveyId) => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
}
const product = await getProductByEnvironmentId(survey.environmentId);
if (!product) {
throw new Error("Product not found");
}
const brandColor = product.brandColor;
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const html = render(<EmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
pretty: true,
});
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
};
const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => {
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&`;
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case TSurveyQuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionType.PictureSelection:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
src={choice.imageUrl}
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg"
/>
) : (
<Link
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
target="_blank"
className="mb-1 mr-1 inline-block h-[110px] w-[220px] rounded-lg">
<Img src={choice.imageUrl} className="h-full w-full rounded-lg" />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};

View File

@@ -38,6 +38,7 @@ export default function CTAQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -34,6 +34,7 @@ export default function ConsentQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -170,6 +170,7 @@ export default function MultipleChoiceMultiForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -170,6 +170,7 @@ export default function MultipleChoiceSingleForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -36,6 +36,7 @@ export default function NPSQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -59,6 +59,7 @@ export default function OpenQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -38,6 +38,7 @@ export default function PictureSelectionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">
{showSubheader && (

View File

@@ -1,10 +1,10 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
import { Label } from "@formbricks/ui/Label";
import { getPlacementStyle } from "@/app/lib/preview";
import { cn } from "@formbricks/lib/cn";
import { TPlacement } from "@formbricks/types/common";
import { Label } from "@formbricks/ui/Label";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
@@ -18,8 +18,8 @@ type TPlacementProps = {
setCurrentPlacement: (placement: TPlacement) => void;
setOverlay: (overlay: string) => void;
overlay: string;
setClickOutside: (clickOutside: boolean) => void;
clickOutside: boolean;
setClickOutsideClose: (clickOutside: boolean) => void;
clickOutsideClose: boolean;
};
export default function Placement({
@@ -27,8 +27,8 @@ export default function Placement({
currentPlacement,
setOverlay,
overlay,
setClickOutside,
clickOutside,
setClickOutsideClose,
clickOutsideClose,
}: TPlacementProps) {
return (
<>
@@ -78,8 +78,8 @@ export default function Placement({
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(value) => setClickOutside(value === "allow")}
value={clickOutside ? "allow" : "disallow"}
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />

View File

@@ -1,11 +1,12 @@
"use client";
import { TSurveyQuestion } from "@formbricks/types/surveys";
import { TSurveyQuestion, TSurvey } from "@formbricks/types/surveys";
import FileInput from "@formbricks/ui/FileInput";
import { Input } from "@formbricks/ui/Input";
// import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { ImagePlusIcon } from "lucide-react";
import { RefObject, useState } from "react";
import { RefObject, useEffect, useState } from "react";
import { MentionsInput, Mention } from "react-mentions";
interface QuestionFormInputProps {
question: TSurveyQuestion;
@@ -14,17 +15,45 @@ interface QuestionFormInputProps {
isInValid: boolean;
environmentId: string;
ref?: RefObject<HTMLInputElement>;
localSurvey: TSurvey;
}
const QuestionFormInput = ({
question,
localSurvey,
questionIdx,
updateQuestion,
isInValid,
// isInValid,
environmentId,
ref,
}: QuestionFormInputProps) => {
}: // ref,
QuestionFormInputProps) => {
const [showImageUploader, setShowImageUploader] = useState<boolean>(!!question.imageUrl);
const [mentionDisplayString, setMentionDisplayString] = useState<string>(question.headline);
const [prevHeadline, setPreviousHeadline] = useState<string>("");
const [data, setData] = useState<
{
id: string;
display: string;
}[]
>();
useEffect(() => {
setData(
localSurvey.questions.map((q) => {
if (question.id !== q.id)
return {
id: q.id,
display: q.headline,
};
else {
return {
id: "",
display: "",
};
}
})
);
}, [localSurvey, question]);
return (
<div className="mt-3">
@@ -42,7 +71,7 @@ const QuestionFormInput = ({
/>
)}
<div className="flex items-center space-x-2">
<Input
{/* <Input
autoFocus
ref={ref}
id="headline"
@@ -50,7 +79,59 @@ const QuestionFormInput = ({
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
isInvalid={isInValid && question.headline.trim() === ""}
/>
/> */}
<MentionsInput
autoFocus
// ref={ref}
id="headline"
name="headline"
value={mentionDisplayString}
onChange={(event, _, newPlainTextValue) => {
setPreviousHeadline(question.headline);
updateQuestion(questionIdx, {
headline: newPlainTextValue,
});
setMentionDisplayString(event.target.value);
}}
style={{
width: "100%",
border: "1px rgb(203 213 225) solid",
borderRadius: "4px",
textArea: {
border: "none",
marginBottom: "1rem",
},
suggestions: {
list: {
backgroundColor: "white",
fontSize: 14,
},
item: {
padding: "5px 15px",
"&focused": {
backgroundColor: "#cee4e5",
},
},
},
}}>
<Mention
data={data || []}
trigger="@"
appendSpaceOnAdd
markup="[__display__]"
displayTransform={(_, display: string) => display}
onAdd={(id: string) => {
updateQuestion(questionIdx, {
recallString: prevHeadline + "recall:" + id,
});
}}
style={{
backgroundColor: "#cee4e5",
padding: "0.2rem",
marginLeft: "0.5rem",
}}
/>
</MentionsInput>
<ImagePlusIcon
aria-label="Toggle image uploader"
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"

View File

@@ -182,6 +182,8 @@ export default function QuestionsView({
setLocalSurvey(updatedSurvey);
};
console.log(localSurvey.questions);
return (
<div className="mt-12 px-5 py-4">
<div className="mb-5 flex flex-col gap-5">

View File

@@ -1,4 +1,4 @@
import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { TSurvey, TSurveyRatingQuestion, TSurveyQuestions } from "@formbricks/types/surveys";
import QuestionFormInput from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionFormInput";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -36,6 +36,7 @@ export default function RatingQuestionForm({
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
localSurvey={localSurvey}
/>
<div className="mt-3">

View File

@@ -19,7 +19,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
const [open, setOpen] = useState(false);
const { type, productOverwrites } = localSurvey;
const { brandColor, clickOutside, darkOverlay, placement, highlightBorderColor } = productOverwrites ?? {};
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
productOverwrites ?? {};
const togglePlacement = () => {
setLocalSurvey({
@@ -93,12 +94,12 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
});
};
const handleClickOutside = (clickOutside: boolean) => {
const handleClickOutsideClose = (clickOutsideClose: boolean) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
clickOutside,
clickOutsideClose,
},
});
};
@@ -163,8 +164,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
setCurrentPlacement={handlePlacementChange}
setOverlay={handleOverlay}
overlay={darkOverlay ? "dark" : "light"}
setClickOutside={handleClickOutside}
clickOutside={!!clickOutside}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
</div>
</div>

View File

@@ -12,8 +12,8 @@ import { TSurvey } from "@formbricks/types/surveys";
import { TProduct } from "@formbricks/types/product";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TActionClass } from "@formbricks/types/actionClasses";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { TMembershipRole } from "@formbricks/types/memberships";
import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading";
interface SurveyEditorProps {
survey: TSurvey;
@@ -59,7 +59,7 @@ export default function SurveyEditor({
}, [localSurvey?.type]);
if (!localSurvey) {
return <ErrorComponent />;
return <Loading />;
}
return (

View File

@@ -15,7 +15,7 @@ import { SplitIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createSurveyAction } from "../actions";
import { customSurvey, templates } from "./templates";
import { customSurvey, templates, testTemplate } from "./templates";
type TemplateList = {
environmentId: string;
@@ -147,7 +147,10 @@ export default function TemplateList({
</div>
)}
</button>
{filteredTemplates.map((template: TTemplate) => (
{(process.env.NODE_ENV === "development"
? [...filteredTemplates, testTemplate]
: filteredTemplates
).map((template: TTemplate) => (
<div
onClick={() => {
const newTemplate = replacePresetPlaceholders(template, product);

View File

@@ -21,6 +21,308 @@ const welcomeCardDefault: TSurveyWelcomeCard = {
timeToFinish: true,
};
export const testTemplate: TTemplate = {
name: "Test template",
description: "Test template consisting of all questions",
preset: {
name: "Test template",
questions: [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter some text:",
required: true,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter some text:",
required: false,
inputType: "text",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter an email",
required: true,
inputType: "email",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter an email",
required: false,
inputType: "email",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a number",
required: true,
inputType: "number",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a number",
required: false,
inputType: "number",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a phone number",
required: true,
inputType: "phone",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a phone number",
required: false,
inputType: "phone",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a url",
required: true,
inputType: "url",
},
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "This is an open text question",
subheader: "Please enter a url",
required: false,
inputType: "url",
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceSingle,
headline: "This ia a Multiple choice Single question",
subheader: "Please select one of the following",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceSingle,
headline: "This ia a Multiple choice Single question",
subheader: "Please select one of the following",
required: false,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option 1",
},
{
id: createId(),
label: "Option 2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceMulti,
headline: "This ia a Multiple choice Multiple question",
subheader: "Please select some from the following",
required: true,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.MultipleChoiceMulti,
headline: "This ia a Multiple choice Multiple question",
subheader: "Please select some from the following",
required: false,
shuffleOption: "none",
choices: [
{
id: createId(),
label: "Option1",
},
{
id: createId(),
label: "Option2",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "number",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "number",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "smiley",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "smiley",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: true,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "star",
},
{
id: createId(),
type: TSurveyQuestionType.Rating,
headline: "This is a rating question",
required: false,
lowerLabel: "Low",
upperLabel: "High",
range: 5,
scale: "star",
},
{
id: createId(),
type: TSurveyQuestionType.CTA,
headline: "This is a CTA question",
html: "This is a test CTA",
buttonLabel: "Click",
buttonUrl: "https://formbricks.com",
buttonExternal: true,
required: true,
dismissButtonLabel: "Maybe later",
},
{
id: createId(),
type: TSurveyQuestionType.CTA,
headline: "This is a CTA question",
html: "This is a test CTA",
buttonLabel: "Click",
buttonUrl: "https://formbricks.com",
buttonExternal: true,
required: false,
dismissButtonLabel: "Maybe later",
},
{
id: createId(),
type: TSurveyQuestionType.PictureSelection,
headline: "This is a Picture select",
allowMulti: true,
required: true,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.PictureSelection,
headline: "This is a Picture select",
allowMulti: true,
required: false,
choices: [
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
},
{
id: createId(),
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
},
],
},
{
id: createId(),
type: TSurveyQuestionType.Consent,
headline: "This is a Consent question",
required: true,
label: "I agree to the terms and conditions",
dismissButtonLabel: "Skip",
},
{
id: createId(),
type: TSurveyQuestionType.Consent,
headline: "This is a Consent question",
required: false,
label: "I agree to the terms and conditions",
dismissButtonLabel: "Skip",
},
],
thankYouCard: thankYouCardDefault,
welcomeCard: {
enabled: false,
timeToFinish: false,
},
hiddenFields: {
enabled: false,
},
},
};
export const templates: TTemplate[] = [
{
name: "Product Market Fit (Superhuman)",
@@ -104,7 +406,7 @@ export const templates: TTemplate[] = [
{
id: createId(),
type: TSurveyQuestionType.OpenText,
headline: "What is the main benefit your receive from {{productName}}?",
headline: "What is the main benefit you receive from {{productName}}?",
required: true,
inputType: "text",
},

View File

@@ -10,6 +10,7 @@ import { Label } from "@formbricks/ui/Label";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
type Product = {
done: () => void;
@@ -73,6 +74,10 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
if (!product) {
return <ErrorComponent />;
}
const buttonStyle = {
backgroundColor: color,
color: isLight(color) ? "black" : "white",
};
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
@@ -140,7 +145,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
</fieldset>
</div>
<div className="mt-4 flex w-full justify-end">
<Button className="pointer-events-none" style={{ backgroundColor: color }}>
<Button className="pointer-events-none" style={buttonStyle}>
Next
</Button>
</div>

View File

@@ -0,0 +1,95 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
params: {
userId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { userId, environmentId } = context.params;
const personId = userId; // legacy workaround for formbricks-js 1.2.0 & 1.2.1
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { key, value } = inputValidation.data;
const person = await getPerson(personId);
if (!person) {
return responses.notFoundResponse("Person", personId, true);
}
let attributeClass = await getAttributeClassByName(environmentId, key);
// create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(environmentId, key, "code");
}
if (!attributeClass) {
return responses.internalServerErrorResponse("Unable to create attribute class", true);
}
// upsert attribute (update or create)
await updatePersonAttribute(personId, attributeClass.id, value);
personCache.revalidate({
id: personId,
environmentId,
});
surveyCache.revalidate({
environmentId,
});
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsState = {
person,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -1,5 +1,5 @@
import { responses } from "@/app/lib/api/response";
import { markDisplayResponded } from "@formbricks/lib/display/service";
import { markDisplayRespondedLegacy } from "@formbricks/lib/display/service";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -14,7 +14,7 @@ export async function POST(_: Request, { params }: { params: { displayId: string
}
try {
const display = await markDisplayResponded(displayId);
const display = await markDisplayRespondedLegacy(displayId);
return responses.successResponse(
{
...display,

View File

@@ -1,8 +1,8 @@
import { responses } from "@/app/lib/api/response";
import { updateDisplay } from "@formbricks/lib/display/service";
import { TDisplayCreateInput, ZDisplayUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { updateDisplayLegacy } from "@formbricks/lib/display/service";
import { ZDisplayLegacyUpdateInput } from "@formbricks/types/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -16,8 +16,8 @@ export async function PUT(
if (!displayId) {
return responses.badRequestResponse("Missing displayId", undefined, true);
}
const displayInput: TDisplayCreateInput = await request.json();
const inputValidation = ZDisplayUpdateInput.safeParse(displayInput);
const displayInput = await request.json();
const inputValidation = ZDisplayLegacyUpdateInput.safeParse(displayInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -27,7 +27,7 @@ export async function PUT(
);
}
try {
const display = await updateDisplay(displayId, inputValidation.data);
const display = await updateDisplayLegacy(displayId, inputValidation.data);
return responses.successResponse(display, true);
} catch (error) {
console.error(error);

View File

@@ -1,11 +1,13 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";
import { personCache } from "@formbricks/lib/person/cache";
import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
interface Context {
@@ -37,9 +39,9 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
const { key, value } = inputValidation.data;
const existingPerson = await getPerson(personId);
const person = await getPerson(personId);
if (!existingPerson) {
if (!person) {
return responses.notFoundResponse("Person", personId, true);
}
@@ -66,7 +68,23 @@ export async function POST(req: Request, context: Context): Promise<NextResponse
environmentId,
});
const state = await getUpdatedState(environmentId, personId);
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsState = {
person,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return responses.successResponse({ ...state }, true);
} catch (error) {

View File

@@ -1,27 +1,27 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponseLegacy } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { createResponse } from "@formbricks/lib/response/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseLegacyInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
import { TSurvey } from "@formbricks/types/surveys";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(request: Request): Promise<NextResponse> {
const responseInput: TResponseInput = await request.json();
const responseInput = await request.json();
if (responseInput.personId === "legacy") {
responseInput.personId = null;
}
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -67,7 +67,7 @@ export async function POST(request: Request): Promise<NextResponse> {
responseInput.personId = null;
}
response = await createResponse({
response = await createResponseLegacy({
...responseInput,
meta,
});

View File

@@ -1,116 +0,0 @@
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { displayCache } from "@formbricks/lib/display/cache";
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { productCache } from "@formbricks/lib/product/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { unstable_cache } from "next/cache";
// Helper function to calculate difference in days between two dates
const diffInDays = (date1: Date, date2: Date) => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
unstable_cache(
async () => {
return await getSyncSurveys(environmentId, person);
},
[`getSyncSurveysCached-${environmentId}`],
{
tags: [
displayCache.tag.byPersonId(person.id),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSyncSurveys = async (
environmentId: string,
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
};

View File

@@ -1,133 +0,0 @@
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/lib/surveys";
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsLegacyState } from "@formbricks/types/js";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
const captureNewSessionTelemetry = async (jsVersion?: string): Promise<void> => {
await captureTelemetry("state update", { jsVersion: jsVersion ?? "unknown" });
};
export const getUpdatedState = async (
environmentId: string,
personId: string,
jsVersion?: string
): Promise<TJsLegacyState> => {
let environment: TEnvironment | null;
if (jsVersion) {
captureNewSessionTelemetry(jsVersion);
}
// check if environment exists
environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
if (!environment?.widgetSetupCompleted) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}
// check team subscriptons
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new Error("Team does not exist");
}
// check if Monthly Active Users limit is reached
if (IS_FORMBRICKS_CLOUD) {
const hasUserTargetingSubscription =
team?.billing?.features.userTargeting.status &&
team?.billing?.features.userTargeting.status in ["active", "canceled"];
const currentMau = await getMonthlyActiveTeamPeopleCount(team.id);
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
if (isMauLimitReached) {
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
throw new Error(errorMessage);
// if (!personId) {
// // don't allow new people
// throw new Error(errorMessage);
// }
// const session = await getSession(sessionId);
// if (!session) {
// // don't allow new sessions
// throw new Error(errorMessage);
// }
// // check if session was created this month (user already active this month)
// const now = new Date();
// const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// if (new Date(session.createdAt) < firstDayOfMonth) {
// throw new Error(errorMessage);
// }
}
}
const person = await getPerson(personId);
if (!person) {
throw new Error("Person not found");
}
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveysCached(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
// return state
const state: TJsLegacyState = {
person: person!,
session: {},
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
};
return state;
};
export const getPublicUpdatedState = async (environmentId: string) => {
// check if environment exists
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
// TODO: check if Monthly Active Users limit is reached
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSurveys(environmentId),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);
if (!product) {
throw new Error("Product not found");
}
const state: TJsLegacyState = {
surveys,
session: {},
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
person: null,
};
return state;
};

View File

@@ -1,4 +1,4 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createAttributeClass, getAttributeClassByName } from "@formbricks/lib/attributeClass/service";

View File

@@ -1,7 +1,7 @@
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/lib/sync";
import { getUpdatedState } from "@/app/api/v1/(legacy)/js/sync/lib/sync";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { NextResponse } from "next/server";
@@ -26,10 +26,12 @@ export async function POST(req: Request): Promise<NextResponse> {
const { environmentId, userId } = inputValidation.data;
const personWithUserId = await getOrCreatePersonByUserId(userId, environmentId);
const state = await getUpdatedState(environmentId, personWithUserId.id);
let person = await getPersonByUserId(environmentId, userId);
if (!person) {
person = await createPerson(environmentId, userId);
}
const state = await getUpdatedState(environmentId, person.id);
return responses.successResponse({ ...state }, true);
} catch (error) {
console.error(error);

View File

@@ -0,0 +1,11 @@
import { TJsLegacyState, TJsState } from "@formbricks/types/js";
export const transformLegacySurveys = (state: TJsState): TJsLegacyState => {
const updatedState: any = { ...state };
updatedState.surveys = updatedState.surveys.map((survey) => {
const updatedSurvey = { ...survey };
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
return updatedSurvey;
});
return { ...updatedState, session: {} };
};

View File

@@ -1,116 +0,0 @@
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { SERVICES_REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { displayCache } from "@formbricks/lib/display/cache";
import { getDisplaysByPersonId } from "@formbricks/lib/display/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { productCache } from "@formbricks/lib/product/cache";
import { getSurveys } from "@formbricks/lib/survey/service";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { unstable_cache } from "next/cache";
// Helper function to calculate difference in days between two dates
const diffInDays = (date1: Date, date2: Date) => {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
};
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
unstable_cache(
async () => {
return await getSyncSurveys(environmentId, person);
},
[`getSyncSurveysCached-${environmentId}-${person.id}`],
{
tags: [
displayCache.tag.byPersonId(person.id),
surveyCache.tag.byEnvironmentId(environmentId),
productCache.tag.byEnvironmentId(environmentId),
],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSyncSurveys = async (
environmentId: string,
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
};

View File

@@ -1,4 +1,3 @@
import { getSyncSurveysCached } from "@/app/api/v1/(legacy)/js/sync/lib/surveys";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import {
IS_FORMBRICKS_CLOUD,
@@ -9,15 +8,25 @@ import {
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { getSurveys, getSyncSurveys } from "@formbricks/lib/survey/service";
import {
getMonthlyActiveTeamPeopleCount,
getMonthlyTeamResponseCount,
getTeamByEnvironmentId,
} from "@formbricks/lib/team/service";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsLegacyState } from "@formbricks/types/js";
import { TJsLegacyState, TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TSurvey } from "@formbricks/types/surveys";
export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => {
const updatedSurveys = surveys.map((survey) => {
const updatedSurvey: any = { ...survey };
updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger }));
return updatedSurvey;
});
return updatedSurveys;
};
export const getUpdatedState = async (environmentId: string, personId?: string): Promise<TJsLegacyState> => {
let environment: TEnvironment | null;
@@ -85,12 +94,14 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
if (isAppSurveyLimitReached) {
surveys = [];
} else if (isPerson) {
surveys = await getSyncSurveysCached(environmentId, person as TPerson);
surveys = await getSyncSurveys(environmentId, person as TPerson);
} else {
surveys = await getSurveys(environmentId);
surveys = surveys.filter((survey) => survey.type === "web");
surveys = surveys.filter((survey) => survey.type === "web" && survey.status === "inProgress");
}
surveys = transformLegacySurveys(surveys);
// get/create rest of the state
const [noCodeActionClasses, product] = await Promise.all([
getActionClasses(environmentId),

View File

@@ -16,10 +16,11 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function PUT(request: Request, context: Context): Promise<NextResponse> {
const { displayId } = context.params;
const { displayId, environmentId } = context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayUpdateInput.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {

View File

@@ -1,11 +1,12 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getLatestActionByPersonId } from "@formbricks/lib/action/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD, MAU_LIMIT, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSyncSurveysCached } from "@formbricks/lib/survey/service";
import { getSyncSurveys } from "@formbricks/lib/survey/service";
import { getMonthlyActiveTeamPeopleCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/js";
@@ -43,52 +44,54 @@ export async function GET(
const { environmentId, userId } = inputValidation.data;
// check if person exists
const person = await getOrCreatePersonByUserId(userId, environmentId);
if (!person) {
return responses.badRequestResponse(`Person with userId ${userId} not found`);
}
let environment: TEnvironment | null;
// check if environment exists
environment = await getEnvironment(environmentId);
if (!environment) {
throw new Error("Environment does not exist");
}
if (!environment?.widgetSetupCompleted) {
await updateEnvironment(environment.id, { widgetSetupCompleted: true });
}
// check team subscriptons
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new Error("Team does not exist");
}
// check if Monthly Active Users limit is reached
// check if MAU limit is reached
let isMauLimitReached = false;
if (IS_FORMBRICKS_CLOUD) {
// check team subscriptons
const team = await getTeamByEnvironmentId(environmentId);
if (!team) {
throw new Error("Team does not exist");
}
const hasUserTargetingSubscription =
team?.billing?.features.userTargeting.status &&
team?.billing?.features.userTargeting.status in ["active", "canceled"];
const currentMau = await getMonthlyActiveTeamPeopleCount(team.id);
const isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
isMauLimitReached = !hasUserTargetingSubscription && currentMau >= PRICING_USERTARGETING_FREE_MTU;
}
// TODO: Problem is that if isMauLimitReached, all sync request will fail
// But what we essentially want, is to fail only for new people syncing for the first time
if (isMauLimitReached) {
const errorMessage = `Monthly Active Users limit reached in ${environmentId} (${currentMau}/${MAU_LIMIT})`;
let person = await getPersonByUserId(environmentId, userId);
if (!isMauLimitReached) {
if (!person) {
person = await createPerson(environmentId, userId);
}
} else {
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
if (!person) {
// if it's a new person and MAU limit is reached, throw an error
throw new Error(errorMessage);
} else {
// check if person has been active this month
const latestAction = await getLatestActionByPersonId(person.id);
if (!latestAction || new Date(latestAction.createdAt).getMonth() !== new Date().getMonth()) {
throw new Error(errorMessage);
}
}
}
const [surveys, noCodeActionClasses, product] = await Promise.all([
getSyncSurveysCached(environmentId, person),
getSyncSurveys(environmentId, person),
getActionClasses(environmentId),
getProductByEnvironmentId(environmentId),
]);

View File

@@ -0,0 +1,47 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getPersonByUserId, updatePerson } from "@formbricks/lib/person/service";
import { ZPersonUpdateInput } from "@formbricks/types/people";
import { NextResponse } from "next/server";
interface Context {
params: {
userId: string;
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(req: Request, context: Context): Promise<NextResponse> {
try {
const { userId, environmentId } = context.params;
const jsonInput = await req.json();
// validate using zod
const inputValidation = ZPersonUpdateInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const person = await getPersonByUserId(environmentId, userId);
if (!person) {
return responses.notFoundResponse("PersonByUserId", userId, true);
}
const updatedPerson = await updatePerson(person.id, inputValidation.data);
return responses.successResponse(updatedPerson, true);
} catch (error) {
console.error(error);
return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true);
}
}

View File

@@ -6,6 +6,7 @@ import { getSurvey } from "@formbricks/lib/survey/service";
import { updateResponse } from "@formbricks/lib/response/service";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { NextResponse } from "next/server";
import { getPerson } from "@formbricks/lib/person/service";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -23,6 +24,13 @@ export async function PUT(
const responseUpdate = await request.json();
// legacy workaround for formbricks-js 1.2.0 & 1.2.1
if (responseUpdate.personId && typeof responseUpdate.personId === "string") {
const person = await getPerson(responseUpdate.personId);
responseUpdate.userId = person?.userId;
delete responseUpdate.personId;
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {

View File

@@ -1,23 +1,50 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { InvalidInputError } from "@formbricks/types/errors";
import { getPerson } from "@formbricks/lib/person/service";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { getSurvey } from "@formbricks/lib/survey/service";
import { createResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { ZId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
interface Context {
params: {
environmentId: string;
};
}
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
}
export async function POST(request: Request): Promise<NextResponse> {
const responseInput: TResponseInput = await request.json();
export async function POST(request: Request, context: Context): Promise<NextResponse> {
const { environmentId } = context.params;
const environmentIdValidation = ZId.safeParse(environmentId);
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
}
const responseInput = await request.json();
// legacy workaround for formbricks-js 1.2.0 & 1.2.1
if (responseInput.personId && typeof responseInput.personId === "string") {
const person = await getPerson(responseInput.personId);
responseInput.userId = person?.userId;
delete responseInput.personId;
}
const agent = UAParser(request.headers.get("user-agent"));
const inputValidation = ZResponseInput.safeParse(responseInput);
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -27,17 +54,20 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
let survey;
try {
survey = await getSurvey(responseInput.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
console.error(error);
return responses.internalServerErrorResponse(error.message);
}
// get and check survey
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
}
const teamDetails = await getTeamDetails(survey.environmentId);
@@ -54,14 +84,8 @@ export async function POST(request: Request): Promise<NextResponse> {
},
};
// check if personId is anonymous
if (responseInput.personId === "anonymous") {
// remove this from the request
responseInput.personId = null;
}
response = await createResponse({
...responseInput,
...inputValidation.data,
meta,
});
} catch (error) {

View File

@@ -1,14 +1,19 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
import { authenticateRequest } from "@/app/api/v1/auth";
import { DatabaseError } from "@formbricks/types/errors";
import { NextRequest } from "next/server";
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
const surveyId = request.nextUrl.searchParams.get("surveyId");
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const responseArray = await getResponsesByEnvironmentId(authentication.environmentId!);
return responses.successResponse(responseArray);
let environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId!);
if (surveyId) {
environmentResponses = environmentResponses.filter((response) => response.surveyId === surveyId);
}
return responses.successResponse(environmentResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);

View File

@@ -10,11 +10,11 @@ export const createResponse = async (
finished: boolean = false
): Promise<any> => {
const api = formbricks.getApi();
const personId = formbricks.getPerson()?.id;
const userId = formbricks.getPerson()?.userId;
return await api.client.response.create({
surveyId,
personId: personId ?? "",
userId: userId ?? "",
finished,
data,
});

View File

@@ -0,0 +1,15 @@
import rateLimit from "@/app/middleware/rateLimit";
import { CLIENT_SIDE_API_RATE_LIMIT, LOGIN_RATE_LIMIT, SIGNUP_RATE_LIMIT } from "@formbricks/lib/constants";
export const signUpLimiter = rateLimit({
interval: SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
});
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
});
export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});

View File

@@ -0,0 +1,10 @@
export const loginRoute = (url: string) => url === "/api/auth/callback/credentials";
export const signupRoute = (url: string) => url === "/api/v1/users";
export const clientSideApiRoute = (url: string): boolean => {
if (url.includes("/api/v1/js/actions")) return true;
if (url.includes("/api/v1/client/storage")) return true;
const regex = /^\/api\/v\d+\/client\//;
return regex.test(url);
};

View File

@@ -2,6 +2,7 @@ import { LRUCache } from "lru-cache";
type Options = {
interval: number;
allowedPerInterval: number;
};
export default function rateLimit(options: Options) {
@@ -20,7 +21,7 @@ export default function rateLimit(options: Options) {
tokenCount[0] += 1;
const currentUsage = tokenCount[0];
const isRateLimited = currentUsage >= 5;
const isRateLimited = currentUsage >= options.allowedPerInterval;
return isRateLimited ? reject() : resolve();
}),
};

View File

@@ -18,7 +18,7 @@ import { FormbricksAPI } from "@formbricks/api";
interface LinkSurveyProps {
survey: TSurvey;
product: TProduct;
personId?: string;
userId?: string;
emailVerificationStatus?: string;
prefillAnswer?: string;
singleUseId?: string;
@@ -29,7 +29,7 @@ interface LinkSurveyProps {
export default function LinkSurvey({
survey,
product,
personId,
userId,
emailVerificationStatus,
prefillAnswer,
singleUseId,
@@ -41,9 +41,7 @@ export default function LinkSurvey({
const isPreview = searchParams?.get("preview") === "true";
const sourceParam = searchParams?.get("source");
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
const [surveyState, setSurveyState] = useState(
new SurveyState(survey.id, singleUseId, responseId, personId)
);
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId, userId));
const [activeQuestionId, setActiveQuestionId] = useState<string>(
survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id
);
@@ -85,21 +83,20 @@ export default function LinkSurvey({
}
}, []);
const [hiddenFieldsRecord, setHiddenFieldsRecord] = useState<Record<string, string | number | string[]>>();
const hiddenFieldsRecord = useMemo<Record<string, string | number | string[]> | null>(() => {
const fieldsRecord: Record<string, string | number | string[]> = {};
let fieldsSet = false;
useEffect(() => {
survey.hiddenFields?.fieldIds?.forEach((field) => {
// set the question and answer to the survey state
const answer = searchParams?.get(field);
if (answer) {
setHiddenFieldsRecord((prev) => {
return {
...prev,
[field]: answer,
};
});
fieldsRecord[field] = answer;
fieldsSet = true;
}
});
// Only return the record if at least one field was set.
return fieldsSet ? fieldsRecord : null;
}, [searchParams, survey.hiddenFields?.fieldIds]);
useEffect(() => {

View File

@@ -14,7 +14,7 @@ import { cn } from "@formbricks/lib/cn";
interface LinkSurveyPinScreenProps {
surveyId: string;
product: TProduct;
personId?: string;
userId?: string;
emailVerificationStatus?: string;
prefillAnswer?: string;
singleUseId?: string;
@@ -28,7 +28,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
product,
webAppUrl,
emailVerificationStatus,
personId,
userId,
prefillAnswer,
singleUseId,
singleUseResponse,
@@ -103,7 +103,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
<LinkSurvey
survey={survey}
product={product}
personId={personId}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={prefillAnswer}
singleUseId={singleUseId}

View File

@@ -1,19 +1,19 @@
export const revalidate = REVALIDATION_INTERVAL;
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getEmailVerificationStatus } from "./lib/helpers";
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import { notFound } from "next/navigation";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { TResponse } from "@formbricks/types/responses";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import type { Metadata } from "next";
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
import { REVALIDATION_INTERVAL, WEBAPP_URL } from "@formbricks/lib/constants";
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { TResponse } from "@formbricks/types/responses";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getEmailVerificationStatus } from "./lib/helpers";
interface LinkSurveyPageProps {
params: {
@@ -146,9 +146,12 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
}
const userId = searchParams.userId;
let person;
if (userId) {
person = await getOrCreatePersonByUserId(userId, survey.environmentId);
// make sure the person exists or get's created
const person = await getPersonByUserId(survey.environmentId, userId);
if (!person) {
await createPerson(survey.environmentId, userId);
}
}
const isSurveyPinProtected = Boolean(!!survey && survey.pin);
@@ -158,7 +161,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
<PinScreen
surveyId={survey.id}
product={product}
personId={person?.id}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
@@ -172,7 +175,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
<LinkSurvey
survey={survey}
product={product}
personId={person?.id}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}

View File

@@ -1,10 +1,8 @@
import rateLimit from "@/app/(auth)/auth/rate-limit";
import { signUpLimiter, loginLimiter, clientSideApiEndpointsLimiter } from "@/app/middleware/bucket";
import { clientSideApiRoute, loginRoute, signupRoute } from "@/app/middleware/endpointValidator";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const signUpLimiter = rateLimit({ interval: 60 * 60 * 1000 }); // 60 minutes
const loginLimiter = rateLimit({ interval: 15 * 60 * 1000 }); // 15 minutes
export async function middleware(request: NextRequest) {
if (process.env.NODE_ENV !== "production") {
return NextResponse.next();
@@ -19,10 +17,12 @@ export async function middleware(request: NextRequest) {
if (ip) {
try {
if (request.nextUrl.pathname === "/api/auth/callback/credentials") {
if (loginRoute(request.nextUrl.pathname)) {
await loginLimiter.check(ip);
} else if (request.nextUrl.pathname === "/api/v1/users") {
} else if (signupRoute(request.nextUrl.pathname)) {
await signUpLimiter.check(ip);
} else if (clientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter.check(ip);
}
return res;
} catch (_e) {
@@ -35,5 +35,11 @@ export async function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/api/auth/callback/credentials", "/api/v1/users"],
matcher: [
"/api/auth/callback/credentials",
"/api/v1/users",
"/api/(.*)/client/:path*",
"/api/v1/js/actions",
"/api/v1/client/storage",
],
};

View File

@@ -47,6 +47,11 @@ const nextConfig = {
destination: "/api/v1/management/surveys",
permanent: true,
},
{
source: "/api/v1/responses",
destination: "/api/v1/management/responses",
permanent: true,
},
{
source: "/api/v1/me",
destination: "/api/v1/management/me",

View File

@@ -11,7 +11,7 @@
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/s3-presigned-post": "^3.438.0",
"@aws-sdk/s3-presigned-post": "^3.451.0",
"@formbricks/api": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/ee": "workspace:*",
@@ -26,42 +26,44 @@
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-email/components": "^0.0.9",
"@sentry/nextjs": "^7.77.0",
"@react-email/components": "^0.0.11",
"@sentry/nextjs": "^7.80.1",
"@vercel/og": "^0.5.20",
"bcryptjs": "^2.4.3",
"dotenv": "^16.3.1",
"encoding": "^0.1.13",
"framer-motion": "10.16.4",
"framer-motion": "10.16.5",
"googleapis": "^128.0.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lru-cache": "^10.0.1",
"lucide-react": "^0.290.0",
"lru-cache": "^10.0.2",
"lucide-react": "^0.292.0",
"mime": "^3.0.0",
"next": "13.5.6",
"nodemailer": "^6.9.7",
"otplib": "^12.0.1",
"posthog-js": "^1.87.3",
"posthog-js": "^1.91.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^1.9.5",
"react-hook-form": "^7.47.0",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.11.0",
"react-icons": "^4.12.0",
"react-mentions": "^4.4.10",
"ua-parser-js": "^1.0.37",
"webpack": "^5.89.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@types/bcryptjs": "^2.4.5",
"@types/lodash": "^4.14.200",
"@types/markdown-it": "^13.0.5",
"@types/qrcode": "^1.5.4",
"@types/bcryptjs": "^2.4.6",
"@types/lodash": "^4.14.201",
"@types/markdown-it": "^13.0.6",
"@types/qrcode": "^1.5.5",
"@types/react-mentions": "^4.1.13",
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/api",
"license": "MIT",
"version": "1.0.0",
"version": "1.1.0",
"description": "Formbricks-api is an api wrapper for the Formbricks client API",
"keywords": [
"Formbricks",
@@ -34,8 +34,8 @@
"@formbricks/types": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"terser": "^5.22.0",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"terser": "^5.24.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.6.3"
}
}

View File

@@ -0,0 +1,18 @@
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { TActionInput } from "@formbricks/types/actions";
import { makeRequest } from "../../utils/makeRequest";
export class ActionAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async create(actionInput: Omit<TActionInput, "environmentId">): Promise<Result<{}, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/actions`, "POST", actionInput);
}
}

View File

@@ -20,7 +20,7 @@ export class DisplayAPI {
async update(
displayId: string,
displayInput: TDisplayUpdateInput
displayInput: Omit<TDisplayUpdateInput, "environmentId">
): Promise<Result<TDisplay, NetworkError | Error>> {
return makeRequest(
this.apiHost,

View File

@@ -1,15 +1,21 @@
import { ResponseAPI } from "./response";
import { DisplayAPI } from "./display";
import { ApiConfig } from "../../types";
import { ActionAPI } from "./action";
import { PeopleAPI } from "./people";
export class Client {
response: ResponseAPI;
display: DisplayAPI;
action: ActionAPI;
people: PeopleAPI;
constructor(options: ApiConfig) {
const { apiHost, environmentId } = options;
this.response = new ResponseAPI(apiHost, environmentId);
this.display = new DisplayAPI(apiHost, environmentId);
this.action = new ActionAPI(apiHost, environmentId);
this.people = new PeopleAPI(apiHost, environmentId);
}
}

View File

@@ -0,0 +1,33 @@
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { makeRequest } from "../../utils/makeRequest";
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
export class PeopleAPI {
private apiHost: string;
private environmentId: string;
constructor(apiHost: string, environmentId: string) {
this.apiHost = apiHost;
this.environmentId = environmentId;
}
async create(userId: string): Promise<Result<TPerson, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/people`, "POST", {
environmentId: this.environmentId,
userId,
});
}
async update(
userId: string,
personInput: TPersonUpdateInput
): Promise<Result<TPerson, NetworkError | Error>> {
return makeRequest(
this.apiHost,
`/api/v1/client/${this.environmentId}/people/${userId}`,
"POST",
personInput
);
}
}

View File

@@ -1,7 +1,7 @@
import { makeRequest } from "../../utils/makeRequest";
import { NetworkError } from "@formbricks/types/errors";
import { Result } from "@formbricks/types/errorHandlers";
import { NetworkError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/responses";
import { makeRequest } from "../../utils/makeRequest";
type TResponseUpdateInputWithResponseId = TResponseUpdateInput & { responseId: string };
@@ -14,7 +14,9 @@ export class ResponseAPI {
this.environmentId = environmentId;
}
async create(responseInput: TResponseInput): Promise<Result<TResponse, NetworkError | Error>> {
async create(
responseInput: Omit<TResponseInput, "environmentId">
): Promise<Result<TResponse, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses`, "POST", responseInput);
}

View File

@@ -25,7 +25,7 @@
"predev": "pnpm generate"
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@prisma/client": "^5.6.0",
"@prisma/extension-accelerate": "^0.6.2",
"dotenv-cli": "^7.3.0"
},
@@ -33,9 +33,9 @@
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"eslint-config-formbricks": "workspace:*",
"prisma": "^5.4.2",
"prisma": "^5.6.0",
"prisma-dbml-generator": "^0.10.0",
"prisma-json-types-generator": "^3.0.2",
"prisma-json-types-generator": "^3.0.3",
"zod": "^3.22.4",
"zod-prisma": "^0.5.4"
}

View File

@@ -18,6 +18,6 @@
},
"dependencies": {
"@formbricks/lib": "workspace:*",
"stripe": "^14.0.0"
"stripe": "^14.4.0"
}
}

View File

@@ -8,8 +8,8 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"eslint": "^8.52.0",
"eslint-config-next": "^14.0.0",
"eslint": "^8.53.0",
"eslint-config-next": "^14.0.3",
"eslint-config-prettier": "^9.0.0",
"eslint-config-turbo": "latest",
"eslint-plugin-react": "7.33.2",

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.2.1",
"version": "1.2.2",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",
@@ -34,17 +34,17 @@
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"@babel/preset-typescript": "^7.23.2",
"@babel/core": "^7.23.3",
"@babel/preset-env": "^7.23.3",
"@babel/preset-typescript": "^7.23.3",
"@formbricks/api": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/surveys": "workspace:*",
"@formbricks/tsconfig": "workspace:*",
"@formbricks/types": "workspace:*",
"@types/jest": "^29.5.5",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"@types/jest": "^29.5.8",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"babel-jest": "^29.7.0",
"cross-env": "^7.0.3",
"eslint-config-formbricks": "workspace:*",
@@ -52,9 +52,9 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"terser": "^5.22.0",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"terser": "^5.24.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.6.3"
},
"jest": {
"transformIgnorePatterns": [

View File

@@ -1,8 +1,10 @@
import { TJsActionInput, TSurveyWithTriggers } from "@formbricks/types/js";
import { TJsActionInput } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { renderWidget } from "./widget";
import { FormbricksAPI } from "@formbricks/api";
const logger = Logger.getInstance();
const config = Config.getInstance();
@@ -22,24 +24,23 @@ export const trackAction = async (
// don't send actions to the backend if the person is not identified
if (config.get().state?.person?.userId && !intentsToNotCreateOnApp.includes(name)) {
logger.debug(`Sending action "${name}" to backend`);
const res = await fetch(`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
const api = new FormbricksAPI({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.action.create({
...input,
userId: config.get().state.person!.userId,
});
if (!res.ok) {
const error = await res.json();
return err({
code: "network_error",
message: `Error tracking action: ${JSON.stringify(error)}`,
status: res.status,
url: res.url,
responseMessage: error.message,
message: `Error tracking action ${name}`,
status: 500,
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/actions`,
responseMessage: res.error.message,
});
}
}
@@ -58,10 +59,10 @@ export const trackAction = async (
return okVoid();
};
export const triggerSurvey = (actionName: string, activeSurveys: TSurveyWithTriggers[]): void => {
export const triggerSurvey = (actionName: string, activeSurveys: TSurvey[]): void => {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (typeof trigger === "string" ? trigger === actionName : trigger.name === actionName) {
if (trigger === actionName) {
logger.debug(`Formbricks: survey ${survey.id} triggered by action "${actionName}"`);
renderWidget(survey);
return;

View File

@@ -1,17 +1,10 @@
import { TJsPeopleAttributeInput, TJsState } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
import { Config } from "./config";
import {
AttributeAlreadyExistsError,
MissingPersonError,
NetworkError,
Result,
err,
ok,
okVoid,
} from "./errors";
import { AttributeAlreadyExistsError, MissingPersonError, NetworkError, Result, err, okVoid } from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
import { FormbricksAPI } from "@formbricks/api";
const config = Config.getInstance();
const logger = Logger.getInstance();
@@ -19,7 +12,7 @@ const logger = Logger.getInstance();
export const updatePersonAttribute = async (
key: string,
value: string
): Promise<Result<TJsState, NetworkError | MissingPersonError>> => {
): Promise<Result<void, NetworkError | MissingPersonError>> => {
if (!config.get().state.person || !config.get().state.person?.id) {
return err({
code: "missing_person",
@@ -27,37 +20,39 @@ export const updatePersonAttribute = async (
});
}
const input: TJsPeopleAttributeInput = {
key,
value,
const input: TPersonUpdateInput = {
attributes: {
[key]: value,
},
};
const res = await fetch(
`${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
config.get().state.person?.id
}/set-attribute`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}
);
const resJson = await res.json();
const api = new FormbricksAPI({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.people.update(config.get().state.person!.userId, input);
if (!res.ok) {
return err({
code: "network_error",
status: res.status,
message: "Error updating person",
url: res.url,
responseMessage: resJson.message,
status: 500,
message: `Error updating person with userId ${config.get().state.person?.userId}`,
url: `${config.get().apiHost}/api/v1/client/${config.get().environmentId}/people/${
config.get().state.person?.userId
}`,
responseMessage: res.error.message,
});
}
return ok(resJson.data as TJsState);
logger.debug("Attribute updated. Syncing...");
await sync({
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().state.person?.userId,
});
return okVoid();
};
export const hasAttributeValue = (key: string, value: string): boolean => {
@@ -95,14 +90,6 @@ export const setPersonAttribute = async (
const result = await updatePersonAttribute(key, value.toString());
if (result.ok) {
const state = result.value;
config.update({
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
state,
});
return okVoid();
}

View File

@@ -1,13 +1,14 @@
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import SurveyState from "@formbricks/lib/surveyState";
import { renderSurveyModal } from "@formbricks/surveys";
import { TJSStateDisplay, TSurveyWithTriggers } from "@formbricks/types/js";
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 { Logger } from "./logger";
import { filterPublicSurveys, sync } from "./sync";
import { FormbricksAPI } from "@formbricks/api";
const containerId = "formbricks-web-container";
const config = Config.getInstance();
@@ -15,7 +16,7 @@ const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let surveyRunning = false;
export const renderWidget = (survey: TSurveyWithTriggers) => {
export const renderWidget = (survey: TSurvey) => {
if (surveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
@@ -45,7 +46,7 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
const productOverwrites = survey.productOverwrites ?? {};
const brandColor = productOverwrites.brandColor ?? product.brandColor;
const highlightBorderColor = productOverwrites.highlightBorderColor ?? product.highlightBorderColor;
const clickOutside = productOverwrites.clickOutside ?? product.clickOutsideClose;
const clickOutside = productOverwrites.clickOutsideClose ?? product.clickOutsideClose;
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
const placement = productOverwrites.placement ?? product.placement;
const isBrandingEnabled = product.inAppSurveyBranding;
@@ -71,12 +72,13 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
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: {
...previousConfig.state,
displays,
},
state,
});
}
@@ -107,18 +109,19 @@ export const renderWidget = (survey: TSurveyWithTriggers) => {
if (!lastDisplay.responded) {
lastDisplay.responded = true;
const previousConfig = config.get();
let state = filterPublicSurveys({
...previousConfig.state,
displays,
});
config.update({
...previousConfig,
state: {
...previousConfig.state,
displays,
},
state,
});
}
}
if (config.get().state.person && config.get().state.person?.id) {
surveyState.updatePersonId(config.get().state.person?.id!);
if (config.get().state.person && config.get().state.person?.userId) {
surveyState.updateUserId(config.get().state.person?.userId!);
}
responseQueue.updateSurveyState(surveyState);
responseQueue.add({

View File

@@ -116,19 +116,9 @@ export const mockSetEmailIdResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: { userId: initialUserId, email: initialUserEmail },
},
id: initialPersonUid,
environmentId,
attributes: { userId: initialUserId, email: initialUserEmail },
},
})
);
@@ -138,22 +128,12 @@ export const mockSetCustomAttributeResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
surveys: [],
session: {
id: sessionId,
createdAt: "2021-03-09T15:00:00.000Z",
updatedAt: "2021-03-09T15:00:00.000Z",
expiresAt: expiryTime,
},
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: initialUserEmail,
[customAttributeKey]: customAttributeValue,
},
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: initialUserEmail,
[customAttributeKey]: customAttributeValue,
},
},
})
@@ -164,16 +144,12 @@ export const mockUpdateEmailResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {
surveys: [],
noCodeActionClasses: [],
person: {
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: updatedUserEmail,
[customAttributeKey]: customAttributeValue,
},
id: initialPersonUid,
environmentId,
attributes: {
userId: initialUserId,
email: updatedUserEmail,
[customAttributeKey]: customAttributeValue,
},
},
})

View File

@@ -61,7 +61,7 @@ test("Formbricks should get the current person with no attributes", () => {
expect(Object.keys(currentStatePersonAttributes)).toHaveLength(0);
});
test("Formbricks should set email", async () => {
/* test("Formbricks should set email", async () => {
mockSetEmailIdResponse();
await formbricks.setEmail(initialUserEmail);
@@ -112,7 +112,7 @@ test("Formbricks should update attribute", async () => {
expect(email).toStrictEqual(updatedUserEmail);
const customAttribute = currentStatePersonAttributes[customAttributeKey];
expect(customAttribute).toStrictEqual(customAttributeValue);
});
}); */
test("Formbricks should track event", async () => {
mockEventTrackResponse();

View File

@@ -40,7 +40,6 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
// sessionId: actionPrisma.sessionId,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.actionClass,
@@ -71,6 +70,60 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
: action;
};
export const getLatestActionByPersonId = async (personId: string): Promise<TAction | null> => {
const action = await unstable_cache(
async () => {
validateInputs([personId, ZId]);
try {
const actionPrisma = await prisma.action.findFirst({
where: {
personId,
},
orderBy: {
createdAt: "desc",
},
include: {
actionClass: true,
},
});
if (!actionPrisma) {
return null;
}
const action: TAction = {
id: actionPrisma.id,
createdAt: actionPrisma.createdAt,
personId: actionPrisma.personId,
properties: actionPrisma.properties,
actionClass: actionPrisma.actionClass,
};
return action;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
},
[`getLastestActionByPersonId-${personId}`],
{
tags: [actionCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
// since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them
// https://github.com/vercel/next.js/issues/51613
return action
? {
...action,
createdAt: new Date(action.createdAt),
}
: action;
};
export const getActionsByPersonId = async (personId: string, page?: number): Promise<TAction[]> => {
const actions = await unstable_cache(
async () => {
@@ -187,7 +240,7 @@ export const createAction = async (data: TActionInput): Promise<TAction> => {
actionType = "automatic";
}
const person = await getPersonByUserId(userId, environmentId);
const person = await getPersonByUserId(environmentId, userId);
if (!person) {
throw new Error("Person not found");

View File

@@ -1,5 +1,4 @@
import "server-only";
import path from "path";
import { env } from "./env.mjs";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const REVALIDATION_INTERVAL = 0; //TODO: find a good way to cache and revalidate data when it changes
@@ -59,7 +58,7 @@ export const RESPONSES_PER_PAGE = 10;
export const TEXT_RESPONSES_PER_PAGE = 5;
// Storage constants
export const UPLOADS_DIR = path.resolve("./uploads");
export const UPLOADS_DIR = "./uploads";
export const MAX_SIZES = {
public: 1024 * 1024 * 10, // 10MB
free: 1024 * 1024 * 10, // 10MB
@@ -75,5 +74,20 @@ export const LOCAL_UPLOAD_URL = {
// Pricing
export const PRICING_USERTARGETING_FREE_MTU = 2500;
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
// Rate Limiting
export const SIGNUP_RATE_LIMIT = {
interval: 60 * 60 * 1000, // 60 minutes
allowedPerInterval: 5,
};
export const LOGIN_RATE_LIMIT = {
interval: 15 * 60 * 1000, // 15 minutes
allowedPerInterval: 5,
};
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 10 * 60 * 1000, // 60 minutes
allowedPerInterval: 50,
};
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;

View File

@@ -6,9 +6,11 @@ import {
TDisplay,
TDisplayCreateInput,
TDisplayLegacyCreateInput,
TDisplayLegacyUpdateInput,
TDisplayUpdateInput,
ZDisplayCreateInput,
ZDisplayLegacyCreateInput,
ZDisplayLegacyUpdateInput,
ZDisplayUpdateInput,
} from "@formbricks/types/displays";
import { ZId } from "@formbricks/types/environment";
@@ -20,6 +22,7 @@ import { getPersonByUserId } from "../person/service";
import { validateInputs } from "../utils/validate";
import { displayCache } from "./cache";
import { formatDisplaysDateFields } from "./util";
import { TPerson } from "@formbricks/types/people";
const selectDisplay = {
id: true,
@@ -30,11 +33,91 @@ const selectDisplay = {
personId: true,
};
export const getDisplay = async (displayId: string): Promise<TDisplay | null> =>
await unstable_cache(
async () => {
validateInputs([displayId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: displayId,
},
select: selectDisplay,
});
return responsePrisma;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDisplay-${displayId}`],
{
tags: [displayCache.tag.byId(displayId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const updateDisplay = async (
displayId: string,
displayInput: Partial<TDisplayUpdateInput>
displayInput: TDisplayUpdateInput
): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayUpdateInput.partial()]);
let person: TPerson | null = null;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.environmentId, displayInput.userId);
if (!person) {
throw new ResourceNotFoundError("Person", displayInput.userId);
}
}
try {
const data = {
...(person?.id && {
person: {
connect: {
id: person.id,
},
},
}),
...(displayInput.responseId && {
responseId: displayInput.responseId,
}),
};
const display = await prisma.display.update({
where: {
id: displayId,
},
data,
select: selectDisplay,
});
displayCache.revalidate({
id: display.id,
surveyId: display.surveyId,
});
return display;
} catch (error) {
console.error(error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateDisplayLegacy = async (
displayId: string,
displayInput: TDisplayLegacyUpdateInput
): Promise<TDisplay> => {
validateInputs([displayInput, ZDisplayLegacyUpdateInput]);
try {
const data = {
...(displayInput.personId && {
@@ -77,7 +160,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
try {
let person;
if (displayInput.userId) {
person = await getPersonByUserId(displayInput.userId, displayInput.environmentId);
person = await getPersonByUserId(displayInput.environmentId, displayInput.userId);
}
const display = await prisma.display.create({
data: {
@@ -152,7 +235,7 @@ export const createDisplayLegacy = async (displayInput: TDisplayLegacyCreateInpu
}
};
export const markDisplayResponded = async (displayId: string): Promise<TDisplay> => {
export const markDisplayRespondedLegacy = async (displayId: string): Promise<TDisplay> => {
validateInputs([displayId, ZId]);
try {

View File

@@ -1,7 +1,7 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { config } from 'dotenv';
config({ path: '../../.env' });
/* import { config } from 'dotenv';
config({ path: '../../.env' }); */
export const env = createEnv({
/*

View File

@@ -22,14 +22,13 @@ import { validateInputs } from "../utils/validate";
import { environmentCache } from "./cache";
import { formatEnvironmentDateFields } from "./util";
export const getEnvironment = (environmentId: string) =>
export const getEnvironment = (environmentId: string): Promise<TEnvironment | null> =>
unstable_cache(
async (): Promise<TEnvironment> => {
async () => {
validateInputs([environmentId, ZId]);
let environmentPrisma;
try {
environmentPrisma = await prisma.environment.findUnique({
return await prisma.environment.findUnique({
where: {
id: environmentId,
},
@@ -42,16 +41,6 @@ export const getEnvironment = (environmentId: string) =>
throw error;
}
try {
const environment = ZEnvironment.parse(environmentPrisma);
return environment;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environment failed");
}
},
[`getEnvironment-${environmentId}`],
{

View File

@@ -13,28 +13,28 @@
},
"dependencies": {
"@formbricks/api": "*",
"@aws-sdk/client-s3": "3.433.0",
"@aws-sdk/s3-request-presigner": "3.433.0",
"@aws-sdk/client-s3": "3.451.0",
"@aws-sdk/s3-request-presigner": "3.451.0",
"@t3-oss/env-nextjs": "^0.7.1",
"mime": "3.0.0",
"@formbricks/database": "*",
"@formbricks/types": "*",
"@paralleldrive/cuid2": "^2.2.2",
"aws-crt": "^1.18.1",
"aws-crt": "^1.19.0",
"date-fns": "^2.30.0",
"jsonwebtoken": "^9.0.2",
"markdown-it": "^13.0.2",
"nanoid": "^5.0.2",
"next-auth": "^4.23.2",
"nodemailer": "^6.9.6",
"posthog-node": "^3.1.2",
"nanoid": "^5.0.3",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.7",
"posthog-node": "^3.1.3",
"server-only": "^0.0.1",
"tailwind-merge": "^1.14.0"
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@formbricks/tsconfig": "*",
"@types/jsonwebtoken": "^9.0.3",
"@types/mime": "3.0.3",
"@types/jsonwebtoken": "^9.0.5",
"@types/mime": "3.0.4",
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -25,6 +25,6 @@ export const canUserAccessPerson = async (userId: string, personId: string): Pro
[`canUserAccessPerson-${userId}-people-${personId}`],
{
revalidate: SERVICES_REVALIDATION_INTERVAL,
tags: [personCache.tag.byId(personId), personCache.tag.byUserId(userId)],
tags: [personCache.tag.byId(personId)],
}
)();

View File

@@ -14,11 +14,8 @@ export const personCache = {
byEnvironmentId(environmentId: string): string {
return `environments-${environmentId}-people`;
},
byUserId(userId: string): string {
return `users-${userId}-people`;
},
byEnvironmentIdAndUserId(environmentId: string, userId: string): string {
return `environments-${environmentId}-users-${userId}-people`;
return `environments-${environmentId}-personByUserId-${userId}`;
},
},
revalidate({ id, environmentId, userId }: RevalidateProps): void {
@@ -26,16 +23,12 @@ export const personCache = {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (userId) {
revalidateTag(this.tag.byUserId(userId));
}
if (environmentId && userId) {
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -10,6 +10,7 @@ import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { validateInputs } from "../utils/validate";
import { personCache } from "./cache";
import { createAttributeClass, getAttributeClassByName } from "../attributeClass/service";
export const selectPerson = {
id: true,
@@ -180,7 +181,8 @@ export const createPerson = async (environmentId: string, userId: string): Promi
personCache.revalidate({
id: transformedPerson.id,
environmentId: transformedPerson.environmentId,
environmentId,
userId,
});
return transformedPerson;
@@ -224,20 +226,62 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
validateInputs([personId, ZId], [personInput, ZPersonUpdateInput]);
try {
const person = await prisma.person.update({
where: {
id: personId,
},
data: personInput,
select: selectPerson,
const person = await getPerson(personId);
if (!person) {
throw new Error(`Person ${personId} not found`);
}
// Process each attribute
const attributeUpdates = Object.entries(personInput.attributes).map(async ([attributeName, value]) => {
let attributeClass = await getAttributeClassByName(person.environmentId, attributeName);
// Create new attribute class if not found
if (attributeClass === null) {
attributeClass = await createAttributeClass(person.environmentId, attributeName, "code");
}
// Now perform the upsert for the attribute with the found or created attributeClassId
await prisma.attribute.upsert({
where: {
attributeClassId_personId: {
attributeClassId: attributeClass!.id,
personId,
},
},
update: {
value: value.toString(),
},
create: {
attributeClass: {
connect: {
id: attributeClass!.id,
},
},
person: {
connect: {
id: personId,
},
},
value: value.toString(),
},
});
});
// Execute all attribute updates
await Promise.all(attributeUpdates);
personCache.revalidate({
id: personId,
environmentId: person.environmentId,
});
return transformPrismaPerson(person);
const updatedPerson = await getPerson(personId);
if (!updatedPerson) {
throw new Error(`Person ${personId} not found`);
}
return updatedPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -247,10 +291,10 @@ export const updatePerson = async (personId: string, personInput: TPersonUpdateI
}
};
export const getPersonByUserId = async (userId: string, environmentId: string): Promise<TPerson | null> => {
const personPrisma = await unstable_cache(
export const getPersonByUserId = async (environmentId: string, userId: string): Promise<TPerson | null> =>
await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
validateInputs([environmentId, ZId], [userId, ZString]);
// check if userId exists as a column
const personWithUserId = await prisma.person.findFirst({
@@ -261,8 +305,10 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
select: selectPerson,
});
if (personWithUserId) {
return personWithUserId;
return personWithUserId ? transformPrismaPerson(personWithUserId) : null;
/* if (personWithUserId) {
return transformPrismaPerson(personWithUserId);
}
// Check if a person with the userId attribute exists
@@ -304,57 +350,13 @@ export const getPersonByUserId = async (userId: string, environmentId: string):
personCache.revalidate({
id: personWithUserIdAttribute.id,
environmentId: personWithUserIdAttribute.environmentId,
environmentId,
userId,
});
});
return personWithUserIdAttribute;
return transformPrismaPerson(personWithUserIdAttribute); */
},
[`getPersonByUserId-${userId}-${environmentId}`],
{
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
if (!personPrisma) {
return null;
}
return transformPrismaPerson(personPrisma);
};
export const getOrCreatePersonByUserId = async (userId: string, environmentId: string): Promise<TPerson> =>
await unstable_cache(
async () => {
validateInputs([userId, ZString], [environmentId, ZId]);
let person = await getPersonByUserId(userId, environmentId);
if (person) {
return person;
}
// create a new person
const personPrisma = await prisma.person.create({
data: {
environment: {
connect: {
id: environmentId,
},
},
userId,
},
select: selectPerson,
});
personCache.revalidate({
id: personPrisma.id,
environmentId: personPrisma.environmentId,
userId,
});
return transformPrismaPerson(personPrisma);
},
[`getOrCreatePersonByUserId-${userId}-${environmentId}`],
[`getPersonByUserId-${environmentId}-${userId}`],
{
tags: [personCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,

View File

@@ -1,5 +1,5 @@
import { TPerson } from "@formbricks/types/people";
export const getPersonIdentifier = (person: TPerson): string | number | null => {
return person?.attributes?.userId || person?.attributes?.email || person?.id || null;
return person?.userId || person?.attributes?.userId || person?.attributes?.email || person?.id || null;
};

View File

@@ -8,8 +8,10 @@ import { TPerson } from "@formbricks/types/people";
import {
TResponse,
TResponseInput,
TResponseLegacyInput,
TResponseUpdateInput,
ZResponseInput,
ZResponseLegacyInput,
ZResponseUpdateInput,
} from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -17,7 +19,7 @@ import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { deleteDisplayByResponseId } from "../display/service";
import { getPerson, transformPrismaPerson } from "../person/service";
import { getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service";
import { formatResponseDateFields } from "../response/util";
import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
@@ -195,6 +197,69 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
validateInputs([responseInput, ZResponseInput]);
captureTelemetry("response created");
try {
let person: TPerson | null = null;
if (responseInput.userId) {
person = await getPersonByUserId(responseInput.environmentId, responseInput.userId);
if (!person) {
throw new ResourceNotFoundError("Person", responseInput.userId);
}
}
const responsePrisma = await prisma.response.create({
data: {
survey: {
connect: {
id: responseInput.surveyId,
},
},
finished: responseInput.finished,
data: responseInput.data,
...(person?.id && {
person: {
connect: {
id: person.id,
},
},
personAttributes: person?.attributes,
}),
...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)),
singleUseId: responseInput.singleUseId,
},
select: responseSelection,
});
const response: TResponse = {
...responsePrisma,
person: responsePrisma.person ? transformPrismaPerson(responsePrisma.person) : null,
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
responseCache.revalidate({
id: response.id,
personId: response.person?.id,
surveyId: response.surveyId,
});
responseNoteCache.revalidate({
responseId: response.id,
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const createResponseLegacy = async (responseInput: TResponseLegacyInput): Promise<TResponse> => {
validateInputs([responseInput, ZResponseLegacyInput]);
captureTelemetry("response created");
try {
let person: TPerson | null = null;

View File

@@ -78,7 +78,7 @@ export class ResponseQueue {
const response = await this.api.client.response.create({
...responseUpdate,
surveyId: this.surveyState.surveyId,
personId: this.surveyState.personId || null,
userId: this.surveyState.userId || null,
singleUseId: this.surveyState.singleUseId || null,
});
if (!response.ok) {

View File

@@ -1,28 +1,27 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/actionClasses";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TSurvey, TSurveyAttributeFilter, TSurveyInput, ZSurvey } from "@formbricks/types/surveys";
import { TActionClass } from "@formbricks/types/actionClasses";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { getActionClasses } from "../actionClass/service";
import { getAttributeClasses } from "../attributeClass/service";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { displayCache } from "../display/cache";
import { getDisplaysByPersonId } from "../display/service";
import { productCache } from "../product/cache";
import { getProductByEnvironmentId } from "../product/service";
import { responseCache } from "../response/cache";
import { captureTelemetry } from "../telemetry";
import { validateInputs } from "../utils/validate";
import { formatSurveyDateFields } from "./util";
import { surveyCache } from "./cache";
import { displayCache } from "../display/cache";
import { productCache } from "../product/cache";
import { TPerson } from "@formbricks/types/people";
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { getAttributeClasses } from "../attributeClass/service";
import { getProductByEnvironmentId } from "../product/service";
import { getDisplaysByPersonId } from "../display/service";
import { diffInDays } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { formatSurveyDateFields } from "./util";
export const selectSurvey = {
id: true,
@@ -606,12 +605,87 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
return newSurvey;
};
export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
export const getSyncSurveys = (environmentId: string, person: TPerson): Promise<TSurvey[]> =>
unstable_cache(
async () => {
return await getSyncSurveys(environmentId, person);
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null)
.length === 0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
},
[`getSyncSurveysCached-${environmentId}`],
[`getSyncSurveys-${environmentId}`],
{
tags: [
displayCache.tag.byPersonId(person.id),
@@ -621,86 +695,3 @@ export const getSyncSurveysCached = (environmentId: string, person: TPerson) =>
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
export const getSyncSurveys = async (
environmentId: string,
person: TPerson
): Promise<TSurveyWithTriggers[]> => {
// get recontactDays from product
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
let surveys = await getSurveys(environmentId);
// filtered surveys for running and web
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web");
const displays = await getDisplaysByPersonId(person.id);
// filter surveys that meet the displayOption criteria
surveys = surveys.filter((survey) => {
if (survey.displayOption === "respondMultiple") {
return true;
} else if (survey.displayOption === "displayOnce") {
return displays.filter((display) => display.surveyId === survey.id).length === 0;
} else if (survey.displayOption === "displayMultiple") {
return (
displays.filter((display) => display.surveyId === survey.id && display.responseId !== null).length ===
0
);
} else {
throw Error("Invalid displayOption");
}
});
const attributeClasses = await getAttributeClasses(environmentId);
// filter surveys that meet the attributeFilters criteria
const potentialSurveysWithAttributes = surveys.filter((survey) => {
const attributeFilters = survey.attributeFilters;
if (attributeFilters.length === 0) {
return true;
}
// check if meets all attribute filters criterias
return attributeFilters.every((attributeFilter) => {
const attributeClassName = attributeClasses.find(
(attributeClass) => attributeClass.id === attributeFilter.attributeClassId
)?.name;
if (!attributeClassName) {
throw Error("Invalid attribute filter class");
}
const personAttributeValue = person.attributes[attributeClassName];
if (attributeFilter.condition === "equals") {
return personAttributeValue === attributeFilter.value;
} else if (attributeFilter.condition === "notEquals") {
return personAttributeValue !== attributeFilter.value;
} else {
throw Error("Invalid attribute filter condition");
}
});
});
const latestDisplay = displays[0];
// filter surveys that meet the recontactDays criteria
surveys = potentialSurveysWithAttributes.filter((survey) => {
if (!latestDisplay) {
return true;
} else if (survey.recontactDays !== null) {
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
if (!lastDisplaySurvey) {
return true;
}
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
} else if (product.recontactDays !== null) {
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays;
} else {
return true;
}
});
return surveys;
};

View File

@@ -3,7 +3,7 @@ import { TResponseUpdate } from "@formbricks/types/responses";
export class SurveyState {
responseId: string | null = null;
displayId: string | null = null;
personId: string | null = null;
userId: string | null = null;
surveyId: string;
responseAcc: TResponseUpdate = { finished: false, data: {} };
singleUseId: string | null;
@@ -12,10 +12,10 @@ export class SurveyState {
surveyId: string,
singleUseId?: string | null,
responseId?: string | null,
personId?: string | null
userId?: string | null
) {
this.surveyId = surveyId;
this.personId = personId ?? null;
this.userId = userId ?? null;
this.singleUseId = singleUseId ?? null;
this.responseId = responseId ?? null;
}
@@ -36,7 +36,7 @@ export class SurveyState {
this.surveyId,
this.singleUseId ?? undefined,
this.responseId ?? undefined,
this.personId ?? undefined
this.userId ?? undefined
);
copyInstance.responseId = this.responseId;
copyInstance.responseAcc = this.responseAcc;
@@ -60,11 +60,11 @@ export class SurveyState {
}
/**
* Update the person ID
* @param id - The person ID
* Update the user ID
* @param id - The user ID
*/
updatePersonId(id: string) {
this.personId = id;
updateUserId(id: string) {
this.userId = id;
}
/**

View File

@@ -3,7 +3,7 @@
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"baseUrl": "packages/lib",
"paths": {
"@/*": ["../../apps/web/*"],
"@prisma/client/*": ["@formbricks/database/client/*"]

View File

@@ -8,7 +8,7 @@ export const validateInputs = (...pairs: ValidationPair[]): void => {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
console.error(`Validation failed for ${schema}: ${inputValidation.error.message}`);
console.error(`Validation failed for ${JSON.stringify(schema)}: ${inputValidation.error.message}`);
throw new ValidationError("Validation failed");
}
}

View File

@@ -44,10 +44,10 @@
]
},
"devDependencies": {
"@types/express": "^4.17.19",
"@types/request-promise-native": "~1.0.19",
"@typescript-eslint/parser": "~6.8",
"eslint-plugin-n8n-nodes-base": "^1.16.0",
"@types/express": "^4.17.21",
"@types/request-promise-native": "~1.0.21",
"@typescript-eslint/parser": "~6.11",
"eslint-plugin-n8n-nodes-base": "^1.16.1",
"gulp": "^4.0.2",
"n8n-core": "legacy",
"n8n-workflow": "legacy"

View File

@@ -29,10 +29,11 @@
"autoprefixer": "^10.4.16",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.31",
"preact": "^10.18.1",
"tailwindcss": "^3.3.3",
"terser": "^5.22.0",
"vite": "^4.4.11",
"vite-plugin-dts": "^3.6.0"
"preact": "^10.19.2",
"tailwindcss": "^3.3.5",
"terser": "^5.24.0",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.6.3",
"vite-tsconfig-paths": "^4.2.1"
}
}

View File

@@ -1,21 +0,0 @@
interface HeadlineProps {
headline?: string;
questionId: string;
style?: any;
required?: boolean;
}
export default function Headline({ headline, questionId, style, required = true }: HeadlineProps) {
return (
<label htmlFor={questionId} className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
<div className={"flex justify-between gap-4"} style={style}>
{headline}
{!required && (
<span className="self-start text-sm font-normal leading-7 text-slate-400" tabIndex={-1}>
Optional
</span>
)}
</div>
</label>
);
}

View File

@@ -1,9 +0,0 @@
export default function Progress({ progress, brandColor }: { progress: number; brandColor: string }) {
return (
<div className="h-2 w-full rounded-full bg-slate-200">
<div
className="transition-width z-20 h-2 rounded-full duration-500"
style={{ backgroundColor: brandColor, width: `${Math.floor(progress * 100)}%` }}></div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { cn } from "../../../lib/cn";
import { cn } from "@/lib/utils";
interface BackButtonProps {
onClick: () => void;
@@ -12,7 +12,7 @@ export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButto
tabIndex={tabIndex}
type={"button"}
className={cn(
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
"border-back-button-border text-heading focus:ring-focus flex items-center rounded-md border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
)}
onClick={onClick}>
{backButtonLabel || "Back"}

View File

@@ -1,11 +1,8 @@
import { useCallback } from "preact/hooks";
import { cn } from "../../../lib/cn";
import { isLight } from "../lib/utils";
interface SubmitButtonProps {
buttonLabel: string | undefined;
isLastQuestion: boolean;
brandColor: string;
onClick: () => void;
focus?: boolean;
tabIndex?: number;
@@ -15,7 +12,6 @@ interface SubmitButtonProps {
function SubmitButton({
buttonLabel,
isLastQuestion,
brandColor,
onClick,
tabIndex = 1,
focus = false,
@@ -38,11 +34,7 @@ function SubmitButton({
type={type}
tabIndex={tabIndex}
autoFocus={focus}
className={cn(
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2",
isLight(brandColor) ? "text-black" : "text-white"
)}
style={{ backgroundColor: brandColor }}
className="bg-brand border-submit-button-border text-on-brand focus:ring-focus flex items-center rounded-md border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={onClick}>
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
</button>

View File

@@ -5,10 +5,10 @@ export default function FormbricksBranding() {
target="_blank"
tabIndex={-1}
className="mb-5 mt-2 flex justify-center">
<p className="text-xs text-slate-400">
<p className="text-signature text-xs">
Powered by{" "}
<b>
<span className="text-slate-500 hover:text-slate-700">Formbricks</span>
<span className="text-info-text hover:text-heading">Formbricks</span>
</b>
</p>
</a>

View File

@@ -0,0 +1,27 @@
interface HeadlineProps {
headline?: string;
questionId: string;
required?: boolean;
alignTextCenter?: boolean;
}
export default function Headline({
headline,
questionId,
required = true,
alignTextCenter = false,
}: HeadlineProps) {
return (
<label htmlFor={questionId} className="text-heading mb-1.5 block text-base font-semibold leading-6">
<div
className={`flex items-center ${alignTextCenter ? "justify-center" : "mr-[3ch] justify-between"}`}>
{headline}
{!required && (
<span className="text-info-text self-start text-sm font-normal leading-7" tabIndex={-1}>
Optional
</span>
)}
</div>
</label>
);
}

View File

@@ -1,11 +1,11 @@
import { cleanHtml } from "../lib/cleanHtml";
import { cleanHtml } from "@/lib/cleanHtml";
export default function HtmlBody({ htmlString, questionId }: { htmlString?: string; questionId: string }) {
if (!htmlString) return null;
return (
<label
htmlFor={questionId}
className="block text-sm font-normal leading-6 text-slate-600"
className="fb-htmlbody" // styles are in global.css
dangerouslySetInnerHTML={{ __html: cleanHtml(htmlString) }}></label>
);
}

View File

@@ -0,0 +1,9 @@
export default function Progress({ progress }: { progress: number }) {
return (
<div className="bg-accent-bg h-2 w-full rounded-full">
<div
className="transition-width bg-brand z-20 h-2 rounded-full duration-500"
style={{ width: `${Math.floor(progress * 100)}%` }}></div>
</div>
);
}

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