331 lines
8.2 KiB
JavaScript
331 lines
8.2 KiB
JavaScript
|
|
// Utility Functions for SkyArtShop
|
|||
|
|
// Centralized helper functions used across the application
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* API Request Handler with Error Management
|
|||
|
|
* @param {string} url - API endpoint
|
|||
|
|
* @param {object} options - Fetch options
|
|||
|
|
* @returns {Promise} - Response data
|
|||
|
|
*/
|
|||
|
|
async function apiRequest(url, options = {}) {
|
|||
|
|
const defaultOptions = {
|
|||
|
|
credentials: "include",
|
|||
|
|
headers: {
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
...options.headers,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(url, { ...defaultOptions, ...options });
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error = await response.json().catch(() => ({
|
|||
|
|
message: `HTTP ${response.status}: ${response.statusText}`,
|
|||
|
|
}));
|
|||
|
|
throw new Error(error.message || "Request failed");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return await response.json();
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV !== "production") {
|
|||
|
|
console.error(`API Request Failed [${url}]:`, error);
|
|||
|
|
}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Debounce function to limit execution rate
|
|||
|
|
* @param {Function} func - Function to debounce
|
|||
|
|
* @param {number} wait - Wait time in milliseconds
|
|||
|
|
* @returns {Function} - Debounced function
|
|||
|
|
*/
|
|||
|
|
function debounce(func, wait = 300) {
|
|||
|
|
let timeout;
|
|||
|
|
return function executedFunction(...args) {
|
|||
|
|
const later = () => {
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
func(...args);
|
|||
|
|
};
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
timeout = setTimeout(later, wait);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Throttle function to limit execution frequency
|
|||
|
|
* @param {Function} func - Function to throttle
|
|||
|
|
* @param {number} limit - Limit in milliseconds
|
|||
|
|
* @returns {Function} - Throttled function
|
|||
|
|
*/
|
|||
|
|
function throttle(func, limit = 300) {
|
|||
|
|
let inThrottle;
|
|||
|
|
return function executedFunction(...args) {
|
|||
|
|
if (!inThrottle) {
|
|||
|
|
func.apply(this, args);
|
|||
|
|
inThrottle = true;
|
|||
|
|
setTimeout(() => (inThrottle = false), limit);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Escape HTML to prevent XSS
|
|||
|
|
* @param {string} text - Text to escape
|
|||
|
|
* @returns {string} - Escaped text
|
|||
|
|
*/
|
|||
|
|
function escapeHtml(text) {
|
|||
|
|
const div = document.createElement("div");
|
|||
|
|
div.textContent = text;
|
|||
|
|
return div.innerHTML;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Format date to readable string
|
|||
|
|
* @param {string} dateString - Date string to format
|
|||
|
|
* @returns {string} - Formatted date
|
|||
|
|
*/
|
|||
|
|
function formatDate(dateString) {
|
|||
|
|
if (!dateString) return "N/A";
|
|||
|
|
|
|||
|
|
const date = new Date(dateString);
|
|||
|
|
if (isNaN(date.getTime())) return "Invalid Date";
|
|||
|
|
|
|||
|
|
return new Intl.DateTimeFormat("en-US", {
|
|||
|
|
year: "numeric",
|
|||
|
|
month: "short",
|
|||
|
|
day: "numeric",
|
|||
|
|
}).format(date);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Format currency
|
|||
|
|
* @param {number} amount - Amount to format
|
|||
|
|
* @param {string} currency - Currency code
|
|||
|
|
* @returns {string} - Formatted currency
|
|||
|
|
*/
|
|||
|
|
function formatCurrency(amount, currency = "USD") {
|
|||
|
|
return new Intl.NumberFormat("en-US", {
|
|||
|
|
style: "currency",
|
|||
|
|
currency: currency,
|
|||
|
|
}).format(amount);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Show toast notification
|
|||
|
|
* @param {string} message - Message to display
|
|||
|
|
* @param {string} type - Type of notification (success, error, warning, info)
|
|||
|
|
* @param {number} duration - Duration in milliseconds
|
|||
|
|
*/
|
|||
|
|
function showToast(message, type = "info", duration = 3000) {
|
|||
|
|
// Remove existing toasts
|
|||
|
|
const existingToast = document.querySelector(".toast-notification");
|
|||
|
|
if (existingToast) {
|
|||
|
|
existingToast.remove();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const toast = document.createElement("div");
|
|||
|
|
toast.className = `toast-notification toast-${type}`;
|
|||
|
|
toast.setAttribute("role", "alert");
|
|||
|
|
toast.setAttribute("aria-live", "polite");
|
|||
|
|
|
|||
|
|
const icons = {
|
|||
|
|
success: "✓",
|
|||
|
|
error: "✗",
|
|||
|
|
warning: "⚠",
|
|||
|
|
info: "ℹ",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
toast.innerHTML = `
|
|||
|
|
<span class="toast-icon" aria-hidden="true">${
|
|||
|
|
icons[type] || icons.info
|
|||
|
|
}</span>
|
|||
|
|
<span class="toast-message">${escapeHtml(message)}</span>
|
|||
|
|
<button class="toast-close" onclick="this.parentElement.remove()" aria-label="Close notification">×</button>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
document.body.appendChild(toast);
|
|||
|
|
|
|||
|
|
// Trigger animation
|
|||
|
|
setTimeout(() => toast.classList.add("show"), 10);
|
|||
|
|
|
|||
|
|
// Auto remove
|
|||
|
|
setTimeout(() => {
|
|||
|
|
toast.classList.remove("show");
|
|||
|
|
setTimeout(() => toast.remove(), 300);
|
|||
|
|
}, duration);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* LocalStorage with error handling
|
|||
|
|
*/
|
|||
|
|
const storage = {
|
|||
|
|
get(key, defaultValue = null) {
|
|||
|
|
try {
|
|||
|
|
const item = localStorage.getItem(key);
|
|||
|
|
return item ? JSON.parse(item) : defaultValue;
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV !== "production") {
|
|||
|
|
console.error("Storage get error:", error);
|
|||
|
|
}
|
|||
|
|
return defaultValue;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
set(key, value) {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|||
|
|
return true;
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV !== "production") {
|
|||
|
|
console.error("Storage set error:", error);
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
remove(key) {
|
|||
|
|
try {
|
|||
|
|
localStorage.removeItem(key);
|
|||
|
|
return true;
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV !== "production") {
|
|||
|
|
console.error("Storage remove error:", error);
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
clear() {
|
|||
|
|
try {
|
|||
|
|
localStorage.clear();
|
|||
|
|
return true;
|
|||
|
|
} catch (error) {
|
|||
|
|
if (process.env.NODE_ENV !== "production") {
|
|||
|
|
console.error("Storage clear error:", error);
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Validate email format
|
|||
|
|
* @param {string} email - Email to validate
|
|||
|
|
* @returns {boolean} - Valid or not
|
|||
|
|
*/
|
|||
|
|
function isValidEmail(email) {
|
|||
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||
|
|
return emailRegex.test(email);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Get image URL with fallback
|
|||
|
|
* @param {string} imagePath - Image path from server
|
|||
|
|
* @param {string} fallback - Fallback image path
|
|||
|
|
* @returns {string} - Full image URL
|
|||
|
|
*/
|
|||
|
|
function getImageUrl(imagePath, fallback = "/assets/images/placeholder.png") {
|
|||
|
|
if (!imagePath) return fallback;
|
|||
|
|
|
|||
|
|
// If already a full URL, return as is
|
|||
|
|
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
|||
|
|
return imagePath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle relative paths
|
|||
|
|
return imagePath.startsWith("/") ? imagePath : `/${imagePath}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Create accessible image element
|
|||
|
|
* @param {string} src - Image source
|
|||
|
|
* @param {string} alt - Alt text
|
|||
|
|
* @param {string} className - CSS classes
|
|||
|
|
* @returns {HTMLImageElement} - Image element
|
|||
|
|
*/
|
|||
|
|
function createImage(src, alt, className = "") {
|
|||
|
|
const img = document.createElement("img");
|
|||
|
|
img.src = getImageUrl(src);
|
|||
|
|
img.alt = alt || "Image";
|
|||
|
|
img.className = className;
|
|||
|
|
img.loading = "lazy";
|
|||
|
|
|
|||
|
|
// Add error handler with fallback
|
|||
|
|
img.onerror = function () {
|
|||
|
|
this.src = "/assets/images/placeholder.png";
|
|||
|
|
this.onerror = null; // Prevent infinite loop
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return img;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Trap focus within modal for accessibility
|
|||
|
|
* @param {HTMLElement} element - Element to trap focus in
|
|||
|
|
*/
|
|||
|
|
function trapFocus(element) {
|
|||
|
|
const focusableElements = element.querySelectorAll(
|
|||
|
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const firstFocusable = focusableElements[0];
|
|||
|
|
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|||
|
|
|
|||
|
|
element.addEventListener("keydown", function (e) {
|
|||
|
|
if (e.key !== "Tab") return;
|
|||
|
|
|
|||
|
|
if (e.shiftKey) {
|
|||
|
|
if (document.activeElement === firstFocusable) {
|
|||
|
|
lastFocusable.focus();
|
|||
|
|
e.preventDefault();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
if (document.activeElement === lastFocusable) {
|
|||
|
|
firstFocusable.focus();
|
|||
|
|
e.preventDefault();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Focus first element
|
|||
|
|
firstFocusable?.focus();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Announce to screen readers
|
|||
|
|
* @param {string} message - Message to announce
|
|||
|
|
* @param {string} priority - Priority level (polite, assertive)
|
|||
|
|
*/
|
|||
|
|
function announceToScreenReader(message, priority = "polite") {
|
|||
|
|
const announcement = document.createElement("div");
|
|||
|
|
announcement.setAttribute("role", "status");
|
|||
|
|
announcement.setAttribute("aria-live", priority);
|
|||
|
|
announcement.className = "sr-only";
|
|||
|
|
announcement.textContent = message;
|
|||
|
|
|
|||
|
|
document.body.appendChild(announcement);
|
|||
|
|
|
|||
|
|
setTimeout(() => announcement.remove(), 1000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Export for use in other scripts
|
|||
|
|
if (typeof module !== "undefined" && module.exports) {
|
|||
|
|
module.exports = {
|
|||
|
|
apiRequest,
|
|||
|
|
debounce,
|
|||
|
|
throttle,
|
|||
|
|
escapeHtml,
|
|||
|
|
formatDate,
|
|||
|
|
formatCurrency,
|
|||
|
|
showToast,
|
|||
|
|
storage,
|
|||
|
|
isValidEmail,
|
|||
|
|
getImageUrl,
|
|||
|
|
createImage,
|
|||
|
|
trapFocus,
|
|||
|
|
announceToScreenReader,
|
|||
|
|
};
|
|||
|
|
}
|