# PWA Mobile Enhancement - Implementation Plan

> Priority #3 Weakness Fix | Est: 5-8 hours | Ongoing: $0

---

## Executive Summary

**Goal:** Make RateRight installable as a native-like app on mobile devices
**Reuse:** 95% of app already works - just needs PWA configuration
**Build:** manifest.json, service worker, install prompt, icons, push notifications

---

## What Exists (Reusable)

| Component | Status | Reuse Level |
|-----------|--------|-------------|
| Mobile-responsive design | Production | 100% |
| Bottom navigation | Production | 100% |
| Safe area insets | Production | 100% |
| Touch-optimized UI | Production | 100% |
| In-app notification sounds | Production | 100% |
| Real-time subscriptions | Production | 100% |
| React Query caching | Production | 100% |
| Lazy-loaded pages | Production | 100% |
| Vite chunked build | Production | 100% |

---

## Current State

**What Works on Mobile:**
- Responsive layout (Tailwind mobile-first)
- BottomNav with 5 tabs
- Safe area padding for notched devices
- Touch gestures (swipeable)
- Audio notifications
- Real-time updates via Supabase

**What's Missing:**
- No manifest.json (can't install to home screen)
- No service worker (no offline, no push)
- No app icons
- No install prompt UI
- No splash screens
- No web push notifications

---

## Implementation Steps

### Phase 1: Basic PWA Setup (2-3 hours)

#### Step 1.1: Create App Icons
**Files:** `admin/public/icons/*`
**Time:** 30 min

Create icon assets:
- `icon-192x192.png` - Android home screen
- `icon-512x512.png` - Android splash
- `apple-touch-icon.png` - iOS home screen (180x180)
- `favicon-32x32.png` - Browser tab
- `favicon-16x16.png` - Browser tab (small)
- `maskable-icon-512x512.png` - Android adaptive icon

**Design:**
- Use RateRight logo
- Solid background (brand color #3B82F6)
- Centered logo icon
- High contrast for visibility

#### Step 1.2: Create manifest.json
**File:** `admin/public/manifest.json`
**Time:** 15 min

```json
{
  "name": "RateRight Growth Engine",
  "short_name": "RateRight",
  "description": "AI-powered sales CRM for the trades",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0F172A",
  "theme_color": "#3B82F6",
  "orientation": "portrait",
  "scope": "/",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/maskable-icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "shortcuts": [
    {
      "name": "Call List",
      "short_name": "Calls",
      "url": "/calls",
      "icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
    },
    {
      "name": "Messages",
      "short_name": "SMS",
      "url": "/inbox",
      "icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
    }
  ],
  "categories": ["business", "productivity"]
}
```

#### Step 1.3: Update index.html Meta Tags
**File:** `admin/index.html`
**Time:** 15 min

Add PWA meta tags:

```html
<head>
  <!-- Existing -->
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />

  <!-- PWA Meta Tags (NEW) -->
  <meta name="theme-color" content="#3B82F6" />
  <meta name="description" content="AI-powered sales CRM for the trades" />

  <!-- iOS specific -->
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
  <meta name="apple-mobile-web-app-title" content="RateRight" />
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />

  <!-- Manifest -->
  <link rel="manifest" href="/manifest.json" />

  <!-- Favicons -->
  <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
  <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
</head>
```

#### Step 1.4: Install vite-plugin-pwa
**File:** `admin/package.json`, `admin/vite.config.js`
**Time:** 30 min

```bash
cd admin && npm install vite-plugin-pwa -D
```

Update vite.config.js:

```javascript
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['icons/*.png'],
      manifest: false, // Use our custom manifest.json
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/rateright-growth-production\.up\.railway\.app\/api\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 5 // 5 minutes
              },
              networkTimeoutSeconds: 10
            }
          },
          {
            urlPattern: /^https:\/\/.*supabase\.co\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'supabase-cache',
              networkTimeoutSeconds: 10
            }
          }
        ]
      }
    })
  ]
});
```

#### Step 1.5: Create Install Prompt Component
**File:** `admin/src/components/InstallPrompt.jsx`
**Time:** 45 min

```jsx
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Download, X } from 'lucide-react';

export default function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] = useState(null);
  const [showPrompt, setShowPrompt] = useState(false);
  const [dismissed, setDismissed] = useState(false);

  useEffect(() => {
    // Check if already dismissed
    if (localStorage.getItem('pwaPromptDismissed')) {
      setDismissed(true);
      return;
    }

    // Check if already installed
    if (window.matchMedia('(display-mode: standalone)').matches) {
      return;
    }

    const handler = (e) => {
      e.preventDefault();
      setDeferredPrompt(e);
      // Show prompt after 5 seconds
      setTimeout(() => setShowPrompt(true), 5000);
    };

    window.addEventListener('beforeinstallprompt', handler);
    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const handleInstall = async () => {
    if (!deferredPrompt) return;

    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;

    if (outcome === 'accepted') {
      setShowPrompt(false);
    }
    setDeferredPrompt(null);
  };

  const handleDismiss = () => {
    setShowPrompt(false);
    localStorage.setItem('pwaPromptDismissed', 'true');
    setDismissed(true);
  };

  if (dismissed || !showPrompt) return null;

  return (
    <AnimatePresence>
      <motion.div
        initial={{ y: 100, opacity: 0 }}
        animate={{ y: 0, opacity: 1 }}
        exit={{ y: 100, opacity: 0 }}
        className="fixed bottom-20 left-4 right-4 bg-slate-800 rounded-xl p-4 shadow-2xl border border-slate-700 z-50 md:hidden"
      >
        <button
          onClick={handleDismiss}
          className="absolute top-2 right-2 p-1 text-slate-400 hover:text-white"
        >
          <X className="w-5 h-5" />
        </button>

        <div className="flex items-center gap-4">
          <div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center">
            <Download className="w-6 h-6 text-white" />
          </div>
          <div className="flex-1">
            <p className="text-white font-medium">Install RateRight</p>
            <p className="text-slate-400 text-sm">Add to home screen for faster access</p>
          </div>
          <button
            onClick={handleInstall}
            className="px-4 py-2 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600"
          >
            Install
          </button>
        </div>
      </motion.div>
    </AnimatePresence>
  );
}
```

#### Step 1.6: Add Install Prompt to App
**File:** `admin/src/App.jsx`
**Time:** 10 min

```jsx
import InstallPrompt from './components/InstallPrompt';

function App() {
  return (
    <>
      {/* ... existing routes ... */}
      <InstallPrompt />
    </>
  );
}
```

---

### Phase 2: Offline Support (1-2 hours)

#### Step 2.1: Create Offline Page
**File:** `admin/public/offline.html`
**Time:** 15 min

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>RateRight - Offline</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
      background: #0F172A;
      color: white;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
    }
    .container { text-align: center; max-width: 400px; }
    .icon { font-size: 64px; margin-bottom: 24px; }
    h1 { font-size: 24px; margin-bottom: 12px; }
    p { color: #94A3B8; margin-bottom: 24px; }
    button {
      background: #3B82F6;
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 8px;
      font-size: 16px;
      cursor: pointer;
    }
    button:hover { background: #2563EB; }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">📡</div>
    <h1>You're Offline</h1>
    <p>RateRight needs an internet connection to sync your leads and messages. Check your connection and try again.</p>
    <button onclick="window.location.reload()">Retry</button>
  </div>
</body>
</html>
```

#### Step 2.2: Configure Workbox for Offline
**File:** `admin/vite.config.js`
**Time:** 30 min

Add offline fallback:

```javascript
VitePWA({
  workbox: {
    navigateFallback: '/offline.html',
    navigateFallbackDenylist: [/^\/api/],
    // ... existing config
  }
})
```

#### Step 2.3: Add Offline Indicator
**File:** `admin/src/components/OfflineIndicator.jsx`
**Time:** 30 min

```jsx
import { useState, useEffect } from 'react';
import { WifiOff } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';

export default function OfflineIndicator() {
  const [isOffline, setIsOffline] = useState(!navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setIsOffline(false);
    const handleOffline = () => setIsOffline(true);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return (
    <AnimatePresence>
      {isOffline && (
        <motion.div
          initial={{ y: -50, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: -50, opacity: 0 }}
          className="fixed top-0 left-0 right-0 bg-amber-500 text-black text-center py-2 z-50 flex items-center justify-center gap-2"
        >
          <WifiOff className="w-4 h-4" />
          <span className="text-sm font-medium">You're offline. Changes will sync when reconnected.</span>
        </motion.div>
      )}
    </AnimatePresence>
  );
}
```

---

### Phase 3: Push Notifications (2-3 hours)

#### Step 3.1: Set Up Firebase Cloud Messaging
**Time:** 30 min

1. Create Firebase project (free tier)
2. Enable Cloud Messaging
3. Get VAPID key and config

Add to Railway env vars:
- `FIREBASE_VAPID_KEY`
- `FIREBASE_PROJECT_ID`
- `FIREBASE_API_KEY`

#### Step 3.2: Push Notification Service
**File:** `src/services/pushNotification.js` (NEW)
**Time:** 1 hour

```javascript
const admin = require('firebase-admin');

// Initialize Firebase Admin (server-side)
admin.initializeApp({
  credential: admin.credential.cert({
    projectId: process.env.FIREBASE_PROJECT_ID,
    // ... other config
  })
});

async function sendPushNotification(token, title, body, data = {}) {
  try {
    await admin.messaging().send({
      token,
      notification: { title, body },
      data,
      webpush: {
        notification: {
          icon: '/icons/icon-192x192.png',
          badge: '/icons/badge-72x72.png',
          vibrate: [200, 100, 200]
        }
      }
    });
    return { success: true };
  } catch (error) {
    console.error('Push notification failed:', error);
    return { success: false, error: error.message };
  }
}

async function sendNewSMSNotification(token, leadName, preview) {
  return sendPushNotification(
    token,
    `New SMS from ${leadName}`,
    preview,
    { type: 'sms', action: '/inbox' }
  );
}

async function sendHotLeadNotification(token, leadName, score) {
  return sendPushNotification(
    token,
    '🔥 Hot Lead Alert',
    `${leadName} just scored ${score}. Time to call!`,
    { type: 'hot_lead', action: '/calls' }
  );
}

module.exports = { sendPushNotification, sendNewSMSNotification, sendHotLeadNotification };
```

#### Step 3.3: Push Token Registration
**File:** `src/routes/notifications.js` (NEW)
**Time:** 30 min

```javascript
const express = require('express');
const router = express.Router();

// Store push token for user
router.post('/subscribe', async (req, res) => {
  const { token } = req.body;
  const userId = req.user?.id;

  // Store token in database
  await supabase
    .from('user_push_tokens')
    .upsert({ user_id: userId, token, updated_at: new Date() });

  res.json({ success: true });
});

// Unsubscribe
router.post('/unsubscribe', async (req, res) => {
  const userId = req.user?.id;

  await supabase
    .from('user_push_tokens')
    .delete()
    .eq('user_id', userId);

  res.json({ success: true });
});

module.exports = router;
```

#### Step 3.4: Frontend Push Registration
**File:** `admin/src/utils/pushNotifications.js` (NEW)
**Time:** 30 min

```javascript
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  // ... other config
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

export async function requestPushPermission() {
  try {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') return null;

    const token = await getToken(messaging, {
      vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY
    });

    // Send token to backend
    await fetch('/api/notifications/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token })
    });

    return token;
  } catch (error) {
    console.error('Push registration failed:', error);
    return null;
  }
}

export function onPushMessage(callback) {
  return onMessage(messaging, callback);
}
```

#### Step 3.5: Push Settings UI
**File:** `admin/src/components/PushSettings.jsx`
**Time:** 30 min

Add to More page:
- Toggle for push notifications
- Permission request flow
- Notification types (SMS, Hot Leads, Callbacks)

---

## Database Schema

```sql
CREATE TABLE IF NOT EXISTS user_push_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id),
  token TEXT NOT NULL,
  platform VARCHAR(20) DEFAULT 'web', -- 'web', 'ios', 'android'
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_id, token)
);

CREATE INDEX idx_push_tokens_user ON user_push_tokens(user_id);
```

---

## Files Summary

```
admin/
├── public/
│   ├── manifest.json (NEW)
│   ├── offline.html (NEW)
│   └── icons/
│       ├── icon-192x192.png (NEW)
│       ├── icon-512x512.png (NEW)
│       ├── maskable-icon-512x512.png (NEW)
│       ├── apple-touch-icon.png (NEW)
│       ├── favicon-32x32.png (NEW)
│       └── favicon-16x16.png (NEW)
├── index.html (UPDATE)
├── vite.config.js (UPDATE)
└── src/
    ├── components/
    │   ├── InstallPrompt.jsx (NEW)
    │   ├── OfflineIndicator.jsx (NEW)
    │   └── PushSettings.jsx (NEW)
    └── utils/
        └── pushNotifications.js (NEW)

src/
├── routes/
│   └── notifications.js (NEW)
└── services/
    └── pushNotification.js (NEW)

supabase/
└── migrations/
    └── YYYYMMDD_push_tokens.sql (NEW)
```

---

## Testing Checklist

- [ ] App appears in Chrome DevTools > Application > Manifest
- [ ] "Install app" option appears in Chrome menu
- [ ] Install prompt shows on mobile after 5 seconds
- [ ] App installs to home screen with correct icon
- [ ] App opens in standalone mode (no browser chrome)
- [ ] Theme color shows in status bar
- [ ] Splash screen displays on launch
- [ ] Offline page shows when network disconnected
- [ ] Offline indicator appears in app
- [ ] Push permission prompt works
- [ ] Push notification received when app closed
- [ ] Notification tap opens correct page
- [ ] Service worker caches static assets
- [ ] API requests use NetworkFirst strategy

---

## Capacitor (Optional - App Store)

For app store presence, wrap the PWA with Capacitor:

```bash
npm install @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android
npx cap init RateRight com.rateright.growth
npx cap add ios
npx cap add android
```

This would add:
- Native iOS app (App Store)
- Native Android app (Play Store)
- Native push notifications
- Native splash screen
- Better offline storage

**Effort:** Additional 4-6 hours
**Cost:** $99/year (Apple Developer), $25 one-time (Google Play)

---

## Implementation Order

1. [ ] Step 1.1: Create app icons
2. [ ] Step 1.2: Create manifest.json
3. [ ] Step 1.3: Update index.html meta tags
4. [ ] Step 1.4: Install vite-plugin-pwa
5. [ ] Step 1.5: Create InstallPrompt component
6. [ ] Step 1.6: Add InstallPrompt to App
7. [ ] Step 2.1: Create offline page
8. [ ] Step 2.2: Configure Workbox for offline
9. [ ] Step 2.3: Add OfflineIndicator component
10. [ ] Step 3.1: Set up Firebase (optional)
11. [ ] Step 3.2: Push notification service (optional)
12. [ ] Step 3.3: Push token registration (optional)
13. [ ] Step 3.4: Frontend push registration (optional)
14. [ ] Step 3.5: Push settings UI (optional)
15. [ ] End-to-end testing

---

## Estimated Time

| Phase | Hours |
|-------|-------|
| Phase 1: Basic PWA Setup | 2-3 |
| Phase 2: Offline Support | 1-2 |
| Phase 3: Push Notifications | 2-3 (optional) |
| **Total** | **5-8 hours** |

---

## Ongoing Costs

| Item | Monthly Cost |
|------|--------------|
| Firebase (free tier) | $0 |
| PWA infrastructure | $0 |
| **Total** | **$0/mo** |

---

*Plan created: Jan 18, 2026*
