Add native contact form with image upload, replace iframe

Replace third-party iframe form with a self-hosted contact form on both the
homepage and contact page. Includes drag-and-drop photo upload (up to 3
images, auto-converted to WebP), honeypot spam protection, IP rate limiting,
inline validation, and auto-reply email to the submitter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-06-06 20:27:30 -04:00
parent 5cefb4d161
commit 2e119b03b2
12 changed files with 1752 additions and 132 deletions

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

175
contact-form.js Normal file
View File

@ -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 = '&times;';
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! Well 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' });
}
})();

View File

@ -1,132 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
<link rel="manifest" href="../assets/favicon/site.webmanifest">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Beach Party Balloons - Your go-to shop for stunning balloon decorations, walk-in arrangements, and deliveries in CT.">
<title>Beach Party Balloons</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Autour+One&family=Mogra&family=Rubik+Glitch&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
h1 { margin-bottom: 2.1rem; }
.section-title { font-size: 3rem; margin-bottom: 2rem; color: #2c3e50; }
.article { margin-bottom: 3rem; }
<link rel="apple-touch-icon" sizes="180x180" href="../assets/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
<link rel="manifest" href="../assets/favicon/site.webmanifest">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Beach Party Balloons - Your go-to shop for stunning balloon decorations, walk-in arrangements, and deliveries in CT.">
<title>Beach Party Balloons</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Autour+One&family=Mogra&family=Rubik+Glitch&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
h1 {
margin-bottom: 2.1rem;
}
.section-title {
font-size: 3rem;
margin-bottom: 2rem;
color: #2c3e50;
}
.content-container {
max-width: 850px;
margin: 2rem auto;
padding: 1rem;
}
.article {
margin-bottom: 3rem;
}
</style>
#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;
}
</style>
</head>
<body>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item " href="../">
Home
</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">
Shop
</a>
<a class="navbar-item" href="../about/">
About Us
</a>
<a class="navbar-item" href="#">
FAQ
</a>
<a class="navbar-item" href="../terms/">
Terms
</a>
<!-- <div class="navbar-item "> -->
<a class="navbar-item" href="../gallery/">
Gallery
</a>
<a class="navbar-item" href="../color/">Colors</a>
<nav class="navbar is-info is-spaced has-shadow" role="navigation" aria-label="main navigation">
<div class="navbar-brand is-size-1">
<a class="navbar-item" href="../">
<img style="background-color: white;" src="../assets/logo/BeachPartyBalloons-logo.webp" alt="Beach Party Balloons logo">
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu has-text-right">
<div class="navbar-end">
<a class="navbar-item" href="../">Home</a>
<a class="navbar-item" href="https://shop.beachpartyballoons.com">Shop</a>
<a class="navbar-item" href="../about/">About Us</a>
<a class="navbar-item" href="../faq/">FAQ</a>
<a class="navbar-item" href="../terms/">Terms</a>
<a class="navbar-item" href="../gallery/">Gallery</a>
<a class="navbar-item" href="../color/">Colors</a>
<a class="navbar-item is-tab is-active" href="../contact/">Contact</a>
</div>
</div>
</nav>
<a class="navbar-item is-tab is-active" href="../contact/">
Contact
</a>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<div class="is-flex-direction-column is-dark">
<h2 class="is-size-4" style="text-align: center;">
<a target="_blank" href="https://maps.app.goo.gl/gRk6NztgMRUsSVJf9">554 Boston Post Road, Milford, CT 06460</a>
</h2>
<h2 class="is-size-4" style="text-align: center;"><a href="tel:2032835626">203.283.5626</a></h2>
<h2 class="is-size-4" style="text-align: center;"><a id="email-link" href="#">&#8203;</a></h2>
</div>
<div class="form-container">
<form id="contactForm" novalidate>
<!-- Honeypot: hidden from humans, filled by bots -->
<div style="display:none;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="field">
<label class="label">Name <span class="has-text-danger">*</span></label>
<div class="control">
<input class="input" type="text" name="name" placeholder="Your name" required autocomplete="name">
</div>
<p class="help is-danger" id="err-name" style="display:none;"></p>
</div>
<div class="field">
<label class="label">Email <span class="has-text-danger">*</span></label>
<div class="control">
<input class="input" type="email" name="email" placeholder="you@example.com" required autocomplete="email">
</div>
<p class="help is-danger" id="err-email" style="display:none;"></p>
</div>
<div class="field">
<label class="label">Phone Number <span class="has-text-danger">*</span></label>
<div class="control">
<input class="input" type="tel" name="phone" placeholder="(203) 555-0123" required autocomplete="tel">
</div>
<p class="help is-danger" id="err-phone" style="display:none;"></p>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="field">
<label class="label">Event Type</label>
<div class="control">
<div class="select is-fullwidth">
<select name="eventType">
<option value="">Select a type…</option>
<option value="Birthday">Birthday</option>
<option value="Corporate">Corporate</option>
<option value="Wedding">Wedding</option>
<option value="Walk-in">Walk-in</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
<div class="navbar-end">
</div>
<div class="column">
<div class="field">
<label class="label">Event Date</label>
<div class="control">
<input class="input" type="date" name="eventDate">
</div>
</div>
</div>
</nav>
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
<div class="is-flex-direction-column is-dark">
<h2 class="is-size-4" style="text-align: center;"> <a target="_blank" href="https://maps.app.goo.gl/gRk6NztgMRUsSVJf9">554 Boston Post Road, Milford, CT 06460</a> </h2>
<h2 class="is-size-4" style="text-align: center;" ><a href="tel:203.283.5626">203.283.5626</a> </h2>
<h2 class="is-size-4" style="text-align: center;" ><a href="mailto:info@beachpartyballoons.com">info@beachpartyballoons.com</a> </h2>
</div>
</div>
<iframe style="border:none;width:100%;" id="contact-us-vjz40v" src="https://forms.beachpartyballoons.com/forms/contact-us-vjz40v"></iframe><script type="text/javascript" onload="initEmbed('contact-us-vjz40v')" src="https://forms.beachpartyballoons.com/widgets/iframe.min.js"></script>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i>
</a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i>
</a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i>
</a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social">
<i class="fa-brands fa-bluesky is-size-2"></i>
</a>
</div>
<h7>Copyright &copy; <span id="year"></span> Beach Party Balloons</h7>
<h7>All images & content are property of Beach Party Balloons. Use of images without written permission is prohibited.</h7>
</div>
</footer>
<script src="../script.js"></script>
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
<div class="field">
<label class="label">Message <span class="has-text-danger">*</span></label>
<div class="control">
<textarea class="textarea" name="message" placeholder="Tell us about your event…" rows="4" required></textarea>
</div>
<p class="help is-danger" id="err-message" style="display:none;"></p>
</div>
<div class="field">
<label class="label">Photos <span class="has-text-grey is-size-7">(up to 3 images)</span></label>
<div class="control">
<div id="dropzone">
<p><i class="fas fa-images"></i> Drag &amp; drop photos here, or <span class="has-text-info" style="text-decoration:underline;">browse</span></p>
<p class="is-size-7 has-text-grey mt-1">JPEG · PNG · HEIC · WebP — max 10 MB each</p>
<input id="photoInput" type="file" name="photos" accept="image/*" multiple style="display:none;">
</div>
<div id="photoPreview"></div>
<p id="photoError" class="help is-danger" style="display:none;">Maximum 3 photos — extras were ignored.</p>
</div>
</div>
<div id="formAlert" class="notification" style="display:none;"></div>
<div class="field">
<div class="control">
<button type="submit" id="submitBtn" class="button is-info is-fullwidth">Send Message</button>
</div>
</div>
</form>
</div>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
<div>
<a target="_blank" href="https://mastodon.social/@beachpartyballoons@mastodon.social"><i class="fa-brands fa-mastodon is-size-2"></i></a>
<a target="_blank" href="https://www.facebook.com/beachpartyballoons"><i class="fa-brands fa-facebook-f is-size-2"></i></a>
<a target="_blank" href="https://www.instagram.com/beachpartyballoons/"><i class="fa-brands fa-instagram is-size-2"></i></a>
<a target="_blank" href="https://bsky.app/profile/beachpartyballoons.bsky.social"><i class="fa-brands fa-bluesky is-size-2"></i></a>
</div>
<p>Copyright &copy; <span id="year"></span> Beach Party Balloons</p>
<p>All images &amp; content are property of Beach Party Balloons. Use of images without written permission is prohibited.</p>
</div>
</footer>
<script src="../script.js"></script>
<script>
const u = 'info', d = 'beachpartyballoons.com';
const link = document.getElementById('email-link');
link.href = 'mailto:' + u + '@' + d;
link.textContent = u + '@' + d;
</script>
<script src="../contact-form.js"></script>
</body>
</html>
</html>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
@ -89,7 +89,7 @@
<h2 class="is-size-4" style="text-align: center;"> <a target="_blank" href="https://maps.app.goo.gl/gRk6NztgMRUsSVJf9">554 Boston Post Road, Milford, CT 06460</a> </h2>
<h2 class="is-size-4" style="text-align: center;" ><a href="tel:203.283.5626">203.283.5626</a> </h2>
<div class="py-2 has-text-centered">
<a href="#contact-us-vjz40v">
<a href="#contact-form">
<button class="button is-info">Contact Us</button>
</a>
</div>
@ -121,7 +121,97 @@
<p id="hours-saturday" class="has-text-centered">Saturday: 9:00 - 3:00</p>
<p id="hours-sunday-monday" class="has-text-centered">Sunday - Monday: Closed</p>
<iframe style="border:none;width:100%;" id="contact-us-vjz40v" src="https://forms.beachpartyballoons.com/forms/contact-us-vjz40v"></iframe><script type="text/javascript" onload="initEmbed('contact-us-vjz40v')" src="https://forms.beachpartyballoons.com/widgets/iframe.min.js"></script>
<div class="form-container" id="contact-form">
<h2 class="is-size-3 has-text-centered">Contact Us</h2>
<form id="contactForm" novalidate>
<!-- Honeypot: hidden from humans, filled by bots -->
<div style="display:none;" aria-hidden="true">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="field">
<label class="label">Name <span class="has-text-danger">*</span></label>
<div class="control">
<input class="input" type="text" name="name" placeholder="Your name" required autocomplete="name">
</div>
<p class="help is-danger" id="err-name" style="display:none;"></p>
</div>
<div class="field">
<label class="label">Email <span class="has-text-danger">*</span></label>
<div class="control">
<input class="input" type="email" name="email" placeholder="you@example.com" required autocomplete="email">
</div>
<p class="help is-danger" id="err-email" style="display:none;"></p>
</div>
<div class="field">
<label class="label">Phone Number <span class="has-text-danger">*</span></label>
<div class="control">
<input class="input" type="tel" name="phone" placeholder="(203) 555-0123" required autocomplete="tel">
</div>
<p class="help is-danger" id="err-phone" style="display:none;"></p>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="field">
<label class="label">Event Type</label>
<div class="control">
<div class="select is-fullwidth">
<select name="eventType">
<option value="">Select a type…</option>
<option value="Birthday">Birthday</option>
<option value="Corporate">Corporate</option>
<option value="Wedding">Wedding</option>
<option value="Walk-in">Walk-in</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Event Date</label>
<div class="control">
<input class="input" type="date" name="eventDate">
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">Message <span class="has-text-danger">*</span></label>
<div class="control">
<textarea class="textarea" name="message" placeholder="Tell us about your event…" rows="4" required></textarea>
</div>
<p class="help is-danger" id="err-message" style="display:none;"></p>
</div>
<div class="field">
<label class="label">Photos <span class="has-text-grey is-size-7">(up to 3 images)</span></label>
<div class="control">
<div id="dropzone">
<p><i class="fas fa-images"></i> Drag &amp; drop photos here, or <span class="has-text-info" style="text-decoration:underline;">browse</span></p>
<p class="is-size-7 has-text-grey mt-1">JPEG · PNG · HEIC · WebP — max 10 MB each</p>
<input id="photoInput" type="file" name="photos" accept="image/*" multiple style="display:none;">
</div>
<div id="photoPreview"></div>
<p id="photoError" class="help is-danger" style="display:none;">Maximum 3 photos — extras were ignored.</p>
</div>
</div>
<div id="formAlert" class="notification" style="display:none;"></div>
<div class="field">
<div class="control">
<button type="submit" id="submitBtn" class="button is-info is-fullwidth">Send Message</button>
</div>
</div>
</form>
</div>
<script src="contact-form.js"></script>
<hr class="section-divider">
<section class="section reviews-section">
@ -226,7 +316,6 @@
<script src="reviews-data.js"></script>
<script src="reviews.js?v=6"></script>
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
<script async data-nf='{"formurl":"https://forms.beachpartyballoons.com/forms/contact-us-vjz40v","emoji":"💬","position":"left","bgcolor":"#0dc9ba","width":"500"}' src='https://forms.beachpartyballoons.com/widgets/embed-min.js'></script>
</body>
</html>

1132
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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": "^2.1.1",
"nodemailer": "^8.0.10",
"sharp": "^0.34.5"
},
"devDependencies": {
"concurrently": "^9.2.1",

124
server.js
View File

@ -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();
@ -56,6 +91,95 @@ apiRouter.post('/update-status', (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 ? `<p><strong>Event Type:</strong> ${eventType}</p>` : '',
eventDateFormatted ? `<p><strong>Event Date:</strong> ${eventDateFormatted}</p>` : '',
].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: `<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
<p><strong>Phone:</strong> <a href="tel:${phone.replace(/\D/g,'')}">${phone}</a></p>
${eventLines}
<hr>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>`,
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: `<p>Hi ${name},</p>
<p>Thanks for reaching out to <strong>Beach Party Balloons</strong>! We've received your message and will get back to you as soon as possible.</p>
<p>Here's a copy of what you sent:</p>
${eventLines}
<p><strong>Message:</strong><br>${message.replace(/\n/g, '<br>')}</p>
<hr>
<p><em>Beach Party Balloons</em><br>554 Boston Post Road, Milford, CT 06460<br><a href="tel:2032835626">203.283.5626</a></p>`,
};
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);

View File

@ -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;

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<script defer data-domain="beachpartyballoons.com" src="https://plausible.io/js/script.hash.outbound-links.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>