Compare commits

...

15 Commits

Author SHA1 Message Date
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
117 changed files with 4095 additions and 3402 deletions

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

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

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

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

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

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

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,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 { getOrCreatePersonByUserId } from "@formbricks/lib/person/service";
@@ -29,7 +29,6 @@ export async function POST(req: Request): Promise<NextResponse> {
const personWithUserId = await getOrCreatePersonByUserId(userId, environmentId);
const state = await getUpdatedState(environmentId, personWithUserId.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,14 +1,16 @@
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 { getOrCreatePersonByUserId, 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";
import { TPerson } from "@formbricks/types/people";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -43,52 +45,52 @@ 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: TPerson | null;
if (!isMauLimitReached) {
person = await getOrCreatePersonByUserId(userId, environmentId);
} else {
person = await getPersonByUserId(userId, environmentId);
const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`;
if (!person) {
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 { getOrCreatePersonByUserId, 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 getOrCreatePersonByUserId(userId, environmentId);
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 { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
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 { 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 { getSurvey } from "@formbricks/lib/survey/service";
import { TResponse } from "@formbricks/types/responses";
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import type { Metadata } from "next";
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
import { notFound } from "next/navigation";
import { getEmailVerificationStatus } from "./lib/helpers";
interface LinkSurveyPageProps {
params: {
@@ -146,9 +146,8 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
}
const userId = searchParams.userId;
let person;
if (userId) {
person = await getOrCreatePersonByUserId(userId, survey.environmentId);
await getOrCreatePersonByUserId(userId, survey.environmentId);
}
const isSurveyPinProtected = Boolean(!!survey && survey.pin);
@@ -158,7 +157,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 +171,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,42 @@
"@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",
"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",
"eslint-config-formbricks": "workspace:*"
}
}

View File

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

@@ -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,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,4 +1,5 @@
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";
@@ -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,5 +1,5 @@
import { TJsPeopleAttributeInput, TJsState } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TJsState } from "@formbricks/types/js";
import { TPerson, TPersonUpdateInput } from "@formbricks/types/people";
import { Config } from "./config";
import {
AttributeAlreadyExistsError,
@@ -12,6 +12,7 @@ import {
} from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { sync } from "./sync";
const config = Config.getInstance();
const logger = Logger.getInstance();
@@ -27,15 +28,16 @@ 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`,
config.get().state.person?.userId
}`,
{
method: "POST",
headers: {
@@ -57,6 +59,14 @@ export const updatePersonAttribute = async (
});
}
logger.debug("Attribute updated. Syncing...");
await sync({
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
userId: config.get().state.person?.userId,
});
return ok(resJson.data as TJsState);
};
@@ -95,14 +105,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 () => {

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.userId, displayInput.environmentId);
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 && {
@@ -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

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

@@ -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,
@@ -224,20 +225,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);

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.userId, responseInput.environmentId);
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,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

@@ -7,11 +7,14 @@ interface HeadlineProps {
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}>
<label
htmlFor={questionId}
className="text-heading mb-1.5 block text-base font-semibold leading-6"
style={style}>
<div className={"mr-[3ch] flex items-center justify-between"} style={style}>
{headline}
{!required && (
<span className="self-start text-sm font-normal leading-7 text-slate-400" tabIndex={-1}>
<span className="text-info-text self-start text-sm font-normal leading-7" tabIndex={-1}>
Optional
</span>
)}

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

View File

@@ -1,17 +1,16 @@
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
import { useEffect, useState } from "preact/hooks";
import Progress from "./Progress";
import { calculateElementIdx } from "../lib/utils";
import { calculateElementIdx } from "@/lib/utils";
interface ProgressBarProps {
survey: TSurveyWithTriggers;
survey: TSurvey;
questionId: string;
brandColor: string;
}
const PROGRESS_INCREMENT = 0.1;
export default function ProgressBar({ survey, questionId, brandColor }: ProgressBarProps) {
export default function ProgressBar({ survey, questionId }: ProgressBarProps) {
const [progress, setProgress] = useState(0); // [0, 1]
const [prevQuestionIdx, setPrevQuestionIdx] = useState(0); // [0, survey.questions.length
const [prevQuestionId, setPrevQuestionId] = useState(""); // [0, survey.questions.length
@@ -19,7 +18,7 @@ export default function ProgressBar({ survey, questionId, brandColor }: Progress
useEffect(() => {
// calculate progress
setProgress(calculateProgress(questionId, survey, progress));
function calculateProgress(questionId: string, survey: TSurveyWithTriggers, progress: number) {
function calculateProgress(questionId: string, survey: TSurvey, progress: number) {
if (survey.questions.length === 0) return 0;
if (questionId === "end") return 1;
let currentQustionIdx = survey.questions.findIndex((e) => e.id === questionId);
@@ -48,5 +47,5 @@ export default function ProgressBar({ survey, questionId, brandColor }: Progress
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionId, survey, setPrevQuestionIdx]);
return <Progress progress={progress} brandColor={brandColor} />;
return <Progress progress={progress} />;
}

View File

@@ -1,14 +1,13 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import CTAQuestion from "./CTAQuestion";
import ConsentQuestion from "./ConsentQuestion";
import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import NPSQuestion from "./NPSQuestion";
import OpenTextQuestion from "./OpenTextQuestion";
import RatingQuestion from "./RatingQuestion";
import PictureSelectionQuestion from "./PictureSelectionQuestion";
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
import CTAQuestion from "@/components/questions/CTAQuestion";
import ConsentQuestion from "@/components/questions/ConsentQuestion";
import MultipleChoiceMultiQuestion from "@/components/questions/MultipleChoiceMultiQuestion";
import MultipleChoiceSingleQuestion from "@/components/questions/MultipleChoiceSingleQuestion";
import NPSQuestion from "@/components/questions/NPSQuestion";
import OpenTextQuestion from "@/components/questions/OpenTextQuestion";
import PictureSelectionQuestion from "@/components/questions/PictureSelectionQuestion";
import RatingQuestion from "@/components/questions/RatingQuestion";
interface QuestionConditionalProps {
question: TSurveyQuestion;
@@ -18,7 +17,6 @@ interface QuestionConditionalProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
autoFocus?: boolean;
}
@@ -30,7 +28,6 @@ export default function QuestionConditional({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
autoFocus = true,
}: QuestionConditionalProps) {
return question.type === TSurveyQuestionType.OpenText ? (
@@ -42,7 +39,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
autoFocus={autoFocus}
/>
) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? (
@@ -54,7 +50,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
@@ -65,7 +60,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.NPS ? (
<NPSQuestion
@@ -76,7 +70,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.CTA ? (
<CTAQuestion
@@ -87,7 +80,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.Rating ? (
<RatingQuestion
@@ -98,7 +90,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.Consent ? (
<ConsentQuestion
@@ -109,7 +100,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : question.type === TSurveyQuestionType.PictureSelection ? (
<PictureSelectionQuestion
@@ -120,7 +110,6 @@ export default function QuestionConditional({
onBack={onBack}
isFirstQuestion={isFirstQuestion}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
/>
) : null;
}

View File

@@ -35,7 +35,7 @@ export default function RedirectCountDown({ redirectUrl, isRedirectDisabled }: R
return (
<div>
<div className="mt-10 rounded-md bg-slate-100 p-2 text-sm">
<div className="bg-accent-bg text-subheading mt-10 rounded-md p-2 text-sm">
<span>You&apos;re redirected in </span>
<span>{timeRemaining}</span>
</div>

View File

@@ -1,6 +1,6 @@
export default function Subheader({ subheader, questionId }: { subheader?: string; questionId: string }) {
return (
<label htmlFor={questionId} className="block text-sm font-normal leading-6 text-slate-600">
<label htmlFor={questionId} className="text-subheading block text-sm font-normal leading-6">
{subheader}
</label>
);

View File

@@ -1,10 +1,10 @@
import FormbricksBranding from "@/components/general/FormbricksBranding";
import { AutoCloseWrapper } from "@/components/wrappers/AutoCloseWrapper";
import { evaluateCondition } from "@/lib/logicEvaluator";
import { cn } from "@/lib/utils";
import { SurveyBaseProps } from "@/types/props";
import type { TResponseData } from "@formbricks/types/responses";
import { useEffect, useRef, useState } from "preact/hooks";
import { evaluateCondition } from "../lib/logicEvaluator";
import { cn } from "../lib/utils";
import { SurveyBaseProps } from "../types/props";
import { AutoCloseWrapper } from "./AutoCloseWrapper";
import FormbricksBranding from "./FormbricksBranding";
import ProgressBar from "./ProgressBar";
import QuestionConditional from "./QuestionConditional";
import ThankYouCard from "./ThankYouCard";
@@ -12,7 +12,6 @@ import WelcomeCard from "./WelcomeCard";
export function Survey({
survey,
brandColor,
isBrandingEnabled,
activeQuestionId,
onDisplay = () => {},
@@ -129,7 +128,6 @@ export function Survey({
fileUrl={survey.welcomeCard.fileUrl}
buttonLabel={survey.welcomeCard.buttonLabel}
timeToFinish={survey.welcomeCard.timeToFinish}
brandColor={brandColor}
onSubmit={onSubmit}
survey={survey}
/>
@@ -139,7 +137,6 @@ export function Survey({
<ThankYouCard
headline={survey.thankYouCard.headline}
subheader={survey.thankYouCard.subheader}
brandColor={brandColor}
redirectUrl={survey.redirectUrl}
isRedirectDisabled={isRedirectDisabled}
/>
@@ -160,7 +157,6 @@ export function Survey({
: currQues.id === survey?.questions[0]?.id
}
isLastQuestion={currQues.id === survey.questions[survey.questions.length - 1].id}
brandColor={brandColor}
/>
)
);
@@ -169,8 +165,8 @@ export function Survey({
return (
<>
<AutoCloseWrapper survey={survey} brandColor={brandColor} onClose={onClose}>
<div className="flex h-full w-full flex-col justify-between bg-white px-6 pb-3 pt-6">
<AutoCloseWrapper survey={survey} onClose={onClose}>
<div className="flex h-full w-full flex-col justify-between bg-[--fb-survey-background-color] px-6 pb-3 pt-6">
<div ref={contentRef} className={cn(loadingElement ? "animate-pulse opacity-60" : "", "my-auto")}>
{survey.questions.length === 0 && !survey.welcomeCard.enabled && !survey.thankYouCard.enabled ? (
// Handle the case when there are no questions and both welcome and thank you cards are disabled
@@ -181,7 +177,7 @@ export function Survey({
</div>
<div className="mt-8">
{isBrandingEnabled && <FormbricksBranding />}
<ProgressBar survey={survey} questionId={questionId} brandColor={brandColor} />
<ProgressBar survey={survey} questionId={questionId} />
</div>
</div>
</AutoCloseWrapper>

View File

@@ -1,9 +1,8 @@
import { SurveyBaseProps } from "../types/props";
import { SurveyBaseProps } from "@/types/props";
import { Survey } from "./Survey";
export function SurveyInline({
survey,
brandColor,
isBrandingEnabled,
activeQuestionId,
onDisplay = () => {},
@@ -14,10 +13,9 @@ export function SurveyInline({
isRedirectDisabled = false,
}: SurveyBaseProps) {
return (
<div id="fbjs" className="h-full w-full">
<div id="fbjs" className="formbricks-form h-full w-full">
<Survey
survey={survey}
brandColor={brandColor}
isBrandingEnabled={isBrandingEnabled}
activeQuestionId={activeQuestionId}
onDisplay={onDisplay}

View File

@@ -1,11 +1,10 @@
import { useState } from "preact/hooks";
import { SurveyModalProps } from "../types/props";
import Modal from "./Modal";
import { SurveyModalProps } from "@/types/props";
import Modal from "@/components/wrappers/Modal";
import { Survey } from "./Survey";
export function SurveyModal({
survey,
brandColor,
isBrandingEnabled,
activeQuestionId,
placement,
@@ -29,7 +28,7 @@ export function SurveyModal({
};
return (
<div id="fbjs">
<div id="fbjs" className="formbricks-form">
<Modal
placement={placement}
clickOutside={clickOutside}
@@ -39,7 +38,6 @@ export function SurveyModal({
onClose={close}>
<Survey
survey={survey}
brandColor={brandColor}
isBrandingEnabled={isBrandingEnabled}
activeQuestionId={activeQuestionId}
onDisplay={onDisplay}

View File

@@ -1,11 +1,10 @@
import Headline from "./Headline";
import RedirectCountDown from "./RedirectCountdown";
import Subheader from "./Subheader";
import Headline from "@/components/general/Headline";
import RedirectCountDown from "@/components/general/RedirectCountdown";
import Subheader from "@/components/general/Subheader";
interface ThankYouCardProps {
headline?: string;
subheader?: string;
brandColor: string;
redirectUrl: string | null;
isRedirectDisabled: boolean;
}
@@ -13,13 +12,12 @@ interface ThankYouCardProps {
export default function ThankYouCard({
headline,
subheader,
brandColor,
redirectUrl,
isRedirectDisabled,
}: ThankYouCardProps) {
return (
<div className="text-center">
<div className="flex items-center justify-center" style={{ color: brandColor }}>
<div className="text-brand flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -35,7 +33,7 @@ export default function ThankYouCard({
</svg>
</div>
<span className="mb-[10px] inline-block h-1 w-16 rounded-[100%] bg-slate-300"></span>
<span className="bg-shadow mb-[10px] inline-block h-1 w-16 rounded-[100%]"></span>
<div>
<Headline headline={headline} questionId="thankYouCard" style={{ "justify-content": "center" }} />

View File

@@ -1,8 +1,8 @@
import SubmitButton from "@/components/buttons/SubmitButton";
import { calculateElementIdx } from "@/lib/utils";
import { TSurvey } from "@formbricks/types/surveys";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { calculateElementIdx } from "../lib/utils";
import { TSurveyWithTriggers } from "@formbricks/types/js";
interface WelcomeCardProps {
headline?: string;
@@ -10,9 +10,8 @@ interface WelcomeCardProps {
fileUrl?: string;
buttonLabel?: string;
timeToFinish?: boolean;
brandColor: string;
onSubmit: (data: { [x: string]: any }) => void;
survey: TSurveyWithTriggers;
survey: TSurvey;
}
const TimerIcon = () => {
@@ -38,7 +37,6 @@ export default function WelcomeCard({
fileUrl,
buttonLabel,
timeToFinish,
brandColor,
onSubmit,
survey,
}: WelcomeCardProps) {
@@ -85,14 +83,13 @@ export default function WelcomeCard({
<SubmitButton
buttonLabel={buttonLabel}
isLastQuestion={false}
brandColor={brandColor}
focus={true}
onClick={() => {
onSubmit({ ["welcomeCard"]: "clicked" });
}}
type="button"
/>
<div className="flex items-center text-xs text-slate-600">Press Enter </div>
<div className="text-subheading flex items-center text-xs">Press Enter </div>
</div>
</div>
{timeToFinish && (

View File

@@ -1,9 +1,9 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import HtmlBody from "@/components/general/HtmlBody";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyCTAQuestion } from "@formbricks/types/surveys";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
interface CTAQuestionProps {
question: TSurveyCTAQuestion;
@@ -13,7 +13,6 @@ interface CTAQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function CTAQuestion({
@@ -22,7 +21,6 @@ export default function CTAQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: CTAQuestionProps) {
return (
<div>
@@ -47,14 +45,13 @@ export default function CTAQuestion({
onClick={() => {
onSubmit({ [question.id]: "dismissed" });
}}
className="mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 dark:text-slate-400">
className="text-heading focus:ring-focus mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2">
{question.dismissButtonLabel || "Skip"}
</button>
)}
<SubmitButton
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
focus={true}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {

View File

@@ -1,9 +1,9 @@
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyConsentQuestion } from "@formbricks/types/surveys";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import HtmlBody from "@/components/general/HtmlBody";
interface ConsentQuestionProps {
question: TSurveyConsentQuestion;
@@ -13,7 +13,6 @@ interface ConsentQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function ConsentQuestion({
@@ -24,7 +23,6 @@ export default function ConsentQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: ConsentQuestionProps) {
return (
<div>
@@ -49,7 +47,7 @@ export default function ConsentQuestion({
onChange({ [question.id]: "accepted" });
}
}}
className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 p-4 text-sm text-slate-800 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2">
className="border-border bg-survey-bg text-heading hover:bg-accent-bg focus:bg-accent-bg focus:ring-border-highlight relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border p-4 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2">
<input
type="checkbox"
id={question.id}
@@ -63,9 +61,8 @@ export default function ConsentQuestion({
}
}}
checked={value === "accepted"}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
className="border-brand text-brand h-4 w-4 border focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${question.id}-label`}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required}
/>
<span id={`${question.id}-label`} className="ml-3 font-medium">
@@ -80,7 +77,6 @@ export default function ConsentQuestion({
<div />
<SubmitButton
tabIndex={2}
brandColor={brandColor}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
onClick={() => {}}

View File

@@ -1,11 +1,11 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import Subheader from "@/components/general/Subheader";
import { cn, shuffleQuestions } from "@/lib/utils";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys";
import { useMemo, useRef, useState, useEffect, useCallback } from "preact/hooks";
import { cn, shuffleQuestions } from "../lib/utils";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
interface MultipleChoiceMultiProps {
question: TSurveyMultipleChoiceMultiQuestion;
@@ -15,7 +15,6 @@ interface MultipleChoiceMultiProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function MultipleChoiceMultiQuestion({
@@ -26,7 +25,6 @@ export default function MultipleChoiceMultiQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: MultipleChoiceMultiProps) {
const getChoicesWithoutOtherLabels = useCallback(
() => question.choices.filter((choice) => choice.id !== "other").map((item) => item.label),
@@ -104,7 +102,7 @@ export default function MultipleChoiceMultiQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2">
<div className="bg-survey-bg relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}
@@ -119,8 +117,10 @@ export default function MultipleChoiceMultiQuestion({
}
}}
className={cn(
value === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none "
value === choice.label
? "border-border-highlight bg-accent-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight hover:bg-accent-bg focus:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
@@ -129,7 +129,7 @@ export default function MultipleChoiceMultiQuestion({
name={question.id}
tabIndex={-1}
value={choice.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
className="border-brand text-brand h-4 w-4 border focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement)?.checked) {
@@ -139,7 +139,6 @@ export default function MultipleChoiceMultiQuestion({
}
}}
checked={Array.isArray(value) && value.includes(choice.label)}
style={{ borderColor: brandColor, color: brandColor }}
required={
question.required && Array.isArray(value) && value.length ? false : question.required
}
@@ -154,8 +153,10 @@ export default function MultipleChoiceMultiQuestion({
<label
tabIndex={questionChoices.length + 1}
className={cn(
value === otherOption.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none"
value === otherOption.label
? "border-border-highlight bg-accent-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight focus-within:bg-accent-bg hover:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}
onKeyDown={(e) => {
if (e.key == "Enter") {
@@ -169,7 +170,7 @@ export default function MultipleChoiceMultiQuestion({
id={otherOption.id}
name={question.id}
value={otherOption.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
className="border-brand text-brand h-4 w-4 border focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={(e) => {
setOtherSelected(!otherSelected);
@@ -181,7 +182,6 @@ export default function MultipleChoiceMultiQuestion({
}
}}
checked={otherSelected}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${otherOption.id}-label`} className="ml-3 font-medium">
{otherOption.label}
@@ -206,7 +206,7 @@ export default function MultipleChoiceMultiQuestion({
}
}}
placeholder="Please specify"
className="mt-3 flex h-10 w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus mt-3 flex h-10 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
/>
@@ -229,7 +229,6 @@ export default function MultipleChoiceMultiQuestion({
tabIndex={questionChoices.length + 2}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>

View File

@@ -1,11 +1,11 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import Subheader from "@/components/general/Subheader";
import { cn, shuffleQuestions } from "@/lib/utils";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys";
import { useMemo, useRef, useState, useEffect } from "preact/hooks";
import { cn, shuffleQuestions } from "../lib/utils";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
interface MultipleChoiceSingleProps {
question: TSurveyMultipleChoiceSingleQuestion;
@@ -15,7 +15,6 @@ interface MultipleChoiceSingleProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function MultipleChoiceSingleQuestion({
@@ -26,7 +25,6 @@ export default function MultipleChoiceSingleQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: MultipleChoiceSingleProps) {
const [otherSelected, setOtherSelected] = useState(
!!value && !question.choices.find((c) => c.label === value)
@@ -73,8 +71,9 @@ export default function MultipleChoiceSingleQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div
className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2"
className="bg-survey-bg relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2"
role="radiogroup">
{questionChoices.map((choice, idx) => (
<label
@@ -89,8 +88,10 @@ export default function MultipleChoiceSingleQuestion({
}
}}
className={cn(
value === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none "
value === choice.label
? "border-border-highlight bg-accent-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight focus-within:bg-accent-bg hover:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
@@ -99,14 +100,13 @@ export default function MultipleChoiceSingleQuestion({
id={choice.id}
name={question.id}
value={choice.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
className="border-brand text-brand h-4 w-4 border focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => {
setOtherSelected(false);
onChange({ [question.id]: choice.label });
}}
checked={value === choice.label}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required && idx === 0}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
@@ -119,8 +119,10 @@ export default function MultipleChoiceSingleQuestion({
<label
tabIndex={questionChoices.length + 1}
className={cn(
value === otherOption.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none"
value === otherOption.label
? "border-border-highlight bg-accent-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight focus-within:bg-accent-bg hover:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
)}
onKeyDown={(e) => {
if (e.key == "Enter") {
@@ -135,14 +137,13 @@ export default function MultipleChoiceSingleQuestion({
tabIndex={-1}
name={question.id}
value={otherOption.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
className="border-brand text-brand h-4 w-4 border focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
setOtherSelected(!otherSelected);
onChange({ [question.id]: "" });
}}
checked={otherSelected}
style={{ borderColor: brandColor, color: brandColor }}
/>
<span id={`${otherOption.id}-label`} className="ml-3 font-medium">
{otherOption.label}
@@ -166,7 +167,7 @@ export default function MultipleChoiceSingleQuestion({
}
}}
placeholder="Please specify"
className="mt-3 flex h-10 w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus mt-3 flex h-10 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
/>
@@ -189,7 +190,6 @@ export default function MultipleChoiceSingleQuestion({
tabIndex={questionChoices.length + 2}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>

View File

@@ -1,10 +1,10 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import Subheader from "@/components/general/Subheader";
import { cn } from "@/lib/utils";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyNPSQuestion } from "@formbricks/types/surveys";
import { cn } from "../lib/utils";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface NPSQuestionProps {
question: TSurveyNPSQuestion;
@@ -14,7 +14,6 @@ interface NPSQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function NPSQuestion({
@@ -25,7 +24,6 @@ export default function NPSQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: NPSQuestionProps) {
return (
<form
@@ -55,8 +53,8 @@ export default function NPSQuestion({
}
}}
className={cn(
value === number ? "z-10 border-slate-400 bg-slate-50" : "",
"relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 text-slate-800 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
value === number ? "border-border-highlight bg-accent-selected-bg z-10" : "border-border",
"bg-survey-bg text-heading hover:bg-accent-bg relative h-10 flex-1 cursor-pointer border text-center text-sm leading-10 first:rounded-l-md last:rounded-r-md focus:outline-none"
)}>
<input
type="radio"
@@ -78,7 +76,7 @@ export default function NPSQuestion({
</label>
))}
</div>
<div className="flex justify-between px-1.5 text-xs leading-6 text-slate-500">
<div className="text-info-text flex justify-between px-1.5 text-xs leading-6">
<p>{question.lowerLabel}</p>
<p>{question.upperLabel}</p>
</div>
@@ -101,7 +99,6 @@ export default function NPSQuestion({
tabIndex={12}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
)}

View File

@@ -1,9 +1,9 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import Subheader from "@/components/general/Subheader";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion } from "@formbricks/types/surveys";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { useCallback } from "react";
interface OpenTextQuestionProps {
@@ -14,7 +14,6 @@ interface OpenTextQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
autoFocus?: boolean;
}
@@ -26,7 +25,6 @@ export default function OpenTextQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
autoFocus = true,
}: OpenTextQuestionProps) {
const handleInputChange = (inputValue: string) => {
@@ -40,8 +38,11 @@ export default function OpenTextQuestion({
currentElement.focus();
}
},
[autoFocus]
[question.id]
);
const isInputEmpty = (value: string) => {
return question.required && !value?.trim();
};
return (
<form
@@ -73,14 +74,16 @@ export default function OpenTextQuestion({
type={question.inputType}
onInput={(e) => handleInputChange(e.currentTarget.value)}
autoFocus={autoFocus}
className="border-border bg-survey-bg focus:border-border-highlight block w-full rounded-md border p-2 shadow-sm focus:outline-none focus:ring-0 sm:text-sm"
onKeyDown={(e) => {
if (e.key == "Enter") onSubmit({ [question.id]: value });
if (e.key === "Enter" && isInputEmpty(value as string)) {
e.preventDefault(); // Prevent form submission
} else if (e.key === "Enter") {
onSubmit({ [question.id]: value });
}
}}
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
className={`block w-full rounded-md border
border-slate-100
bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm`}
/>
) : (
<textarea
@@ -95,11 +98,10 @@ export default function OpenTextQuestion({
type={question.inputType}
onInput={(e) => handleInputChange(e.currentTarget.value)}
autoFocus={autoFocus}
className="border-border bg-survey-bg text-subheading focus:border-border-highlight block w-full rounded-md border p-2 shadow-sm focus:ring-0 sm:text-sm"
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
className={`block w-full rounded-md border
border-slate-100
bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm`}></textarea>
/>
)}
</div>
@@ -113,12 +115,7 @@ export default function OpenTextQuestion({
/>
)}
<div></div>
<SubmitButton
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
<SubmitButton buttonLabel={question.buttonLabel} isLastQuestion={isLastQuestion} onClick={() => {}} />
</div>
</form>
);

View File

@@ -1,11 +1,11 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import Subheader from "@/components/general/Subheader";
import { cn } from "@/lib/utils";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
import { useEffect } from "preact/hooks";
import { cn } from "../lib/utils";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
interface PictureSelectionProps {
question: TSurveyPictureSelectionQuestion;
@@ -15,7 +15,6 @@ interface PictureSelectionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function PictureSelectionQuestion({
@@ -26,7 +25,6 @@ export default function PictureSelectionQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: PictureSelectionProps) {
const addItem = (item: string) => {
let values: string[] = [];
@@ -95,7 +93,7 @@ export default function PictureSelectionQuestion({
<div className="mt-4">
<fieldset>
<legend className="sr-only">Options</legend>
<div className="relative grid max-h-[42vh] grid-cols-2 gap-x-5 gap-y-4 overflow-y-auto rounded-md bg-white pr-2.5">
<div className="rounded-m bg-survey-bg relative grid max-h-[42vh] grid-cols-2 gap-x-5 gap-y-4 overflow-y-auto pr-2.5">
{questionChoices.map((choice, idx) => (
<label
key={choice.id}
@@ -106,17 +104,12 @@ export default function PictureSelectionQuestion({
handleChange(choice.id);
}
}}
style={{
borderColor:
Array.isArray(value) && value.includes(choice.id) ? brandColor : "border-slate-400",
color: brandColor,
}}
onClick={() => handleChange(choice.id)}
className={cn(
Array.isArray(value) && value.includes(choice.id)
? `z-10 border-4 shadow-xl focus:border-4`
? `border-brand text-brand z-10 border-4 shadow-xl focus:border-4`
: "",
"relative box-border inline-block h-28 w-full overflow-hidden rounded-xl border border-slate-400 focus:border-slate-600 focus:bg-slate-50 focus:outline-none"
"border-border focus:border-border-highlight focus:bg-accent-selected-bg relative box-border inline-block h-28 w-full overflow-hidden rounded-xl border focus:outline-none"
)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -132,8 +125,10 @@ export default function PictureSelectionQuestion({
type="checkbox"
tabindex={-1}
checked={Array.isArray(value) && value.includes(choice.id)}
style={{ borderColor: brandColor, color: brandColor }}
className="pointer-events-none absolute right-2 top-2 z-20 h-5 w-5 rounded border border-slate-400"
className={cn(
"border-border pointer-events-none absolute right-2 top-2 z-20 h-5 w-5 rounded border",
Array.isArray(value) && value.includes(choice.id) ? "border-brand text-brand" : ""
)}
required={
question.required && Array.isArray(value) && value.length ? false : question.required
}
@@ -145,8 +140,10 @@ export default function PictureSelectionQuestion({
type="radio"
tabindex={-1}
checked={Array.isArray(value) && value.includes(choice.id)}
style={{ borderColor: brandColor, color: brandColor }}
className="pointer-events-none absolute right-2 top-2 z-20 h-5 w-5 "
className={cn(
"border-border pointer-events-none absolute right-2 top-2 z-20 h-5 w-5 rounded-full border",
Array.isArray(value) && value.includes(choice.id) ? "border-brand text-brand" : ""
)}
required={
question.required && Array.isArray(value) && value.length ? false : question.required
}
@@ -170,7 +167,6 @@ export default function PictureSelectionQuestion({
tabIndex={questionChoices.length + 2}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>

View File

@@ -1,9 +1,10 @@
import { BackButton } from "@/components/buttons/BackButton";
import SubmitButton from "@/components/buttons/SubmitButton";
import Headline from "@/components/general/Headline";
import { cn } from "@/lib/utils";
import { TResponseData } from "@formbricks/types/responses";
import type { TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { useState } from "preact/hooks";
import { cn } from "../lib/utils";
import { BackButton } from "./BackButton";
import Headline from "./Headline";
import {
ConfusedFace,
FrowningFace,
@@ -15,9 +16,8 @@ import {
SmilingFaceWithSmilingEyes,
TiredFace,
WearyFace,
} from "./Smileys";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
} from "../general/Smileys";
import Subheader from "../general/Subheader";
interface RatingQuestionProps {
question: TSurveyRatingQuestion;
@@ -27,7 +27,6 @@ interface RatingQuestionProps {
onBack: () => void;
isFirstQuestion: boolean;
isLastQuestion: boolean;
brandColor: string;
}
export default function RatingQuestion({
@@ -38,7 +37,6 @@ export default function RatingQuestion({
onBack,
isFirstQuestion,
isLastQuestion,
brandColor,
}: RatingQuestionProps) {
const [hoveredNumber, setHoveredNumber] = useState(0);
@@ -87,7 +85,7 @@ export default function RatingQuestion({
key={number}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(0)}
className="max-w-10 relative max-h-10 flex-1 cursor-pointer bg-white text-center text-sm leading-10">
className="max-w-10 bg-survey-bg relative max-h-10 flex-1 cursor-pointer text-center text-sm leading-10">
{question.scale === "number" ? (
<label
tabIndex={i + 1}
@@ -97,10 +95,10 @@ export default function RatingQuestion({
}
}}
className={cn(
value === number ? "z-10 border-slate-400 bg-slate-50" : "",
value === number ? "bg-accent-selected-bg border-border-highlight z-10" : "",
a.length === number ? "rounded-r-md" : "",
number === 1 ? "rounded-l-md" : "",
"block h-full w-full border text-slate-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
"text-heading hover:bg-accent-bg focus:bg-accent-bg block h-full w-full border focus:outline-none"
)}>
<HiddenRadioInput number={number} />
{number}
@@ -114,14 +112,14 @@ export default function RatingQuestion({
}
}}
className={cn(
number <= hoveredNumber ? "text-yellow-500" : "",
"flex h-full w-full justify-center focus:text-yellow-500 focus:outline-none"
number <= hoveredNumber ? "text-rating-focus" : "text-heading",
"focus:text-rating-focus flex h-full w-full justify-center focus:outline-none"
)}
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(0)}>
<HiddenRadioInput number={number} />
{typeof value === "number" && value >= number ? (
<span className="text-yellow-300">
<span className="text-rating-fill">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@@ -152,13 +150,18 @@ export default function RatingQuestion({
</label>
) : (
<label
className={cn(
"flex h-full w-full justify-center",
value === number || hoveredNumber === number
? "stroke-rating-selected text-rating-selected"
: "stroke-heading text-heading"
)}
tabIndex={i + 1}
onKeyDown={(e) => {
if (e.key == "Enter") {
handleSelect(number);
}
}}
className="flex h-full w-full justify-center text-slate-800 focus:outline-none"
onFocus={() => setHoveredNumber(number)}
onBlur={() => setHoveredNumber(0)}>
<HiddenRadioInput number={number} />
@@ -172,7 +175,7 @@ export default function RatingQuestion({
</span>
))}
</div>
<div className="flex justify-between px-1.5 text-xs leading-6 text-slate-500">
<div className="text-subheading flex justify-between px-1.5 text-xs leading-6">
<p className="w-1/2 text-left">{question.lowerLabel}</p>
<p className="w-1/2 text-right">{question.upperLabel}</p>
</div>
@@ -195,7 +198,6 @@ export default function RatingQuestion({
tabIndex={question.range + 1}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
)}
@@ -211,7 +213,7 @@ interface RatingSmileyProps {
}
function RatingSmiley({ active, idx, range }: RatingSmileyProps): JSX.Element {
const activeColor = "fill-yellow-500";
const activeColor = "fill-rating-fill";
const inactiveColor = "fill-none";
let icons = [
<TiredFace className={active ? activeColor : inactiveColor} />,

View File

@@ -1,15 +1,14 @@
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { TSurvey } from "@formbricks/types/surveys";
import { useEffect, useRef, useState } from "preact/hooks";
import Progress from "./Progress";
import Progress from "../general/Progress";
interface AutoCloseProps {
survey: TSurveyWithTriggers;
brandColor: string;
survey: TSurvey;
onClose: () => void;
children: any;
}
export function AutoCloseWrapper({ survey, brandColor, onClose, children }: AutoCloseProps) {
export function AutoCloseWrapper({ survey, onClose, children }: AutoCloseProps) {
const [countdownProgress, setCountdownProgress] = useState(100);
const [countdownStop, setCountdownStop] = useState(false);
const startRef = useRef(performance.now());
@@ -49,9 +48,7 @@ export function AutoCloseWrapper({ survey, brandColor, onClose, children }: Auto
return (
<>
{!countdownStop && survey.autoClose && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
{!countdownStop && survey.autoClose && <Progress progress={countdownProgress} />}
<div onClick={handleStopCountdown} onMouseOver={handleStopCountdown} className="h-full w-full">
{children}
</div>

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { TPlacement } from "@formbricks/types/common";
import { VNode } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { cn } from "../lib/utils";
interface ModalProps {
children: VNode;
@@ -101,14 +101,14 @@ export default function Modal({
className={cn(
getPlacementStyle(placement),
show ? "opacity-100" : "opacity-0",
"pointer-events-auto absolute bottom-0 h-fit w-full overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-hidden rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
)}>
{!isCenter && (
<div class="absolute right-0 top-0 block pr-[1.4rem] pt-2">
<button
type="button"
onClick={onClose}
class="relative rounded-md text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2">
class="text-close-button hover:text-close-button-focus focus:ring-close-button-focus relative rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2">
<span class="sr-only">Close</span>
<svg
class="h-4 w-4"

View File

@@ -1,11 +1,13 @@
import { SurveyInline } from "@/components/general/SurveyInline";
import { SurveyModal } from "@/components/general/SurveyModal";
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
import { SurveyInlineProps, SurveyModalProps } from "@/types/props";
import { h, render } from "preact";
import { SurveyModal } from "./components/SurveyModal";
import { addStylesToDom } from "./lib/styles";
import { SurveyInlineProps, SurveyModalProps } from "./types/props";
import { SurveyInline } from "./components/SurveyInline";
export const renderSurveyInline = (props: SurveyInlineProps) => {
export const renderSurveyInline = (props: SurveyInlineProps & { brandColor: string }) => {
addStylesToDom();
addCustomThemeToDom({ brandColor: props.brandColor });
const { containerId, ...surveyProps } = props;
const element = document.getElementById(containerId);
if (!element) {
@@ -14,8 +16,10 @@ export const renderSurveyInline = (props: SurveyInlineProps) => {
render(h(SurveyInline, surveyProps), element);
};
export const renderSurveyModal = (props: SurveyModalProps) => {
export const renderSurveyModal = (props: SurveyModalProps & { brandColor: string }) => {
addStylesToDom();
addCustomThemeToDom({ brandColor: props.brandColor });
// add container element to DOM
const element = document.createElement("div");
element.id = "formbricks-modal-container";

View File

@@ -47,6 +47,7 @@ export function evaluateCondition(logic: TSurveyLogic, responseValue: any): bool
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === undefined ||
responseValue === "dismissed"
);
default:

View File

@@ -1,6 +1,7 @@
import global from "../styles/global.css?inline";
import preflight from "../styles/preflight.css?inline";
import global from "@/styles/global.css?inline";
import preflight from "@/styles/preflight.css?inline";
import editorCss from "../../../ui/Editor/stylesEditorFrontend.css?inline";
import { isLight } from "@/lib/utils";
export const addStylesToDom = () => {
if (document.getElementById("formbricks__css") === null) {
@@ -10,3 +11,17 @@ export const addStylesToDom = () => {
document.head.appendChild(styleElement);
}
};
export const addCustomThemeToDom = ({ brandColor }: { brandColor: string }) => {
if (document.getElementById("formbricks__css") === null) return;
const styleElement = document.createElement("style");
styleElement.id = "formbricks__css__custom";
styleElement.innerHTML = `
:root {
--fb-brand-color: ${brandColor};
${isLight(brandColor) ? "--fb-brand-text-color: black;" : "--fb-brand-text-color: white;"}
}
`;
document.head.appendChild(styleElement);
};

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