PageSourceSearch

https://www.atchisonkansas.net/js/contact.js?v=20260509v11

js atchisonkansas.net collected 2026-10-02 11:19:46 UTC 12,640 bytes, 377 lines download raw bytes

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.