featurely-site-manager 1.1.17 → 1.1.19

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.
package/CHANGELOG.md CHANGED
@@ -69,11 +69,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
69
69
  - **CRITICAL FIX**: Replaced Math.random() with crypto.getRandomValues() for CSPRNG session IDs and anonymous IDs
70
70
  - **CRITICAL FIX**: Added RegExp pattern escaping to prevent ReDoS (Regular Expression Denial of Service) attacks
71
71
  - Added error handling for invalid targetPages patterns
72
+
72
73
  ## [1.0.5] - 2026-03-15
73
74
 
74
75
  ### Security
75
76
 
76
- - **CRITICAL FIXAdded DOMPurify sanitization for customHtml maintenance pages to prevent XSS attacks
77
+ - \*\*CRITICAL FIXAdded DOMPurify sanitization for customHtml maintenance pages to prevent XSS attacks
77
78
  - Blocks dangerous tags (script, iframe, embed, object, etc.)
78
79
  - Removes event handlers (onerror, onload, onclick, etc.)
79
80
  - Prevents javascript: URIs and other malicious protocols
package/README.md CHANGED
@@ -179,8 +179,12 @@ const manager = new SiteManager({
179
179
  onUpdateRequired: (versionInfo) => {
180
180
  console.log("Update required:", versionInfo.latestVersion);
181
181
  // Force user to update (breaking changes)
182
- if (confirm(`Update required: ${versionInfo.latestVersion.title}\n\n${versionInfo.latestVersion.releaseNotes}`)) {
183
- window.location.href = versionInfo.latestVersion.downloadUrl || '/update';
182
+ if (
183
+ confirm(
184
+ `Update required: ${versionInfo.latestVersion.title}\n\n${versionInfo.latestVersion.releaseNotes}`
185
+ )
186
+ ) {
187
+ window.location.href = versionInfo.latestVersion.downloadUrl || "/update";
184
188
  }
185
189
  },
186
190
  });
@@ -255,12 +259,12 @@ manager.trackEvent("purchase_completed", {
255
259
 
256
260
  When `enableAnalytics` is `true` (default), the SDK automatically tracks:
257
261
 
258
- | Event | When | Properties |
259
- | --- | --- | --- |
260
- | `session_start` | On first page load | `path`, `title`, `referrer` |
261
- | `page_view` | On every page navigation | `path`, `title`, `referrer`, `isLoggedIn`, `userId?`, `userEmail?` |
262
- | `page_exit` | When navigating away from a page | `path`, `durationSeconds` |
263
- | `user_login` | First time `setUser()` is called with a userId | `userId`, `userEmail?`, `userName?` |
262
+ | Event | When | Properties |
263
+ | --------------- | ---------------------------------------------- | ------------------------------------------------------------------ |
264
+ | `session_start` | On first page load | `path`, `title`, `referrer` |
265
+ | `page_view` | On every page navigation | `path`, `title`, `referrer`, `isLoggedIn`, `userId?`, `userEmail?` |
266
+ | `page_exit` | When navigating away from a page | `path`, `durationSeconds` |
267
+ | `user_login` | First time `setUser()` is called with a userId | `userId`, `userEmail?`, `userName?` |
264
268
 
265
269
  Page tracking is SPA-compatible — it patches `history.pushState` and `history.replaceState` and listens to `popstate` so navigations in React, Next.js, Vue, and similar frameworks are captured automatically.
266
270
 
@@ -282,13 +286,13 @@ manager.init();
282
286
 
283
287
  The overlay panel has five tabs:
284
288
 
285
- | Tab | Contents |
286
- | --- | --- |
287
- | **SDK** | Environment, maintenance status, active messages, feature flag count |
288
- | **Logs** | Real-time log stream (info / warn / error); error badge on tab; Clear button |
289
- | **Network** | Config fetch log with status codes and timestamps |
290
- | **Events** | Analytics events queued/sent |
291
- | **Test** | Buttons to test analytics, errors, bugs, feature flags, flush, and refresh |
289
+ | Tab | Contents |
290
+ | ----------- | ---------------------------------------------------------------------------- |
291
+ | **SDK** | Environment, maintenance status, active messages, feature flag count |
292
+ | **Logs** | Real-time log stream (info / warn / error); error badge on tab; Clear button |
293
+ | **Network** | Config fetch log with status codes and timestamps |
294
+ | **Events** | Analytics events queued/sent |
295
+ | **Test** | Buttons to test analytics, errors, bugs, feature flags, flush, and refresh |
292
296
 
293
297
  #### Test Tab Actions
294
298
 
@@ -332,29 +336,29 @@ manager.init();
332
336
 
333
337
  ### SiteManagerConfig
334
338
 
335
- | Option | Type | Required | Default | Description |
336
- | ------------------------ | --------------- | -------- | ------------------------- | ----------------------------------------- |
337
- | `apiKey` | `string` | ✅ | - | Your Featurely API key |
338
- | `projectId` | `string` | ✅ | - | Your Featurely project ID |
339
+ | Option | Type | Required | Default | Description |
340
+ | ------------------------ | --------------- | -------- | ------------------------ | ----------------------------------------- |
341
+ | `apiKey` | `string` | ✅ | - | Your Featurely API key |
342
+ | `projectId` | `string` | ✅ | - | Your Featurely project ID |
339
343
  | `apiUrl` | `string` | ❌ | `'https://featurely.no'` | Custom API endpoint |
340
- | `pollInterval` | `number` | ❌ | `60000` | Config polling interval in ms |
341
- | `userEmail` | `string` | ❌ | - | User email for whitelist & targeting |
342
- | `userId` | `string` | ❌ | - | User ID for analytics & feature flags |
343
- | `bypassCheck` | `() => boolean` | ❌ | - | Custom maintenance bypass function |
344
- | `onMaintenanceEnabled` | `function` | ❌ | - | Maintenance enabled callback |
345
- | `onMaintenanceDisabled` | `function` | ❌ | - | Maintenance disabled callback |
346
- | `onMessageReceived` | `function` | ❌ | - | Message received callback |
347
- | `onMessageDismissed` | `function` | ❌ | - | Message dismissed callback |
348
- | `onFeatureFlagsUpdated` | `function` | ❌ | - | Feature flags updated callback |
349
- | `enableAnalytics` | `boolean` | ❌ | `true` | Enable analytics tracking |
350
- | `analyticsFlushInterval` | `number` | ❌ | `60000` | Analytics flush interval in ms |
351
- | `appVersion` | `string` | ❌ | - | Current app version for version checking |
352
- | `enableVersionCheck` | `boolean` | ❌ | `false` | Enable automatic version checking |
353
- | `versionCheckInterval` | `number` | ❌ | `3600000` | Version check interval in ms (1 hour) |
354
- | `onUpdateAvailable` | `function` | ❌ | - | Callback when update is available |
355
- | `onUpdateRequired` | `function` | ❌ | - | Callback when update is required (forced) |
356
- | `onError` | `function` | ❌ | - | Error callback |
357
- | `debugMode` | `boolean` | ❌ | `false` | Force-enable debug overlay locally |
344
+ | `pollInterval` | `number` | ❌ | `60000` | Config polling interval in ms |
345
+ | `userEmail` | `string` | ❌ | - | User email for whitelist & targeting |
346
+ | `userId` | `string` | ❌ | - | User ID for analytics & feature flags |
347
+ | `bypassCheck` | `() => boolean` | ❌ | - | Custom maintenance bypass function |
348
+ | `onMaintenanceEnabled` | `function` | ❌ | - | Maintenance enabled callback |
349
+ | `onMaintenanceDisabled` | `function` | ❌ | - | Maintenance disabled callback |
350
+ | `onMessageReceived` | `function` | ❌ | - | Message received callback |
351
+ | `onMessageDismissed` | `function` | ❌ | - | Message dismissed callback |
352
+ | `onFeatureFlagsUpdated` | `function` | ❌ | - | Feature flags updated callback |
353
+ | `enableAnalytics` | `boolean` | ❌ | `true` | Enable analytics tracking |
354
+ | `analyticsFlushInterval` | `number` | ❌ | `60000` | Analytics flush interval in ms |
355
+ | `appVersion` | `string` | ❌ | - | Current app version for version checking |
356
+ | `enableVersionCheck` | `boolean` | ❌ | `false` | Enable automatic version checking |
357
+ | `versionCheckInterval` | `number` | ❌ | `3600000` | Version check interval in ms (1 hour) |
358
+ | `onUpdateAvailable` | `function` | ❌ | - | Callback when update is available |
359
+ | `onUpdateRequired` | `function` | ❌ | - | Callback when update is required (forced) |
360
+ | `onError` | `function` | ❌ | - | Error callback |
361
+ | `debugMode` | `boolean` | ❌ | `false` | Force-enable debug overlay locally |
358
362
 
359
363
  ## 🎯 API Methods
360
364
 
package/dist/index.js CHANGED
@@ -115,8 +115,14 @@ var _SiteManager = class _SiteManager {
115
115
  if (this.config.debugMode) {
116
116
  this.setupDebugOverlay();
117
117
  }
118
- this.debugLog("info", `[init] v1.1.16 | project: ${this.config.projectId} | hostname: ${typeof window !== "undefined" ? window.location.hostname : "\u2014"}`);
119
- this.debugLog("info", `[init] analytics: ${this.config.enableAnalytics ? "on" : "off"} | poll: ${this.config.pollInterval}ms | debug: ${this.config.debugMode ? "on" : "off"}`);
118
+ this.debugLog(
119
+ "info",
120
+ `[init] v1.1.19 | project: ${this.config.projectId} | hostname: ${typeof window !== "undefined" ? window.location.hostname : "\u2014"}`
121
+ );
122
+ this.debugLog(
123
+ "info",
124
+ `[init] analytics: ${this.config.enableAnalytics ? "on" : "off"} | poll: ${this.config.pollInterval}ms | debug: ${this.config.debugMode ? "on" : "off"}`
125
+ );
120
126
  if (this.config.enableAnalytics) {
121
127
  this.debugLog("info", "[init] starting analytics + page tracking");
122
128
  this.startAnalyticsFlushing();
@@ -127,7 +133,10 @@ var _SiteManager = class _SiteManager {
127
133
  this.startPolling();
128
134
  this.debugLog("info", `[init] polling every ${this.config.pollInterval / 1e3}s`);
129
135
  if (this.config.enableVersionCheck && this.config.appVersion) {
130
- this.debugLog("info", `[init] checking version (current: ${this.config.appVersion})`);
136
+ this.debugLog(
137
+ "info",
138
+ `[init] checking version (current: ${this.config.appVersion})`
139
+ );
131
140
  await this.checkVersion();
132
141
  this.startVersionChecking();
133
142
  }
@@ -302,7 +311,10 @@ var _SiteManager = class _SiteManager {
302
311
  properties,
303
312
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
304
313
  });
305
- this.recentAnalyticsEvents.unshift({ name: eventName, ts: (/* @__PURE__ */ new Date()).toLocaleTimeString() });
314
+ this.recentAnalyticsEvents.unshift({
315
+ name: eventName,
316
+ ts: (/* @__PURE__ */ new Date()).toLocaleTimeString()
317
+ });
306
318
  if (this.recentAnalyticsEvents.length > 50) this.recentAnalyticsEvents.length = 50;
307
319
  this.debugLog("info", `trackEvent: ${eventName}`);
308
320
  if (this.analyticsQueue.length >= 10) {
@@ -324,7 +336,11 @@ var _SiteManager = class _SiteManager {
324
336
  }
325
337
  }
326
338
  );
327
- this.debugNetwork(`/api/public/v1/site-config`, response.status, Date.now() - fetchStartMs);
339
+ this.debugNetwork(
340
+ `/api/public/v1/site-config`,
341
+ response.status,
342
+ Date.now() - fetchStartMs
343
+ );
328
344
  if (!response.ok) {
329
345
  const errorData = await response.json().catch(() => ({}));
330
346
  const message = errorData.error || response.statusText;
@@ -357,20 +373,48 @@ var _SiteManager = class _SiteManager {
357
373
  delta.push(`flags: ${prevFlagCount}\u2192${newConfig.featureFlags.length}`);
358
374
  if (newConfig.messages.length !== prevMsgCount)
359
375
  delta.push(`messages: ${prevMsgCount}\u2192${newConfig.messages.length}`);
360
- this.debugLog("info", `[config] updated${delta.length ? ": " + delta.join(", ") : " (structure changed)"}`);
376
+ this.debugLog(
377
+ "info",
378
+ `[config] updated${delta.length ? ": " + delta.join(", ") : " (structure changed)"}`
379
+ );
361
380
  const wasMaintenanceEnabled = (_g = this.siteConfig) == null ? void 0 : _g.maintenance.enabled;
362
381
  const oldFeatureFlags = ((_h = this.siteConfig) == null ? void 0 : _h.featureFlags) || [];
363
382
  this.siteConfig = newConfig;
364
- if (newConfig.debugMode === true && !this.debugOverlayEl) {
383
+ const isGlobalDebug = newConfig.debugMode === true;
384
+ const hostname = typeof window !== "undefined" ? window.location.hostname : "";
385
+ const envs = newConfig.environments;
386
+ const matchedEnv = hostname ? envs == null ? void 0 : envs.find(
387
+ (e) => typeof e.url === "string" && e.url !== "" && e.url === hostname
388
+ ) : void 0;
389
+ const isEnvDebug = (matchedEnv == null ? void 0 : matchedEnv.debugEnabled) === true;
390
+ const shouldDebug = isGlobalDebug || isEnvDebug;
391
+ if (shouldDebug && !this.debugOverlayEl) {
392
+ if (isEnvDebug && !isGlobalDebug) {
393
+ this.debugLog(
394
+ "info",
395
+ `[env] matched environment "${matchedEnv.name}" (${hostname}) \u2014 enabling debug overlay`
396
+ );
397
+ }
365
398
  this.setupDebugOverlay();
399
+ } else if (!shouldDebug && this.debugOverlayEl) {
400
+ this.debugLog("info", "[debug] debug disabled by config \u2014 removing overlay");
401
+ this.stopDebugOverlay();
366
402
  }
367
- if (!this.debugOverlayEl) {
368
- const hostname = typeof window !== "undefined" ? window.location.hostname : "";
369
- const envs = newConfig.environments;
370
- const matchedEnv = hostname ? envs == null ? void 0 : envs.find((e) => typeof e.url === "string" && e.url !== "" && e.url === hostname) : void 0;
371
- if ((matchedEnv == null ? void 0 : matchedEnv.debugEnabled) === true) {
372
- this.debugLog("info", `[env] matched environment "${matchedEnv.name}" (${hostname})`);
373
- this.setupDebugOverlay();
403
+ if (matchedEnv && matchedEnv.analyticsEnabled === false) {
404
+ if (this.config.enableAnalytics) {
405
+ this.debugLog(
406
+ "info",
407
+ `[env] analytics disabled for environment "${matchedEnv.name}" (${hostname}) \u2014 stopping analytics`
408
+ );
409
+ this.stopAnalyticsFlushing();
410
+ }
411
+ } else if (matchedEnv && matchedEnv.analyticsEnabled !== false && this.config.enableAnalytics) {
412
+ if (!this.analyticsFlushIntervalId) {
413
+ this.debugLog(
414
+ "info",
415
+ `[env] analytics re-enabled for environment "${matchedEnv.name}" (${hostname})`
416
+ );
417
+ this.startAnalyticsFlushing();
374
418
  }
375
419
  }
376
420
  if (newConfig.maintenance.enabled && !wasMaintenanceEnabled) {
@@ -392,7 +436,10 @@ var _SiteManager = class _SiteManager {
392
436
  } catch (error) {
393
437
  this.consecutiveFetchFailures++;
394
438
  const errMsg = error instanceof Error ? error.message : String(error);
395
- this.debugLog("error", `[config] fetch failed (attempt ${this.consecutiveFetchFailures}): ${errMsg}`);
439
+ this.debugLog(
440
+ "error",
441
+ `[config] fetch failed (attempt ${this.consecutiveFetchFailures}): ${errMsg}`
442
+ );
396
443
  this.errorCount++;
397
444
  if (this.consecutiveFetchFailures <= _SiteManager.MAX_CONSECUTIVE_FAILURES) {
398
445
  const isNetworkError = error instanceof TypeError && (error.message.includes("NetworkError") || error.message.includes("Failed to fetch") || error.message.includes("fetch"));
@@ -411,7 +458,10 @@ var _SiteManager = class _SiteManager {
411
458
  } else if (this.consecutiveFetchFailures === _SiteManager.MAX_CONSECUTIVE_FAILURES + 1) {
412
459
  const silenceMsg = `Featurely Site Manager: Silencing repeated fetch errors after ${_SiteManager.MAX_CONSECUTIVE_FAILURES} failures. Check your apiUrl and Content-Security-Policy configuration.`;
413
460
  console.error(silenceMsg);
414
- this.debugLog("error", `[config] silencing errors after ${_SiteManager.MAX_CONSECUTIVE_FAILURES} consecutive failures`);
461
+ this.debugLog(
462
+ "error",
463
+ `[config] silencing errors after ${_SiteManager.MAX_CONSECUTIVE_FAILURES} consecutive failures`
464
+ );
415
465
  }
416
466
  }
417
467
  }
@@ -450,7 +500,10 @@ var _SiteManager = class _SiteManager {
450
500
  const eventsToSend = [...this.analyticsQueue];
451
501
  this.analyticsQueue = [];
452
502
  this.analyticsEventsSent += eventsToSend.length;
453
- this.debugLog("info", `[analytics] flushing ${eventsToSend.length} event(s) (total sent: ${this.analyticsEventsSent})`);
503
+ this.debugLog(
504
+ "info",
505
+ `[analytics] flushing ${eventsToSend.length} event(s) (total sent: ${this.analyticsEventsSent})`
506
+ );
454
507
  for (const event of eventsToSend) {
455
508
  const payload = JSON.stringify({
456
509
  eventName: event.eventName,
@@ -474,7 +527,11 @@ var _SiteManager = class _SiteManager {
474
527
  body: payload,
475
528
  keepalive: true
476
529
  });
477
- this.debugNetwork(`/api/projects/${this.config.projectId}/analytics/events`, res.status, Date.now() - t0);
530
+ this.debugNetwork(
531
+ `/api/projects/${this.config.projectId}/analytics/events`,
532
+ res.status,
533
+ Date.now() - t0
534
+ );
478
535
  if (!res.ok) {
479
536
  const body = await res.text().catch(() => "(unreadable)");
480
537
  const errDetail = `[analytics] event "${event.eventName}" rejected: ${res.status} ${res.statusText} \u2014 ${body}`;
@@ -484,13 +541,22 @@ var _SiteManager = class _SiteManager {
484
541
  }
485
542
  } catch (fetchError) {
486
543
  const sentViaBeacon = typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" && navigator.sendBeacon(url, payload);
544
+ const errMsg = fetchError instanceof Error ? fetchError.message : String(fetchError);
487
545
  if (!sentViaBeacon) {
488
- const errMsg = fetchError instanceof Error ? fetchError.message : String(fetchError);
489
- console.error(`[Featurely] Failed to send analytics event "${event.eventName}":`, fetchError);
490
- this.debugLog("error", `[analytics] send failed for "${event.eventName}": ${errMsg}`);
546
+ console.error(
547
+ `[Featurely] Failed to send analytics event "${event.eventName}":`,
548
+ fetchError
549
+ );
550
+ this.debugLog(
551
+ "error",
552
+ `[analytics] send failed for "${event.eventName}": ${errMsg}`
553
+ );
491
554
  this.errorCount++;
492
555
  } else {
493
- this.debugLog("warn", `[analytics] fetch failed for "${event.eventName}", delivered via sendBeacon`);
556
+ this.debugLog(
557
+ "warn",
558
+ `[analytics] fetch failed for "${event.eventName}" (${errMsg}), delivered via sendBeacon`
559
+ );
494
560
  }
495
561
  }
496
562
  }
@@ -537,14 +603,18 @@ var _SiteManager = class _SiteManager {
537
603
  this.originalConsoleError = console.error.bind(console);
538
604
  console.error = (...args) => {
539
605
  this.originalConsoleError(...args);
540
- const msg = args.map((a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)).join(" ");
606
+ const msg = args.map(
607
+ (a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)
608
+ ).join(" ");
541
609
  this.errorCount++;
542
610
  this.debugLog("error", `[console.error] ${msg.slice(0, 300)}`);
543
611
  };
544
612
  this.originalConsoleWarn = console.warn.bind(console);
545
613
  console.warn = (...args) => {
546
614
  this.originalConsoleWarn(...args);
547
- const msg = args.map((a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)).join(" ");
615
+ const msg = args.map(
616
+ (a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)
617
+ ).join(" ");
548
618
  this.debugLog("warn", `[console.warn] ${msg.slice(0, 300)}`);
549
619
  };
550
620
  window.addEventListener("error", (e) => {
@@ -597,7 +667,7 @@ var _SiteManager = class _SiteManager {
597
667
  this.debugOverlayEl = el;
598
668
  this.setupGlobalErrorCapture();
599
669
  this.debugLog("info", `[site-manager] debug overlay initialized`);
600
- this.debugLog("info", `[site-manager] v1.1.14 | project: ${this.config.projectId}`);
670
+ this.debugLog("info", `[site-manager] v1.1.19 | project: ${this.config.projectId}`);
601
671
  this.renderDebugOverlay();
602
672
  this.debugRefreshId = setInterval(() => this.renderDebugOverlay(), 1500);
603
673
  }
@@ -653,7 +723,7 @@ var _SiteManager = class _SiteManager {
653
723
  }
654
724
  }
655
725
  } else if (tab === "logs") {
656
- const logs = this.debugLogs.slice(0, 50);
726
+ const logs = this.debugLogs;
657
727
  const errorsInLog = logs.filter((l) => l.level === "error").length;
658
728
  const clearBtn = `<button data-action="clear-logs" style="background:rgba(255,255,255,0.08);border:none;color:${MUTED};cursor:pointer;font-size:9px;font-family:inherit;padding:2px 7px;border-radius:3px">Clear</button>`;
659
729
  const copyBtn = `<button data-action="copy-logs" style="background:rgba(255,255,255,0.08);border:none;color:${MUTED};cursor:pointer;font-size:9px;font-family:inherit;padding:2px 7px;border-radius:3px">Copy</button>`;
@@ -662,10 +732,10 @@ var _SiteManager = class _SiteManager {
662
732
  if (logs.length === 0) {
663
733
  content = logHeader + `<div style="color:${MUTED};padding:12px;text-align:center">No logs yet</div>`;
664
734
  } else {
665
- content = logHeader + logs.map((l) => {
735
+ content = logHeader + `<div style="user-select:text;-webkit-user-select:text">` + logs.map((l) => {
666
736
  const c = l.level === "error" ? RED : l.level === "warn" ? YELLOW : "#a5b4fc";
667
- return `<div style="padding:2px 0;display:flex;gap:6px;align-items:flex-start;border-bottom:1px solid rgba(255,255,255,0.03)"><span style="color:${MUTED};white-space:nowrap;font-size:9px">${l.ts}</span><span style="color:${c};flex:1;word-break:break-word">${l.msg}</span></div>`;
668
- }).join("");
737
+ return `<div style="padding:2px 0;display:flex;gap:6px;align-items:flex-start;border-bottom:1px solid rgba(255,255,255,0.03)"><span style="color:${MUTED};white-space:nowrap;font-size:9px">${l.ts}</span><span style="color:${c};flex:1;word-break:break-word;cursor:text">${l.msg}</span></div>`;
738
+ }).join("") + `</div>`;
669
739
  }
670
740
  } else if (tab === "network") {
671
741
  const calls = this.networkLog.slice(0, 20);
@@ -779,7 +849,10 @@ var _SiteManager = class _SiteManager {
779
849
  };
780
850
  switch (action) {
781
851
  case "test-event":
782
- this.trackEvent("debug_test_event", { source: "debug_panel", ts: (/* @__PURE__ */ new Date()).toISOString() });
852
+ this.trackEvent("debug_test_event", {
853
+ source: "debug_panel",
854
+ ts: (/* @__PURE__ */ new Date()).toISOString()
855
+ });
783
856
  feedback("debug_test_event queued", true);
784
857
  break;
785
858
  case "flush": {
@@ -821,7 +894,10 @@ var _SiteManager = class _SiteManager {
821
894
  case "copy-logs": {
822
895
  const text = this.debugLogs.slice().reverse().map((l) => `[${l.ts}] [${l.level.toUpperCase()}] ${l.msg}`).join("\n");
823
896
  void navigator.clipboard.writeText(text).then(() => {
824
- feedback(`Copied ${this.debugLogs.length} log line${this.debugLogs.length !== 1 ? "s" : ""}`, true);
897
+ feedback(
898
+ `Copied ${this.debugLogs.length} log line${this.debugLogs.length !== 1 ? "s" : ""}`,
899
+ true
900
+ );
825
901
  }).catch(() => {
826
902
  feedback("Copy failed \u2014 clipboard not available", false);
827
903
  });
@@ -939,9 +1015,7 @@ var _SiteManager = class _SiteManager {
939
1015
  async checkVersion(currentVersion) {
940
1016
  const versionToCheck = currentVersion || this.config.appVersion;
941
1017
  if (!versionToCheck) {
942
- console.warn(
943
- "Featurely Site Manager: appVersion not provided for version check"
944
- );
1018
+ console.warn("Featurely Site Manager: appVersion not provided for version check");
945
1019
  return null;
946
1020
  }
947
1021
  try {
@@ -1085,7 +1159,10 @@ var _SiteManager = class _SiteManager {
1085
1159
  this.debugLog("info", "[maintenance] ENABLED \u2014 user bypassed (whitelist match)");
1086
1160
  return;
1087
1161
  }
1088
- this.debugLog("warn", `[maintenance] ENABLED (type: ${this.siteConfig.maintenance.type})`);
1162
+ this.debugLog(
1163
+ "warn",
1164
+ `[maintenance] ENABLED (type: ${this.siteConfig.maintenance.type})`
1165
+ );
1089
1166
  this.showMaintenancePage();
1090
1167
  this.trackEvent("maintenance_enabled", {
1091
1168
  maintenanceType: this.siteConfig.maintenance.type
@@ -1157,7 +1234,17 @@ var _SiteManager = class _SiteManager {
1157
1234
  "button"
1158
1235
  ],
1159
1236
  ALLOWED_ATTR: ["class", "id", "href", "src", "alt", "title", "style"],
1160
- FORBID_TAGS: ["script", "iframe", "embed", "object", "applet", "meta", "link", "form", "input"],
1237
+ FORBID_TAGS: [
1238
+ "script",
1239
+ "iframe",
1240
+ "embed",
1241
+ "object",
1242
+ "applet",
1243
+ "meta",
1244
+ "link",
1245
+ "form",
1246
+ "input"
1247
+ ],
1161
1248
  FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover"],
1162
1249
  ALLOW_DATA_ATTR: false
1163
1250
  });
@@ -1225,10 +1312,16 @@ var _SiteManager = class _SiteManager {
1225
1312
  this.messageContainers.delete(id);
1226
1313
  }
1227
1314
  });
1228
- this.debugLog("info", `[messages] ${activeMessages.length} active (${(_b = (_a = this.siteConfig) == null ? void 0 : _a.messages.length) != null ? _b : 0} total)`);
1315
+ this.debugLog(
1316
+ "info",
1317
+ `[messages] ${activeMessages.length} active (${(_b = (_a = this.siteConfig) == null ? void 0 : _a.messages.length) != null ? _b : 0} total)`
1318
+ );
1229
1319
  activeMessages.forEach((message) => {
1230
1320
  if (!this.messageContainers.has(message.id)) {
1231
- this.debugLog("info", `[messages] showing "${message.title}" (${message.type}, ${message.style})`);
1321
+ this.debugLog(
1322
+ "info",
1323
+ `[messages] showing "${message.title}" (${message.type}, ${message.style})`
1324
+ );
1232
1325
  this.showMessage(message);
1233
1326
  if (this.config.onMessageReceived) {
1234
1327
  this.config.onMessageReceived(message);
package/dist/index.mjs CHANGED
@@ -80,8 +80,14 @@ var _SiteManager = class _SiteManager {
80
80
  if (this.config.debugMode) {
81
81
  this.setupDebugOverlay();
82
82
  }
83
- this.debugLog("info", `[init] v1.1.16 | project: ${this.config.projectId} | hostname: ${typeof window !== "undefined" ? window.location.hostname : "\u2014"}`);
84
- this.debugLog("info", `[init] analytics: ${this.config.enableAnalytics ? "on" : "off"} | poll: ${this.config.pollInterval}ms | debug: ${this.config.debugMode ? "on" : "off"}`);
83
+ this.debugLog(
84
+ "info",
85
+ `[init] v1.1.19 | project: ${this.config.projectId} | hostname: ${typeof window !== "undefined" ? window.location.hostname : "\u2014"}`
86
+ );
87
+ this.debugLog(
88
+ "info",
89
+ `[init] analytics: ${this.config.enableAnalytics ? "on" : "off"} | poll: ${this.config.pollInterval}ms | debug: ${this.config.debugMode ? "on" : "off"}`
90
+ );
85
91
  if (this.config.enableAnalytics) {
86
92
  this.debugLog("info", "[init] starting analytics + page tracking");
87
93
  this.startAnalyticsFlushing();
@@ -92,7 +98,10 @@ var _SiteManager = class _SiteManager {
92
98
  this.startPolling();
93
99
  this.debugLog("info", `[init] polling every ${this.config.pollInterval / 1e3}s`);
94
100
  if (this.config.enableVersionCheck && this.config.appVersion) {
95
- this.debugLog("info", `[init] checking version (current: ${this.config.appVersion})`);
101
+ this.debugLog(
102
+ "info",
103
+ `[init] checking version (current: ${this.config.appVersion})`
104
+ );
96
105
  await this.checkVersion();
97
106
  this.startVersionChecking();
98
107
  }
@@ -267,7 +276,10 @@ var _SiteManager = class _SiteManager {
267
276
  properties,
268
277
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
269
278
  });
270
- this.recentAnalyticsEvents.unshift({ name: eventName, ts: (/* @__PURE__ */ new Date()).toLocaleTimeString() });
279
+ this.recentAnalyticsEvents.unshift({
280
+ name: eventName,
281
+ ts: (/* @__PURE__ */ new Date()).toLocaleTimeString()
282
+ });
271
283
  if (this.recentAnalyticsEvents.length > 50) this.recentAnalyticsEvents.length = 50;
272
284
  this.debugLog("info", `trackEvent: ${eventName}`);
273
285
  if (this.analyticsQueue.length >= 10) {
@@ -289,7 +301,11 @@ var _SiteManager = class _SiteManager {
289
301
  }
290
302
  }
291
303
  );
292
- this.debugNetwork(`/api/public/v1/site-config`, response.status, Date.now() - fetchStartMs);
304
+ this.debugNetwork(
305
+ `/api/public/v1/site-config`,
306
+ response.status,
307
+ Date.now() - fetchStartMs
308
+ );
293
309
  if (!response.ok) {
294
310
  const errorData = await response.json().catch(() => ({}));
295
311
  const message = errorData.error || response.statusText;
@@ -322,20 +338,48 @@ var _SiteManager = class _SiteManager {
322
338
  delta.push(`flags: ${prevFlagCount}\u2192${newConfig.featureFlags.length}`);
323
339
  if (newConfig.messages.length !== prevMsgCount)
324
340
  delta.push(`messages: ${prevMsgCount}\u2192${newConfig.messages.length}`);
325
- this.debugLog("info", `[config] updated${delta.length ? ": " + delta.join(", ") : " (structure changed)"}`);
341
+ this.debugLog(
342
+ "info",
343
+ `[config] updated${delta.length ? ": " + delta.join(", ") : " (structure changed)"}`
344
+ );
326
345
  const wasMaintenanceEnabled = (_g = this.siteConfig) == null ? void 0 : _g.maintenance.enabled;
327
346
  const oldFeatureFlags = ((_h = this.siteConfig) == null ? void 0 : _h.featureFlags) || [];
328
347
  this.siteConfig = newConfig;
329
- if (newConfig.debugMode === true && !this.debugOverlayEl) {
348
+ const isGlobalDebug = newConfig.debugMode === true;
349
+ const hostname = typeof window !== "undefined" ? window.location.hostname : "";
350
+ const envs = newConfig.environments;
351
+ const matchedEnv = hostname ? envs == null ? void 0 : envs.find(
352
+ (e) => typeof e.url === "string" && e.url !== "" && e.url === hostname
353
+ ) : void 0;
354
+ const isEnvDebug = (matchedEnv == null ? void 0 : matchedEnv.debugEnabled) === true;
355
+ const shouldDebug = isGlobalDebug || isEnvDebug;
356
+ if (shouldDebug && !this.debugOverlayEl) {
357
+ if (isEnvDebug && !isGlobalDebug) {
358
+ this.debugLog(
359
+ "info",
360
+ `[env] matched environment "${matchedEnv.name}" (${hostname}) \u2014 enabling debug overlay`
361
+ );
362
+ }
330
363
  this.setupDebugOverlay();
364
+ } else if (!shouldDebug && this.debugOverlayEl) {
365
+ this.debugLog("info", "[debug] debug disabled by config \u2014 removing overlay");
366
+ this.stopDebugOverlay();
331
367
  }
332
- if (!this.debugOverlayEl) {
333
- const hostname = typeof window !== "undefined" ? window.location.hostname : "";
334
- const envs = newConfig.environments;
335
- const matchedEnv = hostname ? envs == null ? void 0 : envs.find((e) => typeof e.url === "string" && e.url !== "" && e.url === hostname) : void 0;
336
- if ((matchedEnv == null ? void 0 : matchedEnv.debugEnabled) === true) {
337
- this.debugLog("info", `[env] matched environment "${matchedEnv.name}" (${hostname})`);
338
- this.setupDebugOverlay();
368
+ if (matchedEnv && matchedEnv.analyticsEnabled === false) {
369
+ if (this.config.enableAnalytics) {
370
+ this.debugLog(
371
+ "info",
372
+ `[env] analytics disabled for environment "${matchedEnv.name}" (${hostname}) \u2014 stopping analytics`
373
+ );
374
+ this.stopAnalyticsFlushing();
375
+ }
376
+ } else if (matchedEnv && matchedEnv.analyticsEnabled !== false && this.config.enableAnalytics) {
377
+ if (!this.analyticsFlushIntervalId) {
378
+ this.debugLog(
379
+ "info",
380
+ `[env] analytics re-enabled for environment "${matchedEnv.name}" (${hostname})`
381
+ );
382
+ this.startAnalyticsFlushing();
339
383
  }
340
384
  }
341
385
  if (newConfig.maintenance.enabled && !wasMaintenanceEnabled) {
@@ -357,7 +401,10 @@ var _SiteManager = class _SiteManager {
357
401
  } catch (error) {
358
402
  this.consecutiveFetchFailures++;
359
403
  const errMsg = error instanceof Error ? error.message : String(error);
360
- this.debugLog("error", `[config] fetch failed (attempt ${this.consecutiveFetchFailures}): ${errMsg}`);
404
+ this.debugLog(
405
+ "error",
406
+ `[config] fetch failed (attempt ${this.consecutiveFetchFailures}): ${errMsg}`
407
+ );
361
408
  this.errorCount++;
362
409
  if (this.consecutiveFetchFailures <= _SiteManager.MAX_CONSECUTIVE_FAILURES) {
363
410
  const isNetworkError = error instanceof TypeError && (error.message.includes("NetworkError") || error.message.includes("Failed to fetch") || error.message.includes("fetch"));
@@ -376,7 +423,10 @@ var _SiteManager = class _SiteManager {
376
423
  } else if (this.consecutiveFetchFailures === _SiteManager.MAX_CONSECUTIVE_FAILURES + 1) {
377
424
  const silenceMsg = `Featurely Site Manager: Silencing repeated fetch errors after ${_SiteManager.MAX_CONSECUTIVE_FAILURES} failures. Check your apiUrl and Content-Security-Policy configuration.`;
378
425
  console.error(silenceMsg);
379
- this.debugLog("error", `[config] silencing errors after ${_SiteManager.MAX_CONSECUTIVE_FAILURES} consecutive failures`);
426
+ this.debugLog(
427
+ "error",
428
+ `[config] silencing errors after ${_SiteManager.MAX_CONSECUTIVE_FAILURES} consecutive failures`
429
+ );
380
430
  }
381
431
  }
382
432
  }
@@ -415,7 +465,10 @@ var _SiteManager = class _SiteManager {
415
465
  const eventsToSend = [...this.analyticsQueue];
416
466
  this.analyticsQueue = [];
417
467
  this.analyticsEventsSent += eventsToSend.length;
418
- this.debugLog("info", `[analytics] flushing ${eventsToSend.length} event(s) (total sent: ${this.analyticsEventsSent})`);
468
+ this.debugLog(
469
+ "info",
470
+ `[analytics] flushing ${eventsToSend.length} event(s) (total sent: ${this.analyticsEventsSent})`
471
+ );
419
472
  for (const event of eventsToSend) {
420
473
  const payload = JSON.stringify({
421
474
  eventName: event.eventName,
@@ -439,7 +492,11 @@ var _SiteManager = class _SiteManager {
439
492
  body: payload,
440
493
  keepalive: true
441
494
  });
442
- this.debugNetwork(`/api/projects/${this.config.projectId}/analytics/events`, res.status, Date.now() - t0);
495
+ this.debugNetwork(
496
+ `/api/projects/${this.config.projectId}/analytics/events`,
497
+ res.status,
498
+ Date.now() - t0
499
+ );
443
500
  if (!res.ok) {
444
501
  const body = await res.text().catch(() => "(unreadable)");
445
502
  const errDetail = `[analytics] event "${event.eventName}" rejected: ${res.status} ${res.statusText} \u2014 ${body}`;
@@ -449,13 +506,22 @@ var _SiteManager = class _SiteManager {
449
506
  }
450
507
  } catch (fetchError) {
451
508
  const sentViaBeacon = typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" && navigator.sendBeacon(url, payload);
509
+ const errMsg = fetchError instanceof Error ? fetchError.message : String(fetchError);
452
510
  if (!sentViaBeacon) {
453
- const errMsg = fetchError instanceof Error ? fetchError.message : String(fetchError);
454
- console.error(`[Featurely] Failed to send analytics event "${event.eventName}":`, fetchError);
455
- this.debugLog("error", `[analytics] send failed for "${event.eventName}": ${errMsg}`);
511
+ console.error(
512
+ `[Featurely] Failed to send analytics event "${event.eventName}":`,
513
+ fetchError
514
+ );
515
+ this.debugLog(
516
+ "error",
517
+ `[analytics] send failed for "${event.eventName}": ${errMsg}`
518
+ );
456
519
  this.errorCount++;
457
520
  } else {
458
- this.debugLog("warn", `[analytics] fetch failed for "${event.eventName}", delivered via sendBeacon`);
521
+ this.debugLog(
522
+ "warn",
523
+ `[analytics] fetch failed for "${event.eventName}" (${errMsg}), delivered via sendBeacon`
524
+ );
459
525
  }
460
526
  }
461
527
  }
@@ -502,14 +568,18 @@ var _SiteManager = class _SiteManager {
502
568
  this.originalConsoleError = console.error.bind(console);
503
569
  console.error = (...args) => {
504
570
  this.originalConsoleError(...args);
505
- const msg = args.map((a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)).join(" ");
571
+ const msg = args.map(
572
+ (a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)
573
+ ).join(" ");
506
574
  this.errorCount++;
507
575
  this.debugLog("error", `[console.error] ${msg.slice(0, 300)}`);
508
576
  };
509
577
  this.originalConsoleWarn = console.warn.bind(console);
510
578
  console.warn = (...args) => {
511
579
  this.originalConsoleWarn(...args);
512
- const msg = args.map((a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)).join(" ");
580
+ const msg = args.map(
581
+ (a) => typeof a === "string" ? a : a instanceof Error ? `${a.message}` : JSON.stringify(a)
582
+ ).join(" ");
513
583
  this.debugLog("warn", `[console.warn] ${msg.slice(0, 300)}`);
514
584
  };
515
585
  window.addEventListener("error", (e) => {
@@ -562,7 +632,7 @@ var _SiteManager = class _SiteManager {
562
632
  this.debugOverlayEl = el;
563
633
  this.setupGlobalErrorCapture();
564
634
  this.debugLog("info", `[site-manager] debug overlay initialized`);
565
- this.debugLog("info", `[site-manager] v1.1.14 | project: ${this.config.projectId}`);
635
+ this.debugLog("info", `[site-manager] v1.1.19 | project: ${this.config.projectId}`);
566
636
  this.renderDebugOverlay();
567
637
  this.debugRefreshId = setInterval(() => this.renderDebugOverlay(), 1500);
568
638
  }
@@ -618,7 +688,7 @@ var _SiteManager = class _SiteManager {
618
688
  }
619
689
  }
620
690
  } else if (tab === "logs") {
621
- const logs = this.debugLogs.slice(0, 50);
691
+ const logs = this.debugLogs;
622
692
  const errorsInLog = logs.filter((l) => l.level === "error").length;
623
693
  const clearBtn = `<button data-action="clear-logs" style="background:rgba(255,255,255,0.08);border:none;color:${MUTED};cursor:pointer;font-size:9px;font-family:inherit;padding:2px 7px;border-radius:3px">Clear</button>`;
624
694
  const copyBtn = `<button data-action="copy-logs" style="background:rgba(255,255,255,0.08);border:none;color:${MUTED};cursor:pointer;font-size:9px;font-family:inherit;padding:2px 7px;border-radius:3px">Copy</button>`;
@@ -627,10 +697,10 @@ var _SiteManager = class _SiteManager {
627
697
  if (logs.length === 0) {
628
698
  content = logHeader + `<div style="color:${MUTED};padding:12px;text-align:center">No logs yet</div>`;
629
699
  } else {
630
- content = logHeader + logs.map((l) => {
700
+ content = logHeader + `<div style="user-select:text;-webkit-user-select:text">` + logs.map((l) => {
631
701
  const c = l.level === "error" ? RED : l.level === "warn" ? YELLOW : "#a5b4fc";
632
- return `<div style="padding:2px 0;display:flex;gap:6px;align-items:flex-start;border-bottom:1px solid rgba(255,255,255,0.03)"><span style="color:${MUTED};white-space:nowrap;font-size:9px">${l.ts}</span><span style="color:${c};flex:1;word-break:break-word">${l.msg}</span></div>`;
633
- }).join("");
702
+ return `<div style="padding:2px 0;display:flex;gap:6px;align-items:flex-start;border-bottom:1px solid rgba(255,255,255,0.03)"><span style="color:${MUTED};white-space:nowrap;font-size:9px">${l.ts}</span><span style="color:${c};flex:1;word-break:break-word;cursor:text">${l.msg}</span></div>`;
703
+ }).join("") + `</div>`;
634
704
  }
635
705
  } else if (tab === "network") {
636
706
  const calls = this.networkLog.slice(0, 20);
@@ -744,7 +814,10 @@ var _SiteManager = class _SiteManager {
744
814
  };
745
815
  switch (action) {
746
816
  case "test-event":
747
- this.trackEvent("debug_test_event", { source: "debug_panel", ts: (/* @__PURE__ */ new Date()).toISOString() });
817
+ this.trackEvent("debug_test_event", {
818
+ source: "debug_panel",
819
+ ts: (/* @__PURE__ */ new Date()).toISOString()
820
+ });
748
821
  feedback("debug_test_event queued", true);
749
822
  break;
750
823
  case "flush": {
@@ -786,7 +859,10 @@ var _SiteManager = class _SiteManager {
786
859
  case "copy-logs": {
787
860
  const text = this.debugLogs.slice().reverse().map((l) => `[${l.ts}] [${l.level.toUpperCase()}] ${l.msg}`).join("\n");
788
861
  void navigator.clipboard.writeText(text).then(() => {
789
- feedback(`Copied ${this.debugLogs.length} log line${this.debugLogs.length !== 1 ? "s" : ""}`, true);
862
+ feedback(
863
+ `Copied ${this.debugLogs.length} log line${this.debugLogs.length !== 1 ? "s" : ""}`,
864
+ true
865
+ );
790
866
  }).catch(() => {
791
867
  feedback("Copy failed \u2014 clipboard not available", false);
792
868
  });
@@ -904,9 +980,7 @@ var _SiteManager = class _SiteManager {
904
980
  async checkVersion(currentVersion) {
905
981
  const versionToCheck = currentVersion || this.config.appVersion;
906
982
  if (!versionToCheck) {
907
- console.warn(
908
- "Featurely Site Manager: appVersion not provided for version check"
909
- );
983
+ console.warn("Featurely Site Manager: appVersion not provided for version check");
910
984
  return null;
911
985
  }
912
986
  try {
@@ -1050,7 +1124,10 @@ var _SiteManager = class _SiteManager {
1050
1124
  this.debugLog("info", "[maintenance] ENABLED \u2014 user bypassed (whitelist match)");
1051
1125
  return;
1052
1126
  }
1053
- this.debugLog("warn", `[maintenance] ENABLED (type: ${this.siteConfig.maintenance.type})`);
1127
+ this.debugLog(
1128
+ "warn",
1129
+ `[maintenance] ENABLED (type: ${this.siteConfig.maintenance.type})`
1130
+ );
1054
1131
  this.showMaintenancePage();
1055
1132
  this.trackEvent("maintenance_enabled", {
1056
1133
  maintenanceType: this.siteConfig.maintenance.type
@@ -1122,7 +1199,17 @@ var _SiteManager = class _SiteManager {
1122
1199
  "button"
1123
1200
  ],
1124
1201
  ALLOWED_ATTR: ["class", "id", "href", "src", "alt", "title", "style"],
1125
- FORBID_TAGS: ["script", "iframe", "embed", "object", "applet", "meta", "link", "form", "input"],
1202
+ FORBID_TAGS: [
1203
+ "script",
1204
+ "iframe",
1205
+ "embed",
1206
+ "object",
1207
+ "applet",
1208
+ "meta",
1209
+ "link",
1210
+ "form",
1211
+ "input"
1212
+ ],
1126
1213
  FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover"],
1127
1214
  ALLOW_DATA_ATTR: false
1128
1215
  });
@@ -1190,10 +1277,16 @@ var _SiteManager = class _SiteManager {
1190
1277
  this.messageContainers.delete(id);
1191
1278
  }
1192
1279
  });
1193
- this.debugLog("info", `[messages] ${activeMessages.length} active (${(_b = (_a = this.siteConfig) == null ? void 0 : _a.messages.length) != null ? _b : 0} total)`);
1280
+ this.debugLog(
1281
+ "info",
1282
+ `[messages] ${activeMessages.length} active (${(_b = (_a = this.siteConfig) == null ? void 0 : _a.messages.length) != null ? _b : 0} total)`
1283
+ );
1194
1284
  activeMessages.forEach((message) => {
1195
1285
  if (!this.messageContainers.has(message.id)) {
1196
- this.debugLog("info", `[messages] showing "${message.title}" (${message.type}, ${message.style})`);
1286
+ this.debugLog(
1287
+ "info",
1288
+ `[messages] showing "${message.title}" (${message.type}, ${message.style})`
1289
+ );
1197
1290
  this.showMessage(message);
1198
1291
  if (this.config.onMessageReceived) {
1199
1292
  this.config.onMessageReceived(message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "featurely-site-manager",
3
- "version": "1.1.17",
3
+ "version": "1.1.19",
4
4
  "description": "Complete site management SDK for maintenance mode, status messages, feature flags, version checking, and analytics",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -50,10 +50,13 @@
50
50
  "homepage": "https://featurely.no",
51
51
  "devDependencies": {
52
52
  "@types/dompurify": "^3.0.5",
53
+ "drizzle-kit": "^0.31.10",
53
54
  "tsup": "^8.5.1",
54
55
  "typescript": "^5.0.0"
55
56
  },
56
57
  "dependencies": {
57
- "dompurify": "^3.3.3"
58
+ "@neondatabase/serverless": "^1.0.2",
59
+ "dompurify": "^3.3.3",
60
+ "drizzle-orm": "^0.45.2"
58
61
  }
59
62
  }