feat(pwa): Add PWA support for installable kiosk app
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s

- Added manifest.json for 'Add to Home Screen' installation
- Added service worker for offline caching
- Added app icons (192px, 512px)
- Updated index.html with mobile meta tags
- Created spec for Escort Handoff Mode (009)
This commit is contained in:
fullsizemalt 2025-12-11 16:13:50 -08:00
parent b35c32279c
commit f51a1072fe
6 changed files with 263 additions and 150 deletions

View file

@ -1,12 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CA Grow Ops Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#10b981" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Visitor Kiosk" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>777 Wolfpack - Visitor Kiosk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

View file

@ -1,21 +1,21 @@
{
"name": "777 Wolfpack - Grow Ops Manager",
"short_name": "Wolfpack",
"description": "Cannabis cultivation management system",
"start_url": "/",
"name": "777 Wolfpack Visitor Kiosk",
"short_name": "Visitor Kiosk",
"description": "Digital visitor check-in kiosk for 777 Wolfpack facility",
"start_url": "/kiosk",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0f172a",
"theme_color": "#10b981",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192.png",
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
@ -27,19 +27,26 @@
],
"shortcuts": [
{
"name": "Tasks",
"url": "/tasks",
"description": "View and manage tasks"
"name": "New Visitor",
"short_name": "New",
"url": "/kiosk?mode=new",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
},
{
"name": "Batches",
"url": "/batches",
"description": "View all batches"
},
{
"name": "Quick Log",
"url": "/touch-points",
"description": "Log a touch point"
"name": "Returning Visitor",
"short_name": "Return",
"url": "/kiosk?mode=returning",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
}
]
}

View file

@ -1,35 +1,27 @@
/// <reference lib="webworker" />
const CACHE_NAME = 'wolfpack-v1';
const CACHE_NAME = 'wolfpack-kiosk-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/kiosk',
'/badges',
'/manifest.json'
];
const API_CACHE = 'wolfpack-api-v1';
const API_CACHE_MAX_AGE = 5 * 60 * 1000; // 5 minutes
declare const self: ServiceWorkerGlobalScope;
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Activate event - clean old caches
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME && name !== API_CACHE)
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
@ -37,118 +29,27 @@ self.addEventListener('activate', (event) => {
self.clients.claim();
});
// Fetch event - network-first for API, cache-first for assets
// Fetch event - network first, falling back to cache
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
if (event.request.method !== 'GET') return;
// API requests - network first, fallback to cache
if (url.pathname.startsWith('/api')) {
event.respondWith(networkFirstWithCache(request));
return;
}
// Skip API requests (always fetch from network)
if (event.request.url.includes('/api/')) return;
// Static assets - cache first, fallback to network
event.respondWith(cacheFirstWithNetwork(request));
});
async function networkFirstWithCache(request: Request): Promise<Response> {
try {
const response = await fetch(request);
// Cache successful GET responses
if (response.ok) {
const cache = await caches.open(API_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed, try cache
const cached = await caches.match(request);
if (cached) {
console.log('[SW] Serving from cache:', request.url);
return cached;
}
// Return offline response for API
return new Response(
JSON.stringify({ error: 'You are offline', offline: true }),
{
status: 503,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
async function cacheFirstWithNetwork(request: Request): Promise<Response> {
const cached = await caches.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
// Cache successful responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Return offline page for navigation requests
if (request.mode === 'navigate') {
const offlinePage = await caches.match('/');
if (offlinePage) return offlinePage;
}
throw error;
}
}
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-touchpoints') {
event.waitUntil(syncTouchpoints());
}
});
async function syncTouchpoints() {
// This would sync any queued offline actions
console.log('[SW] Syncing offline actions...');
}
// Push notifications
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || '777 Wolfpack', {
body: data.body,
icon: '/icon-192.png',
badge: '/badge-72.png',
tag: data.tag || 'notification',
data: data.url
})
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone and cache the response
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request);
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.notification.data) {
event.waitUntil(
self.clients.openWindow(event.notification.data)
);
}
});
export { };

View file

@ -0,0 +1,182 @@
# Visitor Kiosk - Escort Handoff Mode Specification
## Overview
This document specifies the "Escort Handoff Mode" for the mobile visitor kiosk. In this mode, a staff member hands their tablet/phone to the visitor for registration, but the digital badge remains on the staff device rather than transferring to the visitor's personal phone.
## Use Case
In controlled facilities, staff may prefer to:
1. Hand their mobile device to a visitor for self-registration
2. Keep the visitor badge displayed on the staff device
3. Maintain "escort mode" where staff carries the badge throughout the visit
## User Flow
### 1. Staff Initiates Handoff
```
Staff Device (Kiosk Mode)
├── Staff taps "Register Visitor"
├── Device locks into "Handoff Mode"
│ └── Shows: "Please hand device to visitor"
└── Optional: Staff enters their ID as escort
```
### 2. Visitor Self-Registration (On Staff Device)
```
Visitor receives device
├── Fills in:
│ ├── Name (required)
│ ├── Company
│ ├── Purpose (required)
│ └── Type (Visitor, Contractor, etc.)
├── Takes selfie using front camera
├── Accepts NDA (checkbox)
└── Taps "Complete Registration"
```
### 3. Badge Display (Stays on Staff Device)
```
After successful check-in:
├── Badge displays on staff's device
├── Shows: "Return device to staff" overlay
├── QR code for security scan
├── Staff name shown as "Escort"
└── NO auto-redirect to visitor's phone
```
### Key Differences from Standard Mode
| Feature | Standard Kiosk | Escort Handoff |
|---------|---------------|----------------|
| Badge Location | Visitor's phone (via QR scan) | Staff's device |
| Escort Assignment | Optional | Automatic (logged-in staff) |
| Device Return | Not applicable | Required step |
| Badge Portability | On visitor's device | Carried by escort |
## Technical Implementation
### 1. Mode Detection
```typescript
type KioskMode =
| 'standard' // Fixed tablet kiosk
| 'escort-handoff' // Mobile escort mode
| 'self-service'; // Visitor's own device
interface HandoffSession {
escortId: string; // Staff user ID
escortName: string; // For display
startedAt: Date;
deviceId?: string; // For audit
}
```
### 2. API Changes
```typescript
// POST /api/visitors/:id/check-in
{
escortId: string; // Required in handoff mode
handoffMode: boolean; // Flag for escort mode
ndaAccepted: boolean;
photoUrl?: string;
}
// Response includes escortRequired: true
```
### 3. Badge Page Updates
When in escort-handoff mode, the badge page should:
- Display "Escorted by: [Staff Name]"
- Show a prominent "ESCORT REQUIRED" badge
- Add "Return Device to Staff" button after registration
- Disable "Send to My Phone" option
### 4. Staff Authentication
For staff to initiate handoff mode, they should:
- Be logged into the app on their device
- Have appropriate permissions (e.g., `can_escort_visitors`)
- Their user ID auto-populates as escort
## UI Mockup Concepts
### Handoff Initiation Screen (Staff View)
```
┌─────────────────────────────────────┐
│ 777 Wolfpack Visitor Kiosk │
│ │
│ ┌─────────────────────────────┐ │
│ │ 📱 Escort Mode Active │ │
│ │ │ │
│ │ Hand this device to the │ │
│ │ visitor for registration │ │
│ │ │ │
│ │ Escort: John Smith │ │
│ │ Badge will stay on device │ │
│ │ │ │
│ │ [Cancel Handoff] │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
### Post-Registration Screen (Visitor View on Staff Device)
```
┌─────────────────────────────────────┐
│ ✓ Registration Complete │
│ │
│ [Visitor Photo] │
│ Jane Doe │
│ ACME Corp │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🔒 ESCORT REQUIRED │ │
│ │ Badge: V-20251211-042 │ │
│ │ Escort: John Smith │ │
│ └─────────────────────────────┘ │
│ │
│ Please return this device to │
│ your escort to complete check-in │
│ │
│ [ Return to Escort View ] │
│ │
└─────────────────────────────────────┘
```
## Security Considerations
1. **Session Timeout**: Handoff mode should timeout after 5 minutes of inactivity
2. **Escort Validation**: Staff must be logged in with valid credentials
3. **Audit Trail**: Log device handoffs for compliance
4. **Data Isolation**: Visitor cannot access staff's other data on device
5. **Photo Storage**: Photos stored securely, not accessible via device gallery
## Future Enhancements
1. **Geofencing**: Alert if escorted visitor moves outside allowed zones
2. **NFC Badge Tap**: Staff can tap NFC to transfer badge to facility kiosk for checkout
3. **Multi-Visitor Escort**: Staff manages multiple visitors on one device
4. **Offline Mode**: Queue registrations when network is unavailable
## Implementation Priority
| Phase | Feature | Effort |
|-------|---------|--------|
| P1 | Basic handoff mode toggle | Low |
| P1 | Escort auto-assignment | Low |
| P1 | Badge stays on device | Medium |
| P2 | "Return device" confirmation | Low |
| P2 | Session timeout | Low |
| P3 | Staff authentication | Medium |
| P3 | Geofencing | High |