Skip to content

Commit a2d55e3

Browse files
committed
feat: Implement contact form and related components for user inquiries
- Added a new API route for handling contact form submissions using Nodemailer. - Created a ContactForm component with validation using Zod and integrated it into the ContactPage. - Updated Navbar and various pages to include links to the new contact page. - Introduced audio narration for the contact page and added it to the voice tracks. - Enhanced user experience with notifications for form submission success and error handling. - Refactored existing components to improve layout and responsiveness.
1 parent db4c652 commit a2d55e3

File tree

12 files changed

+894
-135
lines changed

12 files changed

+894
-135
lines changed

app/api/contact/route.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { NextResponse } from 'next/server';
2+
import nodemailer from 'nodemailer';
3+
import { z } from 'zod';
4+
5+
// Updated Zod schema - REMOVED jobFile as it will be handled separately
6+
const ContactFormSchema = z.object({
7+
// Core fields
8+
name: z.string().min(1, { message: 'Name is required.' }),
9+
email: z.string().email({ message: 'Invalid email address.' }),
10+
company: z.string().optional(),
11+
topic: z.string().min(1, { message: 'Topic is required.' }),
12+
message: z.string().min(1, { message: 'Message cannot be empty.' }),
13+
_gotcha: z.string().optional(), // Honeypot field
14+
15+
// Optional conditional fields (matching frontend structure)
16+
jobRole: z.string().optional(),
17+
jobCompensation: z.string().optional(),
18+
jobWhyMe: z.string().optional(),
19+
// jobFile is removed from schema, handled directly from FormData
20+
freelanceOverview: z.string().optional(),
21+
freelanceTimeline: z.string().optional(),
22+
freelanceBudget: z.string().optional(),
23+
pressEventName: z.string().optional(),
24+
pressTopic: z.string().optional(),
25+
pressTiming: z.string().optional(),
26+
});
27+
28+
// Nodemailer transporter setup
29+
const transporter = nodemailer.createTransport({
30+
host: process.env.SMTP_HOST,
31+
port: parseInt(process.env.SMTP_PORT || '465', 10), // Default to 465 if not set
32+
secure: parseInt(process.env.SMTP_PORT || '465', 10) === 465, // true for 465, false for other ports
33+
auth: {
34+
user: process.env.SMTP_USER,
35+
pass: process.env.SMTP_PASS,
36+
},
37+
});
38+
39+
// Helper function to build email body parts
40+
const buildEmailSection = (label: string, value: string | undefined | null): string => {
41+
return value ? `${label}: ${value}\n` : '';
42+
};
43+
44+
const buildHtmlEmailSection = (label: string, value: string | undefined | null): string => {
45+
if (!value) return '';
46+
// Basic sanitization/escaping might be needed for HTML context depending on source
47+
const displayValue = value.replace(/\n/g, '<br>');
48+
return `<p><strong>${label}:</strong> ${displayValue}</p>`;
49+
};
50+
51+
export async function POST(request: Request) {
52+
// Check required environment variables
53+
if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS || !process.env.CONTACT_FORM_RECEIVER_EMAIL) {
54+
console.error('Missing required SMTP environment variables');
55+
return NextResponse.json({ error: 'Server configuration error.' }, { status: 500 });
56+
}
57+
58+
try {
59+
// Read the request body as FormData instead of JSON
60+
const formData = await request.formData();
61+
62+
// Extract text fields from FormData
63+
const body: Record<string, any> = {};
64+
for (const [key, value] of formData.entries()) {
65+
// Skip the file field for Zod validation
66+
if (key !== 'jobFile') {
67+
body[key] = value;
68+
}
69+
}
70+
71+
// Extract the file (if any)
72+
const jobFile = formData.get('jobFile') as File | null;
73+
74+
// Validate honeypot first
75+
if (body._gotcha) {
76+
console.log('Honeypot field filled, likely a bot.');
77+
// Return a generic success to not alert the bot, but don't send email
78+
return NextResponse.json({ success: true });
79+
}
80+
81+
// Validate the TEXT form data using the updated schema
82+
const validationResult = ContactFormSchema.safeParse(body);
83+
84+
if (!validationResult.success) {
85+
console.error('Validation Errors:', validationResult.error.flatten());
86+
return NextResponse.json(
87+
{ error: 'Invalid form data', details: validationResult.error.flatten().fieldErrors },
88+
{ status: 400 },
89+
);
90+
}
91+
92+
// Destructure all potential fields from the validated data
93+
const {
94+
name,
95+
email,
96+
message,
97+
topic,
98+
company,
99+
jobRole,
100+
jobCompensation,
101+
jobWhyMe,
102+
freelanceOverview,
103+
freelanceTimeline,
104+
freelanceBudget,
105+
pressEventName,
106+
pressTopic,
107+
pressTiming
108+
} = validationResult.data;
109+
110+
// Construct email content dynamically based on provided fields
111+
let plainText = `Core Information:
112+
`;
113+
plainText += buildEmailSection('Name', name);
114+
plainText += buildEmailSection('Email', email);
115+
plainText += buildEmailSection('Company', company);
116+
plainText += buildEmailSection('Topic', topic);
117+
plainText += buildEmailSection('Message', message);
118+
119+
let htmlText = `<h2>Core Information:</h2>`;
120+
htmlText += buildHtmlEmailSection('Name', name);
121+
htmlText += buildHtmlEmailSection('Email', `<a href="mailto:${email}">${email}</a>`); // Link email
122+
htmlText += buildHtmlEmailSection('Company', company);
123+
htmlText += buildHtmlEmailSection('Topic', topic);
124+
htmlText += buildHtmlEmailSection('Message', message);
125+
126+
// Add conditional sections to the email
127+
if (topic === 'Job Opportunity') {
128+
const section =
129+
buildEmailSection('Role/Title', jobRole) +
130+
buildEmailSection('Compensation', jobCompensation) +
131+
buildEmailSection('Why Me?', jobWhyMe);
132+
if (section) {
133+
plainText += `
134+
Job Opportunity Details:
135+
${section}`;
136+
htmlText += `<hr><h2>Job Opportunity Details:</h2>` +
137+
buildHtmlEmailSection('Role/Title', jobRole) +
138+
buildHtmlEmailSection('Compensation', jobCompensation) +
139+
buildHtmlEmailSection('Why Me?', jobWhyMe);
140+
}
141+
// Add note about attachment if file exists
142+
if (jobFile) {
143+
plainText += buildEmailSection('Resume/JD', 'See attachment');
144+
htmlText += buildHtmlEmailSection('Resume/JD', 'See attachment');
145+
}
146+
} else if (topic === 'Freelance / Consulting') {
147+
const section =
148+
buildEmailSection('Project Overview', freelanceOverview) +
149+
buildEmailSection('Timeline', freelanceTimeline) +
150+
buildEmailSection('Budget', freelanceBudget);
151+
if (section) {
152+
plainText += `
153+
Freelance / Consulting Details:
154+
${section}`;
155+
htmlText += `<hr><h2>Freelance / Consulting Details:</h2>` +
156+
buildHtmlEmailSection('Project Overview', freelanceOverview) +
157+
buildHtmlEmailSection('Timeline', freelanceTimeline) +
158+
buildHtmlEmailSection('Budget', freelanceBudget);
159+
}
160+
} else if (topic === 'Press / Speaking') {
161+
const section =
162+
buildEmailSection('Event/Publication', pressEventName) +
163+
buildEmailSection('Topic/Angle', pressTopic) +
164+
buildEmailSection('Timing/Deadline', pressTiming);
165+
if (section) {
166+
plainText += `
167+
Press / Speaking Details:
168+
${section}`;
169+
htmlText += `<hr><h2>Press / Speaking Details:</h2>` +
170+
buildHtmlEmailSection('Event/Publication', pressEventName) +
171+
buildHtmlEmailSection('Topic/Angle', pressTopic) +
172+
buildHtmlEmailSection('Timing/Deadline', pressTiming);
173+
}
174+
}
175+
176+
177+
// Prepare mail options
178+
const mailOptions: nodemailer.SendMailOptions = {
179+
from: `"${name}" <${process.env.SMTP_USER}>`, // Send FROM the configured user, but show sender's name
180+
replyTo: email, // Set Reply-To to the actual sender's email
181+
to: process.env.CONTACT_FORM_RECEIVER_EMAIL, // The email address handled by Forward Email
182+
subject: `[New Contact] from ${name} about ${topic}`,
183+
text: plainText.trim(), // Trim whitespace
184+
html: htmlText, // Use dynamically generated HTML
185+
attachments: [], // Initialize attachments array
186+
};
187+
188+
// Add attachment if file exists
189+
if (jobFile) {
190+
// Read file content into a buffer
191+
const fileBuffer = Buffer.from(await jobFile.arrayBuffer());
192+
mailOptions.attachments?.push({
193+
filename: jobFile.name,
194+
content: fileBuffer,
195+
contentType: jobFile.type, // Use the content type provided by the browser
196+
});
197+
}
198+
199+
200+
try {
201+
const info = await transporter.sendMail(mailOptions);
202+
console.log('Nodemailer Success: Message sent: %s', info.messageId);
203+
return NextResponse.json({ success: true });
204+
} catch (mailError) {
205+
console.error('Nodemailer Error sending mail:', mailError);
206+
return NextResponse.json({ error: 'Failed to send message.' }, { status: 500 });
207+
}
208+
209+
} catch (error) {
210+
console.error('API Route Error:', error);
211+
// Handle potential FormData parsing errors or other unexpected issues
212+
// Note: Specific error handling might need adjustment for FormData vs JSON
213+
return NextResponse.json({ error: 'An unexpected error occurred.' }, { status: 500 });
214+
}
215+
}

app/contact/page.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { ContactForm } from '@shared-components/organisms/ContactForm/ContactForm';
5+
import { PageContainer } from '@shared-components/layouts/PageContainer/PageContainer';
6+
import { useLoading } from '@contexts/LoadingContext';
7+
import { Title, Divider, Group } from '@mantine/core';
8+
import styled from 'styled-components';
9+
import { LuMail } from 'react-icons/lu';
10+
11+
const HeaderWrapper = styled.div`
12+
margin-top: var(--mantine-spacing-xl);
13+
display: flex;
14+
flex-direction: column;
15+
align-items: center;
16+
`;
17+
18+
export default function ContactPage() {
19+
const { signalPageReady } = useLoading();
20+
21+
useEffect(() => {
22+
signalPageReady();
23+
}, [signalPageReady]);
24+
25+
return (
26+
<PageContainer>
27+
<HeaderWrapper>
28+
<Title order={2} fw={600}>
29+
<Group gap="xs" align="center" justify="center">
30+
<LuMail size="1.8rem" />
31+
Let&apos;s Connect
32+
</Group>
33+
</Title>
34+
<Divider
35+
size="sm"
36+
color="blue.5"
37+
style={{ width: 60, marginTop: 'var(--mantine-spacing-xs)' }}
38+
/>
39+
</HeaderWrapper>
40+
<ContactForm />
41+
</PageContainer>
42+
);
43+
}

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@mantine/dropzone": "^7.17.3",
4747
"@mantine/form": "^7.17.1",
4848
"@mantine/hooks": "^7.17.1",
49+
"@mantine/notifications": "^7.17.5",
4950
"@microsoft/clarity": "^1.0.0",
5051
"@react-spring/animated": "9.5.5",
5152
"@react-spring/core": "9.5.5",
@@ -54,6 +55,7 @@
5455
"@reduxjs/toolkit": "^2.6.1",
5556
"@tabler/icons-react": "^3.31.0",
5657
"@types/dagre": "^0.7.52",
58+
"@types/nodemailer": "^6.4.17",
5759
"@types/react-syntax-highlighter": "^15.5.13",
5860
"@xyflow/react": "^12.4.4",
5961
"axios": "^1.6.0",
@@ -69,6 +71,7 @@
6971
"mammoth": "^1.9.0",
7072
"mermaid": "^11.6.0",
7173
"next": "15.2.1",
74+
"nodemailer": "^6.10.1",
7275
"pdfjs-dist": "^5.1.91",
7376
"phosphor-react": "^1.4.1",
7477
"puppeteer": "^21.0.0",
@@ -84,7 +87,8 @@
8487
"styled-components": "^6.1.16",
8588
"tesseract.js": "^6.0.0",
8689
"vscode-languageserver-types": "^3.17.5",
87-
"wavesurfer.js": "^7.9.1"
90+
"wavesurfer.js": "^7.9.1",
91+
"zod": "^3.24.3"
8892
},
8993
"optionalDependencies": {
9094
"canvas": "^2.11.2"

0 commit comments

Comments
 (0)