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:
Dhruwang Jariwala
2024-04-25 18:34:10 +05:30
committed by GitHub
parent db5efd3b8c
commit 5ff6e88b3b
52 changed files with 15056 additions and 7752 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>Wed 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;">Dont 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)}
`),
});
};

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View 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&apos;t request this, please ignore this email.</Text>
<EmailFooter />
</Container>
);
};

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

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

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

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

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

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

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

View 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}&apos;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>
);
};

View 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&apos;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>
);
};

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

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

View 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&apos;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>
);
};

View File

@@ -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>
Wed love to send you a Weekly Summary, but currently there are no surveys running for
{notificationData.productName}.
</Text>
<Text className="pt-4 font-bold">Dont 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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,
}),
})
),
});
};

View 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"
}
}

View File

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

View File

@@ -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 🤍`),
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>;

View File

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

File diff suppressed because it is too large Load Diff