Update notification flow (#548)

This commit is contained in:
Luke Vella 2023-03-11 10:41:29 +00:00 committed by GitHub
parent cb1fb23b19
commit 39a07558ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 930 additions and 520 deletions

View file

@ -60,6 +60,7 @@ jobs:
run: |
echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV
echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV
echo "SUPPORT_EMAIL=support@rallly.co" >> $GITHUB_ENV
- name: Install dependencies
run: yarn install --frozen-lockfile

View file

@ -2,5 +2,6 @@
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View file

@ -79,9 +79,10 @@
"next": "Next",
"nextMonth": "Next month",
"no": "No",
"verificationEmailSent": "An email has been sent to <b>{{email}}</b> with a link to enable notifications",
"noDatesSelected": "No dates selected",
"notificationsDisabled": "Notifications have been disabled",
"notificationsOff": "Notifications are off",
"notificationsOff": "Get notified when participants respond to your poll",
"notificationsOn": "Notifications are on",
"notificationsOnDescription": "An email will be sent to <b>{{email}}</b> when there is activity on this poll.",
"notificationsVerifyEmail": "You need to verify your email to turn on notifications",
@ -102,6 +103,7 @@
"optionCount_other": "{{count}} options",
"optionCount_two": "{{count}} options",
"optionCount_zero": "{{count}} options",
"expiredOrInvalidLink": "This link is expired or invalid. Please request a new link.",
"newParticipant": "New participant",
"pollHasBeenLocked": "This poll has been locked",
"pollHasBeenVerified": "Your poll has been verified",
@ -123,12 +125,14 @@
"specifyTimesDescription": "Include start and end times for each option",
"stepSummary": "Step {{current}} of {{total}}",
"sunday": "Sunday",
"redirect": "<a>Click here</a> if you are not redirect automatically…",
"timeFormat": "Time format:",
"timeZone": "Time Zone:",
"title": "Title",
"titlePlaceholder": "Monthly Meetup",
"today": "Today",
"unlockPoll": "Unlock poll",
"notificationsEnabled": "Notifications have been enabled for <b>{{title}}</b>",
"unverifiedMessage": "An email has been sent to <b>{{email}}</b> with a link to verify the email address.",
"user": "User",
"userAlreadyExists": "A user with that email already exists",

View file

@ -14,7 +14,6 @@ import { useParticipants } from "./participants-provider";
import ManagePoll from "./poll/manage-poll";
import { useUpdatePollMutation } from "./poll/mutations";
import NotificationsToggle from "./poll/notifications-toggle";
import { UnverifiedPollNotice } from "./poll/unverified-poll-notice";
import { usePoll } from "./poll-context";
import Sharing from "./sharing";
import { useUser } from "./user-provider";
@ -48,6 +47,7 @@ export const AdminControls = (props: { children?: React.ReactNode }) => {
}
}, [urlId, router, updatePollMutation, t]);
// TODO (Luke Vella) [2023-03-10]: We can delete this after 2.3.0 is released
const verifyEmail = trpc.polls.verification.verify.useMutation({
onSuccess: () => {
toast.success(t("pollHasBeenVerified"));
@ -126,7 +126,6 @@ export const AdminControls = (props: { children?: React.ReactNode }) => {
) : null}
</AnimatePresence>
<m.div className="relative z-10 space-y-4" layout="position">
{poll.verified === false ? <UnverifiedPollNotice /> : null}
{props.children}
</m.div>
</div>

View file

@ -52,12 +52,13 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
"btn-disabled": disabled,
"h-auto rounded-full p-2": rounded,
"w-10 p-0": !children,
"pointer-events-none": loading,
},
className,
)}
{...passThroughProps}
role="button"
disabled={disabled || loading}
disabled={disabled}
>
{loading ? (
<SpinnerIcon

View file

@ -22,7 +22,7 @@ const ErrorPage: React.FunctionComponent<ComponentProps> = ({
}) => {
const { t } = useTranslation("errors");
return (
<div className="mx-auto flex h-full max-w-full items-center justify-center px-4 py-8 lg:w-[1024px]">
<div className="flex h-[calc(100vh-100px)] w-full items-center justify-center">
<Head>
<title>{title}</title>
<meta name="robots" content="noindex,nofollow" />

View file

@ -13,9 +13,7 @@ const FullPageLoader: React.FunctionComponent<FullPageLoaderProps> = ({
className,
}) => {
return (
<div
className={clsx(" flex h-full items-center justify-center", className)}
>
<div className={clsx("flex h-full items-center justify-center", className)}>
<div className="bg-primary-500 flex items-center rounded-lg px-4 py-3 text-sm text-white shadow-sm">
<Spinner className="mr-3 h-5 animate-spin" />
{children}

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M17.613 15.538a1.188 1.188 0 01-1.673.155L2.996 4.933a1.188 1.188 0 111.519-1.827l12.944 10.759c.505.42.574 1.169.154 1.673z" clip-rule="evenodd"/>
<path d="M16 11.202V8a6 6 0 00-9.614-4.79L16 11.203zM4.046 7.256A6 6 0 004 8v3.586l-.707.707A1 1 0 004 14h8.16L4.045 7.256zM7.878 17.121A3 3 0 0013 15H7a3 3 0 00.878 2.121z"/>
</svg>

Before

Width:  |  Height:  |  Size: 468 B

After

Width:  |  Height:  |  Size: 445 B

View file

@ -0,0 +1,16 @@
import { NextSeo } from "next-seo";
export const AuthLayout = (
props: React.PropsWithChildren<{ title: string }>,
) => {
return (
<>
<NextSeo nofollow={true} noindex={true} title={props.title} />
<div className="bg-pattern flex h-full items-center justify-center p-4">
<div className="animate-popIn space-y-2 rounded-md border bg-white p-6 text-center shadow-sm">
{props.children}
</div>
</div>
</>
);
};

View file

@ -1,4 +1,4 @@
import { domMax, LazyMotion, m } from "framer-motion";
import { domMax, LazyMotion } from "framer-motion";
import React from "react";
import { DayjsProvider } from "@/utils/dayjs";
@ -18,7 +18,7 @@ const StandardLayout: React.FunctionComponent<{
<ModalProvider>
<div className="bg-pattern relative min-h-full" {...rest}>
<MobileNavigation />
<div className="mx-auto max-w-4xl">{children}</div>
<div className="mx-auto max-w-4xl grow">{children}</div>
</div>
</ModalProvider>
</DayjsProvider>

View file

@ -4,11 +4,20 @@ import * as React from "react";
import { Button } from "@/components/button";
import Bell from "@/components/icons/bell.svg";
import BellCrossed from "@/components/icons/bell-crossed.svg";
import { trpc } from "@/utils/trpc";
import { usePoll } from "../poll-context";
import Tooltip from "../tooltip";
import { useUpdatePollMutation } from "./mutations";
const Email = (props: { children?: React.ReactNode }) => {
return (
<span className="text-primary-300 whitespace-nowrap font-mono font-medium">
{props.children}
</span>
);
};
const NotificationsToggle: React.FunctionComponent = () => {
const { poll, urlId } = usePoll();
const { t } = useTranslation("app");
@ -16,12 +25,23 @@ const NotificationsToggle: React.FunctionComponent = () => {
React.useState(false);
const { mutate: updatePollMutation } = useUpdatePollMutation();
const requestEnableNotifications =
trpc.polls.enableNotifications.useMutation();
return (
<Tooltip
content={
poll.verified ? (
poll.notifications ? (
<div className="max-w-md">
{requestEnableNotifications.isSuccess ? (
<Trans
t={t}
i18nKey="unverifiedMessage"
values={{
email: poll.user.email,
}}
components={{ b: <Email /> }}
/>
) : poll.notifications ? (
<div>
<div className="text-primary-300 font-medium">
{t("notificationsOn")}
@ -34,38 +54,43 @@ const NotificationsToggle: React.FunctionComponent = () => {
email: poll.user.email,
}}
components={{
b: (
<span className="text-primary-300 whitespace-nowrap font-mono font-medium " />
),
b: <Email />,
}}
/>
</div>
</div>
) : (
t("notificationsOff")
)
) : (
t("notificationsVerifyEmail")
)
)}
</div>
}
>
<Button
loading={isUpdatingNotifications}
data-testid="notifications-toggle"
loading={
isUpdatingNotifications || requestEnableNotifications.isLoading
}
icon={poll.verified && poll.notifications ? <Bell /> : <BellCrossed />}
disabled={!poll.verified}
onClick={() => {
setIsUpdatingNotifications(true);
updatePollMutation(
{
urlId,
notifications: !poll.notifications,
},
{
onSuccess: () => {
setIsUpdatingNotifications(false);
disabled={requestEnableNotifications.isSuccess}
onClick={async () => {
if (!poll.verified) {
await requestEnableNotifications.mutateAsync({
adminUrlId: poll.adminUrlId,
});
} else {
setIsUpdatingNotifications(true);
updatePollMutation(
{
urlId,
notifications: !poll.notifications,
},
},
);
{
onSuccess: () => {
setIsUpdatingNotifications(false);
},
},
);
}
}}
/>
</Tooltip>

View file

@ -1,44 +0,0 @@
import { Trans, useTranslation } from "next-i18next";
import { trpc } from "../../utils/trpc";
import { Button } from "../button";
import { usePoll } from "../poll-context";
export const UnverifiedPollNotice = () => {
const { t } = useTranslation("app");
const { poll } = usePoll();
const requestVerificationEmail =
trpc.polls.verification.request.useMutation();
return (
<div className="space-y-3 rounded-md border border-amber-200 bg-amber-100 p-3 text-gray-700 shadow-sm">
<div className="px-1">
<Trans
t={t}
i18nKey="unverifiedMessage"
values={{ email: poll.user.email }}
components={{
b: <span className="whitespace-nowrap font-bold text-slate-700" />,
}}
/>
</div>
<div>
<Button
onClick={() => {
requestVerificationEmail.mutate({
pollId: poll.id,
adminUrlId: poll.adminUrlId,
});
}}
loading={requestVerificationEmail.isLoading}
className="rounded px-3 py-2 font-semibold shadow-sm"
disabled={requestVerificationEmail.isSuccess}
>
{requestVerificationEmail.isSuccess
? "Vertification email sent"
: "Resend verification email"}
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,11 @@
import clsx from "clsx";
import SpinnerSvg from "@/components/icons/spinner.svg";
export const Spinner = (props: { className?: string }) => {
return (
<SpinnerSvg
className={clsx("inline-block h-5 animate-spin", props.className)}
/>
);
};

View file

@ -62,9 +62,13 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
],
{
onPrefetch: async (ssg, ctx) => {
await ssg.polls.getByAdminUrlId.fetch({
const poll = await ssg.polls.getByAdminUrlId.fetch({
urlId: ctx.params?.urlId as string,
});
await ssg.polls.participants.list.prefetch({
pollId: poll.id,
});
},
},
);

View file

@ -0,0 +1,126 @@
import { prisma } from "@rallly/database";
import clsx from "clsx";
import { GetServerSideProps } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { Trans, useTranslation } from "next-i18next";
import React from "react";
import Bell from "@/components/icons/bell.svg";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { Spinner } from "@/components/spinner";
import {
composeGetServerSideProps,
decryptToken,
EnableNotificationsTokenPayload,
} from "@/utils/auth";
import { withPageTranslations } from "@/utils/with-page-translations";
interface PageProps {
title: string;
adminUrlId: string;
}
const Page = ({ title, adminUrlId }: PageProps) => {
const router = useRouter();
const { t } = useTranslation("app");
const [enabled, setEnabled] = React.useState(false);
React.useEffect(() => {
setTimeout(() => {
setEnabled(true);
}, 500);
setTimeout(() => {
router.replace(`/admin/${adminUrlId}`);
}, 3000);
}, [router, adminUrlId]);
return (
<AuthLayout title={t("loading")}>
<div className="flex h-8 items-center justify-center gap-4">
{enabled ? (
<Bell
className={clsx("animate-popIn h-5", {
"opacity-0": !enabled,
})}
/>
) : (
<Spinner />
)}
</div>
<div className="text-slate-800">
<Trans
t={t}
i18nKey="notificationsEnabled"
values={{ title }}
components={{ b: <strong /> }}
/>
</div>
<div className="text-sm text-slate-500">
<Trans
t={t}
i18nKey="redirect"
components={{
a: <Link className="underline" href={`/admin/${adminUrlId}`} />,
}}
/>
</div>
</AuthLayout>
);
};
const redirectToInvalidToken = {
redirect: {
destination: "/auth/invalid-token",
permanent: false,
},
};
export const getServerSideProps: GetServerSideProps = composeGetServerSideProps(
withPageTranslations(["app"]),
async (ctx) => {
const token = ctx.query.token as string;
if (!token) {
return redirectToInvalidToken;
}
const payload = await decryptToken<EnableNotificationsTokenPayload>(token);
if (payload) {
const poll = await prisma.poll.findFirst({
select: {
title: true,
},
where: { adminUrlId: payload.adminUrlId },
});
if (!poll) {
return {
redirect: {
destination: `/admin/${payload.adminUrlId}`,
permanent: false,
},
};
}
await prisma.poll.update({
data: {
notifications: true,
verified: true,
},
where: { adminUrlId: payload.adminUrlId },
});
return {
props: { title: poll.title, adminUrlId: payload.adminUrlId },
};
} else {
return redirectToInvalidToken;
}
},
);
export default Page;

View file

@ -0,0 +1,23 @@
import { useTranslation } from "next-i18next";
import { NextSeo } from "next-seo";
import { AuthLayout } from "@/components/layouts/auth-layout";
import { withPageTranslations } from "@/utils/with-page-translations";
const Page = () => {
const { t } = useTranslation("app");
return (
<AuthLayout title={t("expiredOrInvalidLink")}>
<NextSeo
title={t("expiredOrInvalidLink")}
nofollow={true}
noindex={true}
/>
{t("expiredOrInvalidLink")}
</AuthLayout>
);
};
export const getStaticProps = withPageTranslations(["app"]);
export default Page;

View file

@ -71,7 +71,9 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
const res = await decryptToken<{ userId: string }>(
ctx.query.token as string,
);
userId = res?.userId;
if (res) {
userId = res.userId;
}
}
return {
props: {
@ -83,9 +85,13 @@ export const getServerSideProps: GetServerSideProps = withSessionSsr(
],
{
onPrefetch: async (ssg, ctx) => {
await ssg.polls.getByParticipantUrlId.fetch({
const poll = await ssg.polls.getByParticipantUrlId.fetch({
urlId: ctx.params?.urlId as string,
});
await ssg.polls.participants.list.prefetch({
pollId: poll.id,
});
},
},
);

View file

@ -13,21 +13,6 @@ import {
import { generateOtp } from "../../utils/nanoid";
import { publicProcedure, router } from "../trpc";
const sendVerificationEmail = async (
email: string,
name: string,
code: string,
) => {
await sendEmail("VerificationCodeEmail", {
to: email,
subject: `Your 6-digit code is: ${code}`,
props: {
code,
name,
},
});
};
export const auth = router({
requestRegistration: publicProcedure
.input(
@ -63,7 +48,14 @@ export const auth = router({
code,
});
await sendVerificationEmail(input.email, input.name, code);
await sendEmail("RegisterEmail", {
to: input.email,
subject: "Complete your registration",
props: {
code,
name: input.name,
},
});
return { ok: true, token };
},
@ -76,9 +68,13 @@ export const auth = router({
}),
)
.mutation(async ({ input, ctx }) => {
const { name, email, code } =
await decryptToken<RegistrationTokenPayload>(input.token);
const payload = await decryptToken<RegistrationTokenPayload>(input.token);
if (!payload) {
return { user: null };
}
const { name, email, code } = payload;
if (input.code !== code) {
return { ok: false };
}
@ -128,7 +124,14 @@ export const auth = router({
code,
});
await sendVerificationEmail(input.email, user.name, code);
await sendEmail("LoginEmail", {
to: input.email,
subject: "Login",
props: {
name: user.name,
code,
},
});
return { token };
}),
@ -140,9 +143,13 @@ export const auth = router({
}),
)
.mutation(async ({ input, ctx }) => {
const { userId, code } = await decryptToken<LoginTokenPayload>(
input.token,
);
const payload = await decryptToken<LoginTokenPayload>(input.token);
if (!payload) {
return { user: null };
}
const { userId, code } = payload;
if (input.code !== code) {
return { user: null };

View file

@ -3,8 +3,9 @@ import { sendEmail } from "@rallly/emails";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createToken, EnableNotificationsTokenPayload } from "@/utils/auth";
import { absoluteUrl } from "../../utils/absolute-url";
import { createToken } from "../../utils/auth";
import { nanoid } from "../../utils/nanoid";
import { GetPollApiResponse } from "../../utils/trpc/types";
import { publicProcedure, router } from "../trpc";
@ -95,7 +96,7 @@ export const polls = router({
)
.mutation(async ({ ctx, input }): Promise<{ urlId: string }> => {
const adminUrlId = await nanoid();
const participantUrlId = await nanoid();
let verified = false;
if (ctx.session.user.isGuest === false) {
@ -122,7 +123,7 @@ export const polls = router({
verified: verified,
notifications: verified,
adminUrlId,
participantUrlId: await nanoid(),
participantUrlId,
user: {
connectOrCreate: {
where: {
@ -144,39 +145,19 @@ export const polls = router({
},
});
const pollUrl = absoluteUrl(`/admin/${adminUrlId}`);
const adminLink = absoluteUrl(`/admin/${adminUrlId}`);
const participantLink = absoluteUrl(`/p/${participantUrlId}`);
try {
if (poll.verified) {
await sendEmail("NewPollEmail", {
to: input.user.email,
subject: `${poll.title} has been created`,
props: {
title: poll.title,
name: input.user.name,
adminLink: pollUrl,
},
});
} else {
const verificationCode = await createToken({
pollId: poll.id,
});
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
await sendEmail("NewPollVerificationEmail", {
to: input.user.email,
subject: `${poll.title} has been created`,
props: {
title: poll.title,
name: input.user.name,
adminLink: pollUrl,
verificationLink: verifyEmailUrl,
},
});
}
} catch (e) {
console.error(e);
}
await sendEmail("NewPollEmail", {
to: input.user.email,
subject: `Let's find a date for ${poll.title}`,
props: {
title: poll.title,
name: input.user.name,
adminLink,
participantLink,
},
});
return { urlId: adminUrlId };
}),
@ -265,6 +246,46 @@ export const polls = router({
comments,
verification,
// END LEGACY ROUTES
enableNotifications: publicProcedure
.input(z.object({ adminUrlId: z.string() }))
.mutation(async ({ input }) => {
const poll = await prisma.poll.findUnique({
select: {
id: true,
title: true,
user: {
select: {
name: true,
email: true,
},
},
},
where: {
adminUrlId: input.adminUrlId,
},
});
if (!poll) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const token = await createToken<EnableNotificationsTokenPayload>({
adminUrlId: input.adminUrlId,
});
await sendEmail("EnableNotificationsEmail", {
to: poll.user.email,
subject: "Please verify your email address",
props: {
name: poll.user.name,
title: poll.title,
adminLink: absoluteUrl(`/admin/${input.adminUrlId}`),
verificationLink: absoluteUrl(
`/auth/enable-notifications?token=${token}`,
),
},
});
}),
getByAdminUrlId: publicProcedure
.input(
z.object({

View file

@ -1,14 +1,8 @@
import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { absoluteUrl } from "../../../utils/absolute-url";
import {
createToken,
decryptToken,
mergeGuestsIntoUser,
} from "../../../utils/auth";
import { decryptToken, mergeGuestsIntoUser } from "../../../utils/auth";
import { publicProcedure, router } from "../../trpc";
export const verification = router({
@ -20,10 +14,19 @@ export const verification = router({
}),
)
.mutation(async ({ ctx, input }) => {
const { pollId } = await decryptToken<{
const payload = await decryptToken<{
pollId: string;
}>(input.code);
if (!payload) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid token",
});
}
const { pollId } = payload;
if (pollId !== input.pollId) {
throw new TRPCError({
code: "BAD_REQUEST",
@ -54,45 +57,4 @@ export const verification = router({
};
await ctx.session.save();
}),
request: publicProcedure
.input(
z.object({
pollId: z.string(),
adminUrlId: z.string(),
}),
)
.mutation(async ({ input: { pollId, adminUrlId } }) => {
const poll = await prisma.poll.findUnique({
where: {
id: pollId,
},
include: {
user: true,
},
});
if (!poll) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Poll with id ${pollId} not found`,
});
}
const pollUrl = absoluteUrl(`/admin/${adminUrlId}`);
const token = await createToken({
pollId,
});
const verifyEmailUrl = `${pollUrl}?code=${token}`;
await sendEmail("GuestVerifyEmail", {
to: poll.user.email,
subject: "Please verify your email address",
props: {
title: poll.title,
name: poll.user.name,
adminLink: pollUrl,
verificationLink: verifyEmailUrl,
},
});
}),
});

View file

@ -35,6 +35,10 @@ export type LoginTokenPayload = {
code: string;
};
export type EnableNotificationsTokenPayload = {
adminUrlId: string;
};
export type RegisteredUserSession = {
isGuest: false;
id: string;
@ -75,7 +79,9 @@ export function withSessionRoute(handler: NextApiHandler) {
}, sessionOptions);
}
const compose = (...fns: GetServerSideProps[]): GetServerSideProps => {
export const composeGetServerSideProps = (
...fns: GetServerSideProps[]
): GetServerSideProps => {
return async (ctx) => {
const res = { props: {} };
for (const getServerSideProps of fns) {
@ -87,7 +93,7 @@ const compose = (...fns: GetServerSideProps[]): GetServerSideProps => {
...fnRes.props,
};
} else {
return { notFound: true };
return fnRes;
}
}
@ -105,7 +111,7 @@ export function withSessionSsr(
},
): GetServerSideProps {
const composedHandler = Array.isArray(handler)
? compose(...handler)
? composeGetServerSideProps(...handler)
: handler;
return withIronSessionSsr(async (ctx) => {
@ -137,8 +143,15 @@ export function withSessionSsr(
export const decryptToken = async <P extends Record<string, unknown>>(
token: string,
): Promise<P> => {
return await unsealData(token, { password: sessionOptions.password });
): Promise<P | null> => {
const payload = await unsealData(token, {
password: sessionOptions.password,
});
if (Object.keys(payload).length === 0) {
return null;
}
return payload as P;
};
export const createToken = async <T extends Record<string, unknown>>(

View file

@ -51,15 +51,24 @@ test.describe.serial(() => {
await expect(title).toHaveText("Monthly Meetup");
pollUrl = page.url();
});
test("verify poll", async ({ page, baseURL }) => {
const { email } = await mailServer.captureOne("john.doe@email.com", {
wait: 5000,
});
expect(email.headers.subject).toBe("Monthly Meetup has been created");
expect(email.headers.subject).toBe("Let's find a date for Monthly Meetup");
pollUrl = page.url();
});
test("enable notifications", async ({ page, baseURL }) => {
await page.goto(pollUrl);
await page.getByTestId("notifications-toggle").click();
const { email } = await mailServer.captureOne("john.doe@email.com", {
wait: 5000,
});
expect(email.headers.subject).toBe("Please verify your email address");
const $ = load(email.html);
const verifyLink = $("#verifyEmailUrl").attr("href");
@ -72,6 +81,12 @@ test.describe.serial(() => {
await page.goto(verifyLink);
await expect(
page.getByText("Notifications have been enabled for Monthly Meetup"),
).toBeVisible();
page.getByText("Click here").click();
await expect(page.getByTestId("poll-title")).toHaveText("Monthly Meetup");
});

View file

@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
test.describe("Edit options", () => {
test.describe("edit options", () => {
test("should show warning when deleting options with votes in them", async ({
page,
}) => {

View file

@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { APIRequestContext, expect, test } from "@playwright/test";
import { Prisma, prisma } from "@rallly/database";
import dayjs from "dayjs";
@ -8,7 +8,168 @@ import dayjs from "dayjs";
* * Polls are soft deleted after 30 days of inactivity
* * Soft deleted polls are hard deleted after 7 days of being soft deleted
*/
test.beforeAll(async ({ request, baseURL }) => {
test.describe("house keeping", () => {
const callHouseKeeping = async (
request: APIRequestContext,
baseURL?: string,
) => {
const res = await request.post(`${baseURL}/api/house-keeping`, {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`,
},
});
return res;
};
test.beforeAll(async ({ request, baseURL }) => {
// call the endpoint to delete any existing data that needs to be removed
await callHouseKeeping(request, baseURL);
await seedData();
const res = await callHouseKeeping(request, baseURL);
expect(await res.json()).toMatchObject({
softDeleted: 1,
deleted: 2,
});
});
test("should keep active polls", async () => {
const poll = await prisma.poll.findUnique({
where: {
id: "active-poll",
},
});
// expect active poll to not be deleted
expect(poll).not.toBeNull();
expect(poll?.deleted).toBeFalsy();
});
test("should keep polls that have been soft deleted for less than 7 days", async () => {
const deletedPoll6d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-6d",
deleted: true,
},
});
// expect a poll that has been deleted for 6 days to
expect(deletedPoll6d).not.toBeNull();
});
test("should hard delete polls that have been soft deleted for 7 days", async () => {
const deletedPoll7d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-7d",
deleted: true,
},
});
expect(deletedPoll7d).toBeNull();
const participants = await prisma.participant.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(participants.length).toBe(0);
const votes = await prisma.vote.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(votes.length).toBe(0);
const options = await prisma.option.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(options.length).toBe(0);
});
test("should keep polls that are still active", async () => {
const stillActivePoll = await prisma.poll.findUnique({
where: {
id: "still-active-poll",
},
});
expect(stillActivePoll).not.toBeNull();
expect(stillActivePoll?.deleted).toBeFalsy();
});
test("should soft delete polls that are inactive", async () => {
const inactivePoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll",
deleted: true,
},
});
expect(inactivePoll).not.toBeNull();
expect(inactivePoll?.deleted).toBeTruthy();
expect(inactivePoll?.deletedAt).toBeTruthy();
});
test("should keep new demo poll", async () => {
const demoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-new",
},
});
expect(demoPoll).not.toBeNull();
});
test("should delete old demo poll", async () => {
const oldDemoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-old",
},
});
expect(oldDemoPoll).toBeNull();
});
test("should not delete poll that has options in the future", async () => {
const futureOptionPoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll-future-option",
},
});
expect(futureOptionPoll).not.toBeNull();
});
// Teardown
test.afterAll(async () => {
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join([
"active-poll",
"deleted-poll-6d",
"deleted-poll-7d",
"still-active-poll",
"inactive-poll",
"inactive-poll-future-option",
"demo-poll-new",
"demo-poll-old",
])})`;
await prisma.$executeRaw`DELETE FROM options WHERE id IN (${Prisma.join([
"option-1",
"option-2",
"option-3",
"option-4",
"option-5",
])})`;
await prisma.$executeRaw`DELETE FROM participants WHERE id IN (${Prisma.join(
["participant-1"],
)})`;
});
});
const seedData = async () => {
await prisma.poll.createMany({
data: [
// Active Poll
@ -156,151 +317,4 @@ test.beforeAll(async ({ request, baseURL }) => {
},
],
});
// call house-keeping endpoint
const res = await request.post(`${baseURL}/api/house-keeping`, {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`,
},
});
expect(await res.json()).toMatchObject({
softDeleted: 1,
deleted: 2,
});
});
test("should keep active polls", async () => {
const poll = await prisma.poll.findUnique({
where: {
id: "active-poll",
},
});
// expect active poll to not be deleted
expect(poll).not.toBeNull();
expect(poll?.deleted).toBeFalsy();
});
test("should keep polls that have been soft deleted for less than 7 days", async () => {
const deletedPoll6d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-6d",
deleted: true,
},
});
// expect a poll that has been deleted for 6 days to
expect(deletedPoll6d).not.toBeNull();
});
test("should hard delete polls that have been soft deleted for 7 days", async () => {
const deletedPoll7d = await prisma.poll.findFirst({
where: {
id: "deleted-poll-7d",
deleted: true,
},
});
expect(deletedPoll7d).toBeNull();
const participants = await prisma.participant.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(participants.length).toBe(0);
const votes = await prisma.vote.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(votes.length).toBe(0);
const options = await prisma.option.findMany({
where: {
pollId: "deleted-poll-7d",
},
});
expect(options.length).toBe(0);
});
test("should keep polls that are still active", async () => {
const stillActivePoll = await prisma.poll.findUnique({
where: {
id: "still-active-poll",
},
});
expect(stillActivePoll).not.toBeNull();
expect(stillActivePoll?.deleted).toBeFalsy();
});
test("should soft delete polls that are inactive", async () => {
const inactivePoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll",
deleted: true,
},
});
expect(inactivePoll).not.toBeNull();
expect(inactivePoll?.deleted).toBeTruthy();
expect(inactivePoll?.deletedAt).toBeTruthy();
});
test("should keep new demo poll", async () => {
const demoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-new",
},
});
expect(demoPoll).not.toBeNull();
});
test("should delete old demo poll", async () => {
const oldDemoPoll = await prisma.poll.findFirst({
where: {
id: "demo-poll-old",
},
});
expect(oldDemoPoll).toBeNull();
});
test("should not delete poll that has options in the future", async () => {
const futureOptionPoll = await prisma.poll.findFirst({
where: {
id: "inactive-poll-future-option",
},
});
expect(futureOptionPoll).not.toBeNull();
});
// Teardown
test.afterAll(async () => {
await prisma.$executeRaw`DELETE FROM polls WHERE id IN (${Prisma.join([
"active-poll",
"deleted-poll-6d",
"deleted-poll-7d",
"still-active-poll",
"inactive-poll",
"demo-poll-new",
"demo-poll-old",
])})`;
await prisma.$executeRaw`DELETE FROM options WHERE id IN (${Prisma.join([
"active-poll",
"deleted-poll-6d",
"deleted-poll-7d",
"still-active-poll",
"inactive-poll",
"demo-poll-new",
"demo-poll-old",
])})`;
});
};

View file

@ -46,12 +46,19 @@ export const sendEmail = async <T extends TemplateName>(
templateName: T,
options: SendEmailOptions<T>,
) => {
if (!process.env.SUPPORT_EMAIL) {
console.info("SUPPORT_EMAIL not configured - skipping email send");
return;
}
const transport = getTransport();
const Template = templates[templateName] as TemplateComponent<T>;
try {
return await transport.sendMail({
from: process.env.SUPPORT_EMAIL,
from: {
name: "Rallly",
address: process.env.SUPPORT_EMAIL,
},
to: options.to,
subject: options.subject,
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -1,7 +1,7 @@
export * from "./templates/guest-verify-email";
export * from "./templates/login";
export * from "./templates/new-comment";
export * from "./templates/new-participant";
export * from "./templates/new-participant-confirmation";
export * from "./templates/new-poll";
export * from "./templates/new-poll-verification";
export * from "./templates/verification-code";
export * from "./templates/register";
export * from "./templates/turn-on-notifications";

View file

@ -4,6 +4,7 @@ import {
Body,
Container,
Head,
Hr,
Html,
Img,
Link,
@ -12,14 +13,24 @@ import {
} from "@react-email/components";
import { Tailwind } from "@react-email/tailwind";
export const EmailLayout = (props: {
children: React.ReactNode;
import { SmallText, Text } from "./styled-components";
interface EmailLayoutProps {
preview: string;
}) => {
recipientName: string;
footNote?: React.ReactNode;
}
export const EmailLayout = ({
preview,
recipientName = "Guest",
children,
footNote,
}: React.PropsWithChildren<EmailLayoutProps>) => {
return (
<Html>
<Head />
<Preview>{props.preview}</Preview>
<Preview>{preview}</Preview>
<Tailwind
config={{
theme: {
@ -115,12 +126,21 @@ export const EmailLayout = (props: {
},
}}
>
<Body className="bg-gray-50 p-4">
<Container className="mx-auto bg-white p-6">
<Body className="bg-white px-3 py-6">
<Container className="max-w-lg">
<Section className="mb-4">
<Img src={absoluteUrl("/logo.png")} alt="Rallly" width={128} />
</Section>
<Section>{props.children}</Section>
<Section>
<Text>Hi {recipientName},</Text>
{children}
{footNote ? (
<>
<Hr />
<SmallText>{footNote}</SmallText>
</>
) : null}
</Section>
<Section className="mt-4 text-sm text-slate-500">
<Link className="font-sans text-slate-500" href={absoluteUrl()}>
Home

View file

@ -1,6 +1,9 @@
import { absoluteUrl } from "@rallly/utils";
import { Hr } from "@react-email/components";
import { Container } from "@react-email/container";
import { Link, Text } from "./styled-components";
import { Link, SmallText, Text } from "./styled-components";
import { removeProtocalFromUrl } from "./utils";
export interface NewPollBaseEmailProps {
title: string;
@ -29,6 +32,12 @@ export const NewPollBaseEmail = ({
</Link>
</Text>
{children}
<Hr />
<SmallText>
You are receiving this email because a new poll was created with this
email address on{" "}
<Link href={absoluteUrl()}>{removeProtocalFromUrl(absoluteUrl())}</Link>
</SmallText>
</Container>
);
};

View file

@ -0,0 +1,47 @@
import { EmailLayout } from "./email-layout";
import { Link, Section } from "./styled-components";
export interface NotificationBaseProps {
name: string;
title: string;
pollUrl: string;
unsubscribeUrl: string;
}
export interface NotificationEmailProps extends NotificationBaseProps {
preview: string;
}
export const NotificationEmail = ({
name,
title,
pollUrl,
unsubscribeUrl,
preview,
children,
}: React.PropsWithChildren<NotificationEmailProps>) => {
return (
<EmailLayout
recipientName={name}
footNote={
<>
You&apos;re receiving this email because notifications are enabled for{" "}
<strong>{title}</strong>. If you want to stop receiving emails about
this event you can{" "}
<Link className="whitespace-nowrap" href={unsubscribeUrl}>
turn notifications off
</Link>
.
</>
}
preview={preview}
>
{children}
<Section>
<Link href={pollUrl}>Go to poll &rarr;</Link>
</Section>
</EmailLayout>
);
};
export default NotificationEmail;

View file

@ -39,7 +39,7 @@ export const Link = (props: LinkProps) => {
return (
<UnstyledLink
{...props}
className={clsx("text-primary-500 font-sans text-base", props.className)}
className={clsx("text-primary-500", props.className)}
/>
);
};
@ -61,3 +61,12 @@ export const Section = (props: SectionProps) => {
<UnstyledSection {...props} className={clsx("my-4", props.className)} />
);
};
export const SmallText = (props: TextProps) => {
return (
<UnstyledText
{...props}
className={clsx("font-sans text-sm text-slate-500", props.className)}
/>
);
};

View file

@ -0,0 +1,3 @@
export const removeProtocalFromUrl = (url: string) => {
return url.replace(/(^\w+:|^)\/\//, "");
};

View file

@ -1,41 +0,0 @@
import { Button, Container } from "@react-email/components";
import { EmailLayout } from "./components/email-layout";
import { Section, Text } from "./components/styled-components";
type GuestVerifyEmailProps = {
title: string;
name: string;
verificationLink: string;
adminLink: string;
};
export const GuestVerifyEmail = ({
title = "Untitled Poll",
name = "Guest",
verificationLink = "https://rallly.co",
}: GuestVerifyEmailProps) => {
return (
<EmailLayout preview="Click the button below to verify your email">
<Container>
<Text>Hi {name},</Text>
<Text>
To receive notifications for <strong>&quot;{title}&quot;</strong> you
will need to verify your email address.
</Text>
<Text>To verify your email please click the button below.</Text>
<Section>
<Button
className="bg-primary-500 rounded px-3 py-2 font-sans text-white"
href={verificationLink}
id="verifyEmailUrl"
>
Verify your email &rarr;
</Button>
</Section>
</Container>
</EmailLayout>
);
};
export default GuestVerifyEmail;

View file

@ -0,0 +1,55 @@
import { absoluteUrl } from "@rallly/utils";
import { EmailLayout } from "./components/email-layout";
import { Heading, Link, Text } from "./components/styled-components";
import { removeProtocalFromUrl } from "./components/utils";
interface LoginEmailProps {
name: string;
code: string;
// magicLink: string;
}
export const LoginEmail = ({
name = "Guest",
code = "123456",
}: // magicLink = "https://rallly.co",
LoginEmailProps) => {
return (
<EmailLayout
footNote={
<>
You&apos;re receiving this email because a request was made to login
to{" "}
<Link href={absoluteUrl()}>
{removeProtocalFromUrl(absoluteUrl())}
</Link>
. If this wasn&apos;t you, let us know by replying to this email.
</>
}
recipientName={name}
preview={`Your 6-digit code: ${code}`}
>
<Text>Your 6-digit code is:</Text>
<Heading as="h1" className="font-sans tracking-widest" id="code">
{code}
</Heading>
<Text>
Use this code to complete the verification process on{" "}
<Link href={absoluteUrl()}>{removeProtocalFromUrl(absoluteUrl())}</Link>
</Text>
<Text>
<span className="text-slate-500">
This code is valid for 15 minutes
</span>
</Text>
{/* <Heading>Magic link</Heading>
<Text>
Alternatively, you can login by using this{" "}
<Link href={magicLink}>magic link </Link>
</Text> */}
</EmailLayout>
);
};
export default LoginEmail;

View file

@ -1,12 +1,10 @@
import { EmailLayout } from "./components/email-layout";
import { Button, Link, Section, Text } from "./components/styled-components";
import NotificationEmail, {
NotificationBaseProps,
} from "./components/notification-email";
import { Text } from "./components/styled-components";
export interface NewCommentEmailProps {
name: string;
title: string;
export interface NewCommentEmailProps extends NotificationBaseProps {
authorName: string;
pollUrl: string;
unsubscribeUrl: string;
}
export const NewCommentEmail = ({
@ -17,20 +15,17 @@ export const NewCommentEmail = ({
unsubscribeUrl = "https://rallly.co",
}: NewCommentEmailProps) => {
return (
<EmailLayout preview={`${authorName} has commented on ${title}`}>
<Text>Hi {name},</Text>
<NotificationEmail
name={name}
title={title}
pollUrl={pollUrl}
unsubscribeUrl={unsubscribeUrl}
preview={`${authorName} has commented on ${title}`}
>
<Text>
<strong>{authorName}</strong> has commented on <strong>{title}</strong>.
</Text>
<Section>
<Button href={pollUrl}>Go to poll &rarr;</Button>
</Section>
<Text>
<Link href={unsubscribeUrl}>
Stop receiving notifications for this poll.
</Link>
</Text>
</EmailLayout>
</NotificationEmail>
);
};

View file

@ -12,8 +12,13 @@ export const NewParticipantConfirmationEmail = ({
editSubmissionUrl = "https://rallly.co",
}: NewParticipantConfirmationEmailProps) => {
return (
<EmailLayout preview="To edit your response use the link below">
<Text>Hi {name},</Text>
<EmailLayout
footNote={
<>You are receiving this email because a response was submitting </>
}
recipientName={name}
preview="To edit your response use the link below"
>
<Text>
Thank you for submitting your availability for <strong>{title}</strong>.
</Text>

View file

@ -1,12 +1,10 @@
import { EmailLayout } from "./components/email-layout";
import { Button, Link, Section, Text } from "./components/styled-components";
import NotificationEmail, {
NotificationBaseProps,
} from "./components/notification-email";
import { Text } from "./components/styled-components";
export interface NewParticipantEmailProps {
name: string;
title: string;
export interface NewParticipantEmailProps extends NotificationBaseProps {
participantName: string;
pollUrl: string;
unsubscribeUrl: string;
}
export const NewParticipantEmail = ({
@ -17,21 +15,18 @@ export const NewParticipantEmail = ({
unsubscribeUrl = "https://rallly.co",
}: NewParticipantEmailProps) => {
return (
<EmailLayout preview={`${participantName} has responded`}>
<Text>Hi {name},</Text>
<NotificationEmail
name={name}
title={title}
pollUrl={pollUrl}
unsubscribeUrl={unsubscribeUrl}
preview={`${participantName} has responded`}
>
<Text>
<strong>{participantName}</strong> has shared their availability for{" "}
<strong>{participantName}</strong> has responded to{" "}
<strong>{title}</strong>.
</Text>
<Section>
<Button href={pollUrl}>Go to poll &rarr;</Button>
</Section>
<Text>
<Link href={unsubscribeUrl}>
Stop receiving notifications for this poll.
</Link>
</Text>
</EmailLayout>
</NotificationEmail>
);
};

View file

@ -1,37 +0,0 @@
import { EmailLayout } from "./components/email-layout";
import {
NewPollBaseEmail,
NewPollBaseEmailProps,
} from "./components/new-poll-base";
import { Button, Heading, Section, Text } from "./components/styled-components";
export interface NewPollVerificationEmailProps extends NewPollBaseEmailProps {
verificationLink: string;
}
export const NewPollVerificationEmail = ({
title = "Untitled Poll",
name = "Guest",
verificationLink = "https://rallly.co",
adminLink = "https://rallly.co/admin/abcdefg123",
}: NewPollVerificationEmailProps) => {
return (
<EmailLayout preview="Please verify your email address to turn on notifications">
<NewPollBaseEmail name={name} title={title} adminLink={adminLink}>
<Section className="mt-8 bg-gray-100 px-4 text-center">
<Heading as="h3">
Want to get notified when participants vote?
</Heading>
<Text>Verify your email address to turn on notifications.</Text>
<Section>
<Button id="verifyEmailUrl" href={verificationLink}>
Verify your email &rarr;
</Button>
</Section>
</Section>
</NewPollBaseEmail>
</EmailLayout>
);
};
export default NewPollVerificationEmail;

View file

@ -1,17 +1,93 @@
import { absoluteUrl } from "@rallly/utils";
import { EmailLayout } from "./components/email-layout";
import {
NewPollBaseEmail,
NewPollBaseEmailProps,
} from "./components/new-poll-base";
import { Heading, Link, Section, Text } from "./components/styled-components";
import { removeProtocalFromUrl } from "./components/utils";
export interface NewPollEmailProps {
title: string;
name: string;
adminLink: string;
participantLink: string;
}
const ShareLink = ({
title,
participantLink,
name,
children,
}: React.PropsWithChildren<{
name: string;
title: string;
participantLink: string;
}>) => {
return (
<Link
href={`mailto:?subject=${encodeURIComponent(
`Availability for ${title}`,
)}&body=${encodeURIComponent(
`Hi all,\nI'm trying to find the best date for ${title}.\nCan you please use the link below to choose your preferred dates:\n${participantLink}\nThank you.\n${name}`,
)}`}
>
{children}
</Link>
);
};
const LinkContainer = (props: { link: string }) => {
return (
<Section className="rounded bg-gray-50 p-4">
<Link href={props.link} className="font-mono">
{props.link}
</Link>
</Section>
);
};
export const NewPollEmail = ({
title = "Untitled Poll",
name = "Guest",
adminLink = "https://rallly.co/admin/abcdefg123",
}: NewPollBaseEmailProps) => {
participantLink = "https://rallly.co/p/wxyz9876",
}: NewPollEmailProps) => {
return (
<EmailLayout preview="Please verify your email address to turn on notifications">
<NewPollBaseEmail name={name} title={title} adminLink={adminLink} />
<EmailLayout
footNote={
<>
You are receiving this email because a new poll was created with this
email address on{" "}
<Link href={absoluteUrl()}>
{removeProtocalFromUrl(absoluteUrl())}
</Link>
. If this wasn&apos;t you, please ignore this email.
</>
}
recipientName={name}
preview="Share your participant link to start collecting responses."
>
<Text>
Your new poll is ready! Now lets find a date for{" "}
<strong>{title}</strong>.
</Text>
<Text>
Copy this link and share it with your participants to start collecting
responses.
</Text>
<LinkContainer link={participantLink} />
<Text>
<ShareLink title={title} name={name} participantLink={participantLink}>
Share via email &rarr;
</ShareLink>
</Text>
<Heading>Your secret link</Heading>
<Text>
Use this link to access the admin page where you can view and edit your
poll.
</Text>
<LinkContainer link={adminLink} />
<Text>
<Link href={adminLink}>Go to admin page &rarr;</Link>
</Text>
</EmailLayout>
);
};

View file

@ -0,0 +1,47 @@
import { absoluteUrl } from "@rallly/utils";
import { Heading } from "@react-email/heading";
import { EmailLayout } from "./components/email-layout";
import { Link, Text } from "./components/styled-components";
import { removeProtocalFromUrl } from "./components/utils";
interface RegisterEmailProps {
name: string;
code: string;
}
export const RegisterEmail = ({
name = "Guest",
code = "123456",
}: RegisterEmailProps) => {
return (
<EmailLayout
footNote={
<>
You&apos;re receiving this email because a request was made to
register an account on{" "}
<Link className="text-primary-500" href={absoluteUrl()}>
{removeProtocalFromUrl(absoluteUrl())}
</Link>
.
</>
}
recipientName={name}
preview={`Your 6-digit code is: ${code}`}
>
<Text>Your 6-digit code is:</Text>
<Heading className="font-sans tracking-widest" id="code">
{code}
</Heading>
<Text>
Use this code to complete the verification process on{" "}
<Link href={absoluteUrl()}>{removeProtocalFromUrl(absoluteUrl())}</Link>
</Text>
<Text>
<span className="text-gray-500">This code is valid for 15 minutes</span>
</Text>
</EmailLayout>
);
};
export default RegisterEmail;

View file

@ -0,0 +1,51 @@
import { EmailLayout } from "./components/email-layout";
import {
Button,
Link,
Section,
SmallText,
Text,
} from "./components/styled-components";
type EnableNotificationsEmailProps = {
title: string;
name: string;
verificationLink: string;
adminLink: string;
};
export const EnableNotificationsEmail = ({
title = "Untitled Poll",
name = "Guest",
verificationLink = "https://rallly.co",
adminLink = "https://rallly.co",
}: EnableNotificationsEmailProps) => {
return (
<EmailLayout
recipientName={name}
preview="Before we can send you notifications we need to verify your email"
footNote={
<>
You are receiving this email because a request was made to enable
notifications for <Link href={adminLink}>{title}</Link>.
</>
}
>
<Text>
Before we can send you notifications we need to verify your email.
</Text>
<Text>
Click the button below to complete the email verification and enable
notifications for <strong>{title}</strong>.
</Text>
<Section>
<Button href={verificationLink} id="verifyEmailUrl">
Enable notifications &rarr;
</Button>
</Section>
<SmallText>The link will expire in 15 minutes.</SmallText>
</EmailLayout>
);
};
export default EnableNotificationsEmail;

View file

@ -1,34 +0,0 @@
import { Heading } from "@react-email/heading";
import { EmailLayout } from "./components/email-layout";
import { Section, Text } from "./components/styled-components";
interface VerificationCodeEmailProps {
name: string;
code: string;
}
export const VerificationCodeEmail = ({
name = "Guest",
code = "123456",
}: VerificationCodeEmailProps) => {
return (
<EmailLayout preview={`Your 6-digit code is ${code}`}>
<Text>Hi {name},</Text>
<Text>Please use the code below to verify your email address.</Text>
<Section className="rounded bg-gray-50 text-center">
<Text>Your 6-digit code is:</Text>
<Heading className="font-sans tracking-widest" id="code">
{code}
</Heading>
<Text>
<span className="text-slate-500">
This code is valid for 15 minutes
</span>
</Text>
</Section>
</EmailLayout>
);
};
export default VerificationCodeEmail;