@mushi-mushi/web 0.9.0 → 1.1.0

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.cjs CHANGED
@@ -935,6 +935,236 @@ function getWidgetStyles(theme) {
935
935
  letter-spacing: 0.02em;
936
936
  }
937
937
 
938
+ /* \u2500\u2500 Rewards nudge (category step) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
939
+ .mushi-rewards-nudge {
940
+ border-top: 1px solid ${rule};
941
+ padding: 10px 0 4px;
942
+ margin-top: 6px;
943
+ }
944
+ .mushi-rewards-row {
945
+ display: flex;
946
+ align-items: center;
947
+ gap: 6px;
948
+ margin-bottom: 8px;
949
+ }
950
+ .mushi-tier-pip {
951
+ width: 7px;
952
+ height: 7px;
953
+ border-radius: 50%;
954
+ flex-shrink: 0;
955
+ }
956
+ .mushi-rewards-tier-name {
957
+ font-family: ${fontMono};
958
+ font-size: 11px;
959
+ letter-spacing: 0.08em;
960
+ text-transform: uppercase;
961
+ color: ${ink};
962
+ }
963
+ .mushi-rewards-pts-count {
964
+ font-family: ${fontMono};
965
+ font-size: 11px;
966
+ color: ${inkMuted};
967
+ margin-right: auto;
968
+ }
969
+ .mushi-rewards-pts-earn {
970
+ font-family: ${fontMono};
971
+ font-size: 10px;
972
+ color: ${vermillion};
973
+ letter-spacing: 0.04em;
974
+ white-space: nowrap;
975
+ }
976
+ .mushi-tier-bar-track {
977
+ height: 3px;
978
+ background: ${ruleStrong};
979
+ border-radius: 2px;
980
+ overflow: hidden;
981
+ margin-bottom: 5px;
982
+ }
983
+ .mushi-tier-bar-fill {
984
+ height: 100%;
985
+ background: ${vermillion};
986
+ border-radius: 2px;
987
+ transition: width 600ms ${easeStamp};
988
+ }
989
+ .mushi-rewards-next-label {
990
+ font-family: ${fontMono};
991
+ font-size: 10px;
992
+ color: ${inkMuted};
993
+ text-align: right;
994
+ letter-spacing: 0.02em;
995
+ }
996
+
997
+ /* \u2500\u2500 Rewards on success step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
998
+ .mushi-success-rewards {
999
+ margin-top: 14px;
1000
+ padding-top: 12px;
1001
+ border-top: 1px solid ${rule};
1002
+ width: 100%;
1003
+ }
1004
+ .mushi-success-pts-award {
1005
+ font-family: ${fontMono};
1006
+ font-size: 22px;
1007
+ font-weight: 700;
1008
+ color: ${vermillion};
1009
+ text-align: center;
1010
+ letter-spacing: 0.06em;
1011
+ margin-bottom: 10px;
1012
+ opacity: 0;
1013
+ animation: mushi-pts-pop 420ms ${easeStamp} 900ms forwards;
1014
+ }
1015
+ .success-bar { margin: 0 0 5px; }
1016
+
1017
+ @keyframes mushi-pts-pop {
1018
+ from { opacity: 0; transform: scale(0.75) translateY(6px); }
1019
+ to { opacity: 1; transform: scale(1) translateY(0); }
1020
+ }
1021
+
1022
+ /* \u2500\u2500\u2500 Beta mode strip (category step) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1023
+
1024
+ .mushi-beta-strip {
1025
+ margin: 0 16px 2px;
1026
+ padding: 9px 12px;
1027
+ background: rgba(99, 102, 241, 0.07);
1028
+ border: 1px solid rgba(99, 102, 241, 0.18);
1029
+ border-radius: 8px;
1030
+ display: flex;
1031
+ flex-direction: column;
1032
+ gap: 4px;
1033
+ }
1034
+
1035
+ .mushi-beta-strip-row {
1036
+ display: flex;
1037
+ align-items: center;
1038
+ gap: 8px;
1039
+ }
1040
+
1041
+ .mushi-beta-tag {
1042
+ display: inline-flex;
1043
+ align-items: center;
1044
+ padding: 1px 6px;
1045
+ border-radius: 4px;
1046
+ background: rgba(245, 158, 11, 0.15);
1047
+ border: 1px solid rgba(245, 158, 11, 0.35);
1048
+ color: #b45309;
1049
+ font-family: var(--mushi-font-mono);
1050
+ font-size: 9px;
1051
+ font-weight: 700;
1052
+ letter-spacing: 0.08em;
1053
+ line-height: 1.6;
1054
+ white-space: nowrap;
1055
+ flex-shrink: 0;
1056
+ }
1057
+
1058
+ @media (prefers-color-scheme: dark) {
1059
+ .mushi-beta-tag {
1060
+ background: rgba(245, 158, 11, 0.12);
1061
+ border-color: rgba(245, 158, 11, 0.28);
1062
+ color: #fbbf24;
1063
+ }
1064
+ }
1065
+
1066
+ .mushi-beta-msg {
1067
+ font-size: 11px;
1068
+ color: var(--mushi-text-dim);
1069
+ line-height: 1.45;
1070
+ }
1071
+
1072
+ .mushi-beta-contact-hint {
1073
+ font-size: 10px;
1074
+ color: var(--mushi-text-dim);
1075
+ opacity: 0.72;
1076
+ font-family: var(--mushi-font-mono);
1077
+ }
1078
+
1079
+ .mushi-beta-perks {
1080
+ list-style: none;
1081
+ margin: 2px 0 0;
1082
+ padding: 0;
1083
+ display: flex;
1084
+ flex-direction: column;
1085
+ gap: 2px;
1086
+ }
1087
+
1088
+ .mushi-beta-perks li {
1089
+ font-size: 10.5px;
1090
+ color: #4f46e5;
1091
+ font-weight: 500;
1092
+ }
1093
+
1094
+ @media (prefers-color-scheme: dark) {
1095
+ .mushi-beta-perks li {
1096
+ color: #818cf8;
1097
+ }
1098
+ }
1099
+
1100
+ /* \u2500\u2500\u2500 Beta changelog (collapsible What's new) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1101
+
1102
+ .mushi-changelog {
1103
+ margin-top: 5px;
1104
+ }
1105
+
1106
+ .mushi-changelog-summary {
1107
+ font-size: 10.5px;
1108
+ color: var(--mushi-text-dim);
1109
+ cursor: pointer;
1110
+ list-style: none;
1111
+ display: flex;
1112
+ align-items: center;
1113
+ gap: 4px;
1114
+ user-select: none;
1115
+ }
1116
+
1117
+ .mushi-changelog-summary::before {
1118
+ content: '\u25B6';
1119
+ font-size: 7px;
1120
+ opacity: 0.6;
1121
+ transition: transform 0.15s ease;
1122
+ }
1123
+
1124
+ .mushi-changelog[open] .mushi-changelog-summary::before {
1125
+ transform: rotate(90deg);
1126
+ }
1127
+
1128
+ .mushi-changelog-list {
1129
+ margin: 5px 0 0 4px;
1130
+ padding: 0;
1131
+ list-style: none;
1132
+ display: flex;
1133
+ flex-direction: column;
1134
+ gap: 2px;
1135
+ }
1136
+
1137
+ .mushi-changelog-list li {
1138
+ font-size: 10.5px;
1139
+ color: var(--mushi-text-dim);
1140
+ line-height: 1.5;
1141
+ }
1142
+
1143
+ /* \u2500\u2500\u2500 Beta success footer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1144
+
1145
+ .mushi-beta-success-footer {
1146
+ margin-top: 14px;
1147
+ padding: 10px 14px;
1148
+ background: rgba(99, 102, 241, 0.06);
1149
+ border: 1px solid rgba(99, 102, 241, 0.14);
1150
+ border-radius: 8px;
1151
+ display: flex;
1152
+ flex-direction: column;
1153
+ gap: 3px;
1154
+ text-align: left;
1155
+ }
1156
+
1157
+ .mushi-beta-success-line {
1158
+ font-size: 11px;
1159
+ color: var(--mushi-text-dim);
1160
+ line-height: 1.5;
1161
+ }
1162
+
1163
+ .mushi-beta-success-dim {
1164
+ opacity: 0.65;
1165
+ font-size: 10.5px;
1166
+ }
1167
+
938
1168
  @media (prefers-reduced-motion: reduce) {
939
1169
  *,
940
1170
  *::before,
@@ -945,6 +1175,7 @@ function getWidgetStyles(theme) {
945
1175
  }
946
1176
  .mushi-success-stamp circle { stroke-dashoffset: 0; }
947
1177
  .mushi-success-stamp-label { opacity: 1; }
1178
+ .mushi-success-pts-award { opacity: 1; }
948
1179
  }
949
1180
  `;
950
1181
  }
@@ -1000,7 +1231,8 @@ var MushiWidget = class {
1000
1231
  smartHide: config.smartHide ?? false,
1001
1232
  draggable: config.draggable ?? false,
1002
1233
  brandFooter: config.brandFooter ?? true,
1003
- outdatedBanner: config.outdatedBanner ?? "auto"
1234
+ outdatedBanner: config.outdatedBanner ?? "auto",
1235
+ betaMode: config.betaMode ?? {}
1004
1236
  };
1005
1237
  this.callbacks = callbacks;
1006
1238
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
@@ -1044,6 +1276,7 @@ var MushiWidget = class {
1044
1276
  * root) for up to ~3.3s after destroy. */
1045
1277
  successTimer = null;
1046
1278
  autoCloseTimer = null;
1279
+ rewardsState = null;
1047
1280
  mount() {
1048
1281
  if (this.host.isConnected) return;
1049
1282
  document.body.appendChild(this.host);
@@ -1075,7 +1308,8 @@ var MushiWidget = class {
1075
1308
  ...config.smartHide !== void 0 ? { smartHide: config.smartHide } : {},
1076
1309
  ...config.draggable !== void 0 ? { draggable: config.draggable } : {},
1077
1310
  ...config.brandFooter !== void 0 ? { brandFooter: config.brandFooter } : {},
1078
- ...config.outdatedBanner !== void 0 ? { outdatedBanner: config.outdatedBanner } : {}
1311
+ ...config.outdatedBanner !== void 0 ? { outdatedBanner: config.outdatedBanner } : {},
1312
+ ...config.betaMode !== void 0 ? { betaMode: config.betaMode } : {}
1079
1313
  };
1080
1314
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
1081
1315
  this.syncAttachedLaunchers();
@@ -1150,6 +1384,10 @@ var MushiWidget = class {
1150
1384
  this.sdkFreshness = info;
1151
1385
  if (this.isOpen) this.render();
1152
1386
  }
1387
+ setRewardsState(state) {
1388
+ this.rewardsState = state;
1389
+ if (this.isOpen) this.render();
1390
+ }
1153
1391
  destroy() {
1154
1392
  if (this.successTimer !== null) {
1155
1393
  clearTimeout(this.successTimer);
@@ -1394,6 +1632,7 @@ var MushiWidget = class {
1394
1632
  `).join("");
1395
1633
  return `
1396
1634
  ${this.renderHeader({ title: t.step1.heading, step: STEP_NUMBER.category })}
1635
+ ${this.config.betaMode?.enabled ? this.renderBetaStrip() : ""}
1397
1636
  <div class="mushi-body" role="radiogroup" aria-label="${t.step1.heading}">
1398
1637
  <button type="button" class="mushi-option-btn mushi-reports-entry" data-action="reports">
1399
1638
  <span class="mushi-option-icon" aria-hidden="true">\u{1F4EC}</span>
@@ -1404,10 +1643,52 @@ var MushiWidget = class {
1404
1643
  <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
1405
1644
  </button>
1406
1645
  ${categories}
1646
+ ${this.rewardsState ? this.renderRewardsNudge() : ""}
1407
1647
  </div>
1408
1648
  ${this.renderStepIndicator(STEP_NUMBER.category)}
1409
1649
  `;
1410
1650
  }
1651
+ /** Collapsible "What's new" changelog row. Closes the reporter feedback loop. */
1652
+ renderBetaChangelog() {
1653
+ const entries = this.config.betaMode?.changelogItems;
1654
+ if (!entries?.length) return "";
1655
+ const latest = entries[0];
1656
+ const items = latest.items.map((item) => `<li>\u2022 ${escapeHtml(item)}</li>`).join("");
1657
+ const label = latest.date ? `What\u2019s new in ${escapeHtml(latest.version)} \xB7 ${escapeHtml(latest.date)}` : `What\u2019s new in ${escapeHtml(latest.version)}`;
1658
+ return `
1659
+ <details class="mushi-changelog">
1660
+ <summary class="mushi-changelog-summary">${label}</summary>
1661
+ <ul class="mushi-changelog-list">${items}</ul>
1662
+ </details>
1663
+ `;
1664
+ }
1665
+ /**
1666
+ * Discreet beta status strip: communicates "work in progress", invites
1667
+ * feedback, and sets expectations — reducing user frustration while
1668
+ * nudging the reciprocity instinct ("your reports help us build this").
1669
+ */
1670
+ renderBetaStrip() {
1671
+ const beta = this.config.betaMode;
1672
+ const appName = escapeHtml(beta.appName ?? "This app");
1673
+ const message = beta.message ? escapeHtml(beta.message) : `${appName} is in early development \u2014 updates ship weekly`;
1674
+ const email = beta.contactEmail ? escapeHtml(beta.contactEmail) : null;
1675
+ const perks = beta.perks ?? [];
1676
+ return `
1677
+ <div class="mushi-beta-strip" role="note" aria-label="Beta status">
1678
+ <div class="mushi-beta-strip-row">
1679
+ <span class="mushi-beta-tag" aria-hidden="true">BETA</span>
1680
+ <span class="mushi-beta-msg">${message}</span>
1681
+ </div>
1682
+ ${email ? `<div class="mushi-beta-contact-hint">Reports go to ${email} \xB7 reviewed by the team</div>` : ""}
1683
+ ${perks.length > 0 ? `
1684
+ <ul class="mushi-beta-perks" aria-label="Beta tester perks">
1685
+ ${perks.map((p) => `<li>\u2713 ${escapeHtml(p)}</li>`).join("")}
1686
+ </ul>
1687
+ ` : ""}
1688
+ ${this.renderBetaChangelog()}
1689
+ </div>
1690
+ `;
1691
+ }
1411
1692
  renderReportsStep() {
1412
1693
  const reports = this.reporterReports.map((report) => `
1413
1694
  <button type="button" class="mushi-report-row" data-report-id="${escapeHtml(report.id)}">
@@ -1528,7 +1809,92 @@ var MushiWidget = class {
1528
1809
  </div>
1529
1810
  <div class="mushi-success-headline">${t.widget.submitted}</div>
1530
1811
  <div class="mushi-success-meta">REPORT \xB7 ${time}</div>
1812
+ ${this.rewardsState ? this.renderSuccessRewards() : ""}
1813
+ ${this.config.betaMode?.enabled ? this.renderBetaSuccessFooter() : ""}
1814
+ </div>
1815
+ </div>
1816
+ `;
1817
+ }
1818
+ /**
1819
+ * Reciprocity footer on the success step: closes the feedback loop by
1820
+ * attributing where the report goes, sets a response expectation, and
1821
+ * reinforces the "beta tester" identity (Peak-End Rule — the last thing
1822
+ * the user sees shapes their entire impression of the interaction).
1823
+ */
1824
+ renderBetaSuccessFooter() {
1825
+ const beta = this.config.betaMode;
1826
+ const email = beta.contactEmail ? escapeHtml(beta.contactEmail) : null;
1827
+ const appName = escapeHtml(beta.appName ?? "the team");
1828
+ return `
1829
+ <div class="mushi-beta-success-footer" role="note" aria-label="Beta feedback acknowledgement">
1830
+ ${email ? `<div class="mushi-beta-success-line">\u{1F4EC} Sent to ${email}</div>` : `<div class="mushi-beta-success-line">\u{1F4EC} Sent to ${appName}</div>`}
1831
+ <div class="mushi-beta-success-line mushi-beta-success-dim">We aim to review within 48h \xB7 thank you for helping build this</div>
1832
+ </div>
1833
+ `;
1834
+ }
1835
+ tierColor(slug) {
1836
+ const colors = {
1837
+ free: "#6b7280",
1838
+ explorer: "#3b82f6",
1839
+ contributor: "#8b5cf6",
1840
+ champion: "#f59e0b"
1841
+ };
1842
+ return colors[slug] ?? "#6c47ff";
1843
+ }
1844
+ /** Compact rewards nudge rendered at the bottom of the category-step body. */
1845
+ renderRewardsNudge() {
1846
+ const { tier, nextTier, totalPoints, pointsForReport } = this.rewardsState;
1847
+ const tierName = tier?.displayName ?? "Free";
1848
+ const tierSlug = tier?.slug ?? "free";
1849
+ const color = this.tierColor(tierSlug);
1850
+ let pct = 100;
1851
+ let nextLabel = "";
1852
+ if (nextTier) {
1853
+ const base = tier?.pointsThreshold ?? 0;
1854
+ const ceiling = nextTier.pointsThreshold;
1855
+ pct = ceiling > base ? Math.round(Math.min(1, (totalPoints - base) / (ceiling - base)) * 100) : 100;
1856
+ const remaining = Math.max(0, ceiling - totalPoints);
1857
+ nextLabel = `${remaining.toLocaleString()} pts to ${escapeHtml(nextTier.displayName)}`;
1858
+ }
1859
+ return `
1860
+ <div class="mushi-rewards-nudge" aria-label="Rewards progress">
1861
+ <div class="mushi-rewards-row">
1862
+ <span class="mushi-tier-pip" style="background:${color}" aria-hidden="true"></span>
1863
+ <span class="mushi-rewards-tier-name">${escapeHtml(tierName)}</span>
1864
+ <span class="mushi-rewards-pts-count">${totalPoints.toLocaleString()} pts</span>
1865
+ <span class="mushi-rewards-pts-earn">+${pointsForReport} pts for a report</span>
1531
1866
  </div>
1867
+ ${nextTier ? `
1868
+ <div class="mushi-tier-bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="Progress to ${escapeHtml(nextTier.displayName)}">
1869
+ <div class="mushi-tier-bar-fill" style="width:${pct}%"></div>
1870
+ </div>
1871
+ <div class="mushi-rewards-next-label">${nextLabel}</div>
1872
+ ` : ""}
1873
+ </div>
1874
+ `;
1875
+ }
1876
+ /** Points earned + tier progress shown on the success step. */
1877
+ renderSuccessRewards() {
1878
+ const { tier, nextTier, totalPoints, pointsForReport } = this.rewardsState;
1879
+ const projected = totalPoints + pointsForReport;
1880
+ let pctAfter = 100;
1881
+ let nextLabel = "";
1882
+ if (nextTier) {
1883
+ const base = tier?.pointsThreshold ?? 0;
1884
+ const ceiling = nextTier.pointsThreshold;
1885
+ pctAfter = ceiling > base ? Math.round(Math.min(1, (projected - base) / (ceiling - base)) * 100) : 100;
1886
+ const remaining = Math.max(0, ceiling - projected);
1887
+ nextLabel = remaining > 0 ? `${remaining.toLocaleString()} pts to ${escapeHtml(nextTier.displayName)}` : `\u{1F389} ${escapeHtml(nextTier.displayName)} reached!`;
1888
+ }
1889
+ return `
1890
+ <div class="mushi-success-rewards">
1891
+ <div class="mushi-success-pts-award">+${pointsForReport} pts</div>
1892
+ ${nextTier ? `
1893
+ <div class="mushi-tier-bar-track success-bar" role="progressbar" aria-valuenow="${pctAfter}" aria-valuemin="0" aria-valuemax="100" aria-label="Progress to ${escapeHtml(nextTier.displayName)}">
1894
+ <div class="mushi-tier-bar-fill" style="width:${pctAfter}%"></div>
1895
+ </div>
1896
+ <div class="mushi-rewards-next-label">${nextLabel}</div>
1897
+ ` : ""}
1532
1898
  </div>
1533
1899
  `;
1534
1900
  }
@@ -1692,6 +2058,304 @@ var MushiWidget = class {
1692
2058
  }
1693
2059
  };
1694
2060
 
2061
+ // src/rewards.ts
2062
+ var MIN_FLUSH_INTERVAL = 3e4;
2063
+ var DEFAULT_FLUSH_INTERVAL = 3e5;
2064
+ var DWELL_SAMPLE_INTERVAL = 6e4;
2065
+ var MAX_SESSION_MINUTES_PER_DAY = 60;
2066
+ var DAILY_RESET_KEY_PREFIX = "mushi_session_min_day_";
2067
+ var pendingEvents = [];
2068
+ var flushTimer = null;
2069
+ var dwellTimer = null;
2070
+ var currentUserId = null;
2071
+ var currentUserTraits = null;
2072
+ var reporterTokenHash = null;
2073
+ var apiClient = null;
2074
+ var optedIn = false;
2075
+ var tierCache = null;
2076
+ var tierCacheTime = 0;
2077
+ var TIER_CACHE_TTL = 5 * 60 * 1e3;
2078
+ var seenRoutes = /* @__PURE__ */ new Set();
2079
+ function getConsentKey(projectId) {
2080
+ return `mushi_rewards_consent_${projectId}`;
2081
+ }
2082
+ function isConsentGranted(projectId) {
2083
+ try {
2084
+ return localStorage.getItem(getConsentKey(projectId)) === "1";
2085
+ } catch {
2086
+ return false;
2087
+ }
2088
+ }
2089
+ function setConsentGranted(projectId, granted) {
2090
+ try {
2091
+ if (granted) {
2092
+ localStorage.setItem(getConsentKey(projectId), "1");
2093
+ } else {
2094
+ localStorage.removeItem(getConsentKey(projectId));
2095
+ }
2096
+ optedIn = granted;
2097
+ apiClient?.submitActivity(currentUserId ?? "", [], { optedIn: granted }).catch(() => {
2098
+ });
2099
+ } catch {
2100
+ }
2101
+ }
2102
+ function getTodayKey() {
2103
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2104
+ }
2105
+ function getSessionMinutesToday(projectId) {
2106
+ try {
2107
+ const val = sessionStorage.getItem(`${DAILY_RESET_KEY_PREFIX}${projectId}_${getTodayKey()}`);
2108
+ return val ? parseInt(val, 10) : 0;
2109
+ } catch {
2110
+ return 0;
2111
+ }
2112
+ }
2113
+ function incrementSessionMinutes(projectId) {
2114
+ const today = getTodayKey();
2115
+ const key = `${DAILY_RESET_KEY_PREFIX}${projectId}_${today}`;
2116
+ try {
2117
+ const next = getSessionMinutesToday(projectId) + 1;
2118
+ sessionStorage.setItem(key, String(next));
2119
+ return next;
2120
+ } catch {
2121
+ return 99;
2122
+ }
2123
+ }
2124
+ function initRewards(ctx) {
2125
+ apiClient = ctx.client;
2126
+ currentUserId = ctx.userId;
2127
+ currentUserTraits = ctx.traits ?? null;
2128
+ reporterTokenHash = ctx.reporterToken ?? null;
2129
+ const { projectId } = ctx;
2130
+ const flushMs = Math.max(
2131
+ MIN_FLUSH_INTERVAL,
2132
+ ctx.config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL
2133
+ );
2134
+ if (ctx.config.consentMode === "auto") {
2135
+ optedIn = true;
2136
+ setConsentGranted(projectId, true);
2137
+ } else {
2138
+ optedIn = isConsentGranted(projectId);
2139
+ }
2140
+ if (ctx.config.trackActivity) {
2141
+ installActivityListeners(projectId);
2142
+ }
2143
+ if (flushTimer) clearInterval(flushTimer);
2144
+ flushTimer = setInterval(() => flush(ctx), flushMs);
2145
+ if (dwellTimer) clearInterval(dwellTimer);
2146
+ dwellTimer = setInterval(() => {
2147
+ if (!optedIn || !currentUserId) return;
2148
+ const minutes = getSessionMinutesToday(projectId);
2149
+ if (minutes < MAX_SESSION_MINUTES_PER_DAY) {
2150
+ incrementSessionMinutes(projectId);
2151
+ enqueue({ action: "session_minute", metadata: { minutes_today: minutes + 1 } });
2152
+ }
2153
+ }, DWELL_SAMPLE_INTERVAL);
2154
+ if (ctx.config.showInWidget) {
2155
+ fetchAndCacheTier(currentUserId).then((tier) => {
2156
+ if (tier) renderTierBadge(tier, ctx.config);
2157
+ });
2158
+ }
2159
+ if (ctx.config.consentMode !== "auto" && !optedIn) {
2160
+ renderConsentBanner(projectId, ctx.config);
2161
+ }
2162
+ }
2163
+ function updateRewardsUser(userId, traits) {
2164
+ currentUserId = userId;
2165
+ currentUserTraits = traits ?? null;
2166
+ tierCache = null;
2167
+ tierCacheTime = 0;
2168
+ }
2169
+ function enqueue(event) {
2170
+ if (!optedIn || !currentUserId) return;
2171
+ pendingEvents.push({ ...event, queuedAt: Date.now() });
2172
+ }
2173
+ async function flush(ctx) {
2174
+ if (!optedIn || !currentUserId || pendingEvents.length === 0) return;
2175
+ let hostJwt = null;
2176
+ if (ctx.config.verifyUserToken) {
2177
+ try {
2178
+ hostJwt = await ctx.config.verifyUserToken();
2179
+ } catch {
2180
+ }
2181
+ }
2182
+ const batch = pendingEvents.splice(0, 100);
2183
+ try {
2184
+ await ctx.client.submitActivity(currentUserId, batch, {
2185
+ userTraits: currentUserTraits ?? void 0,
2186
+ reporterTokenHash: reporterTokenHash ?? void 0,
2187
+ optedIn: true,
2188
+ hostJwt: hostJwt ?? void 0
2189
+ });
2190
+ } catch {
2191
+ pendingEvents.unshift(...batch.slice(0, 50));
2192
+ }
2193
+ }
2194
+ async function getTier(userId) {
2195
+ const now = Date.now();
2196
+ if (tierCache && now - tierCacheTime < TIER_CACHE_TTL) return tierCache;
2197
+ return fetchAndCacheTier(userId);
2198
+ }
2199
+ async function fetchAndCacheTier(userId) {
2200
+ if (!apiClient) return null;
2201
+ const res = await apiClient.getMyTier(userId);
2202
+ if (res.ok && res.data) {
2203
+ tierCache = res.data;
2204
+ tierCacheTime = Date.now();
2205
+ return tierCache;
2206
+ }
2207
+ return null;
2208
+ }
2209
+ var routeObserver = null;
2210
+ var clickHandler = null;
2211
+ var origPushState = null;
2212
+ var lastRoute = "";
2213
+ function installActivityListeners(projectId) {
2214
+ const emitRoute = () => {
2215
+ const route = location.pathname;
2216
+ if (route === lastRoute) return;
2217
+ lastRoute = route;
2218
+ const isNewToday = !seenRoutes.has(`${projectId}:${route}`);
2219
+ if (isNewToday) {
2220
+ seenRoutes.add(`${projectId}:${route}`);
2221
+ enqueue({ action: "screen_view_unique_per_day", metadata: { route } });
2222
+ }
2223
+ };
2224
+ origPushState = history.pushState.bind(history);
2225
+ history.pushState = function(...args) {
2226
+ origPushState(...args);
2227
+ emitRoute();
2228
+ };
2229
+ window.addEventListener("popstate", emitRoute);
2230
+ emitRoute();
2231
+ routeObserver = new MutationObserver(() => emitRoute());
2232
+ const main = document.querySelector("main") ?? document.body;
2233
+ routeObserver.observe(main, { childList: true, subtree: false });
2234
+ clickHandler = (e) => {
2235
+ const target = e.target.closest("[data-testid]");
2236
+ if (!target) return;
2237
+ const testid = target.dataset.testid;
2238
+ if (!testid) return;
2239
+ enqueue({ action: "element_selected", metadata: { testid, route: location.pathname } });
2240
+ };
2241
+ document.addEventListener("click", clickHandler, { capture: true, passive: true });
2242
+ }
2243
+ var badgeHost = null;
2244
+ function renderTierBadge(tier, config) {
2245
+ if (!config.showInWidget) return;
2246
+ if (badgeHost) badgeHost.remove();
2247
+ badgeHost = document.createElement("div");
2248
+ badgeHost.id = "mushi-tier-badge";
2249
+ Object.assign(badgeHost.style, {
2250
+ position: "fixed",
2251
+ bottom: "56px",
2252
+ // above the widget button
2253
+ right: "16px",
2254
+ zIndex: "2147483645",
2255
+ fontFamily: "system-ui, sans-serif"
2256
+ });
2257
+ const shadow = badgeHost.attachShadow({ mode: "closed" });
2258
+ shadow.innerHTML = `
2259
+ <style>
2260
+ :host { display: block; }
2261
+ .badge {
2262
+ display: inline-flex;
2263
+ align-items: center;
2264
+ gap: 6px;
2265
+ padding: 4px 10px;
2266
+ border-radius: 999px;
2267
+ background: rgba(0,0,0,0.75);
2268
+ color: #fff;
2269
+ font-size: 11px;
2270
+ font-weight: 600;
2271
+ letter-spacing: 0.02em;
2272
+ backdrop-filter: blur(6px);
2273
+ cursor: default;
2274
+ user-select: none;
2275
+ }
2276
+ .dot {
2277
+ width: 6px;
2278
+ height: 6px;
2279
+ border-radius: 50%;
2280
+ background: #6c47ff;
2281
+ flex-shrink: 0;
2282
+ }
2283
+ </style>
2284
+ <div class="badge">
2285
+ <span class="dot"></span>
2286
+ <span>${tier.displayName}</span>
2287
+ </div>
2288
+ `;
2289
+ document.body.appendChild(badgeHost);
2290
+ }
2291
+ var consentHost = null;
2292
+ function renderConsentBanner(projectId, config) {
2293
+ if (consentHost) return;
2294
+ consentHost = document.createElement("div");
2295
+ consentHost.id = "mushi-consent-banner";
2296
+ Object.assign(consentHost.style, {
2297
+ position: "fixed",
2298
+ bottom: "80px",
2299
+ right: "16px",
2300
+ zIndex: "2147483646",
2301
+ maxWidth: "280px",
2302
+ fontFamily: "system-ui, sans-serif"
2303
+ });
2304
+ const shadow = consentHost.attachShadow({ mode: "closed" });
2305
+ shadow.innerHTML = `
2306
+ <style>
2307
+ :host { display: block; }
2308
+ .banner {
2309
+ background: #fff;
2310
+ border: 1px solid #e5e7eb;
2311
+ border-radius: 12px;
2312
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
2313
+ padding: 14px 16px;
2314
+ font-size: 13px;
2315
+ line-height: 1.5;
2316
+ color: #374151;
2317
+ }
2318
+ .title { font-weight: 700; margin-bottom: 6px; color: #111827; }
2319
+ .actions { display: flex; gap: 8px; margin-top: 10px; }
2320
+ button {
2321
+ flex: 1;
2322
+ padding: 6px 10px;
2323
+ border-radius: 6px;
2324
+ border: none;
2325
+ cursor: pointer;
2326
+ font-size: 12px;
2327
+ font-weight: 600;
2328
+ }
2329
+ .accept { background: #6c47ff; color: #fff; }
2330
+ .decline { background: #f3f4f6; color: #374151; }
2331
+ </style>
2332
+ <div class="banner">
2333
+ <div class="title">\u{1F3AF} Earn rewards</div>
2334
+ <div>Help improve this app and earn points, badges, and perks for your contributions.</div>
2335
+ <div class="actions">
2336
+ <button class="accept" id="accept">Enable</button>
2337
+ <button class="decline" id="decline">No thanks</button>
2338
+ </div>
2339
+ </div>
2340
+ `;
2341
+ shadow.getElementById("accept")?.addEventListener("click", () => {
2342
+ setConsentGranted(projectId, true);
2343
+ consentHost?.remove();
2344
+ consentHost = null;
2345
+ if (config.showInWidget && currentUserId) {
2346
+ fetchAndCacheTier(currentUserId).then((tier) => {
2347
+ if (tier) renderTierBadge(tier, config);
2348
+ });
2349
+ }
2350
+ });
2351
+ shadow.getElementById("decline")?.addEventListener("click", () => {
2352
+ setConsentGranted(projectId, false);
2353
+ consentHost?.remove();
2354
+ consentHost = null;
2355
+ });
2356
+ document.body.appendChild(consentHost);
2357
+ }
2358
+
1695
2359
  // src/capture/console.ts
1696
2360
  var MAX_ENTRIES = 50;
1697
2361
  var MAX_MESSAGE_LENGTH = 500;
@@ -2468,42 +3132,211 @@ function createDiscoveryCapture(opts) {
2468
3132
  // src/sentry.ts
2469
3133
  function getSentryGlobal() {
2470
3134
  try {
2471
- const win = globalThis;
2472
- if (win.__SENTRY__) {
2473
- const sentry = win.__SENTRY__;
2474
- const hub = sentry.hub;
2475
- return hub;
3135
+ const w = globalThis;
3136
+ if (w.Sentry) return w.Sentry;
3137
+ return void 0;
3138
+ } catch {
3139
+ return void 0;
3140
+ }
3141
+ }
3142
+ function getSentryReplayGlobal() {
3143
+ try {
3144
+ const w = globalThis;
3145
+ return w.__SENTRY_REPLAY__;
3146
+ } catch {
3147
+ return void 0;
3148
+ }
3149
+ }
3150
+ function detectSentrySdkFamily() {
3151
+ try {
3152
+ const w = globalThis;
3153
+ const meta = w.__SENTRY__;
3154
+ const sentry = w.Sentry;
3155
+ if (meta?.version === "9" || sentry && typeof sentry.lastEventId === "function") {
3156
+ return meta?.version === "9" ? "v9" : "v8";
3157
+ }
3158
+ if (meta?.version === "8") return "v8";
3159
+ if (sentry && typeof sentry.getCurrentHub === "function") return "v7";
3160
+ return "unknown";
3161
+ } catch {
3162
+ return "unknown";
3163
+ }
3164
+ }
3165
+ function captureSentryContext(_config, options = {}) {
3166
+ const limit = Math.max(0, options.breadcrumbsLimit ?? 30);
3167
+ const out = {};
3168
+ const sentry = getSentryGlobal();
3169
+ if (!sentry) return out;
3170
+ out.sdk = detectSentrySdkFamily();
3171
+ try {
3172
+ const v8 = sentry;
3173
+ if (typeof v8.lastEventId === "function") {
3174
+ out.eventId = v8.lastEventId() ?? void 0;
3175
+ } else {
3176
+ const v7 = sentry;
3177
+ const scope2 = v7.getCurrentHub?.()?.getScope?.();
3178
+ out.eventId = scope2?.getLastEventId?.() ?? void 0;
3179
+ }
3180
+ } catch {
3181
+ }
3182
+ let scope;
3183
+ try {
3184
+ const v8 = sentry;
3185
+ if (typeof v8.getCurrentScope === "function") {
3186
+ scope = v8.getCurrentScope();
3187
+ } else {
3188
+ const v7 = sentry;
3189
+ scope = v7.getCurrentHub?.()?.getScope?.();
3190
+ }
3191
+ } catch {
3192
+ }
3193
+ if (scope) {
3194
+ try {
3195
+ const user = scope.getUser?.();
3196
+ if (user) {
3197
+ out.user = {
3198
+ id: typeof user.id === "string" ? user.id : void 0,
3199
+ email: typeof user.email === "string" ? user.email : void 0,
3200
+ username: typeof user.username === "string" ? user.username : void 0,
3201
+ ip_address: typeof user.ip_address === "string" ? user.ip_address : void 0
3202
+ };
3203
+ }
3204
+ } catch {
3205
+ }
3206
+ try {
3207
+ const tags = scope.getTags?.();
3208
+ if (tags && typeof tags === "object") {
3209
+ const pruned = {};
3210
+ for (const [k, v] of Object.entries(tags)) {
3211
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
3212
+ pruned[k] = v;
3213
+ }
3214
+ }
3215
+ if (Object.keys(pruned).length > 0) out.tags = pruned;
3216
+ }
3217
+ } catch {
3218
+ }
3219
+ try {
3220
+ out.transactionName = scope.getTransactionName?.() ?? scope.getTransaction?.()?.name ?? void 0;
3221
+ } catch {
3222
+ }
3223
+ try {
3224
+ out.sessionId = scope.getSession?.()?.sid ?? void 0;
3225
+ } catch {
3226
+ }
3227
+ try {
3228
+ const raw = scope.getBreadcrumbs?.() ?? scope._breadcrumbs ?? [];
3229
+ if (Array.isArray(raw) && raw.length > 0) {
3230
+ const sliced = raw.slice(-limit);
3231
+ out.breadcrumbs = sliced.map((b) => {
3232
+ const r = b;
3233
+ return {
3234
+ timestamp: typeof r.timestamp === "number" ? (
3235
+ // Sentry stores breadcrumb timestamps in seconds; convert
3236
+ // to ms so the field is comparable to Mushi's own.
3237
+ r.timestamp < 1e12 ? Math.round(r.timestamp * 1e3) : r.timestamp
3238
+ ) : void 0,
3239
+ category: typeof r.category === "string" ? r.category : void 0,
3240
+ level: typeof r.level === "string" ? r.level : void 0,
3241
+ message: typeof r.message === "string" ? r.message : void 0,
3242
+ type: typeof r.type === "string" ? r.type : void 0,
3243
+ data: r.data && typeof r.data === "object" ? r.data : void 0
3244
+ };
3245
+ });
3246
+ }
3247
+ } catch {
3248
+ }
3249
+ }
3250
+ try {
3251
+ const v8 = sentry;
3252
+ let span;
3253
+ if (typeof v8.getActiveSpan === "function") {
3254
+ span = v8.getActiveSpan();
3255
+ } else if (scope?.getSpan) {
3256
+ span = scope.getSpan();
3257
+ }
3258
+ if (span) {
3259
+ const ctx = span.spanContext?.();
3260
+ out.traceId = ctx?.traceId ?? span.traceId ?? void 0;
3261
+ out.spanId = ctx?.spanId ?? span.spanId ?? void 0;
2476
3262
  }
2477
- if (win.Sentry) {
2478
- return win.Sentry;
3263
+ } catch {
3264
+ }
3265
+ let client;
3266
+ try {
3267
+ const v8 = sentry;
3268
+ if (typeof v8.getClient === "function") {
3269
+ client = v8.getClient();
3270
+ } else {
3271
+ const v7 = sentry;
3272
+ client = v7.getCurrentHub?.()?.getClient?.();
3273
+ }
3274
+ } catch {
3275
+ }
3276
+ if (client) {
3277
+ try {
3278
+ const opts = client.getOptions?.();
3279
+ if (opts?.release) out.release = opts.release;
3280
+ if (opts?.environment) out.environment = opts.environment;
3281
+ } catch {
2479
3282
  }
3283
+ try {
3284
+ const dsn = client.getDsn?.();
3285
+ if (dsn?.host && dsn?.projectId && out.eventId) {
3286
+ const orgHost = dsn.host.replace(/^o\d+\./, "");
3287
+ out.issueUrl = `https://${orgHost}/issues/?query=${encodeURIComponent(out.eventId)}`;
3288
+ }
3289
+ } catch {
3290
+ }
3291
+ }
3292
+ try {
3293
+ const v8 = sentry;
3294
+ const replay = v8.getReplay?.() ?? getSentryReplayGlobal();
3295
+ out.replayId = replay?.getReplayId?.() ?? void 0;
2480
3296
  } catch {
2481
3297
  }
2482
- return void 0;
3298
+ return out;
2483
3299
  }
2484
- function captureSentryContext(_config) {
2485
- const context = {};
3300
+ function tagSentryScope(reportId, options = {}) {
3301
+ const sentry = getSentryGlobal();
3302
+ if (!sentry) return;
2486
3303
  try {
2487
- const hub = getSentryGlobal();
2488
- if (!hub) return context;
2489
- const scope = hub.getScope?.();
2490
- if (scope) {
2491
- context.eventId = scope.getLastEventId?.() ?? void 0;
3304
+ const v8 = sentry;
3305
+ if (typeof v8.setTag === "function") {
3306
+ v8.setTag("mushi.report_id", reportId);
3307
+ if (options.reportUrl) v8.setTag("mushi.report_url", options.reportUrl);
3308
+ }
3309
+ if (typeof v8.setContext === "function") {
3310
+ v8.setContext("mushi_report", {
3311
+ id: reportId,
3312
+ ...options.reportUrl ? { url: options.reportUrl } : {},
3313
+ captured_at: (/* @__PURE__ */ new Date()).toISOString()
3314
+ });
2492
3315
  }
2493
- const client = hub.getClient?.();
2494
- if (client) {
2495
- const options = client.getOptions?.();
2496
- context.release = options?.release;
2497
- context.environment = options?.environment;
3316
+ if (typeof v8.addBreadcrumb === "function") {
3317
+ v8.addBreadcrumb({
3318
+ category: "mushi",
3319
+ type: "info",
3320
+ level: "info",
3321
+ message: `Mushi report submitted (${reportId})`,
3322
+ data: { report_id: reportId, ...options.reportUrl ? { url: options.reportUrl } : {} }
3323
+ });
2498
3324
  }
2499
- const win = globalThis;
2500
- if (win.__SENTRY_REPLAY__) {
2501
- const replay = win.__SENTRY_REPLAY__;
2502
- context.replayId = replay.getReplayId?.() ?? void 0;
3325
+ } catch {
3326
+ }
3327
+ try {
3328
+ const v7 = sentry;
3329
+ const scope = v7.getCurrentHub?.()?.getScope?.();
3330
+ if (scope) {
3331
+ scope.setTag?.("mushi.report_id", reportId);
3332
+ if (options.reportUrl) scope.setTag?.("mushi.report_url", options.reportUrl);
3333
+ scope.setContext?.("mushi_report", {
3334
+ id: reportId,
3335
+ ...options.reportUrl ? { url: options.reportUrl } : {}
3336
+ });
2503
3337
  }
2504
3338
  } catch {
2505
3339
  }
2506
- return context;
2507
3340
  }
2508
3341
 
2509
3342
  // src/proactive-triggers.ts
@@ -2680,7 +3513,7 @@ function createProactiveManager(config = {}) {
2680
3513
 
2681
3514
  // src/version.ts
2682
3515
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/web";
2683
- var MUSHI_SDK_VERSION = "0.9.0" ;
3516
+ var MUSHI_SDK_VERSION = "1.1.0" ;
2684
3517
 
2685
3518
  // src/mushi.ts
2686
3519
  var instance = null;
@@ -2719,7 +3552,7 @@ function createInstance(config) {
2719
3552
  const bootstrapConfig = applyPresetConfig(config);
2720
3553
  let activeConfig = bootstrapConfig;
2721
3554
  const log = config.debug ?? false ? core.createLogger({ scope: "mushi", level: "debug", format: "pretty" }) : core.noopLogger;
2722
- const apiClient = core.createApiClient({
3555
+ const apiClient2 = core.createApiClient({
2723
3556
  projectId: bootstrapConfig.projectId,
2724
3557
  apiKey: bootstrapConfig.apiKey,
2725
3558
  ...bootstrapConfig.apiEndpoint ? { apiEndpoint: bootstrapConfig.apiEndpoint } : {}
@@ -2728,6 +3561,30 @@ function createInstance(config) {
2728
3561
  const offlineQueue = core.createOfflineQueue(bootstrapConfig.offline);
2729
3562
  const rateLimiter = core.createRateLimiter({ maxBurst: 10, refillRate: 1, refillIntervalMs: 5e3 });
2730
3563
  const piiScrubber = core.createPiiScrubber();
3564
+ function scrubBreadcrumbsForWire(crumbs) {
3565
+ return crumbs.map((c) => {
3566
+ const next = { ...c };
3567
+ if (typeof c.message === "string") {
3568
+ next.message = piiScrubber.scrub(c.message);
3569
+ }
3570
+ if (c.data && typeof c.data === "object") {
3571
+ const cleaned = {};
3572
+ for (const [k, v] of Object.entries(c.data)) {
3573
+ cleaned[k] = typeof v === "string" ? piiScrubber.scrub(v) : v;
3574
+ }
3575
+ next.data = cleaned;
3576
+ }
3577
+ return next;
3578
+ });
3579
+ }
3580
+ function scrubTagsForWire(tags) {
3581
+ if (!tags) return void 0;
3582
+ const out = {};
3583
+ for (const [k, v] of Object.entries(tags)) {
3584
+ out[k] = typeof v === "string" ? piiScrubber.scrub(v) : v;
3585
+ }
3586
+ return out;
3587
+ }
2731
3588
  let consoleCap = null;
2732
3589
  let networkCap = null;
2733
3590
  let perfCap = null;
@@ -2804,7 +3661,7 @@ function createInstance(config) {
2804
3661
  getUserId: () => userInfo?.id ?? null,
2805
3662
  getSessionId: core.getSessionId,
2806
3663
  onEvent: (event) => {
2807
- void apiClient.postDiscoveryEvent({
3664
+ void apiClient2.postDiscoveryEvent({
2808
3665
  ...event,
2809
3666
  sdk_version: MUSHI_SDK_VERSION
2810
3667
  }).catch((err) => {
@@ -2827,6 +3684,16 @@ function createInstance(config) {
2827
3684
  let runtimeConfigLoaded = false;
2828
3685
  let userInfo = null;
2829
3686
  const customMetadata = {};
3687
+ const stickyTags = {};
3688
+ const breadcrumbs = core.createBreadcrumbBuffer({ max: 50 });
3689
+ breadcrumbs.add({
3690
+ category: "lifecycle",
3691
+ level: "info",
3692
+ message: "Mushi SDK init",
3693
+ data: { projectId: bootstrapConfig.projectId, sdkVersion: MUSHI_SDK_VERSION }
3694
+ });
3695
+ let detachAutoBreadcrumbs = null;
3696
+ detachAutoBreadcrumbs = installAutoBreadcrumbs(breadcrumbs);
2830
3697
  widget = new MushiWidget(bootstrapConfig.widget, {
2831
3698
  onSubmit: async ({ category, description, intent }) => {
2832
3699
  log.info("Report submitted", { category, intent });
@@ -2870,17 +3737,17 @@ function createInstance(config) {
2870
3737
  }
2871
3738
  },
2872
3739
  async onReporterReportsRequest() {
2873
- const result = await apiClient.listReporterReports(core.getReporterToken());
3740
+ const result = await apiClient2.listReporterReports(core.getReporterToken());
2874
3741
  if (!result.ok) throw new Error(result.error?.message ?? "Could not load reports");
2875
3742
  return result.data?.reports ?? [];
2876
3743
  },
2877
3744
  async onReporterCommentsRequest(reportId) {
2878
- const result = await apiClient.listReporterComments(reportId, core.getReporterToken());
3745
+ const result = await apiClient2.listReporterComments(reportId, core.getReporterToken());
2879
3746
  if (!result.ok) throw new Error(result.error?.message ?? "Could not load thread");
2880
3747
  return result.data?.comments ?? [];
2881
3748
  },
2882
3749
  async onReporterReply(reportId, body) {
2883
- const result = await apiClient.replyToReporterReport(reportId, core.getReporterToken(), body);
3750
+ const result = await apiClient2.replyToReporterReport(reportId, core.getReporterToken(), body);
2884
3751
  if (!result.ok) throw new Error(result.error?.message ?? "Could not send reply");
2885
3752
  }
2886
3753
  }, MUSHI_SDK_VERSION);
@@ -2926,8 +3793,8 @@ function createInstance(config) {
2926
3793
  errorBoundary: proactiveCfg?.errorBoundary === true
2927
3794
  });
2928
3795
  }
2929
- offlineQueue.startAutoSync(apiClient);
2930
- offlineQueue.flush(apiClient).then((result) => {
3796
+ offlineQueue.startAutoSync(apiClient2);
3797
+ offlineQueue.flush(apiClient2).then((result) => {
2931
3798
  if (result.sent > 0) log.info("Synced offline reports", { sent: result.sent });
2932
3799
  });
2933
3800
  function applyRuntimeConfig(runtime) {
@@ -2948,7 +3815,7 @@ function createInstance(config) {
2948
3815
  if (shouldUseRuntimeConfig(config)) {
2949
3816
  const cached = readCachedRuntimeConfig(config.projectId);
2950
3817
  if (cached) applyRuntimeConfig(cached);
2951
- apiClient.getSdkConfig().then((result) => {
3818
+ apiClient2.getSdkConfig().then((result) => {
2952
3819
  if (result.ok && result.data) {
2953
3820
  cacheRuntimeConfig(config.projectId, result.data);
2954
3821
  applyRuntimeConfig(result.data);
@@ -2967,7 +3834,7 @@ function createInstance(config) {
2967
3834
  if (activeConfig.widget?.outdatedBanner === "off") return;
2968
3835
  const cached = readCachedSdkVersion(MUSHI_SDK_PACKAGE);
2969
3836
  if (cached) applySdkFreshness(cached);
2970
- const result = await apiClient.getLatestSdkVersion(MUSHI_SDK_PACKAGE);
3837
+ const result = await apiClient2.getLatestSdkVersion(MUSHI_SDK_PACKAGE);
2971
3838
  if (!result.ok || !result.data) return;
2972
3839
  cacheSdkVersion(MUSHI_SDK_PACKAGE, result.data);
2973
3840
  applySdkFreshness(result.data);
@@ -3036,6 +3903,15 @@ function createInstance(config) {
3036
3903
  const fingerprintHash = await core.getDeviceFingerprintHash().catch(() => null);
3037
3904
  const consoleLogs = activeConfig.capture?.console === false ? void 0 : consoleCap?.getEntries();
3038
3905
  const networkLogs = activeConfig.capture?.network === false ? void 0 : networkCap?.getEntries();
3906
+ const reportBreadcrumbs = scrubBreadcrumbsForWire(breadcrumbs.getAll());
3907
+ const stickyTagSnapshot = scrubTagsForWire(
3908
+ Object.keys(stickyTags).length > 0 ? { ...stickyTags } : void 0
3909
+ );
3910
+ const sentryCtxScrubbed = sentryCtx ? {
3911
+ ...sentryCtx,
3912
+ ...sentryCtx.breadcrumbs ? { breadcrumbs: scrubBreadcrumbsForWire(sentryCtx.breadcrumbs) } : {},
3913
+ ...sentryCtx.tags ? { tags: scrubTagsForWire(sentryCtx.tags) } : {}
3914
+ } : void 0;
3039
3915
  const report = {
3040
3916
  id: crypto.randomUUID?.() ?? `mushi_${Date.now()}_${Math.random().toString(36).slice(2)}`,
3041
3917
  projectId: config.projectId,
@@ -3061,10 +3937,24 @@ function createInstance(config) {
3061
3937
  sdkPackage: MUSHI_SDK_PACKAGE,
3062
3938
  sdkVersion: MUSHI_SDK_VERSION,
3063
3939
  proactiveTrigger: pendingProactiveTrigger ?? void 0,
3940
+ // Top-level Sentry-grade observability fields. Breadcrumbs are
3941
+ // surfaced separately from `consoleLogs` because they're the
3942
+ // higher-signal "what just happened" trail (vs. the high-volume
3943
+ // raw console mirror), and the admin /reports drawer shows them
3944
+ // in different panes.
3945
+ ...reportBreadcrumbs.length > 0 ? { breadcrumbs: reportBreadcrumbs } : {},
3946
+ ...stickyTagSnapshot ? { tags: stickyTagSnapshot } : {},
3947
+ ...sentryCtxScrubbed ? { sentryContext: sentryCtxScrubbed } : {},
3064
3948
  sentryEventId: sentryCtx?.eventId,
3065
3949
  sentryReplayId: sentryCtx?.replayId,
3066
3950
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
3067
3951
  };
3952
+ breadcrumbs.add({
3953
+ category: "lifecycle",
3954
+ level: "info",
3955
+ message: `Mushi report submitting (${category})`,
3956
+ data: { reportId: report.id, category }
3957
+ });
3068
3958
  if (config.integrations?.custom) {
3069
3959
  const builder = {
3070
3960
  addMetadata(key, value) {
@@ -3086,14 +3976,34 @@ function createInstance(config) {
3086
3976
  emit("report:queued", { reportId: report.id });
3087
3977
  return;
3088
3978
  }
3089
- const result = await apiClient.submitReport(report);
3979
+ const result = await apiClient2.submitReport(report);
3090
3980
  if (result.ok) {
3091
3981
  log.info("Report sent", { reportId: result.data?.reportId });
3092
3982
  emit("report:sent", { reportId: result.data?.reportId });
3983
+ breadcrumbs.add({
3984
+ category: "lifecycle",
3985
+ level: "info",
3986
+ message: `Mushi report sent (${result.data?.reportId ?? report.id})`
3987
+ });
3988
+ enqueue({
3989
+ action: "report_submit",
3990
+ metadata: { category, reportId: result.data?.reportId ?? report.id }
3991
+ });
3992
+ try {
3993
+ if (config.sentry && result.data?.reportId) {
3994
+ tagSentryScope(result.data.reportId);
3995
+ }
3996
+ } catch {
3997
+ }
3093
3998
  } else {
3094
3999
  log.warn("Report failed, queuing for retry", { reportId: report.id, error: result.error });
3095
4000
  await offlineQueue.enqueue(report);
3096
4001
  emit("report:failed", { reportId: report.id, error: result.error });
4002
+ breadcrumbs.add({
4003
+ category: "lifecycle",
4004
+ level: "warning",
4005
+ message: `Mushi report queued for retry (${report.id})`
4006
+ });
3097
4007
  }
3098
4008
  pendingScreenshot = null;
3099
4009
  pendingElement = null;
@@ -3165,6 +4075,9 @@ function createInstance(config) {
3165
4075
  discoveryCap?.destroy();
3166
4076
  discoveryCap = null;
3167
4077
  offlineQueue.stopAutoSync();
4078
+ detachAutoBreadcrumbs?.();
4079
+ detachAutoBreadcrumbs = null;
4080
+ breadcrumbs.clear();
3168
4081
  listeners.clear();
3169
4082
  instance = null;
3170
4083
  log.debug("Destroyed");
@@ -3179,6 +4092,16 @@ function createInstance(config) {
3179
4092
  }
3180
4093
  const description = piiScrubber.scrub(preFilter.truncate(input.description));
3181
4094
  const category = input.category ?? "bug";
4095
+ const sentryCtx = config.sentry ? captureSentryContext(config.sentry) : void 0;
4096
+ const captureBreadcrumbs = scrubBreadcrumbsForWire(breadcrumbs.getAll());
4097
+ const mergedTags = scrubTagsForWire(
4098
+ Object.keys(stickyTags).length === 0 && !input.tags ? void 0 : { ...stickyTags, ...input.tags ?? {} }
4099
+ );
4100
+ const sentryCtxScrubbed = sentryCtx ? {
4101
+ ...sentryCtx,
4102
+ ...sentryCtx.breadcrumbs ? { breadcrumbs: scrubBreadcrumbsForWire(sentryCtx.breadcrumbs) } : {},
4103
+ ...sentryCtx.tags ? { tags: scrubTagsForWire(sentryCtx.tags) } : {}
4104
+ } : void 0;
3182
4105
  const report = {
3183
4106
  id: crypto.randomUUID?.() ?? `mushi_${Date.now()}_${Math.random().toString(36).slice(2)}`,
3184
4107
  projectId: config.projectId,
@@ -3189,16 +4112,20 @@ function createInstance(config) {
3189
4112
  metadata: {
3190
4113
  ...input.metadata ?? {},
3191
4114
  ...userInfo ? { user: userInfo } : {},
3192
- ...input.tags ? { tags: input.tags } : {},
3193
4115
  ...input.error ? { error: input.error } : {},
3194
4116
  ...input.severity ? { severity: input.severity } : {},
3195
4117
  ...input.component ? { component: input.component } : {},
3196
4118
  ...input.source ? { source: input.source } : { source: "captureEvent" }
3197
4119
  },
4120
+ ...captureBreadcrumbs.length > 0 ? { breadcrumbs: captureBreadcrumbs } : {},
4121
+ ...mergedTags && Object.keys(mergedTags).length > 0 ? { tags: mergedTags } : {},
4122
+ ...sentryCtxScrubbed ? { sentryContext: sentryCtxScrubbed } : {},
3198
4123
  sessionId: core.getSessionId(),
3199
4124
  reporterToken: core.getReporterToken(),
3200
4125
  sdkPackage: MUSHI_SDK_PACKAGE,
3201
4126
  sdkVersion: MUSHI_SDK_VERSION,
4127
+ sentryEventId: sentryCtx?.eventId,
4128
+ sentryReplayId: sentryCtx?.replayId,
3202
4129
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
3203
4130
  };
3204
4131
  emit("report:submitted", { reportId: report.id });
@@ -3207,15 +4134,46 @@ function createInstance(config) {
3207
4134
  emit("report:queued", { reportId: report.id });
3208
4135
  return null;
3209
4136
  }
3210
- const res = await apiClient.submitReport(report);
4137
+ const res = await apiClient2.submitReport(report);
3211
4138
  if (res.ok) {
3212
4139
  emit("report:sent", { reportId: res.data?.reportId });
4140
+ try {
4141
+ if (config.sentry && res.data?.reportId) tagSentryScope(res.data.reportId);
4142
+ } catch {
4143
+ }
3213
4144
  return res.data?.reportId ?? null;
3214
4145
  }
3215
4146
  await offlineQueue.enqueue(report);
3216
4147
  emit("report:failed", { reportId: report.id, error: res.error });
3217
4148
  return null;
3218
4149
  },
4150
+ async captureException(error, options) {
4151
+ const normalised = core.normaliseThrown(error);
4152
+ breadcrumbs.add({
4153
+ category: "lifecycle",
4154
+ level: "error",
4155
+ message: `Mushi.captureException(${normalised.name}): ${normalised.message}`,
4156
+ ...normalised.stack ? { data: { stack: normalised.stack.slice(0, 500) } } : {}
4157
+ });
4158
+ const description = options?.description?.trim() || `${normalised.name}: ${normalised.message}` || "Uncaught exception";
4159
+ return sdk.captureEvent({
4160
+ description,
4161
+ category: options?.category ?? "bug",
4162
+ severity: options?.severity ?? "high",
4163
+ ...options?.component ? { component: options.component } : {},
4164
+ ...options?.tags ? { tags: options.tags } : {},
4165
+ source: options?.source ?? "captureException",
4166
+ error: {
4167
+ name: normalised.name,
4168
+ message: normalised.message,
4169
+ ...normalised.stack ? { stack: normalised.stack } : {}
4170
+ },
4171
+ metadata: {
4172
+ ...options?.metadata ?? {},
4173
+ ...normalised.cause ? { cause: normalised.cause } : {}
4174
+ }
4175
+ });
4176
+ },
3219
4177
  identify(userId, traits) {
3220
4178
  userInfo = { id: userId, ...traits?.email ? { email: traits.email } : {}, ...traits?.name ? { name: traits.name } : {} };
3221
4179
  if (traits) {
@@ -3223,6 +4181,84 @@ function createInstance(config) {
3223
4181
  if (k !== "email" && k !== "name") customMetadata[`user.${k}`] = v;
3224
4182
  }
3225
4183
  }
4184
+ breadcrumbs.add({
4185
+ category: "lifecycle",
4186
+ level: "info",
4187
+ message: `Mushi.identify(${userId})`
4188
+ });
4189
+ if (activeConfig.rewards?.enabled) {
4190
+ const rewardsCtx = {
4191
+ client: apiClient2,
4192
+ config: activeConfig.rewards,
4193
+ projectId: bootstrapConfig.projectId,
4194
+ userId,
4195
+ traits: traits ? { email: traits.email, name: traits.name, provider: traits.provider } : void 0
4196
+ };
4197
+ if (userInfo.id === userId) {
4198
+ initRewards(rewardsCtx);
4199
+ } else {
4200
+ updateRewardsUser(userId, rewardsCtx.traits);
4201
+ }
4202
+ if (activeConfig.rewards.showInWidget !== false) {
4203
+ void apiClient2.getMyPoints(userId).then((res) => {
4204
+ if (!res.ok) return;
4205
+ const d = res.data;
4206
+ widget.setRewardsState({
4207
+ tier: d.tier ? { slug: d.tier.slug ?? "free", displayName: d.tier.display_name ?? "Free", pointsThreshold: d.tier.points_threshold ?? 0 } : null,
4208
+ nextTier: d.next_tier ? { displayName: d.next_tier.display_name ?? "", pointsThreshold: d.next_tier.points_threshold ?? 0 } : null,
4209
+ totalPoints: d.total_points ?? 0,
4210
+ pointsForReport: d.report_submit_pts ?? 50
4211
+ });
4212
+ }).catch(() => {
4213
+ });
4214
+ }
4215
+ }
4216
+ },
4217
+ addBreadcrumb(crumb) {
4218
+ breadcrumbs.add(crumb);
4219
+ },
4220
+ getBreadcrumbs() {
4221
+ return breadcrumbs.getAll();
4222
+ },
4223
+ setTag(key, value) {
4224
+ if (typeof key !== "string" || key.length === 0) return;
4225
+ stickyTags[key] = value;
4226
+ },
4227
+ setTags(tags) {
4228
+ if (!tags || typeof tags !== "object") return;
4229
+ for (const [k, v] of Object.entries(tags)) {
4230
+ if (typeof k === "string" && k.length > 0) {
4231
+ stickyTags[k] = v;
4232
+ }
4233
+ }
4234
+ },
4235
+ clearTag(key) {
4236
+ if (typeof key === "string" && key.length > 0) {
4237
+ delete stickyTags[key];
4238
+ return;
4239
+ }
4240
+ for (const k of Object.keys(stickyTags)) delete stickyTags[k];
4241
+ },
4242
+ // ─── Rewards program (P1) ──────────────────────────────────
4243
+ async getReputation() {
4244
+ if (!userInfo?.id) return null;
4245
+ const res = await apiClient2.getMyPoints(userInfo.id);
4246
+ if (!res.ok) return null;
4247
+ return {
4248
+ totalPoints: res.data.total_points ?? 0,
4249
+ points30d: res.data.points_30d ?? 0,
4250
+ reputation: 1,
4251
+ confirmedBugs: 0,
4252
+ totalReports: 0
4253
+ };
4254
+ },
4255
+ async getTier() {
4256
+ if (!userInfo?.id) return null;
4257
+ return getTier(userInfo.id);
4258
+ },
4259
+ recordActivity(action, metadata) {
4260
+ if (!activeConfig.rewards?.enabled) return;
4261
+ enqueue({ action, metadata });
3226
4262
  }
3227
4263
  };
3228
4264
  return sdk;
@@ -3235,7 +4271,10 @@ function mergeRuntimeConfig(config, runtime) {
3235
4271
  widget: {
3236
4272
  ...config.widget,
3237
4273
  ...runtime.widget,
3238
- ...widgetTrigger ? { trigger: widgetTrigger } : {}
4274
+ ...widgetTrigger ? { trigger: widgetTrigger } : {},
4275
+ // betaMode is local-only: set by the host app, not the dashboard.
4276
+ // Restore it after the runtime spread so it is never silently cleared.
4277
+ ...config.widget?.betaMode ? { betaMode: config.widget.betaMode } : {}
3239
4278
  },
3240
4279
  capture: {
3241
4280
  ...config.capture,
@@ -3460,10 +4499,148 @@ function createNoopInstance() {
3460
4499
  instance = null;
3461
4500
  },
3462
4501
  captureEvent: async () => null,
4502
+ captureException: async () => null,
3463
4503
  identify: () => {
4504
+ },
4505
+ addBreadcrumb: () => {
4506
+ },
4507
+ getBreadcrumbs: () => [],
4508
+ setTag: () => {
4509
+ },
4510
+ setTags: () => {
4511
+ },
4512
+ clearTag: () => {
4513
+ },
4514
+ getReputation: async () => null,
4515
+ getTier: async () => null,
4516
+ recordActivity: () => {
3464
4517
  }
3465
4518
  };
3466
4519
  }
4520
+ function installAutoBreadcrumbs(buffer) {
4521
+ if (typeof window === "undefined") return () => {
4522
+ };
4523
+ const cleanups = [];
4524
+ try {
4525
+ const dispatchRouteChange = (kind) => {
4526
+ buffer.add({
4527
+ category: "navigation",
4528
+ level: "info",
4529
+ message: `${kind}: ${window.location.pathname}`,
4530
+ data: { url: window.location.href, kind }
4531
+ });
4532
+ };
4533
+ const onPop = () => dispatchRouteChange("popstate");
4534
+ window.addEventListener("popstate", onPop, { passive: true });
4535
+ cleanups.push(() => window.removeEventListener("popstate", onPop));
4536
+ const origPush = window.history.pushState;
4537
+ const origReplace = window.history.replaceState;
4538
+ window.history.pushState = function patched(...args) {
4539
+ const ret = origPush.apply(this, args);
4540
+ try {
4541
+ dispatchRouteChange("pushState");
4542
+ } catch {
4543
+ }
4544
+ return ret;
4545
+ };
4546
+ window.history.replaceState = function patched(...args) {
4547
+ const ret = origReplace.apply(this, args);
4548
+ try {
4549
+ dispatchRouteChange("replaceState");
4550
+ } catch {
4551
+ }
4552
+ return ret;
4553
+ };
4554
+ cleanups.push(() => {
4555
+ window.history.pushState = origPush;
4556
+ window.history.replaceState = origReplace;
4557
+ });
4558
+ } catch {
4559
+ }
4560
+ try {
4561
+ const origError = console.error;
4562
+ const origWarn = console.warn;
4563
+ console.error = function(...args) {
4564
+ try {
4565
+ buffer.add({
4566
+ category: "console",
4567
+ level: "error",
4568
+ message: args.map(stringifyConsoleArg).join(" ")
4569
+ });
4570
+ } catch {
4571
+ }
4572
+ return origError.apply(this, args);
4573
+ };
4574
+ console.warn = function(...args) {
4575
+ try {
4576
+ buffer.add({
4577
+ category: "console",
4578
+ level: "warning",
4579
+ message: args.map(stringifyConsoleArg).join(" ")
4580
+ });
4581
+ } catch {
4582
+ }
4583
+ return origWarn.apply(this, args);
4584
+ };
4585
+ cleanups.push(() => {
4586
+ console.error = origError;
4587
+ console.warn = origWarn;
4588
+ });
4589
+ } catch {
4590
+ }
4591
+ try {
4592
+ const onClick = (ev) => {
4593
+ try {
4594
+ const target = ev.target;
4595
+ if (!(target instanceof Element)) return;
4596
+ let cur = target;
4597
+ let hops = 0;
4598
+ while (cur && hops < 10) {
4599
+ const tid = cur.getAttribute("data-testid");
4600
+ if (tid) {
4601
+ const text = (cur.textContent ?? "").trim().slice(0, 80);
4602
+ buffer.add({
4603
+ category: "ui.click",
4604
+ level: "info",
4605
+ message: `clicked ${tid}${text ? ` \u2014 ${text}` : ""}`,
4606
+ data: { testid: tid, tag: cur.tagName.toLowerCase() }
4607
+ });
4608
+ return;
4609
+ }
4610
+ cur = cur.parentElement;
4611
+ hops++;
4612
+ }
4613
+ } catch {
4614
+ }
4615
+ };
4616
+ document.addEventListener("click", onClick, { passive: true, capture: true });
4617
+ cleanups.push(() => document.removeEventListener("click", onClick, true));
4618
+ } catch {
4619
+ }
4620
+ return () => {
4621
+ for (const c of cleanups) {
4622
+ try {
4623
+ c();
4624
+ } catch {
4625
+ }
4626
+ }
4627
+ };
4628
+ }
4629
+ function stringifyConsoleArg(arg) {
4630
+ try {
4631
+ if (arg instanceof Error) {
4632
+ return `${arg.name}: ${arg.message}`;
4633
+ }
4634
+ if (typeof arg === "object" && arg !== null) {
4635
+ const json = JSON.stringify(arg);
4636
+ return json.length > 200 ? `${json.slice(0, 200)}\u2026` : json;
4637
+ }
4638
+ const s = String(arg);
4639
+ return s.length > 200 ? `${s.slice(0, 200)}\u2026` : s;
4640
+ } catch {
4641
+ return `[${typeof arg}]`;
4642
+ }
4643
+ }
3467
4644
 
3468
4645
  exports.Mushi = Mushi;
3469
4646
  exports.MushiWidget = MushiWidget;