AI Prompt: Forma3D.Connect — Phase 7: Progressive Web App (PWA)¶
Purpose: This prompt instructs an AI to implement Phase 7 of Forma3D.Connect
Estimated Effort: 22 hours (~1 week)
Prerequisites: Phase 6 completed (Production Hardening complete)
Output: Installable PWA with push notifications, offline support, and native-like experience
Status: ✅ COMPLETE
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 6 production-ready foundation. Your task is to implement Phase 7: Progressive Web App (PWA) — transforming the existing React dashboard into an installable, cross-platform application with push notifications and offline capabilities.
Phase 7 delivers:
- Installable PWA experience on desktop and mobile browsers
- Web Push Notifications for real-time alerts (order, print job, shipment, system events)
- Offline support with cached order data viewing
- Native-like UX with app badges, splash screens, and mobile-optimized interactions
- Elimination of need for separate Tauri (desktop) and Capacitor (mobile) apps
Phase 7 provides cross-platform access:
PWA Foundation → Push Notifications → Offline Support → Native UX → Cross-Platform ✅
📋 Phase 7 Context¶
What Was Built in Previous Phases¶
The complete automation system is already in place and production-ready:
- Phase 0: Foundation ✅
- Nx monorepo with
apps/api,apps/web, and shared libs - PostgreSQL database with Prisma schema
- NestJS backend structure with modules, services, repositories
-
Azure DevOps CI/CD pipeline
-
Phase 1: Shopify Inbound ✅
- Shopify webhooks receiver with HMAC verification
- Order storage and status management
- Product mapping CRUD operations
- Event logging service
-
OpenAPI/Swagger documentation at
/api/docs -
Phase 1b: Observability ✅
- Sentry error tracking and performance monitoring
- OpenTelemetry-first architecture
- Structured JSON logging with Pino and correlation IDs
- React error boundaries
-
BusinessObservabilityService for state transition and flow tracking
-
Phase 1c: Staging Deployment ✅
- Docker images with multi-stage builds
- Traefik reverse proxy with Let's Encrypt TLS
- Zero-downtime deployments via Docker Compose
-
Staging environment:
https://staging-connect.forma3d.be -
Phase 1d: Acceptance Testing ✅
- Playwright + Gherkin acceptance tests
- Given/When/Then scenarios for deployment verification
-
Azure DevOps pipeline integration
-
Phase 2: SimplyPrint Core ✅
- SimplyPrint API client with HTTP Basic Auth
- Automated print job creation from orders
- Print job status monitoring (webhook + polling)
-
Order-job orchestration with
order.ready-for-fulfillmentevent -
Phase 3: Fulfillment Loop ✅
- Automated Shopify fulfillment creation
- Order cancellation handling
- Retry queue with exponential backoff
- Email notifications for critical failures
-
API key authentication for admin endpoints
-
Phase 4: Dashboard MVP ✅
- React 19 dashboard with TanStack Query
- Order management UI (list, detail, actions)
- Product mapping configuration UI
- Real-time updates via Socket.IO
-
Activity logs with filtering and export
-
Phase 5: Shipping Integration ✅
- Sendcloud API client for shipping labels
- Automated label generation on order completion
- Tracking sync to Shopify fulfillments
-
Shipping management UI in dashboard
-
Phase 5b-5k: Tech Debt Resolution ✅
- Domain boundaries and contracts
- Webhook idempotency
- Frontend tests (200+ tests)
- Typed JSON schemas, API types, error hierarchy
-
Configuration externalization
-
Phase 6: Hardening ✅
- Comprehensive test suite with 80%+ coverage
- Load testing infrastructure (K6)
- Health indicators for external services
- Security hardening (rate limiting, headers)
- Complete documentation and runbooks
What Phase 7 Builds¶
| Feature | Description | Effort |
|---|---|---|
| F7.1: PWA Foundation | Vite PWA plugin, manifest, service worker, icons | 4 hours |
| F7.2: Push Notifications | Web Push API, VAPID keys, notification service | 8 hours |
| F7.3: Offline Support | Workbox caching, IndexedDB, offline indicator | 6 hours |
| F7.4: App-like Experience | Splash screen, badges, pull-to-refresh, install prompt | 4 hours |
🛠️ Tech Stack Reference¶
All technologies from previous phases remain. Additional packages for Phase 7:
| Package | Purpose |
|---|---|
vite-plugin-pwa |
PWA support for Vite (manifest, service worker) |
workbox-* |
Service worker caching strategies |
web-push |
Server-side Web Push API (Node.js) |
idb |
IndexedDB wrapper for offline storage |
@vite-pwa/assets-generator |
PWA icon generation |
🏗️ Architecture Reference¶
Detailed Architecture Diagrams¶
📐 For detailed architecture, refer to the existing PlantUML diagrams:
Diagram Path Description Context View docs/03-architecture/c4-model/1-context/C4_Context.pumlSystem context diagram Container View docs/03-architecture/c4-model/2-container/C4_Container.pumlSystem containers and interactions Component View docs/03-architecture/c4-model/3-component/C4_Component.pumlBackend component architecture PWA Feasibility docs/03-architecture/research/PWA-feasibility-study.mdPWA research and recommendations These PlantUML diagrams should be validated and updated as part of Phase 7 to reflect the PWA architecture.
PWA Architecture Overview¶
┌──────────────────────────────────────────────────────────────────┐
│ PHASE 7: PWA ARCHITECTURE │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ FRONTEND (React PWA) ││
│ │ ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││
│ │ │ Manifest │ │ Service │ │ IndexedDB │ ││
│ │ │ manifest.json│ │ Worker │ │ Offline Data │ ││
│ │ └──────────────┘ └──────────────┘ └──────────────┘ ││
│ │ ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││
│ │ │ Push Manager │ │ Install │ │ Badge API │ ││
│ │ │ Subscription │ │ Prompt │ │ Notifications│ ││
│ │ └──────────────┘ └──────────────┘ └──────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ BACKEND (NestJS API) ││
│ │ ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││
│ │ │ Push │ │ Subscription │ │ Notification │ ││
│ │ │ Service │ │ Repository │ │ Triggers │ ││
│ │ │ (web-push) │ │ (Prisma) │ │ (Events) │ ││
│ │ └──────────────┘ └──────────────┘ └──────────────┘ ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
└──────────────────────────────────────────────────────────────────┘
Push Notification Flow¶
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Event │────▶│ Notification│────▶│ Push │────▶│ Browser │
│ Trigger │ │ Service │ │ Service │ │ Push API │
│ │ │ │ │ (VAPID) │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ User │
│ Device │
│ (PWA) │
└─────────────┘
📁 Files to Create/Modify¶
Frontend PWA Infrastructure¶
apps/web/
├── public/
│ ├── manifest.json # NEW: Web app manifest
│ ├── icons/
│ │ ├── icon-192.png # NEW: PWA icon (192x192)
│ │ ├── icon-512.png # NEW: PWA icon (512x512)
│ │ ├── icon-maskable-192.png # NEW: Maskable icon
│ │ ├── icon-maskable-512.png # NEW: Maskable icon
│ │ └── apple-touch-icon.png # NEW: iOS icon (180x180)
│ └── splash/
│ └── (splash screen images) # NEW: iOS splash screens
│
├── src/
│ ├── pwa/
│ │ ├── install-prompt.tsx # NEW: Install prompt component
│ │ ├── offline-indicator.tsx # NEW: Offline status indicator
│ │ ├── push-permission.tsx # NEW: Push notification permission flow
│ │ ├── sw-update-prompt.tsx # NEW: Service worker update prompt
│ │ └── index.ts # NEW: PWA exports
│ │
│ ├── hooks/
│ │ ├── use-pwa-install.ts # NEW: PWA install hook
│ │ ├── use-push-notifications.ts # NEW: Push subscription hook
│ │ ├── use-online-status.ts # NEW: Online/offline detection
│ │ └── use-offline-data.ts # NEW: IndexedDB data access
│ │
│ ├── lib/
│ │ ├── indexed-db.ts # NEW: IndexedDB wrapper
│ │ └── push-manager.ts # NEW: Push subscription manager
│ │
│ ├── pages/settings/
│ │ └── notifications.tsx # NEW: Notification settings page
│ │
│ └── contexts/
│ └── pwa-context.tsx # NEW: PWA state context
│
├── vite.config.ts # UPDATE: Add vite-plugin-pwa
└── index.html # UPDATE: Add manifest link, theme-color
Backend Push Notification Infrastructure¶
apps/api/src/
├── push-notifications/
│ ├── __tests__/
│ │ ├── push.controller.spec.ts # NEW: Controller tests
│ │ ├── push.service.spec.ts # NEW: Service tests
│ │ └── push.repository.spec.ts # NEW: Repository tests
│ │
│ ├── dto/
│ │ ├── push-subscription.dto.ts # NEW: Subscription DTO
│ │ └── send-notification.dto.ts # NEW: Send notification DTO
│ │
│ ├── events/
│ │ └── push.events.ts # NEW: Push notification events
│ │
│ ├── push.controller.ts # NEW: Push API endpoints
│ ├── push.module.ts # NEW: Push module
│ ├── push.repository.ts # NEW: Subscription persistence
│ └── push.service.ts # NEW: Web Push integration
│
├── notifications/
│ └── notifications.service.ts # UPDATE: Integrate push notifications
│
└── config/
└── configuration.ts # UPDATE: Add VAPID key config
Database Schema¶
prisma/
└── schema.prisma # UPDATE: Add PushSubscription model
Testing Infrastructure¶
apps/acceptance-tests/src/features/
├── pwa-install.feature # NEW: PWA installation tests
├── push-notifications.feature # NEW: Push notification tests
└── offline-mode.feature # NEW: Offline functionality tests
apps/web/src/
├── pwa/__tests__/
│ ├── install-prompt.test.tsx # NEW: Install prompt tests
│ ├── offline-indicator.test.tsx # NEW: Offline indicator tests
│ └── push-permission.test.tsx # NEW: Push permission tests
│
├── hooks/__tests__/
│ ├── use-pwa-install.test.ts # NEW: Install hook tests
│ ├── use-push-notifications.test.ts # NEW: Push hook tests
│ └── use-online-status.test.ts # NEW: Online status tests
load-tests/k6/scenarios/
└── pwa-endpoints.js # NEW: Push subscription load tests
🔧 Feature F7.1: PWA Foundation¶
Requirements Reference¶
- ADR-035: Progressive Web App for Cross-Platform Access
- PWA Feasibility Study recommendations
Implementation¶
1. Install and Configure Vite PWA Plugin¶
Install dependencies:
pnpm add -D vite-plugin-pwa workbox-window
Update apps/web/vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt', // Prompt user before updating
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'logo.svg'],
manifest: {
name: 'Forma3D.Connect',
short_name: 'Forma3D',
description: '3D Print Order Management Dashboard',
theme_color: '#0066cc',
background_color: '#ffffff',
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
categories: ['business', 'productivity'],
},
workbox: {
// Cache static assets
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
// Runtime caching for API calls
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\/v1\/orders/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-orders',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 5, // 5 minutes
},
},
},
{
urlPattern: /^https:\/\/.*\/health/,
handler: 'NetworkOnly',
},
],
},
devOptions: {
enabled: true, // Enable PWA in development for testing
},
}),
],
});
2. Create Web App Manifest¶
Create apps/web/public/manifest.json:
{
"name": "Forma3D.Connect",
"short_name": "Forma3D",
"description": "3D Print Order Management Dashboard",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0066cc",
"orientation": "any",
"scope": "/",
"lang": "en",
"categories": ["business", "productivity"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "Dashboard Overview"
},
{
"src": "/screenshots/orders-mobile.png",
"sizes": "390x844",
"type": "image/png",
"form_factor": "narrow",
"label": "Orders on Mobile"
}
]
}
3. Update index.html¶
Update apps/web/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#0066cc" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Forma3D" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Forma3D.Connect</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
4. Create Install Prompt Component¶
Create apps/web/src/pwa/install-prompt.tsx:
import { useState, useEffect } from 'react';
import { Button } from '../components/ui/button';
import { usePWAInstall } from '../hooks/use-pwa-install';
export function InstallPrompt() {
const { canInstall, promptInstall, isInstalled, dismissPrompt } = usePWAInstall();
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
// Show prompt after 30 seconds if app can be installed
const timer = setTimeout(() => {
if (canInstall && !isInstalled) {
setShowPrompt(true);
}
}, 30000);
return () => clearTimeout(timer);
}, [canInstall, isInstalled]);
if (!showPrompt || isInstalled) {
return null;
}
const handleInstall = async () => {
const installed = await promptInstall();
if (installed) {
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
dismissPrompt();
};
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50">
<div className="flex items-start gap-3">
<img
src="/icons/icon-192.png"
alt="Forma3D"
className="w-12 h-12 rounded-lg"
/>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-white">
Install Forma3D.Connect
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Install for quick access and offline support
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button variant="outline" onClick={handleDismiss} className="flex-1">
Not now
</Button>
<Button onClick={handleInstall} className="flex-1">
Install
</Button>
</div>
</div>
);
}
5. Create PWA Install Hook¶
Create apps/web/src/hooks/use-pwa-install.ts:
import { useState, useEffect, useCallback } from 'react';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
prompt(): Promise<void>;
}
interface UsePWAInstallReturn {
canInstall: boolean;
isInstalled: boolean;
promptInstall: () => Promise<boolean>;
dismissPrompt: () => void;
}
export function usePWAInstall(): UsePWAInstallReturn {
const [installPromptEvent, setInstallPromptEvent] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isIOSStandalone = (window.navigator as { standalone?: boolean }).standalone === true;
setIsInstalled(isStandalone || isIOSStandalone);
// Listen for install prompt
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setInstallPromptEvent(e as BeforeInstallPromptEvent);
};
// Listen for successful install
const handleAppInstalled = () => {
setIsInstalled(true);
setInstallPromptEvent(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const promptInstall = useCallback(async (): Promise<boolean> => {
if (!installPromptEvent) {
return false;
}
await installPromptEvent.prompt();
const choice = await installPromptEvent.userChoice;
if (choice.outcome === 'accepted') {
setIsInstalled(true);
setInstallPromptEvent(null);
return true;
}
return false;
}, [installPromptEvent]);
const dismissPrompt = useCallback(() => {
// Store dismissal in localStorage to not show again for a week
localStorage.setItem('pwa-install-dismissed', Date.now().toString());
}, []);
const canInstall = installPromptEvent !== null && !isInstalled;
return {
canInstall,
isInstalled,
promptInstall,
dismissPrompt,
};
}
6. Service Worker Update Prompt¶
Create apps/web/src/pwa/sw-update-prompt.tsx:
import { useRegisterSW } from 'virtual:pwa-register/react';
import { Button } from '../components/ui/button';
export function ServiceWorkerUpdatePrompt() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(registration) {
console.log('Service worker registered:', registration);
},
onRegisterError(error) {
console.error('Service worker registration failed:', error);
},
});
if (!needRefresh) {
return null;
}
const handleUpdate = () => {
updateServiceWorker(true);
};
const handleDismiss = () => {
setNeedRefresh(false);
};
return (
<div className="fixed top-4 right-4 bg-blue-600 text-white rounded-lg shadow-lg p-4 z-50 max-w-sm">
<p className="font-medium">New version available!</p>
<p className="text-sm mt-1 text-blue-100">
Click update to get the latest features.
</p>
<div className="flex gap-2 mt-3">
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="text-white hover:bg-blue-700"
>
Later
</Button>
<Button
size="sm"
onClick={handleUpdate}
className="bg-white text-blue-600 hover:bg-blue-50"
>
Update now
</Button>
</div>
</div>
);
}
🔧 Feature F7.2: Push Notifications¶
Requirements Reference¶
- Real-time alerts for print job status changes
- Order state change notifications
- Shipment tracking updates
- System error alerts
Push Notification Events¶
| Event Category | Event | Notification Title | Notification Body |
|---|---|---|---|
| Order | order.created |
"New Order Received" | "Order #{orderNumber} from {customerName}" |
| Order | order.cancelled |
"Order Cancelled" | "Order #{orderNumber} has been cancelled" |
| Order | order.ready_for_fulfillment |
"Order Ready to Ship" | "Order #{orderNumber} - all print jobs complete" |
| Order | order.fulfilled |
"Order Fulfilled" | "Order #{orderNumber} shipped successfully" |
| Print Job | printjob.started |
"Print Job Started" | "Order #{orderNumber}: Printing started" |
| Print Job | printjob.completed |
"Print Job Complete" | "Order #{orderNumber}: Print job finished" |
| Print Job | printjob.failed |
"Print Job Failed ⚠️" | "Order #{orderNumber}: Print failed - action required" |
| Shipment | shipment.label_created |
"Shipping Label Ready" | "Order #{orderNumber}: Label generated" |
| Shipment | shipment.shipped |
"Order Shipped" | "Order #{orderNumber} is on its way" |
| Shipment | shipment.delivered |
"Order Delivered" | "Order #{orderNumber} delivered successfully" |
| System | system.error |
"System Alert ⚠️" | "{errorSummary}" |
| System | system.retry_failed |
"Retry Failed" | "Action failed after max retries - manual intervention needed" |
Implementation¶
1. Database Schema for Push Subscriptions¶
Update prisma/schema.prisma:
model PushSubscription {
id String @id @default(uuid())
endpoint String @unique
expirationTime DateTime?
p256dh String // Public key for encryption
auth String // Auth secret
userAgent String? // Browser/device info
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Notification preferences
orderNotifications Boolean @default(true)
printJobNotifications Boolean @default(true)
shipmentNotifications Boolean @default(true)
systemNotifications Boolean @default(true)
@@index([endpoint])
}
Run migration:
pnpm prisma migrate dev --name add_push_subscriptions
2. Push Notification Service (Backend)¶
Install web-push:
pnpm add web-push
pnpm add -D @types/web-push
Create apps/api/src/push-notifications/push.service.ts:
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as webPush from 'web-push';
import { PushRepository } from './push.repository';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../orders/events/order.events';
import { PrintJobStatusChangedEvent } from '../print-jobs/events/print-job.events';
import { ShipmentCreatedEvent, ShipmentStatusChangedEvent } from '../sendcloud/events/shipment.events';
export interface PushPayload {
title: string;
body: string;
icon?: string;
badge?: string;
tag?: string;
data?: Record<string, unknown>;
actions?: Array<{ action: string; title: string }>;
}
@Injectable()
export class PushService implements OnModuleInit {
private readonly logger = new Logger(PushService.name);
private vapidConfigured = false;
constructor(
private readonly configService: ConfigService,
private readonly pushRepository: PushRepository,
) {}
onModuleInit() {
const publicKey = this.configService.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.configService.get<string>('VAPID_PRIVATE_KEY');
const subject = this.configService.get<string>('VAPID_SUBJECT') || 'mailto:admin@forma3d.be';
if (publicKey && privateKey) {
webPush.setVapidDetails(subject, publicKey, privateKey);
this.vapidConfigured = true;
this.logger.log('VAPID keys configured for Web Push');
} else {
this.logger.warn('VAPID keys not configured - push notifications disabled');
}
}
/**
* Get VAPID public key for client subscription
*/
getPublicKey(): string | null {
return this.configService.get<string>('VAPID_PUBLIC_KEY') || null;
}
/**
* Subscribe a client to push notifications
*/
async subscribe(subscription: {
endpoint: string;
expirationTime?: number | null;
keys: { p256dh: string; auth: string };
userAgent?: string;
}): Promise<{ id: string }> {
const saved = await this.pushRepository.upsert({
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime
? new Date(subscription.expirationTime)
: null,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
userAgent: subscription.userAgent,
});
this.logger.log(`Push subscription saved: ${saved.id}`);
return { id: saved.id };
}
/**
* Unsubscribe a client
*/
async unsubscribe(endpoint: string): Promise<void> {
await this.pushRepository.deleteByEndpoint(endpoint);
this.logger.log(`Push subscription removed: ${endpoint}`);
}
/**
* Update notification preferences
*/
async updatePreferences(
endpoint: string,
preferences: {
orderNotifications?: boolean;
printJobNotifications?: boolean;
shipmentNotifications?: boolean;
systemNotifications?: boolean;
},
): Promise<void> {
await this.pushRepository.updatePreferences(endpoint, preferences);
}
/**
* Send push notification to all subscribed clients
*/
async sendToAll(
payload: PushPayload,
filter?: {
orderNotifications?: boolean;
printJobNotifications?: boolean;
shipmentNotifications?: boolean;
systemNotifications?: boolean;
},
): Promise<{ sent: number; failed: number }> {
if (!this.vapidConfigured) {
this.logger.warn('Push notifications disabled - VAPID not configured');
return { sent: 0, failed: 0 };
}
const subscriptions = await this.pushRepository.findAll(filter);
let sent = 0;
let failed = 0;
const notification = JSON.stringify({
...payload,
icon: payload.icon || '/icons/icon-192.png',
badge: payload.badge || '/icons/badge-72.png',
});
for (const sub of subscriptions) {
try {
await webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: { p256dh: sub.p256dh, auth: sub.auth },
},
notification,
);
sent++;
} catch (error) {
failed++;
// Remove invalid subscriptions
if ((error as { statusCode?: number }).statusCode === 410) {
await this.pushRepository.deleteByEndpoint(sub.endpoint);
this.logger.log(`Removed expired subscription: ${sub.endpoint}`);
} else {
this.logger.error(`Push notification failed: ${error}`);
}
}
}
this.logger.log(`Push notifications sent: ${sent}, failed: ${failed}`);
return { sent, failed };
}
// ─────────────────────────────────────────────────────────────
// Event Handlers for Order Notifications
// ─────────────────────────────────────────────────────────────
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.sendToAll(
{
title: 'New Order Received',
body: `Order #${event.shopifyOrderNumber} from ${event.customerName}`,
tag: `order-${event.orderId}`,
data: {
type: 'order.created',
orderId: event.orderId,
url: `/orders/${event.orderId}`,
},
actions: [
{ action: 'view', title: 'View Order' },
],
},
{ orderNotifications: true },
);
}
@OnEvent('order.status-changed')
async handleOrderStatusChanged(event: OrderStatusChangedEvent): Promise<void> {
const notifications: Record<string, { title: string; body: string }> = {
CANCELLED: {
title: 'Order Cancelled',
body: `Order #${event.shopifyOrderNumber} has been cancelled`,
},
COMPLETED: {
title: 'Order Ready to Ship',
body: `Order #${event.shopifyOrderNumber} - all print jobs complete`,
},
FULFILLED: {
title: 'Order Fulfilled',
body: `Order #${event.shopifyOrderNumber} shipped successfully`,
},
};
const notification = notifications[event.newStatus];
if (notification) {
await this.sendToAll(
{
...notification,
tag: `order-${event.orderId}`,
data: {
type: 'order.status-changed',
orderId: event.orderId,
status: event.newStatus,
url: `/orders/${event.orderId}`,
},
},
{ orderNotifications: true },
);
}
}
// ─────────────────────────────────────────────────────────────
// Event Handlers for Print Job Notifications
// ─────────────────────────────────────────────────────────────
@OnEvent('printjob.status-changed')
async handlePrintJobStatusChanged(event: PrintJobStatusChangedEvent): Promise<void> {
const notifications: Record<string, { title: string; body: string; urgent?: boolean }> = {
PRINTING: {
title: 'Print Job Started',
body: `Order #${event.shopifyOrderNumber}: Printing started`,
},
COMPLETED: {
title: 'Print Job Complete',
body: `Order #${event.shopifyOrderNumber}: Print job finished`,
},
FAILED: {
title: 'Print Job Failed ⚠️',
body: `Order #${event.shopifyOrderNumber}: Print failed - action required`,
urgent: true,
},
};
const notification = notifications[event.newStatus];
if (notification) {
await this.sendToAll(
{
title: notification.title,
body: notification.body,
tag: `printjob-${event.printJobId}`,
data: {
type: 'printjob.status-changed',
printJobId: event.printJobId,
orderId: event.orderId,
status: event.newStatus,
url: `/orders/${event.orderId}`,
},
},
{ printJobNotifications: true },
);
}
}
// ─────────────────────────────────────────────────────────────
// Event Handlers for Shipment Notifications
// ─────────────────────────────────────────────────────────────
@OnEvent('shipment.created')
async handleShipmentCreated(event: ShipmentCreatedEvent): Promise<void> {
await this.sendToAll(
{
title: 'Shipping Label Ready',
body: `Order #${event.shopifyOrderNumber}: Label generated`,
tag: `shipment-${event.shipmentId}`,
data: {
type: 'shipment.created',
shipmentId: event.shipmentId,
orderId: event.orderId,
url: `/orders/${event.orderId}`,
},
},
{ shipmentNotifications: true },
);
}
@OnEvent('shipment.status-changed')
async handleShipmentStatusChanged(event: ShipmentStatusChangedEvent): Promise<void> {
const notifications: Record<string, { title: string; body: string }> = {
SHIPPED: {
title: 'Order Shipped',
body: `Order #${event.shopifyOrderNumber} is on its way`,
},
DELIVERED: {
title: 'Order Delivered',
body: `Order #${event.shopifyOrderNumber} delivered successfully`,
},
};
const notification = notifications[event.newStatus];
if (notification) {
await this.sendToAll(
{
...notification,
tag: `shipment-${event.shipmentId}`,
data: {
type: 'shipment.status-changed',
shipmentId: event.shipmentId,
orderId: event.orderId,
status: event.newStatus,
url: `/orders/${event.orderId}`,
},
},
{ shipmentNotifications: true },
);
}
}
// ─────────────────────────────────────────────────────────────
// System Alert Handler
// ─────────────────────────────────────────────────────────────
async sendSystemAlert(title: string, body: string, data?: Record<string, unknown>): Promise<void> {
await this.sendToAll(
{
title: `System Alert: ${title}`,
body,
tag: 'system-alert',
data: {
type: 'system.alert',
...data,
},
},
{ systemNotifications: true },
);
}
}
3. Push Controller¶
Create apps/api/src/push-notifications/push.controller.ts:
import { Controller, Post, Delete, Get, Body, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { PushService } from './push.service';
import { PushSubscriptionDto, UpdatePreferencesDto } from './dto/push-subscription.dto';
@ApiTags('Push Notifications')
@Controller('api/v1/push')
export class PushController {
constructor(private readonly pushService: PushService) {}
@Get('vapid-public-key')
@ApiOperation({ summary: 'Get VAPID public key for push subscription' })
@ApiResponse({ status: 200, description: 'VAPID public key' })
getVapidPublicKey(): { publicKey: string | null } {
return { publicKey: this.pushService.getPublicKey() };
}
@Post('subscribe')
@ApiOperation({ summary: 'Subscribe to push notifications' })
@ApiResponse({ status: 201, description: 'Subscription created' })
async subscribe(@Body() subscription: PushSubscriptionDto): Promise<{ id: string }> {
return this.pushService.subscribe(subscription);
}
@Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Unsubscribe from push notifications' })
@ApiResponse({ status: 204, description: 'Subscription removed' })
async unsubscribe(@Query('endpoint') endpoint: string): Promise<void> {
await this.pushService.unsubscribe(endpoint);
}
@Post('preferences')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Update notification preferences' })
@ApiResponse({ status: 204, description: 'Preferences updated' })
async updatePreferences(
@Query('endpoint') endpoint: string,
@Body() preferences: UpdatePreferencesDto,
): Promise<void> {
await this.pushService.updatePreferences(endpoint, preferences);
}
}
4. Push Subscription DTO¶
Create apps/api/src/push-notifications/dto/push-subscription.dto.ts:
import { IsString, IsOptional, IsNumber, IsBoolean, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class PushKeysDto {
@ApiProperty({ description: 'P256DH public key' })
@IsString()
p256dh: string;
@ApiProperty({ description: 'Auth secret' })
@IsString()
auth: string;
}
export class PushSubscriptionDto {
@ApiProperty({ description: 'Push service endpoint URL' })
@IsString()
endpoint: string;
@ApiPropertyOptional({ description: 'Subscription expiration time' })
@IsOptional()
@IsNumber()
expirationTime?: number | null;
@ApiProperty({ description: 'Encryption keys' })
@ValidateNested()
@Type(() => PushKeysDto)
keys: PushKeysDto;
@ApiPropertyOptional({ description: 'User agent string' })
@IsOptional()
@IsString()
userAgent?: string;
}
export class UpdatePreferencesDto {
@ApiPropertyOptional({ description: 'Receive order notifications' })
@IsOptional()
@IsBoolean()
orderNotifications?: boolean;
@ApiPropertyOptional({ description: 'Receive print job notifications' })
@IsOptional()
@IsBoolean()
printJobNotifications?: boolean;
@ApiPropertyOptional({ description: 'Receive shipment notifications' })
@IsOptional()
@IsBoolean()
shipmentNotifications?: boolean;
@ApiPropertyOptional({ description: 'Receive system notifications' })
@IsOptional()
@IsBoolean()
systemNotifications?: boolean;
}
5. Frontend Push Hook¶
Create apps/web/src/hooks/use-push-notifications.ts:
import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '../lib/api-client';
interface PushPreferences {
orderNotifications: boolean;
printJobNotifications: boolean;
shipmentNotifications: boolean;
systemNotifications: boolean;
}
interface UsePushNotificationsReturn {
isSupported: boolean;
permission: NotificationPermission;
isSubscribed: boolean;
isLoading: boolean;
error: string | null;
preferences: PushPreferences;
subscribe: () => Promise<boolean>;
unsubscribe: () => Promise<void>;
updatePreferences: (prefs: Partial<PushPreferences>) => Promise<void>;
}
const defaultPreferences: PushPreferences = {
orderNotifications: true,
printJobNotifications: true,
shipmentNotifications: true,
systemNotifications: true,
};
export function usePushNotifications(): UsePushNotificationsReturn {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [preferences, setPreferences] = useState<PushPreferences>(defaultPreferences);
const isSupported = 'serviceWorker' in navigator && 'PushManager' in window;
// Check current subscription status
useEffect(() => {
const checkSubscription = async () => {
if (!isSupported) {
setIsLoading(false);
return;
}
try {
setPermission(Notification.permission);
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(subscription !== null);
// Load preferences from localStorage
const savedPrefs = localStorage.getItem('push-preferences');
if (savedPrefs) {
setPreferences(JSON.parse(savedPrefs));
}
} catch (err) {
console.error('Error checking push subscription:', err);
} finally {
setIsLoading(false);
}
};
checkSubscription();
}, [isSupported]);
const subscribe = useCallback(async (): Promise<boolean> => {
if (!isSupported) {
setError('Push notifications not supported');
return false;
}
setIsLoading(true);
setError(null);
try {
// Request permission
const result = await Notification.requestPermission();
setPermission(result);
if (result !== 'granted') {
setError('Notification permission denied');
return false;
}
// Get VAPID public key from server
const { publicKey } = await apiClient.get<{ publicKey: string }>('/push/vapid-public-key');
if (!publicKey) {
setError('Push notifications not configured on server');
return false;
}
// Subscribe to push
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
await apiClient.post('/push/subscribe', {
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys: {
p256dh: arrayBufferToBase64(subscription.getKey('p256dh')),
auth: arrayBufferToBase64(subscription.getKey('auth')),
},
userAgent: navigator.userAgent,
});
setIsSubscribed(true);
return true;
} catch (err) {
console.error('Error subscribing to push:', err);
setError('Failed to subscribe to notifications');
return false;
} finally {
setIsLoading(false);
}
}, [isSupported]);
const unsubscribe = useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe locally
await subscription.unsubscribe();
// Remove from server
await apiClient.delete('/push/unsubscribe', {
params: { endpoint: subscription.endpoint },
});
}
setIsSubscribed(false);
} catch (err) {
console.error('Error unsubscribing:', err);
setError('Failed to unsubscribe');
} finally {
setIsLoading(false);
}
}, []);
const updatePreferences = useCallback(async (prefs: Partial<PushPreferences>): Promise<void> => {
const newPrefs = { ...preferences, ...prefs };
setPreferences(newPrefs);
localStorage.setItem('push-preferences', JSON.stringify(newPrefs));
// Update on server
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await apiClient.post('/push/preferences', prefs, {
params: { endpoint: subscription.endpoint },
});
}
}, [preferences]);
return {
isSupported,
permission,
isSubscribed,
isLoading,
error,
preferences,
subscribe,
unsubscribe,
updatePreferences,
};
}
// Helper functions
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function arrayBufferToBase64(buffer: ArrayBuffer | null): string {
if (!buffer) return '';
const bytes = new Uint8Array(buffer);
let binary = '';
bytes.forEach((b) => (binary += String.fromCharCode(b)));
return window.btoa(binary);
}
6. Push Permission UI Component¶
Create apps/web/src/pwa/push-permission.tsx:
import { Button } from '../components/ui/button';
import { usePushNotifications } from '../hooks/use-push-notifications';
interface PushPermissionProps {
onSuccess?: () => void;
}
export function PushPermission({ onSuccess }: PushPermissionProps) {
const {
isSupported,
permission,
isSubscribed,
isLoading,
error,
subscribe,
unsubscribe,
} = usePushNotifications();
if (!isSupported) {
return (
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
Push notifications are not supported in this browser.
</p>
</div>
);
}
if (permission === 'denied') {
return (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Notifications are blocked. Please enable them in your browser settings.
</p>
</div>
);
}
const handleToggle = async () => {
if (isSubscribed) {
await unsubscribe();
} else {
const success = await subscribe();
if (success && onSuccess) {
onSuccess();
}
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">
Push Notifications
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{isSubscribed
? 'You will receive alerts for orders and print jobs'
: 'Enable notifications to stay updated on orders'}
</p>
</div>
<Button
onClick={handleToggle}
disabled={isLoading}
variant={isSubscribed ? 'outline' : 'default'}
>
{isLoading ? 'Loading...' : isSubscribed ? 'Disable' : 'Enable'}
</Button>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}
🔧 Feature F7.3: Offline Support¶
Requirements Reference¶
- View cached order data when offline
- Offline indicator in UI
- Queue actions for sync when online
Implementation¶
1. IndexedDB Wrapper¶
Create apps/web/src/lib/indexed-db.ts:
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface Forma3DDB extends DBSchema {
orders: {
key: string;
value: {
id: string;
shopifyOrderNumber: string;
customerName: string;
status: string;
totalPrice: number;
currency: string;
createdAt: string;
updatedAt: string;
cachedAt: number;
};
indexes: { 'by-status': string; 'by-cached-at': number };
};
pendingActions: {
key: number;
value: {
id?: number;
type: 'retry' | 'cancel';
entityType: 'order' | 'printjob';
entityId: string;
createdAt: number;
};
};
}
let dbPromise: Promise<IDBPDatabase<Forma3DDB>> | null = null;
export async function getDB(): Promise<IDBPDatabase<Forma3DDB>> {
if (!dbPromise) {
dbPromise = openDB<Forma3DDB>('forma3d-connect', 1, {
upgrade(db) {
// Orders store
const orderStore = db.createObjectStore('orders', { keyPath: 'id' });
orderStore.createIndex('by-status', 'status');
orderStore.createIndex('by-cached-at', 'cachedAt');
// Pending actions store (for offline queue)
db.createObjectStore('pendingActions', {
keyPath: 'id',
autoIncrement: true,
});
},
});
}
return dbPromise;
}
// Orders
export async function cacheOrders(orders: Forma3DDB['orders']['value'][]): Promise<void> {
const db = await getDB();
const tx = db.transaction('orders', 'readwrite');
for (const order of orders) {
await tx.store.put({ ...order, cachedAt: Date.now() });
}
await tx.done;
}
export async function getCachedOrders(): Promise<Forma3DDB['orders']['value'][]> {
const db = await getDB();
return db.getAllFromIndex('orders', 'by-cached-at');
}
export async function getCachedOrder(id: string): Promise<Forma3DDB['orders']['value'] | undefined> {
const db = await getDB();
return db.get('orders', id);
}
export async function clearOldCache(maxAgeMs: number = 1000 * 60 * 60 * 24): Promise<void> {
const db = await getDB();
const tx = db.transaction('orders', 'readwrite');
const cutoff = Date.now() - maxAgeMs;
let cursor = await tx.store.index('by-cached-at').openCursor();
while (cursor) {
if (cursor.value.cachedAt < cutoff) {
await cursor.delete();
}
cursor = await cursor.continue();
}
await tx.done;
}
// Pending Actions (offline queue)
export async function queueAction(action: Omit<Forma3DDB['pendingActions']['value'], 'id' | 'createdAt'>): Promise<void> {
const db = await getDB();
await db.add('pendingActions', { ...action, createdAt: Date.now() });
}
export async function getPendingActions(): Promise<Forma3DDB['pendingActions']['value'][]> {
const db = await getDB();
return db.getAll('pendingActions');
}
export async function clearPendingAction(id: number): Promise<void> {
const db = await getDB();
await db.delete('pendingActions', id);
}
export async function clearAllPendingActions(): Promise<void> {
const db = await getDB();
await db.clear('pendingActions');
}
2. Online Status Hook¶
Create apps/web/src/hooks/use-online-status.ts:
import { useState, useEffect, useCallback } from 'react';
import { getPendingActions, clearPendingAction } from '../lib/indexed-db';
import { apiClient } from '../lib/api-client';
interface UseOnlineStatusReturn {
isOnline: boolean;
pendingActionsCount: number;
syncPendingActions: () => Promise<{ synced: number; failed: number }>;
}
export function useOnlineStatus(): UseOnlineStatusReturn {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [pendingActionsCount, setPendingActionsCount] = useState(0);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Check pending actions count
const checkPending = async () => {
const actions = await getPendingActions();
setPendingActionsCount(actions.length);
};
checkPending();
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const syncPendingActions = useCallback(async (): Promise<{ synced: number; failed: number }> => {
const actions = await getPendingActions();
let synced = 0;
let failed = 0;
for (const action of actions) {
try {
if (action.type === 'retry') {
await apiClient.post(`/${action.entityType}s/${action.entityId}/retry`);
} else if (action.type === 'cancel') {
await apiClient.post(`/${action.entityType}s/${action.entityId}/cancel`);
}
if (action.id) {
await clearPendingAction(action.id);
}
synced++;
} catch (err) {
console.error('Failed to sync action:', err);
failed++;
}
}
setPendingActionsCount(failed);
return { synced, failed };
}, []);
// Auto-sync when coming back online
useEffect(() => {
if (isOnline && pendingActionsCount > 0) {
syncPendingActions();
}
}, [isOnline, pendingActionsCount, syncPendingActions]);
return {
isOnline,
pendingActionsCount,
syncPendingActions,
};
}
3. Offline Indicator Component¶
Create apps/web/src/pwa/offline-indicator.tsx:
import { useOnlineStatus } from '../hooks/use-online-status';
export function OfflineIndicator() {
const { isOnline, pendingActionsCount, syncPendingActions } = useOnlineStatus();
if (isOnline && pendingActionsCount === 0) {
return null;
}
return (
<div
className={`fixed bottom-4 left-4 px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 z-50 ${
isOnline
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-white'
}`}
>
{!isOnline ? (
<>
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
<span className="text-sm font-medium">Offline</span>
{pendingActionsCount > 0 && (
<span className="text-xs bg-white/20 px-2 py-0.5 rounded">
{pendingActionsCount} pending
</span>
)}
</>
) : (
<>
<span className="text-sm font-medium">
Syncing {pendingActionsCount} action{pendingActionsCount > 1 ? 's' : ''}...
</span>
<button
onClick={syncPendingActions}
className="text-xs underline hover:no-underline"
>
Sync now
</button>
</>
)}
</div>
);
}
🔧 Feature F7.4: App-like Experience¶
Requirements Reference¶
- Splash screen on app launch
- Badge count for notifications
- Pull-to-refresh on mobile
- iOS-specific "Add to Home Screen" guidance
Implementation¶
1. Badge API Integration¶
Update apps/web/src/hooks/use-push-notifications.ts to include badge:
// Add to the push notification hook
export async function updateBadge(count: number): Promise<void> {
if ('setAppBadge' in navigator) {
try {
if (count > 0) {
await (navigator as { setAppBadge: (count: number) => Promise<void> }).setAppBadge(count);
} else {
await (navigator as { clearAppBadge: () => Promise<void> }).clearAppBadge();
}
} catch (error) {
console.error('Error updating badge:', error);
}
}
}
2. iOS Install Guidance¶
Create apps/web/src/pwa/ios-install-guide.tsx:
import { useState, useEffect } from 'react';
import { Button } from '../components/ui/button';
export function IOSInstallGuide() {
const [showGuide, setShowGuide] = useState(false);
useEffect(() => {
// Detect iOS Safari (not in standalone mode)
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isStandalone = (window.navigator as { standalone?: boolean }).standalone === true;
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
// Check if we've already shown the guide
const hasShownGuide = localStorage.getItem('ios-install-guide-shown');
if (isIOS && isSafari && !isStandalone && !hasShownGuide) {
// Show after a delay
const timer = setTimeout(() => setShowGuide(true), 5000);
return () => clearTimeout(timer);
}
}, []);
const handleDismiss = () => {
setShowGuide(false);
localStorage.setItem('ios-install-guide-shown', 'true');
};
if (!showGuide) {
return null;
}
return (
<div className="fixed inset-x-4 bottom-4 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4 z-50">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
aria-label="Close"
>
✕
</button>
<h3 className="font-semibold text-gray-900 dark:text-white mb-2">
Install Forma3D.Connect
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Install this app on your iPhone for the best experience:
</p>
<ol className="text-sm text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li className="flex items-center gap-2">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-bold">
1
</span>
Tap the Share button <span className="text-lg">⬆️</span> at the bottom
</li>
<li className="flex items-center gap-2">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-bold">
2
</span>
Scroll down and tap "Add to Home Screen"
</li>
<li className="flex items-center gap-2">
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-bold">
3
</span>
Tap "Add" in the top right corner
</li>
</ol>
<Button variant="outline" onClick={handleDismiss} className="w-full">
Got it
</Button>
</div>
);
}
3. Pull-to-Refresh Component¶
Create apps/web/src/pwa/pull-to-refresh.tsx:
import { useEffect, useRef, useState } from 'react';
interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
}
export function PullToRefresh({ onRefresh, children }: PullToRefreshProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isPulling, setIsPulling] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const startY = useRef(0);
const PULL_THRESHOLD = 80;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleTouchStart = (e: TouchEvent) => {
if (container.scrollTop === 0) {
startY.current = e.touches[0].clientY;
setIsPulling(true);
}
};
const handleTouchMove = (e: TouchEvent) => {
if (!isPulling || isRefreshing) return;
const currentY = e.touches[0].clientY;
const distance = Math.max(0, currentY - startY.current);
if (distance > 0 && container.scrollTop === 0) {
e.preventDefault();
setPullDistance(Math.min(distance * 0.5, PULL_THRESHOLD * 1.5));
}
};
const handleTouchEnd = async () => {
if (pullDistance >= PULL_THRESHOLD && !isRefreshing) {
setIsRefreshing(true);
await onRefresh();
setIsRefreshing(false);
}
setIsPulling(false);
setPullDistance(0);
};
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: false });
container.addEventListener('touchend', handleTouchEnd);
return () => {
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
};
}, [isPulling, isRefreshing, pullDistance, onRefresh]);
return (
<div ref={containerRef} className="h-full overflow-auto">
<div
className="flex justify-center transition-all"
style={{
height: pullDistance,
opacity: pullDistance / PULL_THRESHOLD,
}}
>
<div className={`my-2 ${isRefreshing ? 'animate-spin' : ''}`}>
{isRefreshing ? '↻' : pullDistance >= PULL_THRESHOLD ? '↓' : '↓'}
</div>
</div>
{children}
</div>
);
}
🧪 Testing Requirements¶
Unit Test Scenarios Required¶
| Category | Scenario | Priority |
|---|---|---|
| PWA Install | Install prompt shows after delay | High |
| PWA Install | Install prompt dismissed on "Not now" | Medium |
| PWA Install | isInstalled true in standalone mode | High |
| Push Subscription | Subscribe creates valid subscription | High |
| Push Subscription | Unsubscribe removes subscription | High |
| Push Subscription | Preferences are persisted | Medium |
| Push Service | VAPID keys configured correctly | High |
| Push Service | Notifications sent on order events | High |
| Push Service | Notifications sent on print job events | High |
| Push Service | Notifications sent on shipment events | High |
| Push Service | Invalid subscriptions cleaned up | Medium |
| Offline | Orders cached in IndexedDB | High |
| Offline | Cached orders retrieved when offline | High |
| Offline | Actions queued when offline | Medium |
| Offline | Pending actions synced when online | Medium |
| Service Worker | Static assets cached | High |
| Service Worker | API responses cached with stale-while-revalidate | Medium |
Gherkin Acceptance Tests¶
Create apps/acceptance-tests/src/features/pwa-install.feature:
@web @pwa
Feature: PWA Installation
As an operator
I want to install Forma3D.Connect as an app
So that I can access it quickly from my device
Background:
Given the staging web app is available
And I am using a PWA-compatible browser
@critical
Scenario: PWA manifest is valid
When I check the web app manifest
Then the manifest should have name "Forma3D.Connect"
And the manifest should have valid icons
And the manifest should have display "standalone"
Scenario: Service worker is registered
When I visit the dashboard
Then a service worker should be registered
And the service worker should be active
Scenario: Install prompt appears on Chrome/Edge
Given I am using Chrome or Edge on desktop
And I have not previously dismissed the install prompt
When I use the app for 30 seconds
Then I should see an install prompt
And the prompt should have an "Install" button
Scenario: Install prompt does not appear when already installed
Given the app is already installed as a PWA
When I visit the dashboard
Then I should not see an install prompt
Scenario: iOS Safari shows install guidance
Given I am using Safari on iOS
And the app is not installed
And I have not dismissed the iOS install guide
When I visit the dashboard
Then I should see iOS-specific install instructions
And the instructions should mention "Add to Home Screen"
Create apps/acceptance-tests/src/features/push-notifications.feature:
@web @pwa @push
Feature: Push Notifications
As an operator
I want to receive push notifications
So that I can stay updated on orders and print jobs
Background:
Given the staging web app is available
And I am logged in with a valid API key
And push notifications are supported in my browser
@critical
Scenario: VAPID public key is available
When I request the VAPID public key from the API
Then I should receive a valid public key
Scenario: Enable push notifications
Given I have not enabled push notifications
When I go to notification settings
And I click "Enable" on push notifications
And I grant notification permission
Then push notifications should be enabled
And I should see a confirmation message
Scenario: Disable push notifications
Given I have enabled push notifications
When I go to notification settings
And I click "Disable" on push notifications
Then push notifications should be disabled
Scenario: Configure notification preferences
Given I have enabled push notifications
When I go to notification settings
Then I should see toggles for:
| Order notifications |
| Print job notifications |
| Shipment notifications |
| System notifications |
When I disable "Shipment notifications"
Then my preference should be saved
@critical
Scenario: Receive notification when order is created
Given I have enabled order notifications
And the app is running in the background
When a new order is created via Shopify webhook
Then I should receive a push notification
And the notification title should contain "New Order"
Scenario: Receive notification when print job fails
Given I have enabled print job notifications
When a print job fails
Then I should receive a push notification
And the notification should contain "failed"
And the notification should indicate action is required
Scenario: Receive notification when shipment is created
Given I have enabled shipment notifications
When a shipping label is created
Then I should receive a push notification
And the notification should mention "Shipping Label"
Scenario: Notification click opens relevant page
Given I received a notification for order #1234
When I click on the notification
Then the app should open
And I should be on the order detail page for #1234
Create apps/acceptance-tests/src/features/offline-mode.feature:
@web @pwa @offline
Feature: Offline Mode
As an operator
I want to view order data when offline
So that I can work without internet connectivity
Background:
Given the staging web app is available
And I am logged in with a valid API key
And I have previously viewed the orders list
Scenario: Offline indicator appears when disconnected
Given I am on the dashboard
When I lose internet connectivity
Then I should see an "Offline" indicator
And the indicator should be visually distinct
Scenario: View cached orders when offline
Given I have viewed orders in the last session
When I go offline
And I navigate to the orders page
Then I should see the cached orders
And there should be a notice that data may be stale
Scenario: View cached order detail when offline
Given I have viewed order #1234 in the last session
When I go offline
And I navigate to order #1234 detail page
Then I should see the cached order details
Scenario: Actions are queued when offline
Given I am viewing an order
When I go offline
And I click "Retry Print Job"
Then the action should be queued
And I should see "1 pending" on the offline indicator
Scenario: Pending actions sync when online
Given I have 2 pending actions queued
When I come back online
Then the pending actions should sync automatically
And I should see a confirmation of synced actions
Scenario: App loads when started offline
Given I have previously used the app
And I am currently offline
When I open the app
Then the app should load from cache
And I should see the offline indicator
Scenario: Real-time updates resume after coming online
Given I am on the dashboard
And I went offline for 5 minutes
When I come back online
Then real-time updates should resume
And I should see any missed order updates
Load Test Scenarios¶
Create load-tests/k6/scenarios/pwa-endpoints.js:
/**
* PWA Endpoints Load Test
*
* Tests the push notification subscription endpoints
* under load to verify they can handle multiple concurrent subscribers.
*
* Usage:
* k6 run --env ENV=staging pwa-endpoints.js
*/
import { check, group, sleep } from 'k6';
import http from 'k6/http';
import { Rate, Trend } from 'k6/metrics';
import { getEnvConfig } from '../config.js';
// Custom metrics
const subscriptionSuccess = new Rate('subscription_success');
const vapidKeySuccess = new Rate('vapid_key_success');
const pushEndpointLatency = new Trend('push_endpoint_latency');
// Get environment configuration
const envName = __ENV.ENV || 'local';
const config = getEnvConfig(envName);
const BASE_URL = __ENV.API_URL || config.baseUrl;
export const options = {
scenarios: {
// Simulate multiple users subscribing concurrently
subscription_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 20 }, // Ramp up to 20 users
{ duration: '1m', target: 20 }, // Hold at 20
{ duration: '30s', target: 0 }, // Ramp down
],
},
},
thresholds: {
'http_req_duration': ['p(95)<1000'], // 95th percentile < 1s
'subscription_success': ['rate>0.95'], // 95% success rate
'vapid_key_success': ['rate>0.99'], // 99% success for key fetch
},
};
export default function () {
group('VAPID Key Endpoint', () => {
const response = http.get(`${BASE_URL}/api/v1/push/vapid-public-key`);
pushEndpointLatency.add(response.timings.duration);
const success = check(response, {
'vapid key status is 200': (r) => r.status === 200,
'vapid key is present': (r) => {
try {
const body = JSON.parse(r.body);
return body.publicKey && body.publicKey.length > 0;
} catch {
return false;
}
},
});
vapidKeySuccess.add(success ? 1 : 0);
});
group('Subscription Endpoint', () => {
// Generate a unique mock subscription for each VU iteration
const mockSubscription = {
endpoint: `https://fcm.googleapis.com/fcm/send/${__VU}-${Date.now()}`,
expirationTime: null,
keys: {
p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM',
auth: 'tBHItJI5svbpez7KI4CCXg',
},
userAgent: 'K6 Load Test',
};
const response = http.post(
`${BASE_URL}/api/v1/push/subscribe`,
JSON.stringify(mockSubscription),
{ headers: { 'Content-Type': 'application/json' } }
);
pushEndpointLatency.add(response.timings.duration);
const success = check(response, {
'subscription status is 201': (r) => r.status === 201,
'subscription returns id': (r) => {
try {
const body = JSON.parse(r.body);
return body.id && body.id.length > 0;
} catch {
return false;
}
},
});
subscriptionSuccess.add(success ? 1 : 0);
// Clean up the subscription
if (response.status === 201) {
http.del(
`${BASE_URL}/api/v1/push/unsubscribe?endpoint=${encodeURIComponent(mockSubscription.endpoint)}`
);
}
});
sleep(0.5);
}
export function setup() {
console.log('============================================');
console.log('📊 K6 Load Test: PWA Endpoints');
console.log('============================================');
console.log(`Target URL: ${BASE_URL}`);
console.log(`Environment: ${envName}`);
console.log('');
// Verify API is reachable
const healthCheck = http.get(`${BASE_URL}/health`);
if (healthCheck.status !== 200) {
throw new Error(`API is not reachable at ${BASE_URL}`);
}
console.log('✅ API health check passed');
console.log('🚀 Starting load test...');
console.log('');
}
export function teardown(data) {
console.log('');
console.log('============================================');
console.log('📊 PWA Endpoints Load Test Complete');
console.log('============================================');
}
✅ Validation Checklist¶
Infrastructure¶
- All modules compile without errors
-
pnpm nx build websucceeds with PWA assets -
pnpm nx build apisucceeds -
pnpm lintpasses on all files - No TypeScript errors
- Service worker registers correctly
PWA Foundation (F7.1)¶
- Web app manifest is valid (use Lighthouse)
- All PWA icons generated (192x192, 512x512, maskable)
- Service worker caches static assets
- App is installable on Chrome
- App is installable on Edge
- App appears in standalone mode after install
- Install prompt component works
- Service worker update prompt works
Push Notifications (F7.2)¶
- VAPID keys generated and configured
- Push subscription endpoint works
- Push unsubscription endpoint works
- Notification preferences persistence works
- Push notifications sent on order creation
- Push notifications sent on order status change
- Push notifications sent on print job status change
- Push notifications sent on shipment events
- Push notifications work on Android Chrome
- Push notifications work on iOS Safari (Home Screen installed)
- Notification click opens relevant page
Offline Support (F7.3)¶
- Orders cached in IndexedDB
- Cached orders displayed when offline
- Offline indicator appears when disconnected
- Actions queued when offline
- Pending actions sync when online
- App loads from cache when started offline
App-like Experience (F7.4)¶
- Splash screen displays on app launch
- Badge count updates on notifications
- Pull-to-refresh works on mobile
- iOS install guide displays correctly
- Status bar color configured
Testing¶
- All unit tests passing
- All acceptance tests passing
- Load tests pass with 20+ concurrent subscribers
- Manual testing on Chrome, Safari, Edge complete
🚫 Constraints and Rules¶
MUST DO¶
- Use
vite-plugin-pwafor service worker generation - Implement VAPID-based Web Push (not legacy GCM)
- Store push subscriptions in database (not in-memory)
- Clean up expired/invalid subscriptions automatically
- Provide notification preferences (user can disable categories)
- Cache API responses with appropriate TTL
- Handle offline state gracefully
- Test on iOS Safari (requires Home Screen install for push)
MUST NOT¶
- Skip Web Push on iOS (it works since iOS 16.4+)
- Store VAPID private key in client code
- Send push notifications without user permission
- Cache health endpoints
- Rely solely on service worker for offline data (use IndexedDB)
- Break existing functionality when adding PWA features
🎬 Execution Order¶
PWA Foundation (F7.1)¶
- Install dependencies:
vite-plugin-pwa,workbox-window,idb - Configure Vite PWA plugin in
vite.config.ts - Create web app manifest with proper icons
- Update index.html with PWA meta tags
- Generate PWA icons (192, 512, maskable variants)
- Create install prompt component
- Create PWA install hook
- Create service worker update prompt
- Test installation on Chrome, Safari, Edge
Push Notifications (F7.2)¶
- Generate VAPID keys using
web-push generate-vapid-keys - Add VAPID keys to environment configuration
- Create Prisma schema for PushSubscription
- Run database migration
- Create push repository for subscription persistence
- Create push service with Web Push integration
- Add event handlers for order, print job, shipment events
- Create push controller with subscribe/unsubscribe endpoints
- Create frontend push hook
- Create push permission UI component
- Create notification settings page
- Test notifications on desktop and mobile
Offline Support (F7.3)¶
- Create IndexedDB wrapper for offline data storage
- Configure Workbox for runtime API caching
- Create online status hook
- Create offline indicator component
- Implement action queuing for offline actions
- Implement sync when coming back online
- Test offline scenarios
App-like Experience (F7.4)¶
- Configure splash screen
- Implement badge API for notification counts
- Create pull-to-refresh component for mobile
- Create iOS install guide component
- Test on various devices
Testing & Validation¶
- Write unit tests for all PWA components and hooks
- Write unit tests for push service and repository
- Create Gherkin acceptance tests
- Create K6 load tests for push endpoints
- Run full test suite
- Manual testing on Chrome, Safari, Edge, iOS, Android
- Update documentation
📊 Expected Output¶
When Phase 7 is complete:
Verification Commands¶
# Build with PWA
pnpm nx build web
# Check for PWA assets
ls -la dist/apps/web/manifest.json
ls -la dist/apps/web/sw.js
# Run Lighthouse PWA audit
# (Use Chrome DevTools → Lighthouse → PWA)
# Run all tests
pnpm test
# Run acceptance tests
pnpm nx test acceptance-tests
# Run load tests
k6 run --env ENV=staging load-tests/k6/scenarios/pwa-endpoints.js
Success Metrics¶
| Metric | Target | Verification |
|---|---|---|
| Lighthouse PWA Score | > 90 | Chrome DevTools Lighthouse |
| Service Worker Registered | Yes | Chrome DevTools → Application |
| Install Prompt Works | Chrome/Edge | Manual testing |
| Push Notifications | All platforms | Manual testing |
| Offline Mode | Shows cached data | Manual testing (airplane mode) |
| Push Endpoint Latency (p95) | < 1 second | K6 load test |
| Subscription Success Rate | > 95% | K6 load test |
| Unit Test Coverage | > 80% | pnpm test:coverage |
📝 Documentation Updates¶
CRITICAL: All documentation must be updated to reflect Phase 7 completion.
docs/04-development/implementation-plan.md Updates Required¶
Update the implementation plan to mark Phase 7 as complete:
- Mark F7.1 (PWA Foundation) as ✅ Completed
- Mark F7.2 (Push Notifications) as ✅ Completed
- Mark F7.3 (Offline Support) as ✅ Completed
- Mark F7.4 (App-like Experience) as ✅ Completed
- Update Phase 7 Exit Criteria with checkmarks
- Add implementation notes and component paths
- Update revision history with completion date
Additional Documentation¶
- Create
docs/04-development/pwa-guide.mdwith PWA usage instructions - Update README.md with PWA installation instructions
- Document VAPID key generation in environment setup
- Add push notification troubleshooting section
Architecture Updates¶
- Update C4 Container diagram to reflect PWA architecture
- Remove references to Tauri and Capacitor apps
- Document push notification flow
🔗 Phase 7 Exit Criteria¶
From implementation-plan.md:
- PWA installable on Chrome, Safari, Edge
- Push notifications working on all platforms
- Offline mode shows cached data
- App icon badge shows notification count
- Documentation updated with PWA install instructions
Additional Exit Criteria¶
- All unit tests passing with > 80% coverage for PWA code
- All Gherkin acceptance tests passing
- Load tests passing for push endpoints
- Lighthouse PWA score > 90
- Manual testing complete on desktop and mobile browsers
- iOS Safari tested (with Home Screen installation)
🎉 Cross-Platform Access Complete¶
With Phase 7 complete, Forma3D.Connect provides:
PWA Capabilities¶
- Installable on any modern browser (Chrome, Edge, Safari, Firefox)
- Cross-platform - works on Windows, macOS, Linux, iOS, Android
- Push notifications for real-time order and print job alerts
- Offline support for viewing cached data
- Native-like experience with app badges, splash screens, and smooth interactions
Eliminated Complexity¶
- No separate Tauri desktop app needed
- No separate Capacitor mobile app needed
- No app store submissions or reviews
- No platform-specific build pipelines
- Estimated 80-150 hours saved vs native app development
Deployment Simplicity¶
- Single web deployment serves all platforms
- Instant updates (no app store delays)
- Users always on latest version
END OF PROMPT
This prompt implements Phase 7 of Forma3D.Connect: Progressive Web App functionality. The AI should implement all PWA features to provide cross-platform mobile and desktop access through the existing web application, with push notifications for real-time alerts and offline support for cached data viewing.