feat: add the contact form
This commit is contained in:
parent
e59ef80353
commit
9aaf452310
7 changed files with 473 additions and 4 deletions
13
.env.example
13
.env.example
|
@ -1 +1,12 @@
|
|||
NEXT_PUBLIC_WEBSITE_URL=
|
||||
NEXT_PUBLIC_WEBSITE_URL=
|
||||
|
||||
EMAIL_SERVER=
|
||||
EMAIL_PORT=
|
||||
EMAIL_SECURE=
|
||||
EMAIL_USER=
|
||||
EMAIL_PASS=
|
||||
EMAIL_CONTACT_ADDRESS=
|
||||
EMAIL_CONTACT_DEST=
|
||||
|
||||
NEXT_PUBLIC_HCAPTCHA_SITE_KEY=
|
||||
HCAPTCHA_SECRET=
|
37
app/(root)/contact/layout.jsx
Normal file
37
app/(root)/contact/layout.jsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
export const metadata = {
|
||||
title: "Contact Us - MERNMail",
|
||||
description:
|
||||
"Have questions about MERNMail? Need technical support? Visit our Contact Us page to find various ways to get in touch with our team, including email, and our official support channel.",
|
||||
openGraph: {
|
||||
title: "Contact Us - MERNMail",
|
||||
description:
|
||||
"Have questions about MERNMail? Need technical support? Visit our Contact Us page to find various ways to get in touch with our team, including email, and our official support channel.",
|
||||
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/contact`,
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`,
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
alt: "Contact Us - MERNMail"
|
||||
}
|
||||
]
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@MERNMail",
|
||||
title: "Contact Us - MERNMail",
|
||||
description:
|
||||
"Have questions about MERNMail? Need technical support? Visit our Contact Us page to find various ways to get in touch with our team, including email, and our official support channel.",
|
||||
images: [
|
||||
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`
|
||||
],
|
||||
creator: "@MERNMail"
|
||||
}
|
||||
};
|
||||
|
||||
const ContactLayout = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ContactLayout;
|
138
app/(root)/contact/page.jsx
Normal file
138
app/(root)/contact/page.jsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import { Send } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { emails } from "@/constants";
|
||||
import { isEmail } from "validator";
|
||||
import SocialIcons from "@/components/SocialIcons";
|
||||
|
||||
function Contact() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [hCaptchaToken, setHCaptchaToken] = useState(null);
|
||||
const captchaRef = useRef();
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-lg mx-auto px-4 py-6 gap-2 flex flex-col">
|
||||
<h1 className="text-center text-4xl md:text-6xl py-12 md:py-16 font-bold">
|
||||
Contact us
|
||||
</h1>
|
||||
<div className="flex flex-col md:flex-row mb-6">
|
||||
<form
|
||||
className="border-border border bg-card text-card-foreground rounded-lg mx-2 md:mx-4 max-md:mb-8 p-6 w-full self-center"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
captchaToken: hCaptchaToken,
|
||||
name: name,
|
||||
email: email,
|
||||
message: message
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status == 200) {
|
||||
setStatus("Your message has been sent.");
|
||||
} else {
|
||||
setStatus("Uh oh! Something went wrong.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus("Uh oh! Something went wrong.");
|
||||
}
|
||||
setHCaptchaToken(null);
|
||||
captchaRef.current.resetCaptcha();
|
||||
setName("");
|
||||
setEmail("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
<label htmlFor="contact-name" className="block mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
value={name}
|
||||
id="contact-name"
|
||||
className="block mb-4 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
|
||||
/>
|
||||
<label htmlFor="contact-email" className="block mb-2">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
value={email}
|
||||
id="contact-email"
|
||||
className="block mb-4 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
|
||||
/>
|
||||
<label htmlFor="contact-message" className="block mb-2">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
onChange={(e) => {
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
value={message}
|
||||
id="contact-message"
|
||||
className="block h-44 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
|
||||
/>
|
||||
<div className="my-5">
|
||||
<HCaptcha
|
||||
ref={captchaRef}
|
||||
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY}
|
||||
onVerify={(token) => {
|
||||
setHCaptchaToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{status ? <p className="my-5">{status}</p> : ""}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-primary-foreground p-2 rounded-md text-center w-full hover:bg-primary/75 disabled:bg-primary/50 transition-colors"
|
||||
disabled={!name || !email || !isEmail(email) || !message}
|
||||
>
|
||||
<Send className="inline align-top mr-2" />
|
||||
<span className="align-middle">Send</span>
|
||||
</button>
|
||||
</form>
|
||||
<div className="border-border border bg-card text-card-foreground rounded-lg mx-2 md:mx-4 px-9 py-6 w-full md:max-w-96 self-center">
|
||||
<ul className="mb-6">
|
||||
{emails.map((email, index) => (
|
||||
<li key={index} className="flex items-center my-4">
|
||||
<email.icon className="mr-2 shrink-0" size={24} />
|
||||
<span>
|
||||
<a
|
||||
href={email.url}
|
||||
title={`Send an email to ${email.email}`}
|
||||
className="text-muted-foreground hover:text-accent-foreground transition-colors duration-200"
|
||||
>
|
||||
{email.email}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="text-center space-x-3 py-4 border-border border-t border-b">
|
||||
<SocialIcons />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Contact;
|
195
app/api/contact/route.js
Normal file
195
app/api/contact/route.js
Normal file
|
@ -0,0 +1,195 @@
|
|||
import nodemailer from "nodemailer";
|
||||
import { NextResponse } from "next/server";
|
||||
import dns from "dns/promises";
|
||||
import { isEmail, escape } from "validator";
|
||||
|
||||
const CONTACT_MESSAGE_FIELDS = {
|
||||
name: "Name",
|
||||
email: "Email",
|
||||
message: "Message"
|
||||
};
|
||||
|
||||
export const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_SERVER,
|
||||
port: parseInt(process.env.EMAIL_PORT ? process.env.EMAIL_PORT : "25"),
|
||||
secure: Boolean(process.env.EMAIL_SECURE ? process.env.EMAIL_SECURE : false),
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS
|
||||
}
|
||||
});
|
||||
|
||||
const generateEmailContent = (data) => {
|
||||
const stringData = Object.entries(data).reduce(
|
||||
(str, [key, val]) =>
|
||||
str +
|
||||
(key == "captchaToken"
|
||||
? ""
|
||||
: `${CONTACT_MESSAGE_FIELDS[key] || key}: ${val.replace(/\n/g, "\n")} \n\n`),
|
||||
""
|
||||
);
|
||||
|
||||
const htmlData = Object.entries(data).reduce(
|
||||
(str, [key, val]) =>
|
||||
str +
|
||||
(key == "captchaToken"
|
||||
? ""
|
||||
: `<h3 class="form-heading">${escape(
|
||||
CONTACT_MESSAGE_FIELDS[key] || key
|
||||
)}</h3><p class="form-answer">${escape(val).replace(
|
||||
/\n/g,
|
||||
"<br/>"
|
||||
)}</p>`),
|
||||
""
|
||||
);
|
||||
|
||||
return {
|
||||
text: stringData,
|
||||
html: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Contact Email</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<style type="text/css">
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse !important; }
|
||||
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||
@media screen and (max-width: 525px) {
|
||||
.wrapper { width: 100% !important; max-width: 100% !important; }
|
||||
.responsive-table { width: 100% !important; }
|
||||
.padding { padding: 10px 5% 15px 5% !important; }
|
||||
.section-padding { padding: 0 15px 50px 15px !important; }
|
||||
}
|
||||
.form-container { margin-bottom: 24px; padding: 20px; border: 1px dashed #ccc; }
|
||||
.form-heading { color: #2a2a2a; font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif; font-weight: 400; text-align: left; line-height: 20px; font-size: 18px; margin: 0 0 8px; padding: 0; }
|
||||
.form-answer { color: #2a2a2a; font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif; font-weight: 300; text-align: left; line-height: 20px; font-size: 16px; margin: 0 0 24px; padding: 0; }
|
||||
div[style*="margin: 16px 0;"] { margin: 0 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0 !important; padding: 0 !important; background: #fff">
|
||||
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;"></div>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 10px 15px 30px 15px" class="section-padding">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 500px" class="responsive-table">
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 0 0 0; font-size: 16px; line-height: 25px; color: #232323;" class="padding message-content">
|
||||
<h2>New Contact Message</h2>
|
||||
<div class="form-container">${htmlData}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
};
|
||||
};
|
||||
|
||||
export async function POST(req) {
|
||||
if (req.method !== "POST") {
|
||||
return NextResponse.json(
|
||||
{ message: "Method Not Allowed" },
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await req.json();
|
||||
console.log(data);
|
||||
|
||||
// Verify hCaptcha token
|
||||
const hcaptchaResponse = await fetch(
|
||||
`https://api.hcaptcha.com/siteverify`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: `secret=${process.env.HCAPTCHA_SECRET}&response=${data.captchaToken}`
|
||||
}
|
||||
);
|
||||
|
||||
const hcaptchaData = await hcaptchaResponse.json();
|
||||
|
||||
if (!hcaptchaData.success) {
|
||||
return NextResponse.json(
|
||||
{ message: "Captcha verification failed." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check email address
|
||||
if (!isEmail(data.email)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid email address" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check email host
|
||||
const emailDomainMatch = data.email.match(/@([^@]+)/);
|
||||
const emailDomain = emailDomainMatch ? emailDomainMatch[1] : "";
|
||||
let isEmailHostValid = false;
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(emailDomain);
|
||||
if (mxRecords.length > 0) {
|
||||
for (let i = 0; i < mxRecords.length; i++) {
|
||||
try {
|
||||
const aRecords = await dns.resolve4(mxRecords[i].exchange);
|
||||
if (aRecords.length > 0) {
|
||||
isEmailHostValid = true;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
try {
|
||||
const aaaaRecords = await dns.resolve6(mxRecords[i].exchange);
|
||||
if (aaaaRecords.length > 0) {
|
||||
isEmailHostValid = true;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
} catch (err) {}
|
||||
if (!isEmailHostValid) {
|
||||
return NextResponse.json(
|
||||
{ message: "Email domain is misconfigured" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_CONTACT_ADDRESS,
|
||||
to: process.env.EMAIL_CONTACT_DEST,
|
||||
subject: "Contact Email",
|
||||
...generateEmailContent(data)
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Email sent successfully" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error sending email:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,13 @@
|
|||
import { Bird, MonitorSmartphone, Server, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Bird,
|
||||
Bug,
|
||||
Mail,
|
||||
MonitorSmartphone,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
WebhookIcon
|
||||
} from "lucide-react";
|
||||
|
||||
export const headerLinks = {
|
||||
nav: [
|
||||
|
@ -111,3 +120,26 @@ export const footerLinks = {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const emails = [
|
||||
{
|
||||
icon: Mail,
|
||||
email: "support@mernmail.org",
|
||||
url: "mailto:support@mernmail.org"
|
||||
},
|
||||
{
|
||||
icon: WebhookIcon,
|
||||
email: "webmaster@mernmail.org",
|
||||
url: "mailto:webmaster@mernmail.org"
|
||||
},
|
||||
{
|
||||
icon: Bug,
|
||||
email: "bugreports@mernmail.org",
|
||||
url: "mailto:bugreports@mernmail.org"
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
email: "vulnerability-reports@mernmail.org",
|
||||
url: "mailto:vulnerability-reports@mernmail.org"
|
||||
}
|
||||
];
|
||||
|
|
55
package-lock.json
generated
55
package-lock.json
generated
|
@ -8,17 +8,20 @@
|
|||
"name": "mernmail-website",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hcaptcha/react-hcaptcha": "^1.11.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"globby": "^14.0.2",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^15.0.2",
|
||||
"next-themes": "^0.4.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"prismjs": "^1.29.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-prism": "^2.3.3"
|
||||
"rehype-prism": "^2.3.3",
|
||||
"validator": "^13.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
|
@ -72,6 +75,17 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
|
||||
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@commitlint/cli": {
|
||||
"version": "19.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.5.0.tgz",
|
||||
|
@ -530,6 +544,24 @@
|
|||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hcaptcha/loader": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@hcaptcha/loader/-/loader-1.2.4.tgz",
|
||||
"integrity": "sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw=="
|
||||
},
|
||||
"node_modules/@hcaptcha/react-hcaptcha": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.11.0.tgz",
|
||||
"integrity": "sha512-UKHtzzVMHLTGwab5pgV96UbcXdyh5Qyq8E0G5DTyXq8txMvuDx7rSyC+BneOjWVW0a7O9VuZmkg/EznVLRE45g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.9",
|
||||
"@hcaptcha/loader": "^1.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
@ -8065,6 +8097,14 @@
|
|||
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.9.16",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz",
|
||||
"integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
@ -8977,6 +9017,11 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
|
||||
|
@ -10507,6 +10552,14 @@
|
|||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.12.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
|
|
@ -12,17 +12,20 @@
|
|||
"cz": "cz"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcaptcha/react-hcaptcha": "^1.11.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"globby": "^14.0.2",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "^15.0.2",
|
||||
"next-themes": "^0.4.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"prismjs": "^1.29.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-prism": "^2.3.3"
|
||||
"rehype-prism": "^2.3.3",
|
||||
"validator": "^13.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
|
|
Loading…
Reference in a new issue