patx/demo
update vold form
Commit 41a8f77 · patx · 2026-08-16T17:05:58-04:00
Comments
No comments yet.
Diff
diff --git a/docs/voldservices/index.html b/docs/voldservices/index.html
index 76cc772..fc92245 100644
--- a/docs/voldservices/index.html
+++ b/docs/voldservices/index.html
@@ -922,7 +922,7 @@
margin-top: 50px;
align-items: start;
}
- .quote-side, .quote-form {
+ .quote-side, .quote-form, .form-success {
border: 1px solid var(--line);
border-radius: 4px;
padding: 32px 30px;
@@ -978,7 +978,9 @@
transform: translateY(-2px);
}
+ .quote-form-wrap { min-width: 0; }
.quote-form { display: grid; gap: 18px; }
+ .quote-form[hidden], .form-success[hidden], .form-error[hidden] { display: none; }
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1013,23 +1015,95 @@
.field select option { background: var(--ink-2); color: var(--bone); }
.field textarea { min-height: 122px; resize: vertical; }
+ .field input[type="file"] {
+ padding: 7px;
+ color: var(--muted);
+ cursor: pointer;
+ }
+ .field input[type="file"]::file-selector-button {
+ min-height: 40px;
+ margin-right: 13px;
+ padding: 0 16px;
+ border: 1px solid var(--line);
+ border-radius: 2px;
+ background: rgba(7, 142, 209, .14);
+ color: var(--bone);
+ font-family: var(--mono);
+ font-size: .72rem;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: border-color .18s ease, background .18s ease, color .18s ease;
+ }
+ .field input[type="file"]:hover::file-selector-button {
+ border-color: var(--lift);
+ background: var(--brand);
+ color: #fff;
+ }
+ .field-help, .photo-status {
+ margin: 7px 0 0;
+ font-family: var(--mono);
+ font-size: .7rem;
+ line-height: 1.55;
+ color: var(--muted-dim);
+ }
+ .photo-status:not(:empty) { color: var(--muted); }
+
.form-note {
font-family: var(--mono);
font-size: .72rem;
color: var(--muted-dim);
margin: 0;
}
- .form-success {
- display: none;
- padding: 14px 16px;
- border: 1px solid rgba(88, 189, 236, .45);
+ .form-error {
+ padding: 12px 14px;
+ border: 1px solid rgba(255, 132, 111, .55);
border-radius: 3px;
- background: rgba(7, 142, 209, .16);
+ background: rgba(141, 42, 26, .18);
+ color: #ffd8d1;
+ font-size: .84rem;
+ line-height: 1.55;
+ }
+ .quote-form[aria-busy="true"] .btn { cursor: wait; }
+ .quote-form .btn:disabled {
+ opacity: .68;
+ transform: none;
+ }
+ .form-success {
+ min-height: 410px;
+ align-items: flex-start;
+ justify-content: center;
+ background:
+ linear-gradient(135deg, rgba(7, 142, 209, .18), transparent 58%),
+ var(--panel);
+ }
+ .form-success:not([hidden]) { display: flex; }
+ .success-mark {
+ display: grid;
+ place-items: center;
+ width: 54px;
+ height: 54px;
+ margin-bottom: 26px;
+ border: 1px solid rgba(88, 189, 236, .6);
+ border-radius: 50%;
+ color: var(--lift);
+ background: rgba(7, 142, 209, .14);
+ }
+ .success-mark svg { width: 25px; height: 25px; }
+ .form-success .eyebrow { margin-bottom: 17px; }
+ .form-success h3 { font-size: clamp(2.3rem, 5vw, 4rem); }
+ .form-success p {
+ max-width: 46ch;
+ margin: 20px 0 0;
+ color: var(--muted);
+ }
+ .form-success .quote-value {
+ display: inline-block;
+ margin-top: 22px;
+ color: var(--lift);
font-family: var(--mono);
- font-size: .82rem;
- color: var(--bone);
+ font-size: .76rem;
}
- .form-success.is-shown { display: block; }
/* ── footer ─────────────────────────────────────────────────── */
.footer {
@@ -1676,58 +1750,84 @@
</div>
</div>
- <form class="quote-form" id="quote-form" novalidate>
- <div class="field-row">
- <div class="field">
- <label for="q-name">Name</label>
- <input id="q-name" name="name" type="text" autocomplete="name" placeholder="Your name" required />
+ <div class="quote-form-wrap">
+ <form class="quote-form" id="quote-form"
+ action="https://formspree.io/f/xaewpyaq"
+ method="POST"
+ enctype="multipart/form-data">
+ <div class="field-row">
+ <div class="field">
+ <label for="q-name">Name</label>
+ <input id="q-name" name="name" type="text" autocomplete="name" placeholder="Your name" required />
+ </div>
+ <div class="field">
+ <label for="q-phone">Phone</label>
+ <input id="q-phone" name="phone" type="tel" autocomplete="tel" placeholder="(954) 000-0000" required />
+ </div>
</div>
- <div class="field">
- <label for="q-phone">Phone</label>
- <input id="q-phone" name="phone" type="tel" autocomplete="tel" placeholder="(954) 000-0000" required />
+ <div class="field-row">
+ <div class="field">
+ <label for="q-email">Email</label>
+ <input id="q-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" />
+ </div>
+ <div class="field">
+ <label for="q-city">City</label>
+ <input id="q-city" name="city" type="text" autocomplete="address-level2" placeholder="Fort Lauderdale" />
+ </div>
</div>
- </div>
- <div class="field-row">
- <div class="field">
- <label for="q-email">Email</label>
- <input id="q-email" name="email" type="email" autocomplete="email" placeholder="[email protected]" />
+ <div class="field-row">
+ <div class="field">
+ <label for="q-service">Service needed</label>
+ <select id="q-service" name="service">
+ <option>Pressure washing</option>
+ <option>Paver sealing</option>
+ <option>Paver repair</option>
+ <option>Roof cleaning</option>
+ <option>House wash</option>
+ <option>Pool deck or patio</option>
+ <option>Commercial / recurring</option>
+ <option>Not sure yet</option>
+ </select>
+ </div>
+ <div class="field">
+ <label for="q-property">Property type</label>
+ <select id="q-property" name="property">
+ <option>Residential</option>
+ <option>Commercial</option>
+ <option>HOA / multi-family</option>
+ </select>
+ </div>
</div>
<div class="field">
- <label for="q-city">City</label>
- <input id="q-city" name="city" type="text" autocomplete="address-level2" placeholder="Fort Lauderdale" />
+ <label for="q-message">What needs cleaning?</label>
+ <textarea id="q-message" name="message" placeholder="Approximate size, roof material, when it was last cleaned, or anything else that will help us quote the job."></textarea>
</div>
- </div>
- <div class="field-row">
<div class="field">
- <label for="q-service">Service needed</label>
- <select id="q-service" name="service">
- <option>Pressure washing</option>
- <option>Paver sealing</option>
- <option>Paver repair</option>
- <option>Roof cleaning</option>
- <option>House wash</option>
- <option>Pool deck or patio</option>
- <option>Commercial / recurring</option>
- <option>Not sure yet</option>
- </select>
+ <label for="q-photos">Show us the surface <span aria-hidden="true">·</span> Optional</label>
+ <input id="q-photos" name="photos" type="file" accept="image/*" multiple
+ aria-describedby="q-photos-help q-photos-status" />
+ <p class="field-help" id="q-photos-help">Add up to 5 photos. Maximum 25 MB each and 90 MB total.</p>
+ <p class="photo-status" id="q-photos-status" aria-live="polite"></p>
</div>
- <div class="field">
- <label for="q-property">Property type</label>
- <select id="q-property" name="property">
- <option>Residential</option>
- <option>Commercial</option>
- <option>HOA / multi-family</option>
- </select>
+ <div class="form-error" id="form-error" role="alert" hidden></div>
+ <button class="btn btn--primary btn--block" id="quote-submit" type="submit">Request an estimate</button>
+ <p class="form-note">Prefer to talk? Call or text (954) 401-3301 during business hours.</p>
+ </form>
+
+ <div class="form-success" id="form-success" role="status" aria-live="polite" tabindex="-1" hidden>
+ <div>
+ <div class="success-mark" aria-hidden="true">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="m5 12 4 4L19 6" />
+ </svg>
+ </div>
+ <p class="eyebrow">Request received</p>
+ <h3>Thanks, <span id="success-name">we’ve got it</span>.</h3>
+ <p>We’ll call or text to confirm the details and arrange a walkthrough if one is needed.</p>
+ <a class="quote-value" href="tel:+19544013301">Need us sooner? Call (954) 401-3301</a>
</div>
</div>
- <div class="field">
- <label for="q-message">What needs cleaning?</label>
- <textarea id="q-message" name="message" placeholder="Approximate size, roof material, when it was last cleaned, or anything else that will help us quote the job."></textarea>
- </div>
- <button class="btn btn--primary btn--block" type="submit">Request an estimate</button>
- <p class="form-note">Prefer to talk? Call or text (954) 401-3301 during business hours.</p>
- <div class="form-success" id="form-success" role="status"></div>
- </form>
+ </div>
</div>
</div>
</section>
@@ -2277,30 +2377,133 @@
if (e.key === 'Escape' && lightbox.classList.contains('is-open')) closeLightbox();
});
- /* ── quote form ───────────────────────────────────────────
- Demo only: no backend is wired up yet. Point this at a form
- endpoint (Formspree, Netlify Forms, or an email handler)
- before launch. */
+ /* ── quote form ─────────────────────────────────────────── */
var form = document.getElementById('quote-form');
var success = document.getElementById('form-success');
+ var successName = document.getElementById('success-name');
+ var formError = document.getElementById('form-error');
+ var submitButton = document.getElementById('quote-submit');
+ var photoInput = document.getElementById('q-photos');
+ var photoStatus = document.getElementById('q-photos-status');
+ var maxPhotos = 5;
+ var maxPhotoBytes = 25 * 1024 * 1024;
+ var maxTotalPhotoBytes = 90 * 1024 * 1024;
+
+ function clearFormError() {
+ formError.hidden = true;
+ formError.textContent = '';
+ }
- form.addEventListener('submit', function (e) {
+ function showFormError(message) {
+ formError.textContent = message;
+ formError.hidden = false;
+ }
+
+ function formatPhotoStatus(files) {
+ if (!files.length) return '';
+ var names = files.map(function (file) { return file.name; }).join(', ');
+ return files.length + (files.length === 1 ? ' photo selected: ' : ' photos selected: ') + names;
+ }
+
+ function validatePhotos() {
+ var files = Array.prototype.slice.call(photoInput.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;
+ });
+
+ photoInput.setCustomValidity('');
+
+ if (files.length > maxPhotos) {
+ photoInput.setCustomValidity('Choose no more than 5 photos.');
+ } else if (oversizedPhoto) {
+ photoInput.setCustomValidity(oversizedPhoto.name + ' is larger than 25 MB.');
+ } else if (totalBytes > maxTotalPhotoBytes) {
+ photoInput.setCustomValidity('Keep the combined photo size under 90 MB.');
+ } else if (nonPhoto) {
+ photoInput.setCustomValidity('Choose image files only.');
+ }
+
+ if (!photoInput.checkValidity()) {
+ photoStatus.textContent = photoInput.validationMessage;
+ return false;
+ }
+
+ photoStatus.textContent = formatPhotoStatus(files);
+ return true;
+ }
+
+ function formspreeErrorMessage(data, status) {
+ var errors = data && Array.isArray(data.errors) ? data.errors : [];
+ var codes = errors.map(function (error) { return error.code; });
+
+ if (status === 429) {
+ return 'Too many requests were sent at once. Wait a minute, then try again.';
+ }
+ if (codes.indexOf('NO_FILE_UPLOADS') !== -1) {
+ return 'Photo uploads are not enabled for this form yet. Remove the photos and try again, or call or text (954) 401-3301.';
+ }
+ if (codes.indexOf('TOO_MANY_FILES') !== -1) {
+ return 'Too many photos were attached. Choose up to 5 and try again.';
+ }
+ if (codes.indexOf('FILES_TOO_BIG') !== -1) {
+ return 'One or more photos are too large. Choose smaller files and try again.';
+ }
+
+ var messages = errors.map(function (error) { return error.message; }).filter(Boolean);
+ if (messages.length) return messages.join(' ');
+ return 'Your request could not be sent. Check your connection and try again, or call or text (954) 401-3301.';
+ }
+
+ photoInput.addEventListener('change', function () {
+ clearFormError();
+ validatePhotos();
+ });
+
+ form.addEventListener('submit', async function (e) {
e.preventDefault();
- var name = form.querySelector('#q-name');
- var phone = form.querySelector('#q-phone');
+ clearFormError();
- if (!name.value.trim() || !phone.value.trim()) {
- success.classList.add('is-shown');
- success.textContent = 'Enter your name and phone number.';
- (!name.value.trim() ? name : phone).focus();
+ if (!validatePhotos() || !form.checkValidity()) {
+ form.reportValidity();
return;
}
- form.querySelectorAll('input, select, textarea, button').forEach(function (c) {
- c.disabled = true;
- });
- success.classList.add('is-shown');
- success.textContent = 'Thanks. We’ll call or text to confirm the details. Need us sooner? Call (954) 401-3301.';
+ var submittedName = form.querySelector('#q-name').value.trim();
+ var originalButtonText = submitButton.textContent;
+ form.setAttribute('aria-busy', 'true');
+ submitButton.disabled = true;
+ submitButton.textContent = 'Sending request…';
+
+ try {
+ var response = await fetch(form.action, {
+ method: form.method,
+ body: new FormData(form),
+ headers: { 'Accept': 'application/json' }
+ });
+ var data = await response.json().catch(function () { return null; });
+
+ if (!response.ok) {
+ throw { formspree: true, data: data, status: response.status };
+ }
+
+ successName.textContent = submittedName || 'we’ve got it';
+ form.reset();
+ photoStatus.textContent = '';
+ form.hidden = true;
+ success.hidden = false;
+ success.focus();
+ } catch (error) {
+ var message = error && error.formspree
+ ? formspreeErrorMessage(error.data, error.status)
+ : 'Your request could not be sent. Check your connection and try again, or call or text (954) 401-3301.';
+ showFormError(message);
+ } finally {
+ form.removeAttribute('aria-busy');
+ submitButton.disabled = false;
+ submitButton.textContent = originalButtonText;
+ }
});
document.getElementById('year').textContent = new Date().getFullYear();