feat(pwa): Add PWA support for installable kiosk app
- 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:
parent
b35c32279c
commit
f51a1072fe
6 changed files with 263 additions and 150 deletions
|
|
@ -1,12 +1,35 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>CA Grow Ops Manager</title>
|
<meta name="theme-color" content="#10b981" />
|
||||||
</head>
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<body>
|
<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>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
<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>
|
</html>
|
||||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
|
|
@ -1,21 +1,21 @@
|
||||||
{
|
{
|
||||||
"name": "777 Wolfpack - Grow Ops Manager",
|
"name": "777 Wolfpack Visitor Kiosk",
|
||||||
"short_name": "Wolfpack",
|
"short_name": "Visitor Kiosk",
|
||||||
"description": "Cannabis cultivation management system",
|
"description": "Digital visitor check-in kiosk for 777 Wolfpack facility",
|
||||||
"start_url": "/",
|
"start_url": "/kiosk",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
"background_color": "#0f172a",
|
"background_color": "#0f172a",
|
||||||
"theme_color": "#10b981",
|
"theme_color": "#10b981",
|
||||||
"orientation": "portrait-primary",
|
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icons/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512.png",
|
"src": "/icons/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|
@ -27,19 +27,26 @@
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
"name": "Tasks",
|
"name": "New Visitor",
|
||||||
"url": "/tasks",
|
"short_name": "New",
|
||||||
"description": "View and manage tasks"
|
"url": "/kiosk?mode=new",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Batches",
|
"name": "Returning Visitor",
|
||||||
"url": "/batches",
|
"short_name": "Return",
|
||||||
"description": "View all batches"
|
"url": "/kiosk?mode=returning",
|
||||||
},
|
"icons": [
|
||||||
{
|
{
|
||||||
"name": "Quick Log",
|
"src": "/icons/icon-192.png",
|
||||||
"url": "/touch-points",
|
"sizes": "192x192"
|
||||||
"description": "Log a touch point"
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,35 +1,27 @@
|
||||||
/// <reference lib="webworker" />
|
const CACHE_NAME = 'wolfpack-kiosk-v1';
|
||||||
|
|
||||||
const CACHE_NAME = 'wolfpack-v1';
|
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/kiosk',
|
||||||
'/index.html',
|
'/badges',
|
||||||
'/manifest.json'
|
'/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
|
// Install event - cache static assets
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => {
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
console.log('[SW] Caching static assets');
|
|
||||||
return cache.addAll(STATIC_ASSETS);
|
return cache.addAll(STATIC_ASSETS);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activate event - clean old caches
|
// Activate event - clean up old caches
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.keys().then((cacheNames) => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cacheNames
|
cacheNames
|
||||||
.filter((name) => name !== CACHE_NAME && name !== API_CACHE)
|
.filter((name) => name !== CACHE_NAME)
|
||||||
.map((name) => caches.delete(name))
|
.map((name) => caches.delete(name))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -37,118 +29,27 @@ self.addEventListener('activate', (event) => {
|
||||||
self.clients.claim();
|
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) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
// Skip non-GET requests
|
// Skip non-GET requests
|
||||||
if (request.method !== 'GET') return;
|
if (event.request.method !== 'GET') return;
|
||||||
|
|
||||||
// API requests - network first, fallback to cache
|
// Skip API requests (always fetch from network)
|
||||||
if (url.pathname.startsWith('/api')) {
|
if (event.request.url.includes('/api/')) return;
|
||||||
event.respondWith(networkFirstWithCache(request));
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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;
|
return response;
|
||||||
} catch (error) {
|
})
|
||||||
|
.catch(() => {
|
||||||
// Network failed, try cache
|
// Network failed, try cache
|
||||||
const cached = await caches.match(request);
|
return caches.match(event.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
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
|
|
||||||
if (event.notification.data) {
|
|
||||||
event.waitUntil(
|
|
||||||
self.clients.openWindow(event.notification.data)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export { };
|
|
||||||
|
|
|
||||||
182
specs/009_escort_handoff_mode.md
Normal file
182
specs/009_escort_handoff_mode.md
Normal 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 |
|
||||||
Loading…
Add table
Reference in a new issue