@mushi-mushi/web 1.0.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;
@@ -2849,7 +3513,7 @@ function createProactiveManager(config = {}) {
2849
3513
 
2850
3514
  // src/version.ts
2851
3515
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/web";
2852
- var MUSHI_SDK_VERSION = "1.0.0" ;
3516
+ var MUSHI_SDK_VERSION = "1.1.0" ;
2853
3517
 
2854
3518
  // src/mushi.ts
2855
3519
  var instance = null;
@@ -2888,7 +3552,7 @@ function createInstance(config) {
2888
3552
  const bootstrapConfig = applyPresetConfig(config);
2889
3553
  let activeConfig = bootstrapConfig;
2890
3554
  const log = config.debug ?? false ? core.createLogger({ scope: "mushi", level: "debug", format: "pretty" }) : core.noopLogger;
2891
- const apiClient = core.createApiClient({
3555
+ const apiClient2 = core.createApiClient({
2892
3556
  projectId: bootstrapConfig.projectId,
2893
3557
  apiKey: bootstrapConfig.apiKey,
2894
3558
  ...bootstrapConfig.apiEndpoint ? { apiEndpoint: bootstrapConfig.apiEndpoint } : {}
@@ -2997,7 +3661,7 @@ function createInstance(config) {
2997
3661
  getUserId: () => userInfo?.id ?? null,
2998
3662
  getSessionId: core.getSessionId,
2999
3663
  onEvent: (event) => {
3000
- void apiClient.postDiscoveryEvent({
3664
+ void apiClient2.postDiscoveryEvent({
3001
3665
  ...event,
3002
3666
  sdk_version: MUSHI_SDK_VERSION
3003
3667
  }).catch((err) => {
@@ -3073,17 +3737,17 @@ function createInstance(config) {
3073
3737
  }
3074
3738
  },
3075
3739
  async onReporterReportsRequest() {
3076
- const result = await apiClient.listReporterReports(core.getReporterToken());
3740
+ const result = await apiClient2.listReporterReports(core.getReporterToken());
3077
3741
  if (!result.ok) throw new Error(result.error?.message ?? "Could not load reports");
3078
3742
  return result.data?.reports ?? [];
3079
3743
  },
3080
3744
  async onReporterCommentsRequest(reportId) {
3081
- const result = await apiClient.listReporterComments(reportId, core.getReporterToken());
3745
+ const result = await apiClient2.listReporterComments(reportId, core.getReporterToken());
3082
3746
  if (!result.ok) throw new Error(result.error?.message ?? "Could not load thread");
3083
3747
  return result.data?.comments ?? [];
3084
3748
  },
3085
3749
  async onReporterReply(reportId, body) {
3086
- const result = await apiClient.replyToReporterReport(reportId, core.getReporterToken(), body);
3750
+ const result = await apiClient2.replyToReporterReport(reportId, core.getReporterToken(), body);
3087
3751
  if (!result.ok) throw new Error(result.error?.message ?? "Could not send reply");
3088
3752
  }
3089
3753
  }, MUSHI_SDK_VERSION);
@@ -3129,8 +3793,8 @@ function createInstance(config) {
3129
3793
  errorBoundary: proactiveCfg?.errorBoundary === true
3130
3794
  });
3131
3795
  }
3132
- offlineQueue.startAutoSync(apiClient);
3133
- offlineQueue.flush(apiClient).then((result) => {
3796
+ offlineQueue.startAutoSync(apiClient2);
3797
+ offlineQueue.flush(apiClient2).then((result) => {
3134
3798
  if (result.sent > 0) log.info("Synced offline reports", { sent: result.sent });
3135
3799
  });
3136
3800
  function applyRuntimeConfig(runtime) {
@@ -3151,7 +3815,7 @@ function createInstance(config) {
3151
3815
  if (shouldUseRuntimeConfig(config)) {
3152
3816
  const cached = readCachedRuntimeConfig(config.projectId);
3153
3817
  if (cached) applyRuntimeConfig(cached);
3154
- apiClient.getSdkConfig().then((result) => {
3818
+ apiClient2.getSdkConfig().then((result) => {
3155
3819
  if (result.ok && result.data) {
3156
3820
  cacheRuntimeConfig(config.projectId, result.data);
3157
3821
  applyRuntimeConfig(result.data);
@@ -3170,7 +3834,7 @@ function createInstance(config) {
3170
3834
  if (activeConfig.widget?.outdatedBanner === "off") return;
3171
3835
  const cached = readCachedSdkVersion(MUSHI_SDK_PACKAGE);
3172
3836
  if (cached) applySdkFreshness(cached);
3173
- const result = await apiClient.getLatestSdkVersion(MUSHI_SDK_PACKAGE);
3837
+ const result = await apiClient2.getLatestSdkVersion(MUSHI_SDK_PACKAGE);
3174
3838
  if (!result.ok || !result.data) return;
3175
3839
  cacheSdkVersion(MUSHI_SDK_PACKAGE, result.data);
3176
3840
  applySdkFreshness(result.data);
@@ -3312,7 +3976,7 @@ function createInstance(config) {
3312
3976
  emit("report:queued", { reportId: report.id });
3313
3977
  return;
3314
3978
  }
3315
- const result = await apiClient.submitReport(report);
3979
+ const result = await apiClient2.submitReport(report);
3316
3980
  if (result.ok) {
3317
3981
  log.info("Report sent", { reportId: result.data?.reportId });
3318
3982
  emit("report:sent", { reportId: result.data?.reportId });
@@ -3321,6 +3985,10 @@ function createInstance(config) {
3321
3985
  level: "info",
3322
3986
  message: `Mushi report sent (${result.data?.reportId ?? report.id})`
3323
3987
  });
3988
+ enqueue({
3989
+ action: "report_submit",
3990
+ metadata: { category, reportId: result.data?.reportId ?? report.id }
3991
+ });
3324
3992
  try {
3325
3993
  if (config.sentry && result.data?.reportId) {
3326
3994
  tagSentryScope(result.data.reportId);
@@ -3466,7 +4134,7 @@ function createInstance(config) {
3466
4134
  emit("report:queued", { reportId: report.id });
3467
4135
  return null;
3468
4136
  }
3469
- const res = await apiClient.submitReport(report);
4137
+ const res = await apiClient2.submitReport(report);
3470
4138
  if (res.ok) {
3471
4139
  emit("report:sent", { reportId: res.data?.reportId });
3472
4140
  try {
@@ -3518,6 +4186,33 @@ function createInstance(config) {
3518
4186
  level: "info",
3519
4187
  message: `Mushi.identify(${userId})`
3520
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
+ }
3521
4216
  },
3522
4217
  addBreadcrumb(crumb) {
3523
4218
  breadcrumbs.add(crumb);
@@ -3543,6 +4238,27 @@ function createInstance(config) {
3543
4238
  return;
3544
4239
  }
3545
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 });
3546
4262
  }
3547
4263
  };
3548
4264
  return sdk;
@@ -3555,7 +4271,10 @@ function mergeRuntimeConfig(config, runtime) {
3555
4271
  widget: {
3556
4272
  ...config.widget,
3557
4273
  ...runtime.widget,
3558
- ...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 } : {}
3559
4278
  },
3560
4279
  capture: {
3561
4280
  ...config.capture,
@@ -3791,6 +4510,10 @@ function createNoopInstance() {
3791
4510
  setTags: () => {
3792
4511
  },
3793
4512
  clearTag: () => {
4513
+ },
4514
+ getReputation: async () => null,
4515
+ getTier: async () => null,
4516
+ recordActivity: () => {
3794
4517
  }
3795
4518
  };
3796
4519
  }