{"version":3,"sources":["../src/detect/install-state.ts"],"names":["EXTENSION_READY_EVENT","BEACIO_EVENTS","DISMISS_KEY","RETURN_KEY","RETURN_LINK_HOST","APP_STORE_URL","hasWindowMarker","hasNavigatorMarker","nav","hasInstallMarker","hasActiveMarker","getInstallState","isExtensionActive","observeInstallState","timeoutMs","current","resolve","settled","finish","state","onReady","timer","DEFAULT_DISMISS_DAYS","SHORT_DISMISS_DAYS","isDismissed","until","dismiss","days","dismissShort","buildReturnLink","here","returnPageURL","returnLink","buildOpenInSafariUrl","pageUrl","url","saveReturnContext","getReturnContext","raw","parsed"],"mappings":"qCA0DA,IAAMA,CAAAA,CAAwBC,EAAc,eAAA,CAGtCC,CAAAA,CAAc,uBACdC,CAAAA,CAAa,eAAA,CACbC,EAAmB,iBAAA,CAUZC,CAAAA,CAAgB,0CAQ7B,SAASC,CAAAA,EAA2B,CAClC,OAAO,OAAO,OAAW,GAAA,EAAgB,MAAA,CAAwB,QAAA,EAAU,MAAA,GAAW,WACxF,CAEA,SAASC,CAAAA,EAA8B,CACrC,GAAI,OAAO,SAAA,CAAc,IACvB,OAAO,MAAA,CAET,IAAMC,CAAAA,CAAM,SAAA,CACZ,OAAO,CAAA,EAAQA,CAAAA,CAAI,QAAUA,CAAAA,CAAI,MAAA,CAAO,SAC1C,CAEA,SAASC,CAAAA,EAA4B,CACnC,OAAO,OAAO,SAAa,GAAA,EAAe,QAAA,CAAS,gBAAgB,OAAA,CAAQ,eAAA,GAAoB,MACjG,CAEA,SAASC,GAA2B,CAClC,OAAO,OAAO,QAAA,CAAa,GAAA,EAAe,SAAS,eAAA,CAAgB,OAAA,CAAQ,kBAAoB,MACjG,CAQO,SAASC,CAAAA,EAAyC,CACvD,OAAIJ,GAAmB,EAAKG,CAAAA,GACnB,QAAA,CAELJ,CAAAA,IAAqBG,CAAAA,EAAiB,CACjC,oBAAA,CAEF,eACT,CAGO,SAASG,GAA6B,CAC3C,OAAOD,GAAgB,GAAM,QAC/B,CAaO,SAASE,CAAAA,CAAoBC,CAAAA,CAAY,GAAA,CAAsC,CACpF,IAAMC,EAAUJ,CAAAA,EAAgB,CAChC,OAAII,CAAAA,GAAY,QAAA,EAAY,OAAO,MAAA,CAAW,GAAA,CACrC,QAAQ,OAAA,CAAQA,CAAO,EAGzB,IAAI,OAAA,CAASC,GAAY,CAC9B,IAAIC,EAAU,KAAA,CACRC,CAAAA,CAAUC,CAAAA,EAAuC,CACjDF,CAAAA,GACJA,CAAAA,CAAU,KACV,MAAA,CAAO,mBAAA,CAAoBjB,EAAuBoB,CAAO,CAAA,CACzD,aAAaC,CAAK,CAAA,CAClBL,CAAAA,CAAQG,CAAK,CAAA,EACf,CAAA,CACMC,EAAU,IAAYF,CAAAA,CAAO,QAAQ,CAAA,CAE3C,MAAA,CAAO,iBAAiBlB,CAAAA,CAAuBoB,CAAO,CAAA,CACtD,IAAMC,CAAAA,CAAQ,UAAA,CAAW,IAAMH,CAAAA,CAAOP,CAAAA,EAAiB,CAAA,CAAGG,CAAS,EACrE,CAAC,CACH,CAsBO,IAAMQ,CAAAA,CAAuB,GACvBC,CAAAA,CAAqB,EAG3B,SAASC,CAAAA,EAAuB,CACrC,GAAI,CACF,IAAMC,CAAAA,CAAQ,YAAA,CAAa,OAAA,CAAQvB,CAAW,EAC9C,OAAKuB,CAAAA,CACE,KAAK,GAAA,EAAI,CAAI,SAASA,CAAAA,CAAO,EAAE,CAAA,CADnB,CAAA,CAErB,CAAA,KAAQ,CACN,OAAO,MACT,CACF,CAOO,SAASC,CAAAA,CAAQC,EAAOL,CAAAA,CAA4B,CACzD,GAAI,CACF,YAAA,CAAa,OAAA,CAAQpB,EAAa,MAAA,CAAO,IAAA,CAAK,KAAI,CAAIyB,CAAAA,CAAO,KAAQ,CAAC,EACxE,MAAQ,CAER,CACF,CAQO,SAASC,CAAAA,EAAqB,CACnCF,CAAAA,CAAQH,CAAkB,EAC5B,CAaO,SAASM,CAAAA,EAA0B,CACxC,IAAMC,CAAAA,CAAO,OAAO,MAAA,CAAW,GAAA,CAAc,OAAO,QAAA,CAAS,IAAA,CAAO,qBAC9DC,CAAAA,CAAgB,IAAI,GAAA,CAAID,CAAI,CAAA,CAC5BE,CAAAA,CAAa,IAAI,GAAA,CAAI,CAAA,QAAA,EAAW5B,CAAgB,CAAA,OAAA,CAAS,CAAA,CAC/D,OAAA4B,CAAAA,CAAW,YAAA,CAAa,GAAA,CAAI,KAAA,CAAOD,CAAAA,CAAc,QAAA,EAAU,CAAA,CACpDC,CAAAA,CAAW,UACpB,CAoBO,SAASC,CAAAA,CAAqBC,CAAAA,CAAyB,CAC5D,GAAI,CACF,IAAMC,CAAAA,CAAM,IAAI,IAAID,CAAO,CAAA,CAC3B,OAAQC,CAAAA,CAAI,QAAA,EACV,KAAK,QAAA,CACL,KAAK,QACH,OAAO,CAAA,SAAA,EAAYA,EAAI,IAAI,CAAA,CAAA,CAC7B,QACE,OAAO,EACX,CACF,CAAA,KAAQ,CAEN,OAAO,EACT,CACF,CASO,SAASC,CAAAA,EAA0B,CACxC,GAAI,OAAO,MAAA,CAAW,GAAA,CAAa,OACnC,IAAML,CAAAA,CAAgB,IAAI,GAAA,CAAI,MAAA,CAAO,SAAS,IAAI,CAAA,CAC5CC,EAAaH,CAAAA,EAAgB,CAEnC,GAAI,CACF,YAAA,CAAa,QACX1B,CAAAA,CACA,IAAA,CAAK,UAAU,CAAE,GAAA,CAAK4B,EAAc,QAAA,EAAS,CAAG,UAAA,CAAAC,CAAAA,CAAY,SAAA,CAAW,IAAA,CAAK,KAAM,CAAC,CACrF,CAAA,CACA,SAAA,CAAU,SAAS,OAAA,KACrB,CAAA,KAAQ,CAER,CACA,GAAI,CACF,SAAA,CAAU,SAAA,EAAW,UAAUA,CAAU,EAC3C,MAAQ,CAER,CACF,CAOO,SAASK,CAAAA,EAAwD,CACtE,IAAMP,CAAAA,CAAO,OAAO,OAAW,GAAA,CAAc,MAAA,CAAO,SAAS,IAAA,CAAO,EAAA,CACpE,GAAI,CACF,IAAMQ,EAAM,YAAA,CAAa,OAAA,CAAQnC,CAAU,CAAA,CAC3C,GAAImC,EAAK,CACP,IAAMC,CAAAA,CAAS,IAAA,CAAK,KAAA,CAAMD,CAAG,EACvBH,CAAAA,CAAMI,CAAAA,CAAO,KAAOT,CAAAA,CAC1B,OAAO,CAAE,GAAA,CAAAK,CAAAA,CAAK,UAAA,CAAYI,CAAAA,CAAO,UAAA,EAAcJ,CAAI,CACrD,CACF,CAAA,KAAQ,CAER,CACA,OAAO,CAAE,GAAA,CAAKL,CAAAA,CAAM,UAAA,CAAYA,CAAK,CACvC","file":"chunk-XWGZ4KAL.mjs","sourcesContent":["/**\n * SB-SDK-12: the framework-agnostic, ZERO-DOM headless onboarding primitive for\n * vanilla-JS partners (Storz & Bickel's app is vanilla JS + jQuery and cannot use\n * the React wizard; the only vanilla option before this — showInstallBanner —\n * injects beacio chrome).\n *\n * This is the SINGLE shared derivation of install state + the return-link and\n * dismissal bookkeeping. Before this module the same marker logic was duplicated\n * THREE ways (detect.ts resolveInstallState, react-sdk ExtensionDetector\n * readInstallState, banner.ts isExtensionActiveNow) and the return-link/dismissal\n * helpers were module-private in banner.ts, exported nowhere. Now:\n *   - detect.ts and the react-sdk ExtensionDetector consume getInstallState()\n *     (AC1 \"the detection logic is SHARED, not duplicated\").\n *   - banner.ts consumes saveReturnContext / getReturnContext / isDismissed /\n *     dismiss from here (behavior + private call sites unchanged).\n *   - index.ts re-exports the headless surface so a classic <script> partner can\n *     draw its OWN \"Enable Bluetooth in Safari\" card with no beacio pixels.\n *\n * NONE of these helpers inject DOM.\n *\n * SB-SDK-02 (Part B) constraint: this module is reachable from the package-root\n * barrel, and `@beacio/core` is an OPTIONAL peer (it may be absent on a standalone\n * `npm i @beacio/detect`). So it must NOT hard-import core at module top level —\n * that throws at load. NOTE (W13-RECONCILE 2026-08-05): this used to cite\n * \"optional-core.test.ts + no-toplevel-core-import.test.ts\" as the guards.\n * NEITHER FILE HAS EVER EXISTED anywhere in the repo — the rule is enforced only\n * by the tsup build shape plus the gzip tripwire in\n * `packages/core/tests/bundle-size-budget.test.ts` (a top-level core import would\n * blow the budget), which is indirect. Do not add a citation back until a test\n * with that name is actually written. The one core value it needs — the\n * EXTENSION_READY event name — is\n * inlined here and pinned to core's BeacioEventName union via a fully-erased\n * `import type` + `satisfies`, exactly like index.ts's BEACIO_EVENTS map: a name\n * that diverges from core's source of truth becomes a COMPILE error while no\n * runtime core load is required (events.test.ts is the seam-crossing control).\n */\n// detect now lives INSIDE @beacio/core: the EXTENSION_READY event name is an\n// intra-package import from core's canonical map (single source of truth).\nimport { BEACIO_EVENTS } from '../events';\n\n/**\n * Where the user is in the irreducibly-manual iOS-26 setup funnel, derived purely\n * from the in-page markers the content script / injected polyfill set:\n *  - 'not-installed'      → no markers; the app is not installed\n *  - 'installed-inactive' → installed, but the Safari extension toggle is off\n *  - 'active'             → the polyfill is live on this page\n *\n * The shared shape consumed by detect.ts, the react-sdk ExtensionDetector, and\n * the headless API. (The per-site 'denied' refinement is NOT a marker state — it\n * is derived separately in initBeacio from navigator.bluetooth.getAvailability().)\n */\nexport type ExtensionInstallState = 'not-installed' | 'installed-inactive' | 'active';\n\n/**\n * The in-page extension handshake event the injected polyfill dispatches when it\n * goes live. Sourced directly from core's canonical {@link BEACIO_EVENTS} map so\n * it cannot drift ('beacio:extension:ready').\n */\nconst EXTENSION_READY_EVENT = BEACIO_EVENTS.EXTENSION_READY;\n\n/** localStorage keys — the SAME keys banner.ts has always used (back-compat). */\nconst DISMISS_KEY = 'beacio_dismiss_until';\nconst RETURN_KEY = 'beacio_return';\nconst RETURN_LINK_HOST = 'link.beacio.com';\n\n/**\n * SB-SDK-12 (AC4): the canonical id-form App Store URL for the public beacio app.\n * The id form survives the public App Store rename — pinning the banner CTA here\n * means no banner code path can hardcode a NAME slug (`/app/<slug>/id…`) that\n * would 404 or mislead if Apple's slug differs from \"beacio\". The slug-form URL is\n * a SEPARATE concern owned by the CDN/website surfaces; the SDK side uses the id\n * form only.\n */\nexport const APP_STORE_URL = 'https://apps.apple.com/app/id6761301368';\n\n// ─── Install-state markers (the single shared derivation) ────────────────────\n\ninterface BeacioWindow extends Window {\n  __beacio?: { status?: string };\n}\n\nfunction hasWindowMarker(): boolean {\n  return typeof window !== 'undefined' && (window as BeacioWindow).__beacio?.status === 'installed';\n}\n\nfunction hasNavigatorMarker(): boolean {\n  if (typeof navigator === 'undefined') {\n    return false;\n  }\n  const nav = navigator as { beacio?: { __beacio?: boolean } };\n  return Boolean(nav.beacio && nav.beacio.__beacio);\n}\n\nfunction hasInstallMarker(): boolean {\n  return typeof document !== 'undefined' && document.documentElement.dataset.beacioInstalled === 'true';\n}\n\nfunction hasActiveMarker(): boolean {\n  return typeof document !== 'undefined' && document.documentElement.dataset.beacioExtension === 'true';\n}\n\n/**\n * Synchronously read the current install state from the in-page markers. Pure,\n * zero-DOM, side-effect-free — the one accessor detect.ts, the react-sdk\n * ExtensionDetector, and the banner all share. A vanilla-JS partner calls this\n * to decide whether to render its own \"Enable Bluetooth in Safari\" card.\n */\nexport function getInstallState(): ExtensionInstallState {\n  if (hasNavigatorMarker() || hasActiveMarker()) {\n    return 'active';\n  }\n  if (hasWindowMarker() || hasInstallMarker()) {\n    return 'installed-inactive';\n  }\n  return 'not-installed';\n}\n\n/** True once the content script has flagged the extension active on this page. */\nexport function isExtensionActive(): boolean {\n  return getInstallState() === 'active';\n}\n\n/**\n * SB-SDK-12 (AC3): the headless detector. Resolves the CURRENT install state\n * immediately when it is already 'active' (markers set); otherwise it waits for\n * the in-page extension to announce itself via the canonical EXTENSION_READY\n * handshake ('beacio:extension:ready') — the seam the react-sdk ExtensionDetector\n * and the in-page polyfill already speak — and resolves 'active' when it fires.\n * Falls back to a final marker read after `timeoutMs`. Injects no DOM.\n *\n * This lets a vanilla-JS partner await activation without polling and without any\n * beacio chrome: `const state = await observeInstallState();`.\n */\nexport function observeInstallState(timeoutMs = 3000): Promise<ExtensionInstallState> {\n  const current = getInstallState();\n  if (current === 'active' || typeof window === 'undefined') {\n    return Promise.resolve(current);\n  }\n\n  return new Promise((resolve) => {\n    let settled = false;\n    const finish = (state: ExtensionInstallState): void => {\n      if (settled) return;\n      settled = true;\n      window.removeEventListener(EXTENSION_READY_EVENT, onReady);\n      clearTimeout(timer);\n      resolve(state);\n    };\n    const onReady = (): void => finish('active');\n\n    window.addEventListener(EXTENSION_READY_EVENT, onReady);\n    const timer = setTimeout(() => finish(getInstallState()), timeoutMs);\n  });\n}\n\n// ─── Dismissal frequency-capping (zero-DOM bookkeeping) ──────────────────────\n\n/**\n * SB-PRD-08 (AC5): the two suppression windows, written to the SAME DISMISS_KEY.\n *\n * - LONG (`DEFAULT_DISMISS_DAYS`) is the EXPLICIT \"Don't show again\" — the user\n *   deliberately opted out, so honour it for a fortnight.\n * - SHORT (`SHORT_DISMISS_DAYS`) is a soft \"Not now\" / backdrop tap: the user is\n *   interested-but-not-ready, not opted out.\n *\n * Why the long default is 14 and NOT longer in a hardware-companion context: a\n * Storz & Bickel device is a considered EUR300-700 purchase whose owner returns\n * over days/weeks while they actually receive and set up the hardware. The old\n * behaviour applied this 14-day silence to EVERY dismiss gesture, so one reflexive\n * \"Not now\" churned a warm lead. We keep 14 ONLY for the explicit opt-out and make\n * the incidental dismiss a single day, so the passive on-load banner re-appears on\n * the next session while a force-show recovery path (banner.ts) always lets a\n * dismissed user re-open setup immediately. 14 stays the LONG default (configurable\n * via dismissDays) because an explicit opt-out should not nag the next day either.\n */\nexport const DEFAULT_DISMISS_DAYS = 14;\nexport const SHORT_DISMISS_DAYS = 1;\n\n/** True while a prior dismissal is still inside its suppression window. */\nexport function isDismissed(): boolean {\n  try {\n    const until = localStorage.getItem(DISMISS_KEY);\n    if (!until) return false;\n    return Date.now() < parseInt(until, 10);\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Suppress the prompt for `days` (default {@link DEFAULT_DISMISS_DAYS} = 14) — the\n * LONG, explicit \"Don't show again\" window. Named `dismiss` on the headless\n * surface; banner.ts calls it directly.\n */\nexport function dismiss(days = DEFAULT_DISMISS_DAYS): void {\n  try {\n    localStorage.setItem(DISMISS_KEY, String(Date.now() + days * 86400000));\n  } catch {\n    /* noop */\n  }\n}\n\n/**\n * SB-PRD-08 (AC1): the SHORT, soft-dismissal primitive — a \"Not now\" / backdrop\n * tap suppresses the passive banner for {@link SHORT_DISMISS_DAYS} (1 day) only,\n * not the full fortnight, so an interested-but-not-ready user is not silenced for\n * two weeks. Writes the SAME DISMISS_KEY window as {@link dismiss}, just shorter.\n */\nexport function dismissShort(): void {\n  dismiss(SHORT_DISMISS_DAYS);\n}\n\n// ─── Return-to-web-app context (zero-DOM; clipboard + localStorage) ──────────\n\n/**\n * SBOPT-P2.4: the PURE, side-effect-free return-link builder — the\n * `https://link.beacio.com/return?url=<encoded current href>` form, with NO\n * localStorage write and NO clipboard copy. {@link saveReturnContext} persists and\n * copies this exact string; the tier-3 headless resolveOnboardingState (index.ts)\n * embeds it in the funnel state so a partner can render its OWN return affordance\n * without tripping those side effects. Falls back to the beacio.com origin when\n * window is absent (SSR).\n */\nexport function buildReturnLink(): string {\n  const here = typeof window !== 'undefined' ? window.location.href : 'https://beacio.com';\n  const returnPageURL = new URL(here);\n  const returnLink = new URL(`https://${RETURN_LINK_HOST}/return`);\n  returnLink.searchParams.set('url', returnPageURL.toString());\n  return returnLink.toString();\n}\n\n/**\n * GH #448: the PURE \"escape hatch\" builder for the iOS Home Screen web-app dead\n * end. `x-safari-<url>` is the WebKit scheme that hands a URL to Safari proper, so\n * a page trapped in a standalone web app (where Safari extensions never load) can\n * offer a single tap back into a real Safari tab\n * (project_safari_only_links — the same `x-safari-https://` form the website uses).\n *\n * Only http(s) is mapped; anything else (about:, file:, javascript:, a non-URL, or\n * the empty off-DOM page URL) returns the EMPTY-STRING sentinel rather than\n * undefined, so the caller's field stays required and a renderer can branch on\n * `=== ''` instead of an optionality check. Side-effect-free.\n *\n * The NORMALISED `url.href` is what gets the prefix, never the raw argument: this\n * is a public headless-API entry point taking an arbitrary string, and `new URL()`\n * happily parses forms WebKit's scheme handler would choke on —\n * 'https:beacio.com', 'HTTPS://beacio.com/', or an input with surrounding\n * whitespace would otherwise produce a malformed `x-safari-…` deep link.\n */\nexport function buildOpenInSafariUrl(pageUrl: string): string {\n  try {\n    const url = new URL(pageUrl);\n    switch (url.protocol) {\n      case 'https:':\n      case 'http:':\n        return `x-safari-${url.href}`;\n      default:\n        return '';\n    }\n  } catch {\n    // Not a parseable absolute URL (including the '' off-DOM sentinel).\n    return '';\n  }\n}\n\n/**\n * Persist (and best-effort copy) the originating page so the return link survives\n * the round trip into Settings and back. The return link is the\n * `https://link.beacio.com/return?url=<encoded current href>` form built by\n * {@link buildReturnLink}. Injects no DOM — a partner surfaces the link in its OWN\n * card.\n */\nexport function saveReturnContext(): void {\n  if (typeof window === 'undefined') return;\n  const returnPageURL = new URL(window.location.href);\n  const returnLink = buildReturnLink();\n\n  try {\n    localStorage.setItem(\n      RETURN_KEY,\n      JSON.stringify({ url: returnPageURL.toString(), returnLink, timestamp: Date.now() })\n    );\n    navigator.storage?.persist?.();\n  } catch {\n    /* noop */\n  }\n  try {\n    navigator.clipboard?.writeText(returnLink);\n  } catch {\n    /* noop */\n  }\n}\n\n/**\n * The originating page saved by {@link saveReturnContext}, as a VISIBLE, tappable\n * affordance — never relying on a silent clipboard write. Returns the current\n * href as a sensible fallback when nothing was saved.\n */\nexport function getReturnContext(): { url: string; returnLink: string } {\n  const here = typeof window !== 'undefined' ? window.location.href : '';\n  try {\n    const raw = localStorage.getItem(RETURN_KEY);\n    if (raw) {\n      const parsed = JSON.parse(raw) as { url?: string; returnLink?: string };\n      const url = parsed.url || here;\n      return { url, returnLink: parsed.returnLink || url };\n    }\n  } catch {\n    /* noop */\n  }\n  return { url: here, returnLink: here };\n}\n"]}