patx/rcraft_const

Connect enquiry form to Formspree with optional photo uploads

Commit 0c84cbd · patx · 2026-09-10T12:55:00-04:00

Changeset
0c84cbd8eb29280bf023f0be37268ed8ab883538
Parents
de8d592a1afdcaaabfadaf92f9ffde29a3ffaaa5

View source at this commit

Comments

No comments yet.

Log in to comment

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');