saasco-sdk 0.2.4 → 0.2.6

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/dist/index.js CHANGED
@@ -2,7 +2,187 @@
2
2
  import { isValid, parse } from "psl";
3
3
 
4
4
  // package.json
5
- var version = "0.2.4";
5
+ var version = "0.2.6";
6
+
7
+ // src/lib/deliverOnce.ts
8
+ var sent = /* @__PURE__ */ new Set();
9
+ var pending = /* @__PURE__ */ new Map();
10
+ function deliverOnce(key, send) {
11
+ if (!key || typeof window === "undefined") {
12
+ return Promise.resolve().then(send);
13
+ }
14
+ if (sent.has(key)) {
15
+ return Promise.resolve();
16
+ }
17
+ try {
18
+ if (window.sessionStorage.getItem(key)) {
19
+ return Promise.resolve();
20
+ }
21
+ } catch {
22
+ }
23
+ const existing = pending.get(key);
24
+ if (existing) {
25
+ return existing;
26
+ }
27
+ const task = Promise.resolve().then(send).then(() => {
28
+ sent.add(key);
29
+ try {
30
+ window.sessionStorage.setItem(key, "true");
31
+ } catch {
32
+ }
33
+ }).finally(() => pending.delete(key));
34
+ pending.set(key, task);
35
+ return task;
36
+ }
37
+
38
+ // ../shared/src/lib/analyticsIngestError.ts
39
+ import { z } from "zod";
40
+ var IDENTIFY_INGEST_ERROR_SOURCE = "saasco-identify";
41
+ var ANALYTICS_INGEST_ERROR_SOURCE = "saasco-analytics";
42
+ var AnalyticsIngestErrorCodeSchema = z.enum([
43
+ "CREDENTIAL_INVALID",
44
+ "RESOURCE_FORBIDDEN",
45
+ "RESOURCE_UNSPECIFIED",
46
+ "ENVIRONMENT_UNREACHABLE",
47
+ "INGEST_FAILED"
48
+ ]);
49
+ var AnalyticsIngestSurfaceSchema = z.enum(["identify", "events"]);
50
+ var AnalyticsIngestErrorSchema = z.object({
51
+ affected: z.string(),
52
+ code: AnalyticsIngestErrorCodeSchema,
53
+ message: z.string(),
54
+ projectId: z.string().nullable(),
55
+ recovery: z.string(),
56
+ resource: z.string().nullable(),
57
+ surface: AnalyticsIngestSurfaceSchema
58
+ });
59
+ var IDENTIFY_AFFECTED = "Saasco identification only: CRM contact properties and identity stitching. Page views, charts, and the host application UI are not this failure.";
60
+ var ANALYTICS_EVENT_AFFECTED = "Saasco event tracking only. The host application UI is not this failure.";
61
+ function analyticsIngestError({
62
+ code,
63
+ message,
64
+ projectId,
65
+ resource,
66
+ surface
67
+ }) {
68
+ const copy = ingestCopy({ code, resource, surface });
69
+ return {
70
+ affected: surface === "identify" ? IDENTIFY_AFFECTED : ANALYTICS_EVENT_AFFECTED,
71
+ code,
72
+ message: message ?? copy.message,
73
+ projectId,
74
+ recovery: copy.recovery,
75
+ resource,
76
+ surface
77
+ };
78
+ }
79
+ function ingestCopy({
80
+ code,
81
+ resource,
82
+ surface
83
+ }) {
84
+ const named = resource ? `\`${resource}\`` : "the requested analytics resource";
85
+ const didNotComplete = surface === "identify" ? "Identification did not complete." : "Event tracking did not complete.";
86
+ switch (code) {
87
+ case "CREDENTIAL_INVALID": {
88
+ return {
89
+ message: "Saasco analytics credentials are invalid or missing. This is not an application login or permissions problem.",
90
+ recovery: "Check TINYBIRD_ANALYTICS_TOKEN on the Saasco ingest worker. Do not change host-app auth. Charts and UI styling are unrelated."
91
+ };
92
+ }
93
+ case "RESOURCE_FORBIDDEN": {
94
+ return {
95
+ message: `Saasco analytics is not allowed to use ${named}. ${didNotComplete}`,
96
+ recovery: surface === "identify" ? `Grant the ingest worker access to ${named} (PIPES:READ or DATASOURCES:APPEND). Page tracking may still work.` : `Grant the ingest worker access to ${named} (PIPES:READ or DATASOURCES:APPEND).`
97
+ };
98
+ }
99
+ case "RESOURCE_UNSPECIFIED": {
100
+ return {
101
+ message: `Saasco analytics was denied access without a named pipe or datasource. ${didNotComplete}`,
102
+ recovery: "This is a Saasco ingest configuration issue: the worker could not name the Tinybird resource it tried to use. Host-app login is unrelated."
103
+ };
104
+ }
105
+ case "ENVIRONMENT_UNREACHABLE": {
106
+ return {
107
+ message: surface === "identify" ? "The Saasco ingest host could not be reached, so identification was not sent." : "The Saasco ingest host could not be reached, so this event was not sent.",
108
+ recovery: "If `proxy` points at localhost, start the analytics worker (`pnpm worker:dev`) or use https://t.saasco.com/. This does not mean the host app is broken."
109
+ };
110
+ }
111
+ default: {
112
+ return {
113
+ message: surface === "identify" ? "Saasco identification could not be completed." : "Saasco event tracking could not be completed.",
114
+ recovery: "Retry. If it keeps failing, this is a Saasco analytics ingest issue, not host-app login or chart configuration."
115
+ };
116
+ }
117
+ }
118
+ }
119
+ function ingestErrorSource(surface) {
120
+ return surface === "identify" ? IDENTIFY_INGEST_ERROR_SOURCE : ANALYTICS_INGEST_ERROR_SOURCE;
121
+ }
122
+ function redactSecrets(text) {
123
+ return text.replaceAll(/Bearer\s+\S+/giu, "Bearer [redacted]").replaceAll(/\bp\.[A-Za-z0-9._-]+/gu, "[redacted]").replaceAll(/token=[^&\s]+/giu, "token=[redacted]");
124
+ }
125
+ function isUnsafeIngestMessage(text) {
126
+ return /Bearer \[redacted\]|token=\[redacted\]/iu.test(text) || /token you have provided|doesn't have access to this resource|not enough permissions|invalid token/iu.test(
127
+ text
128
+ );
129
+ }
130
+
131
+ // src/lib/formatAnalyticsRequestFailure.ts
132
+ function formatAnalyticsRequestFailure(input) {
133
+ const surface = input.path === "identify" ? "identify" : "events";
134
+ const projectId = input.projectId ?? null;
135
+ if (input.reason === "http") {
136
+ const parsed = AnalyticsIngestErrorSchema.safeParse(input.body);
137
+ if (parsed.success) {
138
+ return parsed.data;
139
+ }
140
+ }
141
+ if (input.reason === "unreachable") {
142
+ return analyticsIngestError({
143
+ code: "ENVIRONMENT_UNREACHABLE",
144
+ projectId,
145
+ resource: null,
146
+ surface
147
+ });
148
+ }
149
+ const legacy = input.body && typeof input.body === "object" && !Array.isArray(input.body) && "message" in input.body && typeof input.body.message === "string" ? input.body.message : "";
150
+ const safeMessage = redactSecrets(legacy).trim();
151
+ return analyticsIngestError({
152
+ code: "INGEST_FAILED",
153
+ message: safeMessage && !isUnsafeIngestMessage(safeMessage) ? safeMessage : void 0,
154
+ projectId,
155
+ resource: null,
156
+ surface
157
+ });
158
+ }
159
+ function reportIngestFailure({
160
+ failure,
161
+ logger,
162
+ path,
163
+ seen
164
+ }) {
165
+ const key = `${path}:${failure.code}:${failure.resource ?? ""}`;
166
+ if (!seen.has(key)) {
167
+ seen.add(key);
168
+ logger.warn(...analyticsFailureLogLines(failure, path));
169
+ }
170
+ return {
171
+ success: false,
172
+ message: failure.message
173
+ };
174
+ }
175
+ function analyticsFailureLogLines(failure, path) {
176
+ const lines = [
177
+ `${ingestErrorSource(failure.surface)} ${path} failed (${failure.code})`,
178
+ failure.message,
179
+ failure.resource ? `Resource: ${failure.resource}` : null,
180
+ failure.projectId ? `Project: ${failure.projectId}` : null,
181
+ `Affected: ${failure.affected}`,
182
+ `Recovery: ${failure.recovery}`
183
+ ];
184
+ return lines.filter((line) => line !== null);
185
+ }
6
186
 
7
187
  // src/lib/timezones.ts
8
188
  var timezones = {
@@ -451,6 +631,9 @@ var AnalyticsLogger = class {
451
631
  constructor(config) {
452
632
  this.config = config;
453
633
  }
634
+ setLevel(level) {
635
+ this.config.level = level;
636
+ }
454
637
  debug(...args) {
455
638
  if (this.config.level < 3 /* DEBUG */) {
456
639
  return;
@@ -479,12 +662,10 @@ var AnalyticsLogger = class {
479
662
  );
480
663
  }
481
664
  /**
482
- * Log warning information
665
+ * Warnings always print. Ingest failures use this so they stay visible when
666
+ * the default log level is ERROR, without `console.error` (Next.js overlays it).
483
667
  */
484
668
  warn(...args) {
485
- if (this.config.level < 1 /* WARN */) {
486
- return;
487
- }
488
669
  const icon = "\u25CD";
489
670
  const message = `${icon} ${this.config.label}`;
490
671
  console.warn(
@@ -531,9 +712,34 @@ function getIntegrationLoggerLevel(integrationName, debug) {
531
712
  }
532
713
 
533
714
  // src/lib/utils/uuid.ts
534
- import { v4 } from "@lukeed/uuid";
715
+ var HEX = [];
716
+ for (let i = 0; i < 256; i++) {
717
+ HEX[i] = (i + 256).toString(16).slice(1);
718
+ }
535
719
  function uuid() {
536
- return v4();
720
+ const bytes = randomBytes(16);
721
+ bytes[6] = bytes[6] & 15 | 64;
722
+ bytes[8] = bytes[8] & 63 | 128;
723
+ let out = "";
724
+ for (let i = 0; i < 16; i++) {
725
+ out += HEX[bytes[i]];
726
+ if (i === 3 || i === 5 || i === 7 || i === 9) {
727
+ out += "-";
728
+ }
729
+ }
730
+ return out;
731
+ }
732
+ function randomBytes(length) {
733
+ const bytes = new Uint8Array(length);
734
+ const webCrypto = globalThis.crypto;
735
+ if (typeof webCrypto?.getRandomValues === "function") {
736
+ webCrypto.getRandomValues(bytes);
737
+ return bytes;
738
+ }
739
+ for (let i = 0; i < length; i++) {
740
+ bytes[i] = Math.random() * 256 | 0;
741
+ }
742
+ return bytes;
537
743
  }
538
744
 
539
745
  // src/lib/getBrowserContext.ts
@@ -605,6 +811,50 @@ function resolveAdParams(params) {
605
811
 
606
812
  // src/lib/integrations/facebook-pixel.ts
607
813
  import { sha256 } from "js-sha256";
814
+
815
+ // src/lib/integrations/loadPixelScript.ts
816
+ var loads = /* @__PURE__ */ new Map();
817
+ function loadPixelScript(src) {
818
+ const existingLoad = loads.get(src);
819
+ if (existingLoad) {
820
+ return existingLoad;
821
+ }
822
+ const existingScript = Array.from(document.scripts).find(
823
+ (script2) => script2.src === src
824
+ );
825
+ const script = existingScript ?? document.createElement("script");
826
+ const task = new Promise((resolve, reject) => {
827
+ const cleanup = () => {
828
+ clearTimeout(timeout);
829
+ script.removeEventListener("load", loaded);
830
+ script.removeEventListener("error", failed);
831
+ };
832
+ const loaded = () => {
833
+ cleanup();
834
+ resolve();
835
+ };
836
+ const failed = () => {
837
+ cleanup();
838
+ script.remove();
839
+ reject(new Error(`Pixel script unavailable: ${src}`));
840
+ };
841
+ const timeout = setTimeout(failed, 1e4);
842
+ script.addEventListener("load", loaded);
843
+ script.addEventListener("error", failed);
844
+ if (!existingScript) {
845
+ script.async = true;
846
+ script.src = src;
847
+ document.head.append(script);
848
+ }
849
+ }).catch((error) => {
850
+ loads.delete(src);
851
+ throw error;
852
+ });
853
+ loads.set(src, task);
854
+ return task;
855
+ }
856
+
857
+ // src/lib/integrations/facebook-pixel.ts
608
858
  var isBrowser2 = typeof window !== "undefined";
609
859
  var isServer2 = !isBrowser2;
610
860
  var standardFacebookEvents = [
@@ -646,70 +896,32 @@ function createFacebookPixelIntegration(config, debug) {
646
896
  return;
647
897
  }
648
898
  logger.info("Initializing Facebook Pixel", { pixelId: config.pixelId });
649
- if (window.fbq || window._fbq) {
650
- logger.warn("Facebook Pixel is already initialized");
651
- isPixelReady = true;
652
- return;
653
- }
654
- return new Promise((resolve, reject) => {
655
- try {
656
- (function init(f, b, e, v, n, t, s) {
657
- if (f.fbq) {
658
- return;
659
- }
660
- n = f.fbq = function fbq2() {
661
- n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
662
- };
663
- if (!f._fbq) {
664
- f._fbq = n;
665
- }
666
- n.push = n;
667
- n.loaded = true;
668
- n.version = "2.0";
669
- n.queue = [];
670
- t = b.createElement(e);
671
- t.async = true;
672
- t.src = v;
673
- t.addEventListener("load", () => {
674
- isPixelReady = true;
675
- logger.info("Facebook Pixel script loaded successfully");
676
- resolve();
677
- });
678
- t.addEventListener("error", () => {
679
- logger.error("Failed to load Facebook Pixel script");
680
- reject(new Error("Failed to load Facebook Pixel script"));
681
- });
682
- s = b.getElementsByTagName(e)[0];
683
- s.parentNode.insertBefore(t, s);
684
- })(
685
- window,
686
- document,
687
- "script",
688
- "https://connect.facebook.net/en_US/fbevents.js"
689
- );
690
- const { fbq } = window;
691
- if (!fbq) {
692
- reject(new Error("Facebook Pixel failed to initialize"));
693
- return;
694
- }
695
- fbq.disablePushState = true;
696
- fbq.allowDuplicatePageViews = true;
697
- if (config.automaticConfiguration === false) {
698
- logger.debug("Disabling automatic configuration");
699
- fbq("set", "autoConfig", false, config.pixelId);
700
- }
701
- logger.debug("Initializing Facebook Pixel with ID", config.pixelId);
702
- fbq("init", config.pixelId);
703
- if (typeof fbq === "function") {
704
- isPixelReady = true;
705
- logger.info("Facebook Pixel initialized synchronously");
706
- resolve();
899
+ if (!window.fbq) {
900
+ const fbq2 = (...args) => {
901
+ if (fbq2.callMethod) {
902
+ fbq2.callMethod(...args);
903
+ } else {
904
+ fbq2.queue.push(args);
707
905
  }
708
- } catch (error) {
709
- logger.error("Error during Facebook Pixel initialization", error);
710
- reject(error);
711
- }
712
- });
906
+ };
907
+ fbq2.queue = [];
908
+ fbq2.push = fbq2;
909
+ fbq2.loaded = true;
910
+ fbq2.version = "2.0";
911
+ window.fbq = fbq2;
912
+ window._fbq = fbq2;
913
+ }
914
+ const { fbq } = window;
915
+ fbq.disablePushState = true;
916
+ fbq.allowDuplicatePageViews = true;
917
+ if (config.automaticConfiguration === false) {
918
+ fbq("set", "autoConfig", false, config.pixelId);
919
+ }
920
+ fbq("init", config.pixelId);
921
+ if (!fbq.callMethod) {
922
+ await loadPixelScript("https://connect.facebook.net/en_US/fbevents.js");
923
+ }
924
+ isPixelReady = true;
713
925
  },
714
926
  track: (eventName, properties, _context) => {
715
927
  const { fbq } = window;
@@ -717,9 +929,11 @@ function createFacebookPixelIntegration(config, debug) {
717
929
  logger.warn("Facebook Pixel not ready, skipping track event", {
718
930
  eventName
719
931
  });
720
- return;
932
+ throw new Error("Facebook Pixel not ready");
721
933
  }
722
934
  const fbEventName = getFacebookEventName(eventName, config.eventMapping);
935
+ const eventId = properties?.["event_id"];
936
+ const eventOptions = typeof eventId === "string" && eventId.trim().length > 0 ? { eventID: eventId } : void 0;
723
937
  const eventsWithParams = ["Purchase", "StartTrial", "Subscribe"];
724
938
  const isStandardEvent = standardFacebookEvents.includes(
725
939
  fbEventName
@@ -747,10 +961,18 @@ function createFacebookPixelIntegration(config, debug) {
747
961
  event: fbEventName,
748
962
  params: fbParams
749
963
  });
750
- fbq(trackType, fbEventName, fbParams);
964
+ if (eventOptions) {
965
+ fbq(trackType, fbEventName, fbParams, eventOptions);
966
+ } else {
967
+ fbq(trackType, fbEventName, fbParams);
968
+ }
751
969
  } else {
752
970
  logger.info("Tracking Facebook Pixel event", { event: fbEventName });
753
- fbq(trackType, fbEventName);
971
+ if (eventOptions) {
972
+ fbq(trackType, fbEventName, {}, eventOptions);
973
+ } else {
974
+ fbq(trackType, fbEventName);
975
+ }
754
976
  }
755
977
  },
756
978
  identify: (userId, properties, _context) => {
@@ -1104,12 +1326,12 @@ var IntegrationManager = class {
1104
1326
  globalQueue = [];
1105
1327
  config;
1106
1328
  logger;
1107
- initTime;
1108
1329
  currentEnvironment;
1109
1330
  flushTimer;
1110
1331
  unloadHandler;
1111
1332
  constructor(config = {}) {
1112
1333
  this.config = {
1334
+ deduplicationNamespace: config.deduplicationNamespace ?? "default",
1113
1335
  flushInterval: config.flushInterval ?? 5e3,
1114
1336
  loggerLevel: config.loggerLevel ?? 0 /* ERROR */,
1115
1337
  maxIntegrationWaitTime: config.maxIntegrationWaitTime ?? 1e4,
@@ -1119,7 +1341,6 @@ var IntegrationManager = class {
1119
1341
  label: "Saasco Integrations Debug",
1120
1342
  level: this.config.loggerLevel
1121
1343
  });
1122
- this.initTime = Date.now();
1123
1344
  this.currentEnvironment = typeof window === "undefined" ? "server" : "client";
1124
1345
  this.setupPeriodicFlushing();
1125
1346
  this.setupUnloadHandler();
@@ -1196,6 +1417,8 @@ var IntegrationManager = class {
1196
1417
  */
1197
1418
  send(partial) {
1198
1419
  const envelope = {
1420
+ delivered: /* @__PURE__ */ new Set(),
1421
+ pending: /* @__PURE__ */ new Set(),
1199
1422
  context: { ...this.context },
1200
1423
  id: uuid(),
1201
1424
  name: partial.name,
@@ -1203,65 +1426,55 @@ var IntegrationManager = class {
1203
1426
  timestamp: Date.now(),
1204
1427
  type: partial.type
1205
1428
  };
1206
- if (!this.isReady()) {
1207
- if (this.globalQueue.length >= this.config.maxQueueSize) {
1208
- this.globalQueue.shift();
1209
- this.logger.warn("Queue full \u2192 dropped oldest");
1210
- }
1211
- this.globalQueue.push(envelope);
1212
- this.logger.debug(`Queued (${this.globalQueue.length})`, envelope);
1213
- return;
1429
+ if (this.globalQueue.length >= this.config.maxQueueSize) {
1430
+ this.globalQueue.shift();
1431
+ this.logger.warn("Queue full \u2192 dropped oldest");
1214
1432
  }
1215
- this.deliver(envelope);
1433
+ this.globalQueue.push(envelope);
1434
+ this.flush();
1216
1435
  }
1217
- async deliver(evt) {
1218
- for (const { integration, status } of this.integrations.values()) {
1219
- if (status !== "ready") {
1220
- continue;
1221
- }
1222
- try {
1223
- if (evt.type === "track" && integration.track && evt.name) {
1224
- this.logger.debug(`Track (${integration.name}) ${evt.name}`, {
1225
- context: evt.context,
1226
- properties: evt.properties
1227
- });
1228
- await integration.track(evt.name, evt.properties, evt.context);
1229
- } else if (evt.type === "identify" && integration.identify) {
1230
- this.logger.debug(`Identify (${integration.name})`, {
1231
- context: evt.context,
1232
- properties: evt.properties,
1233
- userId: evt.context.distinctId
1234
- });
1235
- await integration.identify(
1236
- evt.context.distinctId,
1237
- evt.properties,
1238
- evt.context
1239
- );
1240
- }
1241
- } catch (error) {
1242
- this.logger.error(
1243
- `deliver error in ${integration.name} (${evt.type})`,
1244
- error
1436
+ async deliver(evt, state) {
1437
+ const { integration } = state;
1438
+ evt.pending.add(integration.name);
1439
+ try {
1440
+ if (evt.type === "track" && integration.track && evt.name) {
1441
+ const eventId = evt.properties?.["event_id"];
1442
+ const key = typeof eventId === "string" && eventId.trim() ? `saasco:${this.config.deduplicationNamespace}:${integration.name}:${evt.name}:${eventId}` : void 0;
1443
+ const { track } = integration;
1444
+ const { name } = evt;
1445
+ await deliverOnce(
1446
+ key,
1447
+ () => track.call(integration, name, evt.properties, evt.context)
1448
+ );
1449
+ } else if (evt.type === "identify" && integration.identify) {
1450
+ await integration.identify(
1451
+ evt.context.distinctId,
1452
+ evt.properties,
1453
+ evt.context
1245
1454
  );
1246
1455
  }
1456
+ evt.delivered.add(integration.name);
1457
+ } catch (error) {
1458
+ this.logger.error(
1459
+ `deliver error in ${integration.name} (${evt.type})`,
1460
+ error
1461
+ );
1462
+ } finally {
1463
+ evt.pending.delete(integration.name);
1247
1464
  }
1248
1465
  }
1249
- /**
1250
- * Flush queued events FIFO once at least one integration is ready.
1251
- */
1466
+ /** Each ready destination drains its own copy; a slow/blocked pixel cannot
1467
+ * discard another pixel's queued events or hold up the ready destinations. */
1252
1468
  flush() {
1253
- if (this.globalQueue.length === 0) {
1254
- return;
1255
- }
1256
- if (!this.isReady()) {
1257
- return;
1258
- }
1259
- this.logger.debug(`flushing ${this.globalQueue.length} queued events`);
1260
- const toSend = this.globalQueue;
1261
- this.globalQueue = [];
1262
- for (const evt of toSend) {
1263
- this.deliver(evt);
1264
- }
1469
+ this.globalQueue = this.globalQueue.filter((evt) => {
1470
+ for (const state of this.integrations.values()) {
1471
+ const { name } = state.integration;
1472
+ if (state.status === "ready" && !evt.delivered.has(name) && !evt.pending.has(name)) {
1473
+ void this.deliver(evt, state);
1474
+ }
1475
+ }
1476
+ return evt.delivered.size < this.integrations.size && Date.now() - evt.timestamp < this.config.maxIntegrationWaitTime;
1477
+ });
1265
1478
  }
1266
1479
  readyCount() {
1267
1480
  let n = 0;
@@ -1272,18 +1485,6 @@ var IntegrationManager = class {
1272
1485
  }
1273
1486
  return n;
1274
1487
  }
1275
- isReady() {
1276
- const timeElapsed = Date.now() - this.initTime;
1277
- if (this.readyCount() < this.integrations.size && timeElapsed < this.config.maxIntegrationWaitTime) {
1278
- return false;
1279
- }
1280
- if (this.readyCount() < this.integrations.size) {
1281
- this.logger.info(
1282
- `proceeding with ${this.readyCount()}/${this.integrations.size} integrations ready after ${timeElapsed}ms wait`
1283
- );
1284
- }
1285
- return true;
1286
- }
1287
1488
  /**
1288
1489
  * Setup periodic flushing if enabled
1289
1490
  */
@@ -1367,50 +1568,20 @@ function createPinterestTagIntegration(config, debug) {
1367
1568
  return;
1368
1569
  }
1369
1570
  logger.info("Initializing Pinterest Tag", { tagId: config.tagId });
1370
- if (window.pintrk || window._pintrk) {
1371
- logger.warn("Pinterest Tag is already initialized");
1372
- isTagReady = true;
1373
- return;
1571
+ if (!window.pintrk) {
1572
+ const pintrk = (...args) => pintrk.queue.push(args);
1573
+ pintrk.queue = [];
1574
+ pintrk.version = "3.0";
1575
+ window.pintrk = pintrk;
1374
1576
  }
1375
- return new Promise((resolve, reject) => {
1376
- try {
1377
- (function init(e) {
1378
- if (!window.pintrk) {
1379
- window.pintrk = function pintrk(...args) {
1380
- window.pintrk.queue.push(args);
1381
- };
1382
- const n = window.pintrk;
1383
- n.queue = [];
1384
- n.version = "3.0";
1385
- const t = document.createElement("script");
1386
- t.async = true;
1387
- t.src = e;
1388
- t.addEventListener("load", () => {
1389
- window.pintrk("load", config.tagId);
1390
- window.pintrk("page");
1391
- setTimeout(() => {
1392
- logger.debug("Pinterest Tag initialized");
1393
- isTagReady = true;
1394
- resolve();
1395
- }, 500);
1396
- });
1397
- t.addEventListener("error", () => {
1398
- logger.error("Failed to load Pinterest Tag script");
1399
- reject(new Error("Failed to load Pinterest Tag script"));
1400
- });
1401
- const r = document.getElementsByTagName("script")[0];
1402
- r.parentNode.insertBefore(t, r);
1403
- }
1404
- })("https://s.pinimg.com/ct/core.js");
1405
- } catch (error) {
1406
- logger.error("Failed to initialize Pinterest Tag", error);
1407
- reject(error);
1408
- }
1409
- });
1577
+ await loadPixelScript("https://s.pinimg.com/ct/core.js");
1578
+ window.pintrk("load", config.tagId);
1579
+ window.pintrk("page");
1580
+ isTagReady = true;
1410
1581
  },
1411
1582
  track: (eventName, properties) => {
1412
1583
  if (!isTagReady || !window.pintrk) {
1413
- return;
1584
+ throw new Error("Pinterest Tag not ready");
1414
1585
  }
1415
1586
  const pinterestEventName = getPinterestEventName(
1416
1587
  eventName,
@@ -1539,8 +1710,8 @@ function createTikTokPixelIntegration(config, debug) {
1539
1710
  return;
1540
1711
  }
1541
1712
  logger.info("Initializing TikTok Pixel", { pixelId: config.pixelId });
1542
- if (window.ttq) {
1543
- logger.warn("TikTok Pixel is already initialized");
1713
+ if (window.ttq?.ready) {
1714
+ await new Promise((resolve) => window.ttq.ready(resolve));
1544
1715
  isPixelReady = true;
1545
1716
  return;
1546
1717
  }
@@ -1592,28 +1763,15 @@ function createTikTokPixelIntegration(config, debug) {
1592
1763
  ttq._t[e] = Date.now();
1593
1764
  ttq._o ||= {};
1594
1765
  ttq._o[e] = n || {};
1595
- const script = document.createElement("script");
1596
- script.type = "text/javascript";
1597
- script.async = true;
1598
- script.src = `${r}?sdkid=${e}&lib=${t}`;
1599
- script.addEventListener("load", () => {
1766
+ loadPixelScript(`${r}?sdkid=${e}&lib=${t}`).then(() => {
1600
1767
  isPixelReady = true;
1601
1768
  resolve();
1602
- });
1603
- script.addEventListener("error", () => {
1604
- reject(new Error("Failed to load TikTok Pixel script"));
1605
- });
1606
- const firstScript = document.getElementsByTagName("script")[0];
1607
- firstScript.parentNode.insertBefore(script, firstScript);
1769
+ }).catch(reject);
1608
1770
  };
1609
1771
  ttq.load(
1610
1772
  config.pixelId,
1611
1773
  config.testMode ? { test_mode: true } : void 0
1612
1774
  );
1613
- if (window.ttq && typeof window.ttq === "function") {
1614
- isPixelReady = true;
1615
- resolve();
1616
- }
1617
1775
  })(window, document, "ttq");
1618
1776
  } catch (error) {
1619
1777
  reject(error);
@@ -1622,8 +1780,8 @@ function createTikTokPixelIntegration(config, debug) {
1622
1780
  },
1623
1781
  track: (eventName, properties, _context) => {
1624
1782
  const { ttq } = window;
1625
- if (!isPixelReady || !ttq) {
1626
- return;
1783
+ if (!isPixelReady || !ttq?.track) {
1784
+ throw new Error("TikTok Pixel not ready");
1627
1785
  }
1628
1786
  const ttEventName = getTikTokEventName(eventName, config.eventMapping);
1629
1787
  if (ttEventName === "PageView") {
@@ -1631,7 +1789,10 @@ function createTikTokPixelIntegration(config, debug) {
1631
1789
  return;
1632
1790
  }
1633
1791
  const ttParams = mapTikTokEventParameters(ttEventName, properties);
1634
- if (ttParams && Object.keys(ttParams).length > 0) {
1792
+ const eventId = properties?.["event_id"];
1793
+ if (typeof eventId === "string" && eventId.trim().length > 0) {
1794
+ ttq.track?.(ttEventName, ttParams ?? {}, { event_id: eventId });
1795
+ } else if (ttParams && Object.keys(ttParams).length > 0) {
1635
1796
  ttq.track?.(ttEventName, ttParams);
1636
1797
  } else {
1637
1798
  ttq.track?.(ttEventName);
@@ -1839,6 +2000,41 @@ function normalizeSaascoUrl(url) {
1839
2000
  return parsed.toString();
1840
2001
  }
1841
2002
 
2003
+ // src/lib/widgetLoader.ts
2004
+ function isEnabledPayload(data2) {
2005
+ return typeof data2 === "object" && data2 !== null && "enabled" in data2 && data2.enabled === true;
2006
+ }
2007
+ function getUrlOrigin(url) {
2008
+ try {
2009
+ return new URL(
2010
+ url,
2011
+ typeof window === "undefined" ? void 0 : window.location.href
2012
+ ).origin;
2013
+ } catch {
2014
+ }
2015
+ }
2016
+ function isLoaderPresent(fileName, injectedFlag) {
2017
+ return Boolean(
2018
+ injectedFlag || document.querySelector(`script[src*="${fileName}"]`)
2019
+ );
2020
+ }
2021
+ function appendLoaderScript({
2022
+ attributes,
2023
+ onLoad,
2024
+ src
2025
+ }) {
2026
+ const script = document.createElement("script");
2027
+ script.async = true;
2028
+ script.src = src;
2029
+ for (const [name, value] of Object.entries(attributes)) {
2030
+ script.setAttribute(name, value);
2031
+ }
2032
+ if (onLoad) {
2033
+ script.addEventListener("load", onLoad);
2034
+ }
2035
+ (document.body ?? document.head ?? document.documentElement).append(script);
2036
+ }
2037
+
1842
2038
  // src/lib/social-proof/storage.ts
1843
2039
  var WIDGET_PAYLOAD_PATH = "/api/social-proof/widget";
1844
2040
  var SOCIAL_PROOF_LOADER_FILE = "saasco-social-proof-loader.js";
@@ -1918,17 +2114,16 @@ async function injectSocialProofLoader({
1918
2114
  return;
1919
2115
  }
1920
2116
  window.__saascoSocialProofInjected = true;
1921
- const script = document.createElement("script");
1922
- script.async = true;
1923
- script.src = normalizeSaascoUrl(scriptUrl);
1924
- script.setAttribute("data-projectId", projectId);
2117
+ const attributes = {
2118
+ "data-projectId": projectId
2119
+ };
1925
2120
  if (baseUrlOverride !== void 0) {
1926
- script.setAttribute(
1927
- "data-base-url",
1928
- normalizeSaascoBaseUrl(baseUrlOverride)
1929
- );
2121
+ attributes["data-base-url"] = normalizeSaascoBaseUrl(baseUrlOverride);
1930
2122
  }
1931
- (document.body ?? document.head ?? document.documentElement).append(script);
2123
+ appendLoaderScript({
2124
+ attributes,
2125
+ src: normalizeSaascoUrl(scriptUrl)
2126
+ });
1932
2127
  }
1933
2128
  async function shouldInjectSocialProof({
1934
2129
  baseUrl,
@@ -1957,23 +2152,12 @@ async function shouldInjectSocialProof({
1957
2152
  writeSession(cacheKey, JSON.stringify({ data: data2, fetchedAt: Date.now() }));
1958
2153
  return isEnabledPayload(data2);
1959
2154
  }
1960
- function isEnabledPayload(data2) {
1961
- return typeof data2 === "object" && data2 !== null && data2.enabled === true;
1962
- }
1963
2155
  function isSocialProofLoaderPresent() {
1964
- return Boolean(
1965
- window.__saascoSocialProofInjected || document.querySelector(`script[src*="${SOCIAL_PROOF_LOADER_FILE}"]`)
2156
+ return isLoaderPresent(
2157
+ SOCIAL_PROOF_LOADER_FILE,
2158
+ window.__saascoSocialProofInjected
1966
2159
  );
1967
2160
  }
1968
- function getUrlOrigin(url) {
1969
- try {
1970
- return new URL(
1971
- url,
1972
- typeof window === "undefined" ? void 0 : window.location.href
1973
- ).origin;
1974
- } catch {
1975
- }
1976
- }
1977
2161
  function isSocialProofQaMode() {
1978
2162
  if (typeof window === "undefined") {
1979
2163
  return false;
@@ -1988,6 +2172,72 @@ function isSocialProofQaMode() {
1988
2172
  }
1989
2173
  }
1990
2174
 
2175
+ // src/lib/support/widgetEnabledCheck.ts
2176
+ var SUPPORT_WIDGET_PATH = "/api/support/widget";
2177
+ var SUPPORT_LOADER_FILE = "saasco-support-loader.js";
2178
+ async function injectSupportLoader({
2179
+ baseUrl: baseUrlOverride,
2180
+ onLoad,
2181
+ placeholder,
2182
+ projectId,
2183
+ scriptUrl
2184
+ }) {
2185
+ if (typeof window === "undefined" || typeof document === "undefined") {
2186
+ return "skipped";
2187
+ }
2188
+ if (isSupportLoaderPresent()) {
2189
+ return "present";
2190
+ }
2191
+ const baseUrl = normalizeSaascoBaseUrl(
2192
+ baseUrlOverride ?? getUrlOrigin(scriptUrl) ?? SAASCO_ORIGIN
2193
+ );
2194
+ const enabled = await shouldInjectSupport({
2195
+ baseUrl,
2196
+ projectId
2197
+ });
2198
+ if (!enabled) {
2199
+ return "skipped";
2200
+ }
2201
+ if (isSupportLoaderPresent()) {
2202
+ return "present";
2203
+ }
2204
+ window.__saascoSupportInjected = true;
2205
+ const attributes = {
2206
+ "data-projectId": projectId
2207
+ };
2208
+ if (baseUrlOverride !== void 0) {
2209
+ attributes["data-base-url"] = normalizeSaascoBaseUrl(baseUrlOverride);
2210
+ }
2211
+ if (placeholder !== void 0) {
2212
+ attributes["data-placeholder"] = placeholder;
2213
+ }
2214
+ appendLoaderScript({
2215
+ attributes,
2216
+ onLoad,
2217
+ src: normalizeSaascoUrl(scriptUrl)
2218
+ });
2219
+ return "injected";
2220
+ }
2221
+ async function shouldInjectSupport({
2222
+ baseUrl,
2223
+ projectId
2224
+ }) {
2225
+ try {
2226
+ const response = await fetch(
2227
+ `${baseUrl}${SUPPORT_WIDGET_PATH}?projectId=${encodeURIComponent(projectId)}`
2228
+ );
2229
+ if (!response.ok) {
2230
+ return false;
2231
+ }
2232
+ return isEnabledPayload(await response.json());
2233
+ } catch {
2234
+ return false;
2235
+ }
2236
+ }
2237
+ function isSupportLoaderPresent() {
2238
+ return isLoaderPresent(SUPPORT_LOADER_FILE, window.__saascoSupportInjected);
2239
+ }
2240
+
1991
2241
  // src/lib/analytics.ts
1992
2242
  function getSupportGlobal() {
1993
2243
  return window.SaascoSupport;
@@ -1997,10 +2247,6 @@ var isServer5 = !isBrowser5;
1997
2247
  var PREF = "saasco-sdk";
1998
2248
  var SESSION_DURATION = 1e3 * 60 * 30;
1999
2249
  var USER_DURATION = 1e3 * 60 * 60 * 24 * 365;
2000
- var SUPPORT_ATTR_MAP = {
2001
- baseUrl: "data-base-url",
2002
- placeholder: "data-placeholder"
2003
- };
2004
2250
  var data = {};
2005
2251
  function getRootDomain(url) {
2006
2252
  if (isServer5) {
@@ -2066,6 +2312,15 @@ function migrateFromLocalStorage() {
2066
2312
  if (isServer5) {
2067
2313
  return;
2068
2314
  }
2315
+ let storage;
2316
+ try {
2317
+ storage = globalThis.localStorage ?? null;
2318
+ } catch {
2319
+ return;
2320
+ }
2321
+ if (!storage) {
2322
+ return;
2323
+ }
2069
2324
  const keysToMigrate = [
2070
2325
  `${PREF}-session-id`,
2071
2326
  `${PREF}-anonymous-id`,
@@ -2075,20 +2330,23 @@ function migrateFromLocalStorage() {
2075
2330
  ];
2076
2331
  for (const key of keysToMigrate) {
2077
2332
  try {
2078
- const value = localStorage.getItem(key);
2333
+ const value = storage.getItem(key);
2079
2334
  if (value) {
2080
2335
  const item = JSON.parse(value);
2081
2336
  const now = Date.now();
2082
2337
  if (item.expiry > now) {
2083
2338
  const remainingTtl = item.expiry - now;
2084
2339
  setCookie(key, JSON.stringify(item.value), remainingTtl);
2085
- localStorage.removeItem(key);
2340
+ storage.removeItem(key);
2086
2341
  } else {
2087
- localStorage.removeItem(key);
2342
+ storage.removeItem(key);
2088
2343
  }
2089
2344
  }
2090
2345
  } catch {
2091
- localStorage.removeItem(key);
2346
+ try {
2347
+ storage.removeItem(key);
2348
+ } catch {
2349
+ }
2092
2350
  }
2093
2351
  }
2094
2352
  }
@@ -2289,7 +2547,7 @@ var Saasco = class {
2289
2547
  * @param config Configuration options.
2290
2548
  * @param config.projectId The unique identifier for the project.
2291
2549
  * @param config.proxy The URL of the proxy server to use, if any.
2292
- * @param config.autoPageTracking Whether to automatically track page views. Default is false.
2550
+ * @param config.autoPageTracking Whether to automatically track page views. Default is true.
2293
2551
  * @param config.enabled Whether analytics is enabled. Default is true. Set to false for development and staging envioronments. Will still allow debug mode to be true, just no events will be sent
2294
2552
  * @param config.debug Whether to log debug information. Default is false.
2295
2553
  * @param config.trackUrlParams Whether to track URL parameters. Default is true.
@@ -2319,7 +2577,8 @@ var Saasco = class {
2319
2577
  };
2320
2578
  this.integrationManager = new IntegrationManager({
2321
2579
  loggerLevel,
2322
- maxQueueSize: 1e3
2580
+ maxQueueSize: 1e3,
2581
+ deduplicationNamespace: this.config.projectId
2323
2582
  });
2324
2583
  this.integrationManager.setContext({});
2325
2584
  void (async () => {
@@ -2342,6 +2601,7 @@ var Saasco = class {
2342
2601
  // here so an `identify()` that fires before the widget bundle finishes
2343
2602
  // loading is replayed once it does.
2344
2603
  lastSupportIdentity = null;
2604
+ ingestFailureKeys = /* @__PURE__ */ new Set();
2345
2605
  init() {
2346
2606
  if (this.isInitialized) {
2347
2607
  this.logger.info(
@@ -2365,52 +2625,20 @@ var Saasco = class {
2365
2625
  );
2366
2626
  }
2367
2627
  this.initAutoPageTracking();
2368
- if (this.config.support && this.config.support.enabled !== false) {
2369
- this.injectSupport();
2370
- }
2371
- if (this.config.socialProof?.enabled !== false) {
2372
- void this.injectSocialProof();
2373
- }
2628
+ void this.injectSupport();
2629
+ void this.injectSocialProof();
2374
2630
  this.isInitialized = true;
2375
2631
  }
2376
2632
  disableDebug() {
2377
2633
  this.logger.info("Debug mode deactivated.");
2378
2634
  this.config.debug = false;
2635
+ this.logger.setLevel(getLoggerLevel(this.config));
2379
2636
  }
2380
2637
  enableDebug() {
2381
2638
  this.config.debug = true;
2639
+ this.logger.setLevel(getLoggerLevel(this.config));
2382
2640
  this.logger.info("Debug mode activated.");
2383
2641
  }
2384
- /**
2385
- * Enables and injects the support widget when it was constructed with
2386
- * `support: { enabled: false }`. Safe to call multiple times.
2387
- */
2388
- enableSupport() {
2389
- if (!this.config.support) {
2390
- this.logger.warn(
2391
- "Support is not configured; enableSupport() was ignored."
2392
- );
2393
- return;
2394
- }
2395
- this.config.support.enabled = true;
2396
- this.injectSupport();
2397
- this.pushSupportIdentity();
2398
- }
2399
- /**
2400
- * Enables and injects the social-proof widget (e.g. after constructing with
2401
- * `socialProof: { enabled: false }`, or when no `socialProof` block was
2402
- * passed). Injection still runs the server check, so the loader only
2403
- * downloads when the project has the app enabled in the dashboard.
2404
- * Safe to call multiple times — the loader injection is guarded against
2405
- * double-injection.
2406
- */
2407
- enableSocialProof() {
2408
- this.config.socialProof = {
2409
- ...this.config.socialProof,
2410
- enabled: true
2411
- };
2412
- void this.injectSocialProof();
2413
- }
2414
2642
  /**
2415
2643
  * Initialize third-party integrations
2416
2644
  */
@@ -2425,48 +2653,52 @@ var Saasco = class {
2425
2653
  );
2426
2654
  return;
2427
2655
  }
2428
- for (const integrationConfig of this.config.integrations) {
2429
- try {
2430
- if (integrationConfig.type === "facebook-pixel") {
2431
- this.logger.info(
2432
- "Registering Facebook Pixel integration with ID:",
2433
- integrationConfig.config.pixelId
2434
- );
2435
- const fbIntegration = createFacebookPixelIntegration(
2436
- integrationConfig.config,
2437
- integrationConfig.debug
2438
- );
2439
- await this.integrationManager.registerIntegration(fbIntegration);
2440
- } else if (integrationConfig.type === "pinterest-tag") {
2441
- this.logger.info(
2442
- "Registering Pinterest Tag integration with ID:",
2443
- integrationConfig.config.tagId
2444
- );
2445
- const pinterestIntegration = createPinterestTagIntegration(
2446
- integrationConfig.config,
2447
- integrationConfig.debug
2448
- );
2449
- await this.integrationManager.registerIntegration(
2450
- pinterestIntegration
2451
- );
2452
- } else if (integrationConfig.type === "tiktok-pixel") {
2656
+ await Promise.all(
2657
+ this.config.integrations.map(async (integrationConfig) => {
2658
+ try {
2659
+ if (integrationConfig.type === "facebook-pixel") {
2660
+ this.logger.info(
2661
+ "Registering Facebook Pixel integration with ID:",
2662
+ integrationConfig.config.pixelId
2663
+ );
2664
+ const fbIntegration = createFacebookPixelIntegration(
2665
+ integrationConfig.config,
2666
+ integrationConfig.debug
2667
+ );
2668
+ await this.integrationManager.registerIntegration(fbIntegration);
2669
+ } else if (integrationConfig.type === "pinterest-tag") {
2670
+ this.logger.info(
2671
+ "Registering Pinterest Tag integration with ID:",
2672
+ integrationConfig.config.tagId
2673
+ );
2674
+ const pinterestIntegration = createPinterestTagIntegration(
2675
+ integrationConfig.config,
2676
+ integrationConfig.debug
2677
+ );
2678
+ await this.integrationManager.registerIntegration(
2679
+ pinterestIntegration
2680
+ );
2681
+ } else if (integrationConfig.type === "tiktok-pixel") {
2682
+ this.logger.info(
2683
+ "Registering TikTok Pixel integration with ID:",
2684
+ integrationConfig.config.pixelId
2685
+ );
2686
+ const tiktokIntegration = createTikTokPixelIntegration(
2687
+ integrationConfig.config,
2688
+ integrationConfig.debug
2689
+ );
2690
+ await this.integrationManager.registerIntegration(
2691
+ tiktokIntegration
2692
+ );
2693
+ }
2694
+ } catch (error) {
2453
2695
  this.logger.info(
2454
- "Registering TikTok Pixel integration with ID:",
2455
- integrationConfig.config.pixelId
2456
- );
2457
- const tiktokIntegration = createTikTokPixelIntegration(
2458
- integrationConfig.config,
2459
- integrationConfig.debug
2696
+ `Failed to register ${integrationConfig.type} integration:`,
2697
+ error
2460
2698
  );
2461
- await this.integrationManager.registerIntegration(tiktokIntegration);
2462
2699
  }
2463
- } catch (error) {
2464
- this.logger.info(
2465
- `Failed to register ${integrationConfig.type} integration:`,
2466
- error
2467
- );
2468
- }
2469
- }
2700
+ })
2701
+ );
2470
2702
  }
2471
2703
  track(actionOrPayload, propertiesOrNothing, contextOrNothing) {
2472
2704
  if (!this.config.projectId) {
@@ -2523,11 +2755,23 @@ var Saasco = class {
2523
2755
  });
2524
2756
  }
2525
2757
  this.integrationManager.track(action, properties);
2526
- return this.doRequest("events", requestData);
2758
+ const eventId = properties?.["event_id"];
2759
+ if (!isBrowser5 || typeof eventId !== "string" || !eventId.trim()) {
2760
+ return this.doRequest("events", requestData);
2761
+ }
2762
+ return deliverOnce(
2763
+ `saasco:${this.config.projectId}:ingest:${action}:${eventId}`,
2764
+ async () => {
2765
+ const result = await this.doRequest("events", requestData);
2766
+ if (!result.success) {
2767
+ throw new Error(result.message);
2768
+ }
2769
+ }
2770
+ ).then(() => ({ success: true, message: "Event dispatched" })).catch((error) => ({ success: false, message: String(error) }));
2527
2771
  }
2528
2772
  /**
2529
2773
  * The page method lets you record page views on your website
2530
- * This records the page title and path and names the event useing the reserved property "Page Viewed"
2774
+ * This records the page title and path and names the event using the reserved name "Page View"
2531
2775
  *
2532
2776
  * Before implementing this make sure you have disabled the autoPageTracking in the config or you will get duplicate page views
2533
2777
  */
@@ -2623,7 +2867,10 @@ var Saasco = class {
2623
2867
  },
2624
2868
  context: context || {}
2625
2869
  };
2626
- this.logger.info("identify", requestData);
2870
+ this.logger.info("identify", {
2871
+ distinctId,
2872
+ projectId: this.config.projectId
2873
+ });
2627
2874
  if (!this.config.enabled) {
2628
2875
  return Promise.resolve({
2629
2876
  success: true,
@@ -2659,6 +2906,13 @@ var Saasco = class {
2659
2906
  message: "Analytics is disabled"
2660
2907
  };
2661
2908
  }
2909
+ const projectId = requestData.projectId ?? null;
2910
+ const fail = (input) => reportIngestFailure({
2911
+ failure: formatAnalyticsRequestFailure({ path, projectId, ...input }),
2912
+ logger: this.logger,
2913
+ path,
2914
+ seen: this.ingestFailureKeys
2915
+ });
2662
2916
  let response;
2663
2917
  try {
2664
2918
  response = await fetch(url, {
@@ -2668,46 +2922,22 @@ var Saasco = class {
2668
2922
  },
2669
2923
  body: JSON.stringify(requestData)
2670
2924
  });
2671
- } catch (error) {
2672
- const message = error instanceof Error ? error.message : String(error);
2673
- this.logger.warn(
2674
- `Network request failed`,
2675
- `
2676
- Error Message: "${message}"`,
2677
- `
2678
- Url: "${url}"`
2679
- );
2680
- return {
2681
- success: false,
2682
- message
2683
- };
2925
+ } catch {
2926
+ return fail({ reason: "unreachable" });
2684
2927
  }
2928
+ let responseBody;
2685
2929
  try {
2686
- const responseBody = await response.json();
2687
- if (!response.ok) {
2688
- throw new Error(
2689
- `${responseBody.message}: ${responseBody.errors ? `[${responseBody.errors.join(", ")}]` : ""}` || JSON.stringify(responseBody)
2690
- );
2691
- }
2692
- return {
2693
- success: true,
2694
- message: responseBody.message ?? ""
2695
- };
2696
- } catch (error) {
2697
- const message = error instanceof Error ? error.message : String(error);
2698
- this.logger.error(
2699
- `
2700
- Error Message: "${message}"`,
2701
- `
2702
- Path: "/${path}"`,
2703
- `
2704
- Request Data: ${JSON.stringify(requestData, null, 2)}`
2705
- );
2706
- return {
2707
- success: false,
2708
- message
2709
- };
2930
+ responseBody = await response.json();
2931
+ } catch {
2932
+ return fail({ reason: "http" });
2933
+ }
2934
+ if (!response.ok) {
2935
+ return fail({ reason: "http", body: responseBody });
2710
2936
  }
2937
+ return {
2938
+ success: true,
2939
+ message: responseBody && typeof responseBody === "object" && "message" in responseBody && typeof responseBody.message === "string" ? responseBody.message : ""
2940
+ };
2711
2941
  }
2712
2942
  /**
2713
2943
  * If autoPageTracking is enabled, this will automatically track page views
@@ -2751,51 +2981,25 @@ Request Data: ${JSON.stringify(requestData, null, 2)}`
2751
2981
  return this.integrationManager.getStats();
2752
2982
  }
2753
2983
  /**
2754
- * Lazily injects the support **loader** from the same `/sdk/` origin as
2755
- * the analytics bundle, forwarding the shared `projectId` and the widget
2756
- * config as `data-*` attributes. The loader (no React) injects the
2757
- * cross-origin embed iframe. Deferred (`async`) and guarded against
2758
- * double-injection so re-running `init()` is a no-op.
2984
+ * Lazily injects the support **loader** (server-gated). See
2985
+ * {@link injectSupportLoader}. Replays identity once the script loads.
2759
2986
  */
2760
- injectSupport() {
2761
- if (!isBrowser5 || typeof document === "undefined") {
2762
- return;
2763
- }
2764
- const chat = this.config.support;
2765
- if (!chat) {
2766
- return;
2767
- }
2768
- if (window.__saascoSupportInjected || document.querySelector('script[src*="saasco-support-loader.js"]')) {
2769
- return;
2770
- }
2771
- const scriptUrl = resolveLoaderScriptUrl(
2772
- "saasco-support-loader.js",
2773
- chat.scriptUrl
2774
- );
2775
- if (!scriptUrl) {
2776
- this.logger.warn(
2777
- "Support is enabled but the loader URL could not be resolved. Pass `support.scriptUrl` pointing at your hosted saasco-support-loader.js (or load the analytics SDK from the CDN)."
2778
- );
2987
+ async injectSupport() {
2988
+ if (!isBrowser5) {
2779
2989
  return;
2780
2990
  }
2781
- window.__saascoSupportInjected = true;
2782
- const script = document.createElement("script");
2783
- script.async = true;
2784
- script.src = normalizeSaascoUrl(scriptUrl);
2785
- script.setAttribute("data-projectId", this.config.projectId);
2786
- for (const [key, attr] of Object.entries(SUPPORT_ATTR_MAP)) {
2787
- const value = chat[key];
2788
- if (value !== void 0) {
2789
- script.setAttribute(
2790
- attr,
2791
- attr === "data-base-url" ? normalizeSaascoBaseUrl(value) : value
2792
- );
2793
- }
2794
- }
2795
- script.addEventListener("load", () => {
2796
- this.pushSupportIdentity();
2991
+ const { support } = this.config;
2992
+ const scriptUrl = resolveLoaderScriptUrl("saasco-support-loader.js", support?.scriptUrl) ?? `${SAASCO_ORIGIN}/sdk/saasco-support-loader.js`;
2993
+ const result = await injectSupportLoader({
2994
+ baseUrl: support?.baseUrl,
2995
+ onLoad: () => this.pushSupportIdentity(),
2996
+ placeholder: support?.placeholder,
2997
+ projectId: this.config.projectId,
2998
+ scriptUrl
2797
2999
  });
2798
- (document.body ?? document.head ?? document.documentElement).append(script);
3000
+ if (result === "present") {
3001
+ this.pushSupportIdentity();
3002
+ }
2799
3003
  }
2800
3004
  /**
2801
3005
  * Lazily injects the social-proof loader (server-gated). See
@@ -2818,27 +3022,23 @@ Request Data: ${JSON.stringify(requestData, null, 2)}`
2818
3022
  }
2819
3023
  /**
2820
3024
  * Records the latest CRM identity and forwards it to the support widget.
2821
- * No-op when support isn't enabled.
2822
3025
  */
2823
3026
  updateSupportIdentity(identity) {
2824
- if (!isBrowser5 || !this.config.support) {
3027
+ if (!isBrowser5) {
2825
3028
  return;
2826
3029
  }
2827
3030
  const hasTraits = identity.traits && Object.keys(identity.traits).length > 0;
2828
3031
  this.lastSupportIdentity = identity.distinctId || identity.email || hasTraits ? identity : null;
2829
- if (this.config.support.enabled === false) {
2830
- return;
2831
- }
2832
3032
  this.pushSupportIdentity();
2833
3033
  }
2834
3034
  /**
2835
3035
  * Pushes the current identity onto `window.SaascoSupport.identify`. The
2836
3036
  * widget bundle publishes that global asynchronously, so this short-polls for
2837
- * it (same approach as tool registration); the load handler also calls this,
2838
- * so a fresh page load with a stored distinctId still identifies the chat.
3037
+ * it (same approach as tool registration). `injectSupport` calls this on
3038
+ * script load (or immediately when the loader is already present).
2839
3039
  */
2840
3040
  pushSupportIdentity() {
2841
- if (!isBrowser5 || !this.config.support || this.config.support.enabled === false) {
3041
+ if (!isBrowser5) {
2842
3042
  return;
2843
3043
  }
2844
3044
  const identity = this.lastSupportIdentity ?? {
@@ -2965,31 +3165,31 @@ function getLoggerLevel(config) {
2965
3165
  }
2966
3166
 
2967
3167
  // ../shared/src/lib/types/browserContext.ts
2968
- import { z } from "zod";
2969
- var BrowserContextSchema = z.object({
2970
- $href: z.string(),
2971
- $locale: z.string(),
2972
- $location: z.string(),
2973
- $pathname: z.string(),
2974
- $referrer: z.string(),
2975
- $referringDomain: z.string(),
2976
- $screenDPI: z.number(),
2977
- $screenHeight: z.number(),
2978
- $screenWidth: z.number(),
2979
- $title: z.string(),
2980
- $userAgent: z.string(),
2981
- $utmAdId: z.string().nullable(),
2982
- $utmAdSource: z.string().nullable(),
2983
- $utmCampaign: z.string().nullable(),
2984
- $utmCampaignId: z.string().nullable(),
2985
- $utmContent: z.string().nullable(),
2986
- $utmCreativeFormat: z.string().nullable(),
2987
- $utmId: z.string().nullable(),
2988
- $utmMarketingTactic: z.string().nullable(),
2989
- $utmMedium: z.string().nullable(),
2990
- $utmSource: z.string().nullable(),
2991
- $utmSourcePlatform: z.string().nullable(),
2992
- $utmTerm: z.string().nullable()
3168
+ import { z as z2 } from "zod";
3169
+ var BrowserContextSchema = z2.object({
3170
+ $href: z2.string(),
3171
+ $locale: z2.string(),
3172
+ $location: z2.string(),
3173
+ $pathname: z2.string(),
3174
+ $referrer: z2.string(),
3175
+ $referringDomain: z2.string(),
3176
+ $screenDPI: z2.number(),
3177
+ $screenHeight: z2.number(),
3178
+ $screenWidth: z2.number(),
3179
+ $title: z2.string(),
3180
+ $userAgent: z2.string(),
3181
+ $utmAdId: z2.string().nullable(),
3182
+ $utmAdSource: z2.string().nullable(),
3183
+ $utmCampaign: z2.string().nullable(),
3184
+ $utmCampaignId: z2.string().nullable(),
3185
+ $utmContent: z2.string().nullable(),
3186
+ $utmCreativeFormat: z2.string().nullable(),
3187
+ $utmId: z2.string().nullable(),
3188
+ $utmMarketingTactic: z2.string().nullable(),
3189
+ $utmMedium: z2.string().nullable(),
3190
+ $utmSource: z2.string().nullable(),
3191
+ $utmSourcePlatform: z2.string().nullable(),
3192
+ $utmTerm: z2.string().nullable()
2993
3193
  });
2994
3194
  export {
2995
3195
  BrowserContextSchema,