Configure push notifications for internal-only access

- Changed VAPID subject from public URL to mailto format
- Updated docker-compose.yml to use mailto:admin@littleshop.local
- Removed dependency on thebankofdebbie.giize.com public domain
- All push notifications now work through VPN (admin.dark.side) only
- Added update-push-internal.sh helper script for deployment
- Improved security by keeping all admin traffic internal

Push notifications will continue working normally through FCM,
but all configuration and management stays on the internal network.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-30 21:15:42 +01:00
parent 021cfc4edc
commit 5e90b86d8c
183 changed files with 522322 additions and 6190 deletions

View File

@@ -1,221 +1,221 @@
// Modern Mobile Enhancements
// Clean, simple mobile-friendly functionality
class ModernMobile {
constructor() {
this.init();
}
init() {
this.setupMobileTableLabels();
this.setupResponsiveNavigation();
this.setupFormEnhancements();
this.setupSmoothInteractions();
}
// Add data labels for mobile table stacking
setupMobileTableLabels() {
const tables = document.querySelectorAll('.table');
tables.forEach(table => {
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (headers[index]) {
cell.setAttribute('data-label', headers[index]);
}
});
});
});
}
// Enhanced mobile navigation
setupResponsiveNavigation() {
const navbar = document.querySelector('.navbar');
const toggler = document.querySelector('.navbar-toggler');
const collapse = document.querySelector('.navbar-collapse');
if (toggler && collapse) {
// Smooth collapse animation
toggler.addEventListener('click', () => {
collapse.classList.toggle('show');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!navbar.contains(e.target) && collapse.classList.contains('show')) {
collapse.classList.remove('show');
}
});
}
}
// Form enhancements for better mobile UX
setupFormEnhancements() {
// Auto-focus first input on desktop
if (window.innerWidth > 768) {
const firstInput = document.querySelector('.form-control:not([readonly]):not([disabled])');
if (firstInput) {
firstInput.focus();
}
}
// Enhanced form validation feedback
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
// Skip validation for file upload forms
if (form.enctype === 'multipart/form-data') {
console.log('Mobile: Skipping validation for file upload form');
return;
}
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
e.preventDefault();
console.log('Mobile: Form validation failed, focusing on first invalid input');
invalidInputs[0].focus();
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
});
// Floating labels effect
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
if (input.value) {
input.parentElement.classList.add('has-value');
}
input.addEventListener('blur', () => {
if (input.value) {
input.parentElement.classList.add('has-value');
} else {
input.parentElement.classList.remove('has-value');
}
});
});
}
// Smooth interactions and feedback
setupSmoothInteractions() {
// Button click feedback
const buttons = document.querySelectorAll('.btn');
buttons.forEach(button => {
button.addEventListener('click', (e) => {
if (!button.disabled) {
button.style.transform = 'scale(0.95)';
setTimeout(() => {
button.style.transform = '';
}, 100);
}
});
});
// Card hover enhancement
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
// Smooth scroll for anchor links
const anchorLinks = document.querySelectorAll('a[href^="#"]');
anchorLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
}
// Show toast notification
showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show`;
toast.style.cssText = `
margin-bottom: 0.5rem;
animation: slideInRight 0.3s ease;
`;
toast.innerHTML = `
${message}
<button type="button" class="btn-close" aria-label="Close"></button>
`;
toastContainer.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
}, 4000);
// Manual close
const closeBtn = toast.querySelector('.btn-close');
closeBtn.addEventListener('click', () => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
});
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
max-width: 300px;
`;
document.body.appendChild(container);
return container;
}
}
// CSS for toast animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade {
opacity: 0 !important;
transition: opacity 0.15s linear !important;
}
`;
document.head.appendChild(style);
// Initialize
const modernMobile = new ModernMobile();
// Modern Mobile Enhancements
// Clean, simple mobile-friendly functionality
class ModernMobile {
constructor() {
this.init();
}
init() {
this.setupMobileTableLabels();
this.setupResponsiveNavigation();
this.setupFormEnhancements();
this.setupSmoothInteractions();
}
// Add data labels for mobile table stacking
setupMobileTableLabels() {
const tables = document.querySelectorAll('.table');
tables.forEach(table => {
const headers = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
cells.forEach((cell, index) => {
if (headers[index]) {
cell.setAttribute('data-label', headers[index]);
}
});
});
});
}
// Enhanced mobile navigation
setupResponsiveNavigation() {
const navbar = document.querySelector('.navbar');
const toggler = document.querySelector('.navbar-toggler');
const collapse = document.querySelector('.navbar-collapse');
if (toggler && collapse) {
// Smooth collapse animation
toggler.addEventListener('click', () => {
collapse.classList.toggle('show');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!navbar.contains(e.target) && collapse.classList.contains('show')) {
collapse.classList.remove('show');
}
});
}
}
// Form enhancements for better mobile UX
setupFormEnhancements() {
// Auto-focus first input on desktop
if (window.innerWidth > 768) {
const firstInput = document.querySelector('.form-control:not([readonly]):not([disabled])');
if (firstInput) {
firstInput.focus();
}
}
// Enhanced form validation feedback
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', (e) => {
// Skip validation for file upload forms
if (form.enctype === 'multipart/form-data') {
console.log('Mobile: Skipping validation for file upload form');
return;
}
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
e.preventDefault();
console.log('Mobile: Form validation failed, focusing on first invalid input');
invalidInputs[0].focus();
invalidInputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
});
// Floating labels effect
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
if (input.value) {
input.parentElement.classList.add('has-value');
}
input.addEventListener('blur', () => {
if (input.value) {
input.parentElement.classList.add('has-value');
} else {
input.parentElement.classList.remove('has-value');
}
});
});
}
// Smooth interactions and feedback
setupSmoothInteractions() {
// Button click feedback
const buttons = document.querySelectorAll('.btn');
buttons.forEach(button => {
button.addEventListener('click', (e) => {
if (!button.disabled) {
button.style.transform = 'scale(0.95)';
setTimeout(() => {
button.style.transform = '';
}, 100);
}
});
});
// Card hover enhancement
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
// Smooth scroll for anchor links
const anchorLinks = document.querySelectorAll('a[href^="#"]');
anchorLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
}
// Show toast notification
showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container') || this.createToastContainer();
const toast = document.createElement('div');
toast.className = `alert alert-${type} alert-dismissible fade show`;
toast.style.cssText = `
margin-bottom: 0.5rem;
animation: slideInRight 0.3s ease;
`;
toast.innerHTML = `
${message}
<button type="button" class="btn-close" aria-label="Close"></button>
`;
toastContainer.appendChild(toast);
// Auto-remove after 4 seconds
setTimeout(() => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
}, 4000);
// Manual close
const closeBtn = toast.querySelector('.btn-close');
closeBtn.addEventListener('click', () => {
toast.classList.add('fade');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 150);
});
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
max-width: 300px;
`;
document.body.appendChild(container);
return container;
}
}
// CSS for toast animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade {
opacity: 0 !important;
transition: opacity 0.15s linear !important;
}
`;
document.head.appendChild(style);
// Initialize
const modernMobile = new ModernMobile();
window.modernMobile = modernMobile;

View File

@@ -0,0 +1,281 @@
// Service Worker for LittleShop Admin PWA
const CACHE_NAME = 'littleshop-admin-v1';
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');