PWA Features
Progressive Web App capabilities including offline support, push notifications, and app installation
PWA Overview
WI-CARPOOL is built as a Progressive Web App, providing native app-like experiences with web technologies.
📱 App Installation
Users can install the app on their home screen for quick access.
🔄 Offline Support
Core functionality works even without internet connection.
🔔 Push Notifications
Real-time notifications for ride updates and messages.
⚡ Fast Loading
Service worker caching for instant loading times.
Service Worker Implementation
Service Worker Registration
// src/utils/pwa.ts
export function registerSW() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('SW registered: ', registration)
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New content available
showUpdateAvailableNotification()
}
})
}
})
} catch (error) {
console.log('SW registration failed: ', error)
}
})
}
}
function showUpdateAvailableNotification() {
// Show user notification about available update
if (confirm('New version available! Reload to update?')) {
window.location.reload()
}
}
Caching Strategy
// public/sw.js
const CACHE_NAME = 'wicarpool-v1.0.0'
const STATIC_CACHE = 'static-v1'
const DYNAMIC_CACHE = 'dynamic-v1'
const STATIC_ASSETS = [
'/',
'/manifest.json',
'/icon-192.png',
'/icon-512.png',
// Add other static assets
]
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(STATIC_ASSETS)
})
)
})
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
// Network first for API calls
event.respondWith(networkFirstStrategy(event.request))
} else {
// Cache first for static assets
event.respondWith(cacheFirstStrategy(event.request))
}
})
async function cacheFirstStrategy(request) {
const cache = await caches.open(DYNAMIC_CACHE)
const cachedResponse = await cache.match(request)
if (cachedResponse) {
return cachedResponse
}
try {
const response = await fetch(request)
cache.put(request, response.clone())
return response
} catch (error) {
// Return offline fallback
return caches.match('/offline.html')
}
}
async function networkFirstStrategy(request) {
try {
const response = await fetch(request)
const cache = await caches.open(DYNAMIC_CACHE)
cache.put(request, response.clone())
return response
} catch (error) {
const cache = await caches.open(DYNAMIC_CACHE)
return cache.match(request)
}
}
App Installation
Install Prompt Component
// src/components/PWAInstallButton.tsx
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Download } from 'lucide-react'
export function PWAInstallButton() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
const [showInstall, setShowInstall] = useState(false)
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e)
setShowInstall(true)
}
window.addEventListener('beforeinstallprompt', handler)
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setShowInstall(false)
}
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
const handleInstall = async () => {
if (!deferredPrompt) return
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
setShowInstall(false)
}
setDeferredPrompt(null)
}
if (!showInstall) return null
return (
<Button onClick={handleInstall} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
Install App
</Button>
)
}
Web App Manifest
// public/manifest.json
{
"name": "WI-CARPOOL - Ride Sharing App",
"short_name": "WI-CARPOOL",
"description": "Modern ride-sharing application connecting passengers and drivers",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait-primary",
"scope": "/",
"lang": "en",
"icons": [
{
"src": "/icon-72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["travel", "transportation"],
"screenshots": [
{
"src": "/screenshot-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshot-mobile.png",
"sizes": "375x812",
"type": "image/png",
"form_factor": "narrow"
}
]
}
Push Notifications
Notification Permission
// src/utils/notifications.ts
export async function requestNotificationPermission(): Promise<boolean> {
if (!('Notification' in window)) {
console.log('This browser does not support notifications')
return false
}
if (Notification.permission === 'granted') {
return true
}
if (Notification.permission === 'denied') {
return false
}
const permission = await Notification.requestPermission()
return permission === 'granted'
}
export async function subscribeUserToPush(): Promise<PushSubscription | null> {
const registration = await navigator.serviceWorker.ready
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.VITE_VAPID_PUBLIC_KEY
})
// Send subscription to server
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
})
return subscription
} catch (error) {
console.error('Failed to subscribe to push notifications:', error)
return null
}
}
export function showLocalNotification(title: string, options: NotificationOptions = {}) {
if (Notification.permission === 'granted') {
new Notification(title, {
icon: '/icon-192.png',
badge: '/icon-72.png',
...options
})
}
}
Push Event Handler
// public/sw.js - Push notification handling
self.addEventListener('push', (event) => {
if (!event.data) return
const data = event.data.json()
const options = {
body: data.body,
icon: '/icon-192.png',
badge: '/icon-72.png',
tag: data.tag || 'default',
data: data.data || {},
actions: data.actions || [],
requireInteraction: data.requireInteraction || false
}
event.waitUntil(
self.registration.showNotification(data.title, options)
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const action = event.action
const data = event.notification.data
if (action === 'view_ride') {
event.waitUntil(
clients.openWindow(`/rides/${data.rideId}`)
)
} else if (action === 'open_chat') {
event.waitUntil(
clients.openWindow(`/chat/${data.chatId}`)
)
} else {
// Default action - open app
event.waitUntil(
clients.openWindow('/')
)
}
})
Offline Support
Offline Detection
// src/hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
// Usage in components
function NetworkStatus() {
const isOnline = useOnlineStatus()
if (!isOnline) {
return (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4">
<p className="text-yellow-700">
You're currently offline. Some features may be limited.
</p>
</div>
)
}
return null
}
Offline Data Sync
// src/utils/offlineSync.ts
interface OfflineAction {
id: string
type: string
data: any
timestamp: number
}
class OfflineSync {
private queue: OfflineAction[] = []
private isProcessing = false
constructor() {
this.loadQueue()
window.addEventListener('online', () => this.processQueue())
}
addAction(type: string, data: any) {
const action: OfflineAction = {
id: crypto.randomUUID(),
type,
data,
timestamp: Date.now()
}
this.queue.push(action)
this.saveQueue()
if (navigator.onLine) {
this.processQueue()
}
}
private async processQueue() {
if (this.isProcessing || this.queue.length === 0) return
this.isProcessing = true
while (this.queue.length > 0) {
const action = this.queue[0]
try {
await this.executeAction(action)
this.queue.shift()
this.saveQueue()
} catch (error) {
console.error('Failed to sync action:', action, error)
break
}
}
this.isProcessing = false
}
private async executeAction(action: OfflineAction) {
switch (action.type) {
case 'book_ride':
return fetch('/api/rides/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.data)
})
// Add other action types
default:
throw new Error(`Unknown action type: ${action.type}`)
}
}
private saveQueue() {
localStorage.setItem('offline-queue', JSON.stringify(this.queue))
}
private loadQueue() {
const stored = localStorage.getItem('offline-queue')
this.queue = stored ? JSON.parse(stored) : []
}
}
export const offlineSync = new OfflineSync()
Performance Optimizations
Resource Loading
- Critical resources are cached during service worker installation
- Non-critical resources are cached on first access
- Images are optimized and served in modern formats
- JavaScript bundles are split for efficient loading
Caching Strategies
- Cache First: Static assets (CSS, JS, images)
- Network First: API calls and dynamic content
- Stale While Revalidate: Frequently updated content
- Cache Only: Offline fallback pages
Best Practices
- Implement proper cache versioning
- Provide meaningful offline experiences
- Handle background sync for critical actions
- Test thoroughly on various devices and network conditions