use maizzle for email templating, starting with reset password email

This commit is contained in:
m5r 2021-10-26 23:34:21 +02:00
parent 3f279634b6
commit 514dae3ebb
25 changed files with 6069 additions and 508 deletions

View File

@ -84,7 +84,7 @@ export function AuthForm<S extends z.ZodType<any, any>>({
type="submit"
disabled={ctx.formState.isSubmitting}
className={clsx(
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
{
"bg-primary-400 cursor-not-allowed": ctx.formState.isSubmitting,
"bg-primary-600 hover:bg-primary-700": !ctx.formState.isSubmitting,

View File

@ -4,7 +4,7 @@ import db, { User } from "../../../db";
import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer";
import { ForgotPassword } from "../validations";
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 4;
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
const user = await db.user.findFirst({ where: { email: email.toLowerCase() } });
@ -36,5 +36,11 @@ async function updatePassword(user: User | null) {
sentTo: user.email,
},
});
await forgotPasswordMailer({ to: user.email, token }).send();
await (
await forgotPasswordMailer({
to: user.email,
token,
userName: user.fullName,
})
).send();
}

View File

@ -1,26 +1,27 @@
import type { BlitzPage } from "blitz";
import { Routes, useMutation } from "blitz";
import BaseLayout from "../../core/layouts/base-layout";
import BaseLayout from "app/core/layouts/base-layout";
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
import { LabeledTextField } from "../components/labeled-text-field";
import { ForgotPassword } from "../validations";
import forgotPassword from "../../auth/mutations/forgot-password";
import forgotPassword from "app/auth/mutations/forgot-password";
const ForgotPasswordPage: BlitzPage = () => {
const [forgotPasswordMutation, { isSuccess }] = useMutation(forgotPassword);
const [forgotPasswordMutation, { isSuccess, reset }] = useMutation(forgotPassword);
return (
<Form
texts={{
title: isSuccess ? "Request submitted" : "Forgot your password?",
subtitle: "",
submit: isSuccess ? "" : "Send reset password instructions",
submit: isSuccess ? "" : "Send reset password link",
}}
schema={ForgotPassword}
initialValues={{ email: "" }}
onSubmit={async (values) => {
try {
reset();
await forgotPasswordMutation(values);
} catch (error: any) {
return {
@ -30,7 +31,9 @@ const ForgotPasswordPage: BlitzPage = () => {
}}
>
{isSuccess ? (
<p>If your email is in our system, you will receive instructions to reset your password shortly.</p>
<p className="text-center">
If your email is in our system, you will receive instructions to reset your password shortly.
</p>
) : (
<LabeledTextField name="email" label="Email" />
)}
@ -40,6 +43,6 @@ const ForgotPasswordPage: BlitzPage = () => {
ForgotPasswordPage.redirectAuthenticatedTo = Routes.Messages();
ForgotPasswordPage.getLayout = (page) => <BaseLayout title="Forgot Your Password?">{page}</BaseLayout>;
ForgotPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
export default ForgotPasswordPage;

View File

@ -56,7 +56,7 @@ const ResetPasswordPage: BlitzPage = () => {
ResetPasswordPage.redirectAuthenticatedTo = Routes.Messages();
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset Your Password">{page}</BaseLayout>;
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
export const getServerSideProps: GetServerSideProps = async (context) => {
if (!context.query.token) {

View File

@ -55,6 +55,6 @@ const SignIn: BlitzPage = () => {
SignIn.redirectAuthenticatedTo = Routes.Messages();
SignIn.getLayout = (page) => <BaseLayout title="Sign In">{page}</BaseLayout>;
SignIn.getLayout = (page) => <BaseLayout title="Sign in">{page}</BaseLayout>;
export default SignIn;

View File

@ -55,6 +55,6 @@ SignUp.redirectAuthenticatedTo = ({ session }) => {
return Routes.Messages();
};
SignUp.getLayout = (page) => <BaseLayout title="Sign Up">{page}</BaseLayout>;
SignUp.getLayout = (page) => <BaseLayout title="Sign up">{page}</BaseLayout>;
export default SignUp;

View File

@ -10,7 +10,7 @@ const BaseLayout = ({ title, children }: LayoutProps) => {
return (
<>
<Head>
<title>{title || "shellphone.app"}</title>
<title>{title ? `${title} | Shellphone` : "Shellphone"}</title>
<link rel="icon" href="/favicon.ico" />
</Head>

View File

@ -15,12 +15,14 @@ const notifyEmailChangeQueue = Queue<Payload>("api/queue/notify-email-change", a
sendEmail({
recipients: [oldEmail],
subject: "",
body: "",
text: "",
html: "",
}),
sendEmail({
recipients: [newEmail],
subject: "",
body: "",
text: "",
html: "",
}),
]);
});

View File

@ -78,7 +78,8 @@ export const subscriptionCreatedQueue = Queue<Payload>("api/queue/subscription-c
if (isReturningSubscriber) {
sendEmail({
subject: "Welcome back to Shellphone",
body: "Welcome back to Shellphone",
text: "Welcome back to Shellphone",
html: "Welcome back to Shellphone",
recipients: [email],
}).catch((error) => {
logger.error(error);
@ -89,7 +90,8 @@ export const subscriptionCreatedQueue = Queue<Payload>("api/queue/subscription-c
sendEmail({
subject: "Welcome to Shellphone",
body: `Welcome to Shellphone`,
text: `Welcome to Shellphone`,
html: `Welcome to Shellphone`,
recipients: [email],
}).catch((error) => {
logger.error(error);

View File

@ -61,7 +61,8 @@ export const subscriptionUpdatedQueue = Queue<Payload>("api/queue/subscription-u
sendEmail({
subject: "Thanks for your purchase",
body: "Thanks for your purchase",
text: "Thanks for your purchase",
html: "Thanks for your purchase",
recipients: [email],
}).catch((error) => {
logger.error(error);

View File

@ -31,7 +31,8 @@ export default async function backup(schedule: "daily" | "weekly" | "monthly") {
gzipChild.on("exit", (code) => {
if (code !== 0) {
return sendEmail({
body: `${schedule} backup failed: gzip: Bad exit code (${code})`,
text: `${schedule} backup failed: gzip: Bad exit code (${code})`,
html: `${schedule} backup failed: gzip: Bad exit code (${code})`,
subject: `${schedule} backup failed: gzip: Bad exit code (${code})`,
recipients: ["error@shellphone.app"],
});
@ -44,7 +45,8 @@ export default async function backup(schedule: "daily" | "weekly" | "monthly") {
if (code !== 0) {
console.log("pg_dump failed, upload aborted");
return sendEmail({
body: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
text: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
html: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
subject: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
recipients: ["error@shellphone.app"],
});
@ -67,7 +69,8 @@ export default async function backup(schedule: "daily" | "weekly" | "monthly") {
.catch((error) => {
logger.error(error);
return sendEmail({
body: `${schedule} backup failed: ${error}`,
text: `${schedule} backup failed: ${error}`,
html: `${schedule} backup failed: ${error}`,
subject: `${schedule} backup failed: ${error}`,
recipients: ["error@shellphone.app"],
});

View File

@ -11,19 +11,24 @@ const credentials = new Credentials({
const ses = new SES({ region: serverRuntimeConfig.awsSes.awsRegion, credentials });
type SendEmailParams = {
body: string;
text: string;
html: string;
subject: string;
recipients: string[];
};
export async function sendEmail({ body, subject, recipients }: SendEmailParams) {
export async function sendEmail({ text, html, subject, recipients }: SendEmailParams) {
const request: SendEmailRequest = {
Destination: { ToAddresses: recipients },
Message: {
Body: {
Text: {
Charset: "UTF-8",
Data: body,
Data: text,
},
Html: {
Charset: "UTF-8",
Data: html,
},
},
Subject: {

View File

@ -0,0 +1,16 @@
<tr>
<td>
<table align="center" class="email-footer w-570 mx-auto text-center sm:w-full">
<tr>
<td align="center" class="content-cell p-45 text-base">
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
&copy; {{ page.year }} {{ page.company.product }}. All rights reserved.
</p>
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
{{ page.company.name }} {{{ page.company.address }}}
</p>
</td>
</tr>
</table>
</td>
</tr>

View File

@ -0,0 +1,11 @@
<tr>
<td align="center" class="email-masthead">
<a
href="https://www.shellphone.app"
class="email-masthead_name text-base font-bold no-underline text-gray-postmark-light"
style="text-shadow: 0 1px 0 #ffffff"
>
<img width="64px" src="https://www.shellphone.app/shellphone.png" alt="Shellphone logo" />
</a>
</td>
</tr>

View File

@ -0,0 +1,32 @@
.button {
@apply inline-block text-white no-underline;
background-color: #3869d4;
border-top: 10px solid #3869d4;
border-right: 18px solid #3869d4;
border-bottom: 10px solid #3869d4;
border-left: 18px solid #3869d4;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
}
.button--green {
background-color: #22bc66;
border-top: 10px solid #22bc66;
border-right: 18px solid #22bc66;
border-bottom: 10px solid #22bc66;
border-left: 18px solid #22bc66;
}
.button--red {
background-color: #ff6136;
border-top: 10px solid #ff6136;
border-right: 18px solid #ff6136;
border-bottom: 10px solid #ff6136;
border-left: 18px solid #ff6136;
}
@screen sm {
.button {
@apply w-full text-center !important;
}
}

View File

@ -0,0 +1,65 @@
@import "buttons";
.purchase_heading {
border-bottom-width: 1px;
border-bottom-color: #eaeaec;
border-bottom-style: solid;
}
.purchase_heading p {
@apply text-xxs leading-24 m-0;
color: #85878e;
}
.purchase_footer {
@apply pt-16 text-base align-middle;
border-top-width: 1px;
border-top-color: #eaeaec;
border-top-style: solid;
}
.body-sub {
@apply mt-25 pt-25 border-t;
border-top-color: #eaeaec;
border-top-style: solid;
}
.discount {
@apply w-full p-24 bg-gray-postmark-lightest;
border: 2px dashed #cbcccf;
}
.email-masthead {
@apply py-24 text-base text-center;
}
@screen dark {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
@apply bg-gray-postmark-darker text-white !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
@apply text-white !important;
}
.attributes_content,
.discount {
@apply bg-gray-postmark-darkest !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}

10
mailers/custom/reset.css Normal file
View File

@ -0,0 +1,10 @@
body {
@apply m-0 p-0 w-full;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
img {
border: 0;
@apply max-w-full leading-full align-middle;
}

View File

@ -0,0 +1,3 @@
.mso-leading-exactly {
mso-line-height-rule: exactly;
}

View File

@ -1,41 +1,39 @@
/* TODO - You need to add a mailer integration in `integrations/` and import here.
*
* The integration file can be very simple. Instantiate the email client
* and then export it. That way you can import here and anywhere else
* and use it straight away.
*/
import previewEmail from "preview-email";
import { sendEmail } from "integrations/aws-ses";
import { render, plaintext } from "./renderer";
type ResetPasswordMailer = {
to: string;
token: string;
userName: string;
};
export function forgotPasswordMailer({ to, token }: ResetPasswordMailer) {
export async function forgotPasswordMailer({ to, token, userName }: ResetPasswordMailer) {
// In production, set APP_ORIGIN to your production server origin
const origin = process.env.APP_ORIGIN || process.env.BLITZ_DEV_SERVER_ORIGIN;
const resetUrl = `${origin}/reset-password?token=${token}`;
const [html, text] = await Promise.all([
render("forgot-password", { action_url: resetUrl, name: userName }),
plaintext("forgot-password", { action_url: resetUrl, name: userName }),
]);
const msg = {
from: "TODO@example.com",
from: "mokhtar@shellphone.app",
to,
subject: "Your Password Reset Instructions",
html: `
<h1>Reset Your Password</h1>
<h3>NOTE: You must set up a production email integration in mailers/forgotPasswordMailer.ts</h3>
<a href="${resetUrl}">
Click here to set a new password
</a>
`,
subject: "Reset your password",
html,
text,
};
return {
async send() {
if (process.env.NODE_ENV === "production") {
// TODO - send the production email, like this:
// await postmark.sendEmail(msg)
throw new Error("No production email implementation in mailers/forgotPasswordMailer");
await sendEmail({
recipients: [msg.to],
subject: msg.subject,
html: msg.html,
text: msg.text,
});
} else {
// Preview email in the browser
await previewEmail(msg);

75
mailers/layouts/main.html Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE {{{ page.doctype || 'html' }}}>
<html lang="{{ page.language || 'en' }}" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="{{ page.charset || 'utf-8' }}" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,
th,
div,
p,
a,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Segoe UI", sans-serif;
mso-line-height-rule: exactly;
}
</style>
<![endif]-->
<if condition="page.title">
<title>{{{ page.title }}}</title>
</if>
<if condition="page.googleFonts">
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?{{{ page.googleFonts }}}&display=swap"
rel="stylesheet"
media="screen"
/>
</if>
<if condition="page.css">
<style>
{{{ page.css }}}
</style>
</if>
<block name="head"></block>
</head>
<body class="{{ page.bodyClass }}">
<if condition="page.preheader">
<div class="hidden">
{{{ page.preheader }}}&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&zwnj; &#160;&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847;
&#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &#847; &zwnj;
&#160;&#847; &#847; &#847; &#847; &#847;
</div>
</if>
<div
role="article"
aria-roledescription="email"
aria-label="{{{ page.title || '' }}}"
lang="{{ page.language || 'en' }}"
>
<block name="template"></block>
</div>
</body>
</html>

225
mailers/renderer.ts Normal file
View File

@ -0,0 +1,225 @@
import fs from "fs";
import path from "path";
// @ts-ignore
import Maizzle from "@maizzle/framework";
export async function render(templateName: string, locals: Record<string, string> = {}) {
const { template, options } = getMaizzleParams(templateName, locals);
const { html } = await Maizzle.render(template, options);
return html;
}
export async function plaintext(templateName: string, locals: Record<string, string> = {}) {
const { template, options } = getMaizzleParams(templateName, locals);
const { plaintext } = await Maizzle.plaintext(template, options);
return plaintext;
}
function getMaizzleParams(templateName: string, locals: Record<string, string>) {
const template = fs
.readFileSync(path.resolve(process.cwd(), "./mailers/templates", `${templateName}.html`))
.toString();
const tailwindCss = fs.readFileSync(path.resolve(process.cwd(), "./mailers/tailwind.css")).toString();
const options = {
tailwind: {
css: tailwindCss,
config: {
mode: "jit",
theme: {
screens: {
sm: { max: "600px" },
dark: { raw: "(prefers-color-scheme: dark)" },
},
extend: {
colors: {
gray: {
"postmark-lightest": "#F4F4F7",
"postmark-lighter": "#F2F4F6",
"postmark-light": "#A8AAAF",
"postmark-dark": "#51545E",
"postmark-darker": "#333333",
"postmark-darkest": "#222222",
"postmark-meta": "#85878E",
},
blue: {
postmark: "#3869D4",
},
},
spacing: {
screen: "100vw",
full: "100%",
px: "1px",
0: "0",
2: "2px",
3: "3px",
4: "4px",
5: "5px",
6: "6px",
7: "7px",
8: "8px",
9: "9px",
10: "10px",
11: "11px",
12: "12px",
14: "14px",
16: "16px",
20: "20px",
21: "21px",
24: "24px",
25: "25px",
28: "28px",
30: "30px",
32: "32px",
35: "35px",
36: "36px",
40: "40px",
44: "44px",
45: "45px",
48: "48px",
52: "52px",
56: "56px",
60: "60px",
64: "64px",
72: "72px",
80: "80px",
96: "96px",
570: "570px",
600: "600px",
"1/2": "50%",
"1/3": "33.333333%",
"2/3": "66.666667%",
"1/4": "25%",
"2/4": "50%",
"3/4": "75%",
"1/5": "20%",
"2/5": "40%",
"3/5": "60%",
"4/5": "80%",
"1/6": "16.666667%",
"2/6": "33.333333%",
"3/6": "50%",
"4/6": "66.666667%",
"5/6": "83.333333%",
"1/12": "8.333333%",
"2/12": "16.666667%",
"3/12": "25%",
"4/12": "33.333333%",
"5/12": "41.666667%",
"6/12": "50%",
"7/12": "58.333333%",
"8/12": "66.666667%",
"9/12": "75%",
"10/12": "83.333333%",
"11/12": "91.666667%",
},
borderRadius: {
none: "0px",
sm: "2px",
DEFAULT: "4px",
md: "6px",
lg: "8px",
xl: "12px",
"2xl": "16px",
"3xl": "24px",
full: "9999px",
},
fontFamily: {
sans: ['"Nunito Sans"', "-apple-system", '"Segoe UI"', "sans-serif"],
serif: ["Constantia", "Georgia", "serif"],
mono: ["Menlo", "Consolas", "monospace"],
},
fontSize: {
0: "0",
xxs: "12px",
xs: "13px",
sm: "14px",
base: "16px",
lg: "18px",
xl: "20px",
"2xl": "24px",
"3xl": "30px",
"4xl": "36px",
"5xl": "48px",
"6xl": "60px",
"7xl": "72px",
"8xl": "96px",
"9xl": "128px",
},
inset: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
letterSpacing: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
lineHeight: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
maxHeight: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
maxWidth: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
xs: "160px",
sm: "192px",
md: "224px",
lg: "256px",
xl: "288px",
"2xl": "336px",
"3xl": "384px",
"4xl": "448px",
"5xl": "512px",
"6xl": "576px",
"7xl": "640px",
}),
minHeight: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
minWidth: (theme: TailwindThemeHelper) => ({
...theme("spacing"),
}),
},
},
corePlugins: {
animation: false,
backgroundOpacity: false,
borderOpacity: false,
divideOpacity: false,
placeholderOpacity: false,
textOpacity: false,
},
},
},
maizzle: {
build: {
posthtml: {
expressions: {
locals,
},
},
},
company: {
name: "Capsule Corp. Dev Pte. Ltd.",
address: `
<br>39 Robinson Rd, #11-01
<br>Singapore 068911
`,
product: "Shellphone",
},
googleFonts: "family=Nunito+Sans:wght@400;700",
year: () => new Date().getFullYear(),
inlineCSS: true,
prettify: true,
removeUnusedCSS: true,
},
};
return {
template,
options,
};
}
type TailwindThemeHelper = (str: string) => {};

18
mailers/tailwind.css Normal file
View File

@ -0,0 +1,18 @@
/* Your custom CSS resets for email */
@import "mailers/custom/reset";
/* Tailwind components that are generated by plugins */
@import "tailwindcss/components";
/**
* @import here any custom components - classes that you'd want loaded
* before the Tailwind utilities, so that the utilities could still
* override them.
*/
@import "mailers/custom/postmark";
/* Tailwind utility classes */
@import "tailwindcss/utilities";
/* Your custom utility classes */
@import "mailers/custom/utilities";

View File

@ -0,0 +1,92 @@
---
bodyClass: bg-gray-postmark-lighter
---
<extends src="mailers/layouts/main.html">
<block name="template">
<table class="email-wrapper w-full bg-gray-postmark-lighter font-sans">
<tr>
<td align="center">
<table class="email-content w-full">
<component src="mailers/components/header.html"></component>
<tr>
<td class="email-body w-full">
<table align="center" class="email-body_inner w-570 bg-white mx-auto sm:w-full">
<tr>
<td class="px-45 py-24">
<div class="text-base">
<h1 class="mt-0 text-2xl font-bold text-left text-gray-postmark-darker">
Hi {{name}},
</h1>
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
You recently requested to reset your password for your
{{page.company.product}} account. Use the button below to reset it.
<strong
>This password reset is only valid for the next 24
hours.</strong
>
</p>
<table align="center" class="w-full text-center my-30 mx-auto">
<tr>
<td align="center">
<table class="w-full">
<tr>
<td align="center" class="text-base">
<a
href="{{action_url}}"
class="button button--green"
target="_blank"
>Reset your password</a
>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
If you did not request a password reset, you can safely ignore this
email.
</p>
<table class="body-sub">
<tr>
<td>
<p
class="
mt-6
mb-20
text-xs
leading-24
text-gray-postmark-dark
"
>
If you're having trouble with the button above, copy and
paste the URL below into your web browser.
</p>
<p
class="
mt-6
mb-20
text-xs
leading-24
text-gray-postmark-dark
"
>
{{action_url}}
</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<!--<component src="mailers/components/footer.html"></component>-->
</table>
</td>
</tr>
</table>
</block>
</extends>

5919
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,7 @@
"@devoxa/paddle-sdk": "0.2.1",
"@headlessui/react": "1.4.1",
"@hookform/resolvers": "2.8.2",
"@maizzle/framework": "3.7.2",
"@panelbear/panelbear-js": "1.3.2",
"@prisma/client": "3.2.1",
"@react-aria/interactions": "3.6.0",