1154 lines
32 KiB
Markdown
1154 lines
32 KiB
Markdown
# React Web Push Notifications - Frontend Implementation Guide
|
|
|
|
A complete guide for implementing web push notifications in your React application with the Dmail API.
|
|
|
|
## 🚀 Quick Start
|
|
|
|
### Prerequisites
|
|
|
|
- React 16.8+ (for hooks support)
|
|
- TypeScript (recommended)
|
|
- HTTPS-enabled domain (required for service workers)
|
|
- Modern browser support (Chrome 50+, Firefox 44+, Safari 16+, Edge 17+)
|
|
|
|
### Installation
|
|
|
|
```bash
|
|
# No additional dependencies needed - uses native Web Push API
|
|
# Optional: Install types for better TypeScript support
|
|
npm install --save-dev @types/web-push
|
|
```
|
|
|
|
## 📁 Project Structure
|
|
|
|
```
|
|
src/
|
|
├── hooks/
|
|
│ ├── usePushNotifications.ts
|
|
│ └── useServiceWorker.ts
|
|
├── components/
|
|
│ ├── PushNotificationPrompt.tsx
|
|
│ ├── NotificationSettings.tsx
|
|
│ └── PushNotificationButton.tsx
|
|
├── contexts/
|
|
│ └── PushNotificationContext.tsx
|
|
├── services/
|
|
│ └── pushNotificationService.ts
|
|
├── types/
|
|
│ └── pushNotifications.ts
|
|
└── utils/
|
|
└── vapidHelper.ts
|
|
public/
|
|
└── sw.js (Service Worker)
|
|
```
|
|
|
|
## 🔧 Implementation
|
|
|
|
### 1. Service Worker Setup
|
|
|
|
Create `public/sw.js`:
|
|
|
|
```javascript
|
|
// Service Worker for handling push notifications
|
|
const CACHE_NAME = "dmail-push-v1";
|
|
|
|
// Install event
|
|
self.addEventListener("install", (event) => {
|
|
console.log("Service Worker installing");
|
|
self.skipWaiting();
|
|
});
|
|
|
|
// Activate event
|
|
self.addEventListener("activate", (event) => {
|
|
console.log("Service Worker activating");
|
|
event.waitUntil(clients.claim());
|
|
});
|
|
|
|
// Push event handler
|
|
self.addEventListener("push", (event) => {
|
|
console.log("Push message received:", event);
|
|
|
|
if (!event.data) {
|
|
console.log("Push event but no data");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = event.data.json();
|
|
console.log("Push data:", data);
|
|
|
|
const options = {
|
|
body: data.body,
|
|
icon: data.icon || "/icons/email-icon-192.png",
|
|
badge: data.badge || "/icons/email-badge-72.png",
|
|
tag: data.tag || "email-notification",
|
|
requireInteraction: data.requireInteraction || true,
|
|
actions: data.actions || [
|
|
{
|
|
action: "view",
|
|
title: "View Email",
|
|
icon: "/icons/view-icon-32.png",
|
|
},
|
|
{
|
|
action: "dismiss",
|
|
title: "Dismiss",
|
|
},
|
|
],
|
|
data: {
|
|
...data.data,
|
|
timestamp: Date.now(),
|
|
},
|
|
vibrate: data.vibrate || [200, 100, 200],
|
|
silent: data.silent || false,
|
|
};
|
|
|
|
event.waitUntil(self.registration.showNotification(data.title, options));
|
|
} catch (error) {
|
|
console.error("Error parsing push data:", error);
|
|
|
|
// Show generic notification if parsing fails
|
|
event.waitUntil(
|
|
self.registration.showNotification("New Email", {
|
|
body: "You have received a new email",
|
|
icon: "/icons/email-icon-192.png",
|
|
tag: "generic-email",
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Notification click handler
|
|
self.addEventListener("notificationclick", (event) => {
|
|
console.log("Notification clicked:", event);
|
|
|
|
event.notification.close();
|
|
|
|
const action = event.action;
|
|
const data = event.notification.data;
|
|
|
|
if (action === "view") {
|
|
// Open the email in the app
|
|
event.waitUntil(clients.openWindow(data.url || "/inbox"));
|
|
} else if (action === "dismiss") {
|
|
// Just close the notification (already done above)
|
|
return;
|
|
} else {
|
|
// Default click action - open the app
|
|
event.waitUntil(clients.openWindow(data.url || "/"));
|
|
}
|
|
});
|
|
|
|
// Background sync (optional - for future enhancements)
|
|
self.addEventListener("sync", (event) => {
|
|
if (event.tag === "email-sync") {
|
|
event.waitUntil(
|
|
// Handle background email sync
|
|
console.log("Background sync triggered"),
|
|
);
|
|
}
|
|
});
|
|
```
|
|
|
|
### 2. TypeScript Types
|
|
|
|
Create `src/types/pushNotifications.ts`:
|
|
|
|
```typescript
|
|
export interface WebPushSubscription {
|
|
endpoint: string;
|
|
keys: {
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
}
|
|
|
|
export interface RegisterPushSubscriptionRequest {
|
|
subscription: WebPushSubscription;
|
|
deviceType?: string;
|
|
deviceName?: string;
|
|
}
|
|
|
|
export interface PushNotificationSettings {
|
|
enabled: boolean;
|
|
subscriptionCount: number;
|
|
lastPushNotificationAt?: string;
|
|
serviceEnabled: boolean;
|
|
vapidPublicKey: string;
|
|
}
|
|
|
|
export interface TestNotificationRequest {
|
|
message?: string;
|
|
}
|
|
|
|
export interface PushNotificationInfo {
|
|
pushNotificationsEnabled: boolean;
|
|
subscriptionCount: number;
|
|
lastPushNotificationAt?: string;
|
|
serviceEnabled: boolean;
|
|
vapidPublicKey: string;
|
|
serviceInfo: {
|
|
enabled: boolean;
|
|
configured: boolean;
|
|
vapidPublicKey: string;
|
|
ttl: number;
|
|
urgency: string;
|
|
};
|
|
setup: {
|
|
instructions: string;
|
|
documentation: {
|
|
webPushAPI: string;
|
|
serviceWorkers: string;
|
|
};
|
|
};
|
|
}
|
|
|
|
export interface PushNotificationContextValue {
|
|
isSupported: boolean;
|
|
isSubscribed: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
subscriptionCount: number;
|
|
subscribe: () => Promise<void>;
|
|
unsubscribe: () => Promise<void>;
|
|
testNotification: () => Promise<void>;
|
|
updateSettings: (enabled: boolean) => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
```
|
|
|
|
### 3. API Service
|
|
|
|
Create `src/services/pushNotificationService.ts`:
|
|
|
|
```typescript
|
|
import { WebPushSubscription, RegisterPushSubscriptionRequest, PushNotificationInfo, TestNotificationRequest } from "../types/pushNotifications";
|
|
|
|
class PushNotificationService {
|
|
private baseURL: string;
|
|
private getAuthToken: () => string | null;
|
|
|
|
constructor(baseURL: string, getAuthToken: () => string | null) {
|
|
this.baseURL = baseURL;
|
|
this.getAuthToken = getAuthToken;
|
|
}
|
|
|
|
private async makeRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
const token = this.getAuthToken();
|
|
|
|
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
|
...options,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
...options.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async getVAPIDPublicKey(): Promise<{ vapidPublicKey: string }> {
|
|
return this.makeRequest("/users/push-notifications/vapid-key");
|
|
}
|
|
|
|
async registerSubscription(data: RegisterPushSubscriptionRequest): Promise<any> {
|
|
return this.makeRequest("/users/push-subscriptions", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async removeSubscription(endpoint: string): Promise<any> {
|
|
return this.makeRequest("/users/push-subscriptions", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ endpoint }),
|
|
});
|
|
}
|
|
|
|
async updateSettings(enabled: boolean): Promise<any> {
|
|
return this.makeRequest("/users/push-notifications/settings", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
async getNotificationInfo(): Promise<PushNotificationInfo> {
|
|
return this.makeRequest("/users/push-notifications/info");
|
|
}
|
|
|
|
async testNotification(data: TestNotificationRequest = {}): Promise<any> {
|
|
return this.makeRequest("/users/push-notifications/test", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
}
|
|
|
|
export default PushNotificationService;
|
|
```
|
|
|
|
### 4. Utility Helper
|
|
|
|
Create `src/utils/vapidHelper.ts`:
|
|
|
|
```typescript
|
|
export 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);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
export function getBrowserInfo(): string {
|
|
const userAgent = navigator.userAgent;
|
|
let browserName = "Unknown";
|
|
|
|
if (userAgent.includes("Chrome")) {
|
|
browserName = "Chrome";
|
|
} else if (userAgent.includes("Firefox")) {
|
|
browserName = "Firefox";
|
|
} else if (userAgent.includes("Safari")) {
|
|
browserName = "Safari";
|
|
} else if (userAgent.includes("Edge")) {
|
|
browserName = "Edge";
|
|
} else if (userAgent.includes("Opera")) {
|
|
browserName = "Opera";
|
|
}
|
|
|
|
return `${browserName} on ${navigator.platform}`;
|
|
}
|
|
|
|
export function isPushSupported(): boolean {
|
|
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
|
}
|
|
```
|
|
|
|
### 5. Service Worker Hook
|
|
|
|
Create `src/hooks/useServiceWorker.ts`:
|
|
|
|
```typescript
|
|
import { useEffect, useState, useCallback } from "react";
|
|
|
|
interface UseServiceWorkerReturn {
|
|
registration: ServiceWorkerRegistration | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
register: () => Promise<void>;
|
|
}
|
|
|
|
export function useServiceWorker(scriptURL = "/sw.js"): UseServiceWorkerReturn {
|
|
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const register = useCallback(async () => {
|
|
if (!("serviceWorker" in navigator)) {
|
|
setError("Service Workers are not supported in this browser");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
const reg = await navigator.serviceWorker.register(scriptURL);
|
|
|
|
// Wait for the service worker to be ready
|
|
await navigator.serviceWorker.ready;
|
|
|
|
setRegistration(reg);
|
|
console.log("Service Worker registered successfully:", reg);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to register service worker";
|
|
setError(errorMessage);
|
|
console.error("Service Worker registration failed:", err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [scriptURL]);
|
|
|
|
useEffect(() => {
|
|
register();
|
|
}, [register]);
|
|
|
|
return { registration, isLoading, error, register };
|
|
}
|
|
```
|
|
|
|
### 6. Push Notifications Hook
|
|
|
|
Create `src/hooks/usePushNotifications.ts`:
|
|
|
|
```typescript
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { WebPushSubscription } from "../types/pushNotifications";
|
|
import PushNotificationService from "../services/pushNotificationService";
|
|
import { urlBase64ToUint8Array, getBrowserInfo, isPushSupported } from "../utils/vapidHelper";
|
|
|
|
interface UsePushNotificationsProps {
|
|
apiService: PushNotificationService;
|
|
}
|
|
|
|
interface UsePushNotificationsReturn {
|
|
isSupported: boolean;
|
|
isSubscribed: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
subscriptionCount: number;
|
|
permission: NotificationPermission;
|
|
subscribe: () => Promise<void>;
|
|
unsubscribe: () => Promise<void>;
|
|
testNotification: () => Promise<void>;
|
|
checkSubscriptionStatus: () => Promise<void>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
export function usePushNotifications({ apiService }: UsePushNotificationsProps): UsePushNotificationsReturn {
|
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [subscriptionCount, setSubscriptionCount] = useState(0);
|
|
const [permission, setPermission] = useState<NotificationPermission>("default");
|
|
|
|
const isSupported = isPushSupported();
|
|
|
|
const clearError = useCallback(() => {
|
|
setError(null);
|
|
}, []);
|
|
|
|
const checkSubscriptionStatus = useCallback(async () => {
|
|
if (!isSupported) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
|
|
// Check browser subscription
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
setIsSubscribed(!!subscription);
|
|
|
|
// Check server-side info
|
|
const info = await apiService.getNotificationInfo();
|
|
setSubscriptionCount(info.subscriptionCount);
|
|
|
|
// Check permission status
|
|
setPermission(Notification.permission);
|
|
} catch (err) {
|
|
console.error("Error checking subscription status:", err);
|
|
setError(err instanceof Error ? err.message : "Failed to check subscription status");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [apiService, isSupported]);
|
|
|
|
const subscribe = useCallback(async (): Promise<void> => {
|
|
if (!isSupported) {
|
|
throw new Error("Push notifications are not supported in this browser");
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
// Request notification permission
|
|
const permission = await Notification.requestPermission();
|
|
setPermission(permission);
|
|
|
|
if (permission !== "granted") {
|
|
throw new Error("Notification permission denied");
|
|
}
|
|
|
|
// Get VAPID public key
|
|
const { vapidPublicKey } = await apiService.getVAPIDPublicKey();
|
|
|
|
if (!vapidPublicKey) {
|
|
throw new Error("VAPID public key not available");
|
|
}
|
|
|
|
// Get service worker registration
|
|
const registration = await navigator.serviceWorker.ready;
|
|
|
|
// Create push subscription
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
});
|
|
|
|
// Register subscription with server
|
|
await apiService.registerSubscription({
|
|
subscription: subscription.toJSON() as WebPushSubscription,
|
|
deviceType: "web",
|
|
deviceName: getBrowserInfo(),
|
|
});
|
|
|
|
setIsSubscribed(true);
|
|
await checkSubscriptionStatus(); // Refresh subscription count
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to subscribe to push notifications";
|
|
setError(errorMessage);
|
|
throw new Error(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [apiService, isSupported, checkSubscriptionStatus]);
|
|
|
|
const unsubscribe = useCallback(async (): Promise<void> => {
|
|
if (!isSupported) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
|
|
if (subscription) {
|
|
// Unsubscribe from browser
|
|
await subscription.unsubscribe();
|
|
|
|
// Remove from server
|
|
await apiService.removeSubscription(subscription.endpoint);
|
|
}
|
|
|
|
setIsSubscribed(false);
|
|
await checkSubscriptionStatus(); // Refresh subscription count
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to unsubscribe from push notifications";
|
|
setError(errorMessage);
|
|
throw new Error(errorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [apiService, isSupported, checkSubscriptionStatus]);
|
|
|
|
const testNotification = useCallback(async (): Promise<void> => {
|
|
try {
|
|
setError(null);
|
|
await apiService.testNotification({
|
|
message: "Test notification from your React app!",
|
|
});
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to send test notification";
|
|
setError(errorMessage);
|
|
throw new Error(errorMessage);
|
|
}
|
|
}, [apiService]);
|
|
|
|
useEffect(() => {
|
|
if (isSupported) {
|
|
checkSubscriptionStatus();
|
|
} else {
|
|
setIsLoading(false);
|
|
}
|
|
}, [checkSubscriptionStatus, isSupported]);
|
|
|
|
return {
|
|
isSupported,
|
|
isSubscribed,
|
|
isLoading,
|
|
error,
|
|
subscriptionCount,
|
|
permission,
|
|
subscribe,
|
|
unsubscribe,
|
|
testNotification,
|
|
checkSubscriptionStatus,
|
|
clearError,
|
|
};
|
|
}
|
|
```
|
|
|
|
### 7. React Context Provider
|
|
|
|
Create `src/contexts/PushNotificationContext.tsx`:
|
|
|
|
```tsx
|
|
import React, { createContext, useContext, ReactNode } from "react";
|
|
import { usePushNotifications } from "../hooks/usePushNotifications";
|
|
import PushNotificationService from "../services/pushNotificationService";
|
|
import { PushNotificationContextValue } from "../types/pushNotifications";
|
|
|
|
const PushNotificationContext = createContext<PushNotificationContextValue | undefined>(undefined);
|
|
|
|
interface PushNotificationProviderProps {
|
|
children: ReactNode;
|
|
apiBaseURL: string;
|
|
getAuthToken: () => string | null;
|
|
}
|
|
|
|
export function PushNotificationProvider({ children, apiBaseURL, getAuthToken }: PushNotificationProviderProps) {
|
|
const apiService = new PushNotificationService(apiBaseURL, getAuthToken);
|
|
|
|
const { isSupported, isSubscribed, isLoading, error, subscriptionCount, subscribe, unsubscribe, testNotification, clearError } =
|
|
usePushNotifications({ apiService });
|
|
|
|
const updateSettings = async (enabled: boolean): Promise<void> => {
|
|
await apiService.updateSettings(enabled);
|
|
};
|
|
|
|
const value: PushNotificationContextValue = {
|
|
isSupported,
|
|
isSubscribed,
|
|
isLoading,
|
|
error,
|
|
subscriptionCount,
|
|
subscribe,
|
|
unsubscribe,
|
|
testNotification,
|
|
updateSettings,
|
|
clearError,
|
|
};
|
|
|
|
return <PushNotificationContext.Provider value={value}>{children}</PushNotificationContext.Provider>;
|
|
}
|
|
|
|
export function usePushNotificationContext(): PushNotificationContextValue {
|
|
const context = useContext(PushNotificationContext);
|
|
if (context === undefined) {
|
|
throw new Error("usePushNotificationContext must be used within a PushNotificationProvider");
|
|
}
|
|
return context;
|
|
}
|
|
```
|
|
|
|
### 8. React Components
|
|
|
|
#### Push Notification Button Component
|
|
|
|
Create `src/components/PushNotificationButton.tsx`:
|
|
|
|
```tsx
|
|
import React from "react";
|
|
import { usePushNotificationContext } from "../contexts/PushNotificationContext";
|
|
|
|
interface PushNotificationButtonProps {
|
|
className?: string;
|
|
enabledText?: string;
|
|
disabledText?: string;
|
|
loadingText?: string;
|
|
unsupportedText?: string;
|
|
}
|
|
|
|
export function PushNotificationButton({
|
|
className = "btn btn-primary",
|
|
enabledText = "Disable Notifications",
|
|
disabledText = "Enable Notifications",
|
|
loadingText = "Loading...",
|
|
unsupportedText = "Not Supported",
|
|
}: PushNotificationButtonProps) {
|
|
const { isSupported, isSubscribed, isLoading, error, subscribe, unsubscribe, clearError } = usePushNotificationContext();
|
|
|
|
const handleClick = async () => {
|
|
try {
|
|
clearError();
|
|
if (isSubscribed) {
|
|
await unsubscribe();
|
|
} else {
|
|
await subscribe();
|
|
}
|
|
} catch (err) {
|
|
// Error is handled by the context
|
|
console.error("Push notification action failed:", err);
|
|
}
|
|
};
|
|
|
|
if (!isSupported) {
|
|
return (
|
|
<button className={`${className} opacity-50 cursor-not-allowed`} disabled title="Push notifications are not supported in this browser">
|
|
{unsupportedText}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<button className={className} onClick={handleClick} disabled={isLoading}>
|
|
{isLoading ? loadingText : isSubscribed ? enabledText : disabledText}
|
|
</button>
|
|
|
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### Notification Settings Component
|
|
|
|
Create `src/components/NotificationSettings.tsx`:
|
|
|
|
```tsx
|
|
import React from "react";
|
|
import { usePushNotificationContext } from "../contexts/PushNotificationContext";
|
|
|
|
export function NotificationSettings() {
|
|
const { isSupported, isSubscribed, isLoading, error, subscriptionCount, subscribe, unsubscribe, testNotification, clearError } =
|
|
usePushNotificationContext();
|
|
|
|
const handleTest = async () => {
|
|
try {
|
|
clearError();
|
|
await testNotification();
|
|
alert("Test notification sent! Check your browser notifications.");
|
|
} catch (err) {
|
|
console.error("Test notification failed:", err);
|
|
}
|
|
};
|
|
|
|
if (!isSupported) {
|
|
return (
|
|
<div className="p-4 bg-yellow-100 border border-yellow-400 rounded-md">
|
|
<h3 className="text-lg font-semibold text-yellow-800">Push Notifications Not Supported</h3>
|
|
<p className="text-yellow-700">
|
|
Your browser doesn't support push notifications. Please use a modern browser like Chrome, Firefox, Safari 16+, or Edge.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 bg-white rounded-lg shadow-md">
|
|
<h3 className="text-xl font-semibold mb-4">📬 Email Notifications</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium">Desktop Notifications</p>
|
|
<p className="text-sm text-gray-600">Get notified instantly when new emails arrive, even when the app is closed.</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={isSubscribed ? unsubscribe : subscribe}
|
|
disabled={isLoading}
|
|
className={`px-4 py-2 rounded-md font-medium ${
|
|
isSubscribed ? "bg-red-600 hover:bg-red-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"
|
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
>
|
|
{isLoading ? "Loading..." : isSubscribed ? "Disable" : "Enable"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-3 bg-red-100 border border-red-400 rounded-md">
|
|
<p className="text-red-700">{error}</p>
|
|
<button onClick={clearError} className="text-red-600 hover:text-red-800 text-sm underline mt-1">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isSubscribed && (
|
|
<div className="space-y-3">
|
|
<div className="p-3 bg-green-100 border border-green-400 rounded-md">
|
|
<p className="text-green-700">
|
|
✅ Notifications are enabled! You have {subscriptionCount} active subscription{subscriptionCount !== 1 ? "s" : ""}.
|
|
</p>
|
|
</div>
|
|
|
|
<button onClick={handleTest} className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md font-medium">
|
|
Send Test Notification
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-xs text-gray-500">
|
|
<p>
|
|
🔒 Your notification preferences are stored securely and can be changed at any time. Notifications work even when your browser is closed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### Notification Prompt Component
|
|
|
|
Create `src/components/PushNotificationPrompt.tsx`:
|
|
|
|
```tsx
|
|
import React, { useState } from "react";
|
|
import { usePushNotificationContext } from "../contexts/PushNotificationContext";
|
|
|
|
interface PushNotificationPromptProps {
|
|
onDismiss?: () => void;
|
|
autoShow?: boolean;
|
|
}
|
|
|
|
export function PushNotificationPrompt({ onDismiss, autoShow = true }: PushNotificationPromptProps) {
|
|
const [isDismissed, setIsDismissed] = useState(false);
|
|
const { isSupported, isSubscribed, isLoading, subscribe, clearError } = usePushNotificationContext();
|
|
|
|
const handleEnable = async () => {
|
|
try {
|
|
clearError();
|
|
await subscribe();
|
|
} catch (err) {
|
|
console.error("Failed to enable notifications:", err);
|
|
}
|
|
};
|
|
|
|
const handleDismiss = () => {
|
|
setIsDismissed(true);
|
|
onDismiss?.();
|
|
};
|
|
|
|
// Don't show if not supported, already subscribed, or manually dismissed
|
|
if (!isSupported || isSubscribed || isDismissed || !autoShow) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 max-w-sm bg-white border border-gray-300 rounded-lg shadow-lg z-50">
|
|
<div className="p-4">
|
|
<div className="flex items-start space-x-3">
|
|
<div className="text-2xl">📧</div>
|
|
<div className="flex-1">
|
|
<h4 className="font-semibold text-gray-900">Stay Updated with Email Notifications</h4>
|
|
<p className="text-sm text-gray-600 mt-1">Get instant notifications when new emails arrive, even when the app is closed.</p>
|
|
</div>
|
|
<button onClick={handleDismiss} className="text-gray-400 hover:text-gray-600" aria-label="Close">
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex space-x-2 mt-4">
|
|
<button
|
|
onClick={handleEnable}
|
|
disabled={isLoading}
|
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-md text-sm font-medium disabled:opacity-50"
|
|
>
|
|
{isLoading ? "Enabling..." : "Enable Notifications"}
|
|
</button>
|
|
<button onClick={handleDismiss} className="px-3 py-2 text-gray-600 hover:text-gray-800 text-sm font-medium">
|
|
Not Now
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 9. App Integration
|
|
|
|
Update your main `App.tsx`:
|
|
|
|
```tsx
|
|
import React from "react";
|
|
import { PushNotificationProvider } from "./contexts/PushNotificationContext";
|
|
import { NotificationSettings } from "./components/NotificationSettings";
|
|
import { PushNotificationPrompt } from "./components/PushNotificationPrompt";
|
|
|
|
function App() {
|
|
// Replace with your actual API base URL and auth token logic
|
|
const apiBaseURL = process.env.REACT_APP_API_URL || "https://api.yourdomain.com";
|
|
const getAuthToken = () => localStorage.getItem("authToken");
|
|
|
|
return (
|
|
<PushNotificationProvider apiBaseURL={apiBaseURL} getAuthToken={getAuthToken}>
|
|
<div className="App">
|
|
{/* Your existing app content */}
|
|
<header className="App-header">
|
|
<h1>Dmail - Email Client</h1>
|
|
</header>
|
|
|
|
<main>
|
|
{/* Your existing routes and components */}
|
|
|
|
{/* Add notification settings to your settings page */}
|
|
<div className="container mx-auto p-4">
|
|
<NotificationSettings />
|
|
</div>
|
|
</main>
|
|
|
|
{/* Notification prompt - shows automatically for new users */}
|
|
<PushNotificationPrompt />
|
|
</div>
|
|
</PushNotificationProvider>
|
|
);
|
|
}
|
|
|
|
export default App;
|
|
```
|
|
|
|
## 🎨 Styling Examples
|
|
|
|
### Tailwind CSS Classes
|
|
|
|
The components use Tailwind CSS classes. If you're not using Tailwind, here are equivalent CSS styles:
|
|
|
|
```css
|
|
/* Button styles */
|
|
.btn {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: none;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: #3b82f6;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background-color: #2563eb;
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Notification prompt */
|
|
.notification-prompt {
|
|
position: fixed;
|
|
bottom: 1rem;
|
|
right: 1rem;
|
|
max-width: 24rem;
|
|
background: white;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
|
z-index: 50;
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* Error messages */
|
|
.error-message {
|
|
background-color: #fef2f2;
|
|
border: 1px solid #fecaca;
|
|
color: #dc2626;
|
|
padding: 0.75rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
/* Success messages */
|
|
.success-message {
|
|
background-color: #f0fdf4;
|
|
border: 1px solid #bbf7d0;
|
|
color: #16a34a;
|
|
padding: 0.75rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
```
|
|
|
|
## 🧪 Testing
|
|
|
|
### Testing Push Notifications
|
|
|
|
1. **Development Testing**:
|
|
|
|
```typescript
|
|
// Add to your component for testing
|
|
const { testNotification } = usePushNotificationContext();
|
|
|
|
const handleTest = async () => {
|
|
try {
|
|
await testNotification();
|
|
console.log("Test notification sent!");
|
|
} catch (error) {
|
|
console.error("Test failed:", error);
|
|
}
|
|
};
|
|
```
|
|
|
|
2. **Browser Developer Tools**:
|
|
- Open Chrome DevTools → Application → Service Workers
|
|
- Check if your service worker is registered and active
|
|
- Use Application → Storage → IndexedDB to inspect subscription data
|
|
|
|
3. **Manual Testing Checklist**:
|
|
- [ ] Service worker registers successfully
|
|
- [ ] Permission request works
|
|
- [ ] Subscription creates and stores properly
|
|
- [ ] Test notification displays correctly
|
|
- [ ] Notification actions work (click to open app)
|
|
- [ ] Unsubscribe removes subscription
|
|
- [ ] Works when browser is closed
|
|
|
|
## 📱 Icons and Assets
|
|
|
|
Add these icons to your `public/icons/` directory:
|
|
|
|
- `email-icon-192.png` - Main notification icon (192x192px)
|
|
- `email-badge-72.png` - Notification badge (72x72px)
|
|
- `view-icon-32.png` - Action button icon (32x32px)
|
|
|
|
## 🔧 Environment Variables
|
|
|
|
Create a `.env` file in your React app:
|
|
|
|
```bash
|
|
# API Configuration
|
|
REACT_APP_API_URL=https://api.yourdomain.com
|
|
|
|
# Optional: Enable debug mode
|
|
REACT_APP_DEBUG_PUSH=true
|
|
|
|
# Optional: Custom service worker path
|
|
REACT_APP_SW_PATH=/sw.js
|
|
```
|
|
|
|
## 🚀 Deployment Considerations
|
|
|
|
### HTTPS Requirement
|
|
|
|
- Push notifications require HTTPS in production
|
|
- Service workers only work over HTTPS (except on localhost)
|
|
- Ensure your domain has a valid SSL certificate
|
|
|
|
### Service Worker Caching
|
|
|
|
- The service worker file (`sw.js`) should not be cached by your CDN
|
|
- Add appropriate cache headers:
|
|
```
|
|
Cache-Control: no-cache, no-store, must-revalidate
|
|
```
|
|
|
|
### Build Configuration
|
|
|
|
- Ensure `sw.js` is copied to your build output
|
|
- Most React build tools automatically copy files from `public/`
|
|
|
|
## 🐛 Troubleshooting
|
|
|
|
### Common Issues
|
|
|
|
1. **Service Worker Not Registering**:
|
|
|
|
```javascript
|
|
// Check in browser console
|
|
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
|
console.log("Active registrations:", registrations);
|
|
});
|
|
```
|
|
|
|
2. **Push Subscription Fails**:
|
|
- Verify VAPID keys are correct
|
|
- Check browser permissions
|
|
- Ensure HTTPS is used
|
|
|
|
3. **Notifications Not Appearing**:
|
|
- Check browser notification settings
|
|
- Verify notification permission is granted
|
|
- Check if "Do Not Disturb" is enabled
|
|
|
|
4. **CORS Issues**:
|
|
- Ensure your API allows requests from your frontend domain
|
|
- Check CORS headers include your domain
|
|
|
|
### Debug Mode
|
|
|
|
Enable debug logging:
|
|
|
|
```typescript
|
|
// Add to your service or component
|
|
const DEBUG = process.env.REACT_APP_DEBUG_PUSH === "true";
|
|
|
|
if (DEBUG) {
|
|
console.log("Push notification debug info:", {
|
|
isSupported: isPushSupported(),
|
|
permission: Notification.permission,
|
|
serviceWorkerReady: "serviceWorker" in navigator,
|
|
});
|
|
}
|
|
```
|
|
|
|
## 🔒 Security Best Practices
|
|
|
|
1. **Validate Permissions**: Always check notification permission before attempting to subscribe
|
|
2. **Error Handling**: Implement proper error boundaries and user feedback
|
|
3. **Token Management**: Securely store and refresh authentication tokens
|
|
4. **Content Security Policy**: Configure CSP headers to allow service worker execution
|
|
5. **VAPID Keys**: Never expose private VAPID keys in frontend code
|
|
|
|
## 📈 Analytics and Monitoring
|
|
|
|
Track push notification metrics:
|
|
|
|
```typescript
|
|
// Example analytics tracking
|
|
const trackPushEvent = (event: string, data?: any) => {
|
|
// Replace with your analytics service
|
|
if (window.gtag) {
|
|
window.gtag("event", event, {
|
|
event_category: "push_notifications",
|
|
...data,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Usage in components
|
|
const { subscribe } = usePushNotificationContext();
|
|
|
|
const handleSubscribe = async () => {
|
|
try {
|
|
await subscribe();
|
|
trackPushEvent("push_subscription_success");
|
|
} catch (error) {
|
|
trackPushEvent("push_subscription_error", { error: error.message });
|
|
}
|
|
};
|
|
```
|
|
|
|
## 🚀 Performance Optimization
|
|
|
|
1. **Lazy Loading**: Load push notification components only when needed
|
|
2. **Service Worker Caching**: Cache API responses for better offline experience
|
|
3. **Bundle Splitting**: Separate push notification code into its own chunk
|
|
4. **Error Boundaries**: Prevent push notification errors from crashing the app
|
|
|
|
## 📋 Checklist for Production
|
|
|
|
- [ ] HTTPS enabled on production domain
|
|
- [ ] VAPID keys generated and configured
|
|
- [ ] Service worker file accessible at `/sw.js`
|
|
- [ ] Icons and assets optimized and available
|
|
- [ ] Error handling implemented
|
|
- [ ] Browser compatibility tested
|
|
- [ ] Permission request UX implemented
|
|
- [ ] Analytics tracking configured
|
|
- [ ] Backup notification strategy (email fallback)
|
|
|
|
## 🆘 Support and Resources
|
|
|
|
- [Web Push API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
|
|
- [Service Workers Guide](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
|
|
- [Notification API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API)
|
|
- [VAPID Specification](https://tools.ietf.org/html/rfc8292)
|
|
|
|
---
|
|
|
|
## 🎉 You're Ready!
|
|
|
|
This complete React implementation provides:
|
|
|
|
- ✅ Type-safe TypeScript integration
|
|
- ✅ Modern React hooks and context
|
|
- ✅ Comprehensive error handling
|
|
- ✅ Production-ready components
|
|
- ✅ Excellent user experience
|
|
- ✅ Full browser compatibility
|
|
|
|
Your users will now receive instant push notifications for new emails, even when your web app is closed! 🚀
|