Compare commits
No commits in common. "main" and "gallery-tags" have entirely different histories.
main
...
gallery-ta
4
.gitignore
vendored
@ -39,7 +39,3 @@ gallery/sculpture/index.html
|
||||
# Build artifacts and backups
|
||||
public/build/
|
||||
backups/
|
||||
|
||||
# Local database files
|
||||
mongodb_data/
|
||||
photo-gallery-app/backend/uploads/
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<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>
|
||||
|
||||
@ -5,16 +5,3 @@
|
||||
.low-tag-card {
|
||||
box-shadow: 0 0 0 2px #ffdd57 inset;
|
||||
}
|
||||
|
||||
#bulkPanel {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
z-index: 10;
|
||||
box-shadow: 0 12px 24px rgba(17, 17, 17, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#bulkPanel {
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const captionInput = document.getElementById('captionInput');
|
||||
const captionToTagsButton = document.getElementById('captionToTags');
|
||||
const manageGallery = document.getElementById('manage-gallery');
|
||||
const manageSearchInput = document.getElementById('manageSearchInput');
|
||||
const editModal = document.getElementById('editModal');
|
||||
const editPhotoId = document.getElementById('editPhotoId');
|
||||
const editCaption = document.getElementById('editCaption');
|
||||
@ -56,17 +55,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const responseDiv = document.getElementById('response');
|
||||
|
||||
const backendUrl = (() => {
|
||||
const { protocol, hostname } = window.location;
|
||||
const productionHosts = new Set([
|
||||
'beachpartyballoons.com',
|
||||
'www.beachpartyballoons.com',
|
||||
'preview.beachpartyballoons.com',
|
||||
'photobackend.beachpartyballoons.com'
|
||||
]);
|
||||
const isProduction = productionHosts.has(hostname);
|
||||
if (!isProduction) {
|
||||
return 'http://localhost:5001';
|
||||
}
|
||||
const { protocol } = window.location;
|
||||
const backendHostname = 'photobackend.beachpartyballoons.com';
|
||||
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
|
||||
})();
|
||||
@ -80,8 +69,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presets: [],
|
||||
labels: {},
|
||||
maxTags: DEFAULT_MAX_TAGS,
|
||||
existing: [],
|
||||
tagCounts: {}
|
||||
existing: []
|
||||
};
|
||||
let adminPassword = '';
|
||||
const storedPassword = localStorage.getItem('bpb-admin-password');
|
||||
@ -92,11 +80,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const mapped = tagMeta.aliases?.[slug] || slug;
|
||||
return mapped;
|
||||
};
|
||||
const resolveSearchTag = (value) => {
|
||||
const slug = slugifyTag(value);
|
||||
if (!slug) return '';
|
||||
return tagMeta.aliases?.[slug] || slug;
|
||||
};
|
||||
const displayTag = (slug) => {
|
||||
if (!slug) return '';
|
||||
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
|
||||
@ -182,7 +165,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
labels: {},
|
||||
maxTags: DEFAULT_MAX_TAGS,
|
||||
existing: [],
|
||||
tagCounts: {},
|
||||
...data
|
||||
};
|
||||
updateTagSuggestions();
|
||||
@ -227,27 +209,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function renderManageGallery() {
|
||||
manageGallery.innerHTML = '';
|
||||
const query = String(manageSearchInput?.value || '').trim().toLowerCase();
|
||||
const normalizedQuery = resolveSearchTag(query);
|
||||
const filtered = query
|
||||
? photos.filter(photo => {
|
||||
const caption = String(photo.caption || '').toLowerCase();
|
||||
const tags = Array.isArray(photo.tags) ? photo.tags : [];
|
||||
const tagText = tags.map(displayTag).join(' ').toLowerCase();
|
||||
return caption.includes(query)
|
||||
|| tags.some(tag => String(tag || '').toLowerCase().includes(query))
|
||||
|| (normalizedQuery && tags.some(tag => String(tag || '').toLowerCase() === normalizedQuery))
|
||||
|| tagText.includes(query);
|
||||
})
|
||||
: photos;
|
||||
if (!filtered.length) {
|
||||
const message = query
|
||||
? 'No photos match your search.'
|
||||
: 'No photos yet. Upload a photo to get started.';
|
||||
manageGallery.innerHTML = `<div class="column"><p class="has-text-grey">${message}</p></div>`;
|
||||
if (!photos.length) {
|
||||
manageGallery.innerHTML = '<div class="column"><p class="has-text-grey">No photos yet. Upload a photo to get started.</p></div>';
|
||||
return;
|
||||
}
|
||||
filtered.forEach(photo => {
|
||||
photos.forEach(photo => {
|
||||
const tagCount = Array.isArray(photo.tags) ? photo.tags.length : 0;
|
||||
const tagStatusClass = tagCount <= 2 ? 'is-warning' : 'is-light';
|
||||
const lowTagClass = tagCount <= 2 ? 'low-tag-card' : '';
|
||||
@ -500,10 +466,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
if (manageSearchInput) {
|
||||
manageSearchInput.addEventListener('input', () => renderManageGallery());
|
||||
}
|
||||
|
||||
selectAllPhotosBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
toggleSelectAll();
|
||||
@ -620,28 +582,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
const getTagCount = (slug) => Number(tagMeta.tagCounts?.[slug] || 0);
|
||||
const sortTagsByCount = (a, b) => {
|
||||
const countDiff = getTagCount(b.slug) - getTagCount(a.slug);
|
||||
if (countDiff !== 0) return countDiff;
|
||||
return (a.label || '').localeCompare(b.label || '');
|
||||
};
|
||||
const sortSlugsByCount = (a, b) => {
|
||||
const countDiff = getTagCount(b) - getTagCount(a);
|
||||
if (countDiff !== 0) return countDiff;
|
||||
return displayTag(a).localeCompare(displayTag(b));
|
||||
};
|
||||
|
||||
function updateTagSuggestions() {
|
||||
if (!tagSuggestions) return;
|
||||
tagSuggestions.innerHTML = '';
|
||||
const mainSorted = [...(tagMeta.main || [])].sort(sortTagsByCount);
|
||||
const otherSorted = [...(tagMeta.other || [])].sort(sortTagsByCount);
|
||||
const existingSorted = [...(tagMeta.existing || [])].sort(sortSlugsByCount);
|
||||
const suggestions = [
|
||||
...mainSorted,
|
||||
...otherSorted,
|
||||
...existingSorted.map(slug => ({ slug, label: displayTag(slug) }))
|
||||
...(tagMeta.main || []),
|
||||
...(tagMeta.other || []),
|
||||
...((tagMeta.existing || []).map(slug => ({ slug, label: displayTag(slug) })))
|
||||
];
|
||||
const seen = new Set();
|
||||
suggestions.forEach(tag => {
|
||||
@ -657,12 +604,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
function updateQuickTags() {
|
||||
if (!quickTagButtons) return;
|
||||
const presetButtons = (tagMeta.presets || []).map(preset => `<button type="button" class="button is-light is-rounded" data-preset="${preset.name}">${preset.name} preset</button>`);
|
||||
const mainButtons = [...(tagMeta.main || [])]
|
||||
.sort(sortTagsByCount)
|
||||
.map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||
const otherButtons = [...(tagMeta.other || [])]
|
||||
.sort(sortTagsByCount)
|
||||
.map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||
const mainButtons = (tagMeta.main || []).map(tag => `<button type="button" class="button is-link is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||
const otherButtons = (tagMeta.other || []).map(tag => `<button type="button" class="button is-light is-rounded" data-tag="${tag.slug}">${tag.label}</button>`);
|
||||
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@ -73,8 +73,8 @@
|
||||
<div class="field">
|
||||
<label class="label">Photo</label>
|
||||
<div class="control">
|
||||
<input class="input has-background-light has-text-black" type="file" id="photoInput" accept="image/*,.heic,.heif" multiple required>
|
||||
<p class="help is-size-7 has-text-grey">Select one or many images (including HEIC/HEIF); each will be converted to WebP automatically.</p>
|
||||
<input class="input has-background-light has-text-black" type="file" id="photoInput" accept="image/*" multiple required>
|
||||
<p class="help is-size-7 has-text-grey">Select one or many images; each will be converted to WebP automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
@ -111,12 +111,6 @@
|
||||
</div>
|
||||
<span class="tag is-info is-light"><i class="fas fa-images mr-2"></i>Gallery</span>
|
||||
</div>
|
||||
<div class="field mb-4">
|
||||
<label class="label is-size-7 has-text-dark">Search by caption or tag</label>
|
||||
<div class="control">
|
||||
<input class="input is-small has-background-light has-text-dark" type="text" id="manageSearchInput" placeholder="e.g. classic, wedding, arch">
|
||||
</div>
|
||||
</div>
|
||||
<div class="box has-background-light mb-4" id="bulkPanel" style="display: none;">
|
||||
<div class="columns is-vcentered is-mobile">
|
||||
<div class="column is-narrow">
|
||||
@ -247,6 +241,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="admin.js" defer></script>
|
||||
<script src="/build/admin.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
187
contact-form.js
@ -1,187 +0,0 @@
|
||||
(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"]');
|
||||
const dateInput = form.querySelector('input[name="eventDate"]');
|
||||
let selectedFiles = [];
|
||||
|
||||
// Forbid past dates (today is allowed)
|
||||
if (dateInput) {
|
||||
const t = new Date();
|
||||
dateInput.min = `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,'0')}-${String(t.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
// 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 firstName = form.querySelector('[name="firstName"]');
|
||||
if (!firstName.value.trim()) { setErr(firstName, 'Please enter your first name.'); ok = false; }
|
||||
else clearErr(firstName);
|
||||
|
||||
const lastName = form.querySelector('[name="lastName"]');
|
||||
if (!lastName.value.trim()) { setErr(lastName, 'Please enter your last name.'); ok = false; }
|
||||
else clearErr(lastName);
|
||||
|
||||
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('firstName', form.querySelector('[name="firstName"]').value.trim());
|
||||
fd.append('lastName', form.querySelector('[name="lastName"]').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' });
|
||||
}
|
||||
})();
|
||||
@ -1,211 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<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>
|
||||
<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; }
|
||||
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||
|
||||
#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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<a class="navbar-item is-tab is-active" href="../contact/">
|
||||
Contact
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
|
||||
<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="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="#">​</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="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">First name <span class="has-text-danger">*</span></label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="firstName" placeholder="Jane" required autocomplete="given-name">
|
||||
</div>
|
||||
<p class="help is-danger" id="err-firstName" style="display:none;"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Last name <span class="has-text-danger">*</span></label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="lastName" placeholder="Smith" required autocomplete="family-name">
|
||||
</div>
|
||||
<p class="help is-danger" id="err-lastName" style="display:none;"></p>
|
||||
</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>
|
||||
|
||||
<h7>Copyright © <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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 © <span id="year"></span> Beach Party Balloons</p>
|
||||
<p>All images & 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>
|
||||
</footer>
|
||||
<script src="../script.js"></script>
|
||||
<!-- <script defer data-domain="beachpartyballoons.com" src="https://metrics.beachpartyballoons.com/js/script.js"></script> -->
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
159
faq/index.html
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<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>
|
||||
@ -45,17 +45,6 @@
|
||||
font-style: italic;
|
||||
color: #2c3e50 !important;
|
||||
}
|
||||
|
||||
.faq-tabs.is-toggle li.is-active a {
|
||||
background-color: #7585ff;
|
||||
border-color: #7585ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.faq-tabs.is-toggle a {
|
||||
border-color: #7585ff;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -115,14 +104,8 @@
|
||||
<section class="section theme-light">
|
||||
<div class="container">
|
||||
<h1 class="title has-text-centered theme-light">Frequently Asked Questions</h1>
|
||||
<div class="tabs is-centered is-toggle is-toggle-rounded faq-tabs">
|
||||
<ul>
|
||||
<li class="is-active" data-tab="faq-tab"><a>FAQ</a></li>
|
||||
<li data-tab="care-tab"><a>Balloon Care</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="faq-tab" class="content tab-content">
|
||||
|
||||
<div class="content">
|
||||
<div class="box">
|
||||
<p class="q">Q: Do you sell bags of balloons or products for DIY?</p>
|
||||
<p>A: Beach Party Balloons does not sell bags of balloons or decor supplies, only finished product.</p>
|
||||
@ -153,124 +136,6 @@
|
||||
<p class="q">Q: My venue does not allow anything to be attached to the wall or helium balloons, what can you do?</p>
|
||||
<p>A: Beach Party Balloons is NOT responsible for any damage caused by balloons, adhesives/attachments, balloons not allowed in by the venue or the cost to retrieve them from the ceiling if they are removed from their weights, which we carefully secure them to. It is your responsibility to check the rules of your venue ahead of time. We offer options like framed garlands, columns, and arches which do not have to be supported by attaching to the wall. If your venue doesn't allow helium balloons, please check if they make an exception for professional decorators, otherwise, we have plenty of air-filled options.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: How far in advance should I book?</p>
|
||||
<p>A: We recommend reaching out as early as possible, especially for weekends and large installs. If your date is coming up soon, contact us anyway and we will do our best to fit you in.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Do you deliver, and how far do you travel?</p>
|
||||
<p>A: Yes, delivery and on-site setup are available. We serve all over Connecticut, and delivery fees depend on distance and the size of the installation.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Do you offer setup and takedown?</p>
|
||||
<p>A: Yes. Many installs include professional setup, and takedown can be arranged in advance if needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Can you match a specific theme or color palette?</p>
|
||||
<p>A: Absolutely. Share your theme, colors, or inspiration photos and we will help you choose balloon colors and styles that fit.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: How much does a balloon installation cost?</p>
|
||||
<p>A: Pricing varies by size, style, colors, delivery distance, and setup needs. The quickest way to get accurate pricing is to request a quote with your date, location, and ideas.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Do you offer same-day or rush orders?</p>
|
||||
<p>A: Sometimes, depending on availability and the size of the order. Call or message us and we will let you know what we can do.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Are balloons safe outdoors?</p>
|
||||
<p>A: Outdoor installs are possible, but heat, sun, wind, and rain can shorten balloon life. We will recommend the best placement and materials for outdoor conditions.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Do you rent or provide frames/stands?</p>
|
||||
<p>A: Yes, we have frames and structures available for arches, garlands, and columns. If you need a freestanding option, let us know.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Q: Can I pick up a custom order?</p>
|
||||
<p>A: Yes. We offer pickup for many custom arrangements. If you are unsure what will fit in your vehicle, ask and we will guide you.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="care-tab" class="content tab-content" style="display: none;">
|
||||
<div class="box">
|
||||
<p class="q">Balloon Care and Safety Guide</p>
|
||||
<p>These tips help your balloons look great for as long as possible and keep everyone safe.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Balloons and temperature</p>
|
||||
<p>Please do not leave balloons in a hot car. Helium expands in heat and can cause balloons to pop. Use air conditioning while transporting on hot days.</p>
|
||||
<p>If balloons get rained on and start to droop, they will usually float again when dry. Helium balloons may temporarily deflate in the cold and re-inflate when warm.</p>
|
||||
<p>A change in temperature can cause vinyl personalized messages to bubble slightly.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Child and pet precautions</p>
|
||||
<p>Balloons are not a toy. Uninflated or popped balloons can be a choking hazard and should never be left with children under eight without supervision.</p>
|
||||
<p>Store balloons away from pets to prevent entanglement or ingestion of broken balloon pieces or decorative contents.</p>
|
||||
<p>If a balloon pops, clear away all pieces immediately.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Latex and allergy notice</p>
|
||||
<p>Most balloons are made of natural rubber latex and may cause allergies. Please watch for symptoms and seek help if needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Do not inhale helium</p>
|
||||
<p>Inhaling helium can be dangerous. It can deprive your body of oxygen and may result in serious injury.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Ceilings and fixtures</p>
|
||||
<p>Lights, textured paint, rough surfaces, and static can pop balloons. If you plan to place balloons on a ceiling, test a small area first.</p>
|
||||
<p>Avoid sharp edges and rough materials.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Latex balloon oxidation</p>
|
||||
<p>Clear balloons can become cloudy when exposed to heat and sun. Colored balloons may take on a soft, matte finish.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Balloons can pop</p>
|
||||
<p>We use high quality balloons and take every precaution, but once balloons leave our care, they are subject to environment and handling.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Balloons and the environment</p>
|
||||
<p>Please dispose of balloons responsibly. At the end of a balloon's life, cut the end with scissors to release the air and place it in the trash.</p>
|
||||
<p>Never release balloons. They become litter and can harm wildlife and the environment.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Latex balloons</p>
|
||||
<p>We use high quality latex balloons. Latex is a natural, plant-based product and is biodegradable over time.</p>
|
||||
<p>Latex is not plastic and is sourced without harming the rubber tree.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Bubble balloons</p>
|
||||
<p>Bubble balloons are clear, stretchy, and long lasting. They are great for indoor and outdoor decor because they do not oxidize.</p>
|
||||
<p>Not suitable for children under 36 months due to small parts. Adult supervision required.</p>
|
||||
<p>When finished, snip the end with scissors before disposing.</p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<p class="q">Foil balloons</p>
|
||||
<p>Foil balloons may conduct electricity. Never release helium-filled foil balloons outdoors.</p>
|
||||
<p>Helium foil balloons must always be attached to a weight. When finished, snip the end and dispose in the trash.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -298,23 +163,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
<script src="../script.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabs = document.querySelectorAll('.faq-tabs li');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(item => item.classList.remove('is-active'));
|
||||
tab.classList.add('is-active');
|
||||
const target = document.getElementById(tab.dataset.tab);
|
||||
tabContents.forEach(content => content.style.display = 'none');
|
||||
if (target) target.style.display = 'block';
|
||||
});
|
||||
});
|
||||
});
|
||||
</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>
|
||||
</html>
|
||||
@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
];
|
||||
let photos = [];
|
||||
let tagMeta = { labels: {}, tags: [], aliases: {} };
|
||||
let tagMeta = { labels: {}, tags: [] };
|
||||
const tagLabel = (slug) => {
|
||||
if (!slug) return '';
|
||||
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
|
||||
@ -56,26 +56,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const slugifyTag = (value) => String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const resolveTagSlug = (value) => {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const aliases = tagMeta.aliases || {};
|
||||
if (aliases[lowerRaw]) {
|
||||
return aliases[lowerRaw];
|
||||
}
|
||||
const labels = tagMeta.labels || {};
|
||||
for (const [slug, label] of Object.entries(labels)) {
|
||||
if (label && label.toLowerCase() === lowerRaw) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
return slugifyTag(raw) || lowerRaw;
|
||||
};
|
||||
|
||||
const apiBaseCandidates = (() => {
|
||||
const protocol = window.location.protocol;
|
||||
@ -108,7 +88,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const response = await fetchWithTimeout(`${baseUrl}/photos/tags`, 3000);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
tagMeta = { labels: {}, tags: [], aliases: {}, ...data };
|
||||
tagMeta = { labels: {}, tags: [], ...data };
|
||||
} catch (err) {
|
||||
// Metadata is optional; fall back to raw tag text if unavailable.
|
||||
}
|
||||
@ -137,12 +117,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
photos = fallbackPhotos;
|
||||
rebuildFilterButtons();
|
||||
}
|
||||
const hashTag = getHashTag();
|
||||
if (hashTag) {
|
||||
applyTagFilter(hashTag, false);
|
||||
} else {
|
||||
applyTagFilter('all', false);
|
||||
}
|
||||
renderFlatGallery(photos);
|
||||
}
|
||||
|
||||
function updateResultCount(count) {
|
||||
@ -163,7 +138,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
filterBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const tag = btn.dataset.tag;
|
||||
applyTagFilter(tag, true);
|
||||
filterByTag(tag.toLowerCase());
|
||||
filterBtns.forEach(otherBtn => otherBtn.classList.remove('is-active'));
|
||||
btn.classList.add('is-active');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -234,11 +211,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
].filter(Boolean).join(', ')
|
||||
: '';
|
||||
const photoTags = normalizeTags(photo.tags);
|
||||
const readableTags = photoTags.map(tagLabel);
|
||||
const photoCard = document.createElement('div');
|
||||
photoCard.className = 'gallery-item';
|
||||
const tagBadges = photoTags
|
||||
.map(tag => `<span class="tag-chip" data-tag="${tag}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tagLabel(tag)}</span>`)
|
||||
.join('');
|
||||
const tagBadges = readableTags.map(tag => `<span class="tag-chip" data-tag="${tag}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tag}</span>`).join('');
|
||||
photoCard.innerHTML = `
|
||||
<div class="gallery-photo">
|
||||
<img loading="lazy" ${srcset ? `srcset="${srcset}" sizes="(min-width: 1024px) 33vw, (min-width: 768px) 45vw, 90vw"` : ''} src="${src}" alt="${photo.caption}" data-caption="${photo.caption}" data-tags="${photoTags.join(',')}" data-full-src="${src}" decoding="async">
|
||||
@ -272,7 +248,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const tagText = chip.dataset.tag || '';
|
||||
applyTagFilter(tagText, true);
|
||||
const slug = normalizeTags(tagText)[0] || tagText.toLowerCase();
|
||||
filterByTag(slug);
|
||||
const matchingBtn = Array.from(filterBtns).find(btn => btn.dataset.tag === slug);
|
||||
filterBtns.forEach(btn => btn.classList.remove('is-active'));
|
||||
if (matchingBtn) matchingBtn.classList.add('is-active');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -280,7 +260,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
function filterPhotos() {
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
const normalizedSearch = resolveTagSlug(searchTerm);
|
||||
// Deactivate tag buttons when searching
|
||||
filterBtns.forEach(btn => btn.classList.remove('is-active'));
|
||||
if (searchTerm) {
|
||||
@ -289,9 +268,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const captionMatch = photo.caption.toLowerCase().includes(searchTerm);
|
||||
const tagMatch = photoTags.some(tag => {
|
||||
const label = tagLabel(tag).toLowerCase();
|
||||
return tag.toLowerCase().includes(searchTerm)
|
||||
|| label.includes(searchTerm)
|
||||
|| (normalizedSearch && tag.toLowerCase() === normalizedSearch);
|
||||
return tag.toLowerCase().includes(searchTerm) || label.includes(searchTerm);
|
||||
});
|
||||
return captionMatch || tagMatch;
|
||||
});
|
||||
@ -304,46 +281,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveFilterButton(tag) {
|
||||
filterBtns.forEach(btn => btn.classList.toggle('is-active', btn.dataset.tag === tag));
|
||||
}
|
||||
|
||||
function setHashTag(tag) {
|
||||
const url = new URL(window.location.href);
|
||||
if (!tag || tag === 'all') {
|
||||
url.hash = '';
|
||||
} else {
|
||||
url.hash = encodeURIComponent(tag);
|
||||
}
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
function filterByTag(tag, updateHash = true) {
|
||||
function filterByTag(tag) {
|
||||
searchInput.value = '';
|
||||
const slug = resolveTagSlug(tag);
|
||||
if (!slug || slug === 'all') {
|
||||
if (tag === 'all') {
|
||||
renderFlatGallery(photos);
|
||||
setActiveFilterButton('all');
|
||||
if (updateHash) setHashTag('');
|
||||
return;
|
||||
} else {
|
||||
const filteredPhotos = photos.filter(photo => {
|
||||
const photoTags = normalizeTags(photo.tags);
|
||||
return photoTags.some(t => t.toLowerCase() === tag);
|
||||
});
|
||||
renderFlatGallery(filteredPhotos);
|
||||
}
|
||||
const filteredPhotos = photos.filter(photo => {
|
||||
const photoTags = normalizeTags(photo.tags);
|
||||
return photoTags.some(t => t.toLowerCase() === slug);
|
||||
});
|
||||
renderFlatGallery(filteredPhotos);
|
||||
setActiveFilterButton(slug);
|
||||
if (updateHash) setHashTag(slug);
|
||||
}
|
||||
|
||||
function getHashTag() {
|
||||
const hash = window.location.hash || '';
|
||||
if (!hash) return '';
|
||||
return resolveTagSlug(decodeURIComponent(hash.replace(/^#/, '')).trim());
|
||||
}
|
||||
|
||||
function applyTagFilter(tag, updateHash = true) {
|
||||
filterByTag(tag, updateHash);
|
||||
}
|
||||
|
||||
function openModal(imageElement) {
|
||||
@ -373,7 +321,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const tagText = chip.dataset.tag || '';
|
||||
applyTagFilter(tagText, true);
|
||||
const slug = normalizeTags(tagText)[0] || tagText.toLowerCase();
|
||||
filterByTag(slug);
|
||||
const matchingBtn = Array.from(filterBtns).find(btn => btn.dataset.tag === slug);
|
||||
filterBtns.forEach(btn => btn.classList.remove('is-active'));
|
||||
if (matchingBtn) matchingBtn.classList.add('is-active');
|
||||
closeModal();
|
||||
});
|
||||
});
|
||||
@ -421,10 +373,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
renderSkeletonLoader();
|
||||
window.addEventListener('hashchange', () => {
|
||||
const tag = getHashTag() || 'all';
|
||||
applyTagFilter(tag, false);
|
||||
});
|
||||
|
||||
fetchPhotos();
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<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>
|
||||
@ -76,6 +76,12 @@
|
||||
<main class="section gallery-wrap">
|
||||
<div class="container">
|
||||
<h1 class="title is-2 has-text-centered mb-4 has-text-dark">Gallery</h1>
|
||||
<div class="has-text-centered">
|
||||
<a class="skip-to-gallery has-background-light has-text-dark" href="#photo-gallery">
|
||||
<i class="fa-solid fa-images"></i>
|
||||
Jump to photos
|
||||
</a>
|
||||
</div>
|
||||
<button onclick="topFunction()" class="has-text-dark" id="top" title="Go to top">Top</button>
|
||||
<div class="box search-box">
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap gap-sm">
|
||||
@ -173,6 +179,6 @@
|
||||
</script>
|
||||
<script src="../script.js" defer></script>
|
||||
<script src="../update.js" defer></script>
|
||||
<script src="gallery.js" defer></script>
|
||||
<script src="/build/gallery.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
195
index.html
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<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>
|
||||
@ -88,10 +88,7 @@
|
||||
<h1 class="is-size-3" style="text-align: center;">Visit our store</h1>
|
||||
<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-form">
|
||||
<button class="button is-info">Contact Us</button>
|
||||
</a>
|
||||
<div class="py-2 has-text-centered"> <a href="#contact-us-vjz40v"> <button class="button is-info ">Contact Us</button</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -121,190 +118,7 @@
|
||||
<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>
|
||||
|
||||
<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="columns is-mobile">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">First name <span class="has-text-danger">*</span></label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="firstName" placeholder="Jane" required autocomplete="given-name">
|
||||
</div>
|
||||
<p class="help is-danger" id="err-firstName" style="display:none;"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Last name <span class="has-text-danger">*</span></label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="lastName" placeholder="Smith" required autocomplete="family-name">
|
||||
</div>
|
||||
<p class="help is-danger" id="err-lastName" style="display:none;"></p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<script src="contact-form.js"></script>
|
||||
|
||||
<hr class="section-divider">
|
||||
<section class="section reviews-section">
|
||||
<div class="container has-text-centered">
|
||||
<h2 class="is-size-3">Google Reviews</h2>
|
||||
<p class="is-size-6 has-text-grey">See what clients are saying, or leave a review after your event.</p>
|
||||
<div class="reviews-summary mt-4">
|
||||
<div class="reviews-stars" aria-label="Average rating: 5.0 out of 5">
|
||||
<span>★</span><span>★</span><span>★</span><span>★</span><span>★</span>
|
||||
</div>
|
||||
<p class="is-size-5 has-text-weight-semibold">Rated <span id="reviewsRatingValue">5.0</span> on Google</p>
|
||||
<p class="is-size-7 has-text-grey">Based on <span id="reviewsCountValue">100+ reviews</span></p>
|
||||
</div>
|
||||
<div class="reviews-grid mt-5" id="reviewsGrid">
|
||||
<article class="review-card">
|
||||
<p>"Stunning balloon arch and super easy to work with. Our guests loved it!"</p>
|
||||
<p class="review-author">— Jordan M., Corporate Event</p>
|
||||
</article>
|
||||
<article class="review-card">
|
||||
<p>"Professional setup and gorgeous colors. Made our celebration unforgettable."</p>
|
||||
<p class="review-author">— Maria L., Birthday Party</p>
|
||||
</article>
|
||||
<article class="review-card">
|
||||
<p>"Fast, friendly, and beautiful designs. Will book again for our next event."</p>
|
||||
<p class="review-author">— Alex T., School Event</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="buttons is-centered mt-4">
|
||||
<a class="button is-info" target="_blank" rel="noopener" href="https://www.google.com/maps/place/Beach+Party+Balloons/@41.2305385,-73.0657635,17z/data=!3m1!4b1!4m6!3m5!1s0x89e80c66edb1f163:0xd0209d75415d0e41!8m2!3d41.2305385!4d-73.0657635!16s%2Fg%2F11bxc5f6tk?entry=ttu&g_ep=EgoyMDI1MTIwOS4wIKXMDSoASAFQAw%3D%3D">Read reviews</a>
|
||||
<a class="button is-light" target="_blank" rel="noopener" href="https://g.page/r/CUEOXUF1nSDQEBE/review">Leave a review</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section trusted-section">
|
||||
<div class="container">
|
||||
<div class="has-text-centered mb-5">
|
||||
<h2 class="is-size-3">Trusted by</h2>
|
||||
<p class="is-size-6 has-text-grey">Brands and organizations we have had the joy of celebrating with.</p>
|
||||
</div>
|
||||
<div class="trusted-logos">
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/512px-Subway_icon.svg.webp" alt="Subway logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/Yale_press_logo.webp" alt="Yale logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/256px-Quinnipiac_University_logo_(2017).svg.webp" alt="Quinnipiac University logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/512px-University_of_New_Haven_logo.webp" alt="University of New Haven logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/Planet_Fitness_(2).webp" alt="Planet Fitness logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/Mohegan-Sun-Logo.webp" alt="Mohegan Sun logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/Post_university_of_conn_logo.webp" alt="Post University logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/logo-full-color.webp" alt="Edge Fitness logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/lincoln-culinary.webp" alt="Lincoln Culinary Institute logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/amazon.webp" alt="Amazon logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo trusted-logo--dark">
|
||||
<img src="assets/trusted/woodwinds-2024-logo-white.webp" alt="Woodwinds logo" loading="lazy">
|
||||
</figure>
|
||||
<figure class="trusted-logo">
|
||||
<img src="assets/trusted/sallys-apizza.webp" alt="Sally's Apizza logo" loading="lazy">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
@ -326,9 +140,8 @@
|
||||
</footer>
|
||||
<script src="script.js"></script>
|
||||
<script src="update.js"></script>
|
||||
<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
@ -18,10 +18,7 @@
|
||||
"body-parser": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.10",
|
||||
"sharp": "^0.34.5"
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
|
||||
@ -2,8 +2,6 @@ const MAIN_TAGS = [
|
||||
{ slug: 'arch', label: 'Arch', aliases: ['arches', 'archway'] },
|
||||
{ slug: 'garland', label: 'Garland', aliases: ['organic', 'organic-garland'] },
|
||||
{ slug: 'columns', label: 'Columns', aliases: ['pillars'] },
|
||||
{ slug: 'centerpiece', label: 'Centerpiece', aliases: ['table', 'tablescape'] },
|
||||
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
|
||||
{ slug: 'birthday', label: 'Birthday', aliases: ['bday', 'birthday-party'] },
|
||||
{ slug: 'baby-shower', label: 'Baby Shower', aliases: ['baby', 'shower'] },
|
||||
{ slug: 'gifts', label: 'Gifts', aliases: ['presents'] },
|
||||
@ -13,54 +11,27 @@ const MAIN_TAGS = [
|
||||
const OTHER_TAGS = [
|
||||
{ slug: 'classic', label: 'Classic', aliases: [] },
|
||||
{ slug: 'organic', label: 'Organic', aliases: [] },
|
||||
{ slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
|
||||
{ slug: 'helium', label: 'Helium', aliases: [] },
|
||||
{ slug: 'air-filled', label: 'Air-filled', aliases: ['airfilled', 'air'] },
|
||||
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
|
||||
{ slug: 'reunion', label: 'Reunion', aliases: [] },
|
||||
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
|
||||
{ slug: 'holiday', label: 'Holiday', aliases: ['holidays'] },
|
||||
{ slug: 'christmas', label: 'Christmas', aliases: ['xmas', 'x-mas'] },
|
||||
{ slug: 'halloween', label: 'Halloween', aliases: [] },
|
||||
{ slug: 'easter', label: 'Easter', aliases: [] },
|
||||
{ slug: 'valentines', label: "Valentine's Day", aliases: ['valentine', 'valentine-day', 'valentines-day'] },
|
||||
{ slug: 'new-years', label: "New Year's", aliases: ['new-year', 'nye', 'new-years-eve', 'new-year-eve'] },
|
||||
{ slug: 'thanksgiving', label: 'Thanksgiving', aliases: ['turkey-day'] },
|
||||
{ slug: 'july-4th', label: 'July 4th', aliases: ['fourth-of-july', '4th-of-july', 'fourth', 'independence-day'] },
|
||||
{ slug: 'st-patricks', label: "St. Patrick's Day", aliases: ['st-pattys', 'st-paddys', 'st-patricks-day'] },
|
||||
{ slug: 'mothers-day', label: "Mother's Day", aliases: ['mothers', 'mom-day'] },
|
||||
{ slug: 'fathers-day', label: "Father's Day", aliases: ['fathers', 'dad-day'] },
|
||||
{ slug: 'graduation-party', label: 'Graduation Party', aliases: ['grad-party', 'graduation-party'] },
|
||||
{ slug: 'holiday', label: 'Holiday', aliases: ['christmas', 'halloween', 'easter'] },
|
||||
{ slug: 'marquee', label: 'Marquee Letters', aliases: ['letters', 'marquee-letters'] },
|
||||
{ slug: 'delivery', label: 'Delivery', aliases: ['deliver', 'delivered'] },
|
||||
{ slug: 'pickup', label: 'Pickup', aliases: ['pick-up', 'collect'] },
|
||||
{ slug: 'neon', label: 'Neon', aliases: ['led', 'light', 'lights'] },
|
||||
];
|
||||
|
||||
const TAG_DEFINITIONS = [...MAIN_TAGS, ...OTHER_TAGS];
|
||||
|
||||
const TAG_PRESETS = [
|
||||
{ name: 'Arch - Classic', tags: ['arch', 'classic'] },
|
||||
{ name: 'Arch - Organic', tags: ['arch', 'organic'] },
|
||||
{ name: 'Arch - Hoop', tags: ['arch', 'hoop'] },
|
||||
{ name: 'Garland - Organic', tags: ['garland', 'organic'] },
|
||||
{ name: 'Columns', tags: ['columns'] },
|
||||
{ name: 'Centerpiece - Helium', tags: ['centerpiece', 'helium'] },
|
||||
{ name: 'Centerpiece - Air-filled', tags: ['centerpiece', 'air-filled'] },
|
||||
{ name: 'Sculpture', tags: ['sculpture'] },
|
||||
{ name: 'Marquee', tags: ['marquee'] }
|
||||
{ name: 'Birthday', tags: ['birthday', 'arch', 'garland'] },
|
||||
{ name: 'Baby Shower', tags: ['baby-shower', 'garland', 'gifts'] },
|
||||
{ name: 'Graduation', tags: ['graduation', 'arch', 'classic'] },
|
||||
{ name: 'Corporate', tags: ['corporate', 'columns', 'delivery'] },
|
||||
{ name: 'Holiday', tags: ['holiday', 'garland', 'marquee'] }
|
||||
];
|
||||
|
||||
const MAX_TAGS = 8;
|
||||
const HOLIDAY_SUBTAGS = new Set([
|
||||
'christmas',
|
||||
'halloween',
|
||||
'easter',
|
||||
'valentines',
|
||||
'new-years',
|
||||
'thanksgiving',
|
||||
'july-4th',
|
||||
'st-patricks',
|
||||
'mothers-day',
|
||||
'fathers-day'
|
||||
]);
|
||||
|
||||
const slugifyTag = (tag) => {
|
||||
return String(tag || '')
|
||||
@ -97,11 +68,6 @@ const normalizeTags = (incomingTags = []) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (normalized.some(tag => HOLIDAY_SUBTAGS.has(tag)) && !seen.has('holiday')) {
|
||||
normalized.push('holiday');
|
||||
seen.add('holiday');
|
||||
}
|
||||
|
||||
return { normalized, rejected: [] };
|
||||
};
|
||||
|
||||
|
||||
@ -66,15 +66,7 @@ router.route('/').get((req, res) => {
|
||||
|
||||
router.route('/tags').get(async (_req, res) => {
|
||||
try {
|
||||
const tagCountsArray = await Photo.aggregate([
|
||||
{ $unwind: '$tags' },
|
||||
{ $group: { _id: '$tags', count: { $sum: 1 } } }
|
||||
]);
|
||||
const tagCounts = tagCountsArray.reduce((acc, item) => {
|
||||
acc[item._id] = item.count;
|
||||
return acc;
|
||||
}, {});
|
||||
const existing = tagCountsArray.map(item => item._id);
|
||||
const existing = await Photo.distinct('tags');
|
||||
res.json({
|
||||
tags: TAG_DEFINITIONS,
|
||||
main: MAIN_TAGS,
|
||||
@ -84,7 +76,6 @@ router.route('/tags').get(async (_req, res) => {
|
||||
maxTags: MAX_TAGS,
|
||||
labels: labelLookup,
|
||||
existing: existing || [],
|
||||
tagCounts,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching tag metadata:', err);
|
||||
@ -97,7 +88,6 @@ router.route('/tags').get(async (_req, res) => {
|
||||
maxTags: MAX_TAGS,
|
||||
labels: labelLookup,
|
||||
existing: [],
|
||||
tagCounts: {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Reprocess uploads for existing Photo documents.
|
||||
*
|
||||
* - For each Photo in Mongo, find a matching source image in uploads/.
|
||||
* - Apply the same watermark + resize pipeline used by the upload endpoint.
|
||||
* - Write main/medium/thumb variants (WEBP) and update the Photo doc paths.
|
||||
* - Photos without a matching source file are skipped.
|
||||
*
|
||||
* Usage:
|
||||
* APPLY=1 node scripts/reprocess_uploads.js # actually write files + update docs
|
||||
* node scripts/reprocess_uploads.js # dry run (default)
|
||||
*
|
||||
* Env:
|
||||
* MONGO_URI (optional) - defaults to mongodb://localhost:27017/photogallery
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const mongoose = require('mongoose');
|
||||
const heicConvert = require('heic-convert');
|
||||
const Photo = require('../models/photo');
|
||||
|
||||
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery';
|
||||
const APPLY = process.env.APPLY === '1';
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
|
||||
const VARIANTS = {
|
||||
main: { size: 2000, quality: 82, suffix: '' },
|
||||
medium: { size: 1200, quality: 80, suffix: '-md' },
|
||||
thumb: { size: 640, quality: 76, suffix: '-sm' },
|
||||
};
|
||||
const SOURCE_EXTS = ['.webp', '.jpg', '.jpeg', '.png', '.heic', '.heif', '.avif', '.bmp', '.tif', '.tiff'];
|
||||
|
||||
const diagonalOverlay = Buffer.from(`
|
||||
<svg width="2400" height="2400" viewBox="0 0 2400 2400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="diagGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.22)" />
|
||||
<stop offset="50%" stop-color="rgba(255,255,255,0.33)" />
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0.22)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(1200 1200) rotate(-32)">
|
||||
<text x="0" y="-80" text-anchor="middle" dominant-baseline="middle"
|
||||
fill="url(#diagGrad)" stroke="rgba(0,0,0,0.16)" stroke-width="8"
|
||||
font-family="Arial Black, Arial, sans-serif" font-size="260" letter-spacing="6" textLength="1800" lengthAdjust="spacingAndGlyphs">
|
||||
BEACH PARTY
|
||||
<tspan x="0" dy="280">BALLOONS</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
const HEIF_BRANDS = new Set(['heic', 'heix', 'hevc', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1', 'avif', 'avis']);
|
||||
const isHeifBuffer = (buffer) => buffer && buffer.length >= 12 && HEIF_BRANDS.has(buffer.slice(8, 12).toString('ascii').toLowerCase());
|
||||
|
||||
function parseBaseName(doc) {
|
||||
const raw = path.basename(doc.filename || doc.path || '', path.extname(doc.filename || doc.path || ''));
|
||||
// Strip any trailing brace artifacts from older filenames
|
||||
const cleaned = raw.replace(/[}]+$/, '');
|
||||
const match = cleaned.match(/^(.*?)(-md|-sm)?$/);
|
||||
return match ? match[1] : cleaned;
|
||||
}
|
||||
|
||||
function deriveCandidateBases(baseName) {
|
||||
const bases = new Set();
|
||||
bases.add(baseName);
|
||||
// If name starts with a leading timestamp and dash, also try the suffix (original filename)
|
||||
const tsMatch = baseName.match(/^(\d{8,})-(.+)$/);
|
||||
if (tsMatch) {
|
||||
bases.add(tsMatch[2]);
|
||||
}
|
||||
return Array.from(bases);
|
||||
}
|
||||
|
||||
function sourceCandidates(doc) {
|
||||
const baseName = parseBaseName(doc);
|
||||
const candidateBases = deriveCandidateBases(baseName);
|
||||
const preferred = new Set();
|
||||
const fromDocPath = doc.path ? doc.path.replace(/^\/+/, '') : '';
|
||||
const fromDocFile = doc.filename ? path.join('uploads', doc.filename) : '';
|
||||
[fromDocPath, fromDocFile]
|
||||
.filter(Boolean)
|
||||
.forEach(rel => preferred.add(path.join(UPLOAD_DIR, rel.replace(/^uploads[\\/]/, ''))));
|
||||
|
||||
// Look for any common source extension using candidate base names
|
||||
for (const base of candidateBases) {
|
||||
for (const ext of SOURCE_EXTS) {
|
||||
preferred.add(path.join(UPLOAD_DIR, `${base}${ext}`));
|
||||
}
|
||||
// Also check variant-style names in case only a variant exists
|
||||
for (const ext of SOURCE_EXTS) {
|
||||
preferred.add(path.join(UPLOAD_DIR, `${base}-md${ext}`));
|
||||
preferred.add(path.join(UPLOAD_DIR, `${base}-sm${ext}`));
|
||||
}
|
||||
}
|
||||
return Array.from(preferred);
|
||||
}
|
||||
|
||||
async function findExistingFile(candidates) {
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const stat = await fsPromises.stat(file);
|
||||
if (stat.isFile()) return file;
|
||||
} catch (_) { /* ignore missing */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSourceBuffer(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
let inputBuffer = await fsPromises.readFile(filePath);
|
||||
if (ext === '.heic' || ext === '.heif' || ext === '.avif' || isHeifBuffer(inputBuffer)) {
|
||||
inputBuffer = await heicConvert({
|
||||
buffer: inputBuffer,
|
||||
format: 'JPEG',
|
||||
quality: 1,
|
||||
});
|
||||
}
|
||||
return inputBuffer;
|
||||
}
|
||||
|
||||
async function stampAndVariants(inputBuffer, baseName) {
|
||||
// Build main stamped image
|
||||
const base = sharp(inputBuffer)
|
||||
.rotate()
|
||||
.resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true })
|
||||
.toColorspace('srgb');
|
||||
|
||||
const { data: baseBuffer, info } = await base.toBuffer({ resolveWithObject: true });
|
||||
const targetWidth = Math.max(Math.floor((info.width || VARIANTS.main.size) * 0.98), 1);
|
||||
const targetHeight = Math.max(Math.floor((info.height || VARIANTS.main.size) * 0.98), 1);
|
||||
|
||||
const overlayBuffer = await sharp(diagonalOverlay, { density: 300 })
|
||||
.resize({ width: targetWidth, height: targetHeight, fit: 'cover' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const stamped = await sharp(baseBuffer)
|
||||
.composite([{ input: overlayBuffer, gravity: 'center' }])
|
||||
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
|
||||
.toBuffer();
|
||||
|
||||
const outputs = {
|
||||
main: { filename: `${baseName}${VARIANTS.main.suffix}.webp`, buffer: stamped },
|
||||
};
|
||||
|
||||
const createVariant = async (key, opts) => {
|
||||
const resized = await sharp(stamped)
|
||||
.resize({ width: opts.size, height: opts.size, fit: 'inside', withoutEnlargement: true })
|
||||
.toFormat('webp', { quality: opts.quality, effort: 5 })
|
||||
.toBuffer();
|
||||
outputs[key] = { filename: `${baseName}${opts.suffix}.webp`, buffer: resized };
|
||||
};
|
||||
await createVariant('medium', VARIANTS.medium);
|
||||
await createVariant('thumb', VARIANTS.thumb);
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
async function processDoc(doc) {
|
||||
const candidates = sourceCandidates(doc);
|
||||
const sourceFile = await findExistingFile(candidates);
|
||||
if (!sourceFile) {
|
||||
return {
|
||||
status: 'missing-source',
|
||||
docId: doc._id,
|
||||
base: parseBaseName(doc),
|
||||
candidates
|
||||
};
|
||||
}
|
||||
|
||||
const inputBuffer = await loadSourceBuffer(sourceFile);
|
||||
const hash = crypto.createHash('sha256').update(inputBuffer).digest('hex');
|
||||
|
||||
const outputs = await stampAndVariants(inputBuffer, parseBaseName(doc));
|
||||
if (APPLY) {
|
||||
for (const { filename, buffer } of Object.values(outputs)) {
|
||||
await fsPromises.writeFile(path.join(UPLOAD_DIR, filename), buffer);
|
||||
}
|
||||
doc.path = path.posix.join('uploads', outputs.main.filename);
|
||||
doc.variants = {
|
||||
medium: path.posix.join('uploads', outputs.medium.filename),
|
||||
thumb: path.posix.join('uploads', outputs.thumb.filename),
|
||||
};
|
||||
doc.hash = doc.hash || hash;
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
return { status: 'processed', docId: doc._id, base: parseBaseName(doc) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(MONGO_URI);
|
||||
console.log(`Connected to Mongo: ${MONGO_URI}`);
|
||||
|
||||
const docs = await Photo.find({});
|
||||
console.log(`Found ${docs.length} photo docs. APPLY=${APPLY ? 'yes' : 'no (dry run)'}`);
|
||||
|
||||
const results = { processed: 0, missing: 0 };
|
||||
const missing = [];
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
const res = await processDoc(doc);
|
||||
if (res.status === 'processed') results.processed++;
|
||||
else {
|
||||
results.missing++;
|
||||
missing.push(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing doc ${doc._id}:`, err.message || err);
|
||||
results.missing++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Done. Processed: ${results.processed}. Skipped (no source/errors): ${results.missing}.`);
|
||||
if (missing.length) {
|
||||
const sample = missing.slice(0, 5);
|
||||
console.log('Missing source examples (up to 5):');
|
||||
for (const item of sample) {
|
||||
console.log(`- doc ${item.docId} base "${item.base}" candidates: ${item.candidates.join(', ')}`);
|
||||
}
|
||||
}
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -10,8 +10,6 @@ const whitelist = [
|
||||
'https://beachpartyballoons.com',
|
||||
'https://www.beachpartyballoons.com',
|
||||
'https://photobackend.beachpartyballoons.com', // Dedicated backend hostname
|
||||
'http://localhost:3052',
|
||||
'http://127.0.0.1:3052',
|
||||
'http://localhost:3050',
|
||||
'http://127.0.0.1:3050',
|
||||
'http://localhost:8080' // Common local dev port
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
window.REVIEW_ROTATION_RATING = '4.9';
|
||||
window.REVIEW_ROTATION_COUNT = '185 reviews';
|
||||
window.REVIEW_ROTATION_DATA = [
|
||||
{
|
||||
author: 'Jessica W.',
|
||||
text: 'If I could give Beach Party Balloons more than five stars, I would! They were super easy to talk to over the phone, worked within my small budget, and provided design suggestions. The balloons were the least stressful part of my event, but they were the highlight!'
|
||||
},
|
||||
{
|
||||
author: 'Wildwolf',
|
||||
text: 'Amazing service, super nice and patient people. Tons of options for balloon styles and arrangements. The staff showed us examples and explained everything. Balloons arrived on time and looked incredible.'
|
||||
},
|
||||
{
|
||||
author: 'Karla R.-P.',
|
||||
text: 'My balloon columns for my baby shower were beautiful. They’re still intact almost a month later. You’re paying for beauty and expertise. I’ll return for all my balloon needs.'
|
||||
},
|
||||
{
|
||||
author: 'Valeska',
|
||||
text: 'They squeezed in a last-minute request and delivered a balloon arch within a week. The decor made the event feel special. Responsive team, timely setup and take-down.'
|
||||
}
|
||||
];
|
||||
83
reviews.js
@ -1,83 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const grid = document.getElementById('reviewsGrid');
|
||||
const ratingEl = document.getElementById('reviewsRatingValue');
|
||||
const countEl = document.getElementById('reviewsCountValue');
|
||||
const reviews = Array.isArray(window.REVIEW_ROTATION_DATA)
|
||||
? window.REVIEW_ROTATION_DATA.filter(item => item && item.text)
|
||||
: [];
|
||||
|
||||
if (!grid || reviews.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buildCard = (review) => {
|
||||
const article = document.createElement('article');
|
||||
article.className = 'review-card';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `"${review.text}"`;
|
||||
const author = document.createElement('p');
|
||||
author.className = 'review-author';
|
||||
author.textContent = `— ${review.author || 'Happy Client'}`;
|
||||
article.appendChild(text);
|
||||
article.appendChild(author);
|
||||
return article;
|
||||
};
|
||||
|
||||
const fadeSwap = (cards) => {
|
||||
grid.classList.add('is-fading');
|
||||
setTimeout(() => {
|
||||
grid.innerHTML = '';
|
||||
cards.forEach(card => grid.appendChild(card));
|
||||
requestAnimationFrame(() => {
|
||||
grid.classList.remove('is-fading');
|
||||
});
|
||||
}, 350);
|
||||
};
|
||||
|
||||
const startRotation = () => {
|
||||
let index = 0;
|
||||
let expandedCard = null;
|
||||
let rotationTimer = null;
|
||||
const rotationIntervalMs = 8000;
|
||||
const expandedHoldMs = 15000;
|
||||
|
||||
const showNext = () => {
|
||||
const slice = reviews.slice(index, index + 3);
|
||||
const display = slice.length < 3
|
||||
? slice.concat(reviews.slice(0, Math.max(0, 3 - slice.length)))
|
||||
: slice;
|
||||
fadeSwap(display.map(buildCard));
|
||||
index = (index + 3) % reviews.length;
|
||||
};
|
||||
|
||||
const scheduleNext = (delay) => {
|
||||
if (rotationTimer) {
|
||||
clearTimeout(rotationTimer);
|
||||
}
|
||||
rotationTimer = setTimeout(showNext, delay);
|
||||
};
|
||||
|
||||
grid.addEventListener('click', (event) => {
|
||||
const card = event.target.closest('.review-card');
|
||||
if (!card) return;
|
||||
if (expandedCard && expandedCard !== card) {
|
||||
expandedCard.classList.remove('is-expanded');
|
||||
}
|
||||
card.classList.toggle('is-expanded');
|
||||
expandedCard = card.classList.contains('is-expanded') ? card : null;
|
||||
scheduleNext(expandedCard ? expandedHoldMs : rotationIntervalMs);
|
||||
});
|
||||
|
||||
showNext();
|
||||
scheduleNext(rotationIntervalMs);
|
||||
};
|
||||
|
||||
if (ratingEl && window.REVIEW_ROTATION_RATING) {
|
||||
ratingEl.textContent = window.REVIEW_ROTATION_RATING;
|
||||
}
|
||||
if (countEl && window.REVIEW_ROTATION_COUNT) {
|
||||
countEl.textContent = window.REVIEW_ROTATION_COUNT;
|
||||
}
|
||||
|
||||
startRotation();
|
||||
});
|
||||
144
server.js
@ -8,9 +8,6 @@ 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;
|
||||
@ -35,38 +32,6 @@ 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();
|
||||
|
||||
@ -91,113 +56,6 @@ 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 { firstName, lastName, email, phone, message, eventType, eventDate } = req.body;
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ');
|
||||
|
||||
if (!firstName || !lastName || !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}\${eventDateFormatted ? \` — \${eventDateFormatted}\` : ''}\`,
|
||||
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)
|
||||
);
|
||||
if (process.env.NTFY_URL) {
|
||||
const ntfyBody = [
|
||||
phone,
|
||||
eventDateFormatted || null,
|
||||
eventType || null,
|
||||
message.slice(0, 100) + (message.length > 100 ? '…' : ''),
|
||||
].filter(Boolean).join(' · ');
|
||||
fetch(process.env.NTFY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Title': `🎈 New inquiry — ${name}`,
|
||||
'Priority': 'default',
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: ntfyBody,
|
||||
}).catch(err => console.error(`[${new Date().toISOString()}] ntfy 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);
|
||||
|
||||
@ -205,7 +63,7 @@ app.use('/api', apiRouter);
|
||||
const staticCacheOptions = {
|
||||
maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0,
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('.html') || filePath.endsWith('update.json')) {
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
} else if (/\.(js|css|svg|ico|png|jpg|jpeg|webp|avif|woff2?)$/i.test(filePath)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
|
||||
154
style.css
@ -14,43 +14,6 @@ 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;
|
||||
@ -150,7 +113,6 @@ header {
|
||||
|
||||
.navbar-item img, .navbar-item svg {
|
||||
max-height: 1.30em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.is-overlay {
|
||||
@ -237,122 +199,6 @@ form{
|
||||
margin-bottom: 1rem;;
|
||||
}
|
||||
|
||||
.reviews-section {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.reviews-section h2,
|
||||
.reviews-section .is-size-3 {
|
||||
color: #7585ff !important;
|
||||
}
|
||||
|
||||
.reviews-summary {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.reviews-stars {
|
||||
font-size: 1.4rem;
|
||||
color: #f2b01e;
|
||||
letter-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
.reviews-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.reviews-grid.is-fading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e6dfc8;
|
||||
border-radius: 14px;
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: 0 10px 22px rgba(24, 40, 72, 0.08);
|
||||
text-align: left;
|
||||
min-height: 150px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.review-card p {
|
||||
margin-bottom: 0.5rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.review-author {
|
||||
color: #6b6b6b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.review-card.is-expanded {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.review-card.is-expanded p {
|
||||
display: block;
|
||||
-webkit-line-clamp: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.trusted-section {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.trusted-section h2,
|
||||
.trusted-section .is-size-3 {
|
||||
color: #7585ff !important;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(117, 133, 255, 0.35);
|
||||
margin: 2rem auto 0;
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
|
||||
.trusted-logos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trusted-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 96px;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 22px rgba(24, 40, 72, 0.08);
|
||||
border: 1px solid #e6dfc8;
|
||||
}
|
||||
|
||||
.trusted-logo img {
|
||||
height: 52px;
|
||||
width: 100%;
|
||||
max-width: 150px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.trusted-logo--dark {
|
||||
background: #1b1b1b;
|
||||
border-color: #1b1b1b;
|
||||
}
|
||||
|
||||
.gallery-hero {
|
||||
background: linear-gradient(135deg, #12b7ad, #1078c2);
|
||||
color: #fff;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<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>
|
||||
|
||||