mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-01 04:49:11 -06:00
Compare commits
15 Commits
@formbrick
...
@formbrick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11c47d4ca | ||
|
|
4baea07471 | ||
|
|
ff87be717c | ||
|
|
e3e595af9a | ||
|
|
3dae10d665 | ||
|
|
6727ccf1cd | ||
|
|
9242ab3a7d | ||
|
|
e9d8de3574 | ||
|
|
0a252e5827 | ||
|
|
632f6068c4 | ||
|
|
4d280e04d1 | ||
|
|
73bde4fda6 | ||
|
|
9d4e21f8a7 | ||
|
|
3eeea7d1b2 | ||
|
|
32268a8ec3 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
1
apps/web/.env
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.env
|
||||
@@ -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
|
||||
<span className="font-semibold">{getTextFromType(type)}</span>, please
|
||||
{type === "linkSurvey" ? (
|
||||
<span className="underline">
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>upgrade your plan.</Link>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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&", "?")
|
||||
.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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts
Normal file
11
apps/web/app/api/v1/(legacy)/js/sync/lib/legacy.ts
Normal 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: {} };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
15
apps/web/app/middleware/bucket.ts
Normal file
15
apps/web/app/middleware/bucket.ts
Normal 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,
|
||||
});
|
||||
10
apps/web/app/middleware/endpointValidator.ts
Normal file
10
apps/web/app/middleware/endpointValidator.ts
Normal 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);
|
||||
};
|
||||
@@ -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();
|
||||
}),
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"stripe": "^14.0.0"
|
||||
"stripe": "^14.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
/*
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"baseUrl": "packages/lib",
|
||||
"paths": {
|
||||
"@/*": ["../../apps/web/*"],
|
||||
"@prisma/client/*": ["@formbricks/database/client/*"]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
9
packages/surveys/src/components/general/Progress.tsx
Normal file
9
packages/surveys/src/components/general/Progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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're redirected in </span>
|
||||
<span>{timeRemaining}</span>
|
||||
</div>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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" }} />
|
||||
@@ -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 && (
|
||||
@@ -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) {
|
||||
@@ -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={() => {}}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
)}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
@@ -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} />,
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user