PageSourceSearch

https://lvlltd-skill-marketplace.pages.dev/assets/shopping-assistant.js?v=4

js lvlltd-skill-marketplace.pages.dev collected 2026-10-03 05:24:50 UTC 14,101 bytes, 426 lines download raw bytes

1/**
2 * LVL LTD — human shopping assistant (static embed)
3 * Catalog-grounded via /api/shop-assist. Session-only memory.
4 * Proactive: max 1 interruption / session on multi-browse or outline dwell.
5 *
6 * P0: skip mount on dense interactive surfaces (marketplace grid, AR/trek
7 * heavy pages) — Skill guide chrome + sessionStorage work competes with
8 * 100+ card paint and can lock the main thread. Opt-in with ?sa=1.
9 */
10(function (global) {
11  "use strict";
12
13  if (global.__LVL_SHOP_ASSIST_MOUNTED__) return;
14
15  function isDenseSurface() {
16    try {
17      var path = (location.pathname || "").toLowerCase();
18      var q = location.search || "";
19      if (/[?&]sa=1(?:&|$)/.test(q)) return false; // explicit opt-in
20      // Marketplace inventory (SSR + interactive grid)
21      if (path.indexOf("/hub/marketplace") !== -1) return true;
22      // Dense AR / trek map shells on apex mirrors
23      if (path.indexOf("/trek/ar") !== -1 || path === "/ar" || path.indexOf("/ar/") === 0)
24        return true;
25      // Explicit kill switch
26      if (/[?&]sa=0(?:&|$)/.test(q)) return true;
27      if (document.documentElement.getAttribute("data-lvl-no-sa") === "1") return true;
28      if (document.body && document.body.getAttribute("data-lvl-no-sa") === "1") return true;
29    } catch (_) {
30      /* */
31    }
32    return false;
33  }
34
35  if (isDenseSurface()) {
36    global.__LVL_SHOP_ASSIST_MOUNTED__ = true; // prevent re-entry if script double-loads
37    global.__LVL_SHOP_ASSIST_SKIPPED__ = "dense_surface";
38    return;
39  }
40
41  global.__LVL_SHOP_ASSIST_MOUNTED__ = true;
42
43  const API = "/api/shop-assist";
44  const SESS_KEY = "lvl_shop_assist_session_v1";
45  const PRO_KEY = "lvl_shop_assist_proactive_v1";
46  const UI_KEY = "lvl_shop_assist_ui_v1";
47
48  function loadJSON(key, fallback) {
49    try {
50      const r = sessionStorage.getItem(key);
51      return r ? JSON.parse(r) : fallback;
52    } catch {
53      return fallback;
54    }
55  }
56  function saveJSON(key, val) {
57    try {
58      sessionStorage.setItem(key, JSON.stringify(val));
59    } catch {
60      /* */
61    }
62  }
63
64  function emptySession() {
65    return {
66      id: "sess_" + Date.now().toString(36),
67      messages: [],
68      lastSkillIds: [],
69    };
70  }
71
72  function defaultPro() {
73    return {
74      skillPageViews: [],
75      outlineDwellMs: {},
76      unlockAttempted: false,
77      proactiveSent: false,
78    };
79  }
80
81  function skillIdFromLocation() {
82    try {
83      const u = new URL(location.href);
84      if (u.searchParams.get("buy")) return u.searchParams.get("buy");
85      const m1 = u.pathname.match(/\/skills\/([^/]+)/);
86      if (m1) return decodeURIComponent(m1[1]);
87      const m2 = u.pathname.match(/\/agents\/([^/]+)/);
88      if (m2 && !["data", "feed", "crew", "docs"].includes(m2[1]))
89        return decodeURIComponent(m2[1]);
90    } catch {
91      /* */
92    }
93    return null;
94  }
95
96  function trackBrowse() {
97    const pro = loadJSON(PRO_KEY, defaultPro());
98    const id = skillIdFromLocation();
99    if (id && pro.skillPageViews.indexOf(id) === -1) {
100      pro.skillPageViews.push(id);
101      pro.skillPageViews = pro.skillPageViews.slice(-20);
102      saveJSON(PRO_KEY, pro);
103    }
104    if (/pay=1|unlock/i.test(location.search)) {
105      pro.unlockAttempted = true;
106      saveJSON(PRO_KEY, pro);
107    }
108    // Outline dwell — single timer, capped (never leave unbounded setIntervals)
109    if (id && /outline/i.test(location.pathname + location.href) && !global.__LVL_SA_DWELL__) {
110      global.__LVL_SA_DWELL__ = true;
111      var ticks = 0;
112      var iv = setInterval(function () {
113        ticks += 1;
114        if (ticks > 36) {
115          // ~3 minutes max
116          clearInterval(iv);
117          return;
118        }
119        const p = loadJSON(PRO_KEY, defaultPro());
120        p.outlineDwellMs[id] = (p.outlineDwellMs[id] || 0) + 5000;
121        saveJSON(PRO_KEY, p);
122      }, 5000);
123    }
124  }
125
126  function evaluateProactive() {
127    const s = loadJSON(PRO_KEY, defaultPro());
128    if (s.proactiveSent || s.unlockAttempted) return null;
129    if (s.skillPageViews.length >= 3) {
130      const last = s.skillPageViews[s.skillPageViews.length - 1];
131      return {
132        message:
133          "You've looked at " +
134          s.skillPageViews.length +
135          ' skills (including “' +
136          last +
137          "”) without unlocking. Is the wallet/USDC step the blocker, or are you still comparing free outlines?",
138      };
139    }
140    for (const id in s.outlineDwellMs) {
141      if (s.outlineDwellMs[id] >= 45000) {
142        return {
143          message:
144            'You\'ve spent a while on the free outline for “' +
145            id +
146            ".” What's holding you back — unclear fit, price, or MetaMask/USDC steps?",
147        };
148      }
149    }
150    return null;
151  }
152
153  function el(tag, cls, html) {
154    const n = document.createElement(tag);
155    if (cls) n.className = cls;
156    if (html != null) n.innerHTML = html;
157    return n;
158  }
159
160  function mount() {
161    trackBrowse();
162
163    const root = el("div", "lvl-sa-root");
164    root.setAttribute("data-lvl-shopping-assistant", "1");
165    const fab = el("button", "lvl-sa-fab");
166    fab.type = "button";
167    fab.textContent = "Skill guide";
168    fab.setAttribute("aria-label", "Open skill shopping guide");
169
170    const panel = el("section", "lvl-sa-panel");
171    panel.setAttribute("aria-label", "LVL skill shopping assistant");
172    panel.hidden = true;
173
174    panel.innerHTML =
175      '<header class="lvl-sa-header">' +
176      '<button type="button" class="lvl-sa-header-main" aria-label="Expand skill guide">' +
177      '<div><strong>Skill guide</strong>' +
178      '<div class="lvl-sa-sub">Humans only · live catalog · free outline first</div></div>' +
179      '<span class="lvl-sa-min-hint" hidden>Click to expand</span>' +
180      "</button>" +
181      '<div class="lvl-sa-header-actions">' +
182      '<button type="button" class="lvl-sa-min" aria-label="Minimize chat" title="Minimize">−</button>' +
183      '<button type="button" class="lvl-sa-x" aria-label="Close chat" title="Close">×</button>' +
184      "</div></header>" +
185      '<div class="lvl-sa-msgs" id="lvl-sa-msgs"></div>' +
186      '<form class="lvl-sa-form" id="lvl-sa-form">' +
187      '<input id="lvl-sa-input" placeholder="What does your agent need?" aria-label="Message" autocomplete="off" />' +
188      '<button type="submit" id="lvl-sa-send">Send</button></form>';
189
190    root.appendChild(fab);
191    root.appendChild(panel);
192    document.body.appendChild(root);
193
194    const msgs = panel.querySelector("#lvl-sa-msgs");
195    const form = panel.querySelector("#lvl-sa-form");
196    const input = panel.querySelector("#lvl-sa-input");
197    const sendBtn = panel.querySelector("#lvl-sa-send");
198    const minBtn = panel.querySelector(".lvl-sa-min");
199    const closeBtn = panel.querySelector(".lvl-sa-x");
200    const headerMain = panel.querySelector(".lvl-sa-header-main");
201    const minHint = panel.querySelector(".lvl-sa-min-hint");
202    let busy = false;
203    let session = loadJSON(SESS_KEY, emptySession());
204    /** @type {"closed"|"open"|"minimized"} */
205    let uiState = loadJSON(UI_KEY, { state: "closed" }).state || "closed";
206
207    function persistUi(state) {
208      uiState = state;
209      saveJSON(UI_KEY, { state: state });
210    }
211
212    function ensureMessages() {
213      if (!msgs.childNodes.length) restore();
214    }
215
216    function setOpen() {
217      panel.hidden = false;
218      fab.hidden = true;
219      panel.classList.remove("is-minimized");
220      minBtn.setAttribute("aria-label", "Minimize chat");
221      minBtn.title = "Minimize";
222      minBtn.textContent = "−";
223      if (minHint) minHint.hidden = true;
224      headerMain.setAttribute("aria-label", "Skill guide");
225      panel.setAttribute("aria-expanded", "true");
226      ensureMessages();
227      persistUi("open");
228      try {
229        input.focus();
230      } catch {
231        /* */
232      }
233    }
234
235    function setMinimized() {
236      panel.hidden = false;
237      fab.hidden = true;
238      panel.classList.add("is-minimized");
239      minBtn.setAttribute("aria-label", "Expand chat");
240      minBtn.title = "Expand";
241      minBtn.textContent = "▢";
242      if (minHint) minHint.hidden = false;
243      headerMain.setAttribute("aria-label", "Expand skill guide");
244      panel.setAttribute("aria-expanded", "false");
245      ensureMessages();
246      persistUi("minimized");
247    }
248
249    function setClosed() {
250      panel.hidden = true;
251      fab.hidden = false;
252      panel.classList.remove("is-minimized");
253      panel.setAttribute("aria-expanded", "false");
254      if (minHint) minHint.hidden = true;
255      persistUi("closed");
256    }
257
258    function renderWelcome() {
259      if (session.messages && session.messages.length) return;
260      const b = el(
261        "div",
262        "lvl-sa-bubble-bot",
263        "Tell me what you need for your agent (e.g. “negotiate purchases” or “cheapest way to try”). I’ll match <strong>real catalog skills</strong> and always point at free outlines before payment."
264      );
265      msgs.appendChild(b);
266    }
267
268    function appendBubble(role, text, proactive) {
269      const b = el(
270        "div",
271        role === "user" ? "lvl-sa-bubble-user" : "lvl-sa-bubble-bot"
272      );
273      if (proactive) {
274        b.appendChild(el("div", "lvl-sa-proactive", "Proactive · once this visit"));
275      }
276      const body = document.createElement("div");
277      body.textContent = text;
278      b.appendChild(body);
279      msgs.appendChild(b);
280      msgs.scrollTop = msgs.scrollHeight;
281    }
282
283    function appendCards(skills) {
284      if (!skills || !skills.length) return;
285      const wrap = el("div", "lvl-sa-cards");
286      skills.forEach(function (s) {
287        const c = el("article", "lvl-sa-card");
288        c.innerHTML =
289          "<strong></strong><div class='lvl-sa-price'></div><p class='lvl-sa-sum'></p>" +
290          (s.tradeoff_note ? "<p class='lvl-sa-trade'></p>" : "") +
291          "<a target='_blank' rel='noopener'>Free outline first</a> · <a>Marketplace</a>";
292        c.querySelector("strong").textContent = s.name;
293        c.querySelector(".lvl-sa-price").textContent = s.price_label || "";
294        c.querySelector(".lvl-sa-sum").textContent = s.summary || "";
295        const trade = c.querySelector(".lvl-sa-trade");
296        if (trade) trade.textContent = s.tradeoff_note || "";
297        const links = c.querySelectorAll("a");
298        links[0].href = s.outline_url;
299        links[1].href = s.marketplace_url;
300        wrap.appendChild(c);
301      });
302      msgs.appendChild(wrap);
303      msgs.scrollTop = msgs.scrollHeight;
304    }
305
306    function restore() {
307      (session.messages || []).forEach(function (m) {
308        appendBubble(m.role, m.content, m.meta && m.meta.proactive);
309      });
310      renderWelcome();
311    }
312
313    fab.onclick = function () {
314      setOpen();
315    };
316    closeBtn.onclick = function (e) {
317      e.stopPropagation();
318      setClosed();
319    };
320    minBtn.onclick = function (e) {
321      e.stopPropagation();
322      if (panel.classList.contains("is-minimized")) setOpen();
323      else setMinimized();
324    };
325    headerMain.onclick = function () {
326      if (panel.classList.contains("is-minimized")) setOpen();
327    };
328    // Never restore full-open panel on load — it covers marketplace cards.
329    // Only restore minimized chrome (compact bar) if user left it that way.
330    // Fresh visits and "open" sessions start closed (FAB only).
331    if (uiState === "minimized") setMinimized();
332    else setClosed();
333
334    form.onsubmit = function (e) {
335      e.preventDefault();
336      const text = (input.value || "").trim();
337      if (!text || busy) return;
338      busy = true;
339      sendBtn.disabled = true;
340      input.value = "";
341      appendBubble("user", text);
342      session.messages.push({
343        role: "user",
344        content: text,
345        at: new Date().toISOString(),
346      });
347      saveJSON(SESS_KEY, session);
348
349      fetch(API, {
350        method: "POST",
351        headers: { "Content-Type": "application/json" },
352        body: JSON.stringify({
353          message: text,
354          session_id: session.id,
355          history: session.messages.slice(-12),
356          page_url: location.href,
357        }),
358      })
359        .then(function (r) {
360          return r.json();
361        })
362        .then(function (data) {
363          const reply =
364            (data && data.reply) ||
365            "Could not load catalog matches — try the marketplace.";
366          appendBubble("assistant", reply);
367          session.messages.push({
368            role: "assistant",
369            content: reply,
370            at: new Date().toISOString(),
371            meta: { skill_ids: (data.skills || []).map(function (s) { return s.id; }) },
372          });
373          saveJSON(SESS_KEY, session);
374          appendCards(data.skills || []);
375        })
376        .catch(function () {
377          appendBubble(
378            "assistant",
379            "Network error. Browse https://lvlltd.com/hub/marketplace or retry."
380          );
381        })
382        .finally(function () {
383          busy = false;
384          sendBtn.disabled = false;
385        });
386    };
387
388    // Proactive once — minimized bar only (never full panel over commerce grid)
389    setTimeout(function () {
390      try {
391        const hit = evaluateProactive();
392        if (!hit) return;
393        const pro = loadJSON(PRO_KEY, defaultPro());
394        pro.proactiveSent = true;
395        saveJSON(PRO_KEY, pro);
396        ensureMessages();
397        appendBubble("assistant", hit.message, true);
398        session.messages.push({
399          role: "assistant",
400          content: hit.message,
401          at: new Date().toISOString(),
402          meta: { proactive: true },
403        });
404        saveJSON(SESS_KEY, session);
405        setMinimized();
406        // Pulse FAB if still closed somehow
407        try {
408          fab.classList.add("lvl-sa-fab-pulse");
409          setTimeout(function () {
410            fab.classList.remove("lvl-sa-fab-pulse");
411          }, 4000);
412        } catch (e) {
413          /* */
414        }
415      } catch (e) {
416        console.warn("shop-assist proactive", e);
417      }
418    }, 2800);
419  }
420
421  if (document.readyState === "loading") {
422    document.addEventListener("DOMContentLoaded", mount);
423  } else {
424    mount();
425  }
426})(typeof window !== "undefined" ? window : globalThis);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.