diff --git a/waybionic/.gitignore b/waybionic/.gitignore index 5ef6a52..2d47273 100644 --- a/waybionic/.gitignore +++ b/waybionic/.gitignore @@ -30,7 +30,7 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files .env* # vercel diff --git a/waybionic/package-lock.json b/waybionic/package-lock.json index 3e0ae6c..605be6f 100644 --- a/waybionic/package-lock.json +++ b/waybionic/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "next": "^15.4.10", + "nodemailer": "^7.0.12", "react": "^19.0.0", "react-dom": "^19.0.0", "react-responsive-carousel": "^3.2.23" @@ -1357,6 +1358,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/waybionic/package.json b/waybionic/package.json index 42acacf..c4e1683 100644 --- a/waybionic/package.json +++ b/waybionic/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "next": "^15.4.10", + "nodemailer": "^7.0.12", "react": "^19.0.0", "react-dom": "^19.0.0", "react-responsive-carousel": "^3.2.23" diff --git a/waybionic/src/app/api/contact/route.ts b/waybionic/src/app/api/contact/route.ts new file mode 100644 index 0000000..d1eda41 --- /dev/null +++ b/waybionic/src/app/api/contact/route.ts @@ -0,0 +1,271 @@ +import { NextResponse } from "next/server"; +import nodemailer from "nodemailer"; + +export const runtime = "nodejs"; + +// Gets all the information from the form, and puts it in a pyaload +type ContactPayload = { + firstName?: string; + lastName?: string; + fullName?: string; + subject?: string; + email?: string; + message?: string; + typeOfContact?: string; + yearOfStudy?: string; + fieldOfStudy?: string; + clubInfo?: string; + businessInfo?: string; + professionInfo?: string; + sponsorshipInfo?: string; + partnershipInfo?: string; + professorInfo?: string; + otherInfo?: string; +}; + +// Regex for checking email +const emailPattern = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +// Remove standard characters w/ internet chracters so that it appears normal in the email +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Removes leading and trailing spaces +function normalize(value?: string) { + return typeof value === "string" ? value.trim() : ""; +} + +export async function POST(request: Request) { + // creates an instance of ContactPayload + let payload: ContactPayload; + + // Next.js AI route, read the incoming HTTP body as JSON and convert to ContactPayload + try { + payload = (await request.json()) as ContactPayload; + } catch (error) { + return NextResponse.json( + { error: "Invalid request payload." }, + { status: 400 } + ); + } + + // Getting the values from the form + const firstName = normalize(payload.firstName); + const lastName = normalize(payload.lastName); + const fullName = normalize(payload.fullName) || `${firstName} ${lastName}`.trim(); + const subject = normalize(payload.subject); + const email = normalize(payload.email); + const message = normalize(payload.message); + const typeOfContact = normalize(payload.typeOfContact); + const yearOfStudy = normalize(payload.yearOfStudy); + const fieldOfStudy = normalize(payload.fieldOfStudy); + const clubInfo = normalize(payload.clubInfo); + const businessInfo = normalize(payload.businessInfo); + const professionInfo = normalize(payload.professionInfo); + const sponsorshipInfo = normalize(payload.sponsorshipInfo); + const partnershipInfo = normalize(payload.partnershipInfo); + const professorInfo = normalize(payload.professorInfo); + const otherInfo = normalize(payload.otherInfo); + + // Error checking / form parsing + + if (!firstName || !lastName || !subject || !email) { + return NextResponse.json( + { error: "Please complete all required fields." }, + { status: 400 } + ); + } + + console.log(typeOfContact); + if (typeOfContact === "Student" && (!yearOfStudy || !fieldOfStudy)) { + return NextResponse.json( + { error: "Please complete all required student fields." }, + { status: 400 } + ); + } + + // Error filtering for non-student + const rules: Record = { + "Club": { value: clubInfo, error: "Please complete all required club fields." }, + "Business": { value: businessInfo, error: "Please complete all required business fields." }, + "Profession": { value: professionInfo, error: "Please complete all required profession fields." }, + "Sponsorship": { value: sponsorshipInfo, error: "Please complete all required sponsorship fields." }, + "Partnership": { value: partnershipInfo, error: "Please complete all required partnership fields." }, + "Professor": { value: professorInfo, error: "Please complete all required professor fields." }, + "Other": { value: otherInfo, error: "Please complete all fields." } + }; + + if (typeOfContact !== "Student" && rules[typeOfContact]) { + const { value, error } = rules[typeOfContact]; + + if (!value) { + return NextResponse.json({ error }, { status: 400 }); + } + } + + + /// Email error filtering + if (!emailPattern.test(email)) { + return NextResponse.json( + { error: "Please enter a valid email address." }, + { status: 400 } + ); + } + + const smtpHost = process.env.SMTP_HOST; + const smtpPort = Number(process.env.SMTP_PORT || ""); + const smtpUser = process.env.SMTP_USER; + const smtpPass = process.env.SMTP_PASS; + const smtpSecure = + process.env.SMTP_SECURE === "true" || smtpPort === 465; + + if (!smtpHost || !smtpPort || !smtpUser || !smtpPass) { + console.error("Contact email missing SMTP config.", { + hasHost: Boolean(smtpHost), + hasPort: Number.isFinite(smtpPort) && smtpPort > 0, + hasUser: Boolean(smtpUser), + hasPass: Boolean(smtpPass) + }); + return NextResponse.json( + { error: "Email service is not configured yet." }, + { status: 500 } + ); + } + + const toAddress = process.env.CONTACT_TO || "waybionics@gmail.com"; + const fromAddress = + process.env.CONTACT_FROM || `Waybionic Website <${smtpUser}>`; + + const submittedAt = new Date().toISOString(); + const safeName = escapeHtml(fullName || "Website Visitor"); + const safeSubject = escapeHtml(subject); + const safeEmail = escapeHtml(email); + const safeMessage = escapeHtml(message); + const safeClubInfo = escapeHtml(clubInfo); + const safeBusinessInfo = escapeHtml(businessInfo); + const safeProfessionInfo = escapeHtml(professionInfo); + const safeSponsorshipInfo = escapeHtml(sponsorshipInfo); + const safePartnershipInfo = escapeHtml(partnershipInfo); + const safeProfessorInfo = escapeHtml(professorInfo); + const safeOtherInfo = escapeHtml(otherInfo); + + const messageLinks = { + "Student": safeMessage, + "Club": safeClubInfo, + "Business": safeBusinessInfo, + "Industry Professional": safeProfessionInfo, + "Professor": safeProfessorInfo, + "Partnership": safePartnershipInfo, + "Sponsorship": safeSponsorshipInfo, + "Other": safeOtherInfo + } + + const textBody = [ + "New Contact Request", + "", + `Name: ${fullName || "Website Visitor"}`, + `Type of Contact: ${typeOfContact}`, + ...(typeOfContact === "Student" ? [`Year: ${yearOfStudy}`] : []), + `Email: ${email}`, + `Subject: ${subject}`, + `Submitted: ${submittedAt}`, + "", + "Message:", + messageLinks[typeOfContact] + ].join("\n"); + + const htmlBody = ` +
+

New Contact Request

+ + + + + + + + + + + ${typeOfContact === "Student" ? + ` + + + + + + + + + ` : ""} + + + + + + + + + + + + + +
Name${safeName}
Type of Contact${typeOfContact}
School Year${yearOfStudy}
Major${fieldOfStudy}
Email${safeEmail}
Subject${safeSubject}
Submitted${submittedAt}
+
Message
+
+ ${messageLinks[typeOfContact]} +
+

+ Submitted from the Waybionic website contact form. +

+
+ `; + + try { + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + auth: { + user: smtpUser, + pass: smtpPass + } + }); + + await transporter.sendMail({ + to: toAddress, + from: fromAddress, + replyTo: email, + subject: `Waybionic Contact: ${subject}`, + text: textBody, + html: htmlBody + }); + } catch (error) { + const err = error as Error & { + code?: string; + responseCode?: number; + response?: string; + command?: string; + }; + console.error("Contact email send failed.", { + message: err.message, + code: err.code, + responseCode: err.responseCode, + response: err.response, + command: err.command + }); + return NextResponse.json( + { error: "Unable to send your message right now." }, + { status: 500 } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/waybionic/src/app/contact/components/NonStudent.tsx b/waybionic/src/app/contact/components/NonStudent.tsx new file mode 100644 index 0000000..1a8bfcb --- /dev/null +++ b/waybionic/src/app/contact/components/NonStudent.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import NonStudentMessage from './NonStudentMessage' +import NonStudentInput from './NonStudentInput' +import emailjs from "@emailjs/browser"; + +const NonStudent = ({ typeOfContact, setClubInfo, setBusinessInfo, setProfessionInfo, setSponsorshipInfo, setPartnershipInfo, setOtherInfo, clubInfo, businessInfo, professionInfo, sponsorshipInfo, partnershipInfo, otherInfo, professorInfo, setProfessorInfo }) => { + const getStateValue = () => { + switch(typeOfContact) { + case "Club": + return clubInfo + case "Business": + return businessInfo + case "Industry Professional": + return professionInfo + case "Sponsorship": + return sponsorshipInfo + case "Partnership": + return partnershipInfo + case "Professor": + return professorInfo + default: + return otherInfo + } + } + + const setStateValue = (value: string) => { + switch(typeOfContact) { + case "Club": + setClubInfo(value) + break + case "Business": + setBusinessInfo(value) + break + case "Industry Professional": + setProfessionInfo(value) + break + case "Sponsorship": + setSponsorshipInfo(value) + break + case "Partnership": + setPartnershipInfo(value) + break + case "Professor": + setProfessorInfo(value) + break + default: + setOtherInfo(value) + } + } + + if (typeOfContact && typeOfContact !== "Student") { + return ( + <> +
+ + +
+ + ) + } + + return null +} + +export default NonStudent \ No newline at end of file diff --git a/waybionic/src/app/contact/components/NonStudentInput.tsx b/waybionic/src/app/contact/components/NonStudentInput.tsx new file mode 100644 index 0000000..d4a193f --- /dev/null +++ b/waybionic/src/app/contact/components/NonStudentInput.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +const NonStudentInput = ({ getStateValue, setStateValue}) => { + return ( + <> +