Compare commits

...

14 Commits

29 changed files with 928 additions and 65 deletions

4
.gitignore vendored
View File

@ -39,3 +39,7 @@ gallery/sculpture/index.html
# Build artifacts and backups
public/build/
backups/
# Local database files
mongodb_data/
photo-gallery-app/backend/uploads/

View File

@ -5,3 +5,16 @@
.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;
}
}

View File

@ -21,6 +21,7 @@ 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');
@ -55,7 +56,17 @@ document.addEventListener('DOMContentLoaded', () => {
const responseDiv = document.getElementById('response');
const backendUrl = (() => {
const { protocol } = window.location;
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 backendHostname = 'photobackend.beachpartyballoons.com';
return `${protocol}//${backendHostname}`; // No explicit port needed as it's on 443
})();
@ -69,7 +80,8 @@ document.addEventListener('DOMContentLoaded', () => {
presets: [],
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: []
existing: [],
tagCounts: {}
};
let adminPassword = '';
const storedPassword = localStorage.getItem('bpb-admin-password');
@ -80,6 +92,11 @@ 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];
@ -165,6 +182,7 @@ document.addEventListener('DOMContentLoaded', () => {
labels: {},
maxTags: DEFAULT_MAX_TAGS,
existing: [],
tagCounts: {},
...data
};
updateTagSuggestions();
@ -209,11 +227,27 @@ document.addEventListener('DOMContentLoaded', () => {
function renderManageGallery() {
manageGallery.innerHTML = '';
if (!photos.length) {
manageGallery.innerHTML = '<div class="column"><p class="has-text-grey">No photos yet. Upload a photo to get started.</p></div>';
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>`;
return;
}
photos.forEach(photo => {
filtered.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' : '';
@ -466,6 +500,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
if (manageSearchInput) {
manageSearchInput.addEventListener('input', () => renderManageGallery());
}
selectAllPhotosBtn.addEventListener('click', (e) => {
e.preventDefault();
toggleSelectAll();
@ -582,13 +620,28 @@ 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 = [
...(tagMeta.main || []),
...(tagMeta.other || []),
...((tagMeta.existing || []).map(slug => ({ slug, label: displayTag(slug) })))
...mainSorted,
...otherSorted,
...existingSorted.map(slug => ({ slug, label: displayTag(slug) }))
];
const seen = new Set();
suggestions.forEach(tag => {
@ -604,8 +657,12 @@ 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 || []).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>`);
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>`);
quickTagButtons.innerHTML = [...presetButtons, ...mainButtons, ...otherButtons].join('');
}

View File

@ -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/*" multiple required>
<p class="help is-size-7 has-text-grey">Select one or many images; each will be converted to WebP automatically.</p>
<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>
</div>
</div>
<div class="field">
@ -111,6 +111,12 @@
</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">
@ -241,6 +247,6 @@
</div>
</div>
<script src="/build/admin.js" defer></script>
<script src="admin.js" defer></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
assets/trusted/amazon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -45,6 +45,17 @@
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>
@ -104,8 +115,14 @@
<section class="section theme-light">
<div class="container">
<h1 class="title has-text-centered theme-light">Frequently Asked Questions</h1>
<div class="content">
<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="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>
@ -136,6 +153,124 @@
<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>
@ -163,7 +298,23 @@
</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>

View File

@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
];
let photos = [];
let tagMeta = { labels: {}, tags: [] };
let tagMeta = { labels: {}, tags: [], aliases: {} };
const tagLabel = (slug) => {
if (!slug) return '';
if (tagMeta.labels && tagMeta.labels[slug]) return tagMeta.labels[slug];
@ -56,6 +56,26 @@ 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;
@ -88,7 +108,7 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetchWithTimeout(`${baseUrl}/photos/tags`, 3000);
if (!response.ok) return;
const data = await response.json();
tagMeta = { labels: {}, tags: [], ...data };
tagMeta = { labels: {}, tags: [], aliases: {}, ...data };
} catch (err) {
// Metadata is optional; fall back to raw tag text if unavailable.
}
@ -117,7 +137,12 @@ document.addEventListener('DOMContentLoaded', () => {
photos = fallbackPhotos;
rebuildFilterButtons();
}
renderFlatGallery(photos);
const hashTag = getHashTag();
if (hashTag) {
applyTagFilter(hashTag, false);
} else {
applyTagFilter('all', false);
}
}
function updateResultCount(count) {
@ -138,9 +163,7 @@ document.addEventListener('DOMContentLoaded', () => {
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tag = btn.dataset.tag;
filterByTag(tag.toLowerCase());
filterBtns.forEach(otherBtn => otherBtn.classList.remove('is-active'));
btn.classList.add('is-active');
applyTagFilter(tag, true);
});
});
}
@ -211,10 +234,11 @@ 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 = readableTags.map(tag => `<span class="tag-chip" data-tag="${tag}"><i class="fa-solid fa-wand-magic-sparkles"></i>${tag}</span>`).join('');
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('');
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">
@ -248,11 +272,7 @@ document.addEventListener('DOMContentLoaded', () => {
chip.addEventListener('click', (e) => {
e.stopPropagation();
const tagText = chip.dataset.tag || '';
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');
applyTagFilter(tagText, true);
});
});
});
@ -260,6 +280,7 @@ 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) {
@ -268,7 +289,9 @@ 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);
return tag.toLowerCase().includes(searchTerm)
|| label.includes(searchTerm)
|| (normalizedSearch && tag.toLowerCase() === normalizedSearch);
});
return captionMatch || tagMatch;
});
@ -281,17 +304,46 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
function filterByTag(tag) {
searchInput.value = '';
if (tag === 'all') {
renderFlatGallery(photos);
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 {
const filteredPhotos = photos.filter(photo => {
const photoTags = normalizeTags(photo.tags);
return photoTags.some(t => t.toLowerCase() === tag);
});
renderFlatGallery(filteredPhotos);
url.hash = encodeURIComponent(tag);
}
history.replaceState(null, '', url);
}
function filterByTag(tag, updateHash = true) {
searchInput.value = '';
const slug = resolveTagSlug(tag);
if (!slug || slug === 'all') {
renderFlatGallery(photos);
setActiveFilterButton('all');
if (updateHash) setHashTag('');
return;
}
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) {
@ -321,11 +373,7 @@ document.addEventListener('DOMContentLoaded', () => {
chip.addEventListener('click', (e) => {
e.stopPropagation();
const tagText = chip.dataset.tag || '';
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');
applyTagFilter(tagText, true);
closeModal();
});
});
@ -373,5 +421,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
renderSkeletonLoader();
window.addEventListener('hashchange', () => {
const tag = getHashTag() || 'all';
applyTagFilter(tag, false);
});
fetchPhotos();
});

View File

@ -76,12 +76,6 @@
<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">
@ -179,6 +173,6 @@
</script>
<script src="../script.js" defer></script>
<script src="../update.js" defer></script>
<script src="/build/gallery.js" defer></script>
<script src="gallery.js" defer></script>
</body>
</html>

View File

@ -88,7 +88,10 @@
<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-us-vjz40v"> <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>
@ -118,7 +121,87 @@
<p id="hours-saturday" class="has-text-centered">Saturday: 9:00 - 3:00</p>
<p id="hours-sunday-monday" class="has-text-centered">Sunday - Monday: Closed</p>
<iframe style="border:none;width:100%;" id="contact-us-vjz40v" src="https://forms.beachpartyballoons.com/forms/contact-us-vjz40v"></iframe><script type="text/javascript" onload="initEmbed('contact-us-vjz40v')" src="https://forms.beachpartyballoons.com/widgets/iframe.min.js"></script>
<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>
<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>
<footer class="footer has-background-primary-light">
<div class="content has-text-centered">
@ -140,6 +223,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>

View File

@ -2,6 +2,8 @@ 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'] },
@ -11,27 +13,54 @@ const MAIN_TAGS = [
const OTHER_TAGS = [
{ slug: 'classic', label: 'Classic', aliases: [] },
{ slug: 'organic', label: 'Organic', aliases: [] },
{ slug: 'sculpture', label: 'Sculpture', aliases: ['sculpt'] },
{ slug: 'hoop', label: 'Hoop', aliases: ['ring'] },
{ slug: 'helium', label: 'Helium', aliases: [] },
{ slug: 'air-filled', label: 'Air-filled', aliases: ['airfilled', 'air'] },
{ slug: 'reunion', label: 'Reunion', aliases: [] },
{ slug: 'corporate', label: 'Corporate', aliases: ['business', 'office'] },
{ slug: 'holiday', label: 'Holiday', aliases: ['christmas', 'halloween', 'easter'] },
{ 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: '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: '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'] }
{ 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'] }
];
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 || '')
@ -68,6 +97,11 @@ const normalizeTags = (incomingTags = []) => {
}
});
if (normalized.some(tag => HOLIDAY_SUBTAGS.has(tag)) && !seen.has('holiday')) {
normalized.push('holiday');
seen.add('holiday');
}
return { normalized, rejected: [] };
};

View File

@ -66,7 +66,15 @@ router.route('/').get((req, res) => {
router.route('/tags').get(async (_req, res) => {
try {
const existing = await Photo.distinct('tags');
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);
res.json({
tags: TAG_DEFINITIONS,
main: MAIN_TAGS,
@ -76,6 +84,7 @@ router.route('/tags').get(async (_req, res) => {
maxTags: MAX_TAGS,
labels: labelLookup,
existing: existing || [],
tagCounts,
});
} catch (err) {
console.error('Error fetching tag metadata:', err);
@ -88,6 +97,7 @@ router.route('/tags').get(async (_req, res) => {
maxTags: MAX_TAGS,
labels: labelLookup,
existing: [],
tagCounts: {},
});
}
});

View File

@ -0,0 +1,234 @@
#!/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);
});

View File

@ -10,6 +10,8 @@ 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

20
reviews-data.js Normal file
View File

@ -0,0 +1,20 @@
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. Theyre still intact almost a month later. Youre paying for beauty and expertise. Ill 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 Normal file
View File

@ -0,0 +1,83 @@
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();
});

View File

@ -63,7 +63,7 @@ app.use('/api', apiRouter);
const staticCacheOptions = {
maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
if (filePath.endsWith('.html') || filePath.endsWith('update.json')) {
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');

117
style.css
View File

@ -113,6 +113,7 @@ header {
.navbar-item img, .navbar-item svg {
max-height: 1.30em;
border-radius: 8px;
}
.is-overlay {
@ -199,6 +200,122 @@ 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;

View File

@ -35,7 +35,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
fetch('update.json')
fetch(`/update.json?ts=${Date.now()}`, { cache: 'no-store' })
.then(response => response.json())
.then(data => {
const update = data[0];