Implemented a professional loading screen for the PWA to eliminate the "hang and wait" experience during app initialization. Changes: - Added full-screen gradient loading overlay with TeleShop branding - Implemented animated triple-ring spinner with smooth animations - Added automatic removal after PWA initialization (500ms fade-out) - Included 5-second fallback timeout to prevent infinite loading - Updated service worker cache version to v2 - Enhanced JWT validation to detect test/temporary keys - Updated appsettings.json with secure JWT key Design Features: - Purple/blue gradient background matching brand colors - Pulsing logo animation for visual interest - Staggered spinner rings with cubic-bezier easing - Fade-in-out loading text animation - Mobile-responsive design (scales appropriately on all devices) Technical Implementation: - Loading screen visible by default (no FOUC) - Removed via JavaScript when PWA manager initialization completes - Graceful fade-out animation before DOM removal - Console logging for debugging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
281 lines
7.9 KiB
JavaScript
281 lines
7.9 KiB
JavaScript
// Service Worker for LittleShop Admin PWA
|
|
const CACHE_NAME = 'littleshop-admin-v2';
|
|
const urlsToCache = [
|
|
'/Admin/Dashboard',
|
|
'/manifest.json',
|
|
'/lib/bootstrap/css/bootstrap.min.css',
|
|
'/lib/fontawesome/css/all.min.css',
|
|
'/css/modern-admin.css',
|
|
'/css/mobile-admin.css',
|
|
'/lib/jquery/jquery.min.js',
|
|
'/lib/bootstrap/js/bootstrap.bundle.min.js',
|
|
'/js/pwa.js',
|
|
'/js/notifications.js',
|
|
'/icons/icon-192x192.png',
|
|
'/icons/icon-512x512.png'
|
|
];
|
|
|
|
// Install event - cache essential files
|
|
self.addEventListener('install', event => {
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => {
|
|
console.log('Service Worker: Caching essential files');
|
|
return cache.addAll(urlsToCache);
|
|
})
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
// Activate event - clean up old caches
|
|
self.addEventListener('activate', event => {
|
|
event.waitUntil(
|
|
caches.keys().then(cacheNames => {
|
|
return Promise.all(
|
|
cacheNames
|
|
.filter(cacheName => cacheName !== CACHE_NAME)
|
|
.map(cacheName => caches.delete(cacheName))
|
|
);
|
|
}).then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
// Fetch event - network first, fallback to cache
|
|
self.addEventListener('fetch', event => {
|
|
// Skip non-GET requests
|
|
if (event.request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Skip API requests from cache
|
|
if (event.request.url.includes('/api/')) {
|
|
event.respondWith(
|
|
fetch(event.request)
|
|
.catch(() => {
|
|
// If offline and it's an API request, return offline message
|
|
return new Response(
|
|
JSON.stringify({ error: 'Offline - Please check your connection' }),
|
|
{ headers: { 'Content-Type': 'application/json' } }
|
|
);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For everything else, try network first, then cache
|
|
event.respondWith(
|
|
fetch(event.request)
|
|
.then(response => {
|
|
// Don't cache if not a successful response
|
|
if (!response || response.status !== 200 || response.type === 'error') {
|
|
return response;
|
|
}
|
|
|
|
// Clone the response as it can only be consumed once
|
|
const responseToCache = response.clone();
|
|
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => {
|
|
cache.put(event.request, responseToCache);
|
|
});
|
|
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
// If network fails, try cache
|
|
return caches.match(event.request)
|
|
.then(response => {
|
|
if (response) {
|
|
return response;
|
|
}
|
|
// If not in cache, return offline page for navigation requests
|
|
if (event.request.mode === 'navigate') {
|
|
return caches.match('/offline.html');
|
|
}
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Push notification event
|
|
self.addEventListener('push', event => {
|
|
console.log('Push notification received:', event);
|
|
|
|
let notificationData = {
|
|
title: 'LittleShop Admin',
|
|
body: 'You have a new notification',
|
|
icon: '/icons/icon-192x192.png',
|
|
badge: '/icons/icon-96x96.png',
|
|
vibrate: [200, 100, 200],
|
|
data: {
|
|
dateOfArrival: Date.now(),
|
|
primaryKey: 1
|
|
}
|
|
};
|
|
|
|
if (event.data) {
|
|
try {
|
|
const data = event.data.json();
|
|
notificationData = {
|
|
title: data.title || notificationData.title,
|
|
body: data.body || notificationData.body,
|
|
icon: data.icon || notificationData.icon,
|
|
badge: data.badge || notificationData.badge,
|
|
vibrate: data.vibrate || notificationData.vibrate,
|
|
data: data.data || notificationData.data,
|
|
tag: data.tag,
|
|
requireInteraction: data.requireInteraction || false,
|
|
actions: data.actions || []
|
|
};
|
|
} catch (e) {
|
|
console.error('Error parsing push data:', e);
|
|
}
|
|
}
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(notificationData.title, notificationData)
|
|
);
|
|
});
|
|
|
|
// Notification click event
|
|
self.addEventListener('notificationclick', event => {
|
|
console.log('Notification clicked:', event);
|
|
event.notification.close();
|
|
|
|
// Handle action clicks
|
|
if (event.action) {
|
|
switch (event.action) {
|
|
case 'view':
|
|
clients.openWindow(event.notification.data.url || '/Admin/Dashboard');
|
|
break;
|
|
case 'dismiss':
|
|
// Just close the notification
|
|
break;
|
|
default:
|
|
clients.openWindow('/Admin/Dashboard');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Default click - open the app or focus if already open
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
.then(clientList => {
|
|
// Check if there's already a window open
|
|
for (let client of clientList) {
|
|
if (client.url.includes('/Admin/') && 'focus' in client) {
|
|
return client.focus();
|
|
}
|
|
}
|
|
// If no window is open, open a new one
|
|
if (clients.openWindow) {
|
|
return clients.openWindow(event.notification.data?.url || '/Admin/Dashboard');
|
|
}
|
|
})
|
|
);
|
|
});
|
|
|
|
// Background sync for offline actions
|
|
self.addEventListener('sync', event => {
|
|
console.log('Background sync triggered:', event.tag);
|
|
|
|
if (event.tag === 'sync-orders') {
|
|
event.waitUntil(syncOrders());
|
|
} else if (event.tag === 'sync-products') {
|
|
event.waitUntil(syncProducts());
|
|
}
|
|
});
|
|
|
|
// Sync orders when back online
|
|
async function syncOrders() {
|
|
try {
|
|
// Get any pending orders from IndexedDB
|
|
const db = await openDB();
|
|
const tx = db.transaction('pending-orders', 'readonly');
|
|
const store = tx.objectStore('pending-orders');
|
|
const pendingOrders = await store.getAll();
|
|
|
|
for (const order of pendingOrders) {
|
|
await fetch('/api/orders', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(order)
|
|
});
|
|
|
|
// Remove from pending after successful sync
|
|
const deleteTx = db.transaction('pending-orders', 'readwrite');
|
|
await deleteTx.objectStore('pending-orders').delete(order.id);
|
|
}
|
|
|
|
// Show notification of successful sync
|
|
self.registration.showNotification('Orders Synced', {
|
|
body: `${pendingOrders.length} orders synchronized successfully`,
|
|
icon: '/icons/icon-192x192.png'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error syncing orders:', error);
|
|
}
|
|
}
|
|
|
|
// Sync products when back online
|
|
async function syncProducts() {
|
|
try {
|
|
const response = await fetch('/api/catalog/products');
|
|
if (response.ok) {
|
|
const products = await response.json();
|
|
|
|
// Update cached products
|
|
const cache = await caches.open(CACHE_NAME);
|
|
await cache.put('/api/catalog/products', new Response(JSON.stringify(products)));
|
|
|
|
console.log('Products synced successfully');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error syncing products:', error);
|
|
}
|
|
}
|
|
|
|
// Helper function to open IndexedDB
|
|
function openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open('LittleShopDB', 1);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
|
|
request.onupgradeneeded = event => {
|
|
const db = event.target.result;
|
|
|
|
if (!db.objectStoreNames.contains('pending-orders')) {
|
|
db.createObjectStore('pending-orders', { keyPath: 'id', autoIncrement: true });
|
|
}
|
|
|
|
if (!db.objectStoreNames.contains('pending-products')) {
|
|
db.createObjectStore('pending-products', { keyPath: 'id', autoIncrement: true });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Message event for communication with the app
|
|
self.addEventListener('message', event => {
|
|
console.log('Service Worker received message:', event.data);
|
|
|
|
if (event.data.action === 'skipWaiting') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
if (event.data.action === 'clearCache') {
|
|
event.waitUntil(
|
|
caches.keys().then(cacheNames => {
|
|
return Promise.all(
|
|
cacheNames.map(cacheName => caches.delete(cacheName))
|
|
);
|
|
}).then(() => {
|
|
event.ports[0].postMessage({ success: true });
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
console.log('Service Worker loaded successfully'); |