Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a405019
feat(email): add email template and send API route
stevenphanny Jan 19, 2026
fc654b8
chore(MAC-239): small tailwind change
stevenphanny Jan 19, 2026
1358792
feat(email): add email template and send API route
stevenphanny Jan 19, 2026
392f614
chore(MAC-239): small tailwind change
stevenphanny Jan 19, 2026
403e054
Merge branch 'mac-239-setup-resend-emails' of https://github.com/mona…
stevenphanny Jan 19, 2026
aa9a659
feat(MAC-239): setup basic form for testing and temporarily using ono…
stevenphanny Jan 20, 2026
f080e06
feat(MAC-239): created email-template for nicer gmail view
stevenphanny Jan 22, 2026
2ac7c7c
fix(MAC-239): removed phone fields from form
stevenphanny Jan 22, 2026
3ddd1c1
fix(MAC-239): custom error handling because default look ugly
stevenphanny Jan 22, 2026
9840960
feat(MAC-239): setup basic form for testing and temporarily using ono…
stevenphanny Jan 20, 2026
f3dda9b
feat(MAC-239): created email-template for nicer gmail view
stevenphanny Jan 22, 2026
1f7cdb3
fix(MAC-239): removed phone fields from form
stevenphanny Jan 22, 2026
5b9a4bb
fix(MAC-239): custom error handling because default look ugly
stevenphanny Jan 22, 2026
8323bd4
Merge branch 'mac-239-setup-resend-emails' of https://github.com/mona…
stevenphanny Jan 23, 2026
6f31601
Merge remote-tracking branch 'origin' into mac-239-setup-resend-emails
stevenphanny Jan 25, 2026
edf5957
fix(MAC-239): fix colouring to match new colours
stevenphanny Jan 25, 2026
b908658
fix(MAC-239): .env.example was wrong
stevenphanny Jan 25, 2026
37a08ec
Update app/api/send/route.ts
stevenphanny Jan 25, 2026
b01440b
Update package.json
stevenphanny Jan 25, 2026
7bf9e03
Update components/ContactPageClient.tsx
stevenphanny Jan 25, 2026
ef48664
Update .env.example
stevenphanny Jan 25, 2026
e585590
Update components/ContactPageClient.tsx
stevenphanny Jan 25, 2026
e679c5f
fix(MAC-239): adding resend api key check and fixing css on contact page
stevenphanny Jan 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2024-01-18

# Sanity CMS Configuration (Sanity CLI: dev, deploy)
SANITY_STUDIO_PROJECT_ID=your_project_id
SANITY_STUDIO_DATASET=production
SANITY_STUDIO_API_VERSION=2024-01-18
# Resend Configuration
Comment thread
stevenphanny marked this conversation as resolved.
RESEND_API_KEY=your_resend_api_key

30 changes: 30 additions & 0 deletions app/api/send/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { EmailTemplate } from '../../../components/EmailTemplate';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

Copilot AI Jan 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RESEND_API_KEY is read directly from process.env when constructing the Resend client, but there is no guard for the case where it is missing or empty; this will cause failures that may be hard to diagnose. Consider explicitly checking for a defined API key and returning a clear 500 response (or throwing during startup) with a descriptive error message when it is not configured.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct, @stevenphanny

@stevenphanny stevenphanny Jan 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct, @stevenphanny

I've added a check in my most recent commit. Should now alert on startup.


export async function POST(req: Request) {
try {
// Get the form data from the request
const { name, emailAddress, subject, message } = await req.json();

const { data, error } = await resend.emails.send({
from: 'noreply@monashcoding.com',
// to: 'coding@monashclubs.org',
to: 'projects@monashcoding.com',
replyTo: emailAddress, // User's email will be set as reply-to
subject: subject || 'New Message from Monash Coding Site',
react: EmailTemplate({ name, emailAddress, subject, message }),
Comment thread
stevenphanny marked this conversation as resolved.
Outdated
});

if (error) {
console.error('Resend API error:', error);
return Response.json({ error }, { status: 500 });
}

return Response.json(data);
} catch (error) {
console.error('Catch error:', error);
return Response.json({ error: String(error) }, { status: 500 });
}
}
252 changes: 241 additions & 11 deletions components/ContactPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { motion } from "framer-motion";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ContactPageData, ContactSocialLink } from "@/lib/sanity/types";
import { RibbonAwareSection } from "@/components/RibbonAwareSection";

Expand Down Expand Up @@ -66,6 +67,18 @@ interface ContactPageClientProps {
}

export default function ContactPageClient({ data }: ContactPageClientProps) {
// Form state
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
});

// Error state
const [errors, setErrors] = useState<Record<string, string>>({});


// Use Sanity data or fallbacks
const pageTitle = data?.pageTitle || "Get in Touch";
const pageSubtitle = data?.pageSubtitle || "Have a question or want to collaborate? We'd love to hear from you.";
Expand All @@ -76,13 +89,83 @@ export default function ContactPageClient({ data }: ContactPageClientProps) {
const locationMapLink = data?.locationMapLink || "https://maps.google.com/?q=Monash+University+Clayton";
const socialLinks = data?.socialLinks || defaultSocialLinks;

// Handle form input changes. Clears errors on change.
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;

setFormData((prev) => ({
...prev,
[name]: value,
}));

setErrors((prev) => ({
...prev,
[name]: "",
}));
};


// Validate form fields
const validate = () => {
const newErrors: Record<string, string> = {};

if (!formData.name.trim()) newErrors.name = "Name is required";
if (!formData.email.trim()) newErrors.email = "Email is required";
if (!formData.subject.trim()) newErrors.subject = "Subject is required";
if (!formData.message.trim()) newErrors.message = "Message is required";

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleSendEmail = async (
e: React.FormEvent<HTMLFormElement>
) => {
e.preventDefault();
if (!validate()) return;

try {
const response = await fetch("/api/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: formData.name,
emailAddress: formData.email,
subject: formData.subject,
message: formData.message,
}),
});

if (!response.ok) {
throw new Error("Failed to send email");
}

alert("Email sent successfully!");

setFormData({
name: "",
email: "",
subject: "",
message: "",
});
} catch (error) {
console.error("Error sending email:", error);
alert("Error sending email");
}
};


return (
<RibbonAwareSection
as="main"
backgroundClassName="bg-linear-to-b from-background to-secondary"
backgroundClassName=""
contentClassName="min-h-screen pt-32 flex flex-col items-center justify-center"
>
<div className="max-w-[600px] w-full py-16 px-8 text-center">
<div className="max-w-150 w-full py-16 px-8 text-center">
Comment thread
stevenphanny marked this conversation as resolved.
Outdated
<motion.h1
className="text-[clamp(2.5rem,5vw,4rem)] font-extrabold text-foreground mb-4"
initial={{ opacity: 0, y: 40 }}
Expand All @@ -92,14 +175,161 @@ export default function ContactPageClient({ data }: ContactPageClientProps) {
{pageTitle}
</motion.h1>
<motion.p
className="text-lg text-white/60 mb-12 leading-relaxed"
className="text-lg text-white/80 mb-12 leading-relaxed"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
{pageSubtitle}
</motion.p>

<motion.form
noValidate
onSubmit={handleSendEmail}
className="mb-12 p-8 bg-white/50 border border-black/10 rounded-2xl w-full max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15 }}
>
<div className="grid grid-cols-1 gap-4 mb-6">

{/* NAME */}
<div>
<label className="block text-sm font-medium text-background mb-2">
Name
</label>
<input
name="name"
value={formData.name}
onChange={handleInputChange}
className={`w-full px-4 py-2 rounded-lg bg-white/80 transition-all placeholder:text-black/40
${errors.name
? "border border-red-500 focus:ring-2 focus:ring-red-500/30"
: "border border-black/10 focus:ring-2 focus:ring-gold-700/20"}
`}
placeholder="Your name"
/>
<AnimatePresence>
{errors.name && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="mt-1 text-sm text-red-600"
>
{errors.name}
</motion.p>
)}
</AnimatePresence>
</div>

{/* EMAIL */}
<div>
<label className="block text-sm font-medium text-background mb-2">
Email
</label>
<input
name="email"
value={formData.email}
onChange={handleInputChange}
className={`w-full px-4 py-2 rounded-lg bg-white/80 transition-all placeholder:text-black/40
${errors.email
? "border border-red-500 focus:ring-2 focus:ring-red-500/30"
: "border border-black/10 focus:ring-2 focus:ring-gold-700/20"}
`}
placeholder="your@email.com"
/>
<AnimatePresence>
{errors.email && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="mt-1 text-sm text-red-600"
>
{errors.email}
</motion.p>
)}
</AnimatePresence>
</div>

{/* SUBJECT */}
<div>
<label className="block text-sm font-medium text-background mb-2">
Subject
</label>
<input
name="subject"
value={formData.subject}
onChange={handleInputChange}
className={`w-full px-4 py-2 rounded-lg bg-white/80 transition-all placeholder:text-black/40
${errors.subject
? "border border-red-500 focus:ring-2 focus:ring-red-500/30"
: "border border-black/10 focus:ring-2 focus:ring-gold-700/20"}
`}
placeholder="Subject of your message"
/>
<AnimatePresence>
{errors.subject && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="mt-1 text-sm text-red-600"
>
{errors.subject}
</motion.p>
)}
</AnimatePresence>
</div>

{/* MESSAGE */}
<div>
<label className="block text-sm font-medium text-background mb-2">
Message
</label>
<textarea
name="message"
rows={5}
value={formData.message}
onChange={handleInputChange}
className={`w-full px-4 py-2 rounded-lg bg-white/80 resize-none transition-all placeholder:text-black/40
${errors.message
? "border border-red-500 focus:ring-2 focus:ring-red-500/30"
: "border border-black/10 focus:ring-2 focus:ring-gold-700/20"}
`}
placeholder="Your message here..."
/>
<AnimatePresence>
{errors.message && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="mt-1 text-sm text-red-600"
>
{errors.message}
</motion.p>
)}
</AnimatePresence>
</div>
</div>

<motion.button
type="submit"
className="w-full py-3 px-6 bg-gold-700 text-background rounded-lg font-medium hover:bg-gold-800 active:scale-95"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Send Message
</motion.button>
</motion.form>

{/* Have the contact methods side by side */}
<div className="flex flex-row gap-6 mb-12">

</div>

Comment thread
stevenphanny marked this conversation as resolved.
Outdated
<div className="flex flex-col gap-6">
<motion.a
href={`mailto:${email}`}
Expand All @@ -109,8 +339,8 @@ export default function ContactPageClient({ data }: ContactPageClientProps) {
transition={{ duration: 0.4, delay: 0.2 }}
whileHover={{ scale: 1.02 }}
>
<div className="w-[50px] h-[50px] bg-accent/10 rounded-2xl flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div className="w-12.5 h-12.5 bg-gold-700/10 rounded-2xl flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-gold-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
Comment thread
stevenphanny marked this conversation as resolved.
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
Expand All @@ -131,8 +361,8 @@ export default function ContactPageClient({ data }: ContactPageClientProps) {
transition={{ duration: 0.4, delay: 0.3 }}
whileHover={{ scale: 1.02 }}
>
<div className="w-[50px] h-[50px] bg-accent/10 rounded-2xl flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-accent" viewBox="0 0 24 24" fill="currentColor">
<div className="w-12.5 h-12.5 bg-gold-700/10 rounded-2xl flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-gold-700" viewBox="0 0 24 24" fill="currentColor">
Comment thread
Swofty-Developments marked this conversation as resolved.
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</div>
Expand All @@ -152,8 +382,8 @@ export default function ContactPageClient({ data }: ContactPageClientProps) {
transition={{ duration: 0.4, delay: 0.4 }}
whileHover={{ scale: 1.02 }}
>
<div className="w-[50px] h-[50px] bg-accent/10 rounded-2xl flex items-center justify-center shrink-0">
<svg className="w-6 h-6 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div className="w-12.5 h-12.5 bg-gold-700/10 rounded-2xl flex items-center justify-center shrink-0">
Comment thread
stevenphanny marked this conversation as resolved.
<svg className="w-6 h-6 text-gold-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
Expand All @@ -179,7 +409,7 @@ export default function ContactPageClient({ data }: ContactPageClientProps) {
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="w-[50px] h-[50px] bg-white/5 border border-white/10 rounded-2xl flex items-center justify-center text-white/50 transition-all duration-300 hover:bg-accent/10 hover:border-accent/30 hover:text-accent"
className="w-12.5 h-12.5 bg-white/50 border border-black/10 rounded-2xl flex items-center justify-center text-background/50 transition-all duration-300 hover:bg-gold-700/10 hover:border-gold-700/30 hover:text-gold-700"
Comment thread
stevenphanny marked this conversation as resolved.
Outdated
aria-label={link.platform}
>
<SocialIcon platform={link.platform} />
Expand Down
Loading
Loading