|
8 | 8 | */ |
9 | 9 |
|
10 | 10 | /** |
11 | | - * Public reCAPTCHA v3 site key (frontend only) |
12 | | - * Replace this placeholder with your actual key |
| 11 | + * reCAPTCHA v3 public site key (visible in frontend code) |
| 12 | + * ⚠️ Replace with your own key from https://www.google.com/recaptcha/admin |
13 | 13 | * @constant {string} |
14 | 14 | */ |
15 | 15 | const RECAPTCHA_SITE_KEY = 'YOUR_RECAPTCHA_SITE_KEY'; |
16 | 16 |
|
17 | 17 | /** |
| 18 | + * Backend JSON response shape |
18 | 19 | * @typedef {Object} AjaxResponse |
19 | | - * @property {boolean} success Indicates if backend processing succeeded |
20 | | - * @property {string} message Human readable status or error |
21 | | - * @property {string=} field Optional form field name that failed validation |
22 | | - */ |
23 | | - |
24 | | -/** |
25 | | - * @typedef {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} FormControl |
| 20 | + * @property {boolean} success - True if message was sent successfully |
| 21 | + * @property {string} message - User-facing status message |
| 22 | + * @property {string=} field - Name of the form field that failed (optional) |
26 | 23 | */ |
27 | 24 |
|
28 | 25 | document.addEventListener('DOMContentLoaded', () => { |
29 | 26 | 'use strict'; |
30 | 27 |
|
31 | | - /** @type {HTMLFormElement|null} */ |
| 28 | + // 1. Find form and required DOM elements |
32 | 29 | const form = document.querySelector('.needs-validation'); |
33 | | - if (!form) return; |
| 30 | + if (!form) return; // Exit if form not found on page |
34 | 31 |
|
35 | | - /** @type {HTMLElement|null} */ |
36 | 32 | const spinner = document.getElementById('loading-spinner'); |
37 | | - /** @type {HTMLButtonElement|null} */ |
38 | 33 | const submitButton = form.querySelector('button[type="submit"]'); |
39 | | - /** @type {HTMLElement|null} */ |
40 | 34 | const alertContainer = document.getElementById('alert-status'); |
41 | | - /** @type {boolean} */ |
42 | | - let inFlight = false; |
| 35 | + |
| 36 | + let isSubmitting = false; // Prevent duplicate submissions |
43 | 37 |
|
44 | | - // Live validation |
| 38 | + // 2. Setup live validation (show green/red feedback as user types) |
45 | 39 | form.querySelectorAll('input, select, textarea').forEach((field) => { |
46 | | - const eventName = field.tagName === 'SELECT' ? 'change' : 'input'; |
47 | | - field.addEventListener(eventName, () => { |
48 | | - if (!field.value.trim()) { |
| 40 | + const eventType = field.tagName === 'SELECT' ? 'change' : 'input'; |
| 41 | + |
| 42 | + field.addEventListener(eventType, () => { |
| 43 | + const isEmpty = !field.value.trim(); |
| 44 | + const isValid = field.checkValidity(); |
| 45 | + |
| 46 | + // Remove all validation classes if field is empty |
| 47 | + if (isEmpty) { |
49 | 48 | field.classList.remove('is-valid', 'is-invalid'); |
50 | | - } else if (field.checkValidity()) { |
| 49 | + } |
| 50 | + // Show green checkmark if valid |
| 51 | + else if (isValid) { |
51 | 52 | field.classList.add('is-valid'); |
52 | 53 | field.classList.remove('is-invalid'); |
53 | | - } else { |
| 54 | + } |
| 55 | + // Show red error if invalid |
| 56 | + else { |
54 | 57 | field.classList.add('is-invalid'); |
55 | 58 | field.classList.remove('is-valid'); |
56 | 59 | } |
57 | 60 | }); |
58 | 61 | }); |
59 | 62 |
|
60 | | - // Handle form submission with AJAX |
| 63 | + // 3. Handle form submission |
61 | 64 | form.addEventListener('submit', async (event) => { |
62 | 65 | event.preventDefault(); |
63 | 66 | event.stopPropagation(); |
64 | 67 |
|
65 | | - if (inFlight) return; |
| 68 | + // Ignore if already submitting |
| 69 | + if (isSubmitting) return; |
66 | 70 |
|
| 71 | + // Reset any previous validation styling |
67 | 72 | form.classList.remove('was-validated'); |
68 | | - form.querySelectorAll('.is-valid, .is-invalid').forEach((el) => el.classList.remove('is-valid', 'is-invalid')); |
| 73 | + form.querySelectorAll('.is-valid, .is-invalid').forEach((el) => { |
| 74 | + el.classList.remove('is-valid', 'is-invalid'); |
| 75 | + }); |
69 | 76 |
|
70 | | - // Native HTML5 validity check |
| 77 | + // Check HTML5 built-in validation (required, email format, etc.) |
71 | 78 | if (!form.checkValidity()) { |
72 | 79 | form.classList.add('was-validated'); |
73 | | - form.querySelector(':invalid')?.focus(); |
| 80 | + form.querySelector(':invalid')?.focus(); // Focus first invalid field |
74 | 81 | return; |
75 | 82 | } |
76 | 83 |
|
| 84 | + // Prepare form data for sending |
77 | 85 | const formData = new FormData(form); |
78 | | - const endpoint = 'AjaxForm.php'; |
| 86 | + const backendURL = 'AjaxForm.php'; |
79 | 87 |
|
80 | | - // Show loading spinner and disable form |
| 88 | + // Disable form to prevent changes during submission |
81 | 89 | if (spinner) spinner.classList.remove('d-none'); |
82 | 90 | if (submitButton) submitButton.disabled = true; |
83 | | - form.querySelectorAll('input, select, textarea, button').forEach((el) => { |
84 | | - if (el !== submitButton) el.disabled = true; |
| 91 | + form.querySelectorAll('input, select, textarea, button').forEach((element) => { |
| 92 | + if (element !== submitButton) element.disabled = true; |
85 | 93 | }); |
86 | | - inFlight = true; |
| 94 | + isSubmitting = true; |
87 | 95 |
|
88 | 96 | try { |
| 97 | + // Step 1: Verify reCAPTCHA is loaded |
89 | 98 | if (!RECAPTCHA_SITE_KEY || RECAPTCHA_SITE_KEY === 'YOUR_RECAPTCHA_SITE_KEY') { |
90 | 99 | throw new Error('⚠️ Missing reCAPTCHA site key.'); |
91 | 100 | } |
92 | 101 | if (typeof grecaptcha === 'undefined' || !grecaptcha?.ready) { |
93 | | - throw new Error('⚠️ reCAPTCHA not loaded.'); |
| 102 | + throw new Error('⚠️ reCAPTCHA script not loaded.'); |
94 | 103 | } |
95 | 104 |
|
96 | | - // Wait for reCAPTCHA to be ready and get the token |
97 | | - const token = await new Promise((resolve, reject) => { |
| 105 | + // Step 2: Get reCAPTCHA token (proves user is human) |
| 106 | + const recaptchaToken = await new Promise((resolve, reject) => { |
98 | 107 | try { |
99 | 108 | grecaptcha.ready(() => { |
100 | | - grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: 'submit' }).then(resolve).catch(reject); |
| 109 | + grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: 'submit' }) |
| 110 | + .then(resolve) |
| 111 | + .catch(reject); |
101 | 112 | }); |
102 | | - } catch (e) { |
103 | | - reject(e); |
| 113 | + } catch (error) { |
| 114 | + reject(error); |
104 | 115 | } |
105 | 116 | }); |
106 | 117 |
|
107 | | - // Append token to input form |
108 | | - formData.append('recaptcha_token', token); |
| 118 | + // Add token to form data |
| 119 | + formData.append('recaptcha_token', recaptchaToken); |
109 | 120 |
|
110 | | - // Send data using Fetch API (AJAX) |
111 | | - const response = await fetch(endpoint, { |
| 121 | + // Step 3: Send form data to backend |
| 122 | + const response = await fetch(backendURL, { |
112 | 123 | method: 'POST', |
113 | 124 | body: formData, |
114 | 125 | headers: { Accept: 'application/json' }, |
115 | 126 | }); |
116 | | - if (!response.ok) throw new Error(`⚠️ Network error: ${response.status}`); |
117 | 127 |
|
| 128 | + if (!response.ok) { |
| 129 | + throw new Error(`⚠️ Network error: ${response.status}`); |
| 130 | + } |
| 131 | + |
| 132 | + // Step 4: Parse JSON response |
118 | 133 | /** @type {AjaxResponse} */ |
119 | | - let result; |
| 134 | + let data; |
120 | 135 | try { |
121 | | - result = await response.json(); |
| 136 | + data = await response.json(); |
122 | 137 | } catch { |
123 | | - throw new Error('⚠️ Invalid JSON response.'); |
| 138 | + throw new Error('⚠️ Invalid JSON response from server.'); |
124 | 139 | } |
125 | 140 |
|
126 | | - const success = !!result?.success; |
127 | | - const message = result?.message || (success ? 'Success.' : 'An error occurred.'); |
128 | | - const field = result?.field; |
| 141 | + const wasSuccessful = !!data?.success; |
| 142 | + const statusMessage = data?.message || (wasSuccessful ? 'Success.' : 'An error occurred.'); |
| 143 | + const invalidFieldName = data?.field; |
129 | 144 |
|
130 | | - // Highlight the invalid field |
131 | | - if (field) { |
132 | | - const target = form.querySelector(`[name="${CSS.escape(field)}"]`); |
133 | | - if (target) { |
134 | | - target.classList.add('is-invalid'); |
135 | | - target.focus(); |
| 145 | + // Highlight specific field if backend indicates validation error |
| 146 | + if (invalidFieldName) { |
| 147 | + const invalidField = form.querySelector(`[name="${CSS.escape(invalidFieldName)}"]`); |
| 148 | + if (invalidField) { |
| 149 | + invalidField.classList.add('is-invalid'); |
| 150 | + invalidField.focus(); |
136 | 151 | form.classList.remove('was-validated'); |
137 | 152 | } |
138 | 153 | } |
139 | 154 |
|
| 155 | + // Show success or error alert |
140 | 156 | if (alertContainer) { |
141 | | - alertContainer.className = `alert alert-${success ? 'success' : 'danger'} fade show`; |
142 | | - alertContainer.textContent = message; |
| 157 | + alertContainer.className = `alert alert-${wasSuccessful ? 'success' : 'danger'} fade show`; |
| 158 | + alertContainer.textContent = statusMessage; |
143 | 159 | alertContainer.classList.remove('d-none'); |
144 | 160 | alertContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
145 | 161 | } |
146 | 162 |
|
147 | | - // If the form was submitted successfully, reset it |
148 | | - if (success) { |
| 163 | + // Reset form on success |
| 164 | + if (wasSuccessful) { |
149 | 165 | form.reset(); |
150 | 166 | form.classList.remove('was-validated'); |
151 | | - form.querySelectorAll('.is-valid, .is-invalid').forEach((el) => el.classList.remove('is-valid', 'is-invalid')); |
| 167 | + form.querySelectorAll('.is-valid, .is-invalid').forEach((el) => { |
| 168 | + el.classList.remove('is-valid', 'is-invalid'); |
| 169 | + }); |
152 | 170 | } |
153 | | - } catch (err) { |
154 | | - console.error(err); |
| 171 | + } catch (error) { |
| 172 | + console.error(error); |
| 173 | + |
| 174 | + // Show error alert |
155 | 175 | if (alertContainer) { |
156 | 176 | alertContainer.className = 'alert alert-danger fade show'; |
157 | | - alertContainer.textContent = err?.message || 'Unexpected error.'; |
| 177 | + alertContainer.textContent = error?.message || 'Unexpected error.'; |
158 | 178 | alertContainer.classList.remove('d-none'); |
159 | 179 | } |
160 | 180 | } finally { |
161 | | - // Hide loading spinner and enable form |
| 181 | + // Re-enable form |
162 | 182 | if (spinner) spinner.classList.add('d-none'); |
163 | 183 | if (submitButton) submitButton.disabled = false; |
164 | | - form.querySelectorAll('input, select, textarea, button').forEach((el) => { |
165 | | - if (el !== submitButton) el.disabled = false; |
| 184 | + form.querySelectorAll('input, select, textarea, button').forEach((element) => { |
| 185 | + if (element !== submitButton) element.disabled = false; |
166 | 186 | }); |
167 | | - inFlight = false; |
| 187 | + isSubmitting = false; |
168 | 188 | } |
169 | 189 | }); |
170 | 190 | }); |
0 commit comments