Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
156 changes: 131 additions & 25 deletions nevo_frontend/app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import React, { useState } from 'react';

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export default function ContactPage() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
Expand All @@ -11,36 +13,63 @@
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState({ email: '', subject: '', message: '' });

function validateEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
function validateEmail(value: string) {
return EMAIL_REGEX.test(value);
}

async function handleSubmit(ev: React.FormEvent) {
function validateForm() {
const errors = { email: '', subject: '', message: '' };

if (!validateEmail(email)) {
errors.email = 'Please enter a valid email address.';
}

if (!subject || subject.trim().length < 3) {
errors.subject = 'Please enter a subject with at least 3 characters.';
}

if (!message || message.trim().length < 10) {
errors.message = 'Please enter a message with at least 10 characters.';
}

setFieldErrors(errors);
return !errors.email && !errors.subject && !errors.message;
}

async function handleSubmit(ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault();
setError(null);
setSuccess(null);

if (!validateEmail(email)) return setError('Please enter a valid email address.');
if (!subject || subject.trim().length < 3) return setError('Please enter a subject (3+ chars).');
if (!message || message.trim().length < 10) return setError('Please enter a message (10+ chars).');
if (!validateForm()) {
setError('Please fix the highlighted fields before sending.');
return;
}

setLoading(true);

try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, subject, message, autoReply }),
});
const j = await res.json();
if (!res.ok) throw new Error(j?.error || 'Submission failed');

const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || 'Submission failed');
}

setSuccess('Thanks — your message was sent. We will respond shortly.');
setName('');
setEmail('');
setSubject('');
setMessage('');
setAutoReply(true);
setFieldErrors({ email: '', subject: '', message: '' });
} catch (err: any) {

Check failure on line 72 in nevo_frontend/app/contact/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend CI

Unexpected any. Specify a different type

Check failure on line 72 in nevo_frontend/app/contact/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend Build Check

Unexpected any. Specify a different type
setError(err.message || 'Submission failed');
} finally {
setLoading(false);
Expand All @@ -50,39 +79,116 @@
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<h1 className="text-2xl font-bold mb-2">Contact Support</h1>
<p className="text-sm text-[var(--color-text-muted)] mb-6">Have a question, found an issue, or want to give feedback? Send us a message and we&apos;ll get back to you.</p>
<p className="text-sm text-[var(--color-text-muted)] mb-6">
Have a question, found an issue, or want to give feedback? Send us a message and we&apos;ll get back to you.
</p>

<form onSubmit={handleSubmit} className="space-y-4">
{error && <div className="text-sm text-error">{error}</div>}
{success && <div className="text-sm text-success">{success}</div>}
<form onSubmit={handleSubmit} noValidate className="space-y-6">
<div aria-live="polite" className="min-h-[1.5rem]">
{error && <p className="text-sm text-error">{error}</p>}
{success && <p className="text-sm text-success">{success}</p>}
</div>

<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">Name (optional)</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]" />
<label htmlFor="name" className="block text-xs text-[var(--color-text-muted)] mb-1">
Name (optional)
</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]"
placeholder="Your name"
/>
</div>

<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} required className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]" type="email" />
<label htmlFor="email" className="block text-xs text-[var(--color-text-muted)] mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
aria-invalid={!!fieldErrors.email}
aria-describedby="email-error"
className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]"
placeholder="you@example.com"
/>
{fieldErrors.email && (
<p id="email-error" className="mt-1 text-xs text-error">
{fieldErrors.email}
</p>
)}
</div>

<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">Subject</label>
<input value={subject} onChange={(e) => setSubject(e.target.value)} required className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]" />
<label htmlFor="subject" className="block text-xs text-[var(--color-text-muted)] mb-1">
Subject
</label>
<input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
required
minLength={3}
aria-invalid={!!fieldErrors.subject}
aria-describedby="subject-error"
className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]"
placeholder="What can we help you with?"
/>
{fieldErrors.subject && (
<p id="subject-error" className="mt-1 text-xs text-error">
{fieldErrors.subject}
</p>
)}
</div>

<div>
<label className="block text-xs text-[var(--color-text-muted)] mb-1">Message</label>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required rows={6} className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]" />
<label htmlFor="message" className="block text-xs text-[var(--color-text-muted)] mb-1">
Message
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
minLength={10}
rows={6}
aria-invalid={!!fieldErrors.message}
aria-describedby="message-error"
className="w-full rounded-xl border px-3 py-2 bg-[var(--color-surface)]"
placeholder="Tell us what happened, include any relevant details or links."
/>
{fieldErrors.message && (
<p id="message-error" className="mt-1 text-xs text-error">
{fieldErrors.message}
</p>
)}
</div>

<div className="flex items-center gap-3">
<input id="autoReply" type="checkbox" checked={autoReply} onChange={(e) => setAutoReply(e.target.checked)} />
<label htmlFor="autoReply" className="text-sm text-[var(--color-text-muted)]">Send me an auto-reply confirmation</label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<label className="inline-flex items-center gap-3 text-sm text-[var(--color-text-muted)]">
<input
id="autoReply"
type="checkbox"
checked={autoReply}
onChange={(e) => setAutoReply(e.target.checked)}
className="h-4 w-4 rounded border"
/>
Send me an auto-reply confirmation
</label>
<p className="text-xs text-[var(--color-text-muted)]">This helps us verify we can follow up to your inquiry.</p>
</div>

<div className="flex items-center justify-between gap-4">
<button type="submit" disabled={loading} className="rounded-xl bg-brand-600 text-white px-4 py-2 disabled:opacity-50">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-brand-600 text-white px-4 py-2 disabled:opacity-50"
>
{loading ? 'Sending…' : 'Send Message'}
</button>
<p className="text-xs text-[var(--color-text-muted)]">We aim to reply within 48 hours.</p>
Expand Down
65 changes: 42 additions & 23 deletions nevo_frontend/src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,65 +6,85 @@ type Submission = {
email: string;
subject: string;
message: string;
autoReply?: boolean;
autoReply: boolean;
createdAt: string;
ip: string;
};

// In-memory store (mock DB) and simple rate-limit map
const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL || 'support@nevo.example';
const EMAIL_FROM = process.env.EMAIL_FROM || 'no-reply@nevo.example';
const STORE: Submission[] = [];
const RATE_LIMIT_MAP: Record<string, { count: number; firstTs: number }> = {};
const RATE_LIMIT_MAX = 5; // per hour per IP (mock)

function validateEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
function validateEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

async function sendEmail(to: string, subject: string, text: string, html: string) {
// eslint-disable-next-line no-console
console.log(`[contact] Email queued to ${to}`);
// eslint-disable-next-line no-console
console.log({ from: EMAIL_FROM, to, subject, text, html });
// TODO: Replace with a real email provider integration (SendGrid, SES, Mailgun, etc.)
}

export async function POST(req: Request) {
try {
const ip = (req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'local') as string;
const ip =
(req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'local') as string;
const now = Date.now();
const rl = RATE_LIMIT_MAP[ip] || { count: 0, firstTs: now };

if (now - rl.firstTs > 1000 * 60 * 60) {
rl.count = 0;
rl.firstTs = now;
}

if (rl.count >= RATE_LIMIT_MAX) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}

const body = await req.json();
const { name, email, subject, message, autoReply } = body || {};

if (!email || !validateEmail(email)) return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
if (!subject || String(subject).trim().length < 3) return NextResponse.json({ error: 'Invalid subject' }, { status: 400 });
if (!message || String(message).trim().length < 10) return NextResponse.json({ error: 'Invalid message' }, { status: 400 });
if (!email || !validateEmail(String(email))) {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}

if (!subject || String(subject).trim().length < 3) {
return NextResponse.json({ error: 'Invalid subject' }, { status: 400 });
}

if (!message || String(message).trim().length < 10) {
return NextResponse.json({ error: 'Invalid message' }, { status: 400 });
}

// store
const submission: Submission = {
id: Math.random().toString(36).slice(2, 10),
name: name || undefined,
email,
subject,
message,
name: name ? String(name).trim() : undefined,
email: String(email).trim(),
subject: String(subject).trim(),
message: String(message).trim(),
autoReply: !!autoReply,
createdAt: new Date().toISOString(),
ip,
};

// Store submissions in an in-memory mock database for this implementation.
STORE.push(submission);

// increment rate limit
rl.count += 1;
RATE_LIMIT_MAP[ip] = rl;

// Mock send email: log to console (replace with real provider in production)
// Note: In this dev environment we can't actually send email.
// Keep logic minimal and safe.
// Support team email: support@nevo.example (placeholder)
// eslint-disable-next-line no-console
console.log('[contact] New submission:', submission);
const supportText = `New support request from ${submission.email}\n\nSubject: ${submission.subject}\n\nMessage:\n${submission.message}`;
const supportHtml = `<p><strong>New support request from</strong> ${submission.email}</p><p><strong>Subject:</strong> ${submission.subject}</p><p><strong>Message:</strong></p><p>${submission.message.replace(/\n/g, '<br/>')}</p>`;
await sendEmail(SUPPORT_EMAIL, `New contact inquiry: ${submission.subject}`, supportText, supportHtml);

if (submission.autoReply) {
// eslint-disable-next-line no-console
console.log(`[contact] Sending auto-reply to ${submission.email}`);
const autoReplyText = `Thanks for reaching out! We received your message and will reply as soon as possible.`;
const autoReplyHtml = `<p>Thanks for reaching out! We received your message and will reply as soon as possible.</p><p><strong>Subject:</strong> ${submission.subject}</p>`;
await sendEmail(submission.email, 'Your Nevo support request was received', autoReplyText, autoReplyHtml);
}

return NextResponse.json({ ok: true, id: submission.id });
Expand All @@ -74,6 +94,5 @@ export async function POST(req: Request) {
}

export async function GET() {
// For admin/dev debugging only: return stored submissions (in real app protect this)
return NextResponse.json({ submissions: STORE });
}
Loading