feat: fix push notifications and enhance PWA experience

- Fix InvalidCharacterError in push notifications with proper VAPID key validation
- Add attractive PWA install prompt component with cross-browser support
- Make notification settings always visible regardless of queue status
- Implement PWA install manager with user engagement detection
- Use SvelteKit navigation APIs instead of browser history API
- Add comprehensive error handling and logging
- Include cross-browser compatibility and responsive design
- Add development tooling improvements

Fixes push notification bugs and significantly improves PWA user experience
with modern, accessible interface components and proper error handling.
This commit is contained in:
Giancarmine Salucci
2025-12-22 15:18:03 +01:00
parent 621e113537
commit e49dbfae41
11 changed files with 760 additions and 33 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import InstallPrompt from './components/InstallPrompt.svelte';
import './layout.css';
let { children } = $props();
@@ -10,3 +11,6 @@
</svelte:head>
{@render children()}
<!-- PWA Install Prompt -->
<InstallPrompt />

View File

@@ -5,6 +5,7 @@
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
import QueueItemCard from './components/QueueItemCard.svelte';
import NotificationSettings from './components/NotificationSettings.svelte';
import { replaceState } from '$app/navigation';
let items = $state<QueueItem[]>([]);
let loading = $state(true);
@@ -204,7 +205,7 @@
// Remove highlight parameter from URL without navigation
const url = new URL(window.location.href);
url.searchParams.delete('highlight');
window.history.replaceState({}, '', url.toString());
replaceState(url, {});
}
</script>
@@ -314,12 +315,10 @@
</div>
{/if}
<!-- Notification Settings -->
{#if filteredItems.length > 0 || filter !== 'all'}
<div class="mt-8">
<NotificationSettings />
</div>
{/if}
<!-- Notification Settings - Always visible -->
<div class="mt-8">
<NotificationSettings />
</div>
<!-- Connection Status -->
<div class="fixed bottom-4 right-4">

View File

@@ -0,0 +1,249 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { pwaInstallManager } from '$lib/client/PWAInstallManager';
let showPrompt = $state(false);
let showFallback = $state(false);
let canInstall = $state(false);
let installing = $state(false);
let userEngaged = $state(false);
let unsubscribe: (() => void) | null = null;
onMount(() => {
// Don't show if already dismissed or in standalone mode
if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) {
return;
}
// Listen for install state changes
unsubscribe = pwaInstallManager.onInstallStateChange((installable) => {
canInstall = installable;
// Show prompt after user engagement and delay
if (installable && userEngaged && !pwaInstallManager.isDismissed()) {
setTimeout(() => {
showPrompt = true;
}, 2000);
} else if (!installable && userEngaged && !pwaInstallManager.isStandalone() && !pwaInstallManager.isDismissed()) {
// Show fallback instructions for browsers without beforeinstallprompt
setTimeout(() => {
showFallback = true;
}, 5000);
}
});
// Detect user engagement
const detectEngagement = () => {
userEngaged = true;
document.removeEventListener('scroll', detectEngagement);
document.removeEventListener('click', detectEngagement);
document.removeEventListener('keydown', detectEngagement);
};
document.addEventListener('scroll', detectEngagement, { once: true });
document.addEventListener('click', detectEngagement, { once: true });
document.addEventListener('keydown', detectEngagement, { once: true });
return () => {
unsubscribe?.();
document.removeEventListener('scroll', detectEngagement);
document.removeEventListener('click', detectEngagement);
document.removeEventListener('keydown', detectEngagement);
};
});
async function handleInstall() {
installing = true;
try {
const result = await pwaInstallManager.showInstallPrompt();
if (result === 'accepted') {
showPrompt = false;
showFallback = false;
} else if (result === 'dismissed') {
handleDismiss();
}
} catch (error) {
console.error('Install failed:', error);
} finally {
installing = false;
}
}
function handleDismiss() {
showPrompt = false;
showFallback = false;
pwaInstallManager.setDismissed();
}
</script>
<!-- Main Install Prompt (for browsers with beforeinstallprompt support) -->
{#if showPrompt && canInstall}
<div class="fixed bottom-0 left-0 right-0 z-50 transform transition-transform duration-300 ease-out animate-slide-up">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-2xl">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- App Icon -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white">Install InstaRecipe</h3>
<p class="text-blue-100 text-sm">
Get faster access and offline support. Works like a native app!
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-2 ml-4">
<button
onclick={handleInstall}
disabled={installing}
class="bg-white text-blue-600 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors disabled:opacity-50 flex items-center space-x-2 shadow-lg"
>
{#if installing}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Installing...</span>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Install</span>
{/if}
</button>
<button
onclick={handleDismiss}
class="text-blue-100 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
title="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Features List -->
<div class="mt-3 flex flex-wrap gap-3 text-xs text-blue-100">
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Offline access</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Push notifications</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Faster loading</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Home screen access</span>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Fallback Instructions (for browsers without beforeinstallprompt) -->
{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()}
<div class="fixed bottom-4 right-4 max-w-sm bg-white border rounded-lg shadow-xl p-4 z-40 animate-fade-in">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 mb-1">Install InstaRecipe</h4>
<p class="text-xs text-gray-600 mb-3">
{pwaInstallManager.getInstallInstructions()}
</p>
<!-- Browser-specific hints -->
{#if pwaInstallManager.getBrowserName() === 'safari'}
<div class="flex items-center space-x-1 text-xs text-blue-600 bg-blue-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"></path>
</svg>
<span>Use the Share button</span>
</div>
{:else}
<div class="flex items-center space-x-1 text-xs text-green-600 bg-green-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Look for install button</span>
</div>
{/if}
</div>
<button
onclick={handleDismiss}
class="text-gray-400 hover:text-gray-500 flex-shrink-0"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
</style>

View File

@@ -64,6 +64,12 @@
<p class="text-sm text-gray-600 mb-4">
Get notified when your recipe extractions complete, even when InstaRecipe is not open.
{#if typeof window !== 'undefined'}
<!-- Check if we're on the homepage and queue appears empty -->
{#if window.location.pathname === '/' && !document.querySelector('[data-queue-item]')}
Start by adding some Instagram recipe URLs to see notifications in action!
{/if}
{/if}
</p>
<!-- Status -->