@modernman00/shared-js-lib 1.2.81 → 1.2.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Http.js CHANGED
@@ -1,90 +1,191 @@
1
1
  import { id, log } from './UtilityHtml.js';
2
2
  import axios from './axiosWrapper.js';
3
3
  import { redirectAfterDelay, parseErrorResponse } from './general.js';
4
+ import { swalSuccess, swalError, swalToast } from './Alert.js';
5
+ import { setButtonLoading, clearButtonLoading } from './ButtonLoader.js';
6
+ import { acquireLock, releaseLock } from './FormLock.js';
7
+ import { isOnline } from './NetworkGuard.js';
4
8
 
5
9
  /**
10
+ * Sends form data via POST request with automatic button spinner, SweetAlert2 alerts, and robust error handling.
11
+ *
12
+ * Can be called with positional arguments (backward-compatible):
13
+ * postFormData(url, formId, redirect, css, recaptchaToken, button)
14
+ *
15
+ * Or with a modern config object:
16
+ * postFormData(url, formId, { redirect, css, button, recaptchaToken, resetForm })
6
17
  *
7
- * Sends form data via POST request.
8
18
  * @param {string} url - The URL to post the data to.
9
- * @param {string} formId - The ID or class of the form.
10
- * @param {string|null} redirect - The page to redirect to after successful submission.
11
- * @param {string|null} css - The CSS framework to use for notification styling (e.g., 'W3css', 'bulma').
12
- NOTICE:::Make sure you set the notification id as the formId_notification
13
- * @param {Object} [options.recaptchaV3=null] - reCAPTCHA v3 configuration ({ siteKey: 'OUR_ENTERPRISE_SITE_KEY', action: 'LOGIN' }).
19
+ * @param {string|HTMLFormElement} formId - The ID string or DOM element of the form.
20
+ * @param {string|Object|null} [redirectOrOptions=null] - Redirect URL, or options configuration object.
21
+ * @param {string|null} [css=null] - CSS framework ('bootstrap', 'bulma', 'W3css', 'tailwind').
22
+ * @param {Object|null} [recaptchaToken=null] - reCAPTCHA token payload ({ token, action }).
23
+ * @param {string|HTMLElement|null} [button=null] - Specific button ID or element to show spinner on.
24
+ * @returns {Promise<Object>} Axios response object on success.
14
25
  */
15
- export const postFormData = async (url, formId, redirect = null, css = null, recaptchaToken = null) => {
26
+ export const postFormData = async (
27
+ url,
28
+ formId,
29
+ redirectOrOptions = null,
30
+ css = null,
31
+ recaptchaToken = null,
32
+ button = null
33
+ ) => {
34
+ // Determine if third argument is an options object
35
+ const isOptionsObj = redirectOrOptions !== null && typeof redirectOrOptions === 'object';
36
+ const redirect = isOptionsObj ? redirectOrOptions.redirect : redirectOrOptions;
37
+ const themeCss = isOptionsObj ? (redirectOrOptions.css || css) : css;
38
+ const recaptcha = isOptionsObj ? (redirectOrOptions.recaptchaToken || recaptchaToken) : recaptchaToken;
39
+ const buttonTarget = isOptionsObj ? (redirectOrOptions.button || button) : button;
40
+ const shouldResetForm = isOptionsObj && typeof redirectOrOptions.resetForm === 'boolean'
41
+ ? redirectOrOptions.resetForm
42
+ : true;
43
+
44
+ // 1. Connectivity Check
45
+ if (!isOnline()) {
46
+ swalToast('No internet connection. Please check your network and retry.', 'warning', 4000);
47
+ return { error: true, message: 'Offline' };
48
+ }
16
49
 
17
- const loader = id('setLoader');
18
- if (loader) loader.style.display = 'block';
50
+ // 2. Double-Submit Protection Lock
51
+ const lockKey = `${url}::${typeof formId === 'string' ? formId : (formId.id || 'form')}`;
52
+ if (!acquireLock(lockKey)) {
53
+ swalToast('Please wait, a submission is already in progress...', 'warning', 2500);
54
+ return { error: true, message: 'In flight' };
55
+ }
19
56
 
20
- let notificationForm = `${formId}_notification`;
21
- const notificationId = id(notificationForm);
57
+ // Resolve form element
58
+ const form = typeof formId === 'string' ? id(formId) : formId;
59
+ if (!form) {
60
+ releaseLock(lockKey);
61
+ throw new Error('Form element not found');
62
+ }
22
63
 
23
-
24
- if (!notificationId) {
25
- throw new Error('Notification element not found');
64
+ // Resolve button for spinner:
65
+ // Works with any button type: type="button", type="submit", or no type.
66
+ let btnEl = null;
67
+ if (buttonTarget) {
68
+ btnEl = typeof buttonTarget === 'string'
69
+ ? (id(buttonTarget.replace(/^#/, '')) || document.querySelector(buttonTarget))
70
+ : buttonTarget;
71
+ }
72
+ if (!btnEl && form instanceof HTMLFormElement) {
73
+ // Smart fallback priority:
74
+ // 1. type="submit" or input submit
75
+ // 2. Any button with common action classes or IDs (save, submit)
76
+ // 3. ANY button in the form, including type="button"
77
+ btnEl = form.querySelector('button[type="submit"], input[type="submit"]')
78
+ || form.querySelector('button#submit, button#save, button.btn-primary, button.is-primary')
79
+ || form.querySelector('button');
26
80
  }
27
- // Cleanup previous notification styles
28
- notificationId.style.display = 'none';
29
81
 
30
- ['is-danger', 'is-success', 'w3-red', 'w3-green', 'bg-danger', 'bg-success'].forEach(cls => notificationId.classList.remove(cls));
82
+ // Set button spinner and disable button
83
+ if (btnEl) {
84
+ setButtonLoading(btnEl);
85
+ }
31
86
 
32
- // add value to the hidden csrf input field
33
- // const csrfToken = getCookieValue('XSRF-TOKEN');
34
- // if (csrfToken) {
35
- // const csrfInput = id('token');
36
- // if (csrfInput) {
37
- // csrfInput.value = csrfToken; // Set the CSRF token in the hidden input field
38
- // }
87
+ // Optional legacy external loader
88
+ const loader = id('setLoader');
89
+ if (loader) loader.style.display = 'block';
39
90
 
40
- // extract the form entries
41
- // extract the form entriesËËË
42
- const form = id(formId)
91
+ // Determine notification requirement:
92
+ // If called with modern options object, notification is optional (SweetAlert2 handles alerts).
93
+ // If called with legacy positional arguments, enforce legacy expectation if configured.
94
+ const formIdStr = typeof formId === 'string' ? formId : (form.id || '');
95
+ const notificationId = formIdStr ? id(`${formIdStr}_notification`) : null;
43
96
 
44
- if (!form) {
45
- throw new Error('Form element not found');
97
+ if (!notificationId && !isOptionsObj) {
98
+ throw new Error('Notification element not found');
46
99
  }
47
100
 
48
- let formEntries = new FormData(form);
101
+ if (notificationId) {
102
+ notificationId.style.display = 'none';
103
+ ['is-danger', 'is-success', 'w3-red', 'w3-green', 'bg-danger', 'bg-success'].forEach(cls =>
104
+ notificationId.classList.remove(cls)
105
+ );
106
+ }
49
107
 
50
- // if recaptcha token is provided, append it for v3
51
- // if recaptcha token is provided, append it for v3
52
- if (recaptchaToken) {
108
+ // Build form data
109
+ let formEntries;
110
+ if (form instanceof FormData) {
111
+ formEntries = form;
112
+ } else if (form instanceof HTMLFormElement) {
113
+ formEntries = new FormData(form);
114
+ } else {
115
+ formEntries = new FormData();
116
+ }
53
117
 
54
- formEntries.append('siteKey', recaptchaToken.token);
55
- formEntries.append('action', recaptchaToken.action);
118
+ // Append recaptcha token if present
119
+ if (recaptcha) {
120
+ formEntries.append('siteKey', recaptcha.token || '');
121
+ formEntries.append('action', recaptcha.action || 'SUBMIT');
56
122
  }
57
123
 
58
124
  formEntries.delete('submit');
59
125
  formEntries.delete('checkbox');
60
126
 
61
127
  const options = {
62
- baseURL: '/', // Adjust to your API base URL
63
- withCredentials: true, // Ensure cookies (e.g., XSRF token) are sent
128
+ baseURL: '/',
129
+ withCredentials: true,
64
130
  };
65
131
 
66
- // AXIOS POST FUNCTIONALITY
67
132
  try {
68
133
  const response = await axios.post(url, formEntries, options);
69
- const successClass = getNotificationClassByCSS(css || 'bulma', 'green');
134
+ const successClass = getNotificationClassByCSS(themeCss || 'bulma', 'green');
70
135
 
71
- // check if response.data.message is an array
72
- const { message } = response.data || {};
136
+ const { message } = response?.data || {};
73
137
  if (!message) {
74
138
  throw new Error('Response data does not contain a message');
75
139
  }
76
- processFormDataAction(successClass, message, notificationId);
140
+
141
+ const successMsg = typeof message === 'string' && message.trim()
142
+ ? message.trim()
143
+ : 'Operation completed successfully.';
144
+
145
+ // Modern SweetAlert2 toast notification
146
+ swalSuccess(successMsg, true);
147
+
148
+ // Fallback DOM notification if element exists
149
+ if (notificationId) {
150
+ processFormDataAction(successClass, successMsg, notificationId);
151
+ }
77
152
 
78
153
  if (redirect) {
79
- redirectAfterDelay(redirect, 2000);
154
+ redirectAfterDelay(redirect, 1500);
155
+ }
156
+
157
+ if (shouldResetForm && typeof form.reset === 'function') {
158
+ form.reset();
80
159
  }
81
160
 
82
- form.reset();
161
+ return response;
83
162
 
84
163
  } catch (error) {
85
- const errorClass = getNotificationClassByCSS(css || 'bulma', 'red');
86
- const ErrorMsg = parseErrorResponse(error);
87
- processFormDataAction(errorClass, ErrorMsg, notificationId);
164
+ const errorClass = getNotificationClassByCSS(themeCss || 'bulma', 'red');
165
+ const errorMsg = parseErrorResponse(error) || 'An error occurred during submission.';
166
+
167
+ // Modern SweetAlert2 error modal/toast
168
+ swalError(errorMsg, true);
169
+
170
+ // Fallback DOM notification if element exists
171
+ if (notificationId) {
172
+ processFormDataAction(errorClass, errorMsg, notificationId);
173
+ }
174
+
175
+ return { error: true, message: errorMsg, rawError: error };
176
+
177
+ } finally {
178
+ // Release lock
179
+ releaseLock(lockKey);
180
+
181
+ // Guaranteed spinner cleanup
182
+ if (btnEl) {
183
+ clearButtonLoading(btnEl);
184
+ }
185
+ if (loader) {
186
+ loader.style.display = 'none';
187
+ loader.classList.remove('loader');
188
+ }
88
189
  }
89
190
  };
90
191
 
@@ -167,11 +268,14 @@ export const getApiData = async (URL, token = null) => {
167
268
  headers: {
168
269
  "X-Requested-With": "XMLHttpRequest",
169
270
  "Content-Type": "application/json",
170
- Accept: "application/json",
171
- ...(token && { Authorization: `Bearer ${token}` }), // only add if token exists
271
+ Accept: "application/json"
172
272
  },
173
273
  };
174
274
 
275
+ if (token) {
276
+ config.headers['Authorization'] = `Bearer ${token}`;
277
+ }
278
+
175
279
  const response = await axios.get(URL, config);
176
280
  return response.data;
177
281
  } catch (error) {
@@ -208,11 +312,14 @@ export const getMultipleApiData = async (url1, url2, token = null) => {
208
312
  headers: {
209
313
  "X-Requested-With": "XMLHttpRequest",
210
314
  "Content-Type": "application/json",
211
- Accept: "application/json",
212
- ...(token && { Authorization: `Bearer ${token}` }), // add only if token exists
315
+ Accept: "application/json"
213
316
  },
214
317
  };
215
318
 
319
+ if (token) {
320
+ config.headers['Authorization'] = `Bearer ${token}`;
321
+ }
322
+
216
323
  // run requests in parallel
217
324
  const [res1, res2] = await axios.all([
218
325
  axios.get(url1, config),
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ import { id } from './UtilityHtml.js';
3
+
4
+ const VALID_EMAIL_REGEX = /^[\w\-\.\+]+\@[a-zA-Z0-9\.\-]+\.[a-zA-Z0-9]{2,6}$/;
5
+
6
+ /**
7
+ * Attaches real-time validation and micro-feedback to form fields on blur/input.
8
+ *
9
+ * @param {string|HTMLElement} fieldId - ID or DOM element of the input
10
+ * @param {Object} options
11
+ * @param {'email'|'password'|'required'|'custom'} [options.type='required']
12
+ * @param {(value: string) => { valid: boolean, message?: string }} [options.validator]
13
+ * @param {string} [options.errorColor='#e74c3c']
14
+ * @param {string} [options.successColor='#27ae60']
15
+ */
16
+ export const attachInlineValidation = (fieldId, options = {}) => {
17
+ if (typeof document === 'undefined') return;
18
+
19
+ const el = typeof fieldId === 'string' ? id(fieldId) : fieldId;
20
+ if (!el) return;
21
+
22
+ const {
23
+ type = 'required',
24
+ validator = null,
25
+ errorColor = '#e74c3c',
26
+ successColor = '#27ae60'
27
+ } = options;
28
+
29
+ const errorElId = `${el.id || el.name}_error`;
30
+ let feedbackEl = id(errorElId);
31
+
32
+ const validate = () => {
33
+ const val = (el.value || '').trim();
34
+ let isValid = true;
35
+ let message = '';
36
+
37
+ if (typeof validator === 'function') {
38
+ const result = validator(val);
39
+ isValid = !!result.valid;
40
+ message = result.message || '';
41
+ } else if (type === 'required') {
42
+ isValid = val.length > 0;
43
+ message = isValid ? '' : 'This field is required.';
44
+ } else if (type === 'email') {
45
+ isValid = VALID_EMAIL_REGEX.test(val);
46
+ message = isValid ? '' : 'Please enter a valid email address.';
47
+ } else if (type === 'password') {
48
+ isValid = val.length >= 8;
49
+ message = isValid ? '' : 'Password must be at least 8 characters long.';
50
+ }
51
+
52
+ // Apply micro-feedback styling
53
+ if (isValid) {
54
+ el.style.borderColor = val ? successColor : '';
55
+ if (feedbackEl) {
56
+ feedbackEl.innerHTML = '';
57
+ feedbackEl.style.display = 'none';
58
+ }
59
+ } else {
60
+ el.style.borderColor = errorColor;
61
+ if (feedbackEl) {
62
+ feedbackEl.style.display = 'block';
63
+ feedbackEl.style.color = errorColor;
64
+ feedbackEl.innerHTML = message;
65
+ }
66
+ }
67
+
68
+ return isValid;
69
+ };
70
+
71
+ el.addEventListener('blur', validate);
72
+ el.addEventListener('input', () => {
73
+ // Clear error border while user is re-typing
74
+ if (el.style.borderColor === errorColor) {
75
+ validate();
76
+ }
77
+ });
78
+ };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ import { swalToast } from './Alert.js';
3
+
4
+ let isListening = false;
5
+ let wasOffline = false;
6
+
7
+ /**
8
+ * Checks if the browser is currently online.
9
+ * @returns {boolean}
10
+ */
11
+ export const isOnline = () => {
12
+ return typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean'
13
+ ? navigator.onLine
14
+ : true;
15
+ };
16
+
17
+ /**
18
+ * Attaches automatic online/offline listeners to show clean toasts.
19
+ * Safe to call multiple times (idempotent).
20
+ */
21
+ export const setupNetworkListeners = () => {
22
+ if (typeof window === 'undefined' || isListening) return;
23
+ isListening = true;
24
+
25
+ window.addEventListener('offline', () => {
26
+ wasOffline = true;
27
+ swalToast('Internet connection lost. You are offline.', 'warning', 4000);
28
+ });
29
+
30
+ window.addEventListener('online', () => {
31
+ if (wasOffline) {
32
+ swalToast('Connection restored. Back online!', 'success', 3000);
33
+ wasOffline = false;
34
+ }
35
+ });
36
+ };
37
+
38
+ /**
39
+ * Wraps an async request to check connectivity before dispatching.
40
+ *
41
+ * @param {() => Promise<any>} requestFn
42
+ * @param {string} [offlineMsg='No internet connection. Please check your network.']
43
+ * @returns {Promise<any>}
44
+ */
45
+ export const withNetworkGuard = async (
46
+ requestFn,
47
+ offlineMsg = 'No internet connection. Please check your network.'
48
+ ) => {
49
+ if (!isOnline()) {
50
+ swalToast(offlineMsg, 'warning', 4000);
51
+ throw new Error(offlineMsg);
52
+ }
53
+ return await requestFn();
54
+ };
@@ -0,0 +1,278 @@
1
+ /**
2
+ * 📦 OfflineQueue — Resilient IndexedDB Offline Request Queue
3
+ * Part of @modernman00/shared-js-lib
4
+ *
5
+ * Implements the Deloitte Digital & BRATS-approved enterprise architecture:
6
+ * 1. UUIDv4 Idempotency Key injection ('X-Idempotency-Key') to eliminate duplicate writes
7
+ * 2. Strict Security Blacklist: Refuses to store credentials, passwords, or /auth/* /admin/* requests
8
+ * 3. Kieran Memory Cap: 2MB max per payload, 30 items max queue depth
9
+ * 4. 24-Hour automatic request expiration
10
+ * 5. Automatic replay on 'online' event with fresh CSRF token header
11
+ */
12
+
13
+ export class OfflineQueue {
14
+ constructor(options = {}) {
15
+ this.appName = options.appName || (typeof window !== 'undefined' ? window.location.hostname.replace(/[^a-zA-Z0-9]/g, '_') : 'app');
16
+ this.dbName = options.dbName || `${this.appName}_offline_db`;
17
+ this.storeName = 'offline_requests';
18
+ this.dbVersion = 1;
19
+ this.maxItems = options.maxItems || 30;
20
+ this.maxPayloadBytes = options.maxPayloadBytes || 2 * 1024 * 1024; // 2MB cap
21
+ this.maxAgeMs = options.maxAgeMs || 24 * 60 * 60 * 1000; // 24 hours
22
+ this.db = null;
23
+ this.isReplaying = false;
24
+
25
+ // Security Blacklist (never cache credentials/auth/passwords offline)
26
+ this.blacklistPatterns = [
27
+ /\/auth\//i,
28
+ /\/admin\//i,
29
+ /\/login/i,
30
+ /\/logout/i,
31
+ /\/signout/i,
32
+ /\/password/i,
33
+ /\/2fa/i,
34
+ /\/totp/i
35
+ ];
36
+
37
+ if (typeof window !== 'undefined') {
38
+ window.addEventListener('online', () => this.replay());
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Generates standard RFC4122 v4 UUID for request idempotency
44
+ */
45
+ static generateUUID() {
46
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
47
+ return crypto.randomUUID();
48
+ }
49
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
50
+ const r = (Math.random() * 16) | 0;
51
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
52
+ return v.toString(16);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Initializes or returns the open IndexedDB instance
58
+ */
59
+ async getDB() {
60
+ if (this.db) return this.db;
61
+ if (typeof indexedDB === 'undefined') {
62
+ throw new Error('IndexedDB is not supported in this browser environment');
63
+ }
64
+
65
+ return new Promise((resolve, reject) => {
66
+ const request = indexedDB.open(this.dbName, this.dbVersion);
67
+
68
+ request.onerror = () => reject(request.error);
69
+ request.onsuccess = () => {
70
+ this.db = request.result;
71
+ resolve(this.db);
72
+ };
73
+ request.onupgradeneeded = (event) => {
74
+ const db = event.target.result;
75
+ if (!db.objectStoreNames.contains(this.storeName)) {
76
+ const store = db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
77
+ store.createIndex('timestamp', 'timestamp', { unique: false });
78
+ store.createIndex('idempotencyKey', 'idempotencyKey', { unique: true });
79
+ }
80
+ };
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Check if URL matches the security blacklist
86
+ */
87
+ isBlacklisted(url) {
88
+ if (!url || typeof url !== 'string') return true;
89
+ return this.blacklistPatterns.some(pattern => pattern.test(url));
90
+ }
91
+
92
+ /**
93
+ * Enqueues an outgoing request for offline storage and later replay
94
+ */
95
+ async enqueue(requestData) {
96
+ const { url, method = 'POST', headers = {}, body = null } = requestData;
97
+
98
+ // Security Gate
99
+ if (this.isBlacklisted(url)) {
100
+ console.warn(`[OfflineQueue] Blocked security-sensitive request from offline storage: ${url}`);
101
+ return false;
102
+ }
103
+
104
+ // Payload Size Guard (Kieran Memory Protection)
105
+ const serializedBody = typeof body === 'string' ? body : JSON.stringify(body || {});
106
+ const bodySize = typeof Blob !== 'undefined' ? new Blob([serializedBody]).size : serializedBody.length;
107
+ if (bodySize > this.maxPayloadBytes) {
108
+ console.warn(`[OfflineQueue] Request payload exceeds 2MB memory cap (${bodySize} bytes). Dropping.`);
109
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
110
+ window.dispatchEvent(new CustomEvent('offlinequeue:payload-too-large', { detail: { url } }));
111
+ }
112
+ return false;
113
+ }
114
+
115
+ // Filter headers to strip sensitive authorization tokens from plaintext IndexedDB
116
+ const safeHeaders = {};
117
+ const headerEntries = headers.entries ? Array.from(headers.entries()) : Object.entries(headers || {});
118
+ for (const [key, value] of headerEntries) {
119
+ const lower = key.toLowerCase();
120
+ if (lower !== 'authorization' && lower !== 'cookie' && lower !== 'set-cookie') {
121
+ safeHeaders[key] = value;
122
+ }
123
+ }
124
+
125
+ const idempotencyKey = safeHeaders['x-idempotency-key'] || safeHeaders['X-Idempotency-Key'] || OfflineQueue.generateUUID();
126
+ safeHeaders['X-Idempotency-Key'] = idempotencyKey;
127
+
128
+ const db = await this.getDB();
129
+
130
+ return new Promise((resolve, reject) => {
131
+ const tx = db.transaction(this.storeName, 'readwrite');
132
+ const store = tx.objectStore(this.storeName);
133
+
134
+ // Check max queue length
135
+ const countReq = store.count();
136
+ countReq.onsuccess = () => {
137
+ if (countReq.result >= this.maxItems) {
138
+ console.warn(`[OfflineQueue] Queue reached max depth (${this.maxItems}). Dropping oldest request.`);
139
+ const openCursor = store.openCursor();
140
+ openCursor.onsuccess = (e) => {
141
+ const cursor = e.target.result;
142
+ if (cursor) {
143
+ store.delete(cursor.primaryKey);
144
+ }
145
+ };
146
+ }
147
+
148
+ const item = {
149
+ url,
150
+ method: method.toUpperCase(),
151
+ headers: safeHeaders,
152
+ body: serializedBody,
153
+ idempotencyKey,
154
+ timestamp: Date.now()
155
+ };
156
+
157
+ store.add(item);
158
+ };
159
+
160
+ tx.oncomplete = () => {
161
+ console.log(`[OfflineQueue] Request queued successfully with Idempotency Key: ${idempotencyKey}`);
162
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
163
+ window.dispatchEvent(new CustomEvent('offlinequeue:enqueued', { detail: { url, idempotencyKey } }));
164
+ }
165
+ resolve(idempotencyKey);
166
+ };
167
+ tx.onerror = () => reject(tx.error);
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Replays all stored offline requests sequentially
173
+ */
174
+ async replay() {
175
+ if (this.isReplaying || (typeof navigator !== 'undefined' && !navigator.onLine)) {
176
+ return;
177
+ }
178
+
179
+ this.isReplaying = true;
180
+ const db = await this.getDB();
181
+
182
+ try {
183
+ const items = await new Promise((resolve, reject) => {
184
+ const tx = db.transaction(this.storeName, 'readonly');
185
+ const store = tx.objectStore(this.storeName);
186
+ const req = store.getAll();
187
+ req.onsuccess = () => resolve(req.result || []);
188
+ req.onerror = () => reject(req.error);
189
+ });
190
+
191
+ if (items.length === 0) {
192
+ this.isReplaying = false;
193
+ return;
194
+ }
195
+
196
+ console.log(`[OfflineQueue] Network restored. Replaying ${items.length} queued request(s)...`);
197
+
198
+ // Refresh CSRF Token if available in DOM
199
+ let currentCsrf = null;
200
+ if (typeof document !== 'undefined') {
201
+ const metaCsrf = document.querySelector('meta[name="csrf-token"]') || document.querySelector('meta[name="csrf"]');
202
+ if (metaCsrf) currentCsrf = metaCsrf.getAttribute('content');
203
+ }
204
+
205
+ const now = Date.now();
206
+
207
+ for (const item of items) {
208
+ // Expiration check (24h)
209
+ if (now - item.timestamp > this.maxAgeMs) {
210
+ console.warn(`[OfflineQueue] Request to ${item.url} expired (>24h). Removing from queue.`);
211
+ await this.deleteItem(item.id);
212
+ continue;
213
+ }
214
+
215
+ const headers = { ...item.headers };
216
+ if (currentCsrf) {
217
+ headers['X-CSRF-TOKEN'] = currentCsrf;
218
+ }
219
+
220
+ try {
221
+ const response = await fetch(item.url, {
222
+ method: item.method,
223
+ headers,
224
+ body: item.body
225
+ });
226
+
227
+ // If server successfully processed (2xx or 4xx business error), delete from queue
228
+ // (4xx means invalid input or already processed, no point in infinite retry loop)
229
+ if (response.ok || (response.status >= 400 && response.status < 500)) {
230
+ console.log(`[OfflineQueue] Replayed ${item.url} -> Status ${response.status}`);
231
+ await this.deleteItem(item.id);
232
+ } else {
233
+ console.warn(`[OfflineQueue] Server returned ${response.status} for ${item.url}. Will retry later.`);
234
+ break; // Stop replay if server is returning 5xx errors
235
+ }
236
+ } catch (networkErr) {
237
+ console.warn(`[OfflineQueue] Network still unstable during replay: ${networkErr.message}`);
238
+ break; // Stop loop and wait for next online event
239
+ }
240
+ }
241
+ } catch (err) {
242
+ console.error('[OfflineQueue] Replay cycle error:', err);
243
+ } finally {
244
+ this.isReplaying = false;
245
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
246
+ window.dispatchEvent(new CustomEvent('offlinequeue:replay-complete'));
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Deletes a single item by primary key ID
253
+ */
254
+ async deleteItem(id) {
255
+ const db = await this.getDB();
256
+ return new Promise((resolve, reject) => {
257
+ const tx = db.transaction(this.storeName, 'readwrite');
258
+ const store = tx.objectStore(this.storeName);
259
+ store.delete(id);
260
+ tx.oncomplete = () => resolve();
261
+ tx.onerror = () => reject(tx.error);
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Clears all queued items
267
+ */
268
+ async clear() {
269
+ const db = await this.getDB();
270
+ return new Promise((resolve, reject) => {
271
+ const tx = db.transaction(this.storeName, 'readwrite');
272
+ const store = tx.objectStore(this.storeName);
273
+ store.clear();
274
+ tx.oncomplete = () => resolve();
275
+ tx.onerror = () => reject(tx.error);
276
+ });
277
+ }
278
+ }