web-manager 4.3.1 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ class Notifications {
2
+ constructor(manager) {
3
+ this.manager = manager;
4
+ this._requestInProgress = false;
5
+ }
6
+
7
+ initialize(config) {
8
+ this._vapidKey = this.manager.config?.firebase?.messaging?.config?.vapidKey || null;
9
+
10
+ const storage = this.manager.storage();
11
+ const stored = storage.get('notifications');
12
+ const permission = typeof Notification !== 'undefined' ? Notification.permission : 'default';
13
+
14
+ console.log('[WM:push] Page load check:', { storedSubscribed: stored?.subscribed, storedToken: stored?.token?.slice(-8), permission });
15
+
16
+ // If localStorage says subscribed but browser permission disagrees, clear it
17
+ if (stored?.subscribed && permission !== 'granted') {
18
+ console.log('[WM:push] Clearing stale subscription — permission is', permission);
19
+ storage.set('notifications', { subscribed: false, token: null });
20
+ }
21
+
22
+ // Arm auto-request if not currently subscribed (including just-cleared)
23
+ const autoRequest = config?.autoRequest;
24
+ if ((!stored?.subscribed || permission !== 'granted') && autoRequest > 0) {
25
+ console.log('[WM:push] Arming auto-request (delay:', autoRequest + 'ms)');
26
+ this._setupAutoRequest(autoRequest);
27
+ }
28
+
29
+ // Listen for foreground messages (tab is focused)
30
+ if (permission === 'granted') {
31
+ console.log('[WM:push] Setting up foreground listener...', { supported: this.isSupported(), hasMessaging: !!this.manager.firebaseMessaging });
32
+ this.onMessage((payload) => {
33
+ console.log('[WM:push] Foreground message received:', payload);
34
+ }).then(unsub => {
35
+ console.log('[WM:push] Foreground listener registered:', typeof unsub === 'function' ? 'OK' : 'FAILED (got empty fn)');
36
+ });
37
+ }
38
+ }
39
+
40
+ _setupAutoRequest(delay) {
41
+ if (typeof document === 'undefined') {
42
+ return;
43
+ }
44
+
45
+ const handleClick = () => {
46
+ document.removeEventListener('click', handleClick);
47
+
48
+ setTimeout(() => {
49
+ console.log('[WM:push] Auto-requesting notification permissions...');
50
+ this.subscribe().catch(err => {
51
+ console.error('[WM:push] Auto-subscription failed:', err.message);
52
+ });
53
+ }, delay);
54
+ };
55
+
56
+ document.addEventListener('click', handleClick);
57
+ }
58
+
59
+ // Check if notifications are supported
60
+ isSupported() {
61
+ return 'Notification' in window &&
62
+ 'serviceWorker' in navigator &&
63
+ this.manager.firebaseMessaging !== undefined;
64
+ }
65
+
66
+ // Check if user is subscribed to notifications
67
+ async isSubscribed() {
68
+ try {
69
+ if (!this.isSupported()) {
70
+ return false;
71
+ }
72
+
73
+ return Notification.permission === 'granted';
74
+ } catch (error) {
75
+ console.error('Check subscription error:', error);
76
+ return false;
77
+ }
78
+ }
79
+
80
+ // Subscribe to push notifications
81
+ async subscribe(options = {}) {
82
+ try {
83
+ if (!this.isSupported()) {
84
+ throw new Error('Push notifications are not supported');
85
+ }
86
+
87
+ if (this._requestInProgress) {
88
+ throw new Error('Subscription request already in progress');
89
+ }
90
+
91
+ this._requestInProgress = true;
92
+
93
+ // Get Firebase messaging
94
+ const messaging = this.manager.firebaseMessaging;
95
+ if (!messaging) {
96
+ throw new Error('Firebase Messaging not initialized');
97
+ }
98
+
99
+ // Get service worker registration
100
+ const swRegistration = this.manager.state.serviceWorker;
101
+ if (!swRegistration) {
102
+ throw new Error('Service Worker not registered');
103
+ }
104
+
105
+ // Request notification permission if not already granted
106
+ if (Notification.permission === 'default') {
107
+ const permission = await Notification.requestPermission();
108
+ if (permission !== 'granted') {
109
+ throw new Error('Notification permission denied');
110
+ }
111
+ } else if (Notification.permission === 'denied') {
112
+ throw new Error('Notification permission denied');
113
+ }
114
+
115
+ // Get FCM token
116
+ const { getToken } = await import('firebase/messaging');
117
+ const tokenOptions = { serviceWorkerRegistration: swRegistration };
118
+ if (this._vapidKey) { tokenOptions.vapidKey = this._vapidKey; }
119
+ const token = await getToken(messaging, tokenOptions);
120
+
121
+ if (!token) {
122
+ throw new Error('Failed to get FCM token');
123
+ }
124
+
125
+ // Save subscription info
126
+ await this._saveSubscription(token);
127
+
128
+ // Track in local storage
129
+ const storage = this.manager.storage();
130
+ storage.set('notifications', {
131
+ subscribed: true,
132
+ token: token,
133
+ timestamp: new Date().toISOString(),
134
+ uid: this.manager.auth().getUser()?.uid || null
135
+ });
136
+
137
+ this._requestInProgress = false;
138
+ return { subscribed: true, token };
139
+
140
+ } catch (error) {
141
+ this._requestInProgress = false;
142
+ console.error('Subscribe error:', error);
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ // Unsubscribe from push notifications
148
+ async unsubscribe() {
149
+ try {
150
+ if (!this.isSupported()) {
151
+ return false;
152
+ }
153
+
154
+ const { deleteToken } = await import('firebase/messaging');
155
+ const messaging = this.manager.firebaseMessaging;
156
+
157
+ if (messaging) {
158
+ await deleteToken(messaging);
159
+ }
160
+
161
+ // Clear local storage
162
+ const storage = this.manager.storage();
163
+ storage.remove('notifications');
164
+
165
+ return true;
166
+ } catch (error) {
167
+ console.error('Unsubscribe error:', error);
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ // Request permission (without subscribing)
173
+ async requestPermission() {
174
+ try {
175
+ if (!this.isSupported()) {
176
+ throw new Error('Notifications not supported');
177
+ }
178
+
179
+ const permission = await Notification.requestPermission();
180
+ return permission === 'granted';
181
+ } catch (error) {
182
+ console.error('Request permission error:', error);
183
+ return false;
184
+ }
185
+ }
186
+
187
+ // Get current FCM token
188
+ async getToken() {
189
+ try {
190
+ if (!this.isSupported()) {
191
+ return null;
192
+ }
193
+
194
+ const messaging = this.manager.firebaseMessaging;
195
+ if (!messaging) {
196
+ return null;
197
+ }
198
+
199
+ const { getToken } = await import('firebase/messaging');
200
+ const swRegistration = this.manager.state.serviceWorker;
201
+
202
+ const tokenOptions = { serviceWorkerRegistration: swRegistration };
203
+ if (this._vapidKey) { tokenOptions.vapidKey = this._vapidKey; }
204
+ return await getToken(messaging, tokenOptions);
205
+ } catch (error) {
206
+ console.error('Get token error:', error);
207
+ return null;
208
+ }
209
+ }
210
+
211
+ // Listen for foreground messages
212
+ async onMessage(callback) {
213
+ try {
214
+ if (!this.isSupported()) {
215
+ return () => {};
216
+ }
217
+
218
+ const { onMessage } = await import('firebase/messaging');
219
+ const messaging = this.manager.firebaseMessaging;
220
+
221
+ if (!messaging) {
222
+ return () => {};
223
+ }
224
+
225
+ return onMessage(messaging, (payload) => {
226
+ console.log('Foreground message received:', payload);
227
+
228
+ // Extract notification data - handle both payload.notification and payload.data formats
229
+ const notificationData = payload.notification || payload.data || {};
230
+ const { title, body, icon, badge, image, click_action, url, tag } = notificationData;
231
+
232
+ // Determine the click URL (prioritize click_action, then url, then data.url)
233
+ const clickUrl = click_action || url || payload.data?.click_action || payload.data?.url;
234
+
235
+ // Show notification if we have at least a title
236
+ if (title) {
237
+ const notification = new Notification(title, {
238
+ body: body || '',
239
+ icon: icon || '/favicon.ico',
240
+ badge: badge,
241
+ image: image,
242
+ tag: tag || 'default',
243
+ data: { ...payload.data, clickUrl },
244
+ requireInteraction: true,
245
+ renotify: true
246
+ });
247
+
248
+ notification.onclick = (event) => {
249
+ event.preventDefault();
250
+
251
+ // Focus or open the target window
252
+ if (clickUrl) {
253
+ // Try to find an existing window/tab with this URL
254
+ window.focus();
255
+ window.open(clickUrl, '_blank');
256
+ } else {
257
+ // Just focus the current window if no URL
258
+ window.focus();
259
+ }
260
+
261
+ notification.close();
262
+ };
263
+ }
264
+
265
+ // Call the user's callback with the full payload
266
+ if (callback) {
267
+ callback(payload);
268
+ }
269
+ });
270
+ } catch (error) {
271
+ console.error('Message listener error:', error);
272
+ return () => {};
273
+ }
274
+ }
275
+
276
+ // Sync subscription when auth state changes or on page load.
277
+ // Re-fetches the current FCM token — if it changed (browser rotated it,
278
+ // service worker was re-registered, etc.), saves the new token to Firestore
279
+ // and updates localStorage. Clears the subscribed state if the token is gone.
280
+ async syncSubscription() {
281
+ try {
282
+ const storage = this.manager.storage();
283
+ const storedNotification = storage.get('notifications');
284
+
285
+ const permission = typeof Notification !== 'undefined' ? Notification.permission : 'default';
286
+
287
+ console.log('[WM:push:sync] Starting sync:', { storedSubscribed: storedNotification?.subscribed, storedToken: storedNotification?.token?.slice(-8), permission });
288
+
289
+ if (permission !== 'granted') {
290
+ if (storedNotification?.subscribed) {
291
+ console.log('[WM:push:sync] Permission not granted — clearing localStorage');
292
+ storage.set('notifications', { subscribed: false, token: null });
293
+ } else {
294
+ console.log('[WM:push:sync] Permission not granted and not subscribed — nothing to do');
295
+ }
296
+ return false;
297
+ }
298
+
299
+ // Permission is granted — check if there's a live token (covers localStorage cleared, new browser, etc.)
300
+ const currentToken = await this.getToken();
301
+
302
+ if (!currentToken) {
303
+ console.log('[WM:push:sync] Token fetch returned null — clearing localStorage');
304
+ storage.set('notifications', { subscribed: false, token: null });
305
+ return false;
306
+ }
307
+
308
+ console.log('[WM:push:sync] Token valid:', currentToken.slice(-8), storedNotification?.token ? (storedNotification.token.slice(-8) === currentToken.slice(-8) ? '(unchanged)' : '(CHANGED from ' + storedNotification.token.slice(-8) + ')') : '(recovered — localStorage was empty)');
309
+
310
+ await this._saveSubscription(currentToken);
311
+
312
+ const user = this.manager.auth().getUser();
313
+ storage.set('notifications', {
314
+ subscribed: true,
315
+ token: currentToken,
316
+ uid: user?.uid || null,
317
+ timestamp: new Date().toISOString(),
318
+ });
319
+
320
+ console.log('[WM:push:sync] Sync complete — subscribed');
321
+ return true;
322
+ } catch (error) {
323
+ console.error('[WM:push:sync] Sync error:', error);
324
+ return false;
325
+ }
326
+ }
327
+
328
+ // Save subscription to Firestore
329
+ async _saveSubscription(token) {
330
+ try {
331
+ const firestore = this.manager.firestore();
332
+ const user = this.manager.auth().getUser();
333
+ const storage = this.manager.storage();
334
+
335
+ if (!token) {
336
+ return;
337
+ }
338
+
339
+ const now = new Date();
340
+ const timestamp = now.toISOString();
341
+ const timestampUNIX = Math.floor(now.getTime() / 1000);
342
+
343
+ // Get context for client information
344
+ const context = this.manager.utilities().getContext();
345
+ const clientData = context.client;
346
+
347
+ // Reference to the notification document (ID is the token)
348
+ const notificationDoc = firestore.doc(`notifications/${token}`);
349
+
350
+ // Check if document already exists
351
+ const existingDoc = await notificationDoc.get();
352
+ const existingData = existingDoc.exists() ? existingDoc.data() : null;
353
+
354
+ // Determine if we need to update
355
+ const currentUid = user?.uid || null;
356
+ const existingOwner = existingData?.owner || null;
357
+ const needsUpdate = existingOwner !== currentUid;
358
+
359
+ // Create or update the document as needed
360
+ if (!existingData) {
361
+ // New subscription - create the document
362
+ await notificationDoc.set({
363
+ token,
364
+ owner: currentUid,
365
+ tags: ['general'],
366
+ attribution: storage.get('attribution', {}),
367
+ context: { client: clientData },
368
+ metadata: {
369
+ created: { timestamp, timestampUNIX },
370
+ updated: { timestamp, timestampUNIX },
371
+ },
372
+ });
373
+ } else if (needsUpdate) {
374
+ // Existing subscription needs update (userId changed)
375
+ // Use dot-notation to avoid overwriting metadata.created
376
+ await notificationDoc.update({
377
+ owner: currentUid,
378
+ context: { client: clientData },
379
+ 'metadata.updated': { timestamp, timestampUNIX },
380
+ });
381
+ }
382
+ // If no update needed, do nothing
383
+
384
+ } catch (error) {
385
+ console.error('Save subscription error:', error);
386
+ // Don't throw - this is not critical for the subscription process
387
+ }
388
+ }
389
+ }
390
+
391
+ export default Notifications;
@@ -0,0 +1,199 @@
1
+ // Helper functions
2
+ function isLighthouse() {
3
+ try {
4
+ return typeof navigator !== 'undefined' && navigator.userAgent?.includes('Lighthouse');
5
+ } catch (e) {
6
+ return false;
7
+ }
8
+ }
9
+
10
+ function isAutomatedBrowser() {
11
+ try {
12
+ return typeof navigator !== 'undefined' && navigator.webdriver === true;
13
+ } catch (e) {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ class mod {
19
+ constructor(manager) {
20
+ this.manager = manager;
21
+ this.initialized = false;
22
+ this.Sentry = null;
23
+ this.config = null;
24
+ }
25
+
26
+ /**
27
+ * Initialize Sentry error tracking
28
+ * @param {Object} config - Sentry configuration object
29
+ * @returns {Promise} Resolves when initialization is complete
30
+ */
31
+ init(config = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ // Dynamically import Sentry to reduce initial bundle size
34
+ import('@sentry/browser')
35
+ .then((mod) => {
36
+ // Store reference and expose globally
37
+ this.Sentry = mod;
38
+
39
+ // Add to window if window is defined
40
+ if (typeof window !== 'undefined') {
41
+ window.Sentry = mod;
42
+ }
43
+
44
+ // Build configuration with our defaults
45
+ this.config = this._buildConfig(config);
46
+
47
+ // Initialize Sentry
48
+ this.Sentry.init(this.config);
49
+ this.initialized = true;
50
+
51
+ resolve({ initialized: true });
52
+ })
53
+ .catch((error) => {
54
+ console.error('[Sentry] Failed to initialize:', error);
55
+ reject(error);
56
+ });
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Build Sentry configuration with defaults and integrations
62
+ * @private
63
+ */
64
+ _buildConfig(userConfig) {
65
+ const config = { ...userConfig };
66
+ const manager = this.manager;
67
+
68
+ // Set release version and environment
69
+ config.release = `${manager.config.brand.id}@${manager.config.buildTime}`;
70
+ config.environment = manager.config.environment || 'production';
71
+ config.integrations = config.integrations || [];
72
+
73
+ // Add browser tracing integration if not already present
74
+ if (!config.integrations.some(i => i.name === 'BrowserTracing')) {
75
+ config.integrations.push(this.Sentry.browserTracingIntegration());
76
+ }
77
+
78
+ // Add replay integration if sample rates are configured
79
+ const hasReplays = (config.replaysSessionSampleRate > 0) ||
80
+ (config.replaysOnErrorSampleRate > 0);
81
+
82
+ if (hasReplays && !config.integrations.some(i => i.name === 'Replay')) {
83
+ config.integrations.push(this.Sentry.replayIntegration({
84
+ maskAllText: false,
85
+ blockAllMedia: false,
86
+ }));
87
+ }
88
+
89
+ // Configure beforeSend to enrich events with user data and session info
90
+ config.beforeSend = (event, hint) => {
91
+ const startTime = this.manager.config.page.startTime || Date.now();
92
+ const hoursSinceStart = (Date.now() - startTime) / (1000 * 3600);
93
+ const storage = this.manager.storage();
94
+
95
+ // Add custom tags
96
+ event.tags = {
97
+ ...event.tags,
98
+ 'process.type': 'browser',
99
+ 'usage.session.hours': hoursSinceStart.toFixed(2),
100
+ };
101
+
102
+ // Add user info from storage
103
+ event.user = {
104
+ ...event.user,
105
+ email: storage.get('user.auth.email', ''),
106
+ uid: storage.get('user.auth.uid', ''),
107
+ };
108
+
109
+ // Log error to console for debugging
110
+ console.error('[Sentry] Caught error:', {
111
+ message: event.message || (event.exception?.values?.[0]?.value) || 'Unknown error',
112
+ level: event.level,
113
+ tags: event.tags,
114
+ user: event.user,
115
+ hint
116
+ });
117
+
118
+ // Block sending in development mode
119
+ if (this.manager.isDevelopment()) {
120
+ console.log('[Sentry] Development mode - not sending to Sentry');
121
+ return null;
122
+ }
123
+
124
+ // Block sending if Lighthouse is running
125
+ if (isLighthouse()) {
126
+ console.log('[Sentry] Lighthouse detected - not sending to Sentry');
127
+ return null;
128
+ }
129
+
130
+ // Block sending if automated browser (Selenium, Puppeteer, etc.)
131
+ if (isAutomatedBrowser()) {
132
+ console.log('[Sentry] Automated browser detected - not sending to Sentry');
133
+ return null;
134
+ }
135
+
136
+ return event;
137
+ };
138
+
139
+ return config;
140
+ }
141
+
142
+ /**
143
+ * Capture an exception and send to Sentry
144
+ * Safe to call even if Sentry is not initialized
145
+ * @param {Error} error - The error to capture
146
+ * @param {Object} captureContext - Additional context for the error
147
+ * @returns {string|null} Event ID if successful, null otherwise
148
+ */
149
+ captureException(error, captureContext) {
150
+ // Log the error
151
+ console.error('[Sentry] Capturing exception:', error);
152
+
153
+ // Safe to call - won't throw if not initialized
154
+ if (!this.initialized) {
155
+ console.log('[Sentry] Not initialized, skipping capture');
156
+ return null;
157
+ }
158
+
159
+ // Call Sentry to capture the exception
160
+ try {
161
+ return this.Sentry.captureException(error, captureContext);
162
+ } catch (captureError) {
163
+ console.error('[Sentry] Failed to capture exception:', captureError);
164
+ return null;
165
+ }
166
+ }
167
+
168
+ // /**
169
+ // * Capture a message and send to Sentry
170
+ // * Safe to call even if Sentry is not initialized
171
+ // * @param {string} message - The message to capture
172
+ // * @param {string} level - Severity level (debug, info, warning, error, fatal)
173
+ // * @param {Object} captureContext - Additional context for the message
174
+ // * @returns {string|null} Event ID if successful, null otherwise
175
+ // */
176
+ // captureMessage(message, level = 'info', captureContext) {
177
+ // if (!message) {
178
+ // console.warn('[Sentry] captureMessage called with no message');
179
+ // return null;
180
+ // }
181
+
182
+ // console.log(`[Sentry] Capturing message (${level}):`, message);
183
+
184
+ // // Safe to call - won't throw if not initialized
185
+ // if (!this.initialized || !this.Sentry) {
186
+ // console.log('[Sentry] Not initialized, skipping capture');
187
+ // return null;
188
+ // }
189
+
190
+ // try {
191
+ // return this.Sentry.captureMessage(message, level, captureContext);
192
+ // } catch (captureError) {
193
+ // console.error('[Sentry] Failed to capture message:', captureError);
194
+ // return null;
195
+ // }
196
+ // }
197
+ }
198
+
199
+ export default mod;