Merge branch 'main' into surveyBg

This commit is contained in:
Anjy Gupta
2023-11-17 01:46:46 +05:30
committed by GitHub
17 changed files with 791 additions and 407 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,21 +85,20 @@ export default function LinkSurvey({
}
}, []);
const [hiddenFieldsRecord, setHiddenFieldsRecord] = useState<Record<string, string | number | string[]>>();
const hiddenFieldsRecord = useMemo<Record<string, string | number | string[]> | null>(() => {
const fieldsRecord: Record<string, string | number | string[]> = {};
let fieldsSet = false;
useEffect(() => {
survey.hiddenFields?.fieldIds?.forEach((field) => {
// set the question and answer to the survey state
const answer = searchParams?.get(field);
if (answer) {
setHiddenFieldsRecord((prev) => {
return {
...prev,
[field]: answer,
};
});
fieldsRecord[field] = answer;
fieldsSet = true;
}
});
// Only return the record if at least one field was set.
return fieldsSet ? fieldsRecord : null;
}, [searchParams, survey.hiddenFields?.fieldIds]);
useEffect(() => {

View File

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

View File

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

View File

@@ -103,6 +103,20 @@ export const colours = [
"#CDFAD5",
];
// Rate Limiting
export const SIGNUP_RATE_LIMIT = {
interval: 60 * 60 * 1000, // 60 minutes
allowedPerInterval: 5,
};
export const LOGIN_RATE_LIMIT = {
interval: 15 * 60 * 1000, // 15 minutes
allowedPerInterval: 5,
};
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 10 * 60 * 1000, // 60 minutes
allowedPerInterval: 50,
};
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;

View File

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

View File

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

View File

@@ -255,17 +255,15 @@ export default function SingleResponseCard({
{timeSince(response.updatedAt.toISOString())}
</time>
{!isViewer && (
<TooltipRenderer
shouldRender={isSubmissionFresh || !response.finished}
tooltipContent={deleteSubmissionToolTip}>
<TooltipRenderer shouldRender={isSubmissionFresh} tooltipContent={deleteSubmissionToolTip}>
<TrashIcon
onClick={() => {
if (!isSubmissionFresh || !response.finished) {
if (!isSubmissionFresh) {
setDeleteDialogOpen(true);
}
}}
className={`h-4 w-4 ${
isSubmissionFresh || !response.finished
isSubmissionFresh
? "cursor-not-allowed text-gray-400"
: "text-slate-500 hover:text-red-700"
} `}