feat: Implement comprehensive PWA functionality
- Add full PWA configuration with manifest and service worker - Create PWAInstallBanner component with mobile detection - Implement install banner on login page (shows below login form) - Add usePWA composable for reusable PWA functionality - Configure offline support with Workbox caching strategies - Add PWA initialization plugin - Update app name to 'Port Nimara Portal' throughout - Use circular logo in install banner and instructions - Banner shows only once and hides if already installed - Support both Android (direct install) and iOS (manual instructions) - Add comprehensive documentation for PWA implementation Features: - Mobile-only install banner with dismissal tracking - Standalone mode detection to hide banner when installed - Platform-specific installation instructions - Offline functionality with API caching - Auto-updating service worker - Native app-like experience when installed
This commit is contained in:
parent
4916c20f64
commit
b25e93d2a0
|
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<v-slide-y-transition>
|
||||
<v-card
|
||||
v-if="showBanner"
|
||||
class="mt-4 mx-auto"
|
||||
max-width="350"
|
||||
elevation="2"
|
||||
rounded
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<v-row align="center" no-gutters>
|
||||
<v-col cols="auto" class="mr-3">
|
||||
<v-img
|
||||
src="/Port Nimara New Logo-Circular Frame.png"
|
||||
width="48"
|
||||
height="48"
|
||||
class="rounded-circle"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<div class="text-body-2 font-weight-medium mb-1">
|
||||
Install Port Nimara Portal
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-1">
|
||||
Get faster access and work offline
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="dismissBanner"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-3" no-gutters>
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
block
|
||||
@click="installApp"
|
||||
:loading="installing"
|
||||
>
|
||||
<v-icon left>mdi-download</v-icon>
|
||||
Install App
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-slide-y-transition>
|
||||
|
||||
<!-- Installation Instructions Dialog -->
|
||||
<v-dialog v-model="showInstructions" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-img
|
||||
src="/Port Nimara New Logo-Circular Frame.png"
|
||||
width="32"
|
||||
height="32"
|
||||
class="rounded-circle mr-2"
|
||||
/>
|
||||
Install Port Nimara Portal
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<div v-if="isIOS">
|
||||
<div class="text-body-2 mb-3">
|
||||
To install this app on your iPhone/iPad:
|
||||
</div>
|
||||
<v-list density="compact">
|
||||
<v-list-item prepend-icon="mdi-share-variant">
|
||||
<v-list-item-title class="text-body-2">
|
||||
Tap the <strong>Share</strong> button at the bottom
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-plus-box">
|
||||
<v-list-item-title class="text-body-2">
|
||||
Scroll down and tap <strong>"Add to Home Screen"</strong>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="mdi-check">
|
||||
<v-list-item-title class="text-body-2">
|
||||
Tap <strong>"Add"</strong> to confirm
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-alert type="info" variant="tonal" class="mt-3">
|
||||
<div class="text-caption">
|
||||
Make sure you're using Safari browser for this to work.
|
||||
</div>
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="text-body-2 mb-3">
|
||||
Your browser doesn't support automatic installation.
|
||||
</div>
|
||||
<div class="text-caption text-grey-darken-1">
|
||||
Try using Chrome or another modern browser for the best experience.
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showInstructions = false" variant="text">
|
||||
Close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { isInstalled, isMobile, isIOS, canInstall, install, init } = usePWA();
|
||||
|
||||
const showBanner = ref(false);
|
||||
const showInstructions = ref(false);
|
||||
const installing = ref(false);
|
||||
|
||||
// Check if banner has been dismissed
|
||||
const wasDismissed = computed(() => {
|
||||
if (process.client) {
|
||||
return localStorage.getItem('pwa-install-dismissed') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if should show banner
|
||||
const shouldShowBanner = computed(() => {
|
||||
return canInstall.value && !wasDismissed.value;
|
||||
});
|
||||
|
||||
const dismissBanner = () => {
|
||||
showBanner.value = false;
|
||||
if (process.client) {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const installApp = async () => {
|
||||
installing.value = true;
|
||||
|
||||
try {
|
||||
const success = await install();
|
||||
if (success) {
|
||||
dismissBanner();
|
||||
} else if (isIOS.value) {
|
||||
// iOS - show instructions
|
||||
showInstructions.value = true;
|
||||
} else {
|
||||
// Other platforms - show instructions
|
||||
showInstructions.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Install failed:', error);
|
||||
if (isIOS.value) {
|
||||
showInstructions.value = true;
|
||||
}
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (process.client) {
|
||||
// Initialize PWA functionality
|
||||
init();
|
||||
|
||||
// Show banner if conditions are met
|
||||
if (shouldShowBanner.value) {
|
||||
// Small delay to let the page load
|
||||
setTimeout(() => {
|
||||
showBanner.value = true;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Watch for installation status changes
|
||||
watch(isInstalled, (newValue) => {
|
||||
if (newValue) {
|
||||
dismissBanner();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
export const usePWA = () => {
|
||||
const isInstalled = ref(false);
|
||||
const isInstallable = ref(false);
|
||||
const deferredPrompt = ref<any>(null);
|
||||
|
||||
// Check if app is installed (running in standalone mode)
|
||||
const checkInstallStatus = () => {
|
||||
if (process.client) {
|
||||
isInstalled.value = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://');
|
||||
}
|
||||
};
|
||||
|
||||
// Check if device is mobile
|
||||
const isMobile = computed(() => {
|
||||
if (process.client) {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if device is iOS
|
||||
const isIOS = computed(() => {
|
||||
if (process.client) {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if device is Android
|
||||
const isAndroid = computed(() => {
|
||||
if (process.client) {
|
||||
return /Android/i.test(navigator.userAgent);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Install the app
|
||||
const install = async () => {
|
||||
if (deferredPrompt.value) {
|
||||
deferredPrompt.value.prompt();
|
||||
const choiceResult = await deferredPrompt.value.userChoice;
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the install prompt');
|
||||
deferredPrompt.value = null;
|
||||
isInstallable.value = false;
|
||||
return true;
|
||||
} else {
|
||||
console.log('User dismissed the install prompt');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if app can be installed
|
||||
const canInstall = computed(() => {
|
||||
return isMobile.value && !isInstalled.value && (isInstallable.value || isIOS.value);
|
||||
});
|
||||
|
||||
// Initialize PWA functionality
|
||||
const init = () => {
|
||||
if (process.client) {
|
||||
checkInstallStatus();
|
||||
|
||||
// Listen for the beforeinstallprompt event
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
console.log('beforeinstallprompt event fired');
|
||||
e.preventDefault();
|
||||
deferredPrompt.value = e;
|
||||
isInstallable.value = true;
|
||||
});
|
||||
|
||||
// Listen for successful app installation
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('PWA was installed');
|
||||
isInstalled.value = true;
|
||||
isInstallable.value = false;
|
||||
deferredPrompt.value = null;
|
||||
});
|
||||
|
||||
// Listen for display mode changes
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener('change', (e) => {
|
||||
isInstalled.value = e.matches;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isInstalled: readonly(isInstalled),
|
||||
isInstallable: readonly(isInstallable),
|
||||
isMobile,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
canInstall,
|
||||
install,
|
||||
init
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# PWA Implementation Guide
|
||||
|
||||
## Overview
|
||||
The Port Nimara Portal has been implemented as a Progressive Web App (PWA) to provide users with a native app-like experience on mobile devices.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. PWA Configuration
|
||||
- **App Name**: Port Nimara Portal
|
||||
- **Short Name**: Port Nimara
|
||||
- **Theme Color**: #387bca (brand primary color)
|
||||
- **Background Color**: #ffffff
|
||||
- **Display Mode**: standalone
|
||||
- **Start URL**: /
|
||||
- **Icons**: Multiple sizes from 72x72 to 512x512 pixels
|
||||
|
||||
### 2. Install Banner
|
||||
- **Location**: Login page, below the login form
|
||||
- **Behavior**:
|
||||
- Only shows on mobile devices
|
||||
- Hidden if app is already installed (standalone mode)
|
||||
- Shows only once (dismissal stored in localStorage)
|
||||
- Animated slide-in with 1-second delay
|
||||
|
||||
### 3. Installation Flow
|
||||
- **Android**: Direct browser install prompt
|
||||
- **iOS**: Step-by-step instructions modal
|
||||
- **Other browsers**: Fallback instructions
|
||||
|
||||
### 4. Offline Support
|
||||
- **Service Worker**: Auto-updating with Workbox
|
||||
- **Caching**: Network-first for API calls, cache-first for assets
|
||||
- **Navigation Fallback**: Routes to home page when offline
|
||||
|
||||
## Files Added/Modified
|
||||
|
||||
### New Files
|
||||
- `components/PWAInstallBanner.vue` - Install banner component
|
||||
- `composables/usePWA.ts` - PWA functionality composable
|
||||
- `plugins/pwa.client.ts` - PWA initialization plugin
|
||||
- `docs/pwa-implementation.md` - This documentation
|
||||
|
||||
### Modified Files
|
||||
- `nuxt.config.ts` - Added PWA configuration
|
||||
- `pages/login.vue` - Added install banner component
|
||||
|
||||
## Configuration Details
|
||||
|
||||
### PWA Module Settings
|
||||
```typescript
|
||||
pwa: {
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Port Nimara Portal',
|
||||
short_name: 'Port Nimara',
|
||||
description: 'Port Nimara Client Portal - Manage your berth interests and expressions of interest',
|
||||
theme_color: '#387bca',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [/* 8 icon sizes */]
|
||||
},
|
||||
workbox: {
|
||||
// Caching strategies
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Requirements
|
||||
The following icon sizes are configured:
|
||||
- 72x72, 96x96, 128x128, 144x144 (Android)
|
||||
- 152x152, 192x192 (iOS, Android)
|
||||
- 384x384, 512x512 (Splash screens, large displays)
|
||||
|
||||
All icons are located in `/public/icons/` and based on the circular Port Nimara logo.
|
||||
|
||||
### Browser Support
|
||||
- **Chrome/Edge (Android)**: Full PWA support with install prompt
|
||||
- **Safari (iOS)**: Manual installation via "Add to Home Screen"
|
||||
- **Firefox**: Basic PWA support
|
||||
- **Other browsers**: Graceful degradation with instructions
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### For Users
|
||||
1. Visit the login page on a mobile device
|
||||
2. If eligible, an install banner will appear below the login form
|
||||
3. Tap "Install App" to begin installation
|
||||
4. Follow platform-specific instructions if needed
|
||||
|
||||
### For Developers
|
||||
The PWA functionality is automatically initialized. Use the `usePWA()` composable to access PWA state:
|
||||
|
||||
```typescript
|
||||
const { isInstalled, canInstall, install, isMobile, isIOS } = usePWA();
|
||||
```
|
||||
|
||||
## Testing
|
||||
- Test on actual mobile devices for best results
|
||||
- Use Chrome DevTools > Application > Manifest to verify configuration
|
||||
- Check Service Worker registration in DevTools > Application > Service Workers
|
||||
- Test offline functionality by disabling network
|
||||
|
||||
## Deployment Notes
|
||||
- Ensure HTTPS is enabled (required for PWA)
|
||||
- Icons must be accessible via direct URLs
|
||||
- Service worker will be auto-generated and registered
|
||||
- Manifest will be auto-generated from nuxt.config.ts settings
|
||||
|
||||
## Future Enhancements
|
||||
- Push notifications
|
||||
- Background sync
|
||||
- App shortcuts
|
||||
- Share target functionality
|
||||
- Install banner in dashboard for non-mobile users
|
||||
|
|
@ -5,18 +5,106 @@ export default defineNuxtConfig({
|
|||
modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
|
||||
app: {
|
||||
head: {
|
||||
titleTemplate: "%s • Port Nimara Client Portal",
|
||||
title: "Port Nimara Client Portal",
|
||||
titleTemplate: "%s • Port Nimara Portal",
|
||||
title: "Port Nimara Portal",
|
||||
meta: [
|
||||
{ property: "og:title", content: "Port Nimara Client Portal" },
|
||||
{ property: "og:title", content: "Port Nimara Portal" },
|
||||
{ property: "og:image", content: "/og-image.png" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-status-bar-style", content: "default" },
|
||||
{ name: "apple-mobile-web-app-title", content: "Port Nimara Portal" },
|
||||
],
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
},
|
||||
},
|
||||
},
|
||||
pwa: {
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'Port Nimara Portal',
|
||||
short_name: 'Port Nimara',
|
||||
description: 'Port Nimara Client Portal - Manage your berth interests and expressions of interest',
|
||||
theme_color: '#387bca',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-72x72.png',
|
||||
sizes: '72x72',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-96x96.png',
|
||||
sizes: '96x96',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-128x128.png',
|
||||
sizes: '128x128',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-144x144.png',
|
||||
sizes: '144x144',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-152x152.png',
|
||||
sizes: '152x152',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-384x384.png',
|
||||
sizes: '384x384',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/',
|
||||
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 // 24 hours
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
client: {
|
||||
installPrompt: true,
|
||||
periodicSyncForUpdates: 20
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
}
|
||||
},
|
||||
runtimeConfig: {
|
||||
nocodb: {
|
||||
url: "",
|
||||
|
|
|
|||
127
pages/login.vue
127
pages/login.vue
|
|
@ -3,71 +3,76 @@
|
|||
<v-main class="container">
|
||||
<v-container class="fill-height" fluid>
|
||||
<v-row align="center" justify="center" class="fill-height">
|
||||
<v-card class="pa-6" rounded max-width="350" elevation="2">
|
||||
<v-form @submit.prevent="submit" v-model="valid">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12">
|
||||
<v-img src="/logo.jpg" width="200" class="mb-3 mx-auto" />
|
||||
</v-col>
|
||||
<v-scroll-y-transition>
|
||||
<v-col v-if="errorThrown" cols="12" class="my-3">
|
||||
<v-alert
|
||||
text="Invalid email address or password"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
/>
|
||||
<v-col cols="12" class="d-flex flex-column align-center">
|
||||
<v-card class="pa-6" rounded max-width="350" elevation="2">
|
||||
<v-form @submit.prevent="submit" v-model="valid">
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12">
|
||||
<v-img src="/logo.jpg" width="200" class="mb-3 mx-auto" />
|
||||
</v-col>
|
||||
</v-scroll-y-transition>
|
||||
<v-col cols="12">
|
||||
<v-row dense>
|
||||
<v-col cols="12" class="mt-4">
|
||||
<v-text-field
|
||||
v-model="emailAddress"
|
||||
placeholder="Email address"
|
||||
:disabled="loading"
|
||||
:rules="[
|
||||
(value) => !!value || 'Must not be empty',
|
||||
(value) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ||
|
||||
'Invalid email address',
|
||||
]"
|
||||
variant="outlined"
|
||||
type="email"
|
||||
autofocus
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
@click:append-inner="passwordVisible = !passwordVisible"
|
||||
v-model="password"
|
||||
placeholder="Password"
|
||||
:disabled="loading"
|
||||
:type="passwordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
passwordVisible ? 'mdi-eye' : 'mdi-eye-off'
|
||||
"
|
||||
:rules="[(value) => !!value || 'Must not be empty']"
|
||||
autocomplete="current-password"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
text="Log in"
|
||||
:disabled="!valid"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
<v-scroll-y-transition>
|
||||
<v-col v-if="errorThrown" cols="12" class="my-3">
|
||||
<v-alert
|
||||
text="Invalid email address or password"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-scroll-y-transition>
|
||||
<v-col cols="12">
|
||||
<v-row dense>
|
||||
<v-col cols="12" class="mt-4">
|
||||
<v-text-field
|
||||
v-model="emailAddress"
|
||||
placeholder="Email address"
|
||||
:disabled="loading"
|
||||
:rules="[
|
||||
(value) => !!value || 'Must not be empty',
|
||||
(value) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ||
|
||||
'Invalid email address',
|
||||
]"
|
||||
variant="outlined"
|
||||
type="email"
|
||||
autofocus
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
@click:append-inner="passwordVisible = !passwordVisible"
|
||||
v-model="password"
|
||||
placeholder="Password"
|
||||
:disabled="loading"
|
||||
:type="passwordVisible ? 'text' : 'password'"
|
||||
:append-inner-icon="
|
||||
passwordVisible ? 'mdi-eye' : 'mdi-eye-off'
|
||||
"
|
||||
:rules="[(value) => !!value || 'Must not be empty']"
|
||||
autocomplete="current-password"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
text="Log in"
|
||||
:disabled="!valid"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="large"
|
||||
block
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
||||
<!-- PWA Install Banner -->
|
||||
<PWAInstallBanner />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
export default defineNuxtPlugin(() => {
|
||||
const { init } = usePWA();
|
||||
|
||||
// Initialize PWA functionality when the app starts
|
||||
if (process.client) {
|
||||
init();
|
||||
|
||||
// Register service worker update handler
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('PWA updated successfully');
|
||||
// You could show a toast notification here
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue