patx/rcraft_const
Connect enquiry form to Formspree with optional photo uploads
Commit 0c84cbd · patx · 2026-09-10T12:55:00-04:00
Comments
No comments yet.
Diff
diff --git a/docs/assets/contact-photos.js b/docs/assets/contact-photos.js
new file mode 100644
index 0000000..dc12f0c
--- /dev/null
+++ b/docs/assets/contact-photos.js
@@ -0,0 +1,136 @@
+const contactPhotos = (() => {
+ const photoInput = document.getElementById('project-photos');
+ const photoList = document.getElementById('photo-list');
+ const photoStatus = document.getElementById('photos-status');
+ const formError = document.getElementById('form-error');
+ const maxPhotos = 5;
+ const maxPhotoBytes = 25 * 1024 * 1024;
+ const maxTotalPhotoBytes = 90 * 1024 * 1024;
+ let selectedPhotos = [];
+ function clearFormError() { formError.hidden = true; formError.textContent = ''; }
+ function formatPhotoStatus(files) {
+ if (!files.length) return '';
+ return files.length + ' of ' + maxPhotos + (files.length === 1 ? ' photo is' : ' photos are') + ' ready to send.';
+ }
+
+ function photoValidationMessage(files) {
+ var totalBytes = files.reduce(function (total, file) { return total + file.size; }, 0);
+ var oversizedPhoto = files.find(function (file) { return file.size > maxPhotoBytes; });
+ var nonPhoto = files.find(function (file) {
+ return file.type ? file.type.indexOf('image/') !== 0 : !/\.(jpe?g|png|gif|webp|heic|heif|avif|bmp|tiff?)$/i.test(file.name);
+ });
+
+ if (files.length > maxPhotos) {
+ return 'Choose no more than 5 photos. Remove one before adding another.';
+ }
+ if (oversizedPhoto) {
+ return oversizedPhoto.name + ' is larger than 25 MB and was not added.';
+ }
+ if (totalBytes > maxTotalPhotoBytes) {
+ return 'Keep the combined photo size under 90 MB. Those photos were not added.';
+ }
+ if (nonPhoto) return 'Choose image files only.';
+ return '';
+ }
+
+ function formatPhotoSize(bytes) {
+ if (bytes < 1024 * 1024) return Math.max(1, Math.round(bytes / 1024)) + ' KB';
+ return (bytes / (1024 * 1024)).toFixed(1).replace('.0', '') + ' MB';
+ }
+
+ function photoKey(file) {
+ return [file.name, file.size, file.lastModified, file.type].join('|');
+ }
+
+ function renderPhotos(message) {
+ photoList.textContent = '';
+ selectedPhotos.forEach(function (file, index) {
+ var item = document.createElement('li');
+ var fileDetails = document.createElement('span');
+ var fileName = document.createElement('span');
+ var fileSize = document.createElement('span');
+ var removeButton = document.createElement('button');
+
+ item.className = 'photo-item';
+ fileDetails.className = 'photo-file';
+ fileName.className = 'photo-name';
+ fileName.textContent = file.name;
+ fileSize.className = 'photo-size';
+ fileSize.textContent = 'Photo ' + (index + 1) + ' · ' + formatPhotoSize(file.size);
+ removeButton.className = 'photo-remove';
+ removeButton.type = 'button';
+ removeButton.dataset.photoIndex = index;
+ removeButton.setAttribute('aria-label', 'Remove ' + file.name);
+ removeButton.textContent = 'Remove';
+
+ fileDetails.appendChild(fileName);
+ fileDetails.appendChild(fileSize);
+ item.appendChild(fileDetails);
+ item.appendChild(removeButton);
+ photoList.appendChild(item);
+ });
+
+ photoList.hidden = selectedPhotos.length === 0;
+ photoStatus.textContent = message || formatPhotoStatus(selectedPhotos);
+ }
+
+ photoInput.addEventListener('change', function () {
+ if (photoInput.disabled) return;
+ clearFormError();
+ var incomingPhotos = Array.prototype.slice.call(photoInput.files || []);
+ var selectedKeys = selectedPhotos.map(photoKey);
+ var duplicateCount = 0;
+
+ photoInput.value = '';
+ incomingPhotos = incomingPhotos.filter(function (file) {
+ var key = photoKey(file);
+ if (selectedKeys.indexOf(key) !== -1) {
+ duplicateCount += 1;
+ return false;
+ }
+ selectedKeys.push(key);
+ return true;
+ });
+
+ var candidatePhotos = selectedPhotos.concat(incomingPhotos);
+ var message = photoValidationMessage(candidatePhotos);
+ photoInput.setCustomValidity('');
+
+ if (message) {
+ renderPhotos(message);
+ return;
+ }
+
+ selectedPhotos = candidatePhotos;
+ renderPhotos(duplicateCount
+ ? (duplicateCount === 1 ? 'That photo is already selected. ' : 'Those photos are already selected. ') + formatPhotoStatus(selectedPhotos)
+ : '');
+ });
+
+ photoList.addEventListener('click', function (e) {
+ var removeButton = e.target.closest('.photo-remove');
+ if (!removeButton || photoInput.disabled) return;
+
+ var index = Number(removeButton.dataset.photoIndex);
+ if (!Number.isInteger(index) || !selectedPhotos[index]) return;
+
+ selectedPhotos.splice(index, 1);
+ photoInput.setCustomValidity('');
+ clearFormError();
+ renderPhotos();
+ photoInput.focus();
+ });
+
+
+ return {
+ appendTo(data) {
+ data.delete(photoInput.name);
+ selectedPhotos.forEach(file => data.append(photoInput.name, file, file.name));
+ },
+ clear() { selectedPhotos = []; photoInput.value = ''; renderPhotos(); },
+ setDisabled(disabled) {
+ photoInput.disabled = disabled;
+ photoList.querySelectorAll('button').forEach(button => { button.disabled = disabled; });
+ }
+ };
+})();
diff --git a/docs/assets/site.css b/docs/assets/site.css
index 9141c1c..a61e180 100644
--- a/docs/assets/site.css
+++ b/docs/assets/site.css
@@ -1171,3 +1171,22 @@ a:focus-visible, summary:focus-visible { outline: 2px solid var(--gold-light); o
footer .contact-email { margin-top: 1rem; }
.footer-phone { padding: 0.85rem 1rem; }
.contact-detail .contact-email { min-width: 0; }
+
+/* Optional project photographs */
+.photo-field { position: relative; }
+.photo-input { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+.photo-picker { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; padding: 1rem; border: 1px dashed var(--stone); cursor: pointer; color: var(--light-stone); font-size: 0.85rem; line-height: 1.6; }
+.photo-picker-action { color: var(--gold-light); font-weight: 500; }
+.photo-picker:hover { border-color: var(--gold-light); }
+.photo-input:focus-visible + .photo-picker { outline: 2px solid var(--gold-light); outline-offset: 4px; }
+.photo-input:disabled + .photo-picker { opacity: 0.5; cursor: wait; }
+.photo-help, .photo-status { font-size: 0.8rem; line-height: 1.7; color: var(--light-stone); }
+.photo-status { color: var(--gold-light); }
+.photo-list { list-style: none; margin: 0.5rem 0; }
+.photo-item { display: flex; align-items: center; gap: 1rem; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid rgba(184,146,58,0.2); }
+.photo-file { min-width: 0; }
+.photo-name, .photo-size { display: block; overflow-wrap: anywhere; font-size: 0.85rem; line-height: 1.6; }
+.photo-size { font-size: 0.75rem; color: var(--light-stone); }
+.photo-remove { flex-shrink: 0; min-height: 44px; padding: 0.5rem; font: inherit; font-size: 0.8rem; color: var(--gold-light); background: transparent; border: 1px solid var(--stone); cursor: pointer; }
+.photo-remove:focus-visible { outline: 2px solid var(--gold-light); outline-offset: 3px; }
+.photo-remove:disabled { opacity: 0.5; cursor: wait; }
diff --git a/docs/index.html b/docs/index.html
index cf3546d..9c2f67c 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -440,28 +440,28 @@
<span class="contact-value">Monday – Saturday<br>7:00am – 6:00pm</span>
</div>
</div>
- <form class="contact-form" onsubmit="handleSubmit(event)">
+ <form class="contact-form" action="https://formspree.io/f/moeqrdkp" method="POST" enctype="multipart/form-data" onsubmit="handleSubmit(event)">
<div class="form-row">
<div class="form-group">
- <label class="form-label">First Name</label>
- <input class="form-input" type="text" placeholder="John">
+ <label class="form-label" for="first-name">First Name</label>
+ <input id="first-name" name="first_name" autocomplete="given-name" required class="form-input" type="text" placeholder="John">
</div>
<div class="form-group">
- <label class="form-label">Surname</label>
- <input class="form-input" type="text" placeholder="Smith">
+ <label class="form-label" for="surname">Surname</label>
+ <input id="surname" name="surname" autocomplete="family-name" class="form-input" type="text" placeholder="Smith">
</div>
</div>
<div class="form-group">
- <label class="form-label">Email Address</label>
- <input class="form-input" type="email" placeholder="[email protected]">
+ <label class="form-label" for="email">Email Address</label>
+ <input id="email" name="email" autocomplete="email" required class="form-input" type="email" placeholder="[email protected]">
</div>
<div class="form-group">
- <label class="form-label">Phone Number</label>
- <input class="form-input" type="tel" placeholder="+44 7XXX XXXXXX">
+ <label class="form-label" for="phone">Phone Number</label>
+ <input id="phone" name="phone" autocomplete="tel" class="form-input" type="tel" placeholder="+44 7XXX XXXXXX">
</div>
<div class="form-group">
- <label class="form-label">Service Required</label>
- <select class="form-select">
+ <label class="form-label" for="service">Service Required</label>
+ <select id="service" name="service" class="form-select">
<option value="">Choose a service…</option>
<option>Extension or Loft Conversion</option>
<option>Refurbishment</option>
@@ -472,11 +472,23 @@
</select>
</div>
<div class="form-group">
- <label class="form-label">Tell Us About Your Building Project</label>
- <textarea class="form-input" placeholder="Tell us about the work, your postcode and preferred timescale…"></textarea>
+ <label class="form-label" for="message">Tell Us About Your Building Project</label>
+ <textarea id="message" name="message" required class="form-input" placeholder="Tell us about the work, your postcode and preferred timescale…"></textarea>
+ </div>
+ <div class="form-group photo-field">
+ <label class="form-label" for="project-photos">Project photos · Optional</label>
+ <input class="photo-input" id="project-photos" name="photos" type="file" accept="image/*" multiple aria-describedby="photos-help photos-status">
+ <label class="photo-picker" for="project-photos">
+ <span class="photo-picker-action">Add photos</span>
+ <span>One at a time or several together</span>
+ </label>
+ <p class="photo-help" id="photos-help">Show us the property or the area you would like to improve. Add up to 5 photos, with a maximum of 25 MB each and 90 MB in total. You can open the picker again to add more.</p>
+ <ul class="photo-list" id="photo-list" aria-label="Selected photos" hidden></ul>
+ <p class="photo-status" id="photos-status" aria-live="polite"></p>
</div>
<button type="submit" class="btn-primary" style="width:100%;padding:1.1rem;">Send Enquiry</button>
- <div id="form-success" style="display:none;text-align:center;padding:1rem;color:var(--gold);font-size:0.85rem;letter-spacing:0.1em;text-transform:uppercase;">✓ Message sent — we will be in touch shortly</div>
+ <p id="form-error" role="alert" tabindex="-1" hidden></p>
+ <div id="form-success" role="status" tabindex="-1" style="display:none;text-align:center;padding:1rem;color:var(--gold);font-size:0.85rem;letter-spacing:0.1em;text-transform:uppercase;">✓ Message sent — we will be in touch shortly</div>
</form>
</section>
@@ -536,20 +548,64 @@
<img id="image-modal-img" src="" alt="">
</div>
+<script src="assets/contact-photos.js"></script>
<script>
-function handleSubmit(e) {
+async function handleSubmit(e) {
e.preventDefault();
const form = e.target;
+ const submitButton = form.querySelector('button[type="submit"]');
+ if (submitButton.disabled || !form.reportValidity()) return;
+
const successMessage = document.getElementById('form-success');
+ const errorMessage = document.getElementById('form-error');
+ const data = new FormData(form);
+ contactPhotos.appendTo(data);
+ contactPhotos.setDisabled(true);
+ submitButton.disabled = true;
+ submitButton.textContent = 'Sending…';
+ form.setAttribute('aria-busy', 'true');
+ errorMessage.hidden = true;
+
+ try {
+ const response = await fetch(form.action, {
+ method: 'POST',
+ body: data,
+ headers: { Accept: 'application/json' }
+ });
+ if (!response.ok) {
+ const result = await response.json().catch(() => ({}));
+ const details = Array.isArray(result.errors)
+ ? result.errors.map(error => error.message).filter(Boolean).join(' ')
+ : '';
+ const codes = Array.isArray(result.errors) ? result.errors.map(error => error.code) : [];
+ if (codes.includes('NO_FILE_UPLOADS')) {
+ throw new Error('Photos could not be accepted. Remove the photos and send your enquiry again, or email them to [email protected].');
+ }
+ if (response.status === 413 || codes.includes('FILES_TOO_BIG')) {
+ throw new Error('The photo upload is too large. Please choose smaller photos and try again.');
+ }
+ throw new Error(response.status === 429
+ ? 'Too many enquiries have been sent. Please wait a few minutes and try again, or call 07485 133871.'
+ : details || 'Your enquiry could not be sent. Please try again, or call 07485 133871.');
+ }
- form.reset();
- form.classList.add('is-submitted');
- form.querySelectorAll('input, select, textarea, button[type="submit"]').forEach((control) => {
- control.disabled = true;
- });
- successMessage.style.display = 'block';
- successMessage.setAttribute('tabindex', '-1');
- successMessage.focus();
+ form.reset();
+ contactPhotos.clear();
+ form.classList.add('is-submitted');
+ successMessage.style.display = 'block';
+ successMessage.focus();
+ } catch (error) {
+ errorMessage.textContent = error instanceof TypeError
+ ? 'We could not connect to send your enquiry. Please check your connection and try again, or call 07485 133871.'
+ : error.message;
+ errorMessage.hidden = false;
+ errorMessage.focus();
+ } finally {
+ form.removeAttribute('aria-busy');
+ contactPhotos.setDisabled(false);
+ submitButton.disabled = false;
+ submitButton.textContent = 'Send Enquiry';
+ }
}
const siteNav = document.querySelector('nav');