Skip to content

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-fulfillment event

  • 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.puml System context diagram
Container View docs/03-architecture/c4-model/2-container/C4_Container.puml System containers and interactions
Component View docs/03-architecture/c4-model/3-component/C4_Component.puml Backend component architecture
PWA Feasibility docs/03-architecture/research/PWA-feasibility-study.md PWA 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 web succeeds with PWA assets
  • pnpm nx build api succeeds
  • pnpm lint passes 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-pwa for 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)

  1. Install dependencies: vite-plugin-pwa, workbox-window, idb
  2. Configure Vite PWA plugin in vite.config.ts
  3. Create web app manifest with proper icons
  4. Update index.html with PWA meta tags
  5. Generate PWA icons (192, 512, maskable variants)
  6. Create install prompt component
  7. Create PWA install hook
  8. Create service worker update prompt
  9. Test installation on Chrome, Safari, Edge

Push Notifications (F7.2)

  1. Generate VAPID keys using web-push generate-vapid-keys
  2. Add VAPID keys to environment configuration
  3. Create Prisma schema for PushSubscription
  4. Run database migration
  5. Create push repository for subscription persistence
  6. Create push service with Web Push integration
  7. Add event handlers for order, print job, shipment events
  8. Create push controller with subscribe/unsubscribe endpoints
  9. Create frontend push hook
  10. Create push permission UI component
  11. Create notification settings page
  12. Test notifications on desktop and mobile

Offline Support (F7.3)

  1. Create IndexedDB wrapper for offline data storage
  2. Configure Workbox for runtime API caching
  3. Create online status hook
  4. Create offline indicator component
  5. Implement action queuing for offline actions
  6. Implement sync when coming back online
  7. Test offline scenarios

App-like Experience (F7.4)

  1. Configure splash screen
  2. Implement badge API for notification counts
  3. Create pull-to-refresh component for mobile
  4. Create iOS install guide component
  5. Test on various devices

Testing & Validation

  1. Write unit tests for all PWA components and hooks
  2. Write unit tests for push service and repository
  3. Create Gherkin acceptance tests
  4. Create K6 load tests for push endpoints
  5. Run full test suite
  6. Manual testing on Chrome, Safari, Edge, iOS, Android
  7. 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.md with 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.