mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 09:50:10 -06:00
Merge branch 'main' into surveyBg
This commit is contained in:
@@ -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,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
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();
|
||||
}),
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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"
|
||||
} `}
|
||||
|
||||
Reference in New Issue
Block a user