@sensiblestats/widget-react 0.37.3 → 0.38.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @sensiblestats/widget-react
2
2
 
3
+ ## 0.38.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Persist the chat conversation in the browser for a sliding hour: on return within the hour the widget silently restores the prior thread (and continues the same server thread), otherwise it starts fresh. Adds a "New conversation" control to clear and start over. Client-only (localStorage); no SDK or server change. Conversations now sync live across open browser tabs (a write or "New conversation" in one tab mirrors into the others).
8
+
3
9
  ## 0.37.3
4
10
 
5
11
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -57,6 +57,7 @@ var STRINGS = {
57
57
  launcherLabel: "Chat with SensibleStats",
58
58
  placeholder: "Ask about a team, player or match\u2026",
59
59
  closeChat: "Close chat",
60
+ newConversation: "New conversation",
60
61
  chatDialogLabel: "SensibleStats chat",
61
62
  stopResponse: "Stop response",
62
63
  sendMessage: "Send message",
@@ -129,6 +130,7 @@ var STRINGS = {
129
130
  launcherLabel: "SensibleStats ile sohbet et",
130
131
  placeholder: "Bir tak\u0131m, oyuncu veya ma\xE7 hakk\u0131nda sor\u2026",
131
132
  closeChat: "Sohbeti kapat",
133
+ newConversation: "Yeni sohbet",
132
134
  chatDialogLabel: "SensibleStats sohbet",
133
135
  stopResponse: "Yan\u0131t\u0131 durdur",
134
136
  sendMessage: "Mesaj g\xF6nder",
@@ -224,6 +226,7 @@ function normalizeUi(cfg) {
224
226
  starters: cfg.starters ?? [],
225
227
  injectStyles: cfg.injectStyles ?? true,
226
228
  closeChat: strings.closeChat,
229
+ newConversation: strings.newConversation,
227
230
  chatDialogLabel: strings.chatDialogLabel,
228
231
  stopResponse: strings.stopResponse,
229
232
  sendMessage: strings.sendMessage,
@@ -642,7 +645,7 @@ function marketOddsRowToSlipSelection(vm, row, conversationId) {
642
645
  // src/reducer.ts
643
646
  var POPUP_MAX = 160;
644
647
  function initialState(starters) {
645
- return { isOpen: false, messages: [], isStreaming: false, status: null, actionsLoading: false, starters, error: null, lastUserInput: null, lastDisplayText: null, popupText: null, conversationId: null };
648
+ return { isOpen: false, messages: [], isStreaming: false, status: null, actionsLoading: false, starters, configuredStarters: starters, error: null, lastUserInput: null, lastDisplayText: null, popupText: null, conversationId: null };
646
649
  }
647
650
  function activeTurn(state) {
648
651
  const last = state.messages[state.messages.length - 1];
@@ -722,6 +725,19 @@ function reducer(state, action) {
722
725
  }
723
726
  case "DISMISS_POPUP":
724
727
  return { ...state, popupText: null };
728
+ case "RESET":
729
+ return { ...initialState(state.configuredStarters), isOpen: state.isOpen };
730
+ case "RESTORE":
731
+ return {
732
+ ...state,
733
+ messages: action.messages,
734
+ conversationId: action.conversationId,
735
+ isStreaming: false,
736
+ status: null,
737
+ actionsLoading: false,
738
+ starters: action.messages.length ? [] : state.configuredStarters,
739
+ error: null
740
+ };
725
741
  case "EVENT":
726
742
  return applyEvent(state, action.event);
727
743
  case "STREAM_ERROR":
@@ -841,17 +857,82 @@ function useLiveDashboardPoll({ messages, dispatch, getLiveDashboard }) {
841
857
  }, [anchorKey, messages, forceUpdate]);
842
858
  }
843
859
 
860
+ // src/conversationStore.ts
861
+ var TTL_MS = 36e5;
862
+ var MAX_MESSAGES = 30;
863
+ function readConversation(storageKey, now) {
864
+ let raw;
865
+ try {
866
+ raw = window.localStorage.getItem(storageKey);
867
+ } catch {
868
+ return null;
869
+ }
870
+ if (!raw) return null;
871
+ let parsed;
872
+ try {
873
+ parsed = JSON.parse(raw);
874
+ } catch {
875
+ return "corrupt";
876
+ }
877
+ if (parsed?.v !== 1 || !Array.isArray(parsed.messages) || typeof parsed.updatedAt !== "number") return "corrupt";
878
+ if (now - parsed.updatedAt > TTL_MS) return "expired";
879
+ return { conversationId: parsed.conversationId ?? null, messages: parsed.messages };
880
+ }
881
+ function writeConversation(storageKey, conversationId, messages, now) {
882
+ let capped = messages.slice(-MAX_MESSAGES);
883
+ for (let attempt = 0; attempt < 2; attempt++) {
884
+ try {
885
+ window.localStorage.setItem(storageKey, JSON.stringify({ v: 1, updatedAt: now, conversationId, messages: capped }));
886
+ return;
887
+ } catch {
888
+ if (capped.length <= 1) return;
889
+ capped = capped.slice(Math.ceil(capped.length / 2));
890
+ }
891
+ }
892
+ }
893
+ function clearConversation(storageKey) {
894
+ try {
895
+ window.localStorage.removeItem(storageKey);
896
+ } catch {
897
+ }
898
+ }
899
+
844
900
  // src/useChatStream.ts
901
+ function hasAnswerContent(t) {
902
+ return !!t.text || t.cards.length > 0 || t.insights.length > 0 || t.marketOdds.length > 0 || t.oddsMovement.length > 0 || !!t.greetingDashboard || !!t.clubFanDashboard;
903
+ }
845
904
  var STREAM_FALLBACK_TIMEOUT_MS = 3e4;
846
- function useChatStream(conversation, starterTexts, greetingMessage) {
847
- const seed = (0, import_react2.useMemo)(() => initialState(starterTexts.map(chipFromText)), []);
848
- const [state, dispatch] = (0, import_react2.useReducer)(reducer, seed);
849
- const idRef = (0, import_react2.useRef)(0);
905
+ function seedIdCounter(messages) {
906
+ let max = -1;
907
+ for (const m of messages) {
908
+ const n = Number(m.id.slice(1));
909
+ if (m.id[0] === "m" && Number.isFinite(n) && n > max) max = n;
910
+ }
911
+ return max + 1;
912
+ }
913
+ function useChatStream(conversation, starterTexts, greetingMessage, storageKey) {
914
+ const boot = (0, import_react2.useMemo)(() => {
915
+ const payload = storageKey ? readConversation(storageKey, Date.now()) : null;
916
+ const restored = payload != null && payload !== "expired" && payload !== "corrupt";
917
+ const base = initialState(starterTexts.map(chipFromText));
918
+ if (restored) {
919
+ return { state: { ...base, messages: payload.messages, conversationId: payload.conversationId, starters: [] }, restored: true, stale: false };
920
+ }
921
+ return { state: base, restored: false, stale: payload === "expired" || payload === "corrupt" };
922
+ }, []);
923
+ const [state, dispatch] = (0, import_react2.useReducer)(reducer, boot.state);
924
+ const idRef = (0, import_react2.useRef)(seedIdCounter(boot.state.messages));
850
925
  const handleRef = (0, import_react2.useRef)(null);
851
926
  const fallbackTimerRef = (0, import_react2.useRef)(null);
852
927
  const stateRef = (0, import_react2.useRef)(state);
853
928
  stateRef.current = state;
854
- const greetedRef = (0, import_react2.useRef)(false);
929
+ const greetedRef = (0, import_react2.useRef)(boot.restored);
930
+ const hydratedRef = (0, import_react2.useRef)(false);
931
+ const restoreSeededRef = (0, import_react2.useRef)(false);
932
+ if (!restoreSeededRef.current) {
933
+ restoreSeededRef.current = true;
934
+ if (boot.restored && boot.state.conversationId) conversation.conversationId = boot.state.conversationId;
935
+ }
855
936
  const nextId = () => `m${idRef.current++}`;
856
937
  const clearFallbackTimer = (0, import_react2.useCallback)(() => {
857
938
  if (fallbackTimerRef.current != null) {
@@ -907,10 +988,9 @@ function useChatStream(conversation, starterTexts, greetingMessage) {
907
988
  const last = stateRef.current.lastUserInput;
908
989
  if (last) run(last, stateRef.current.lastDisplayText ?? void 0);
909
990
  }, [run]);
910
- (0, import_react2.useEffect)(() => {
991
+ const fireGreeting = (0, import_react2.useCallback)(() => {
911
992
  const message = greetingMessage?.trim();
912
- if (!message || greetedRef.current) return;
913
- greetedRef.current = true;
993
+ if (!message) return;
914
994
  try {
915
995
  handleRef.current?.cancel();
916
996
  clearFallbackTimer();
@@ -923,6 +1003,55 @@ function useChatStream(conversation, starterTexts, greetingMessage) {
923
1003
  dispatch({ kind: "STOP" });
924
1004
  }
925
1005
  }, [greetingMessage, conversation, consume, clearFallbackTimer, armFallbackTimer]);
1006
+ (0, import_react2.useEffect)(() => {
1007
+ if (greetedRef.current) return;
1008
+ greetedRef.current = true;
1009
+ fireGreeting();
1010
+ }, [fireGreeting]);
1011
+ (0, import_react2.useEffect)(() => {
1012
+ if (boot.stale && storageKey) clearConversation(storageKey);
1013
+ }, []);
1014
+ (0, import_react2.useEffect)(() => {
1015
+ if (!storageKey || state.isStreaming) return;
1016
+ if (hydratedRef.current) {
1017
+ hydratedRef.current = false;
1018
+ return;
1019
+ }
1020
+ const msgs = state.messages;
1021
+ const persistable = msgs.filter((m, i) => {
1022
+ if (m.role === "user") {
1023
+ const next = msgs[i + 1];
1024
+ return !!next && next.role === "assistant" && next.done && !next.isGreeting && hasAnswerContent(next);
1025
+ }
1026
+ return m.done && !m.isGreeting && hasAnswerContent(m);
1027
+ });
1028
+ if (persistable.length === 0) return;
1029
+ writeConversation(storageKey, state.conversationId, persistable, Date.now());
1030
+ }, [storageKey, state.isStreaming, state.messages, state.conversationId]);
1031
+ (0, import_react2.useEffect)(() => {
1032
+ if (!storageKey) return;
1033
+ const onStorage = (e) => {
1034
+ if (e.key !== storageKey || e.storageArea !== window.localStorage) return;
1035
+ if (stateRef.current.isStreaming) return;
1036
+ if (e.newValue == null) {
1037
+ idRef.current = 0;
1038
+ conversation.conversationId = void 0;
1039
+ hydratedRef.current = true;
1040
+ dispatch({ kind: "RESET" });
1041
+ greetedRef.current = true;
1042
+ fireGreeting();
1043
+ return;
1044
+ }
1045
+ const parsed = readConversation(storageKey, Date.now());
1046
+ if (!parsed || parsed === "expired" || parsed === "corrupt") return;
1047
+ idRef.current = seedIdCounter(parsed.messages);
1048
+ conversation.conversationId = parsed.conversationId ?? void 0;
1049
+ hydratedRef.current = true;
1050
+ dispatch({ kind: "RESTORE", messages: parsed.messages, conversationId: parsed.conversationId });
1051
+ };
1052
+ window.addEventListener("storage", onStorage);
1053
+ return () => window.removeEventListener("storage", onStorage);
1054
+ }, [storageKey, conversation, fireGreeting]);
926
1055
  const open = (0, import_react2.useCallback)(() => dispatch({ kind: "OPEN" }), []);
927
1056
  const close = (0, import_react2.useCallback)(() => {
928
1057
  handleRef.current?.cancel();
@@ -941,9 +1070,20 @@ function useChatStream(conversation, starterTexts, greetingMessage) {
941
1070
  else open();
942
1071
  }, [close, open]);
943
1072
  const dismissPopup = (0, import_react2.useCallback)(() => dispatch({ kind: "DISMISS_POPUP" }), []);
1073
+ const reset = (0, import_react2.useCallback)(() => {
1074
+ handleRef.current?.cancel();
1075
+ handleRef.current = null;
1076
+ clearFallbackTimer();
1077
+ if (storageKey) clearConversation(storageKey);
1078
+ idRef.current = 0;
1079
+ conversation.conversationId = void 0;
1080
+ dispatch({ kind: "RESET" });
1081
+ greetedRef.current = true;
1082
+ fireGreeting();
1083
+ }, [storageKey, conversation, clearFallbackTimer, fireGreeting]);
944
1084
  const getLiveDashboard = (0, import_react2.useCallback)(() => conversation.getLiveDashboard(), [conversation]);
945
1085
  useLiveDashboardPoll({ messages: state.messages, dispatch, getLiveDashboard });
946
- return { state, open, close, stop, toggle, send, retry, dismissPopup };
1086
+ return { state, open, close, stop, toggle, send, retry, dismissPopup, reset };
947
1087
  }
948
1088
 
949
1089
  // src/useLiveMonitor.ts
@@ -1288,6 +1428,9 @@ function CloseIcon() {
1288
1428
  function ChevronIcon() {
1289
1429
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 24 24", width: "1em", height: "1em", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "M9 5l7 7-7 7" }) });
1290
1430
  }
1431
+ function NewChatIcon() {
1432
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 24 24", width: "1em", height: "1em", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { fill: "none", d: "M12 5v14M5 12h14", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) });
1433
+ }
1291
1434
  var DASHBOARD_EMOJI = {
1292
1435
  football: "\u26BD",
1293
1436
  "goal-net": "\u{1F945}",
@@ -1356,10 +1499,11 @@ var import_react22 = require("react");
1356
1499
 
1357
1500
  // src/components/ChatHeader.tsx
1358
1501
  var import_jsx_runtime5 = require("react/jsx-runtime");
1359
- function ChatHeader({ onClose, closeLabel = "Close chat", logoUrl, headerTitle }) {
1502
+ function ChatHeader({ onClose, closeLabel = "Close chat", logoUrl, headerTitle, onNewConversation, canReset, newConversationLabel = "New conversation" }) {
1360
1503
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("header", { className: "ss-w-header", children: [
1361
1504
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(BrandMark, { logoUrl, className: "ss-w-header-mark" }),
1362
1505
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "ss-w-header-title", children: headerTitle ?? "SensibleStats" }),
1506
+ canReset && onNewConversation ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("button", { type: "button", className: "ss-w-newconv", "aria-label": newConversationLabel, onClick: onNewConversation, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(NewChatIcon, {}) }) : null,
1363
1507
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("button", { type: "button", className: "ss-w-close", "aria-label": closeLabel, onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(CloseIcon, {}) })
1364
1508
  ] });
1365
1509
  }
@@ -3231,7 +3375,7 @@ function useIsModal() {
3231
3375
  return modal;
3232
3376
  }
3233
3377
  function ChatPanel({ controller, ui, slip, onDashboardTap, monitor }) {
3234
- const { state, close, stop, send } = controller;
3378
+ const { state, close, stop, send, reset } = controller;
3235
3379
  const isModal = useIsModal();
3236
3380
  (0, import_react22.useEffect)(() => {
3237
3381
  const onKey = (e) => {
@@ -3248,7 +3392,18 @@ function ChatPanel({ controller, ui, slip, onDashboardTap, monitor }) {
3248
3392
  if (monitor && monitor.activeTab !== "chat" && state.isStreaming) monitor.markChatActivity();
3249
3393
  }, [monitor, state.isStreaming, state.messages.length]);
3250
3394
  return /* @__PURE__ */ (0, import_jsx_runtime49.jsxs)("div", { className: "ss-w-panel", role: "dialog", "aria-modal": isModal, "aria-label": ui.chatDialogLabel, children: [
3251
- /* @__PURE__ */ (0, import_jsx_runtime49.jsx)(ChatHeader, { onClose: close, closeLabel: ui.closeChat, logoUrl: ui.logoUrl, headerTitle: ui.headerTitle }),
3395
+ /* @__PURE__ */ (0, import_jsx_runtime49.jsx)(
3396
+ ChatHeader,
3397
+ {
3398
+ onClose: close,
3399
+ closeLabel: ui.closeChat,
3400
+ logoUrl: ui.logoUrl,
3401
+ headerTitle: ui.headerTitle,
3402
+ onNewConversation: reset,
3403
+ canReset: state.messages.length > 0,
3404
+ newConversationLabel: ui.newConversation
3405
+ }
3406
+ ),
3252
3407
  monitor && /* @__PURE__ */ (0, import_jsx_runtime49.jsx)(
3253
3408
  TabBar,
3254
3409
  {
@@ -3363,8 +3518,9 @@ var WIDGET_CSS = `.ss-w-root { all: revert; }
3363
3518
  }
3364
3519
  .ss-w-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--ss-w-line); }
3365
3520
  .ss-w-header-mark { width: 18px; height: 18px; display: block; object-fit: contain; }
3366
- .ss-w-header-title { font-weight: 660; }
3367
- .ss-w-close { margin-left: auto; background: none; border: none; color: var(--ss-w-faint); cursor: pointer; font-size: 16px; display: grid; place-items: center; }
3521
+ .ss-w-header-title { font-weight: 660; flex: 1; }
3522
+ .ss-w-newconv { background: none; border: none; color: var(--ss-w-faint); cursor: pointer; font-size: 16px; display: grid; place-items: center; }
3523
+ .ss-w-close { background: none; border: none; color: var(--ss-w-faint); cursor: pointer; font-size: 16px; display: grid; place-items: center; }
3368
3524
 
3369
3525
  /* messages */
3370
3526
  /* .ss-w-mon-list (Matches/Odds tab scrollers) shares .ss-w-list's box/scroll rules by design --
@@ -4111,7 +4267,8 @@ function StatsWidget(props) {
4111
4267
  const markIntroDone = (0, import_react23.useCallback)(() => {
4112
4268
  introDoneRef.current = true;
4113
4269
  }, []);
4114
- const controller = useChatStream(built?.conversation ?? emptyConversation(), ui.starters, built ? ui.greetingMessage : void 0);
4270
+ const convStorageKey = `ss-w-conv:${cfg?.operatorId ?? ""}:${cfg?.userContext?.operatorUserId ?? "anon"}`;
4271
+ const controller = useChatStream(built?.conversation ?? emptyConversation(), ui.starters, built ? ui.greetingMessage : void 0, convStorageKey);
4115
4272
  const monitor = useLiveMonitor({
4116
4273
  getLiveMonitor: (pins) => built ? built.conversation.getLiveMonitor(pins) : Promise.resolve(null),
4117
4274
  getMarketOdds: (matchId, dev) => built ? built.conversation.getMarketOdds(matchId, dev) : Promise.resolve(null),