|
| 1 | +/** |
| 2 | + * Manual Cache Manager |
| 3 | + * User-controlled caching with 48-hour expiration |
| 4 | + */ |
| 5 | + |
| 6 | +class ManualCacheManager { |
| 7 | + constructor() { |
| 8 | + this.CACHE_DURATION = 48 * 60 * 60 * 1000; // 48 hours in milliseconds |
| 9 | + this.CACHE_KEY = 'offline_cache_timestamp'; |
| 10 | + this.VERSION = 'v2.0.0'; |
| 11 | + this.PAGE_CACHE_NAME = `vouchervault-pages-${this.VERSION}`; |
| 12 | + this.init(); |
| 13 | + } |
| 14 | + |
| 15 | + init() { |
| 16 | + this.updateCacheStatus(); |
| 17 | + |
| 18 | + // Check cache status every minute to update expiration time |
| 19 | + setInterval(() => { |
| 20 | + this.updateCacheStatus(); |
| 21 | + }, 60000); // Update every 60 seconds |
| 22 | + |
| 23 | + console.log('[ManualCache] Initialized'); |
| 24 | + } |
| 25 | + |
| 26 | + /** |
| 27 | + * Check if cache is still valid (within 48 hours) |
| 28 | + */ |
| 29 | + async isCacheValid() { |
| 30 | + const timestamp = localStorage.getItem(this.CACHE_KEY); |
| 31 | + if (!timestamp) return false; |
| 32 | + |
| 33 | + const age = Date.now() - parseInt(timestamp); |
| 34 | + if (age >= this.CACHE_DURATION) return false; |
| 35 | + |
| 36 | + // Also verify cache actually exists |
| 37 | + try { |
| 38 | + const cache = await caches.open(this.PAGE_CACHE_NAME); |
| 39 | + const keys = await cache.keys(); |
| 40 | + return keys.length > 0; |
| 41 | + } catch (error) { |
| 42 | + return false; |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * Get remaining cache time in hours and minutes |
| 48 | + */ |
| 49 | + getRemainingTime() { |
| 50 | + const timestamp = localStorage.getItem(this.CACHE_KEY); |
| 51 | + if (!timestamp) return { hours: 0, minutes: 0 }; |
| 52 | + |
| 53 | + const age = Date.now() - parseInt(timestamp); |
| 54 | + const remaining = Math.max(0, this.CACHE_DURATION - age); |
| 55 | + |
| 56 | + const hours = Math.floor(remaining / (60 * 60 * 1000)); |
| 57 | + const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); |
| 58 | + |
| 59 | + return { hours, minutes }; |
| 60 | + } |
| 61 | + |
| 62 | + /** |
| 63 | + * Get remaining cache time in hours (for backwards compatibility) |
| 64 | + */ |
| 65 | + getRemainingHours() { |
| 66 | + return this.getRemainingTime().hours; |
| 67 | + } |
| 68 | + |
| 69 | + /** |
| 70 | + * Update cache status display |
| 71 | + */ |
| 72 | + async updateCacheStatus() { |
| 73 | + const statusElement = document.getElementById('cache-status'); |
| 74 | + const buttonTextElement = document.getElementById('cache-button-text'); |
| 75 | + |
| 76 | + if (!statusElement) { |
| 77 | + console.warn('[ManualCache] Status element not found'); |
| 78 | + return; |
| 79 | + } |
| 80 | + |
| 81 | + const isValid = await this.isCacheValid(); |
| 82 | + console.log('[ManualCache] Updating status, valid:', isValid); |
| 83 | + |
| 84 | + if (isValid) { |
| 85 | + const { hours, minutes } = this.getRemainingTime(); |
| 86 | + statusElement.textContent = `${hours}h ${minutes}m left`; |
| 87 | + statusElement.className = 'badge bg-success ms-2'; |
| 88 | + if (buttonTextElement) { |
| 89 | + buttonTextElement.textContent = 'Refresh Cache'; |
| 90 | + } |
| 91 | + } else { |
| 92 | + // If cache doesn't exist but localStorage has timestamp, clear it |
| 93 | + if (localStorage.getItem(this.CACHE_KEY)) { |
| 94 | + localStorage.removeItem(this.CACHE_KEY); |
| 95 | + console.log('[ManualCache] Cleared stale timestamp'); |
| 96 | + } |
| 97 | + statusElement.textContent = 'Not cached'; |
| 98 | + statusElement.className = 'badge bg-secondary ms-2'; |
| 99 | + if (buttonTextElement) { |
| 100 | + buttonTextElement.textContent = 'Cache for Offline'; |
| 101 | + } |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * Cache all current page data and items |
| 107 | + */ |
| 108 | + async cacheData() { |
| 109 | + if (!('caches' in window)) { |
| 110 | + this.showToast('Error', 'Caching not supported in this browser', 'danger'); |
| 111 | + return; |
| 112 | + } |
| 113 | + |
| 114 | + const buttonTextElement = document.getElementById('cache-button-text'); |
| 115 | + const originalText = buttonTextElement ? buttonTextElement.textContent : ''; |
| 116 | + |
| 117 | + if (buttonTextElement) { |
| 118 | + buttonTextElement.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Caching...'; |
| 119 | + } |
| 120 | + |
| 121 | + try { |
| 122 | + // Get current language |
| 123 | + const langMatch = window.location.pathname.match(/^\/(en|de|fr|it|es|pt|nl|pl|ru|zh|ja|ko)/); |
| 124 | + const currentLang = langMatch ? langMatch[1] : 'en'; |
| 125 | + |
| 126 | + const urlsToCache = []; |
| 127 | + |
| 128 | + // Add essential pages |
| 129 | + urlsToCache.push(`/${currentLang}/`); |
| 130 | + urlsToCache.push(`/${currentLang}/dashboard`); |
| 131 | + urlsToCache.push(`/${currentLang}/shared-items/`); |
| 132 | + urlsToCache.push(`/${currentLang}/offline/`); |
| 133 | + |
| 134 | + // Add all filter combinations |
| 135 | + const types = ['giftcard', 'coupon', 'voucher', 'loyaltycard']; |
| 136 | + const statuses = ['available', 'used', 'expired', 'soon_expiring', 'shared_by_me', 'shared_with_me']; |
| 137 | + |
| 138 | + types.forEach(type => { |
| 139 | + urlsToCache.push(`/${currentLang}/?type=${type}`); |
| 140 | + }); |
| 141 | + |
| 142 | + statuses.forEach(status => { |
| 143 | + urlsToCache.push(`/${currentLang}/?status=${status}`); |
| 144 | + }); |
| 145 | + |
| 146 | + types.forEach(type => { |
| 147 | + statuses.forEach(status => { |
| 148 | + urlsToCache.push(`/${currentLang}/?type=${type}&status=${status}`); |
| 149 | + }); |
| 150 | + }); |
| 151 | + |
| 152 | + // Extract all item URLs from the current page |
| 153 | + const itemLinks = document.querySelectorAll('a[href*="/items/view/"]'); |
| 154 | + itemLinks.forEach(link => { |
| 155 | + const url = new URL(link.href); |
| 156 | + urlsToCache.push(url.pathname); |
| 157 | + }); |
| 158 | + |
| 159 | + console.log(`[ManualCache] Caching ${urlsToCache.length} URLs...`); |
| 160 | + |
| 161 | + // Cache all URLs |
| 162 | + const cache = await caches.open(this.PAGE_CACHE_NAME); |
| 163 | + let cached = 0; |
| 164 | + let failed = 0; |
| 165 | + |
| 166 | + for (const url of urlsToCache) { |
| 167 | + try { |
| 168 | + const response = await fetch(url, { |
| 169 | + method: 'GET', |
| 170 | + credentials: 'same-origin' |
| 171 | + }); |
| 172 | + |
| 173 | + if (response.ok) { |
| 174 | + // Add timestamp header |
| 175 | + const blob = await response.clone().blob(); |
| 176 | + const headers = new Headers(response.headers); |
| 177 | + headers.set('sw-cached-time', Date.now().toString()); |
| 178 | + |
| 179 | + const timestampedResponse = new Response(blob, { |
| 180 | + status: response.status, |
| 181 | + statusText: response.statusText, |
| 182 | + headers: headers |
| 183 | + }); |
| 184 | + |
| 185 | + await cache.put(url, timestampedResponse); |
| 186 | + cached++; |
| 187 | + } else { |
| 188 | + failed++; |
| 189 | + } |
| 190 | + } catch (error) { |
| 191 | + console.warn(`[ManualCache] Failed to cache ${url}:`, error); |
| 192 | + failed++; |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + // Save cache timestamp |
| 197 | + localStorage.setItem(this.CACHE_KEY, Date.now().toString()); |
| 198 | + |
| 199 | + console.log(`[ManualCache] ✓ Cached ${cached} pages, ${failed} failed`); |
| 200 | + this.showToast('Success', `Cached ${cached} pages for offline use (valid for 48 hours)`, 'success'); |
| 201 | + this.updateCacheStatus(); |
| 202 | + |
| 203 | + } catch (error) { |
| 204 | + console.error('[ManualCache] Caching failed:', error); |
| 205 | + this.showToast('Error', 'Failed to cache data. Please try again.', 'danger'); |
| 206 | + } finally { |
| 207 | + this.updateCacheStatus(); |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + /** |
| 212 | + * Clear all cached data |
| 213 | + */ |
| 214 | + async clearCache() { |
| 215 | + try { |
| 216 | + const cacheNames = await caches.keys(); |
| 217 | + await Promise.all( |
| 218 | + cacheNames |
| 219 | + .filter(name => name.includes('vouchervault')) |
| 220 | + .map(name => caches.delete(name)) |
| 221 | + ); |
| 222 | + |
| 223 | + localStorage.removeItem(this.CACHE_KEY); |
| 224 | + this.updateCacheStatus(); |
| 225 | + |
| 226 | + this.showToast('Success', 'Cache cleared', 'success'); |
| 227 | + console.log('[ManualCache] Cache cleared'); |
| 228 | + } catch (error) { |
| 229 | + console.error('[ManualCache] Failed to clear cache:', error); |
| 230 | + this.showToast('Error', 'Failed to clear cache', 'danger'); |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + /** |
| 235 | + * Show toast notification |
| 236 | + */ |
| 237 | + showToast(title, message, type = 'info') { |
| 238 | + let toastContainer = document.getElementById('cache-toast-container'); |
| 239 | + if (!toastContainer) { |
| 240 | + toastContainer = document.createElement('div'); |
| 241 | + toastContainer.id = 'cache-toast-container'; |
| 242 | + toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3'; |
| 243 | + toastContainer.style.zIndex = '11'; |
| 244 | + document.body.appendChild(toastContainer); |
| 245 | + } |
| 246 | + |
| 247 | + const toastId = 'toast-' + Date.now(); |
| 248 | + const iconClass = type === 'success' ? 'bi-check-circle-fill' : |
| 249 | + type === 'danger' ? 'bi-exclamation-triangle-fill' : |
| 250 | + 'bi-info-circle-fill'; |
| 251 | + |
| 252 | + const bgClass = type === 'success' ? 'bg-success' : |
| 253 | + type === 'danger' ? 'bg-danger' : |
| 254 | + 'bg-info'; |
| 255 | + |
| 256 | + const toastHtml = ` |
| 257 | + <div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true"> |
| 258 | + <div class="toast-header ${bgClass} text-white"> |
| 259 | + <i class="bi ${iconClass} me-2"></i> |
| 260 | + <strong class="me-auto">${title}</strong> |
| 261 | + <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button> |
| 262 | + </div> |
| 263 | + <div class="toast-body"> |
| 264 | + ${message} |
| 265 | + </div> |
| 266 | + </div> |
| 267 | + `; |
| 268 | + |
| 269 | + toastContainer.insertAdjacentHTML('beforeend', toastHtml); |
| 270 | + |
| 271 | + const toastElement = document.getElementById(toastId); |
| 272 | + const toast = new bootstrap.Toast(toastElement, { |
| 273 | + autohide: true, |
| 274 | + delay: 5000 |
| 275 | + }); |
| 276 | + toast.show(); |
| 277 | + |
| 278 | + toastElement.addEventListener('hidden.bs.toast', () => { |
| 279 | + toastElement.remove(); |
| 280 | + }); |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +// Initialize when DOM is ready |
| 285 | +let manualCacheManager; |
| 286 | +if (document.readyState === 'loading') { |
| 287 | + document.addEventListener('DOMContentLoaded', () => { |
| 288 | + manualCacheManager = new ManualCacheManager(); |
| 289 | + }); |
| 290 | +} else { |
| 291 | + manualCacheManager = new ManualCacheManager(); |
| 292 | +} |
0 commit comments