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 <noreply@anthropic.com>
This commit is contained in:
parent
fca6e8da0a
commit
aee1f10179
175
main-site/contact-form.js
Normal file
175
main-site/contact-form.js
Normal 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 = '×';
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -3,7 +3,6 @@
|
|||||||
<head>
|
<head>
|
||||||
<script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.hash.outbound-links.js"></script>
|
<script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/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="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="32x32" href="../assets/favicon/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="../assets/favicon/favicon-16x16.png">
|
||||||
@ -12,45 +11,22 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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.">
|
<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>
|
<title>Beach Party Balloons</title>
|
||||||
<link
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||||
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 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 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="../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" />
|
<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" />
|
||||||
<script src="/nav.js" defer></script>
|
<script src="/nav.js" defer></script>
|
||||||
|
|
||||||
<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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="site-nav"></div>
|
<div id="site-nav"></div>
|
||||||
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
|
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
|
||||||
|
|
||||||
<div class="is-flex-direction-column is-dark">
|
<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;">
|
||||||
<h2 class="is-size-4" style="text-align: center;" ><a href="tel:203.283.5626">203.283.5626</a> </h2>
|
<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;" id="bpb-email-h2">
|
<h2 class="is-size-4" style="text-align: center;" id="bpb-email-h2">
|
||||||
<button id="bpb-email-btn" style="background:none;border:none;cursor:pointer;font:inherit;color:inherit;text-decoration:underline;padding:0;">
|
<button id="bpb-email-btn" style="background:none;border:none;cursor:pointer;font:inherit;color:inherit;text-decoration:underline;padding:0;">
|
||||||
Click to show email address
|
Click to show email address
|
||||||
@ -62,15 +38,100 @@
|
|||||||
document.getElementById('bpb-email-h2').innerHTML = '<a href="mailto:' + a + '">' + a + '<\/a>';
|
document.getElementById('bpb-email-h2').innerHTML = '<a href="mailto:' + a + '">' + a + '<\/a>';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
</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>
|
||||||
|
<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>
|
</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>
|
<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 & 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>
|
||||||
|
|
||||||
<div id="site-footer"></div>
|
<div id="site-footer"></div>
|
||||||
<script src="../script.js"></script>
|
<script src="../script.js"></script>
|
||||||
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
|
<script src="../contact-form.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -39,7 +39,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 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="tel:203.283.5626">203.283.5626</a> </h2>
|
||||||
<div class="py-2 has-text-centered">
|
<div class="py-2 has-text-centered">
|
||||||
<a href="#contact-us-vjz40v">
|
<a href="#contact-form">
|
||||||
<button class="button is-info">Contact Us</button>
|
<button class="button is-info">Contact Us</button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -71,7 +71,96 @@
|
|||||||
<p id="hours-saturday" class="has-text-centered">Saturday: 9:00 - 3:00</p>
|
<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>
|
<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 & 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>
|
||||||
|
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
<section class="section reviews-section">
|
<section class="section reviews-section">
|
||||||
@ -158,8 +247,7 @@
|
|||||||
<script src="update.js"></script>
|
<script src="update.js"></script>
|
||||||
<script src="reviews-data.js"></script>
|
<script src="reviews-data.js"></script>
|
||||||
<script src="reviews.js?v=6"></script>
|
<script src="reviews.js?v=6"></script>
|
||||||
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
|
<script src="contact-form.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -18,7 +18,10 @@
|
|||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"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": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
|||||||
@ -8,6 +8,9 @@ const bodyParser = require('body-parser');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const multer = require('multer');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3050;
|
const port = 3050;
|
||||||
@ -32,6 +35,38 @@ app.use(cors({
|
|||||||
}));
|
}));
|
||||||
app.use(bodyParser.json());
|
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 ---
|
// --- API Routes ---
|
||||||
const apiRouter = express.Router();
|
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 ? `<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
|
// Mount the API router under the /api path
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,43 @@ html {
|
|||||||
height: 100%;
|
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 {
|
body {
|
||||||
font-family: "Autour One", serif;
|
font-family: "Autour One", serif;
|
||||||
color:#363636;
|
color:#363636;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user