@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.js CHANGED
@@ -933,6 +933,236 @@ function getWidgetStyles(theme) {
933
933
  letter-spacing: 0.02em;
934
934
  }
935
935
 
936
+ /* \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 */
937
+ .mushi-rewards-nudge {
938
+ border-top: 1px solid ${rule};
939
+ padding: 10px 0 4px;
940
+ margin-top: 6px;
941
+ }
942
+ .mushi-rewards-row {
943
+ display: flex;
944
+ align-items: center;
945
+ gap: 6px;
946
+ margin-bottom: 8px;
947
+ }
948
+ .mushi-tier-pip {
949
+ width: 7px;
950
+ height: 7px;
951
+ border-radius: 50%;
952
+ flex-shrink: 0;
953
+ }
954
+ .mushi-rewards-tier-name {
955
+ font-family: ${fontMono};
956
+ font-size: 11px;
957
+ letter-spacing: 0.08em;
958
+ text-transform: uppercase;
959
+ color: ${ink};
960
+ }
961
+ .mushi-rewards-pts-count {
962
+ font-family: ${fontMono};
963
+ font-size: 11px;
964
+ color: ${inkMuted};
965
+ margin-right: auto;
966
+ }
967
+ .mushi-rewards-pts-earn {
968
+ font-family: ${fontMono};
969
+ font-size: 10px;
970
+ color: ${vermillion};
971
+ letter-spacing: 0.04em;
972
+ white-space: nowrap;
973
+ }
974
+ .mushi-tier-bar-track {
975
+ height: 3px;
976
+ background: ${ruleStrong};
977
+ border-radius: 2px;
978
+ overflow: hidden;
979
+ margin-bottom: 5px;
980
+ }
981
+ .mushi-tier-bar-fill {
982
+ height: 100%;
983
+ background: ${vermillion};
984
+ border-radius: 2px;
985
+ transition: width 600ms ${easeStamp};
986
+ }
987
+ .mushi-rewards-next-label {
988
+ font-family: ${fontMono};
989
+ font-size: 10px;
990
+ color: ${inkMuted};
991
+ text-align: right;
992
+ letter-spacing: 0.02em;
993
+ }
994
+
995
+ /* \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 */
996
+ .mushi-success-rewards {
997
+ margin-top: 14px;
998
+ padding-top: 12px;
999
+ border-top: 1px solid ${rule};
1000
+ width: 100%;
1001
+ }
1002
+ .mushi-success-pts-award {
1003
+ font-family: ${fontMono};
1004
+ font-size: 22px;
1005
+ font-weight: 700;
1006
+ color: ${vermillion};
1007
+ text-align: center;
1008
+ letter-spacing: 0.06em;
1009
+ margin-bottom: 10px;
1010
+ opacity: 0;
1011
+ animation: mushi-pts-pop 420ms ${easeStamp} 900ms forwards;
1012
+ }
1013
+ .success-bar { margin: 0 0 5px; }
1014
+
1015
+ @keyframes mushi-pts-pop {
1016
+ from { opacity: 0; transform: scale(0.75) translateY(6px); }
1017
+ to { opacity: 1; transform: scale(1) translateY(0); }
1018
+ }
1019
+
1020
+ /* \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 */
1021
+
1022
+ .mushi-beta-strip {
1023
+ margin: 0 16px 2px;
1024
+ padding: 9px 12px;
1025
+ background: rgba(99, 102, 241, 0.07);
1026
+ border: 1px solid rgba(99, 102, 241, 0.18);
1027
+ border-radius: 8px;
1028
+ display: flex;
1029
+ flex-direction: column;
1030
+ gap: 4px;
1031
+ }
1032
+
1033
+ .mushi-beta-strip-row {
1034
+ display: flex;
1035
+ align-items: center;
1036
+ gap: 8px;
1037
+ }
1038
+
1039
+ .mushi-beta-tag {
1040
+ display: inline-flex;
1041
+ align-items: center;
1042
+ padding: 1px 6px;
1043
+ border-radius: 4px;
1044
+ background: rgba(245, 158, 11, 0.15);
1045
+ border: 1px solid rgba(245, 158, 11, 0.35);
1046
+ color: #b45309;
1047
+ font-family: var(--mushi-font-mono);
1048
+ font-size: 9px;
1049
+ font-weight: 700;
1050
+ letter-spacing: 0.08em;
1051
+ line-height: 1.6;
1052
+ white-space: nowrap;
1053
+ flex-shrink: 0;
1054
+ }
1055
+
1056
+ @media (prefers-color-scheme: dark) {
1057
+ .mushi-beta-tag {
1058
+ background: rgba(245, 158, 11, 0.12);
1059
+ border-color: rgba(245, 158, 11, 0.28);
1060
+ color: #fbbf24;
1061
+ }
1062
+ }
1063
+
1064
+ .mushi-beta-msg {
1065
+ font-size: 11px;
1066
+ color: var(--mushi-text-dim);
1067
+ line-height: 1.45;
1068
+ }
1069
+
1070
+ .mushi-beta-contact-hint {
1071
+ font-size: 10px;
1072
+ color: var(--mushi-text-dim);
1073
+ opacity: 0.72;
1074
+ font-family: var(--mushi-font-mono);
1075
+ }
1076
+
1077
+ .mushi-beta-perks {
1078
+ list-style: none;
1079
+ margin: 2px 0 0;
1080
+ padding: 0;
1081
+ display: flex;
1082
+ flex-direction: column;
1083
+ gap: 2px;
1084
+ }
1085
+
1086
+ .mushi-beta-perks li {
1087
+ font-size: 10.5px;
1088
+ color: #4f46e5;
1089
+ font-weight: 500;
1090
+ }
1091
+
1092
+ @media (prefers-color-scheme: dark) {
1093
+ .mushi-beta-perks li {
1094
+ color: #818cf8;
1095
+ }
1096
+ }
1097
+
1098
+ /* \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 */
1099
+
1100
+ .mushi-changelog {
1101
+ margin-top: 5px;
1102
+ }
1103
+
1104
+ .mushi-changelog-summary {
1105
+ font-size: 10.5px;
1106
+ color: var(--mushi-text-dim);
1107
+ cursor: pointer;
1108
+ list-style: none;
1109
+ display: flex;
1110
+ align-items: center;
1111
+ gap: 4px;
1112
+ user-select: none;
1113
+ }
1114
+
1115
+ .mushi-changelog-summary::before {
1116
+ content: '\u25B6';
1117
+ font-size: 7px;
1118
+ opacity: 0.6;
1119
+ transition: transform 0.15s ease;
1120
+ }
1121
+
1122
+ .mushi-changelog[open] .mushi-changelog-summary::before {
1123
+ transform: rotate(90deg);
1124
+ }
1125
+
1126
+ .mushi-changelog-list {
1127
+ margin: 5px 0 0 4px;
1128
+ padding: 0;
1129
+ list-style: none;
1130
+ display: flex;
1131
+ flex-direction: column;
1132
+ gap: 2px;
1133
+ }
1134
+
1135
+ .mushi-changelog-list li {
1136
+ font-size: 10.5px;
1137
+ color: var(--mushi-text-dim);
1138
+ line-height: 1.5;
1139
+ }
1140
+
1141
+ /* \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 */
1142
+
1143
+ .mushi-beta-success-footer {
1144
+ margin-top: 14px;
1145
+ padding: 10px 14px;
1146
+ background: rgba(99, 102, 241, 0.06);
1147
+ border: 1px solid rgba(99, 102, 241, 0.14);
1148
+ border-radius: 8px;
1149
+ display: flex;
1150
+ flex-direction: column;
1151
+ gap: 3px;
1152
+ text-align: left;
1153
+ }
1154
+
1155
+ .mushi-beta-success-line {
1156
+ font-size: 11px;
1157
+ color: var(--mushi-text-dim);
1158
+ line-height: 1.5;
1159
+ }
1160
+
1161
+ .mushi-beta-success-dim {
1162
+ opacity: 0.65;
1163
+ font-size: 10.5px;
1164
+ }
1165
+
936
1166
  @media (prefers-reduced-motion: reduce) {
937
1167
  *,
938
1168
  *::before,
@@ -943,6 +1173,7 @@ function getWidgetStyles(theme) {
943
1173
  }
944
1174
  .mushi-success-stamp circle { stroke-dashoffset: 0; }
945
1175
  .mushi-success-stamp-label { opacity: 1; }
1176
+ .mushi-success-pts-award { opacity: 1; }
946
1177
  }
947
1178
  `;
948
1179
  }
@@ -998,7 +1229,8 @@ var MushiWidget = class {
998
1229
  smartHide: config.smartHide ?? false,
999
1230
  draggable: config.draggable ?? false,
1000
1231
  brandFooter: config.brandFooter ?? true,
1001
- outdatedBanner: config.outdatedBanner ?? "auto"
1232
+ outdatedBanner: config.outdatedBanner ?? "auto",
1233
+ betaMode: config.betaMode ?? {}
1002
1234
  };
1003
1235
  this.callbacks = callbacks;
1004
1236
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
@@ -1042,6 +1274,7 @@ var MushiWidget = class {
1042
1274
  * root) for up to ~3.3s after destroy. */
1043
1275
  successTimer = null;
1044
1276
  autoCloseTimer = null;
1277
+ rewardsState = null;
1045
1278
  mount() {
1046
1279
  if (this.host.isConnected) return;
1047
1280
  document.body.appendChild(this.host);
@@ -1073,7 +1306,8 @@ var MushiWidget = class {
1073
1306
  ...config.smartHide !== void 0 ? { smartHide: config.smartHide } : {},
1074
1307
  ...config.draggable !== void 0 ? { draggable: config.draggable } : {},
1075
1308
  ...config.brandFooter !== void 0 ? { brandFooter: config.brandFooter } : {},
1076
- ...config.outdatedBanner !== void 0 ? { outdatedBanner: config.outdatedBanner } : {}
1309
+ ...config.outdatedBanner !== void 0 ? { outdatedBanner: config.outdatedBanner } : {},
1310
+ ...config.betaMode !== void 0 ? { betaMode: config.betaMode } : {}
1077
1311
  };
1078
1312
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
1079
1313
  this.syncAttachedLaunchers();
@@ -1148,6 +1382,10 @@ var MushiWidget = class {
1148
1382
  this.sdkFreshness = info;
1149
1383
  if (this.isOpen) this.render();
1150
1384
  }
1385
+ setRewardsState(state) {
1386
+ this.rewardsState = state;
1387
+ if (this.isOpen) this.render();
1388
+ }
1151
1389
  destroy() {
1152
1390
  if (this.successTimer !== null) {
1153
1391
  clearTimeout(this.successTimer);
@@ -1392,6 +1630,7 @@ var MushiWidget = class {
1392
1630
  `).join("");
1393
1631
  return `
1394
1632
  ${this.renderHeader({ title: t.step1.heading, step: STEP_NUMBER.category })}
1633
+ ${this.config.betaMode?.enabled ? this.renderBetaStrip() : ""}
1395
1634
  <div class="mushi-body" role="radiogroup" aria-label="${t.step1.heading}">
1396
1635
  <button type="button" class="mushi-option-btn mushi-reports-entry" data-action="reports">
1397
1636
  <span class="mushi-option-icon" aria-hidden="true">\u{1F4EC}</span>
@@ -1402,10 +1641,52 @@ var MushiWidget = class {
1402
1641
  <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
1403
1642
  </button>
1404
1643
  ${categories}
1644
+ ${this.rewardsState ? this.renderRewardsNudge() : ""}
1405
1645
  </div>
1406
1646
  ${this.renderStepIndicator(STEP_NUMBER.category)}
1407
1647
  `;
1408
1648
  }
1649
+ /** Collapsible "What's new" changelog row. Closes the reporter feedback loop. */
1650
+ renderBetaChangelog() {
1651
+ const entries = this.config.betaMode?.changelogItems;
1652
+ if (!entries?.length) return "";
1653
+ const latest = entries[0];
1654
+ const items = latest.items.map((item) => `<li>\u2022 ${escapeHtml(item)}</li>`).join("");
1655
+ const label = latest.date ? `What\u2019s new in ${escapeHtml(latest.version)} \xB7 ${escapeHtml(latest.date)}` : `What\u2019s new in ${escapeHtml(latest.version)}`;
1656
+ return `
1657
+ <details class="mushi-changelog">
1658
+ <summary class="mushi-changelog-summary">${label}</summary>
1659
+ <ul class="mushi-changelog-list">${items}</ul>
1660
+ </details>
1661
+ `;
1662
+ }
1663
+ /**
1664
+ * Discreet beta status strip: communicates "work in progress", invites
1665
+ * feedback, and sets expectations — reducing user frustration while
1666
+ * nudging the reciprocity instinct ("your reports help us build this").
1667
+ */
1668
+ renderBetaStrip() {
1669
+ const beta = this.config.betaMode;
1670
+ const appName = escapeHtml(beta.appName ?? "This app");
1671
+ const message = beta.message ? escapeHtml(beta.message) : `${appName} is in early development \u2014 updates ship weekly`;
1672
+ const email = beta.contactEmail ? escapeHtml(beta.contactEmail) : null;
1673
+ const perks = beta.perks ?? [];
1674
+ return `
1675
+ <div class="mushi-beta-strip" role="note" aria-label="Beta status">
1676
+ <div class="mushi-beta-strip-row">
1677
+ <span class="mushi-beta-tag" aria-hidden="true">BETA</span>
1678
+ <span class="mushi-beta-msg">${message}</span>
1679
+ </div>
1680
+ ${email ? `<div class="mushi-beta-contact-hint">Reports go to ${email} \xB7 reviewed by the team</div>` : ""}
1681
+ ${perks.length > 0 ? `
1682
+ <ul class="mushi-beta-perks" aria-label="Beta tester perks">
1683
+ ${perks.map((p) => `<li>\u2713 ${escapeHtml(p)}</li>`).join("")}
1684
+ </ul>
1685
+ ` : ""}
1686
+ ${this.renderBetaChangelog()}
1687
+ </div>
1688
+ `;
1689
+ }
1409
1690
  renderReportsStep() {
1410
1691
  const reports = this.reporterReports.map((report) => `
1411
1692
  <button type="button" class="mushi-report-row" data-report-id="${escapeHtml(report.id)}">
@@ -1526,7 +1807,92 @@ var MushiWidget = class {
1526
1807
  </div>
1527
1808
  <div class="mushi-success-headline">${t.widget.submitted}</div>
1528
1809
  <div class="mushi-success-meta">REPORT \xB7 ${time}</div>
1810
+ ${this.rewardsState ? this.renderSuccessRewards() : ""}
1811
+ ${this.config.betaMode?.enabled ? this.renderBetaSuccessFooter() : ""}
1812
+ </div>
1813
+ </div>
1814
+ `;
1815
+ }
1816
+ /**
1817
+ * Reciprocity footer on the success step: closes the feedback loop by
1818
+ * attributing where the report goes, sets a response expectation, and
1819
+ * reinforces the "beta tester" identity (Peak-End Rule — the last thing
1820
+ * the user sees shapes their entire impression of the interaction).
1821
+ */
1822
+ renderBetaSuccessFooter() {
1823
+ const beta = this.config.betaMode;
1824
+ const email = beta.contactEmail ? escapeHtml(beta.contactEmail) : null;
1825
+ const appName = escapeHtml(beta.appName ?? "the team");
1826
+ return `
1827
+ <div class="mushi-beta-success-footer" role="note" aria-label="Beta feedback acknowledgement">
1828
+ ${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>`}
1829
+ <div class="mushi-beta-success-line mushi-beta-success-dim">We aim to review within 48h \xB7 thank you for helping build this</div>
1830
+ </div>
1831
+ `;
1832
+ }
1833
+ tierColor(slug) {
1834
+ const colors = {
1835
+ free: "#6b7280",
1836
+ explorer: "#3b82f6",
1837
+ contributor: "#8b5cf6",
1838
+ champion: "#f59e0b"
1839
+ };
1840
+ return colors[slug] ?? "#6c47ff";
1841
+ }
1842
+ /** Compact rewards nudge rendered at the bottom of the category-step body. */
1843
+ renderRewardsNudge() {
1844
+ const { tier, nextTier, totalPoints, pointsForReport } = this.rewardsState;
1845
+ const tierName = tier?.displayName ?? "Free";
1846
+ const tierSlug = tier?.slug ?? "free";
1847
+ const color = this.tierColor(tierSlug);
1848
+ let pct = 100;
1849
+ let nextLabel = "";
1850
+ if (nextTier) {
1851
+ const base = tier?.pointsThreshold ?? 0;
1852
+ const ceiling = nextTier.pointsThreshold;
1853
+ pct = ceiling > base ? Math.round(Math.min(1, (totalPoints - base) / (ceiling - base)) * 100) : 100;
1854
+ const remaining = Math.max(0, ceiling - totalPoints);
1855
+ nextLabel = `${remaining.toLocaleString()} pts to ${escapeHtml(nextTier.displayName)}`;
1856
+ }
1857
+ return `
1858
+ <div class="mushi-rewards-nudge" aria-label="Rewards progress">
1859
+ <div class="mushi-rewards-row">
1860
+ <span class="mushi-tier-pip" style="background:${color}" aria-hidden="true"></span>
1861
+ <span class="mushi-rewards-tier-name">${escapeHtml(tierName)}</span>
1862
+ <span class="mushi-rewards-pts-count">${totalPoints.toLocaleString()} pts</span>
1863
+ <span class="mushi-rewards-pts-earn">+${pointsForReport} pts for a report</span>
1529
1864
  </div>
1865
+ ${nextTier ? `
1866
+ <div class="mushi-tier-bar-track" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="Progress to ${escapeHtml(nextTier.displayName)}">
1867
+ <div class="mushi-tier-bar-fill" style="width:${pct}%"></div>
1868
+ </div>
1869
+ <div class="mushi-rewards-next-label">${nextLabel}</div>
1870
+ ` : ""}
1871
+ </div>
1872
+ `;
1873
+ }
1874
+ /** Points earned + tier progress shown on the success step. */
1875
+ renderSuccessRewards() {
1876
+ const { tier, nextTier, totalPoints, pointsForReport } = this.rewardsState;
1877
+ const projected = totalPoints + pointsForReport;
1878
+ let pctAfter = 100;
1879
+ let nextLabel = "";
1880
+ if (nextTier) {
1881
+ const base = tier?.pointsThreshold ?? 0;
1882
+ const ceiling = nextTier.pointsThreshold;
1883
+ pctAfter = ceiling > base ? Math.round(Math.min(1, (projected - base) / (ceiling - base)) * 100) : 100;
1884
+ const remaining = Math.max(0, ceiling - projected);
1885
+ nextLabel = remaining > 0 ? `${remaining.toLocaleString()} pts to ${escapeHtml(nextTier.displayName)}` : `\u{1F389} ${escapeHtml(nextTier.displayName)} reached!`;
1886
+ }
1887
+ return `
1888
+ <div class="mushi-success-rewards">
1889
+ <div class="mushi-success-pts-award">+${pointsForReport} pts</div>
1890
+ ${nextTier ? `
1891
+ <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)}">
1892
+ <div class="mushi-tier-bar-fill" style="width:${pctAfter}%"></div>
1893
+ </div>
1894
+ <div class="mushi-rewards-next-label">${nextLabel}</div>
1895
+ ` : ""}
1530
1896
  </div>
1531
1897
  `;
1532
1898
  }
@@ -1690,6 +2056,304 @@ var MushiWidget = class {
1690
2056
  }
1691
2057
  };
1692
2058
 
2059
+ // src/rewards.ts
2060
+ var MIN_FLUSH_INTERVAL = 3e4;
2061
+ var DEFAULT_FLUSH_INTERVAL = 3e5;
2062
+ var DWELL_SAMPLE_INTERVAL = 6e4;
2063
+ var MAX_SESSION_MINUTES_PER_DAY = 60;
2064
+ var DAILY_RESET_KEY_PREFIX = "mushi_session_min_day_";
2065
+ var pendingEvents = [];
2066
+ var flushTimer = null;
2067
+ var dwellTimer = null;
2068
+ var currentUserId = null;
2069
+ var currentUserTraits = null;
2070
+ var reporterTokenHash = null;
2071
+ var apiClient = null;
2072
+ var optedIn = false;
2073
+ var tierCache = null;
2074
+ var tierCacheTime = 0;
2075
+ var TIER_CACHE_TTL = 5 * 60 * 1e3;
2076
+ var seenRoutes = /* @__PURE__ */ new Set();
2077
+ function getConsentKey(projectId) {
2078
+ return `mushi_rewards_consent_${projectId}`;
2079
+ }
2080
+ function isConsentGranted(projectId) {
2081
+ try {
2082
+ return localStorage.getItem(getConsentKey(projectId)) === "1";
2083
+ } catch {
2084
+ return false;
2085
+ }
2086
+ }
2087
+ function setConsentGranted(projectId, granted) {
2088
+ try {
2089
+ if (granted) {
2090
+ localStorage.setItem(getConsentKey(projectId), "1");
2091
+ } else {
2092
+ localStorage.removeItem(getConsentKey(projectId));
2093
+ }
2094
+ optedIn = granted;
2095
+ apiClient?.submitActivity(currentUserId ?? "", [], { optedIn: granted }).catch(() => {
2096
+ });
2097
+ } catch {
2098
+ }
2099
+ }
2100
+ function getTodayKey() {
2101
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2102
+ }
2103
+ function getSessionMinutesToday(projectId) {
2104
+ try {
2105
+ const val = sessionStorage.getItem(`${DAILY_RESET_KEY_PREFIX}${projectId}_${getTodayKey()}`);
2106
+ return val ? parseInt(val, 10) : 0;
2107
+ } catch {
2108
+ return 0;
2109
+ }
2110
+ }
2111
+ function incrementSessionMinutes(projectId) {
2112
+ const today = getTodayKey();
2113
+ const key = `${DAILY_RESET_KEY_PREFIX}${projectId}_${today}`;
2114
+ try {
2115
+ const next = getSessionMinutesToday(projectId) + 1;
2116
+ sessionStorage.setItem(key, String(next));
2117
+ return next;
2118
+ } catch {
2119
+ return 99;
2120
+ }
2121
+ }
2122
+ function initRewards(ctx) {
2123
+ apiClient = ctx.client;
2124
+ currentUserId = ctx.userId;
2125
+ currentUserTraits = ctx.traits ?? null;
2126
+ reporterTokenHash = ctx.reporterToken ?? null;
2127
+ const { projectId } = ctx;
2128
+ const flushMs = Math.max(
2129
+ MIN_FLUSH_INTERVAL,
2130
+ ctx.config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL
2131
+ );
2132
+ if (ctx.config.consentMode === "auto") {
2133
+ optedIn = true;
2134
+ setConsentGranted(projectId, true);
2135
+ } else {
2136
+ optedIn = isConsentGranted(projectId);
2137
+ }
2138
+ if (ctx.config.trackActivity) {
2139
+ installActivityListeners(projectId);
2140
+ }
2141
+ if (flushTimer) clearInterval(flushTimer);
2142
+ flushTimer = setInterval(() => flush(ctx), flushMs);
2143
+ if (dwellTimer) clearInterval(dwellTimer);
2144
+ dwellTimer = setInterval(() => {
2145
+ if (!optedIn || !currentUserId) return;
2146
+ const minutes = getSessionMinutesToday(projectId);
2147
+ if (minutes < MAX_SESSION_MINUTES_PER_DAY) {
2148
+ incrementSessionMinutes(projectId);
2149
+ enqueue({ action: "session_minute", metadata: { minutes_today: minutes + 1 } });
2150
+ }
2151
+ }, DWELL_SAMPLE_INTERVAL);
2152
+ if (ctx.config.showInWidget) {
2153
+ fetchAndCacheTier(currentUserId).then((tier) => {
2154
+ if (tier) renderTierBadge(tier, ctx.config);
2155
+ });
2156
+ }
2157
+ if (ctx.config.consentMode !== "auto" && !optedIn) {
2158
+ renderConsentBanner(projectId, ctx.config);
2159
+ }
2160
+ }
2161
+ function updateRewardsUser(userId, traits) {
2162
+ currentUserId = userId;
2163
+ currentUserTraits = traits ?? null;
2164
+ tierCache = null;
2165
+ tierCacheTime = 0;
2166
+ }
2167
+ function enqueue(event) {
2168
+ if (!optedIn || !currentUserId) return;
2169
+ pendingEvents.push({ ...event, queuedAt: Date.now() });
2170
+ }
2171
+ async function flush(ctx) {
2172
+ if (!optedIn || !currentUserId || pendingEvents.length === 0) return;
2173
+ let hostJwt = null;
2174
+ if (ctx.config.verifyUserToken) {
2175
+ try {
2176
+ hostJwt = await ctx.config.verifyUserToken();
2177
+ } catch {
2178
+ }
2179
+ }
2180
+ const batch = pendingEvents.splice(0, 100);
2181
+ try {
2182
+ await ctx.client.submitActivity(currentUserId, batch, {
2183
+ userTraits: currentUserTraits ?? void 0,
2184
+ reporterTokenHash: reporterTokenHash ?? void 0,
2185
+ optedIn: true,
2186
+ hostJwt: hostJwt ?? void 0
2187
+ });
2188
+ } catch {
2189
+ pendingEvents.unshift(...batch.slice(0, 50));
2190
+ }
2191
+ }
2192
+ async function getTier(userId) {
2193
+ const now = Date.now();
2194
+ if (tierCache && now - tierCacheTime < TIER_CACHE_TTL) return tierCache;
2195
+ return fetchAndCacheTier(userId);
2196
+ }
2197
+ async function fetchAndCacheTier(userId) {
2198
+ if (!apiClient) return null;
2199
+ const res = await apiClient.getMyTier(userId);
2200
+ if (res.ok && res.data) {
2201
+ tierCache = res.data;
2202
+ tierCacheTime = Date.now();
2203
+ return tierCache;
2204
+ }
2205
+ return null;
2206
+ }
2207
+ var routeObserver = null;
2208
+ var clickHandler = null;
2209
+ var origPushState = null;
2210
+ var lastRoute = "";
2211
+ function installActivityListeners(projectId) {
2212
+ const emitRoute = () => {
2213
+ const route = location.pathname;
2214
+ if (route === lastRoute) return;
2215
+ lastRoute = route;
2216
+ const isNewToday = !seenRoutes.has(`${projectId}:${route}`);
2217
+ if (isNewToday) {
2218
+ seenRoutes.add(`${projectId}:${route}`);
2219
+ enqueue({ action: "screen_view_unique_per_day", metadata: { route } });
2220
+ }
2221
+ };
2222
+ origPushState = history.pushState.bind(history);
2223
+ history.pushState = function(...args) {
2224
+ origPushState(...args);
2225
+ emitRoute();
2226
+ };
2227
+ window.addEventListener("popstate", emitRoute);
2228
+ emitRoute();
2229
+ routeObserver = new MutationObserver(() => emitRoute());
2230
+ const main = document.querySelector("main") ?? document.body;
2231
+ routeObserver.observe(main, { childList: true, subtree: false });
2232
+ clickHandler = (e) => {
2233
+ const target = e.target.closest("[data-testid]");
2234
+ if (!target) return;
2235
+ const testid = target.dataset.testid;
2236
+ if (!testid) return;
2237
+ enqueue({ action: "element_selected", metadata: { testid, route: location.pathname } });
2238
+ };
2239
+ document.addEventListener("click", clickHandler, { capture: true, passive: true });
2240
+ }
2241
+ var badgeHost = null;
2242
+ function renderTierBadge(tier, config) {
2243
+ if (!config.showInWidget) return;
2244
+ if (badgeHost) badgeHost.remove();
2245
+ badgeHost = document.createElement("div");
2246
+ badgeHost.id = "mushi-tier-badge";
2247
+ Object.assign(badgeHost.style, {
2248
+ position: "fixed",
2249
+ bottom: "56px",
2250
+ // above the widget button
2251
+ right: "16px",
2252
+ zIndex: "2147483645",
2253
+ fontFamily: "system-ui, sans-serif"
2254
+ });
2255
+ const shadow = badgeHost.attachShadow({ mode: "closed" });
2256
+ shadow.innerHTML = `
2257
+ <style>
2258
+ :host { display: block; }
2259
+ .badge {
2260
+ display: inline-flex;
2261
+ align-items: center;
2262
+ gap: 6px;
2263
+ padding: 4px 10px;
2264
+ border-radius: 999px;
2265
+ background: rgba(0,0,0,0.75);
2266
+ color: #fff;
2267
+ font-size: 11px;
2268
+ font-weight: 600;
2269
+ letter-spacing: 0.02em;
2270
+ backdrop-filter: blur(6px);
2271
+ cursor: default;
2272
+ user-select: none;
2273
+ }
2274
+ .dot {
2275
+ width: 6px;
2276
+ height: 6px;
2277
+ border-radius: 50%;
2278
+ background: #6c47ff;
2279
+ flex-shrink: 0;
2280
+ }
2281
+ </style>
2282
+ <div class="badge">
2283
+ <span class="dot"></span>
2284
+ <span>${tier.displayName}</span>
2285
+ </div>
2286
+ `;
2287
+ document.body.appendChild(badgeHost);
2288
+ }
2289
+ var consentHost = null;
2290
+ function renderConsentBanner(projectId, config) {
2291
+ if (consentHost) return;
2292
+ consentHost = document.createElement("div");
2293
+ consentHost.id = "mushi-consent-banner";
2294
+ Object.assign(consentHost.style, {
2295
+ position: "fixed",
2296
+ bottom: "80px",
2297
+ right: "16px",
2298
+ zIndex: "2147483646",
2299
+ maxWidth: "280px",
2300
+ fontFamily: "system-ui, sans-serif"
2301
+ });
2302
+ const shadow = consentHost.attachShadow({ mode: "closed" });
2303
+ shadow.innerHTML = `
2304
+ <style>
2305
+ :host { display: block; }
2306
+ .banner {
2307
+ background: #fff;
2308
+ border: 1px solid #e5e7eb;
2309
+ border-radius: 12px;
2310
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
2311
+ padding: 14px 16px;
2312
+ font-size: 13px;
2313
+ line-height: 1.5;
2314
+ color: #374151;
2315
+ }
2316
+ .title { font-weight: 700; margin-bottom: 6px; color: #111827; }
2317
+ .actions { display: flex; gap: 8px; margin-top: 10px; }
2318
+ button {
2319
+ flex: 1;
2320
+ padding: 6px 10px;
2321
+ border-radius: 6px;
2322
+ border: none;
2323
+ cursor: pointer;
2324
+ font-size: 12px;
2325
+ font-weight: 600;
2326
+ }
2327
+ .accept { background: #6c47ff; color: #fff; }
2328
+ .decline { background: #f3f4f6; color: #374151; }
2329
+ </style>
2330
+ <div class="banner">
2331
+ <div class="title">\u{1F3AF} Earn rewards</div>
2332
+ <div>Help improve this app and earn points, badges, and perks for your contributions.</div>
2333
+ <div class="actions">
2334
+ <button class="accept" id="accept">Enable</button>
2335
+ <button class="decline" id="decline">No thanks</button>
2336
+ </div>
2337
+ </div>
2338
+ `;
2339
+ shadow.getElementById("accept")?.addEventListener("click", () => {
2340
+ setConsentGranted(projectId, true);
2341
+ consentHost?.remove();
2342
+ consentHost = null;
2343
+ if (config.showInWidget && currentUserId) {
2344
+ fetchAndCacheTier(currentUserId).then((tier) => {
2345
+ if (tier) renderTierBadge(tier, config);
2346
+ });
2347
+ }
2348
+ });
2349
+ shadow.getElementById("decline")?.addEventListener("click", () => {
2350
+ setConsentGranted(projectId, false);
2351
+ consentHost?.remove();
2352
+ consentHost = null;
2353
+ });
2354
+ document.body.appendChild(consentHost);
2355
+ }
2356
+
1693
2357
  // src/capture/console.ts
1694
2358
  var MAX_ENTRIES = 50;
1695
2359
  var MAX_MESSAGE_LENGTH = 500;
@@ -2847,7 +3511,7 @@ function createProactiveManager(config = {}) {
2847
3511
 
2848
3512
  // src/version.ts
2849
3513
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/web";
2850
- var MUSHI_SDK_VERSION = "1.0.0" ;
3514
+ var MUSHI_SDK_VERSION = "1.1.0" ;
2851
3515
 
2852
3516
  // src/mushi.ts
2853
3517
  var instance = null;
@@ -2886,7 +3550,7 @@ function createInstance(config) {
2886
3550
  const bootstrapConfig = applyPresetConfig(config);
2887
3551
  let activeConfig = bootstrapConfig;
2888
3552
  const log = config.debug ?? false ? createLogger({ scope: "mushi", level: "debug", format: "pretty" }) : noopLogger;
2889
- const apiClient = createApiClient({
3553
+ const apiClient2 = createApiClient({
2890
3554
  projectId: bootstrapConfig.projectId,
2891
3555
  apiKey: bootstrapConfig.apiKey,
2892
3556
  ...bootstrapConfig.apiEndpoint ? { apiEndpoint: bootstrapConfig.apiEndpoint } : {}
@@ -2995,7 +3659,7 @@ function createInstance(config) {
2995
3659
  getUserId: () => userInfo?.id ?? null,
2996
3660
  getSessionId,
2997
3661
  onEvent: (event) => {
2998
- void apiClient.postDiscoveryEvent({
3662
+ void apiClient2.postDiscoveryEvent({
2999
3663
  ...event,
3000
3664
  sdk_version: MUSHI_SDK_VERSION
3001
3665
  }).catch((err) => {
@@ -3071,17 +3735,17 @@ function createInstance(config) {
3071
3735
  }
3072
3736
  },
3073
3737
  async onReporterReportsRequest() {
3074
- const result = await apiClient.listReporterReports(getReporterToken());
3738
+ const result = await apiClient2.listReporterReports(getReporterToken());
3075
3739
  if (!result.ok) throw new Error(result.error?.message ?? "Could not load reports");
3076
3740
  return result.data?.reports ?? [];
3077
3741
  },
3078
3742
  async onReporterCommentsRequest(reportId) {
3079
- const result = await apiClient.listReporterComments(reportId, getReporterToken());
3743
+ const result = await apiClient2.listReporterComments(reportId, getReporterToken());
3080
3744
  if (!result.ok) throw new Error(result.error?.message ?? "Could not load thread");
3081
3745
  return result.data?.comments ?? [];
3082
3746
  },
3083
3747
  async onReporterReply(reportId, body) {
3084
- const result = await apiClient.replyToReporterReport(reportId, getReporterToken(), body);
3748
+ const result = await apiClient2.replyToReporterReport(reportId, getReporterToken(), body);
3085
3749
  if (!result.ok) throw new Error(result.error?.message ?? "Could not send reply");
3086
3750
  }
3087
3751
  }, MUSHI_SDK_VERSION);
@@ -3127,8 +3791,8 @@ function createInstance(config) {
3127
3791
  errorBoundary: proactiveCfg?.errorBoundary === true
3128
3792
  });
3129
3793
  }
3130
- offlineQueue.startAutoSync(apiClient);
3131
- offlineQueue.flush(apiClient).then((result) => {
3794
+ offlineQueue.startAutoSync(apiClient2);
3795
+ offlineQueue.flush(apiClient2).then((result) => {
3132
3796
  if (result.sent > 0) log.info("Synced offline reports", { sent: result.sent });
3133
3797
  });
3134
3798
  function applyRuntimeConfig(runtime) {
@@ -3149,7 +3813,7 @@ function createInstance(config) {
3149
3813
  if (shouldUseRuntimeConfig(config)) {
3150
3814
  const cached = readCachedRuntimeConfig(config.projectId);
3151
3815
  if (cached) applyRuntimeConfig(cached);
3152
- apiClient.getSdkConfig().then((result) => {
3816
+ apiClient2.getSdkConfig().then((result) => {
3153
3817
  if (result.ok && result.data) {
3154
3818
  cacheRuntimeConfig(config.projectId, result.data);
3155
3819
  applyRuntimeConfig(result.data);
@@ -3168,7 +3832,7 @@ function createInstance(config) {
3168
3832
  if (activeConfig.widget?.outdatedBanner === "off") return;
3169
3833
  const cached = readCachedSdkVersion(MUSHI_SDK_PACKAGE);
3170
3834
  if (cached) applySdkFreshness(cached);
3171
- const result = await apiClient.getLatestSdkVersion(MUSHI_SDK_PACKAGE);
3835
+ const result = await apiClient2.getLatestSdkVersion(MUSHI_SDK_PACKAGE);
3172
3836
  if (!result.ok || !result.data) return;
3173
3837
  cacheSdkVersion(MUSHI_SDK_PACKAGE, result.data);
3174
3838
  applySdkFreshness(result.data);
@@ -3310,7 +3974,7 @@ function createInstance(config) {
3310
3974
  emit("report:queued", { reportId: report.id });
3311
3975
  return;
3312
3976
  }
3313
- const result = await apiClient.submitReport(report);
3977
+ const result = await apiClient2.submitReport(report);
3314
3978
  if (result.ok) {
3315
3979
  log.info("Report sent", { reportId: result.data?.reportId });
3316
3980
  emit("report:sent", { reportId: result.data?.reportId });
@@ -3319,6 +3983,10 @@ function createInstance(config) {
3319
3983
  level: "info",
3320
3984
  message: `Mushi report sent (${result.data?.reportId ?? report.id})`
3321
3985
  });
3986
+ enqueue({
3987
+ action: "report_submit",
3988
+ metadata: { category, reportId: result.data?.reportId ?? report.id }
3989
+ });
3322
3990
  try {
3323
3991
  if (config.sentry && result.data?.reportId) {
3324
3992
  tagSentryScope(result.data.reportId);
@@ -3464,7 +4132,7 @@ function createInstance(config) {
3464
4132
  emit("report:queued", { reportId: report.id });
3465
4133
  return null;
3466
4134
  }
3467
- const res = await apiClient.submitReport(report);
4135
+ const res = await apiClient2.submitReport(report);
3468
4136
  if (res.ok) {
3469
4137
  emit("report:sent", { reportId: res.data?.reportId });
3470
4138
  try {
@@ -3516,6 +4184,33 @@ function createInstance(config) {
3516
4184
  level: "info",
3517
4185
  message: `Mushi.identify(${userId})`
3518
4186
  });
4187
+ if (activeConfig.rewards?.enabled) {
4188
+ const rewardsCtx = {
4189
+ client: apiClient2,
4190
+ config: activeConfig.rewards,
4191
+ projectId: bootstrapConfig.projectId,
4192
+ userId,
4193
+ traits: traits ? { email: traits.email, name: traits.name, provider: traits.provider } : void 0
4194
+ };
4195
+ if (userInfo.id === userId) {
4196
+ initRewards(rewardsCtx);
4197
+ } else {
4198
+ updateRewardsUser(userId, rewardsCtx.traits);
4199
+ }
4200
+ if (activeConfig.rewards.showInWidget !== false) {
4201
+ void apiClient2.getMyPoints(userId).then((res) => {
4202
+ if (!res.ok) return;
4203
+ const d = res.data;
4204
+ widget.setRewardsState({
4205
+ tier: d.tier ? { slug: d.tier.slug ?? "free", displayName: d.tier.display_name ?? "Free", pointsThreshold: d.tier.points_threshold ?? 0 } : null,
4206
+ nextTier: d.next_tier ? { displayName: d.next_tier.display_name ?? "", pointsThreshold: d.next_tier.points_threshold ?? 0 } : null,
4207
+ totalPoints: d.total_points ?? 0,
4208
+ pointsForReport: d.report_submit_pts ?? 50
4209
+ });
4210
+ }).catch(() => {
4211
+ });
4212
+ }
4213
+ }
3519
4214
  },
3520
4215
  addBreadcrumb(crumb) {
3521
4216
  breadcrumbs.add(crumb);
@@ -3541,6 +4236,27 @@ function createInstance(config) {
3541
4236
  return;
3542
4237
  }
3543
4238
  for (const k of Object.keys(stickyTags)) delete stickyTags[k];
4239
+ },
4240
+ // ─── Rewards program (P1) ──────────────────────────────────
4241
+ async getReputation() {
4242
+ if (!userInfo?.id) return null;
4243
+ const res = await apiClient2.getMyPoints(userInfo.id);
4244
+ if (!res.ok) return null;
4245
+ return {
4246
+ totalPoints: res.data.total_points ?? 0,
4247
+ points30d: res.data.points_30d ?? 0,
4248
+ reputation: 1,
4249
+ confirmedBugs: 0,
4250
+ totalReports: 0
4251
+ };
4252
+ },
4253
+ async getTier() {
4254
+ if (!userInfo?.id) return null;
4255
+ return getTier(userInfo.id);
4256
+ },
4257
+ recordActivity(action, metadata) {
4258
+ if (!activeConfig.rewards?.enabled) return;
4259
+ enqueue({ action, metadata });
3544
4260
  }
3545
4261
  };
3546
4262
  return sdk;
@@ -3553,7 +4269,10 @@ function mergeRuntimeConfig(config, runtime) {
3553
4269
  widget: {
3554
4270
  ...config.widget,
3555
4271
  ...runtime.widget,
3556
- ...widgetTrigger ? { trigger: widgetTrigger } : {}
4272
+ ...widgetTrigger ? { trigger: widgetTrigger } : {},
4273
+ // betaMode is local-only: set by the host app, not the dashboard.
4274
+ // Restore it after the runtime spread so it is never silently cleared.
4275
+ ...config.widget?.betaMode ? { betaMode: config.widget.betaMode } : {}
3557
4276
  },
3558
4277
  capture: {
3559
4278
  ...config.capture,
@@ -3789,6 +4508,10 @@ function createNoopInstance() {
3789
4508
  setTags: () => {
3790
4509
  },
3791
4510
  clearTag: () => {
4511
+ },
4512
+ getReputation: async () => null,
4513
+ getTier: async () => null,
4514
+ recordActivity: () => {
3792
4515
  }
3793
4516
  };
3794
4517
  }