@modernman00/shared-js-lib 1.2.77 → 1.2.79
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/AcctMgt/createFormHandler.js +25 -13
- package/AcctMgt/processAll.js +23 -1
- package/AcctMgt/recaptchaToken.js +25 -10
- package/AcctMgt/update.js +66 -33
- package/Alert.js +158 -0
- package/AuthInterceptor.js +56 -0
- package/ButtonLoader.js +133 -0
- package/Clipboard.js +45 -0
- package/Cookie.js +1 -19
- package/FormLock.js +58 -0
- package/Http.js +148 -47
- package/InlineValidation.js +78 -0
- package/NetworkGuard.js +54 -0
- package/README.md +74 -0
- package/ShowResponse.js +120 -0
- package/index.js +7 -0
- package/package.json +10 -2
- package/release-history.md +2 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// createFormHandler.js
|
|
2
|
+
'use strict';
|
|
2
3
|
import { bindEvent } from '../Utility.js';
|
|
3
4
|
import { recaptchaToken } from './recaptchaToken.js';
|
|
4
5
|
import { loginSubmission } from './processAll.js';
|
|
6
|
+
import { swalError } from '../Alert.js';
|
|
7
|
+
import { clearButtonLoading } from '../ButtonLoader.js';
|
|
5
8
|
|
|
6
9
|
export function createFormHandler({
|
|
7
10
|
formId,
|
|
@@ -16,20 +19,29 @@ export function createFormHandler({
|
|
|
16
19
|
const handler = async (e) => {
|
|
17
20
|
e.preventDefault();
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
try {
|
|
23
|
+
const token = await recaptchaToken(recaptchaAction);
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
await loginSubmission({
|
|
26
|
+
formId,
|
|
27
|
+
loginURL: route,
|
|
28
|
+
redirect,
|
|
29
|
+
buttonId,
|
|
30
|
+
css: theme,
|
|
31
|
+
lengthLimit,
|
|
32
|
+
optionalFields,
|
|
33
|
+
recaptchaToken: token
|
|
34
|
+
? { token, action: recaptchaAction }
|
|
35
|
+
: null
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('createFormHandler Error:', err);
|
|
39
|
+
swalError(err.message || 'Form submission encountered an unexpected error.', true);
|
|
40
|
+
clearButtonLoading(buttonId);
|
|
41
|
+
}
|
|
32
42
|
};
|
|
33
|
-
|
|
43
|
+
|
|
44
|
+
// 🔹 Click binding with defensive execution
|
|
34
45
|
bindEvent({ id: buttonId, handler });
|
|
35
46
|
}
|
|
47
|
+
|
package/AcctMgt/processAll.js
CHANGED
|
@@ -5,6 +5,8 @@ import { showLoader, clearLoader } from '../Loader.js';
|
|
|
5
5
|
import { id } from '../UtilityHtml.js';
|
|
6
6
|
import { showError } from '../ShowResponse.js';
|
|
7
7
|
import FormHelper from '../FormHelper.js';
|
|
8
|
+
import { setButtonLoading, clearButtonLoading } from '../ButtonLoader.js';
|
|
9
|
+
import { swalError } from '../Alert.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Handles the submission of a form and redirects the user.
|
|
@@ -13,6 +15,7 @@ export const loginSubmission = async ({
|
|
|
13
15
|
formId,
|
|
14
16
|
loginURL,
|
|
15
17
|
redirect,
|
|
18
|
+
buttonId = 'button',
|
|
16
19
|
css = null,
|
|
17
20
|
lengthLimit = null, // { id: [...], max: [...] } or null
|
|
18
21
|
optionalFields = [],
|
|
@@ -23,6 +26,9 @@ export const loginSubmission = async ({
|
|
|
23
26
|
loaderClasses = "animate-spin mx-auto block w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full";
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// Resolve target button (by provided buttonId, or finding submit button within form)
|
|
30
|
+
let targetButton = id(buttonId);
|
|
31
|
+
|
|
26
32
|
try {
|
|
27
33
|
const formInput = id(formId);
|
|
28
34
|
|
|
@@ -30,6 +36,10 @@ export const loginSubmission = async ({
|
|
|
30
36
|
throw new Error(`Form ${formId} not found`);
|
|
31
37
|
}
|
|
32
38
|
|
|
39
|
+
if (!targetButton) {
|
|
40
|
+
targetButton = formInput.querySelector('button[type="submit"], input[type="submit"], button');
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
const formInputArr = Array.from(formInput.elements);
|
|
34
44
|
const formData = new FormHelper(formInputArr);
|
|
35
45
|
|
|
@@ -49,9 +59,15 @@ export const loginSubmission = async ({
|
|
|
49
59
|
return;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
const notificationElement = id(`${formId}_notification`) || formInput;
|
|
53
63
|
notificationElement.scrollIntoView({ behavior: 'smooth' });
|
|
54
64
|
|
|
65
|
+
// Enable button spinner on the submit button itself
|
|
66
|
+
if (targetButton) {
|
|
67
|
+
setButtonLoading(targetButton);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Keep external loader for backward compatibility
|
|
55
71
|
showLoader('setLoader', loaderClasses);
|
|
56
72
|
localStorage.setItem('redirect', redirect);
|
|
57
73
|
|
|
@@ -59,7 +75,13 @@ export const loginSubmission = async ({
|
|
|
59
75
|
|
|
60
76
|
} catch (err) {
|
|
61
77
|
showError(err);
|
|
78
|
+
swalError(err.message || 'An error occurred while submitting the form.', true);
|
|
62
79
|
} finally {
|
|
80
|
+
// Guaranteed cleanup: remove button spinner & external loader
|
|
81
|
+
if (targetButton) {
|
|
82
|
+
clearButtonLoading(targetButton);
|
|
83
|
+
}
|
|
63
84
|
clearLoader('setLoader', loaderClasses);
|
|
64
85
|
}
|
|
65
86
|
};
|
|
87
|
+
|
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
// recaptchaToken.js
|
|
2
|
+
'use strict';
|
|
2
3
|
import { showError } from '../ShowResponse.js';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Retrieves a reCAPTCHA v3 or Enterprise token safely.
|
|
7
|
+
* Checks for both grecaptcha.enterprise and standard grecaptcha v3.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} [action='SUBMIT'] - The action name for the score.
|
|
10
|
+
* @returns {Promise<string|null>} Token string or null if unavailable.
|
|
11
|
+
*/
|
|
4
12
|
export const recaptchaToken = async (action = 'SUBMIT') => {
|
|
5
|
-
|
|
13
|
+
if (typeof window === 'undefined') return null;
|
|
14
|
+
const siteKey = window.RECAPTCHA_SITE_KEY || window.recaptchaSiteKey || null;
|
|
15
|
+
if (!siteKey || !window.grecaptcha) return null;
|
|
6
16
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{ action }
|
|
12
|
-
);
|
|
13
|
-
} catch (err) {
|
|
14
|
-
showError(err);
|
|
17
|
+
try {
|
|
18
|
+
// 1. Check Google Cloud reCAPTCHA Enterprise
|
|
19
|
+
if (window.grecaptcha.enterprise && typeof window.grecaptcha.enterprise.execute === 'function') {
|
|
20
|
+
return await window.grecaptcha.enterprise.execute(siteKey, { action });
|
|
15
21
|
}
|
|
22
|
+
|
|
23
|
+
// 2. Fallback to standard reCAPTCHA v3
|
|
24
|
+
if (typeof window.grecaptcha.execute === 'function') {
|
|
25
|
+
return await window.grecaptcha.execute(siteKey, { action });
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
showError(err);
|
|
29
|
+
console.warn('reCAPTCHA execution skipped due to error:', err.message);
|
|
16
30
|
}
|
|
17
31
|
|
|
18
|
-
return
|
|
32
|
+
return null;
|
|
19
33
|
};
|
|
34
|
+
|
package/AcctMgt/update.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// update.js
|
|
2
|
+
'use strict';
|
|
2
3
|
import { id } from '../UtilityHtml.js';
|
|
3
4
|
import { bindEvent } from '../Utility.js';
|
|
4
5
|
import { msgException, showError } from '../ShowResponse.js';
|
|
5
|
-
import axios from '
|
|
6
|
+
import axios from '../axiosWrapper.js';
|
|
6
7
|
import { redirectAfterDelay, parseErrorResponse } from '../general.js';
|
|
7
8
|
import { recaptchaToken } from './recaptchaToken.js';
|
|
9
|
+
import { setButtonLoading, clearButtonLoading } from '../ButtonLoader.js';
|
|
10
|
+
import { swalSuccess, swalError } from '../Alert.js';
|
|
8
11
|
|
|
9
12
|
export function update({
|
|
10
13
|
formId,
|
|
11
|
-
buttonId,
|
|
14
|
+
buttonId = 'button',
|
|
12
15
|
route,
|
|
13
16
|
redirect,
|
|
14
17
|
recaptchaAction = 'SUBMIT'
|
|
@@ -16,53 +19,83 @@ export function update({
|
|
|
16
19
|
const handler = async (e) => {
|
|
17
20
|
e.preventDefault();
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
let btnEl = id(buttonId) || (typeof formId === 'string' ? id(formId)?.querySelector('button') : null);
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
24
|
+
try {
|
|
25
|
+
if (btnEl) {
|
|
26
|
+
setButtonLoading(btnEl);
|
|
27
|
+
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
const inputs = form.querySelectorAll('[name]');
|
|
29
|
+
const token = await recaptchaToken(recaptchaAction);
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const form = id(formId);
|
|
32
|
+
if (!form) {
|
|
33
|
+
msgException(`Form with ID "${formId}" not found.`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const original = input.getAttribute('data-original');
|
|
37
|
-
const current = input.type === 'file' ? input.files[0] : input.value;
|
|
37
|
+
const formData = new FormData();
|
|
38
|
+
const inputs = form.querySelectorAll('[name]');
|
|
38
39
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
} else if (current !== original) {
|
|
42
|
-
formData.append(name, current);
|
|
40
|
+
if (token) {
|
|
41
|
+
formData.append('recaptchaTokenV3', token);
|
|
43
42
|
}
|
|
44
|
-
});
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
inputs.forEach(input => {
|
|
45
|
+
const name = input.name;
|
|
46
|
+
const original = input.getAttribute('data-original');
|
|
47
|
+
const current = input.type === 'file' ? input.files[0] : input.value;
|
|
48
|
+
|
|
49
|
+
if (input.type === 'file') {
|
|
50
|
+
if (current) formData.append(name, current);
|
|
51
|
+
} else if (current !== original) {
|
|
52
|
+
formData.append(name, current);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const response = await axios.post(route, formData, {
|
|
57
|
+
baseURL: '/',
|
|
58
|
+
withCredentials: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const message = response?.data?.message || 'Updated successfully';
|
|
62
|
+
const cleanMessage = typeof message === 'string' ? message : 'Updated successfully';
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
// Modern SweetAlert2 toast
|
|
65
|
+
swalSuccess(cleanMessage, true);
|
|
66
|
+
|
|
67
|
+
// Fallback DOM notification if element exists
|
|
68
|
+
const notificationId = id(`${formId}_notification`);
|
|
69
|
+
if (notificationId) {
|
|
70
|
+
notificationId.style.display = 'block';
|
|
71
|
+
notificationId.innerHTML = cleanMessage;
|
|
72
|
+
notificationId.scrollIntoView({ behavior: 'smooth' });
|
|
73
|
+
}
|
|
54
74
|
|
|
55
75
|
if (redirect) {
|
|
56
|
-
redirectAfterDelay(redirect,
|
|
76
|
+
redirectAfterDelay(redirect, 1500);
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
} catch (error) {
|
|
60
|
-
const errorMsg = parseErrorResponse(error);
|
|
80
|
+
const errorMsg = parseErrorResponse(error) || 'An error occurred during update.';
|
|
81
|
+
showError(error);
|
|
82
|
+
|
|
83
|
+
// Modern SweetAlert2 error modal
|
|
84
|
+
swalError(errorMsg, true);
|
|
85
|
+
|
|
86
|
+
// Fallback DOM notification if element exists
|
|
61
87
|
const notificationId = id(`${formId}_notification`);
|
|
62
|
-
notificationId
|
|
63
|
-
|
|
88
|
+
if (notificationId) {
|
|
89
|
+
notificationId.style.display = 'block';
|
|
90
|
+
notificationId.innerHTML = `<li style="color:white; background-color:red">${errorMsg}</li>`;
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
if (btnEl) {
|
|
94
|
+
clearButtonLoading(btnEl);
|
|
95
|
+
}
|
|
64
96
|
}
|
|
65
97
|
};
|
|
66
98
|
|
|
67
99
|
bindEvent({ id: buttonId, handler });
|
|
68
100
|
}
|
|
101
|
+
|
package/Alert.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import Swal from 'sweetalert2';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if running in browser environment (SSR safety guard).
|
|
6
|
+
*/
|
|
7
|
+
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SweetAlert2 Toast Mixin with sensible modern defaults.
|
|
11
|
+
*/
|
|
12
|
+
const Toast = Swal.mixin({
|
|
13
|
+
toast: true,
|
|
14
|
+
position: 'top-end',
|
|
15
|
+
showConfirmButton: false,
|
|
16
|
+
timer: 3500,
|
|
17
|
+
timerProgressBar: true,
|
|
18
|
+
didOpen: (toast) => {
|
|
19
|
+
toast.addEventListener('mouseenter', Swal.stopTimer);
|
|
20
|
+
toast.addEventListener('mouseleave', Swal.resumeTimer);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Display a modern SweetAlert2 toast notification.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} message - Toast message to display
|
|
28
|
+
* @param {'success'|'error'|'warning'|'info'|'question'} [icon='success'] - Toast icon
|
|
29
|
+
* @param {number} [timer=3500] - Duration in ms
|
|
30
|
+
* @returns {Promise<any>}
|
|
31
|
+
*/
|
|
32
|
+
export const swalToast = async (message, icon = 'success', timer = 3500) => {
|
|
33
|
+
if (!isBrowser()) return null;
|
|
34
|
+
try {
|
|
35
|
+
return await Toast.fire({
|
|
36
|
+
icon,
|
|
37
|
+
title: message,
|
|
38
|
+
timer
|
|
39
|
+
});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('SwalToast Error:', err);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Display a success alert (toast by default, or modal if specified).
|
|
48
|
+
*
|
|
49
|
+
* @param {string} message - Success message
|
|
50
|
+
* @param {boolean} [asToast=true] - Whether to show as toast or modal
|
|
51
|
+
* @param {string} [title='Success'] - Modal title (only used when asToast=false)
|
|
52
|
+
* @returns {Promise<any>}
|
|
53
|
+
*/
|
|
54
|
+
export const swalSuccess = async (message, asToast = true, title = 'Success') => {
|
|
55
|
+
if (!isBrowser()) return null;
|
|
56
|
+
try {
|
|
57
|
+
if (asToast) {
|
|
58
|
+
return await swalToast(message, 'success');
|
|
59
|
+
}
|
|
60
|
+
return await Swal.fire({
|
|
61
|
+
icon: 'success',
|
|
62
|
+
title,
|
|
63
|
+
text: message,
|
|
64
|
+
confirmButtonColor: '#3085d6',
|
|
65
|
+
confirmButtonText: 'OK'
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error('SwalSuccess Error:', err);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Display an error alert (modal dialog by default for high visibility).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} message - Error description/message
|
|
77
|
+
* @param {boolean} [isModal=true] - true for prominent modal, false for toast
|
|
78
|
+
* @param {string} [title='Error'] - Modal title
|
|
79
|
+
* @returns {Promise<any>}
|
|
80
|
+
*/
|
|
81
|
+
export const swalError = async (message, isModal = true, title = 'Error') => {
|
|
82
|
+
if (!isBrowser()) return null;
|
|
83
|
+
try {
|
|
84
|
+
if (!isModal) {
|
|
85
|
+
return await swalToast(message, 'error');
|
|
86
|
+
}
|
|
87
|
+
return await Swal.fire({
|
|
88
|
+
icon: 'error',
|
|
89
|
+
title,
|
|
90
|
+
text: message,
|
|
91
|
+
confirmButtonColor: '#d33',
|
|
92
|
+
confirmButtonText: 'Dismiss'
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('SwalError Error:', err);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Display a warning alert modal or toast.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} message - Warning message
|
|
104
|
+
* @param {string} [title='Warning'] - Modal title
|
|
105
|
+
* @returns {Promise<any>}
|
|
106
|
+
*/
|
|
107
|
+
export const swalWarning = async (message, title = 'Warning') => {
|
|
108
|
+
if (!isBrowser()) return null;
|
|
109
|
+
try {
|
|
110
|
+
return await Swal.fire({
|
|
111
|
+
icon: 'warning',
|
|
112
|
+
title,
|
|
113
|
+
text: message,
|
|
114
|
+
confirmButtonColor: '#f39c12',
|
|
115
|
+
confirmButtonText: 'Understood'
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('SwalWarning Error:', err);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Display an interactive confirmation modal dialog.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} title - Prompt title
|
|
127
|
+
* @param {string} text - Detailed prompt text or warning
|
|
128
|
+
* @param {string} [confirmButtonText='Yes, proceed'] - Label for confirm action
|
|
129
|
+
* @param {string} [cancelButtonText='Cancel'] - Label for cancel action
|
|
130
|
+
* @returns {Promise<boolean>} Resolves to true if user confirmed, false otherwise
|
|
131
|
+
*/
|
|
132
|
+
export const swalConfirm = async (
|
|
133
|
+
title,
|
|
134
|
+
text,
|
|
135
|
+
confirmButtonText = 'Yes, proceed',
|
|
136
|
+
cancelButtonText = 'Cancel'
|
|
137
|
+
) => {
|
|
138
|
+
if (!isBrowser()) return false;
|
|
139
|
+
try {
|
|
140
|
+
const result = await Swal.fire({
|
|
141
|
+
title,
|
|
142
|
+
text,
|
|
143
|
+
icon: 'warning',
|
|
144
|
+
showCancelButton: true,
|
|
145
|
+
confirmButtonColor: '#3085d6',
|
|
146
|
+
cancelButtonColor: '#d33',
|
|
147
|
+
confirmButtonText,
|
|
148
|
+
cancelButtonText,
|
|
149
|
+
reverseButtons: true
|
|
150
|
+
});
|
|
151
|
+
return !!result.isConfirmed;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error('SwalConfirm Error:', err);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export { Swal };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import axios from './axiosWrapper.js';
|
|
3
|
+
import { swalConfirm } from './Alert.js';
|
|
4
|
+
|
|
5
|
+
let isInterceptorInstalled = false;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Installs an Axios response interceptor that automatically handles HTTP 401 Unauthorized errors.
|
|
9
|
+
* Prompts user with SweetAlert2 modal and redirects to login if confirmed.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} [options={}]
|
|
12
|
+
* @param {string} [options.loginUrl='/login'] - Redirect destination on 401
|
|
13
|
+
* @param {string} [options.message='Your session has expired. Please log in again to continue.']
|
|
14
|
+
* @param {string} [options.title='Session Expired']
|
|
15
|
+
*/
|
|
16
|
+
export const setupAuthExpiryInterceptor = (options = {}) => {
|
|
17
|
+
if (typeof window === 'undefined' || isInterceptorInstalled) return;
|
|
18
|
+
isInterceptorInstalled = true;
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
loginUrl = '/login',
|
|
22
|
+
message = 'Your session has expired. Please log in again to continue.',
|
|
23
|
+
title = 'Session Expired'
|
|
24
|
+
} = options;
|
|
25
|
+
|
|
26
|
+
axios.interceptors.response.use(
|
|
27
|
+
(response) => response,
|
|
28
|
+
async (error) => {
|
|
29
|
+
const status = error?.response?.status;
|
|
30
|
+
|
|
31
|
+
if (status === 401) {
|
|
32
|
+
try {
|
|
33
|
+
const confirmed = await swalConfirm(
|
|
34
|
+
title,
|
|
35
|
+
message,
|
|
36
|
+
'Log In',
|
|
37
|
+
'Stay on Page'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (confirmed) {
|
|
41
|
+
// Save current page URL so caller can redirect back after login
|
|
42
|
+
try {
|
|
43
|
+
sessionStorage.setItem('post_login_redirect', window.location.href);
|
|
44
|
+
} catch (_) {}
|
|
45
|
+
|
|
46
|
+
window.location.href = loginUrl;
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('setupAuthExpiryInterceptor Error:', err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Promise.reject(error);
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
};
|
package/ButtonLoader.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { id } from './UtilityHtml.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Universal SVG Spinner markup (framework agnostic, responsive, lightweight).
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_SPINNER_SVG = `
|
|
8
|
+
<svg class="mmlib-btn-spinner" style="display:inline-block; vertical-align:middle; width:1.15em; height:1.15em; animation:mmlib-spin 0.75s linear infinite; margin-right:0.5em;" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
9
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" stroke-opacity="0.25"/>
|
|
10
|
+
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
|
11
|
+
</svg>
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Inject required spinner keyframe animation once into document head if not present.
|
|
16
|
+
*/
|
|
17
|
+
const ensureSpinnerStyles = () => {
|
|
18
|
+
if (typeof document === 'undefined') return;
|
|
19
|
+
const styleId = 'mmlib-button-spinner-styles';
|
|
20
|
+
if (!document.getElementById(styleId)) {
|
|
21
|
+
const style = document.createElement('style');
|
|
22
|
+
style.id = styleId;
|
|
23
|
+
style.textContent = `
|
|
24
|
+
@keyframes mmlib-spin {
|
|
25
|
+
from { transform: rotate(0deg); }
|
|
26
|
+
to { transform: rotate(360deg); }
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
document.head?.appendChild(style);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve target button element by ID, CSS selector, or Element reference.
|
|
35
|
+
* Supports any button type (type="button", type="submit", etc.)
|
|
36
|
+
* @param {HTMLElement|string} btn
|
|
37
|
+
* @returns {HTMLElement|null}
|
|
38
|
+
*/
|
|
39
|
+
export const resolveButton = (btn) => {
|
|
40
|
+
if (typeof btn === 'string') {
|
|
41
|
+
const trimmed = btn.trim();
|
|
42
|
+
return id(trimmed.replace(/^#/, '')) || (typeof document !== 'undefined' ? document.querySelector(trimmed) : null);
|
|
43
|
+
}
|
|
44
|
+
return btn instanceof HTMLElement ? btn : null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sets a button into a loading state:
|
|
49
|
+
* - Disables the button to prevent duplicate submissions
|
|
50
|
+
* - Injects a smooth spinning SVG
|
|
51
|
+
* - Preserves original text/HTML for later restoration
|
|
52
|
+
*
|
|
53
|
+
* @param {HTMLElement|string} btn - Button element or ID string
|
|
54
|
+
* @param {string} [loadingText=''] - Optional text to display next to spinner
|
|
55
|
+
* @returns {boolean} True if successfully set to loading state
|
|
56
|
+
*/
|
|
57
|
+
export const setButtonLoading = (btn, loadingText = '') => {
|
|
58
|
+
try {
|
|
59
|
+
const el = resolveButton(btn);
|
|
60
|
+
if (!el) return false;
|
|
61
|
+
|
|
62
|
+
ensureSpinnerStyles();
|
|
63
|
+
|
|
64
|
+
// Avoid overriding if already in loading state
|
|
65
|
+
if (!el.hasAttribute('data-mmlib-loading')) {
|
|
66
|
+
el.setAttribute('data-mmlib-loading', 'true');
|
|
67
|
+
el.setAttribute('data-original-html', el.innerHTML);
|
|
68
|
+
el.setAttribute('data-original-disabled', el.disabled ? 'true' : 'false');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
el.disabled = true;
|
|
72
|
+
el.setAttribute('aria-busy', 'true');
|
|
73
|
+
|
|
74
|
+
const displayText = loadingText ? `<span>${loadingText}</span>` : el.getAttribute('data-original-html') || '';
|
|
75
|
+
el.innerHTML = `${DEFAULT_SPINNER_SVG}${displayText}`;
|
|
76
|
+
|
|
77
|
+
return true;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error('setButtonLoading Error:', err);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Restores a button from its loading state back to its original HTML and state.
|
|
86
|
+
*
|
|
87
|
+
* @param {HTMLElement|string} btn - Button element or ID string
|
|
88
|
+
* @returns {boolean} True if successfully restored
|
|
89
|
+
*/
|
|
90
|
+
export const clearButtonLoading = (btn) => {
|
|
91
|
+
try {
|
|
92
|
+
const el = resolveButton(btn);
|
|
93
|
+
if (!el) return false;
|
|
94
|
+
|
|
95
|
+
if (el.hasAttribute('data-mmlib-loading')) {
|
|
96
|
+
const originalHtml = el.getAttribute('data-original-html');
|
|
97
|
+
const wasDisabledBefore = el.getAttribute('data-original-disabled') === 'true';
|
|
98
|
+
|
|
99
|
+
if (originalHtml !== null) {
|
|
100
|
+
el.innerHTML = originalHtml;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
el.disabled = wasDisabledBefore;
|
|
104
|
+
el.removeAttribute('aria-busy');
|
|
105
|
+
el.removeAttribute('data-mmlib-loading');
|
|
106
|
+
el.removeAttribute('data-original-html');
|
|
107
|
+
el.removeAttribute('data-original-disabled');
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('clearButtonLoading Error:', err);
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Helper to wrap any async action with automatic button loading state and guaranteed cleanup.
|
|
120
|
+
*
|
|
121
|
+
* @param {HTMLElement|string} btn - Button element or ID
|
|
122
|
+
* @param {() => Promise<any>} asyncAction - Async callback function to execute
|
|
123
|
+
* @param {string} [loadingText=''] - Optional loading text
|
|
124
|
+
* @returns {Promise<any>} Result of asyncAction
|
|
125
|
+
*/
|
|
126
|
+
export const withButtonSpinner = async (btn, asyncAction, loadingText = '') => {
|
|
127
|
+
setButtonLoading(btn, loadingText);
|
|
128
|
+
try {
|
|
129
|
+
return await asyncAction();
|
|
130
|
+
} finally {
|
|
131
|
+
clearButtonLoading(btn);
|
|
132
|
+
}
|
|
133
|
+
};
|