From aee1f10179caa4952ed9cfe29c067df7b4ce9020 Mon Sep 17 00:00:00 2001 From: chris Date: Sat, 6 Jun 2026 20:32:05 -0400 Subject: [PATCH] Sync native contact form to main-site, replace iframe Replaces the third-party iframe form on both the homepage and contact page with the self-hosted form: drag-and-drop photo upload, honeypot, rate limiting, inline validation, auto-reply email. Adds multer/sharp/nodemailer dependencies and the /api/contact endpoint to server.js. Co-Authored-By: Claude Sonnet 4.6 --- main-site/contact-form.js | 175 ++++++++++++++++++++++++++++++++ main-site/contact/index.html | 191 +++++++++++++++++++++++------------ main-site/index.html | 96 +++++++++++++++++- main-site/package.json | 5 +- main-site/server.js | 124 +++++++++++++++++++++++ main-site/style.css | 37 +++++++ 6 files changed, 558 insertions(+), 70 deletions(-) create mode 100644 main-site/contact-form.js diff --git a/main-site/contact-form.js b/main-site/contact-form.js new file mode 100644 index 0000000..3230d45 --- /dev/null +++ b/main-site/contact-form.js @@ -0,0 +1,175 @@ +(function () { + const form = document.getElementById('contactForm'); + if (!form) return; + + const dropzone = document.getElementById('dropzone'); + const photoInput = document.getElementById('photoInput'); + const photoPreview = document.getElementById('photoPreview'); + const photoError = document.getElementById('photoError'); + const formAlert = document.getElementById('formAlert'); + const submitBtn = document.getElementById('submitBtn'); + const textarea = form.querySelector('textarea[name="message"]'); + const phoneInput = form.querySelector('input[name="phone"]'); + let selectedFiles = []; + + // Textarea auto-resize + textarea.style.overflow = 'hidden'; + textarea.addEventListener('input', function () { + this.style.height = 'auto'; + this.style.height = this.scrollHeight + 'px'; + }); + + // Phone auto-format + phoneInput.addEventListener('input', function () { + const digits = this.value.replace(/\D/g, '').slice(0, 10); + if (digits.length >= 7) this.value = '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6); + else if (digits.length >= 4) this.value = '(' + digits.slice(0,3) + ') ' + digits.slice(3); + else this.value = digits; + }); + + // Clear inline errors on input + form.querySelectorAll('.input, .textarea').forEach(el => { + el.addEventListener('input', () => clearErr(el)); + }); + + function setErr(input, msg) { + input.classList.add('is-danger'); + const el = document.getElementById('err-' + input.name); + if (el) { el.textContent = msg; el.style.display = ''; } + } + + function clearErr(input) { + input.classList.remove('is-danger'); + const el = document.getElementById('err-' + input.name); + if (el) el.style.display = 'none'; + } + + function validate() { + let ok = true; + + const name = form.querySelector('[name="name"]'); + if (!name.value.trim()) { setErr(name, 'Please enter your name.'); ok = false; } + else clearErr(name); + + const email = form.querySelector('[name="email"]'); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim())) { + setErr(email, 'Please enter a valid email address.'); ok = false; + } else clearErr(email); + + const phone = form.querySelector('[name="phone"]'); + if (phone.value.replace(/\D/g, '').length < 10) { + setErr(phone, 'Please enter a valid 10-digit phone number.'); ok = false; + } else clearErr(phone); + + const msg = form.querySelector('[name="message"]'); + if (!msg.value.trim()) { setErr(msg, 'Please enter a message.'); ok = false; } + else clearErr(msg); + + return ok; + } + + // Dropzone + dropzone.addEventListener('click', () => photoInput.click()); + dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('drag-over'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); + dropzone.addEventListener('drop', e => { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + addFiles(Array.from(e.dataTransfer.files)); + }); + photoInput.addEventListener('change', () => { + addFiles(Array.from(photoInput.files)); + photoInput.value = ''; + }); + + function addFiles(newFiles) { + const images = newFiles.filter(f => f.type.startsWith('image/')); + const tooBig = images.filter(f => f.size > 10 * 1024 * 1024); + if (tooBig.length) showAlert('One or more photos exceed 10 MB and were skipped.', 'is-warning'); + const ok = images.filter(f => f.size <= 10 * 1024 * 1024); + const combined = [...selectedFiles, ...ok]; + photoError.style.display = combined.length > 3 ? '' : 'none'; + selectedFiles = combined.slice(0, 3); + renderPreviews(); + } + + function renderPreviews() { + photoPreview.innerHTML = ''; + selectedFiles.forEach((file, i) => { + const url = URL.createObjectURL(file); + const wrap = document.createElement('div'); + wrap.className = 'preview-wrap'; + const img = document.createElement('img'); + img.src = url; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'remove-btn'; + btn.innerHTML = '×'; + btn.addEventListener('click', () => { + URL.revokeObjectURL(url); + selectedFiles.splice(i, 1); + photoError.style.display = 'none'; + renderPreviews(); + }); + wrap.appendChild(img); + wrap.appendChild(btn); + photoPreview.appendChild(wrap); + }); + } + + // Submit + form.addEventListener('submit', async e => { + e.preventDefault(); + formAlert.style.display = 'none'; + + if (!validate()) { + showAlert('Please correct the errors below.', 'is-danger'); + // Scroll to first error + const first = form.querySelector('.is-danger'); + if (first) first.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + const fd = new FormData(); + fd.append('name', form.querySelector('[name="name"]').value.trim()); + fd.append('email', form.querySelector('[name="email"]').value.trim()); + fd.append('phone', form.querySelector('[name="phone"]').value.trim()); + fd.append('message', form.querySelector('[name="message"]').value.trim()); + const et = form.querySelector('[name="eventType"]'); + if (et) fd.append('eventType', et.value); + const ed = form.querySelector('[name="eventDate"]'); + if (ed) fd.append('eventDate', ed.value); + const hp = form.querySelector('[name="website"]'); + if (hp) fd.append('website', hp.value); + selectedFiles.forEach(f => fd.append('photos', f)); + + submitBtn.classList.add('is-loading'); + submitBtn.disabled = true; + + try { + const res = await fetch('/api/contact', { method: 'POST', body: fd }); + const data = await res.json(); + if (data.success) { + showAlert("Message sent! We’ll get back to you soon. Check your email for a confirmation.", 'is-success'); + form.reset(); + selectedFiles = []; + renderPreviews(); + textarea.style.height = 'auto'; + } else { + showAlert(data.message || 'Something went wrong. Please try again.', 'is-danger'); + } + } catch { + showAlert('Network error. Please try again or email us directly.', 'is-danger'); + } finally { + submitBtn.classList.remove('is-loading'); + submitBtn.disabled = false; + } + }); + + function showAlert(msg, cls) { + formAlert.className = 'notification ' + cls; + formAlert.textContent = msg; + formAlert.style.display = ''; + formAlert.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } +})(); diff --git a/main-site/contact/index.html b/main-site/contact/index.html index a3df8af..475ecd9 100644 --- a/main-site/contact/index.html +++ b/main-site/contact/index.html @@ -2,75 +2,136 @@ - - - - - - - - - - Beach Party Balloons - - - - - - - - + + + + + + + + + Beach Party Balloons + + + + + + - - + + -
-

554 Boston Post Road, Milford, CT 06460

-

203.283.5626

-

- -

- +
+

+ 554 Boston Post Road, Milford, CT 06460 +

+

203.283.5626

+

+ +

+ +
-
- +
+
+ + - - +
+ +
+ +
+ +
- - - +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+

Drag & drop photos here, or browse

+

JPEG · PNG · HEIC · WebP — max 10 MB each

+ +
+
+ +
+
+ + + +
+
+ +
+
+
+
+ + + + - \ No newline at end of file + diff --git a/main-site/index.html b/main-site/index.html index bef03f7..09d7335 100644 --- a/main-site/index.html +++ b/main-site/index.html @@ -39,7 +39,7 @@

554 Boston Post Road, Milford, CT 06460

203.283.5626

@@ -71,7 +71,96 @@

Saturday: 9:00 - 3:00

Sunday - Monday: Closed

- +
+

Contact Us

+
+ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+

Drag & drop photos here, or browse

+

JPEG · PNG · HEIC · WebP — max 10 MB each

+ +
+
+ +
+
+ + + +
+
+ +
+
+
+

@@ -158,8 +247,7 @@ - - + diff --git a/main-site/package.json b/main-site/package.json index 300325f..d94910b 100644 --- a/main-site/package.json +++ b/main-site/package.json @@ -18,7 +18,10 @@ "body-parser": "^2.2.0", "cors": "^2.8.5", "dotenv": "^17.2.3", - "express": "^5.1.0" + "express": "^5.1.0", + "multer": "^1.4.5-lts.2", + "nodemailer": "^6.10.1", + "sharp": "^0.34.2" }, "devDependencies": { "concurrently": "^9.2.1", diff --git a/main-site/server.js b/main-site/server.js index 25dfb06..2af2f8b 100644 --- a/main-site/server.js +++ b/main-site/server.js @@ -8,6 +8,9 @@ const bodyParser = require('body-parser'); const fs = require('fs'); const path = require('path'); const cors = require('cors'); +const multer = require('multer'); +const sharp = require('sharp'); +const nodemailer = require('nodemailer'); const app = express(); const port = 3050; @@ -32,6 +35,38 @@ app.use(cors({ })); app.use(bodyParser.json()); +// --- Contact form rate limiter (in-memory, 5 submissions per IP per 15 min) --- +const contactRateLimit = new Map(); +setInterval(() => { + const now = Date.now(); + for (const [ip, r] of contactRateLimit) { + if (now > r.resetAt) contactRateLimit.delete(ip); + } +}, 3_600_000); + +// --- Email transporter --- +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587'), + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +// --- File upload (memory storage, max 3 files) --- +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith('image/')) { + return cb(new Error('Only image files are allowed')); + } + cb(null, true); + }, +}); + // --- API Routes --- const apiRouter = express.Router(); @@ -65,6 +100,95 @@ apiRouter.post('/update-status', requireAuth, (req, res) => { }); }); +apiRouter.post('/contact', upload.array('photos', 3), async (req, res) => { + // Rate limiting + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const now = Date.now(); + const rl = contactRateLimit.get(ip) || { count: 0, resetAt: now + 15 * 60 * 1000 }; + if (now > rl.resetAt) { rl.count = 0; rl.resetAt = now + 15 * 60 * 1000; } + rl.count++; + contactRateLimit.set(ip, rl); + if (rl.count > 5) { + return res.status(429).json({ success: false, message: 'Too many submissions. Please wait a few minutes and try again.' }); + } + + // Honeypot — silently succeed so bots think they won + if (req.body.website) { + return res.json({ success: true }); + } + + const { name, email, phone, message, eventType, eventDate } = req.body; + + if (!name || !email || !phone || !message) { + return res.status(400).json({ success: false, message: 'Please fill in all required fields.' }); + } + + const attachments = []; + for (const file of (req.files || [])) { + const webpBuffer = await sharp(file.buffer).webp({ quality: 85 }).toBuffer(); + const baseName = path.parse(file.originalname).name; + attachments.push({ filename: `${baseName}.webp`, content: webpBuffer, contentType: 'image/webp' }); + } + + function formatDate(str) { + if (!str) return null; + const [y, m, d] = str.split('-'); + const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; + return `${months[parseInt(m, 10) - 1]} ${parseInt(d, 10)}, ${y}`; + } + + const eventDateFormatted = formatDate(eventDate); + const eventLines = [ + eventType ? `

Event Type: ${eventType}

` : '', + eventDateFormatted ? `

Event Date: ${eventDateFormatted}

` : '', + ].join(''); + const eventText = [ + eventType ? `Event Type: ${eventType}` : '', + eventDateFormatted ? `Event Date: ${eventDateFormatted}` : '', + ].filter(Boolean).join('\n'); + + const notifyMail = { + from: `"Beach Party Balloons" <${process.env.SMTP_USER}>`, + replyTo: `"${name}" <${email}>`, + to: process.env.CONTACT_TO, + subject: `New Inquiry from ${name}${eventType ? ` — ${eventType}` : ''}`, + text: `Name: ${name}\nEmail: ${email}\nPhone: ${phone}\n${eventText}\n\nMessage:\n${message}`, + html: `

Name: ${name}

+

Email: ${email}

+

Phone: ${phone}

+ ${eventLines} +
+

Message:

+

${message.replace(/\n/g, '
')}

`, + attachments, + }; + + const autoReply = { + from: `"Beach Party Balloons" <${process.env.SMTP_USER}>`, + to: `"${name}" <${email}>`, + subject: `We got your message — Beach Party Balloons`, + text: `Hi ${name},\n\nThanks for reaching out to Beach Party Balloons! We've received your message and will get back to you as soon as possible.\n\nHere's a copy of what you sent:\n${eventText}${eventText ? '\n' : ''}\nMessage:\n${message}\n\n— The Beach Party Balloons Team\n554 Boston Post Road, Milford, CT 06460\n203.283.5626`, + html: `

Hi ${name},

+

Thanks for reaching out to Beach Party Balloons! We've received your message and will get back to you as soon as possible.

+

Here's a copy of what you sent:

+ ${eventLines} +

Message:
${message.replace(/\n/g, '
')}

+
+

Beach Party Balloons
554 Boston Post Road, Milford, CT 06460
203.283.5626

`, + }; + + try { + await transporter.sendMail(notifyMail); + transporter.sendMail(autoReply).catch(err => + console.error(`[${new Date().toISOString()}] Auto-reply failed:`, err) + ); + res.json({ success: true }); + } catch (err) { + console.error(`[${new Date().toISOString()}] Contact form mail error:`, err); + res.status(500).json({ success: false, message: 'Failed to send message. Please try again or email us directly.' }); + } +}); + // Mount the API router under the /api path app.use('/api', apiRouter); diff --git a/main-site/style.css b/main-site/style.css index b743d69..4479b6d 100644 --- a/main-site/style.css +++ b/main-site/style.css @@ -14,6 +14,43 @@ html { height: 100%; } +.content-container { + max-width: 850px; + margin: 2rem auto; + padding: 1rem; +} + +.form-container { + max-width: 560px; + margin: 2rem auto; + padding: 1rem; +} + +#dropzone { + border: 2px dashed #b5b5b5; + border-radius: 6px; + padding: 1.25rem 1rem; + text-align: center; + cursor: pointer; + background: #fafafa; + transition: border-color 0.2s; +} +#dropzone:hover, #dropzone.drag-over { border-color: #209cee; } + +#photoPreview { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 0.5rem; } +.preview-wrap { position: relative; display: inline-block; } +.preview-wrap img { + width: 80px; height: 80px; object-fit: cover; + border-radius: 4px; border: 1px solid #ddd; display: block; +} +.preview-wrap .remove-btn { + position: absolute; top: 2px; right: 2px; + background: rgba(0,0,0,.55); color: #fff; + border: none; border-radius: 50%; + width: 20px; height: 20px; font-size: 13px; + cursor: pointer; display: flex; align-items: center; justify-content: center; +} + body { font-family: "Autour One", serif; color:#363636;