mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-12 09:39:39 -06:00
chore: Transactional emails to React email (#2349)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
db5efd3b8c
commit
5ff6e88b3b
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { sendInviteMemberEmail } from "@formbricks/email";
|
||||
import { hasTeamAuthority } from "@formbricks/lib/auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
@@ -109,8 +110,36 @@ export const createInviteTokenAction = async (inviteId: string) => {
|
||||
return { inviteToken: encodeURIComponent(inviteToken) };
|
||||
};
|
||||
|
||||
export const resendInviteAction = async (inviteId: string) => {
|
||||
return await resendInvite(inviteId);
|
||||
export const resendInviteAction = async (inviteId: string, teamId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasTeamAuthority(session.user.id, teamId);
|
||||
|
||||
if (INVITE_DISABLED) {
|
||||
throw new AuthenticationError("Invite disabled");
|
||||
}
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
throw new AuthenticationError("Not authorized");
|
||||
}
|
||||
|
||||
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(teamId, session.user.id);
|
||||
if (!hasCreateOrUpdateMembersAccess) {
|
||||
throw new AuthenticationError("Not authorized");
|
||||
}
|
||||
const invite = await getInvite(inviteId);
|
||||
|
||||
const updatedInvite = await resendInvite(inviteId);
|
||||
await sendInviteMemberEmail(
|
||||
inviteId,
|
||||
updatedInvite.email,
|
||||
invite?.creator.name ?? "",
|
||||
updatedInvite.name ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
export const inviteUserAction = async (
|
||||
@@ -150,6 +179,10 @@ export const inviteUserAction = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (invite) {
|
||||
await sendInviteMemberEmail(invite.id, email, session.user.name ?? "", name ?? "", false);
|
||||
}
|
||||
|
||||
return invite;
|
||||
};
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
|
||||
try {
|
||||
if (!invite) return;
|
||||
|
||||
await resendInviteAction(invite.id);
|
||||
await resendInviteAction(invite.id, team.id);
|
||||
toast.success("Invitation sent once more.");
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { sendEmbedSurveyPreviewEmail } from "@formbricks/email";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
|
||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { formatSurveyDateFields } from "@formbricks/lib/survey/util";
|
||||
|
||||
@@ -1,32 +1,8 @@
|
||||
import {
|
||||
Column,
|
||||
Container,
|
||||
Button as EmailButton,
|
||||
Img,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getPreviewEmailTemplateHtml } from "@formbricks/email/components/survey/PreviewEmailTemplste";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { isLight } from "@formbricks/lib/utils";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { RatingSmiley } from "@formbricks/ui/RatingSmiley";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
export const getEmailTemplateHtml = async (surveyId) => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
@@ -39,362 +15,10 @@ export const getEmailTemplateHtml = async (surveyId) => {
|
||||
}
|
||||
const brandColor = product.styling.brandColor?.light || COLOR_DEFAULTS.brandColor;
|
||||
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
|
||||
const html = render(<EmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
|
||||
pretty: true,
|
||||
});
|
||||
const html = getPreviewEmailTemplateHtml(survey, surveyUrl, brandColor);
|
||||
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 defaultLanguageCode = "default";
|
||||
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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-slate-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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 font-medium text-slate-800">
|
||||
<Text className="m-0 inline-block">
|
||||
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex w-max flex-col">
|
||||
<Section className="block overflow-hidden rounded-md border border-slate-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-slate-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">
|
||||
{getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block w-max p-0 text-right">
|
||||
{getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}></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">
|
||||
{getLocalizedValue(firstQuestion.dismissButtonLabel, defaultLanguageCode) || "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"
|
||||
)}>
|
||||
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section className=" w-full">
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 w-full items-center justify-center">
|
||||
<Section
|
||||
className={cn("w-full overflow-hidden rounded-md", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
})}>
|
||||
<Column className="mb-4 flex w-full justify-around">
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
" m-0 h-10 w-full p-0 text-center align-middle leading-10 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
{firstQuestion.scale === "smiley" && (
|
||||
<RatingSmiley active={false} idx={i} range={firstQuestion.range} />
|
||||
)}
|
||||
{firstQuestion.scale === "number" && (
|
||||
<Text className="m-0 flex h-10 items-center">{i + 1}</Text>
|
||||
)}
|
||||
{firstQuestion.scale === "star" && <Text className="text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Column>
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block p-0 text-right">
|
||||
{getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</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-slate-200 bg-slate-50 p-4 text-slate-800"
|
||||
key={choice.id}>
|
||||
{getLocalizedValue(choice.label, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="mt-2 block rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${getLocalizedValue(choice.label, defaultLanguageCode)}`}>
|
||||
{getLocalizedValue(choice.label, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</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>
|
||||
);
|
||||
case TSurveyQuestionType.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Container>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
You have been invited to schedule a meet via cal.com.
|
||||
</Text>
|
||||
<EmailButton
|
||||
className={cn(
|
||||
"bg-brand-color mx-auto block w-max cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium ",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
Schedule your meeting
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Section className="mt-4 flex h-12 w-full items-center justify-center rounded-lg border border-solid border-slate-200 bg-white">
|
||||
<CalendarDaysIcon className="mb-1 inline h-4 w-4" />
|
||||
<Text className="inline text-sm font-medium">Select a date</Text>
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Address:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Section
|
||||
key={index}
|
||||
className="mt-4 block h-10 w-full rounded-lg border border-solid border-slate-200 bg-slate-50"
|
||||
/>
|
||||
))}
|
||||
<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 overflow-auto 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { sendInviteMemberEmail } from "@formbricks/email";
|
||||
import { hasTeamAuthority } from "@formbricks/lib/auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
@@ -55,10 +56,19 @@ export const inviteTeamMateAction = async (
|
||||
name: "",
|
||||
role,
|
||||
},
|
||||
isOnboardingInvite: true,
|
||||
inviteMessage: inviteMessage,
|
||||
});
|
||||
|
||||
if (invite) {
|
||||
await sendInviteMemberEmail(
|
||||
invite.id,
|
||||
email,
|
||||
session.user.name ?? "",
|
||||
"",
|
||||
true, // is onboarding invite
|
||||
inviteMessage
|
||||
);
|
||||
}
|
||||
|
||||
return invite;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { sendInviteAcceptedEmail } from "@formbricks/email";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { sendInviteAcceptedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { withEmailTemplate } from "@formbricks/lib/emails/email-template";
|
||||
import { sendEmail } from "@formbricks/lib/emails/emails";
|
||||
|
||||
import { Insights, NotificationResponse, Survey, SurveyResponse } from "./types";
|
||||
|
||||
const getEmailSubject = (productName: string) => {
|
||||
return `${productName} User Insights - Last Week by Formbricks`;
|
||||
};
|
||||
|
||||
const notificationHeader = (
|
||||
productName: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startYear: number,
|
||||
endYear: number
|
||||
) =>
|
||||
`
|
||||
<div style="display: block; padding: 1rem 0rem;">
|
||||
<div style="float: left; margin-top: 0.5rem;">
|
||||
<h1 style="margin: 0rem;">Hey 👋</h1>
|
||||
</div>
|
||||
<div style="float: right;">
|
||||
<p style="text-align: right; margin: 0; font-weight: 600;">Weekly Report for ${productName}</p>
|
||||
${getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)}
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
`;
|
||||
|
||||
const getNotificationHeaderimePeriod = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startYear: number,
|
||||
endYear: number
|
||||
) => {
|
||||
if (startYear == endYear) {
|
||||
return `<p style="text-align: right; margin: 0;">${startDate} - ${endDate} ${endYear}</p>`;
|
||||
} else {
|
||||
return `<p style="text-align: right; margin: 0;">${startDate} ${startYear} - ${endDate} ${endYear}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
const notificationInsight = (insights: Insights) =>
|
||||
`<div style="display: block;">
|
||||
<table style="background-color: #f1f5f9; border-radius:1em; margin-top:1em; margin-bottom:1em;">
|
||||
<tr>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Surveys</p>
|
||||
<h1>${insights.numLiveSurvey}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Displays</p>
|
||||
<h1>${insights.totalDisplays}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Responses</p>
|
||||
<h1>${insights.totalResponses}</h1>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completed</p>
|
||||
<h1>${insights.totalCompletedResponses}</h1>
|
||||
</td>
|
||||
${
|
||||
insights.totalDisplays !== 0
|
||||
? `<td style="text-align:center;">
|
||||
<p style="font-size:0.9em">Completion %</p>
|
||||
<h1>${Math.round(insights.completionRate)}%</h1>
|
||||
</td>`
|
||||
: ""
|
||||
}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function convertSurveyStatus(status) {
|
||||
const statusMap = {
|
||||
inProgress: "Live",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
const getButtonLabel = (count) => {
|
||||
if (count === 1) {
|
||||
return "View Response";
|
||||
}
|
||||
return `View ${count > 2 ? count - 1 : "1"} more Response${count > 2 ? "s" : ""}`;
|
||||
};
|
||||
|
||||
const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
|
||||
if (!surveys.length) return ` `;
|
||||
|
||||
return surveys
|
||||
.map((survey) => {
|
||||
const displayStatus = convertSurveyStatus(survey.status);
|
||||
const isLive = displayStatus === "Live";
|
||||
const noResponseLastWeek = isLive && survey.responses.length === 0;
|
||||
|
||||
return `
|
||||
<div style="display: block; margin-top: 3em;">
|
||||
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA" style="color: #1e293b; text-decoration: none;">
|
||||
<h2 style="display: inline; text-decoration: underline;">${survey.name}</h2>
|
||||
</a>
|
||||
<span style="display: inline; margin-left: 10px; background-color: ${isLive ? "#34D399" : "#cbd5e1"}; color: ${isLive ? "#F3F4F6" : "#1e293b"}; border-radius: 99px; padding: 2px 8px; font-size: 0.9em;">
|
||||
${displayStatus}
|
||||
</span>
|
||||
${noResponseLastWeek ? "<p>No new response received this week 🕵️</p>" : createSurveyFields(survey.responses)}
|
||||
${survey.responseCount > 0 ? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA">${noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responseCount)}</a>` : ""}
|
||||
</div>
|
||||
<br/>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const createSurveyFields = (surveyResponses: SurveyResponse[]) => {
|
||||
if (surveyResponses.length === 0)
|
||||
return `<div style="margin-top:1em;">
|
||||
<p style="font-weight: bold; margin:0px;">No Responses yet!</p>
|
||||
</div>`;
|
||||
let surveyFields = "";
|
||||
const responseCount = surveyResponses.length;
|
||||
|
||||
surveyResponses.forEach((response, index) => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [headline, answer] of Object.entries(response)) {
|
||||
surveyFields += `
|
||||
<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${headline}</p>
|
||||
<p style="font-weight: bold; margin:0px;">${answer}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add <hr/> only when there are 2 or more responses to display, and it's not the last response
|
||||
if (responseCount >= 2 && index < responseCount - 1) {
|
||||
surveyFields += "<hr/>";
|
||||
}
|
||||
});
|
||||
|
||||
return surveyFields;
|
||||
};
|
||||
|
||||
const notificationFooter = (environmentId: string) => {
|
||||
return `
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
|
||||
<p><i>To halt Weekly Updates, <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications">please turn them off</a> in your settings 🙏</i></p>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const createReminderNotificationBody = (notificationData: NotificationResponse) => {
|
||||
return `
|
||||
<p>We’d love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
|
||||
|
||||
<p style="font-weight: bold; padding-top:1em;">Don’t let a week pass without learning about your users:</p>
|
||||
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
|
||||
|
||||
<br/>
|
||||
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
|
||||
${notificationFooter(notificationData.environmentId)}
|
||||
`;
|
||||
};
|
||||
|
||||
export const sendWeeklySummaryNotificationEmail = async (
|
||||
email: string,
|
||||
notificationData: NotificationResponse
|
||||
) => {
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const startDate = `${notificationData.lastWeekDate.getDate()} ${
|
||||
monthNames[notificationData.lastWeekDate.getMonth()]
|
||||
}`;
|
||||
const endDate = `${notificationData.currentDate.getDate()} ${
|
||||
monthNames[notificationData.currentDate.getMonth()]
|
||||
}`;
|
||||
const startYear = notificationData.lastWeekDate.getFullYear();
|
||||
const endYear = notificationData.currentDate.getFullYear();
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: withEmailTemplate(`
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${notificationInsight(notificationData.insights)}
|
||||
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
|
||||
${notificationFooter(notificationData.environmentId)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendNoLiveSurveyNotificationEmail = async (
|
||||
email: string,
|
||||
notificationData: NotificationResponse
|
||||
) => {
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const startDate = `${notificationData.lastWeekDate.getDate()} ${
|
||||
monthNames[notificationData.lastWeekDate.getMonth()]
|
||||
}`;
|
||||
const endDate = `${notificationData.currentDate.getDate()} ${
|
||||
monthNames[notificationData.currentDate.getMonth()]
|
||||
}`;
|
||||
const startYear = notificationData.lastWeekDate.getFullYear();
|
||||
const endYear = notificationData.currentDate.getFullYear();
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: withEmailTemplate(`
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${createReminderNotificationBody(notificationData)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
@@ -2,12 +2,18 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "@formbricks/email";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { convertResponseValue } from "@formbricks/lib/responses";
|
||||
import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
|
||||
|
||||
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "./email";
|
||||
import { EnvironmentData, NotificationResponse, ProductData, Survey, SurveyResponse } from "./types";
|
||||
import {
|
||||
TWeeklySummaryEnvironmentData,
|
||||
TWeeklySummaryNotificationDataSurvey,
|
||||
TWeeklySummaryNotificationResponse,
|
||||
TWeeklySummaryProductData,
|
||||
TWeeklySummarySurveyResponseData,
|
||||
} from "@formbricks/types/weeklySummary";
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
@@ -73,7 +79,7 @@ const getTeamIds = async (): Promise<string[]> => {
|
||||
return teams.map((team) => team.id);
|
||||
};
|
||||
|
||||
const getProductsByTeamId = async (teamId: string): Promise<ProductData[]> => {
|
||||
const getProductsByTeamId = async (teamId: string): Promise<TWeeklySummaryProductData[]> => {
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
@@ -164,7 +170,10 @@ const getProductsByTeamId = async (teamId: string): Promise<ProductData[]> => {
|
||||
});
|
||||
};
|
||||
|
||||
const getNotificationResponse = (environment: EnvironmentData, productName: string): NotificationResponse => {
|
||||
const getNotificationResponse = (
|
||||
environment: TWeeklySummaryEnvironmentData,
|
||||
productName: string
|
||||
): TWeeklySummaryNotificationResponse => {
|
||||
const insights = {
|
||||
totalCompletedResponses: 0,
|
||||
totalDisplays: 0,
|
||||
@@ -173,11 +182,11 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri
|
||||
numLiveSurvey: 0,
|
||||
};
|
||||
|
||||
const surveys: Survey[] = [];
|
||||
const surveys: TWeeklySummaryNotificationDataSurvey[] = [];
|
||||
// iterate through the surveys and calculate the overall insights
|
||||
for (const survey of environment.surveys) {
|
||||
const parsedSurvey = checkForRecallInHeadline(survey, "default");
|
||||
const surveyData: Survey = {
|
||||
const surveyData: TWeeklySummaryNotificationDataSurvey = {
|
||||
id: parsedSurvey.id,
|
||||
name: parsedSurvey.name,
|
||||
status: parsedSurvey.status,
|
||||
@@ -187,19 +196,21 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri
|
||||
// iterate through the responses and calculate the survey insights
|
||||
for (const response of parsedSurvey.responses) {
|
||||
// only take the first 3 responses
|
||||
if (surveyData.responses.length >= 1) {
|
||||
if (surveyData.responses.length >= 3) {
|
||||
break;
|
||||
}
|
||||
const surveyResponse: SurveyResponse = {};
|
||||
const surveyResponses: TWeeklySummarySurveyResponseData[] = [];
|
||||
for (const question of parsedSurvey.questions) {
|
||||
const headline = question.headline;
|
||||
const answer = response.data[question.id]?.toString() || null;
|
||||
if (answer === null || answer === "" || answer?.length === 0) {
|
||||
continue;
|
||||
}
|
||||
surveyResponse[getLocalizedValue(headline, "default")] = answer;
|
||||
const responseValue = convertResponseValue(response.data[question.id], question);
|
||||
const surveyResponse: TWeeklySummarySurveyResponseData = {
|
||||
headline: getLocalizedValue(headline, "default"),
|
||||
responseValue,
|
||||
questionType: question.type,
|
||||
};
|
||||
surveyResponses.push(surveyResponse);
|
||||
}
|
||||
surveyData.responses.push(surveyResponse);
|
||||
surveyData.responses = surveyResponses;
|
||||
}
|
||||
surveys.push(surveyData);
|
||||
// calculate the overall insights
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion, TSurveyStatus } from "@formbricks/types/surveys";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
export interface Insights {
|
||||
totalCompletedResponses: number;
|
||||
totalDisplays: number;
|
||||
totalResponses: number;
|
||||
completionRate: number;
|
||||
numLiveSurvey: number;
|
||||
}
|
||||
|
||||
export interface SurveyResponse {
|
||||
[headline: string]: string | number | boolean | Date | string[];
|
||||
}
|
||||
|
||||
export interface Survey {
|
||||
id: string;
|
||||
name: string;
|
||||
responses: SurveyResponse[];
|
||||
responseCount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface NotificationResponse {
|
||||
environmentId: string;
|
||||
currentDate: Date;
|
||||
lastWeekDate: Date;
|
||||
productName: string;
|
||||
surveys: Survey[];
|
||||
insights: Insights;
|
||||
}
|
||||
|
||||
// Prisma Types
|
||||
|
||||
type ResponseData = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
finished: boolean;
|
||||
data: TResponseData;
|
||||
};
|
||||
|
||||
type DisplayData = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type SurveyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
questions: TSurveyQuestion[];
|
||||
status: TSurveyStatus;
|
||||
responses: ResponseData[];
|
||||
displays: DisplayData[];
|
||||
};
|
||||
|
||||
export type EnvironmentData = {
|
||||
id: string;
|
||||
surveys: SurveyData[];
|
||||
};
|
||||
|
||||
type UserData = {
|
||||
email: string;
|
||||
notificationSettings: TUserNotificationSettings;
|
||||
};
|
||||
|
||||
type MembershipData = {
|
||||
user: UserData;
|
||||
};
|
||||
|
||||
type TeamData = {
|
||||
memberships: MembershipData[];
|
||||
};
|
||||
|
||||
export type ProductData = {
|
||||
id: string;
|
||||
name: string;
|
||||
environments: EnvironmentData[];
|
||||
team: TeamData;
|
||||
};
|
||||
@@ -3,8 +3,8 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/email";
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendForgotPasswordEmail } from "@formbricks/lib/emails/emails";
|
||||
import { sendForgotPasswordEmail } from "@formbricks/email";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { email } = await request.json();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendPasswordResetNotifyEmail } from "@formbricks/lib/emails/emails";
|
||||
import { sendPasswordResetNotifyEmail } from "@formbricks/email";
|
||||
import { verifyToken } from "@formbricks/lib/jwt";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@formbricks/email";
|
||||
import {
|
||||
DEFAULT_TEAM_ID,
|
||||
DEFAULT_TEAM_ROLE,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
INVITE_DISABLED,
|
||||
SIGNUP_ENABLED,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@formbricks/lib/emails/emails";
|
||||
import { deleteInvite } from "@formbricks/lib/invite/service";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendVerificationEmail } from "@formbricks/lib/emails/emails";
|
||||
import { sendVerificationEmail } from "@formbricks/email";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { email } = await request.json();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
|
||||
|
||||
import { LinkSurveyEmailData, sendLinkSurveyToVerifiedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { LinkSurveyEmailData, sendLinkSurveyToVerifiedEmail } from "@formbricks/email";
|
||||
import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@formbricks/tailwind-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@formbricks/email": "workspace:*",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@json2csv/node": "^7.0.6",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
|
||||
@@ -56,8 +57,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-email": "^2.1.1",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.1.0",
|
||||
"redis": "^4.6.13",
|
||||
|
||||
24
packages/email/components/auth/ForgotPasswordEmail.tsx
Normal file
24
packages/email/components/auth/ForgotPasswordEmail.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
interface ForgotPasswordEmailProps {
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const ForgotPasswordEmail = ({ verifyLink }: ForgotPasswordEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Change password</Heading>
|
||||
<Text>
|
||||
You have requested a link to change your password. You can do this by clicking the link below:
|
||||
</Text>
|
||||
<EmailButton label={"Change password"} href={verifyLink} />
|
||||
<Text className="font-bold">The link is valid for 24 hours.</Text>
|
||||
<Text className="mb-0">If you didn't request this, please ignore this email.</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
14
packages/email/components/auth/PasswordResetNotifyEmail.tsx
Normal file
14
packages/email/components/auth/PasswordResetNotifyEmail.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
export const PasswordResetNotifyEmail = () => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Password changed</Heading>
|
||||
<Text>Your password has been changed successfully.</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
32
packages/email/components/auth/VerificationEmail.tsx
Normal file
32
packages/email/components/auth/VerificationEmail.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Container, Heading, Link, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
interface VerificationEmailProps {
|
||||
verifyLink: string;
|
||||
verificationRequestLink: string;
|
||||
}
|
||||
|
||||
export const VerificationEmail = ({ verifyLink, verificationRequestLink }: VerificationEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Almost there!</Heading>
|
||||
<Text>To start using Formbricks please verify your email below:</Text>
|
||||
<EmailButton href={verifyLink} label={"Verify email"} />
|
||||
<Text>You can also click on this link:</Text>
|
||||
<Link href={verifyLink} className="break-all text-black">
|
||||
{verifyLink}
|
||||
</Link>
|
||||
<Text className="font-bold">The link is valid for 24h.</Text>
|
||||
<Text>
|
||||
If it has expired please request a new token here:{" "}
|
||||
<Link href={verificationRequestLink} className="text-black underline">
|
||||
Request new verification
|
||||
</Link>
|
||||
</Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
15
packages/email/components/general/EmailButton.tsx
Normal file
15
packages/email/components/general/EmailButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Button } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
interface EmailButtonProps {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const EmailButton = ({ label, href }: EmailButtonProps) => {
|
||||
return (
|
||||
<Button className="rounded-md bg-black p-4 text-white" href={href}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
10
packages/email/components/general/EmailFooter.tsx
Normal file
10
packages/email/components/general/EmailFooter.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
export const EmailFooter = () => {
|
||||
return (
|
||||
<Text>
|
||||
Have a great day!<br></br> The Formbricks Team!
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
83
packages/email/components/general/EmailTemplate.tsx
Normal file
83
packages/email/components/general/EmailTemplate.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Body, Column, Container, Html, Img, Link, Row, Section } from "@react-email/components";
|
||||
import { Tailwind } from "@react-email/components";
|
||||
import { Fragment } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
content: JSX.Element;
|
||||
}
|
||||
|
||||
export const EmailTemplate = ({ content }: EmailTemplateProps) => (
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<Fragment>
|
||||
<Body
|
||||
className="m-0 h-full w-full justify-center bg-slate-100 bg-slate-50 p-6 text-center text-base font-medium text-slate-800"
|
||||
style={{
|
||||
fontFamily: "'Poppins', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
|
||||
}}>
|
||||
<Section className="flex items-center justify-center">
|
||||
<Link href="https://formbricks.com?utm_source=email_header&utm_medium=email" target="_blank">
|
||||
<Img
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"
|
||||
alt="Formbricks Logo"
|
||||
className="w-80"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
<Container className="mx-auto my-8 max-w-xl bg-white p-4 text-left">{content}</Container>
|
||||
|
||||
<Section>
|
||||
<Row>
|
||||
<Column align="right" key="twitter">
|
||||
<Link target="_blank" href="https://twitter.com/formbricks">
|
||||
<Img
|
||||
title="Twitter"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Twitter-transp.png"
|
||||
alt="Tw"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="center" className="w-20" key="github">
|
||||
<Link target="_blank" href="https://formbricks.com/github">
|
||||
<Img
|
||||
title="GitHub"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Github-transp.png"
|
||||
alt="GitHub"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column align="left" key="discord">
|
||||
<Link target="_blank" href="https://formbricks.com/discord">
|
||||
<Img
|
||||
title="Discord"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Discord-transp.png"
|
||||
alt="Discord"
|
||||
width="32"
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section className="mt-4 text-center">
|
||||
Formbricks {new Date().getFullYear()}. All rights reserved.
|
||||
<br />
|
||||
<Link
|
||||
href="https://formbricks.com/imprint?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank">
|
||||
Imprint
|
||||
</Link>{" "}
|
||||
|{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/privacy-policy?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Section>
|
||||
</Body>
|
||||
</Fragment>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
19
packages/email/components/invite/InviteAcceptedEmail.tsx
Normal file
19
packages/email/components/invite/InviteAcceptedEmail.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Container, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
interface InviteAcceptedEmailProps {
|
||||
inviterName: string;
|
||||
inviteeName: string;
|
||||
}
|
||||
|
||||
export const InviteAcceptedEmail = ({ inviterName, inviteeName }: InviteAcceptedEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Text>Hey {inviterName},</Text>
|
||||
<Text>Just letting you know that {inviteeName} accepted your invitation. Have fun collaborating! </Text>
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
25
packages/email/components/invite/InviteEmail.tsx
Normal file
25
packages/email/components/invite/InviteEmail.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Container, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
interface InviteEmailProps {
|
||||
inviteeName: string;
|
||||
inviterName: string;
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const InviteEmail = ({ inviteeName, inviterName, verifyLink }: InviteEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Text>Hey {inviteeName},</Text>
|
||||
<Text>
|
||||
Your colleague {inviterName} invited you to join them at Formbricks. To accept the invitation, please
|
||||
click the link below:
|
||||
</Text>
|
||||
<EmailButton label="Join team" href={verifyLink} />
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
32
packages/email/components/invite/OnboardingInviteEmail.tsx
Normal file
32
packages/email/components/invite/OnboardingInviteEmail.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
interface OnboardingInviteEmailProps {
|
||||
inviteMessage: string;
|
||||
inviterName: string;
|
||||
verifyLink: string;
|
||||
}
|
||||
|
||||
export const OnboardingInviteEmail = ({
|
||||
inviteMessage,
|
||||
inviterName,
|
||||
verifyLink,
|
||||
}: OnboardingInviteEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Hey 👋</Heading>
|
||||
<Text>{inviteMessage}</Text>
|
||||
<Text className="text-xl font-medium">Get Started in Minutes</Text>
|
||||
<ol>
|
||||
<li>Create an account to join {inviterName}'s team.</li>
|
||||
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>
|
||||
<li>Done ✅</li>
|
||||
</ol>
|
||||
<EmailButton label={`Join ${inviterName}'s team`} href={verifyLink} />
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
21
packages/email/components/survey/EmbedSurveyPreviewEmail.tsx
Normal file
21
packages/email/components/survey/EmbedSurveyPreviewEmail.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
interface EmbedSurveyPreviewEmailProps {
|
||||
html: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EmbedSurveyPreviewEmail = ({ html, environmentId }: EmbedSurveyPreviewEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Preview Email Embed</Heading>
|
||||
<Text>This is how the code snippet looks embedded into an email:</Text>
|
||||
<Text className="text-sm">
|
||||
<b>Didn't request this?</b> Help us fight spam and forward this mail to hola@formbricks.com
|
||||
</Text>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }}></div>
|
||||
<Text className="text-center text-sm text-slate-700">Environment ID: {environmentId}</Text>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
29
packages/email/components/survey/LinkSurveyEmail.tsx
Normal file
29
packages/email/components/survey/LinkSurveyEmail.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { EmailFooter } from "../general/EmailFooter";
|
||||
|
||||
interface LinkSurveyEmailProps {
|
||||
surveyData?:
|
||||
| {
|
||||
name?: string;
|
||||
subheading?: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
getSurveyLink: () => string;
|
||||
}
|
||||
|
||||
export const LinkSurveyEmail = ({ surveyData, getSurveyLink }: LinkSurveyEmailProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Hey 👋</Heading>
|
||||
<Text>Thanks for validating your email. Here is your Survey.</Text>
|
||||
<Text className="font-bold">{surveyData?.name}</Text>
|
||||
<Text>{surveyData?.subheading}</Text>
|
||||
<EmailButton label="Take survey" href={getSurveyLink()} />
|
||||
<EmailFooter />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
426
packages/email/components/survey/PreviewEmailTemplste.tsx
Normal file
426
packages/email/components/survey/PreviewEmailTemplste.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
Column,
|
||||
Container,
|
||||
Button as EmailButton,
|
||||
Img,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { isLight } from "@formbricks/lib/utils";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { RatingSmiley } from "@formbricks/ui/RatingSmiley";
|
||||
|
||||
interface PreviewEmailTemplateProps {
|
||||
survey: TSurvey;
|
||||
surveyUrl: string;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
export const getPreviewEmailTemplateHtml = (survey: TSurvey, surveyUrl: string, brandColor: string) => {
|
||||
return render(<PreviewEmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
|
||||
pretty: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const PreviewEmailTemplate = ({ survey, surveyUrl, brandColor }: PreviewEmailTemplateProps) => {
|
||||
const url = `${surveyUrl}?preview=true`;
|
||||
const urlWithPrefilling = `${surveyUrl}?preview=true&`;
|
||||
const defaultLanguageCode = "default";
|
||||
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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-slate-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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 font-medium text-slate-800">
|
||||
<Text className="m-0 inline-block">
|
||||
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex w-max flex-col">
|
||||
<Section className="block overflow-hidden rounded-md border border-slate-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-slate-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">
|
||||
{getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block w-max p-0 text-right">
|
||||
{getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
|
||||
}}></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">
|
||||
{getLocalizedValue(firstQuestion.dismissButtonLabel, defaultLanguageCode) || "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"
|
||||
)}>
|
||||
{getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section className=" w-full">
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 w-full items-center justify-center">
|
||||
<Section
|
||||
className={cn("w-full overflow-hidden rounded-md", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
})}>
|
||||
<Column className="mb-4 flex w-full justify-around">
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
" m-0 h-10 w-full p-0 text-center align-middle leading-10 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
{firstQuestion.scale === "smiley" && (
|
||||
<RatingSmiley active={false} idx={i} range={firstQuestion.range} />
|
||||
)}
|
||||
{firstQuestion.scale === "number" && (
|
||||
<Text className="m-0 flex h-10 items-center">{i + 1}</Text>
|
||||
)}
|
||||
{firstQuestion.scale === "star" && <Text className="text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Column>
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block p-0 text-right">
|
||||
{getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</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-slate-200 bg-slate-50 p-4 text-slate-800"
|
||||
key={choice.id}>
|
||||
{getLocalizedValue(choice.label, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="mt-2 block rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${getLocalizedValue(choice.label, defaultLanguageCode)}`}>
|
||||
{getLocalizedValue(choice.label, defaultLanguageCode)}
|
||||
</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">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</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>
|
||||
);
|
||||
case TSurveyQuestionType.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Container>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
You have been invited to schedule a meet via cal.com.
|
||||
</Text>
|
||||
<EmailButton
|
||||
className={cn(
|
||||
"bg-brand-color mx-auto block w-max cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium ",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
Schedule your meeting
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Section className="mt-4 flex h-12 w-full items-center justify-center rounded-lg border border-solid border-slate-200 bg-white">
|
||||
<CalendarDaysIcon className="mb-1 inline h-4 w-4" />
|
||||
<Text className="inline text-sm font-medium">Select a date</Text>
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Matrix:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, "default")}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, "default")}
|
||||
</Text>
|
||||
<Container className="mx-0">
|
||||
<Section className="w-full table-auto">
|
||||
<Row>
|
||||
<Column className="w-40 break-words px-4 py-2"></Column>
|
||||
{firstQuestion.columns.map((column, columnIndex) => {
|
||||
return (
|
||||
<Column
|
||||
key={columnIndex}
|
||||
className="max-w-40 break-words px-4 py-2 text-center text-gray-800">
|
||||
{getLocalizedValue(column, "default")}
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
{firstQuestion.rows.map((row, rowIndex) => {
|
||||
return (
|
||||
<Row key={rowIndex} className={`${rowIndex % 2 === 0 ? "bg-gray-100" : ""} rounded-custom`}>
|
||||
<Column className="w-40 break-words px-4 py-2">
|
||||
{getLocalizedValue(row, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map(() => {
|
||||
return (
|
||||
<Column className="px-4 py-2 text-gray-800">
|
||||
<Section className="h-4 w-4 rounded-full bg-white p-2 outline"></Section>
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Address:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
|
||||
</Text>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Section
|
||||
key={index}
|
||||
className="mt-4 block h-10 w-full rounded-lg border border-solid border-slate-200 bg-slate-50"
|
||||
/>
|
||||
))}
|
||||
<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 overflow-auto 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>
|
||||
);
|
||||
};
|
||||
139
packages/email/components/survey/ResponseFinishedEmail.tsx
Normal file
139
packages/email/components/survey/ResponseFinishedEmail.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Column, Container, Hr, Img, Link, Row, Section, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { getQuestionResponseMapping } from "@formbricks/lib/responses";
|
||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TTeam } from "@formbricks/types/teams";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
|
||||
export const renderEmailResponseValue = (response: string | string[], questionType: string) => {
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionType.FileUpload:
|
||||
return (
|
||||
<Container>
|
||||
{typeof response !== "string" &&
|
||||
response.map((response) => (
|
||||
<Link
|
||||
href={response}
|
||||
key={response}
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-gray-200 p-2 text-black shadow-sm">
|
||||
<FileIcon />
|
||||
<Text className="mb-0 truncate">{getOriginalFileNameFromUrl(response)}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
return (
|
||||
<Container className="flex">
|
||||
<Row>
|
||||
{typeof response !== "string" &&
|
||||
response.map((response) => (
|
||||
<Column>
|
||||
<Img src={response} id={response} alt={response.split("/").pop()} className="m-2 h-28" />
|
||||
</Column>
|
||||
))}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
interface ResponseFinishedEmailProps {
|
||||
survey: TSurvey;
|
||||
responseCount: number;
|
||||
response: TResponse;
|
||||
WEBAPP_URL: string;
|
||||
environmentId: string;
|
||||
team: TTeam | null;
|
||||
}
|
||||
|
||||
export const ResponseFinishedEmail = ({
|
||||
survey,
|
||||
responseCount,
|
||||
response,
|
||||
WEBAPP_URL,
|
||||
environmentId,
|
||||
team,
|
||||
}: ResponseFinishedEmailProps) => {
|
||||
const questions = getQuestionResponseMapping(survey, response);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="mb-4 text-3xl font-bold">Hey 👋</Text>
|
||||
<Text className="mb-4">
|
||||
Congrats, you received a new response to your survey! Someone just completed your survey{" "}
|
||||
<strong>{survey.name}</strong>:
|
||||
</Text>
|
||||
<Hr />
|
||||
{questions.map((question) => {
|
||||
if (!question.response) return;
|
||||
return (
|
||||
<Row key={question.question}>
|
||||
<Column className="w-full">
|
||||
<Text className="mb-2 font-medium">{question.question}</Text>
|
||||
{renderEmailResponseValue(question.response, question.type)}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
<EmailButton
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA`}
|
||||
label={
|
||||
responseCount > 1
|
||||
? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}`
|
||||
: `View survey summary`
|
||||
}
|
||||
/>
|
||||
<Hr />
|
||||
<Section className="mt-4 text-center text-sm">
|
||||
<Text className="font-bold">Don't want to get these notifications?</Text>
|
||||
<Text className="mb-0">
|
||||
Turn off notifications for{" "}
|
||||
<Link
|
||||
className="text-black underline"
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}`}>
|
||||
this form
|
||||
</Link>
|
||||
</Text>
|
||||
<Text className="mt-0">
|
||||
Turn off notifications for{" "}
|
||||
<Link
|
||||
className="text-black underline"
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}`}>
|
||||
all newly created forms{" "}
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</Column>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const FileIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-file">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Container, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { NotificationFooter } from "./NotificationFooter";
|
||||
|
||||
interface CreateReminderNotificationBodyProps {
|
||||
notificationData: TWeeklySummaryNotificationResponse;
|
||||
}
|
||||
|
||||
export const CreateReminderNotificationBody = ({ notificationData }: CreateReminderNotificationBodyProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Text>
|
||||
We’d love to send you a Weekly Summary, but currently there are no surveys running for
|
||||
{notificationData.productName}.
|
||||
</Text>
|
||||
<Text className="pt-4 font-bold">Don’t let a week pass without learning about your users:</Text>
|
||||
<EmailButton
|
||||
label="Setup a new survey"
|
||||
href={`${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA`}
|
||||
/>
|
||||
<Text className="pt-4">
|
||||
Need help finding the right survey for your product? Pick a 15-minute slot{" "}
|
||||
<a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)
|
||||
</Text>
|
||||
<NotificationFooter environmentId={notificationData.environmentId} />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Container, Hr, Link, Tailwind, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import {
|
||||
TWeeklySummaryNotificationDataSurvey,
|
||||
TWeeklySummarySurveyResponseData,
|
||||
} from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { EmailButton } from "../general/EmailButton";
|
||||
import { renderEmailResponseValue } from "../survey/ResponseFinishedEmail";
|
||||
|
||||
const getButtonLabel = (count: number): string => {
|
||||
if (count === 1) {
|
||||
return "View Response";
|
||||
}
|
||||
return `View ${count > 2 ? count - 1 : "1"} more Response${count > 2 ? "s" : ""}`;
|
||||
};
|
||||
|
||||
const convertSurveyStatus = (status: string): string => {
|
||||
const statusMap = {
|
||||
inProgress: "In Progress",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
draft: "Draft",
|
||||
scheduled: "Scheduled",
|
||||
};
|
||||
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
interface LiveSurveyNotificationProps {
|
||||
environmentId: string;
|
||||
surveys: TWeeklySummaryNotificationDataSurvey[];
|
||||
}
|
||||
|
||||
export const LiveSurveyNotification = ({ environmentId, surveys }: LiveSurveyNotificationProps) => {
|
||||
const createSurveyFields = (surveyResponses: TWeeklySummarySurveyResponseData[]) => {
|
||||
if (surveyResponses.length === 0) {
|
||||
return (
|
||||
<Container className="mt-4">
|
||||
<Text className="m-0 font-bold">No Responses yet!</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
let surveyFields: JSX.Element[] = [];
|
||||
const responseCount = surveyResponses.length;
|
||||
|
||||
surveyResponses.forEach((surveyResponse, index) => {
|
||||
if (!surveyResponse.responseValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
surveyFields.push(
|
||||
<Container className="mt-4" key={`${index}-${surveyResponse.headline}`}>
|
||||
<Text className="m-0">{surveyResponse.headline}</Text>
|
||||
{renderEmailResponseValue(surveyResponse.responseValue, surveyResponse.questionType)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Add <hr/> only when there are 2 or more responses to display, and it's not the last response
|
||||
if (responseCount >= 2 && index < responseCount - 1) {
|
||||
surveyFields.push(<Hr key={`hr-${index}`} />);
|
||||
}
|
||||
});
|
||||
|
||||
return surveyFields;
|
||||
};
|
||||
|
||||
if (!surveys.length) return "";
|
||||
|
||||
return surveys.map((survey, index) => {
|
||||
const displayStatus = convertSurveyStatus(survey.status);
|
||||
const isInProgress = displayStatus === "In Progress";
|
||||
const noResponseLastWeek = isInProgress && survey.responses.length === 0;
|
||||
return (
|
||||
<Tailwind key={index}>
|
||||
<Container className="mt-12">
|
||||
<Text className="mb-0 inline">
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}
|
||||
className="text-xl text-black underline">
|
||||
{survey.name}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className={`ml-2 inline ${isInProgress ? "bg-green-400 text-gray-100" : "bg-gray-300 text-blue-800"} rounded-full px-2 py-1 text-sm`}>
|
||||
{displayStatus}
|
||||
</Text>
|
||||
{noResponseLastWeek ? (
|
||||
<Text>No new response received this week 🕵️</Text>
|
||||
) : (
|
||||
createSurveyFields(survey.responses)
|
||||
)}
|
||||
{survey.responseCount > 0 && (
|
||||
<Container className="mt-4 block">
|
||||
<EmailButton
|
||||
label={noResponseLastWeek ? "View previous responses" : getButtonLabel(survey.responseCount)}
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Container>
|
||||
</Tailwind>
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { CreateReminderNotificationBody } from "./CreateReminderNotificationBody";
|
||||
import { NotificationHeader } from "./NotificationHeader";
|
||||
|
||||
interface NoLiveSurveyNotificationEmailProps {
|
||||
notificationData: TWeeklySummaryNotificationResponse;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
}
|
||||
|
||||
export const NoLiveSurveyNotificationEmail = ({
|
||||
notificationData,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}: NoLiveSurveyNotificationEmailProps) => {
|
||||
return (
|
||||
<div>
|
||||
<NotificationHeader
|
||||
productName={notificationData.productName}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
startYear={startYear}
|
||||
endYear={endYear}
|
||||
/>
|
||||
<CreateReminderNotificationBody notificationData={notificationData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Container, Link, Text } from "@react-email/components";
|
||||
import { Tailwind } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
interface NotificatonFooterProps {
|
||||
environmentId: string;
|
||||
}
|
||||
export const NotificationFooter = ({ environmentId }: NotificatonFooterProps) => {
|
||||
return (
|
||||
<Tailwind>
|
||||
<Container className="w-full">
|
||||
<Text className="mb-0 pt-4 font-medium">All the best,</Text>
|
||||
<Text className="mt-0">The Formbricks Team 🤍</Text>
|
||||
<Container
|
||||
className="mt-0 w-full rounded-md bg-slate-100 px-4 text-center text-xs leading-5"
|
||||
style={{ fontStyle: "italic" }}>
|
||||
<Text>
|
||||
To halt Weekly Updates,{" "}
|
||||
<Link
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications`}
|
||||
className="text-black underline">
|
||||
please turn them off
|
||||
</Link>{" "}
|
||||
in your settings 🙏
|
||||
</Text>
|
||||
</Container>
|
||||
</Container>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Container, Heading, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
interface NotificationHeaderProps {
|
||||
productName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
}
|
||||
|
||||
export const NotificationHeader = ({
|
||||
productName,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}: NotificationHeaderProps) => {
|
||||
const getNotificationHeaderimePeriod = (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
startYear: number,
|
||||
endYear: number
|
||||
) => {
|
||||
if (startYear == endYear) {
|
||||
return (
|
||||
<Text className="m-0 text-right">
|
||||
{startDate} - {endDate} {endYear}
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text className="m-0 text-right">
|
||||
{startDate} {startYear} - {endDate} {endYear}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
<div className="block px-0 py-4">
|
||||
<div className="float-left mt-2">
|
||||
<Heading className="m-0">Hey 👋</Heading>
|
||||
</div>
|
||||
<div className="float-right">
|
||||
<Text className="m-0 text-right font-semibold">Weekly Report for {productName}</Text>
|
||||
{getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Column, Container, Row, Section, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { TWeeklySummaryInsights } from "@formbricks/types/weeklySummary";
|
||||
|
||||
interface NotificationInsightProps {
|
||||
insights: TWeeklySummaryInsights;
|
||||
}
|
||||
|
||||
export const NotificationInsight = ({ insights }: NotificationInsightProps) => {
|
||||
return (
|
||||
<Container>
|
||||
<Section className="my-4 rounded-md bg-slate-100">
|
||||
<Row>
|
||||
<Column className="text-center">
|
||||
<Text className="text-sm">Surveys</Text>
|
||||
<Text className="text-lg font-bold">{insights.numLiveSurvey}</Text>
|
||||
</Column>
|
||||
<Column className="text-center">
|
||||
<Text className="text-sm">Displays</Text>
|
||||
<Text className="text-lg font-bold">{insights.totalDisplays}</Text>
|
||||
</Column>
|
||||
<Column className="text-center">
|
||||
<Text className="text-sm">Responses</Text>
|
||||
<Text className="text-lg font-bold">{insights.totalResponses}</Text>
|
||||
</Column>
|
||||
<Column className="text-center">
|
||||
<Text className="text-sm">Completed</Text>
|
||||
<Text className="text-lg font-bold">{insights.totalCompletedResponses}</Text>
|
||||
</Column>
|
||||
{insights.totalDisplays !== 0 ? (
|
||||
<Column className="text-center">
|
||||
<Text className="text-sm">Completion %</Text>
|
||||
<Text className="text-lg font-bold">{Math.round(insights.completionRate)}%</Text>
|
||||
</Column>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { LiveSurveyNotification } from "./LiveSurveyNotification";
|
||||
import { NotificationFooter } from "./NotificationFooter";
|
||||
import { NotificationHeader } from "./NotificationHeader";
|
||||
import { NotificationInsight } from "./NotificationInsight";
|
||||
|
||||
interface WeeklySummaryNotificationEmailProps {
|
||||
notificationData: TWeeklySummaryNotificationResponse;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startYear: number;
|
||||
endYear: number;
|
||||
}
|
||||
|
||||
export const WeeklySummaryNotificationEmail = ({
|
||||
notificationData,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}: WeeklySummaryNotificationEmailProps) => {
|
||||
return (
|
||||
<div>
|
||||
<NotificationHeader
|
||||
productName={notificationData.productName}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
startYear={startYear}
|
||||
endYear={endYear}
|
||||
/>
|
||||
<NotificationInsight insights={notificationData.insights} />
|
||||
<LiveSurveyNotification
|
||||
surveys={notificationData.surveys}
|
||||
environmentId={notificationData.environmentId}
|
||||
/>
|
||||
<NotificationFooter environmentId={notificationData.environmentId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
280
packages/email/index.tsx
Normal file
280
packages/email/index.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
SMTP_HOST,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_PORT,
|
||||
SMTP_SECURE_ENABLED,
|
||||
SMTP_USER,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { createInviteToken, createToken, createTokenForLinkSurvey } from "@formbricks/lib/jwt";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TWeeklySummaryNotificationResponse } from "@formbricks/types/weeklySummary";
|
||||
|
||||
import { ForgotPasswordEmail } from "./components/auth/ForgotPasswordEmail";
|
||||
import { PasswordResetNotifyEmail } from "./components/auth/PasswordResetNotifyEmail";
|
||||
import { VerificationEmail } from "./components/auth/VerificationEmail";
|
||||
import { EmailTemplate } from "./components/general/EmailTemplate";
|
||||
import { InviteAcceptedEmail } from "./components/invite/InviteAcceptedEmail";
|
||||
import { InviteEmail } from "./components/invite/InviteEmail";
|
||||
import { OnboardingInviteEmail } from "./components/invite/OnboardingInviteEmail";
|
||||
import { EmbedSurveyPreviewEmail } from "./components/survey/EmbedSurveyPreviewEmail";
|
||||
import { LinkSurveyEmail } from "./components/survey/LinkSurveyEmail";
|
||||
import { ResponseFinishedEmail } from "./components/survey/ResponseFinishedEmail";
|
||||
import { NoLiveSurveyNotificationEmail } from "./components/weekly-summary/NoLiveSurveyNotificationEmail";
|
||||
import { WeeklySummaryNotificationEmail } from "./components/weekly-summary/WeeklySummaryNotificationEmail";
|
||||
|
||||
export const IS_SMTP_CONFIGURED: boolean =
|
||||
SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD ? true : false;
|
||||
|
||||
interface sendEmailData {
|
||||
to: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
interface TEmailUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface LinkSurveyEmailData {
|
||||
surveyId: string;
|
||||
email: string;
|
||||
suId: string;
|
||||
surveyData?:
|
||||
| {
|
||||
name?: string;
|
||||
subheading?: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const getEmailSubject = (productName: string): string => {
|
||||
return `${productName} User Insights - Last Week by Formbricks`;
|
||||
};
|
||||
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
export const sendEmail = async (emailData: sendEmailData) => {
|
||||
try {
|
||||
if (IS_SMTP_CONFIGURED) {
|
||||
let transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
logger: DEBUG,
|
||||
debug: DEBUG,
|
||||
});
|
||||
const emailDefaults = {
|
||||
from: `Formbricks <${MAIL_FROM || "noreply@formbricks.com"}>`,
|
||||
};
|
||||
await transporter.sendMail({ ...emailDefaults, ...emailData });
|
||||
} else {
|
||||
console.error(`Could not Email :: SMTP not configured :: ${emailData.subject}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendVerificationEmail = async (user: TEmailUser) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?email=${encodeURIComponent(
|
||||
user.email
|
||||
)}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Please verify your email to use Formbricks",
|
||||
html: render(EmailTemplate({ content: VerificationEmail({ verificationRequestLink, verifyLink }) })),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendForgotPasswordEmail = async (user: TEmailUser) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Formbricks password",
|
||||
html: render(EmailTemplate({ content: ForgotPasswordEmail({ verifyLink }) })),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendPasswordResetNotifyEmail = async (user: TEmailUser) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Your Formbricks password has been changed",
|
||||
html: render(EmailTemplate({ content: PasswordResetNotifyEmail() })),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendInviteMemberEmail = async (
|
||||
inviteId: string,
|
||||
email: string,
|
||||
inviterName: string,
|
||||
inviteeName: string,
|
||||
isOnboardingInvite?: boolean,
|
||||
inviteMessage?: string
|
||||
) => {
|
||||
const token = createInviteToken(inviteId, email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (isOnboardingInvite && inviteMessage) {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `${inviterName} needs a hand setting up Formbricks. Can you help out?`,
|
||||
html: render(
|
||||
EmailTemplate({ content: OnboardingInviteEmail({ verifyLink, inviteMessage, inviterName }) })
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `You're invited to collaborate on Formbricks!`,
|
||||
html: render(EmailTemplate({ content: InviteEmail({ inviteeName, inviterName, verifyLink }) })),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const sendInviteAcceptedEmail = async (inviterName: string, inviteeName: string, email: string) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `You've got a new team member!`,
|
||||
html: render(EmailTemplate({ content: InviteAcceptedEmail({ inviteeName, inviterName }) })),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
environmentId: string,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
responseCount: number
|
||||
) => {
|
||||
const personEmail = response.person?.attributes["email"];
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail?.toString() || MAIL_FROM,
|
||||
html: render(
|
||||
EmailTemplate({
|
||||
content: ResponseFinishedEmail({ survey, responseCount, response, WEBAPP_URL, environmentId, team }),
|
||||
})
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendEmbedSurveyPreviewEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
environmentId: string
|
||||
) => {
|
||||
await sendEmail({
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: render(EmailTemplate({ content: EmbedSurveyPreviewEmail({ html, environmentId }) })),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendLinkSurveyToVerifiedEmail = async (data: LinkSurveyEmailData) => {
|
||||
const surveyId = data.surveyId;
|
||||
const email = data.email;
|
||||
const surveyData = data.surveyData;
|
||||
const singleUseId = data.suId ?? null;
|
||||
const token = createTokenForLinkSurvey(surveyId, email);
|
||||
const getSurveyLink = () => {
|
||||
if (singleUseId) {
|
||||
return `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
|
||||
}
|
||||
return `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
|
||||
};
|
||||
await sendEmail({
|
||||
to: data.email,
|
||||
subject: "Your Formbricks Survey",
|
||||
html: render(EmailTemplate({ content: LinkSurveyEmail({ surveyData, getSurveyLink }) })),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendWeeklySummaryNotificationEmail = async (
|
||||
email: string,
|
||||
notificationData: TWeeklySummaryNotificationResponse
|
||||
) => {
|
||||
const startDate = `${notificationData.lastWeekDate.getDate()} ${
|
||||
monthNames[notificationData.lastWeekDate.getMonth()]
|
||||
}`;
|
||||
const endDate = `${notificationData.currentDate.getDate()} ${
|
||||
monthNames[notificationData.currentDate.getMonth()]
|
||||
}`;
|
||||
const startYear = notificationData.lastWeekDate.getFullYear();
|
||||
const endYear = notificationData.currentDate.getFullYear();
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: render(
|
||||
EmailTemplate({
|
||||
content: WeeklySummaryNotificationEmail({
|
||||
notificationData,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}),
|
||||
})
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendNoLiveSurveyNotificationEmail = async (
|
||||
email: string,
|
||||
notificationData: TWeeklySummaryNotificationResponse
|
||||
) => {
|
||||
const startDate = `${notificationData.lastWeekDate.getDate()} ${
|
||||
monthNames[notificationData.lastWeekDate.getMonth()]
|
||||
}`;
|
||||
const endDate = `${notificationData.currentDate.getDate()} ${
|
||||
monthNames[notificationData.currentDate.getMonth()]
|
||||
}`;
|
||||
const startYear = notificationData.lastWeekDate.getFullYear();
|
||||
const endYear = notificationData.currentDate.getFullYear();
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: render(
|
||||
EmailTemplate({
|
||||
content: NoLiveSurveyNotificationEmail({
|
||||
notificationData,
|
||||
startDate,
|
||||
endDate,
|
||||
startYear,
|
||||
endYear,
|
||||
}),
|
||||
})
|
||||
),
|
||||
});
|
||||
};
|
||||
19
packages/email/package.json
Normal file
19
packages/email/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@formbricks/email",
|
||||
"version": "1.0.0",
|
||||
"description": "Email package",
|
||||
"main": "./index.tsx",
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@react-email/components": "^0.0.16",
|
||||
"@react-email/render": "^0.0.12",
|
||||
"lucide-react": "^0.365.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"react-email": "^2.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
export const withEmailTemplate = (content: string) =>
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1"
|
||||
/>
|
||||
<base target="_blank" />
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f1f5f9;
|
||||
font-family: "Poppins", "Helvetica Neue", "Segoe UI", Helvetica,
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 26px;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f4f4f4;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 1px dashed #94a3b8;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
table td {
|
||||
padding: 5px;
|
||||
}
|
||||
.socialicons {
|
||||
max-width: 200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 27px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
background-color: #f8fafc;
|
||||
padding: 30px;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.brandcolor {
|
||||
color: #00c4b8;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background-color: #f1f5f9;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
color: #475569;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tooltip a {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-top:12px;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
text-decoration: none !important;
|
||||
color: #fff !important;
|
||||
font-weight: 500;
|
||||
padding: 10px 30px;
|
||||
display: inline-block;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.button:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.footer a {
|
||||
color: #cbd5e1;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.gutter img {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #000000;
|
||||
}
|
||||
a:hover {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.wrap {
|
||||
max-width: auto;
|
||||
}
|
||||
.gutter {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style="
|
||||
background-color: #f1f5f9;
|
||||
font-family: 'Poppins', 'Helvetica Neue', 'Segoe UI', Helvetica,
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
"
|
||||
>
|
||||
<div class="gutter" style="padding: 30px">
|
||||
<a href="https://formbricks.com?utm_source=email_header&utm_medium=email" target="_blank">
|
||||
<img
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"
|
||||
alt="Formbricks Logo"
|
||||
/></a>
|
||||
</div>
|
||||
<div
|
||||
class="wrap"
|
||||
style="
|
||||
background-color: #f8fafc;
|
||||
padding: 30px;
|
||||
max-width: 525px;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
"
|
||||
>
|
||||
${content}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="footer"
|
||||
style="text-align: center; font-size: 12px; color: #cbd5e1"
|
||||
>
|
||||
<table class="socialicons">
|
||||
<tr>
|
||||
<td>
|
||||
<a target="_blank" href="https://twitter.com/formbricks"
|
||||
><img
|
||||
title="Twitter"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Twitter-transp.png"
|
||||
alt="Tw"
|
||||
width="32"
|
||||
/></a>
|
||||
</td>
|
||||
<td>
|
||||
<a target="_blank" href="https://formbricks.com/github"
|
||||
><img
|
||||
title="GitHub"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Github-transp.png"
|
||||
alt="GitHub"
|
||||
width="32"
|
||||
/></a>
|
||||
</td>
|
||||
<td>
|
||||
<a target="_blank" href="https://formbricks.com/discord"
|
||||
><img
|
||||
title="Discord"
|
||||
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Discord-transp.png"
|
||||
alt="Discord"
|
||||
width="32"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="padding-top: 8px; line-height: initial">
|
||||
Formbricks ${new Date().getFullYear()}. All rights reserved.<br />
|
||||
<a
|
||||
style="text-decoration: none"
|
||||
href="https://formbricks.com/imprint?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank"
|
||||
>Imprint</a
|
||||
>
|
||||
|
|
||||
<a
|
||||
style="text-decoration: none"
|
||||
href="https://formbricks.com/privacy-policy?utm_source=email_footer&utm_medium=email"
|
||||
target="_blank"
|
||||
>Privacy Policy</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -1,297 +0,0 @@
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
SMTP_HOST,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_PORT,
|
||||
SMTP_SECURE_ENABLED,
|
||||
SMTP_USER,
|
||||
WEBAPP_URL,
|
||||
} from "../constants";
|
||||
import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt";
|
||||
import { getProductByEnvironmentId } from "../product/service";
|
||||
import { getQuestionResponseMapping, processResponseData } from "../responses";
|
||||
import { getOriginalFileNameFromUrl } from "../storage/utils";
|
||||
import { getTeamByEnvironmentId } from "../team/service";
|
||||
import { withEmailTemplate } from "./email-template";
|
||||
|
||||
const nodemailer = require("nodemailer");
|
||||
|
||||
export const IS_SMTP_CONFIGURED: boolean =
|
||||
SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD ? true : false;
|
||||
|
||||
interface sendEmailData {
|
||||
to: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
interface TEmailUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface LinkSurveyEmailData {
|
||||
surveyId: string;
|
||||
email: string;
|
||||
suId: string;
|
||||
surveyData?: {
|
||||
name?: string;
|
||||
subheading?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const sendEmail = async (emailData: sendEmailData) => {
|
||||
try {
|
||||
if (IS_SMTP_CONFIGURED) {
|
||||
let transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
logger: DEBUG,
|
||||
debug: DEBUG,
|
||||
});
|
||||
const emailDefaults = {
|
||||
from: `Formbricks <${MAIL_FROM || "noreply@formbricks.com"}>`,
|
||||
};
|
||||
await transporter.sendMail({ ...emailDefaults, ...emailData });
|
||||
} else {
|
||||
console.error(`Could not Email :: SMTP not configured :: ${emailData.subject}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendVerificationEmail = async (user: TEmailUser) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?email=${encodeURIComponent(
|
||||
user.email
|
||||
)}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Please verify your email to use Formbricks",
|
||||
html: withEmailTemplate(`<h1>Almost there!</h1>
|
||||
To start using Formbricks please verify your email below:<br/><br/>
|
||||
<a class="button" href="${verifyLink}">Verify email</a><br/><br/>
|
||||
You can also click on this link:<br/>
|
||||
<a href="${verifyLink}" style="word-break: break-all; color: #1e293b;">${verifyLink}</a><br/><br/>
|
||||
<strong>The link is valid for 24h.</strong><br/><br/>If it has expired please request a new token here:
|
||||
<a href="${verificationRequestLink}">Request new verification</a><br/>
|
||||
<br/>
|
||||
Your Formbricks Team`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendForgotPasswordEmail = async (user: TEmailUser) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Formbricks password",
|
||||
html: withEmailTemplate(`<h1>Change password</h1>
|
||||
You have requested a link to change your password. You can do this by clicking the link below:<br/><br/>
|
||||
<a class="button" href="${verifyLink}">Change password</a><br/>
|
||||
<br/>
|
||||
<strong>The link is valid for 24 hours.</strong><br/><br/>If you didn't request this, please ignore this email.<br/>
|
||||
Your Formbricks Team`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendPasswordResetNotifyEmail = async (user: TEmailUser) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Your Formbricks password has been changed",
|
||||
html: withEmailTemplate(`<h1>Password changed</h1>
|
||||
Your password has been changed successfully.<br/>
|
||||
<br/>
|
||||
Your Formbricks Team`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendInviteMemberEmail = async (
|
||||
inviteId: string,
|
||||
email: string,
|
||||
inviterName: string | null,
|
||||
inviteeName: string | null,
|
||||
isOnboardingInvite?: boolean,
|
||||
inviteMessage?: string
|
||||
) => {
|
||||
const token = createInviteToken(inviteId, email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (isOnboardingInvite && inviteMessage) {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `${inviterName} needs a hand setting up Formbricks. Can you help out?`,
|
||||
html: withEmailTemplate(`Hey 👋,<br/><br/>
|
||||
${inviteMessage}
|
||||
<h2>Get Started in Minutes</h2>
|
||||
<ol>
|
||||
<li>Create an account to join ${inviterName}'s team.</li>
|
||||
<li>Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.</li>
|
||||
<li>Done ✅</li>
|
||||
</ol>
|
||||
<a class="button" href="${verifyLink}">Join ${inviterName}'s team</a><br/>
|
||||
<br/>
|
||||
Have a great day!<br/>
|
||||
The Formbricks Team!`),
|
||||
});
|
||||
} else {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `You're invited to collaborate on Formbricks!`,
|
||||
html: withEmailTemplate(`Hey ${inviteeName},<br/><br/>
|
||||
Your colleague ${inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:<br/><br/>
|
||||
<a class="button" href="${verifyLink}">Join team</a><br/>
|
||||
<br/>
|
||||
Have a great day!<br/>
|
||||
The Formbricks Team!`),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const sendInviteAcceptedEmail = async (inviterName: string, inviteeName: string, email: string) => {
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: `You've got a new team member!`,
|
||||
html: withEmailTemplate(`Hey ${inviterName},
|
||||
<br/><br/>
|
||||
Just letting you know that ${inviteeName} accepted your invitation. Have fun collaborating!
|
||||
<br/><br/>
|
||||
Have a great day!<br/>
|
||||
The Formbricks Team!`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
environmentId: string,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
responseCount: number
|
||||
) => {
|
||||
const personEmail = response.person?.attributes["email"];
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
if (!product) return;
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail?.toString() || MAIL_FROM,
|
||||
html: withEmailTemplate(`
|
||||
<h1>Hey 👋</h1>
|
||||
<p>Congrats, you received a new response to your survey!
|
||||
Someone just completed your survey <strong>${survey.name}:</strong><br/></p>
|
||||
|
||||
<hr/>
|
||||
|
||||
${getQuestionResponseMapping(survey, response)
|
||||
.map(
|
||||
(question) =>
|
||||
question.answer &&
|
||||
`<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${question.question}</p>
|
||||
${
|
||||
question.type === TSurveyQuestionType.FileUpload
|
||||
? typeof question.answer !== "string" &&
|
||||
question.answer
|
||||
.map((answer) => {
|
||||
return `
|
||||
<div style="position: relative; display: flex; width: 15rem; flex-direction: column; align-items: center; justify-content: center; border-radius: 0.5rem; background-color: #e2e8f0; color: black; margin-top:8px;">
|
||||
<div style="margin-top: 1rem; color: black;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="lucide lucide-file">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p style="margin-top: 0.5rem; width: 80%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 1rem; font-size: 0.875rem; color: black;">
|
||||
${getOriginalFileNameFromUrl(answer)}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("")
|
||||
: `<p style="margin:0px; white-space:pre-wrap"><b>${processResponseData(question.answer)}</b></p>`
|
||||
}
|
||||
|
||||
</div>`
|
||||
)
|
||||
.join("")}
|
||||
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">${responseCount > 1 ? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}` : `View survey summary`}</a>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div style="margin-top:0.8em; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
|
||||
<p><b>Don't want to get these notifications?</b></p>
|
||||
<p>Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}">this form</a>.
|
||||
<br/> Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}">all newly created forms</a>.</p></div>
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendEmbedSurveyPreviewEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
environmentId: string
|
||||
) => {
|
||||
await sendEmail({
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: withEmailTemplate(`
|
||||
<h1>Preview</h1>
|
||||
<p>This is how the code snippet looks embedded into an email 👇</p>
|
||||
<p style="font-size:0.8em;"><b>Didn't request this?</b> Help us fight spam and forward this mail to hola@formbricks.com</p>
|
||||
${html}
|
||||
<p style="font-size:0.8em; color:gray; text-align:center">Environment ID: ${environmentId}</p>`),
|
||||
});
|
||||
};
|
||||
|
||||
export const sendLinkSurveyToVerifiedEmail = async (data: LinkSurveyEmailData) => {
|
||||
const surveyId = data.surveyId;
|
||||
const email = data.email;
|
||||
const surveyData = data.surveyData;
|
||||
const singleUseId = data.suId ?? null;
|
||||
const token = createTokenForLinkSurvey(surveyId, email);
|
||||
const getSurveyLink = () => {
|
||||
if (singleUseId) {
|
||||
return `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
|
||||
}
|
||||
return `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
|
||||
};
|
||||
await sendEmail({
|
||||
to: data.email,
|
||||
subject: "Your Formbricks Survey",
|
||||
html: withEmailTemplate(`<h1>Hey 👋</h1>
|
||||
Thanks for validating your email. Here is your Survey.<br/><br/>
|
||||
<strong>${surveyData?.name}</strong>
|
||||
<p>${surveyData?.subheading}</p>
|
||||
<a class="button" href="${getSurveyLink()}">Take survey</a><br/>
|
||||
<br/>
|
||||
All the best,<br/>
|
||||
Your Formbricks Team 🤍`),
|
||||
});
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@formbricks/types/invites";
|
||||
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { sendInviteMemberEmail } from "../emails/emails";
|
||||
import { getMembershipByUserIdTeamId } from "../membership/service";
|
||||
import { formatDateFields } from "../utils/datetime";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
@@ -191,8 +190,6 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
|
||||
await sendInviteMemberEmail(inviteId, invite.email, invite.creator?.name ?? "", invite.name ?? "");
|
||||
|
||||
const updatedInvite = await prisma.invite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
@@ -221,20 +218,16 @@ export const inviteUser = async ({
|
||||
currentUser,
|
||||
invitee,
|
||||
teamId,
|
||||
isOnboardingInvite,
|
||||
inviteMessage,
|
||||
}: {
|
||||
teamId: string;
|
||||
invitee: TInvitee;
|
||||
currentUser: TCurrentUser;
|
||||
isOnboardingInvite?: boolean;
|
||||
inviteMessage?: string;
|
||||
}): Promise<TInvite> => {
|
||||
validateInputs([teamId, ZString], [invitee, ZInvitee], [currentUser, ZCurrentUser]);
|
||||
|
||||
try {
|
||||
const { name, email, role } = invitee;
|
||||
const { id: currentUserId, name: currentUserName } = currentUser;
|
||||
const { id: currentUserId } = currentUser;
|
||||
const existingInvite = await prisma.invite.findFirst({ where: { email, teamId } });
|
||||
|
||||
if (existingInvite) {
|
||||
@@ -271,7 +264,6 @@ export const inviteUser = async ({
|
||||
teamId: invite.teamId,
|
||||
});
|
||||
|
||||
await sendInviteMemberEmail(invite.id, email, currentUserName, name, isOnboardingInvite, inviteMessage);
|
||||
return invite;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.7",
|
||||
"next-auth": "^4.24.7",
|
||||
"nodemailer": "^6.9.13",
|
||||
"posthog-node": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
|
||||
@@ -1,40 +1,53 @@
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
|
||||
import { getLocalizedValue } from "./i18n/utils";
|
||||
|
||||
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
|
||||
export const convertResponseValue = (
|
||||
answer: string | number | string[] | Record<string, string>,
|
||||
question: TSurveyQuestion
|
||||
): string | string[] => {
|
||||
if (!answer) return "";
|
||||
else {
|
||||
switch (question.type) {
|
||||
case "fileUpload":
|
||||
if (typeof answer === "string") {
|
||||
return [answer];
|
||||
} else return answer as string[];
|
||||
|
||||
case "pictureSelection":
|
||||
if (typeof answer === "string") {
|
||||
const imageUrl = question.choices.find((choice) => choice.id === answer)?.imageUrl;
|
||||
return imageUrl ? [imageUrl] : [];
|
||||
} else if (Array.isArray(answer)) {
|
||||
return answer
|
||||
.map((answerId) => question.choices.find((choice) => choice.id === answerId)?.imageUrl)
|
||||
.filter((url): url is string => url !== undefined);
|
||||
} else return [];
|
||||
|
||||
default:
|
||||
return processResponseData(answer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getQuestionResponseMapping = (
|
||||
survey: TSurvey,
|
||||
response: TResponse
|
||||
): { question: string; answer: string | string[]; type: TSurveyQuestionType }[] => {
|
||||
): { question: string; response: string | string[]; type: TSurveyQuestionType }[] => {
|
||||
const questionResponseMapping: {
|
||||
question: string;
|
||||
answer: string | string[];
|
||||
response: string | string[];
|
||||
type: TSurveyQuestionType;
|
||||
}[] = [];
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const answer = response.data[question.id];
|
||||
|
||||
const getAnswer = () => {
|
||||
if (!answer) return "";
|
||||
else {
|
||||
if (question.type === "fileUpload") {
|
||||
if (typeof answer === "string") {
|
||||
return [answer];
|
||||
} else {
|
||||
return answer as string[];
|
||||
// as array
|
||||
}
|
||||
} else {
|
||||
return processResponseData(answer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: getLocalizedValue(question.headline, "default"),
|
||||
answer: getAnswer(),
|
||||
response: convertResponseValue(answer, question),
|
||||
type: question.type,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,9 +239,9 @@ export default function FileInput({
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
class="lucide lucide-file"
|
||||
className="text-heading h-6">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
|
||||
@@ -67,9 +67,9 @@ export const QuestionMedia = ({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
class="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
|
||||
@@ -45,12 +45,12 @@ const UsersIcon = () => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-4 w-4">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -117,10 +117,10 @@ export default function Modal({
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4L20 20M4 20L20 4" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4L20 20M4 20L20 4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ const CalendarIcon = () => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
class="lucide lucide-calendar-days">
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
@@ -34,9 +34,9 @@ const CalendarCheckIcon = () => (
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
class="lucide lucide-calendar-check">
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
|
||||
@@ -447,7 +447,7 @@ export const ZSurveyType = z.enum(["link", "app", "website"]);
|
||||
|
||||
export type TSurveyType = z.infer<typeof ZSurveyType>;
|
||||
|
||||
const ZSurveyStatus = z.enum(["draft", "scheduled", "inProgress", "paused", "completed"]);
|
||||
export const ZSurveyStatus = z.enum(["draft", "scheduled", "inProgress", "paused", "completed"]);
|
||||
|
||||
export type TSurveyStatus = z.infer<typeof ZSurveyStatus>;
|
||||
|
||||
|
||||
100
packages/types/weeklySummary.ts
Normal file
100
packages/types/weeklySummary.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZResponseData } from "./responses";
|
||||
import { ZSurveyQuestion, ZSurveyStatus } from "./surveys";
|
||||
import { ZUserNotificationSettings } from "./user";
|
||||
|
||||
const ZWeeklySummaryInsights = z.object({
|
||||
totalCompletedResponses: z.number(),
|
||||
totalDisplays: z.number(),
|
||||
totalResponses: z.number(),
|
||||
completionRate: z.number(),
|
||||
numLiveSurvey: z.number(),
|
||||
});
|
||||
|
||||
export type TWeeklySummaryInsights = z.infer<typeof ZWeeklySummaryInsights>;
|
||||
|
||||
export const ZWeeklySummarySurveyResponseData = z.object({
|
||||
headline: z.string(),
|
||||
responseValue: z.union([z.string(), z.array(z.string())]),
|
||||
questionType: z.string(),
|
||||
});
|
||||
|
||||
export type TWeeklySummarySurveyResponseData = z.infer<typeof ZWeeklySummarySurveyResponseData>;
|
||||
|
||||
export const ZWeeklySummaryNotificationDataSurvey = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
responses: z.array(ZWeeklySummarySurveyResponseData),
|
||||
responseCount: z.number(),
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export type TWeeklySummaryNotificationDataSurvey = z.infer<typeof ZWeeklySummaryNotificationDataSurvey>;
|
||||
|
||||
export const ZWeeklySummaryNotificationResponse = z.object({
|
||||
environmentId: z.string(),
|
||||
currentDate: z.date(),
|
||||
lastWeekDate: z.date(),
|
||||
productName: z.string(),
|
||||
surveys: z.array(ZWeeklySummaryNotificationDataSurvey),
|
||||
insights: ZWeeklySummaryInsights,
|
||||
});
|
||||
|
||||
export type TWeeklySummaryNotificationResponse = z.infer<typeof ZWeeklySummaryNotificationResponse>;
|
||||
|
||||
export const ZWeeklyEmailResponseData = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
});
|
||||
|
||||
export type TWeeklyEmailResponseData = z.infer<typeof ZWeeklyEmailResponseData>;
|
||||
|
||||
export const ZWeeklySummarySurveyData = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
questions: z.array(ZSurveyQuestion),
|
||||
status: ZSurveyStatus,
|
||||
responses: z.array(ZWeeklyEmailResponseData),
|
||||
displays: z.array(z.object({ id: z.string() })),
|
||||
});
|
||||
|
||||
export type TWeeklySummarySurveyData = z.infer<typeof ZWeeklySummarySurveyData>;
|
||||
|
||||
export const ZWeeklySummaryEnvironmentData = z.object({
|
||||
id: z.string(),
|
||||
surveys: z.array(ZWeeklySummarySurveyData),
|
||||
});
|
||||
|
||||
export type TWeeklySummaryEnvironmentData = z.infer<typeof ZWeeklySummaryEnvironmentData>;
|
||||
|
||||
export const ZWeeklySummaryUserData = z.object({
|
||||
email: z.string(),
|
||||
notificationSettings: ZUserNotificationSettings,
|
||||
});
|
||||
|
||||
export type TWeeklySummaryUserData = z.infer<typeof ZWeeklySummaryUserData>;
|
||||
|
||||
export const ZWeeklySummaryMembershipData = z.object({
|
||||
user: ZWeeklySummaryUserData,
|
||||
});
|
||||
|
||||
export type TWeeklySummaryMembershipData = z.infer<typeof ZWeeklySummaryMembershipData>;
|
||||
|
||||
export const ZWeeklyEmailTeamData = z.object({
|
||||
memberships: z.array(ZWeeklySummaryMembershipData),
|
||||
});
|
||||
|
||||
export type TWeeklyEmailTeamData = z.infer<typeof ZWeeklyEmailTeamData>;
|
||||
|
||||
export const ZWeeklySummaryProductData = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
environments: z.array(ZWeeklySummaryEnvironmentData),
|
||||
team: ZWeeklyEmailTeamData,
|
||||
});
|
||||
|
||||
export type TWeeklySummaryProductData = z.infer<typeof ZWeeklySummaryProductData>;
|
||||
@@ -27,12 +27,12 @@ export const FileUploadResponse = ({ selected }: FileUploadResponseProps) => {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
19774
pnpm-lock.yaml
generated
19774
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user