1/** 2 * Contact Sales on the Experience page. The same form, payload and endpoint as the 3 * app's pricing page and plan picker (shared/sales-contact.ts); the server sends the 4 * message to the sales inbox. Nothing here is sent anywhere else. 5 */ 6const ENDPOINT = '/api/contact/sales'; 7const FIELDS = ['name', 'email', 'company', 'message', 'website']; 8 9export function mountGatewayContact(doc = document, win = window) { 10 const dialog = doc.querySelector('[data-gateway-contact]'); 11 const trigger = doc.querySelector('[data-gateway-contact-sales]'); 12 const form = dialog?.querySelector('form'); 13 const body = dialog?.querySelector('[data-gateway-contact-body]'); 14 const done = dialog?.querySelector('[data-gateway-contact-success]'); 15 const doneTitle = dialog?.querySelector('[data-gateway-contact-success-title]'); 16 const submit = form?.querySelector('button[type="submit"]'); 17 const status = dialog?.querySelector('[data-gateway-contact-status]'); 18 const title = dialog?.querySelector('#gateway-contact-title'); 19 const dismissers = dialog ? [...dialog.querySelectorAll('[data-gateway-contact-close],[data-gateway-contact-done]')] : []; 20 const field = name => form?.querySelector(`[name="${name}"]`); 21 if (!dialog || !trigger || !form || !body || !done || !doneTitle || !submit || !status || !title || !dismissers.length || FIELDS.some(name => !field(name))) return null; 22 const shell = doc.querySelector('[data-gateway]'); 23 const submitLabel = submit.textContent; 24 let previousFocus, previousInert = false, fallback = false, pending = false, destroyed = false; 25 let abort, timer; 26 const listeners = []; 27 const listen = (target, type, callback) => { 28 target.addEventListener(type, callback); 29 listeners.push(() => target.removeEventListener(type, callback)); 30 }; 31 const setState = state => { dialog.dataset.contactState = state; }; 32 33 // After a sent message the next visit starts with a clean form. 34 function reset() { 35 if (dialog.dataset.contactState !== 'success') return; 36 form.reset(); 37 done.hidden = true; 38 body.hidden = false; 39 status.textContent = ''; 40 setState('idle'); 41 } 42 function close() { 43 if (dialog.hidden) return; 44 if (!fallback && typeof dialog.close === 'function' && dialog.open) dialog.close(); 45 dialog.removeAttribute('open'); 46 dialog.hidden = true; 47 if (fallback && shell) shell.inert = previousInert; 48 fallback = false; 49 reset(); 50 if (previousFocus?.isConnected) previousFocus.focus(); 51 previousFocus = null; 52 } 53 function open() { 54 if (!dialog.hidden || [...doc.querySelectorAll('dialog[open]')].some(other => other !== dialog)) return; 55 previousFocus = doc.activeElement; 56 dialog.hidden = false; 57 try { 58 if (typeof dialog.showModal !== 'function') throw new Error('Native dialog unavailable'); 59 dialog.showModal(); 60 } catch { 61 fallback = true; 62 dialog.setAttribute('open', ''); 63 if (shell) { previousInert = shell.inert === true; shell.inert = true; } 64 } 65 // On a phone, focusing a field would raise the keyboard over the form before it is read. 66 const touch = win.matchMedia?.('(pointer: coarse)')?.matches === true; 67 (touch ? title : field('name')).focus(); 68 } 69 // The browser checks required fields, lengths and the email pattern, in its own language. 70 function valid() { 71 for (const name of ['name', 'email', 'company', 'message']) field(name).value = field(name).value.trim(); 72 return form.reportValidity(); 73 } 74 async function send(event) { 75 event.preventDefault(); 76 if (pending || destroyed || body.hidden) return; 77 if (!valid()) return; 78 pending = true; 79 submit.disabled = true; 80 submit.textContent = submit.dataset.sending || submitLabel; 81 form.setAttribute('aria-busy', 'true'); 82 status.textContent = ''; 83 setState('pending'); 84 abort = new win.AbortController(); 85 timer = win.setTimeout(() => abort.abort(), 15000); 86 try { 87 const payload = Object.fromEntries(FIELDS.map(name => [name, field(name).value])); 88 const response = await win.fetch(ENDPOINT, { 89 method: 'POST', credentials: 'same-origin', cache: 'no-store', 90 headers: { 'Content-Type': 'application/json' }, 91 body: JSON.stringify({ ...payload, source: 'experience', locale: doc.documentElement.lang || 'en' }), 92 signal: abort.signal, 93 }); 94 if (!response.ok) { 95 setState(response.status === 429 ? 'rate-limited' : 'error'); 96 throw new Error('Contact unavailable'); 97 } 98 const result = await response.json(); 99 if (result?.success !== true) throw new Error('Invalid contact response'); 100 if (destroyed) return; 101 setState('success'); 102 form.reset(); 103 body.hidden = true; 104 done.hidden = false;
105 doneTitle.focus(); 106 } catch { 107 if (destroyed) return; 108 if (dialog.dataset.contactState !== 'rate-limited') setState('error'); 109 status.textContent = status.dataset.error || ''; 110 // No automatic retry: the server may already have sent the message. 111 } finally { 112 win.clearTimeout(timer); 113 if (!destroyed) { 114 pending = false; 115 submit.disabled = false; 116 submit.textContent = submitLabel; 117 form.removeAttribute('aria-busy'); 118 } 119 } 120 } 121 122 setState('idle'); 123 trigger.hidden = false; 124 listen(trigger, 'click', open); 125 dismissers.forEach(button => listen(button, 'click', close)); 126 listen(dialog, 'cancel', event => { event.preventDefault(); close(); }); 127 listen(form, 'submit', event => { void send(event); }); 128 listen(dialog, 'keydown', event => { 129 if (event.key === 'Escape' && fallback) { event.preventDefault(); close(); return; } 130 if (event.key !== 'Tab') return; 131 const elements = [...dialog.querySelectorAll('a[href], button, input, textarea, [tabindex]')] 132 .filter(element => !element.disabled && !element.hidden && !element.closest('[hidden],[aria-hidden="true"]') && element.tabIndex >= 0); 133 const first = elements[0], last = elements[elements.length - 1]; 134 if (!first) { event.preventDefault(); return; } 135 if (event.shiftKey && (doc.activeElement === first || !dialog.contains(doc.activeElement))) { 136 event.preventDefault(); last.focus(); 137 } else if (!event.shiftKey && (doc.activeElement === last || !dialog.contains(doc.activeElement))) { 138 event.preventDefault(); first.focus(); 139 } 140 }); 141 return { 142 open, close, 143 get pending() { return pending; }, 144 destroy() { 145 destroyed = true; 146 abort?.abort(); 147 win.clearTimeout(timer); 148 close(); 149 listeners.forEach(remove => remove()); 150 }, 151 }; 152} 153 154if (typeof document !== 'undefined' && document.querySelector('[data-gateway-contact]')) { 155 mountGatewayContact(); 156}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.