1/** 2 * Atchison Area Chamber of Commerce 3 * Contact Page JavaScript 4 * Handles form validation and submission via Firebase Functions / SendGrid 5 */ 6 7import { db, functions, friendlyCallableError } from './firebase-config.js'; 8import { collection, addDoc, serverTimestamp } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js'; 9import { httpsCallable } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-functions.js'; 10 11// DOM Elements 12const contactForm = document.getElementById('contact-form'); 13const subjectCards = document.getElementById('subject-cards'); 14const subjectTypeInput = document.getElementById('subject-type'); 15const submitBtn = document.getElementById('submit-btn'); 16const submitText = document.getElementById('submit-text'); 17const submitIcon = document.getElementById('submit-icon'); 18const loadingSpinner = document.getElementById('loading-spinner'); 19const formSuccess = document.getElementById('form-success'); 20 21// Initialize 22document.addEventListener('DOMContentLoaded', () => { 23 initSubjectCards(); 24 initFormValidation(); 25 highlightCurrentDay(); 26 handleUrlParams(); 27}); 28 29/** 30 * Initialize subject card selection 31 */ 32function initSubjectCards() { 33 const cards = subjectCards.querySelectorAll('.subject-card'); 34 35 cards.forEach(card => { 36 card.addEventListener('click', () => { 37 // Remove active class from all cards 38 cards.forEach(c => c.classList.remove('subject-card--active')); 39 40 // Add active class to clicked card 41 card.classList.add('subject-card--active'); 42 43 // Update hidden input 44 subjectTypeInput.value = card.dataset.subject; 45 }); 46 }); 47 48 // Set first card as default active 49 cards[0].classList.add('subject-card--active'); 50} 51 52/** 53 * Handle URL parameters (e.g., ?subject=membership) 54 */ 55function handleUrlParams() { 56 const params = new URLSearchParams(window.location.search); 57 const subject = params.get('subject'); 58 59 if (subject) { 60 const card = subjectCards.querySelector(`[data-subject="${subject}"]`); 61 if (card) { 62 // Remove active from all, add to matched 63 subjectCards.querySelectorAll('.subject-card').forEach(c => c.classList.remove('subject-card--active')); 64 card.classList.add('subject-card--active'); 65 subjectTypeInput.value = subject; 66 } 67 } 68} 69 70/** 71 * Initialize form validation and submission 72 */ 73function initFormValidation() { 74 contactForm.addEventListener('submit', async (e) => { 75 e.preventDefault(); 76 77 // Clear previous errors 78 clearErrors(); 79 80 // Validate form 81 if (!validateForm()) { 82 return; 83 } 84 85 // Submit form 86 await submitForm(); 87 }); 88 89 // Real-time validation on blur 90 const requiredFields = contactForm.querySelectorAll('[required]'); 91 requiredFields.forEach(field => { 92 field.addEventListener('blur', () => validateField(field)); 93 field.addEventListener('input', () => { 94 if (field.classList.contains('form-input--error')) { 95 validateField(field); 96 } 97 }); 98 }); 99} 100 101/** 102 * Validate entire form 103 */ 104function validateForm() { 105 let isValid = true; 106 107 // First name 108 const firstName = document.getElementById('first-name'); 109 if (!firstName.value.trim()) { 110 showError('first-name', 'Please enter your first name'); 111 isValid = false; 112 } 113 114 // Last name 115 const lastName = document.getElementById('last-name'); 116 if (!lastName.value.trim()) { 117 showError('last-name', 'Please enter your last name'); 118 isValid = false; 119 } 120 121 // Email 122 const email = document.getElementById('email'); 123 if (!email.value.trim()) { 124 showError('email', 'Please enter your email address'); 125 isValid = false; 126 } else if (!isValidEmail(email.value)) { 127 showError('email', 'Please enter a valid email address'); 128 isValid = false; 129 } 130 131 // Message 132 const message = document.getElementById('message'); 133 if (!message.value.trim()) { 134 showError('message', 'Please enter your message'); 135 isValid = false; 136 } else if (message.value.trim().length < 10) { 137 showError('message', 'Please enter a more detailed message'); 138 isValid = false; 139 } 140 141 // Privacy checkbox 142 const privacy = document.getElementById('privacy'); 143 if (!privacy.checked) { 144 showError('privacy', 'Please agree to the privacy policy'); 145 isValid = false; 146 } 147 148 return isValid; 149} 150 151/** 152 * Validate individual field 153 */ 154function validateField(field) { 155 const errorEl = document.getElementById(`${field.id}-error`); 156 157 if (field.required && !field.value.trim()) { 158 field.classList.add('form-input--error'); 159 if (errorEl) errorEl.textContent = 'This field is required'; 160 return false; 161 } 162 163 if (field.type === 'email' && field.value.trim() && !isValidEmail(field.value)) { 164 field.classList.add('form-input--error'); 165 if (errorEl) errorEl.textContent = 'Please enter a valid email'; 166 return false; 167 } 168 169 field.classList.remove('form-input--error'); 170 if (errorEl) errorEl.textContent = ''; 171 return true; 172} 173 174/** 175 * Show error for a field 176 */ 177function showError(fieldId, message) { 178 const field = document.getElementById(fieldId); 179 const errorEl = document.getElementById(`${fieldId}-error`); 180 181 if (field) { 182 field.classList.add('form-input--error'); 183 } 184 if (errorEl) { 185 errorEl.textContent = message; 186 } 187} 188 189/** 190 * Clear all errors 191 */ 192function clearErrors() { 193 contactForm.querySelectorAll('.form-input--error').forEach(el => { 194 el.classList.remove('form-input--error'); 195 });
196 contactForm.querySelectorAll('.form-error').forEach(el => { 197 el.textContent = ''; 198 }); 199} 200 201/** 202 * Validate email format 203 */ 204function isValidEmail(email) { 205 const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 206 return re.test(email); 207} 208 209/** 210 * Submit form 211 * 212 * Primary path: write the submission directly to the `contactSubmissions` 213 * Firestore collection. Public create is allowed by rules so this works 214 * unauthenticated. The unified admin's Inboxes tab reads from this same 215 * collection, so chamber staff sees every message immediately. 216 * 217 * Secondary (optional): if a `sendChamberContactEmail` Cloud Function is 218 * deployed it gets called for SendGrid email notification, but the form is 219 * not blocked on it â the submission is already persisted by then. 220 */ 221async function submitForm() { 222 setLoadingState(true); 223 224 const firstName = document.getElementById('first-name').value.trim(); 225 const lastName = document.getElementById('last-name').value.trim(); 226 const email = document.getElementById('email').value.trim(); 227 const phone = document.getElementById('phone').value.trim(); 228 const businessName = document.getElementById('business-name').value.trim(); 229 const message = document.getElementById('message').value.trim(); 230 const newsletter = document.getElementById('newsletter').checked; 231 const subjectType = subjectTypeInput.value; 232 233 const submission = { 234 source: 'chamber-website', 235 subjectType, 236 firstName, 237 lastName, 238 name: `${firstName} ${lastName}`.trim(), 239 email, 240 phone: phone || null, 241 businessName: businessName || null, 242 message, 243 newsletter, 244 status: 'new', 245 userAgent: navigator.userAgent || null, 246 pageUrl: window.location.href, 247 timestamp: serverTimestamp() 248 }; 249 250 try { 251 // 1. Persist to Firestore so the admin can read it. This is the 252 // primary contract â if this fails, the user sees an error. 253 await addDoc(collection(db, 'contactSubmissions'), submission); 254 255 // 2. Best-effort SendGrid notification. The function does not yet 256 // exist on the chamber side; once `sendChamberContactEmail` is 257 // deployed, it'll fire. Failure here is silent â the submission 258 // is already saved. 259 try { 260 const sendContactEmail = httpsCallable(functions, 'sendChamberContactEmail'); 261 await sendContactEmail({ 262 ...submission, 263 timestamp: new Date().toISOString() 264 }); 265 } catch (emailError) { 266 // Rate-limit and validation rejections are real feedback for the 267 // user â surface them. Anything else (function not deployed, 268 // transient) stays silent because the submission is already saved. 269 const code = emailError?.code || ''; 270 if (code === 'functions/resource-exhausted' || code === 'functions/invalid-argument') { 271 throw emailError; 272 } 273 console.debug('Email notification skipped:', emailError?.message || emailError); 274 } 275 276 showSuccess(); 277 } catch (error) { 278 console.error('Error submitting form:', error); 279 showFormError(friendlyCallableError(error, 'Unable to send your message right now. Please try again, or call us directly at (913) 367-2427.')); 280 } finally { 281 setLoadingState(false); 282 } 283} 284 285/** 286 * Set loading state on button 287 */ 288function setLoadingState(loading) { 289 if (loading) { 290 submitBtn.disabled = true; 291 submitText.textContent = 'Sending...'; 292 submitIcon.style.display = 'none'; 293 loadingSpinner.style.display = 'inline-flex'; 294 contactForm.classList.add('form-loading'); 295 } else { 296 submitBtn.disabled = false; 297 submitText.textContent = 'Send Message'; 298 submitIcon.style.display = 'inline'; 299 loadingSpinner.style.display = 'none'; 300 contactForm.classList.remove('form-loading'); 301 } 302} 303 304/** 305 * Show success message 306 */ 307function showSuccess() { 308 contactForm.style.display = 'none'; 309 document.querySelector('.subject-cards').style.display = 'none'; 310 formSuccess.style.display = 'block'; 311 312 // Scroll to success message 313 formSuccess.scrollIntoView({ behavior: 'smooth', block: 'center' }); 314} 315 316/** 317 * Show form error message 318 */ 319function showFormError(message) { 320 // Create error alert if it doesn't exist 321 let errorAlert = document.getElementById('form-error-alert'); 322 323 if (!errorAlert) { 324 errorAlert = document.createElement('div'); 325 errorAlert.id = 'form-error-alert'; 326 errorAlert.className = 'alert alert-error'; 327 errorAlert.style.marginBottom = 'var(--space-4)'; 328 contactForm.insertBefore(errorAlert, contactForm.firstChild); 329 } 330 331 errorAlert.innerHTML = ` 332 <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 333 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> 334 </svg>
335 <span>${message}</span> 336 `; 337 errorAlert.style.display = 'flex'; 338 339 // Scroll to error 340 errorAlert.scrollIntoView({ behavior: 'smooth', block: 'center' }); 341} 342 343/** 344 * Reset form to send another message 345 */ 346window.resetForm = function() { 347 contactForm.reset(); 348 contactForm.style.display = 'block'; 349 document.querySelector('.subject-cards').style.display = 'grid'; 350 formSuccess.style.display = 'none'; 351 352 // Reset subject selection 353 const cards = subjectCards.querySelectorAll('.subject-card'); 354 cards.forEach(c => c.classList.remove('subject-card--active')); 355 cards[0].classList.add('subject-card--active'); 356 subjectTypeInput.value = 'general'; 357 358 // Clear any error alerts 359 const errorAlert = document.getElementById('form-error-alert'); 360 if (errorAlert) errorAlert.remove(); 361}; 362 363/** 364 * Highlight current day in hours table 365 */ 366function highlightCurrentDay() { 367 const today = new Date().getDay(); 368 const hoursTable = document.getElementById('hours-table'); 369 const rows = hoursTable.querySelectorAll('tr'); 370 371 rows.forEach(row => { 372 if (parseInt(row.dataset.day) === today) { 373 row.classList.add('hours-today'); 374 } 375 }); 376} 377
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.