mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-05 21:26:05 +02:00
✨ Update notification flow (#548)
This commit is contained in:
parent
cb1fb23b19
commit
39a07558ee
41 changed files with 930 additions and 520 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 |
16
apps/web/src/components/layouts/auth-layout.tsx
Normal file
16
apps/web/src/components/layouts/auth-layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
11
apps/web/src/components/spinner.tsx
Normal file
11
apps/web/src/components/spinner.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
126
apps/web/src/pages/auth/enable-notifications.tsx
Normal file
126
apps/web/src/pages/auth/enable-notifications.tsx
Normal 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;
|
23
apps/web/src/pages/auth/invalid-token.tsx
Normal file
23
apps/web/src/pages/auth/invalid-token.tsx
Normal 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;
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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>>(
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
@ -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",
|
||||
])})`;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'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 →</Link>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationEmail;
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
3
packages/emails/src/templates/components/utils.ts
Normal file
3
packages/emails/src/templates/components/utils.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const removeProtocalFromUrl = (url: string) => {
|
||||
return url.replace(/(^\w+:|^)\/\//, "");
|
||||
};
|
|
@ -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>"{title}"</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 →
|
||||
</Button>
|
||||
</Section>
|
||||
</Container>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuestVerifyEmail;
|
55
packages/emails/src/templates/login.tsx
Normal file
55
packages/emails/src/templates/login.tsx
Normal 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're receiving this email because a request was made to login
|
||||
to{" "}
|
||||
<Link href={absoluteUrl()}>
|
||||
{removeProtocalFromUrl(absoluteUrl())}
|
||||
</Link>
|
||||
. If this wasn'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;
|
|
@ -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 →</Button>
|
||||
</Section>
|
||||
<Text>
|
||||
<Link href={unsubscribeUrl}>
|
||||
Stop receiving notifications for this poll.
|
||||
</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
</NotificationEmail>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 →</Button>
|
||||
</Section>
|
||||
<Text>
|
||||
<Link href={unsubscribeUrl}>
|
||||
Stop receiving notifications for this poll.
|
||||
</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
</NotificationEmail>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 →
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</NewPollBaseEmail>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewPollVerificationEmail;
|
|
@ -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'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 →
|
||||
</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 →</Link>
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
|
47
packages/emails/src/templates/register.tsx
Normal file
47
packages/emails/src/templates/register.tsx
Normal 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'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;
|
51
packages/emails/src/templates/turn-on-notifications.tsx
Normal file
51
packages/emails/src/templates/turn-on-notifications.tsx
Normal 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 →
|
||||
</Button>
|
||||
</Section>
|
||||
<SmallText>The link will expire in 15 minutes.</SmallText>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnableNotificationsEmail;
|
|
@ -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;
|
Loading…
Add table
Reference in a new issue