diff --git a/FCM_IMPLEMENTATION_NOTES.md b/FCM_IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..f4dc7d8 --- /dev/null +++ b/FCM_IMPLEMENTATION_NOTES.md @@ -0,0 +1,311 @@ +# FCM PWA Implementation Notes +_Reference for applying FCM fixes to other projects_ + +--- + +## Part 1 — Guide Key Points (fcm_details.txt) + +### How FCM works (the correct flow) +1. User grants notification permission +2. Firebase generates a unique FCM token for the device +3. Token is stored on your server for targeting +4. Server sends push requests to Firebase +5. Firebase delivers notifications to the device +6. Service worker handles display and click interactions + +### Common vibe-coding failures with FCM + +**1. Service worker confusion** +Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail. + +**2. Deprecated API usage** +Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is: +```javascript +const token = await messaging.getToken({ + vapidKey: VAPID_KEY, + serviceWorkerRegistration: registration +}); +``` + +**3. Token generation without durable storage** +Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart. + +**4. Poor permission flow** +Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit. + +**5. Missing notificationclick handler** +Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app. + +**6. Silent failures** +Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback. + +**7. iOS blind spots** +iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop. + +### Correct `getToken()` pattern (from guide) +```javascript +// Register SW first, then pass it directly to getToken +const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); +const token = await getToken(messaging, { + vapidKey: VAPID_KEY, + serviceWorkerRegistration: registration +}); +if (!token) throw new Error('getToken() returned empty — check VAPID key and SW'); +``` + +### Correct `firebase-messaging-sw.js` pattern (from guide) +```javascript +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'); + +firebase.initializeApp({ /* config */ }); +const messaging = firebase.messaging(); + +messaging.onBackgroundMessage((payload) => { + self.registration.showNotification(payload.notification.title, { + body: payload.notification.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + tag: 'fcm-notification', + data: payload.data + }); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + if (event.action === 'close') return; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) return client.focus(); + } + if (clients.openWindow) return clients.openWindow('/'); + }) + ); +}); +``` + +--- + +## Part 2 — Code Fixes Applied to fcm-app + +### app.js fixes + +**Fix: `showUserInfo()` missing** +Function was called on login and session restore but never defined — crashed immediately on login. +```javascript +function showUserInfo() { + document.getElementById('loginForm').style.display = 'none'; + document.getElementById('userInfo').style.display = 'block'; + document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser; +} +``` + +**Fix: `setupApp()` wrong element IDs** +`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML. +```javascript +// Wrong +document.getElementById('sendNotification').addEventListener('click', sendNotification); +// Fixed +document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification); +// Also added id="logoutBtn" to the logout button in index.html +``` + +**Fix: `logout()` not clearing localStorage** +Session was restored on next page load even after logout. +```javascript +function logout() { + currentUser = null; + fcmToken = null; + localStorage.removeItem('currentUser'); // was missing + // ... +} +``` + +**Fix: Race condition in messaging initialization** +`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise: +```javascript +let initPromise = null; +function initializeFirebase() { + if (initPromise) return initPromise; + initPromise = navigator.serviceWorker.register('/sw.js') + .then((registration) => { + swRegistration = registration; + messaging = firebase.messaging(); + }) + .catch((error) => { initPromise = null; throw error; }); + return initPromise; +} +// In login(): +await initializeFirebase(); // ensures messaging is ready before getToken() +``` + +**Fix: `deleteToken()` invalidating tokens on every page load** +`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0. +Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`. + +**Fix: Session restore without re-registering token** +When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token. +```javascript +// In setupApp(), after restoring session: +if (Notification.permission === 'granted') { + initializeFirebase() + .then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration })) + .then(token => { if (token) return registerToken(currentUser, token); }) + .catch(err => console.error('Token refresh on session restore failed:', err)); +} +``` + +**Fix: Deprecated VAPID/SW API replaced** +```javascript +// Removed (deprecated): +messaging.usePublicVapidKey(VAPID_KEY); +messaging.useServiceWorker(registration); +const token = await messaging.getToken(); + +// Replaced with: +const VAPID_KEY = 'your-vapid-key'; +let swRegistration = null; +// swRegistration set inside initializeFirebase() .then() +const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }); +``` + +**Fix: Null token guard** +`getToken()` can return null — passing null to the server produced a confusing 400 error. +```javascript +if (!token) { + throw new Error('getToken() returned empty — check VAPID key and service worker'); +} +``` + +**Fix: Error message included server response** +```javascript +// Before: throw new Error('Failed to register token'); +// After: +throw new Error(`Server returned ${response.status}: ${errorText}`); +``` + +**Fix: Duplicate foreground message handlers** +`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners. +```javascript +let foregroundHandlerSetup = false; +function handleForegroundMessages() { + if (foregroundHandlerSetup) return; + foregroundHandlerSetup = true; + messaging.onMessage(/* ... */); +} +``` + +**Fix: `login()` event.preventDefault() crash** +Button called `login()` with no argument, so `event.preventDefault()` threw on undefined. +```javascript +async function login(event) { + if (event) event.preventDefault(); // guard added +``` + +**Fix: `firebase-messaging-sw.js` redirect stub replaced** +File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above). + +**Fix: `notificationclick` handler added to `sw.js`** +Clicking a background notification did nothing. Handler added to focus existing window or open a new one. + +**Fix: CDN URLs removed from `urlsToCache` in `sw.js`** +External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install. +```javascript +// Removed from urlsToCache: +// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js', +// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js' +``` + +### server.js fixes + +**Fix: `icon`/`badge`/`tag` in wrong notification object** +These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`). +```javascript +// Wrong: +notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' } +// Fixed: +notification: { title, body }, +webpush: { + notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }, + // ... +} +``` + +**Fix: `saveTokens()` in route handler not crash-safe** +```javascript +try { + saveTokens(); +} catch (saveError) { + console.error('Failed to persist tokens to disk:', saveError); +} +``` + +**Fix: `setInterval(saveTokens)` uncaught exception crashed the server** +An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state. +```javascript +setInterval(() => { + try { saveTokens(); } + catch (error) { console.error('Auto-save tokens failed:', error); } +}, 30000); +``` + +--- + +## Part 3 — Docker / Infrastructure Fixes + +### Root cause of "no other users" bug +The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain: +1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`) +2. This propagated out of `setInterval` as an uncaught exception +3. Node.js exited the process +4. Docker restarted the container with empty state +5. Tokens were never on disk, so restart = all tokens lost + +### Dockerfile fix +```dockerfile +# Create non-root user AND a writable data directory (while still root) +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 && \ + mkdir -p /app/data && \ + chown nodejs:nodejs /app/data +``` +`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it. + +### docker-compose.yml fix +```yaml +services: + your-app: + volumes: + - app_data:/app/data # named volume survives container rebuilds + +volumes: + app_data: +``` +Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`. + +### server.js path fix +```javascript +// Changed from: +const TOKENS_FILE = './tokens.json'; +// To: +const TOKENS_FILE = './data/tokens.json'; +``` + +--- + +## Checklist for applying to another project + +- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub) +- [ ] `notificationclick` handler present in service worker +- [ ] CDN URLs NOT in `urlsToCache` in any service worker +- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()` +- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker` +- [ ] `deleteToken()` is NOT called on page load +- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'` +- [ ] Null/empty token check before sending to server +- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification` +- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval` +- [ ] Docker: data directory created with correct user ownership in Dockerfile +- [ ] Docker: named volume mounted for data directory in docker-compose.yml +- [ ] Duplicate foreground message handler registration is guarded diff --git a/backend/package.json b/backend/package.json index 2ad5582..d57bdcd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.6", + "version": "0.12.7", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js index 9139786..fab0568 100644 --- a/backend/src/routes/push.js +++ b/backend/src/routes/push.js @@ -47,6 +47,10 @@ async function sendPushToUser(schema, userId, payload) { groupId: payload.groupId ? String(payload.groupId) : '', }, android: { priority: 'high' }, + apns: { + headers: { 'apns-priority': '10' }, + payload: { aps: { contentAvailable: true } }, + }, webpush: { headers: { Urgency: 'high' } }, }); } catch (err) { @@ -141,6 +145,10 @@ router.post('/test', authMiddleware, async (req, res) => { const message = { token: sub.fcm_token, android: { priority: 'high' }, + apns: { + headers: { 'apns-priority': '10' }, + payload: { aps: { contentAvailable: true } }, + }, webpush: { headers: { Urgency: 'high' } }, }; diff --git a/build.sh b/build.sh index 868dd65..dd14fd8 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.6}" +VERSION="${1:-0.12.7}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/fcm_details.txt b/fcm_details.txt new file mode 100644 index 0000000..f92d2b7 --- /dev/null +++ b/fcm_details.txt @@ -0,0 +1,1013 @@ +How It Works +At a high level, push notifications follow a predictable sequence. + +User grants notification permission +Firebase generates a unique FCM token for the device +Token is stored on your server for targeting +Server sends push requests to Firebase +Firebase delivers notifications to the user’s device +Service worker handles notification display and user interactions +Where vibe coding commonly fails with Firebase +Recently, when I was building out a web app with Claude Code, I got great results until it came to FCM integration. My conversations with Claude started entering a loop and I realized that part of the reason was the version of docs it was following. There were few other issues I was able to identify: + +1. Service worker confusion +Auto generated setups often register multiple service workers or place Firebase logic in the wrong file. Sometimes the dedicated firebase-messaging-sw.js is not served from the root scope, or it gets replaced during a build. The result is predictable: FCM tokens appear to work, yet background notifications never arrive. + +2. Misconfig in development and production environments +The local configuration is different from that of production. Common patterns include using the wrong VAPID key, mixing Firebase projects, or forgetting to update service worker credentials after deployment. Notifications may work locally over HTTPS, then break entirely once the app is live. + +3. Token generation without durable storage +I also noticed that without a persistent storage, I was able to trigger notifications, but tokens disappear when users switch devices, clear storage, or reinstall the PWA. Worse, sometimes, the token is rarely tied to a user identity, making real notification targeting unreliable. + +4. Poor permission flow +Generated interfaces tend to request notification permission immediately on page load. Users often deny it, and the app provides no clear way to recover or explain the value of notifications. This permanently reduces deliverability. + +5. iOS blind spots +Most testing I did was in Chrome on Android or desktop. On iOS, notifications require the PWA to be added to the home screen, strict HTTPS configuration, and a correctly structured manifest. This was not clear until I went live. + +6. Backend mismatch for real notifications +Manual sending from the Firebase console always worked well. But when trying to get the backend to actually send messages, there are a number of additional steps of config need.. + +7. Silent failures instead of clear errors +Perhaps the biggest issue is invisibility. Tokens may be null, service workers may not register, or VAPID keys may be wrong, yet nothing clearly surfaces in the UI. This guide explicitly shows how to inspect each layer so failures become visible and fixable. + +Prerequisites +Before starting, ensure you have: + +HTTPS Website: Push notifications require secure connections +Service Worker: Essential for background notification handling +Web App Manifest: Defines how your PWA appears when installed +Node.js: For development and build tools +Firebase Account: For FCM services +Modern Browser: Chrome, Firefox, Safari, or Edge +Setting up a Project in Firebase Console +Step 1: Create Firebase Project +You begin by creating a Firebase project in the Firebase Console and registering your web application. + +Navigate to Firebase Console +Go to Firebase Console +Sign in with your Google account + +Create New Project +Click “Add project” → Enter project name → Continue + +Configure Google Analytics (Optional but recommended) +Choose existing account or create new → Select region → Accept terms + +Project Creation +Click “Create project” → Wait for setup completion + +Step 2: Add Web App to Project +Register Web App (Project Overview → Add app → Web (>) icon) + +App Configuration (App nickname: “Your PWA Name” → Firebase Hosting: ☑️ (optional) → Click “Register app”) + +Save Firebase Configuration + +// Copy this configuration object +const firebaseConfig = { + apiKey: "your-api-key", + authDomain: "your-project.firebaseapp.com", + projectId: "your-project-id", + storageBucket: "your-project.appspot.com", + messagingSenderId: "123456789012", + appId: "1:123456789012:web:abcdef123456" +}; +Step 3: Enable Cloud Messaging +Navigate to Cloud Messaging (Project settings → Cloud Messaging tab) + +Get Vijay’s stories in your inbox +Join Medium for free to get updates from this writer. + +Enter your email +Subscribe + +Remember me for faster sign in + +Generate VAPID Keys (Web configuration → Web Push certificates → Generate key pair) + +Important: Save the generated VAPID key — you’ll need it for client-side implementation. + +Setting up Service Accounts +If you do need service account setup, here is how to create one: + +Step 1: Create Service Account +Access Service Accounts in the Google Cloud Console: (Project settings → Service accounts tab) +Generate Private Key: (Firebase Admin SDK → Generate new private key → Generate key) +Download JSON File: A serviceAccountKey.json file will be downloaded +Security Warning: Never commit this file to version control! + +Step 2: Service Account JSON Structure +Your downloaded file will look like this: + +# json +{ + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-xxxxx@your-project.iam.gserviceaccount.com", + "client_id": "123456789012345678901", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-xxxxx%40your-project.iam.gserviceaccount.com" +} +Step 3: Secure Storage for Different Environments +For Supabase Edge Functions: + +sql +-- Store as encrypted secrets in Supabase +-- Dashboard → Project Settings → Edge Functions → Environment Variables + +FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account",...} +FIREBASE_PROJECT_ID=your-project-id +For Client-Side API Routes (Next.js): + +# Store in environment variables +VERCEL_PUBLIC_FIREBASE_SERVICE_ACCOUNT_KEY='{"type":"service_account",...}' +VERCEL_PUBLIC_FIREBASE_PROJECT_ID=your-project-id + +# Add to .gitignore +serviceAccountKey.json +.env.local +For Serverless Functions (Vercel, Netlify): + +# Store as environment variables in deployment platform +FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account",...} +FIREBASE_PROJECT_ID=your-project-id +Step 4: Integration Examples +Supabase Edge Function Example: + +// supabase/functions/send-notification/index.ts +import { serve } from "https://deno.land/std@0.168.0/http/server.ts" +import { initializeApp, cert } from "https://deno.land/x/firebase_admin@v1.0.0/mod.ts" + +const firebaseConfig = { + credential: cert(JSON.parse(Deno.env.get('FIREBASE_SERVICE_ACCOUNT_KEY')!)), + projectId: Deno.env.get('FIREBASE_PROJECT_ID') +} +const app = initializeApp(firebaseConfig) +serve(async (req) => { + const { userToken, title, body, data } = await req.json() + + try { + const message = { + token: userToken, + notification: { title, body }, + data: data || {} + } + + const response = await app.messaging().send(message) + return new Response(JSON.stringify({ success: true, messageId: response })) + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { status: 500 }) + } +}) +Server-Side API Route Example (Next.js): + +// pages/api/send-notification.js +import admin from 'firebase-admin' + +if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY)), + projectId: process.env.FIREBASE_PROJECT_ID + }) +} +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const { token, title, body, data } = req.body + + try { + const message = { + token, + notification: { title, body }, + data: data || {}, + webpush: { + fcmOptions: { + link: data?.url || '/' + } + } + } + + const response = await admin.messaging().send(message) + res.status(200).json({ success: true, messageId: response }) + } catch (error) { + res.status(500).json({ error: error.message }) + } +} +Environment Setup +Step 1: Install Dependencies +# For React/Vite PWA +npm install firebase vite-plugin-pwa workbox-window + +# For vanilla JavaScript +npm install firebase +Step 2: Environment Variables +Create .env file: + +# Client-side Firebase config +VITE_FIREBASE_API_KEY=your-api-key +VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=your-project-id +VITE_FIREBASE_STORAGE_BUCKET=your-project.appspot.com +VITE_FIREBASE_MESSAGING_SENDER_ID=123456789012 +VITE_FIREBASE_APP_ID=1:123456789012:web:abcdef123456 +VITE_WEBPUSH_NOTIFICATION_KEY=your-vapid-key +Implementation Steps +Step 1: Firebase Configuration +Create src/firebase/config.js: + +import { initializeApp } from 'firebase/app'; +import { getMessaging, getToken, onMessage } from 'firebase/messaging'; + +const firebaseConfig = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, + appId: import.meta.env.VITE_FIREBASE_APP_ID +}; +// Initialize Firebase +const app = initializeApp(firebaseConfig); +// Initialize Firebase Cloud Messaging +const messaging = getMessaging(app); +export { messaging, getToken, onMessage }; +Step 2: Service Worker Setup +Create public/firebase-messaging-sw.js: + +// Import Firebase scripts +importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-messaging-compat.js'); + +// Initialize Firebase in service worker +firebase.initializeApp({ + apiKey: "your-api-key", + authDomain: "your-project.firebaseapp.com", + projectId: "your-project-id", + storageBucket: "your-project.appspot.com", + messagingSenderId: "123456789012", + appId: "1:123456789012:web:abcdef123456" +}); +const messaging = firebase.messaging(); +// Handle background messages +messaging.onBackgroundMessage((payload) => { + console.log('Received background message:', payload); + + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/icon-192x192.png', + badge: '/badge-72x72.png', + tag: 'notification-tag', + data: payload.data, + actions: [ + { + action: 'open', + title: 'Open App' + }, + { + action: 'close', + title: 'Close' + } + ] + }; + self.registration.showNotification(notificationTitle, notificationOptions); +}); +// Handle notification clicks +self.addEventListener('notificationclick', (event) => { + console.log('Notification clicked:', event); + + event.notification.close(); + + if (event.action === 'close') { + return; + } + + // Navigate to app + event.waitUntil( + clients.matchAll({ type: 'window' }).then((clientList) => { + for (const client of clientList) { + if (client.url === '/' && 'focus' in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); +Step 3: Notification Permission Handler +Create src/utils/notificationPermission.js: + +import { messaging, getToken } from '../firebase/config'; + +const VAPID_KEY = import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY; +export function isNotificationSupported() { + return 'Notification' in window && 'serviceWorker' in navigator; +} +export async function requestNotificationPermission() { + if (!isNotificationSupported()) { + throw new Error('Notifications not supported'); + } + const permission = await Notification.requestPermission(); + + if (permission === 'granted') { + // Register service worker first + const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js'); + + // Get FCM token + const token = await getToken(messaging, { + vapidKey: VAPID_KEY, + serviceWorkerRegistration: registration + }); + + console.log('FCM Token:', token); + + // Send token to your server + await sendTokenToServer(token); + + return token; + } else { + throw new Error(`Permission denied: ${permission}`); + } +} +async function sendTokenToServer(token) { + try { + await fetch('/api/fcm-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token, userId: getCurrentUserId() }) + }); + } catch (error) { + console.error('Failed to send token to server:', error); + } +} +function getCurrentUserId() { + // Return current user ID from your auth system + return 'user-123'; +} +Step 4: FCM Token Service +Create src/services/FCMTokenService.js: + +import { messaging, getToken, onMessage } from '../firebase/config'; +import { requestNotificationPermission } from '../utils/notificationPermission'; + +export class FCMTokenService { + static async registerFCMToken() { + try { + if (Notification.permission === 'granted') { + const token = await getToken(messaging, { + vapidKey: import.meta.env.VITE_WEBPUSH_NOTIFICATION_KEY + }); + + if (token) { + await this.saveTokenToDatabase(token); + return true; + } + } + return false; + } catch (error) { + console.error('FCM token registration failed:', error); + return false; + } + } + static async saveTokenToDatabase(token) { + // Store token in your preferred storage solution + // Example with localStorage + localStorage.setItem('fcmToken', token); + localStorage.setItem('fcmTokenTimestamp', Date.now().toString()); + + // Or integrate with your database/API + console.log('FCM token saved:', token); + } + static setupForegroundNotifications() { + onMessage(messaging, (payload) => { + console.log('Foreground message received:', payload); + + // Display notification manually for foreground messages + if (Notification.permission === 'granted') { + new Notification(payload.notification.title, { + body: payload.notification.body, + icon: payload.notification.icon || '/icon-192x192.png', + data: payload.data + }); + } + }); + } + static async clearToken() { + try { + // Clear token from local storage + localStorage.removeItem('fcmToken'); + localStorage.removeItem('fcmTokenTimestamp'); + console.log('FCM token cleared'); + } catch (error) { + console.error('Failed to clear FCM token:', error); + } + } +} +function getAuthToken() { + // Return your auth token if using authentication + // For demo purposes, return a placeholder + return 'demo-auth-token'; +} +Step 5: React Component Integration +Create src/components/NotificationPermissionDialog.jsx: + +import React, { useState, useEffect } from 'react'; +import { FCMTokenService } from '../services/FCMTokenService'; +import { requestNotificationPermission, isNotificationSupported } from '../utils/notificationPermission'; + +export function NotificationPermissionDialog() { + const [permissionState, setPermissionState] = useState(Notification.permission); + const [showDialog, setShowDialog] = useState(false); + useEffect(() => { + // Show dialog if notifications are supported but not granted + if (isNotificationSupported() && Notification.permission === 'default') { + const timer = setTimeout(() => setShowDialog(true), 2000); + return () => clearTimeout(timer); + } + + // Set up foreground notifications if already granted + if (Notification.permission === 'granted') { + FCMTokenService.setupForegroundNotifications(); + FCMTokenService.registerFCMToken(); + } + }, []); + const handleEnableNotifications = async () => { + try { + await requestNotificationPermission(); + setPermissionState('granted'); + setShowDialog(false); + + // Set up notifications + FCMTokenService.setupForegroundNotifications(); + } catch (error) { + console.error('Failed to enable notifications:', error); + setPermissionState('denied'); + } + }; + const handleDismiss = () => { + setShowDialog(false); + }; + if (!showDialog || !isNotificationSupported()) { + return null; + } + return ( +
+ Get notified about important updates and messages. + You can change this setting anytime. +
+FCM Token: {token}
+ +