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:
Matt 2025-06-12 16:36:32 +02:00
parent 4916c20f64
commit b25e93d2a0
6 changed files with 586 additions and 64 deletions

View File

@ -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>

101
composables/usePWA.ts Normal file
View File

@ -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
};
};

117
docs/pwa-implementation.md Normal file
View File

@ -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

View File

@ -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: "",

View File

@ -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>

16
plugins/pwa.client.ts Normal file
View File

@ -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
});
}
}
});