@swype-org/deposit 0.3.26 → 0.3.32

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.
@@ -507,21 +507,84 @@ function parseCapabilities(value) {
507
507
  }
508
508
  return value.filter((entry) => typeof entry === "string");
509
509
  }
510
- function buildViewportMessage(viewportLvh, safeAreaBottom) {
511
- return { type: "blink:viewport", viewportLvh, safeAreaBottom };
510
+ function buildViewportMessage(viewportLvh, safeAreaBottom, embedMaxHeightPx) {
511
+ return {
512
+ type: "blink:viewport",
513
+ viewportLvh,
514
+ safeAreaBottom,
515
+ ...embedMaxHeightPx === void 0 ? {} : { embedMaxHeightPx }
516
+ };
517
+ }
518
+ var MAX_CONTENT_HEIGHT_PX = 2e4;
519
+ function parseContentHeight(data) {
520
+ if (!data || typeof data !== "object") {
521
+ return null;
522
+ }
523
+ const msg = data;
524
+ if (msg.type !== "blink:content-height") {
525
+ return null;
526
+ }
527
+ const raw = msg.heightPx;
528
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
529
+ return null;
530
+ }
531
+ const heightPx = Math.round(raw);
532
+ if (heightPx <= 0 || heightPx > MAX_CONTENT_HEIGHT_PX) {
533
+ return null;
534
+ }
535
+ return {
536
+ type: "blink:content-height",
537
+ heightPx,
538
+ ...parseWidthHint("preferredWidthPx", msg.preferredWidthPx),
539
+ ...parseWidthHint("minWidthPx", msg.minWidthPx)
540
+ };
541
+ }
542
+ function parseWidthHint(key, value) {
543
+ if (typeof value !== "number" || !Number.isFinite(value)) return {};
544
+ const px = Math.round(value);
545
+ if (px <= 0 || px > MAX_CONTENT_HEIGHT_PX) return {};
546
+ return { [key]: px };
512
547
  }
513
548
  function buildRevealMessage() {
514
549
  return { type: "blink:reveal" };
515
550
  }
516
- function buildSignedPayloadMessage(merchantId, payload, signature) {
551
+ function buildSignedPayloadMessage(merchantId, payload, signature, balance) {
517
552
  return {
518
553
  type: "blink:signed-payload",
519
554
  merchantId,
520
555
  payload,
521
- signature
556
+ signature,
557
+ ...balance === void 0 ? {} : { balance }
522
558
  };
523
559
  }
524
560
 
561
+ // src/brandParam.ts
562
+ var BRAND_COLOR_PARAM_KEYS = {
563
+ colorPrimary: "p",
564
+ colorBackground: "bg",
565
+ colorText: "t",
566
+ colorDanger: "d",
567
+ colorBorder: "b"
568
+ };
569
+ function normalizeBrandHex(value) {
570
+ if (typeof value !== "string") return null;
571
+ const hex = value.trim().toLowerCase();
572
+ if (!/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/.test(hex)) return null;
573
+ const digits = hex.slice(1);
574
+ if (digits.length === 6) return `#${digits}`;
575
+ return `#${digits[0]}${digits[0]}${digits[1]}${digits[1]}${digits[2]}${digits[2]}`;
576
+ }
577
+
578
+ // src/balanceParam.ts
579
+ var MAX_BALANCE_USD = 1e12;
580
+ function normalizeDisplayBalance(value) {
581
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
582
+ if (value < 0 || value >= MAX_BALANCE_USD) return null;
583
+ const cents = Math.floor(Number((value * 100).toPrecision(15)));
584
+ if (cents < 0 || cents >= MAX_BALANCE_USD * 100) return null;
585
+ return (cents / 100).toFixed(2);
586
+ }
587
+
525
588
  // src/viewportMetrics.ts
526
589
  function measureViewportMetrics() {
527
590
  const unavailable = { viewportLvh: 0, safeAreaBottom: 0 };
@@ -550,6 +613,84 @@ function measureViewportMetrics() {
550
613
  }
551
614
  }
552
615
 
616
+ // src/iframeCore.ts
617
+ function createFrameElement(url) {
618
+ const iframe = document.createElement("iframe");
619
+ iframe.src = url;
620
+ const iframeOrigin = new URL(url).origin;
621
+ iframe.allow = `publickey-credentials-get ${iframeOrigin}; publickey-credentials-create ${iframeOrigin}; clipboard-write ${iframeOrigin}; web-share ${iframeOrigin}`;
622
+ return { iframe, iframeOrigin };
623
+ }
624
+ function attachFrameBridges(options) {
625
+ const { iframe, iframeOrigin, postViewport, getEmbedMaxHeightPx, shouldRevealOnLoad } = options;
626
+ let detached = false;
627
+ const discoverer = createWalletDiscoverer();
628
+ let rpcHostHandle = null;
629
+ const attachBridge = () => {
630
+ if (rpcHostHandle) return;
631
+ if (!iframe.contentWindow) return;
632
+ rpcHostHandle = attachRpcHost({
633
+ iframeWindow: iframe.contentWindow,
634
+ iframeOrigin,
635
+ discoverer
636
+ });
637
+ };
638
+ attachBridge();
639
+ iframe.addEventListener("load", attachBridge);
640
+ function postReveal() {
641
+ if (detached) return;
642
+ iframe.contentWindow?.postMessage(buildRevealMessage(), iframeOrigin);
643
+ }
644
+ function postViewportMetrics() {
645
+ if (detached || !postViewport) return;
646
+ const metrics = measureViewportMetrics();
647
+ if (metrics.viewportLvh <= 0) return;
648
+ iframe.contentWindow?.postMessage(
649
+ buildViewportMessage(
650
+ metrics.viewportLvh,
651
+ metrics.safeAreaBottom,
652
+ getEmbedMaxHeightPx?.()
653
+ ),
654
+ iframeOrigin
655
+ );
656
+ }
657
+ let viewportPostScheduled = false;
658
+ const onViewportChange = () => {
659
+ if (viewportPostScheduled) return;
660
+ viewportPostScheduled = true;
661
+ requestAnimationFrame(() => {
662
+ viewportPostScheduled = false;
663
+ postViewportMetrics();
664
+ });
665
+ };
666
+ if (postViewport) {
667
+ window.addEventListener("resize", onViewportChange);
668
+ window.addEventListener("orientationchange", onViewportChange);
669
+ }
670
+ const onLoadReveal = () => {
671
+ postViewportMetrics();
672
+ if (shouldRevealOnLoad()) postReveal();
673
+ };
674
+ iframe.addEventListener("load", onLoadReveal);
675
+ return {
676
+ postReveal,
677
+ postViewportMetrics,
678
+ detach() {
679
+ if (detached) return;
680
+ detached = true;
681
+ iframe.removeEventListener("load", attachBridge);
682
+ iframe.removeEventListener("load", onLoadReveal);
683
+ window.removeEventListener("resize", onViewportChange);
684
+ window.removeEventListener("orientationchange", onViewportChange);
685
+ if (rpcHostHandle) {
686
+ rpcHostHandle.detach();
687
+ rpcHostHandle = null;
688
+ }
689
+ discoverer.destroy();
690
+ }
691
+ };
692
+ }
693
+
553
694
  // src/iframe.ts
554
695
  var STYLE_ID = "blink-deposit-styles";
555
696
  var CLOSE_DURATION_MS = 280;
@@ -591,60 +732,20 @@ function createIframe(url, containerElement, options) {
591
732
  }
592
733
  const container = document.createElement("div");
593
734
  container.setAttribute("data-blink-container", "");
594
- const iframe = document.createElement("iframe");
595
- iframe.src = url;
596
- const iframeOrigin = new URL(url).origin;
597
- iframe.allow = `publickey-credentials-get ${iframeOrigin}; publickey-credentials-create ${iframeOrigin}; clipboard-write ${iframeOrigin}`;
735
+ const { iframe, iframeOrigin } = createFrameElement(url);
598
736
  const handle = document.createElement("div");
599
737
  container.appendChild(handle);
600
738
  container.appendChild(iframe);
601
739
  overlay.appendChild(container);
602
740
  const mountTarget = containerElement ?? document.body;
603
741
  mountTarget.appendChild(overlay);
604
- const discoverer = createWalletDiscoverer();
605
- let rpcHostHandle = null;
606
- const attachBridge = () => {
607
- if (rpcHostHandle) return;
608
- if (!iframe.contentWindow) return;
609
- rpcHostHandle = attachRpcHost({
610
- iframeWindow: iframe.contentWindow,
611
- iframeOrigin,
612
- discoverer
613
- });
614
- };
615
- attachBridge();
616
- iframe.addEventListener("load", attachBridge);
617
- function postReveal() {
618
- if (closed) return;
619
- iframe.contentWindow?.postMessage(buildRevealMessage(), iframeOrigin);
620
- }
621
- function postViewportMetrics() {
622
- if (closed || !options?.fluid) return;
623
- const metrics = measureViewportMetrics();
624
- if (metrics.viewportLvh <= 0) return;
625
- iframe.contentWindow?.postMessage(
626
- buildViewportMessage(metrics.viewportLvh, metrics.safeAreaBottom),
627
- iframeOrigin
628
- );
629
- }
630
- let viewportPostScheduled = false;
631
- const onViewportChange = () => {
632
- if (viewportPostScheduled) return;
633
- viewportPostScheduled = true;
634
- requestAnimationFrame(() => {
635
- viewportPostScheduled = false;
636
- postViewportMetrics();
637
- });
638
- };
639
- if (options?.fluid) {
640
- window.addEventListener("resize", onViewportChange);
641
- window.addEventListener("orientationchange", onViewportChange);
642
- }
643
- const onLoadReveal = () => {
644
- postViewportMetrics();
645
- if (!hidden) postReveal();
646
- };
647
- iframe.addEventListener("load", onLoadReveal);
742
+ const bridges = attachFrameBridges({
743
+ iframe,
744
+ iframeOrigin,
745
+ // Only fluid needs them: the legacy fixed overlay sizes the iframe itself.
746
+ postViewport: options?.fluid === true,
747
+ shouldRevealOnLoad: () => !hidden
748
+ });
648
749
  let savedOverflow = "";
649
750
  let scrollLocked = false;
650
751
  const onBackdropClick = (event) => {
@@ -675,8 +776,6 @@ function createIframe(url, containerElement, options) {
675
776
  overlay.removeEventListener("click", onBackdropClick);
676
777
  overlay.removeEventListener("touchmove", onTouchMove);
677
778
  document.removeEventListener("keydown", onKeyDown);
678
- window.removeEventListener("resize", onViewportChange);
679
- window.removeEventListener("orientationchange", onViewportChange);
680
779
  }
681
780
  function unlockScroll() {
682
781
  if (!scrollLocked) return;
@@ -687,7 +786,7 @@ function createIframe(url, containerElement, options) {
687
786
  if (closed) return;
688
787
  closed = true;
689
788
  removeListeners();
690
- detachBridge();
789
+ bridges.detach();
691
790
  overlay.setAttribute("data-blink-closing", "");
692
791
  let removed = false;
693
792
  const removeOverlay = () => {
@@ -700,15 +799,6 @@ function createIframe(url, containerElement, options) {
700
799
  container.addEventListener("animationend", removeOverlay, { once: true });
701
800
  setTimeout(removeOverlay, CLOSE_DURATION_MS + 50);
702
801
  }
703
- function detachBridge() {
704
- iframe.removeEventListener("load", attachBridge);
705
- iframe.removeEventListener("load", onLoadReveal);
706
- if (rpcHostHandle) {
707
- rpcHostHandle.detach();
708
- rpcHostHandle = null;
709
- }
710
- discoverer.destroy();
711
- }
712
802
  return {
713
803
  get contentWindow() {
714
804
  return iframe.contentWindow;
@@ -733,15 +823,17 @@ function createIframe(url, containerElement, options) {
733
823
  overlay.style.display = "";
734
824
  lockScroll();
735
825
  attachDismissalListeners();
736
- postViewportMetrics();
737
- postReveal();
826
+ bridges.postViewportMetrics();
827
+ bridges.postReveal();
738
828
  },
739
829
  postReveal() {
740
- postReveal();
830
+ bridges.postReveal();
741
831
  },
742
832
  downgradeToFixed() {
743
833
  overlay.removeAttribute("data-blink-fluid");
744
834
  },
835
+ applyContentHeight() {
836
+ },
745
837
  onClose(callback) {
746
838
  closeCallback = callback;
747
839
  },
@@ -750,7 +842,7 @@ function createIframe(url, containerElement, options) {
750
842
  if (!closed) {
751
843
  closed = true;
752
844
  removeListeners();
753
- detachBridge();
845
+ bridges.detach();
754
846
  overlay.remove();
755
847
  unlockScroll();
756
848
  }
@@ -787,6 +879,117 @@ function shouldUseMobileSheetLayout() {
787
879
  return shortestScreenSide > 0 && shortestScreenSide <= MOBILE_SHEET_MAX_SCREEN_PX;
788
880
  }
789
881
 
882
+ // src/embeddedIframe.ts
883
+ var EMBED_STYLE_ID = "blink-deposit-embed-styles";
884
+ var EMBED_STYLES = `
885
+ [data-blink-embed]{display:block;width:100%;position:relative}
886
+ [data-blink-embed][data-blink-embed-hidden]{height:0;overflow:hidden;visibility:hidden;pointer-events:none}
887
+ [data-blink-embed] iframe{display:block;width:100%;height:0;border:none;background:transparent;color-scheme:light}
888
+ `;
889
+ function createEmbeddedIframe(url, containerElement, options) {
890
+ ensureEmbedStyles();
891
+ if (!containerElement.isConnected) {
892
+ console.warn(
893
+ "[blink] The containerElement passed to Deposit is not in the document. Embedded mode renders into it for the life of the instance, so it must be a stable node \u2014 render the slot once and hide it with `visibility: hidden` (NOT `display: none`, which removes its layout and leaves the warming flow unable to measure itself) instead of unmounting it, or construct a new Deposit against the new node."
894
+ );
895
+ }
896
+ let closed = false;
897
+ let hidden = options?.hidden === true;
898
+ let closeCallback = null;
899
+ const readEmbedMaxHeightPx = () => typeof options?.embedMaxHeightPx === "function" ? options.embedMaxHeightPx() : options?.embedMaxHeightPx;
900
+ const wrapper = document.createElement("div");
901
+ wrapper.setAttribute("data-blink-embed", "");
902
+ if (hidden) {
903
+ wrapper.setAttribute("data-blink-embed-hidden", "");
904
+ }
905
+ const { iframe, iframeOrigin } = createFrameElement(url);
906
+ iframe.setAttribute("scrolling", "no");
907
+ const initialBudget = readEmbedMaxHeightPx();
908
+ if (initialBudget !== void 0) {
909
+ iframe.style.height = `${initialBudget}px`;
910
+ }
911
+ wrapper.appendChild(iframe);
912
+ containerElement.appendChild(wrapper);
913
+ const bridges = attachFrameBridges({
914
+ iframe,
915
+ iframeOrigin,
916
+ // Embedded always needs them: the flow has no usable viewport of its own,
917
+ // so the basis it sizes against can only come from here.
918
+ postViewport: true,
919
+ getEmbedMaxHeightPx: readEmbedMaxHeightPx,
920
+ shouldRevealOnLoad: () => !hidden
921
+ });
922
+ function teardown() {
923
+ closed = true;
924
+ bridges.detach();
925
+ wrapper.remove();
926
+ }
927
+ return {
928
+ get contentWindow() {
929
+ return iframe.contentWindow;
930
+ },
931
+ close() {
932
+ if (closed) return;
933
+ teardown();
934
+ closeCallback?.();
935
+ },
936
+ isClosed() {
937
+ return closed;
938
+ },
939
+ isHidden() {
940
+ return hidden;
941
+ },
942
+ reveal() {
943
+ if (closed || !hidden) return;
944
+ hidden = false;
945
+ wrapper.removeAttribute("data-blink-embed-hidden");
946
+ warnIfContainerNotRendered(containerElement);
947
+ bridges.postViewportMetrics();
948
+ bridges.postReveal();
949
+ },
950
+ postReveal() {
951
+ bridges.postReveal();
952
+ },
953
+ downgradeToFixed() {
954
+ },
955
+ applyContentHeight(heightPx) {
956
+ if (closed) return;
957
+ const budget = readEmbedMaxHeightPx();
958
+ const capped = budget === void 0 ? heightPx : Math.min(heightPx, budget);
959
+ iframe.style.height = `${Math.max(0, Math.round(capped))}px`;
960
+ },
961
+ onClose(callback) {
962
+ closeCallback = callback;
963
+ },
964
+ destroy() {
965
+ closeCallback = null;
966
+ if (!closed) teardown();
967
+ }
968
+ };
969
+ }
970
+ function warnIfContainerNotRendered(containerElement) {
971
+ if (typeof containerElement.getBoundingClientRect !== "function") return;
972
+ const rect = containerElement.getBoundingClientRect();
973
+ if (rect.width > 0 || rect.height > 0) return;
974
+ const display = typeof getComputedStyle === "function" ? getComputedStyle(containerElement).display : "";
975
+ console.warn(
976
+ `[blink] The containerElement has no layout at the moment the flow is revealed${display === "none" ? " (display: none)" : ""}. A display:none subtree cannot be measured, so the flow may report no height and the panel stays blank at its budget height. Hide the slot with \`visibility: hidden\` (plus \`height: 0; overflow: hidden\`) instead of \`display: none\`, so it keeps its box while it warms up.`
977
+ );
978
+ }
979
+ function ensureEmbedStyles() {
980
+ const existingStyle = document.getElementById(EMBED_STYLE_ID);
981
+ if (existingStyle) {
982
+ if (existingStyle.textContent !== EMBED_STYLES) {
983
+ existingStyle.textContent = EMBED_STYLES;
984
+ }
985
+ return;
986
+ }
987
+ const style = document.createElement("style");
988
+ style.id = EMBED_STYLE_ID;
989
+ style.textContent = EMBED_STYLES;
990
+ document.head.appendChild(style);
991
+ }
992
+
790
993
  // src/signer.ts
791
994
  var DEFAULT_SIGNER_TIMEOUT_MS = 15e3;
792
995
  async function callSigner(signer, request, timeoutMs) {
@@ -917,6 +1120,13 @@ var SANDBOX_WEBVIEW_BASE_URL = "https://pay-sandbox.blink.cash";
917
1120
  var IFRAME_READY_TIMEOUT_MS = 2e3;
918
1121
  var WARM_IFRAME_READY_TIMEOUT_MS = 6e4;
919
1122
  var LAYOUT_FLUID_CAPABILITY = "layout-fluid";
1123
+ var DEFAULT_EMBED_MAX_HEIGHT_FRACTION = 0.9;
1124
+ function dismissedError() {
1125
+ return new DepositError(
1126
+ "DEPOSIT_DISMISSED",
1127
+ "The deposit was dismissed before the transfer completed."
1128
+ );
1129
+ }
920
1130
  var IFRAME_NOT_READY = { ready: false, fluidCapable: false };
921
1131
  function resolveWebviewBaseUrl(config) {
922
1132
  if (config.webviewBaseUrl) return config.webviewBaseUrl;
@@ -937,11 +1147,20 @@ var Deposit = class {
937
1147
  warmIframe = null;
938
1148
  warmIframeReady = null;
939
1149
  warmIframeReadyCancel = null;
1150
+ /** Resolved embedded-vs-overlay decision; see {@link Deposit.isEmbedded}. */
1151
+ embeddedResolved = null;
1152
+ /**
1153
+ * Settles the in-flight `requestDeposit()` as a dismissal. Non-null exactly
1154
+ * while a flow is in progress, so {@link Deposit.close} can reject the promise
1155
+ * the caller is awaiting instead of tearing the frame down under it.
1156
+ */
1157
+ dismissActiveFlow = null;
940
1158
  listeners = {
941
1159
  complete: /* @__PURE__ */ new Set(),
942
1160
  error: /* @__PURE__ */ new Set(),
943
1161
  close: /* @__PURE__ */ new Set(),
944
- "status-change": /* @__PURE__ */ new Set()
1162
+ "status-change": /* @__PURE__ */ new Set(),
1163
+ resize: /* @__PURE__ */ new Set()
945
1164
  };
946
1165
  /** Current phase of the deposit flow. */
947
1166
  get status() {
@@ -959,10 +1178,40 @@ var Deposit = class {
959
1178
  get isActive() {
960
1179
  return this._status === "signer-loading" || this._status === "iframe-active";
961
1180
  }
1181
+ /**
1182
+ * How this instance will actually present: `'embedded'` renders inline in
1183
+ * `containerElement`, `'overlay'` covers the page.
1184
+ *
1185
+ * Not simply an echo of the config — a host that asked for `'embedded'` gets
1186
+ * `'overlay'` on mobile, where a phone-sized method panel cannot hold the
1187
+ * flow (see {@link Deposit.isEmbedded}). Read it when laying out the panel:
1188
+ * on `'overlay'` no `resize` event ever fires, so a panel sized from those
1189
+ * reports would sit empty behind the overlay — collapse or skip it.
1190
+ *
1191
+ * Live until the SDK builds its first (warm-up) frame and fixed from then on,
1192
+ * so re-read it on resize rather than caching it at mount.
1193
+ */
1194
+ get presentation() {
1195
+ return this.isEmbedded() ? "embedded" : "overlay";
1196
+ }
962
1197
  constructor(config) {
963
1198
  if (!config.signer || typeof config.signer !== "string" && typeof config.signer !== "function") {
964
1199
  throw new DepositError("INVALID_REQUEST", "DepositConfig.signer is required (URL string or SignerFunction).");
965
1200
  }
1201
+ if (config.presentation === "embedded") {
1202
+ if (!config.containerElement) {
1203
+ throw new DepositError(
1204
+ "INVALID_REQUEST",
1205
+ 'DepositConfig.containerElement is required when presentation is "embedded".'
1206
+ );
1207
+ }
1208
+ if (config.layout === "fixed") {
1209
+ throw new DepositError(
1210
+ "INVALID_REQUEST",
1211
+ 'DepositConfig.layout "fixed" is an overlay-only escape hatch and cannot be combined with presentation "embedded".'
1212
+ );
1213
+ }
1214
+ }
966
1215
  this.config = config;
967
1216
  this.log("Deposit instance created", {
968
1217
  signer: typeof config.signer === "string" ? config.signer : "<function>"
@@ -988,10 +1237,7 @@ var Deposit = class {
988
1237
  const webviewBaseUrl = resolveWebviewBaseUrl(this.config);
989
1238
  this.hostedOrigin = this.config.hostedFlowOrigin ?? new URL(webviewBaseUrl).origin;
990
1239
  const preloadUrl = this.buildPreloadUrl(webviewBaseUrl);
991
- const iframe = createIframe(preloadUrl, this.config.containerElement, {
992
- hidden: true,
993
- fluid: this.isFluidLayout()
994
- });
1240
+ const iframe = this.createPresentedIframe(preloadUrl, { hidden: true });
995
1241
  const ready = this.waitForIframeReady(iframe, WARM_IFRAME_READY_TIMEOUT_MS);
996
1242
  this.warmIframe = iframe;
997
1243
  this.warmIframeReady = ready.promise;
@@ -1020,6 +1266,83 @@ var Deposit = class {
1020
1266
  window.addEventListener("load", whenIdle, { once: true });
1021
1267
  }
1022
1268
  }
1269
+ /**
1270
+ * Whether the flow renders inline in the host's element rather than as an
1271
+ * overlay.
1272
+ *
1273
+ * `presentation: 'embedded'` is a request, not a guarantee: **on mobile the
1274
+ * flow presents as the normal overlay** even for an embedded host. An
1275
+ * aggregator's method panel is a narrow, short column on a phone, and the
1276
+ * flow inside it is a payment journey with a keypad, wallet lists and a QR
1277
+ * code — it needs the screen. The overlay is exactly what a phone user gets
1278
+ * from every other Blink integration, and the host's own dialog stays behind
1279
+ * it. Desktop keeps the inline card, where the panel has room.
1280
+ *
1281
+ * **The decision lives with the frame.** Read live until a frame is built,
1282
+ * then committed for good ({@link Deposit.createPresentedIframe}) — `preload()`
1283
+ * warms a frame from these same predicates and the flow that later adopts it
1284
+ * must agree, because a frame warmed at `layout=embed` handed to the overlay
1285
+ * presenter (or the reverse) is a blank modal. Committing at frame creation
1286
+ * rather than at construction matters: a host that constructs `Deposit` before
1287
+ * layout settles — a widget mounting inside a transition, a background or
1288
+ * prerendered tab — reports a zero-width viewport, and `(max-width: 640px)`
1289
+ * matches at zero, so caching then would lock a desktop user into the overlay
1290
+ * over a viewport that never existed. The warm-up already waits for `load`
1291
+ * plus an idle callback, so by the time it commits the measurement is real.
1292
+ */
1293
+ isEmbedded() {
1294
+ if (this.config.presentation !== "embedded") return false;
1295
+ if (this.embeddedResolved !== null) return this.embeddedResolved;
1296
+ if (typeof window === "undefined") return true;
1297
+ const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
1298
+ if (!(viewportWidth > 0)) return true;
1299
+ return !shouldUseMobileSheetLayout();
1300
+ }
1301
+ /**
1302
+ * The host's height budget for the inline iframe. Explicit config wins;
1303
+ * otherwise most of the host viewport, which keeps a tall flow from
1304
+ * outgrowing the page it is embedded in.
1305
+ */
1306
+ resolveEmbedMaxHeightPx() {
1307
+ if (this.config.embedMaxHeightPx !== void 0) return this.config.embedMaxHeightPx;
1308
+ if (typeof window === "undefined") return void 0;
1309
+ const derived = Math.round(window.innerHeight * DEFAULT_EMBED_MAX_HEIGHT_FRACTION);
1310
+ return derived > 0 ? derived : void 0;
1311
+ }
1312
+ /** Build the iframe for the configured presentation. */
1313
+ createPresentedIframe(url, options) {
1314
+ if (this.config.presentation === "embedded" && this.embeddedResolved === null) {
1315
+ this.embeddedResolved = this.isEmbedded();
1316
+ if (!this.embeddedResolved) {
1317
+ this.log("Mobile device: presenting the embedded flow as an overlay");
1318
+ }
1319
+ }
1320
+ if (this.isEmbedded()) {
1321
+ return createEmbeddedIframe(url, this.config.containerElement, {
1322
+ hidden: options.hidden,
1323
+ // A getter, not a value: the default is derived from the host
1324
+ // viewport, which changes on rotation and window resize.
1325
+ embedMaxHeightPx: () => this.resolveEmbedMaxHeightPx()
1326
+ });
1327
+ }
1328
+ return createIframe(url, this.overlayMountTarget(), {
1329
+ hidden: options.hidden,
1330
+ fluid: this.isFluidLayout()
1331
+ });
1332
+ }
1333
+ /**
1334
+ * Where the overlay mounts. `containerElement` means two different things by
1335
+ * presentation: an overlay mount point (`presentation: 'overlay'`) or the
1336
+ * aggregator's inline panel slot (`'embedded'`). When an embedded flow
1337
+ * presents as an overlay on mobile, that slot must NOT be the mount point —
1338
+ * `position: fixed` resolves against the nearest transformed/filtered/
1339
+ * contained ancestor, and an aggregator's animated dialog is exactly that, so
1340
+ * the "full-screen" overlay would end up positioned inside their panel.
1341
+ * Falls through to `document.body`.
1342
+ */
1343
+ overlayMountTarget() {
1344
+ return this.config.presentation === "embedded" ? void 0 : this.config.containerElement;
1345
+ }
1023
1346
  buildPreloadUrl(webviewBaseUrl) {
1024
1347
  const preloadUrl = new URL(webviewBaseUrl);
1025
1348
  preloadUrl.searchParams.set("preload", "true");
@@ -1085,6 +1408,7 @@ var Deposit = class {
1085
1408
  const settle = (fn) => {
1086
1409
  if (this.requestId !== currentRequestId || settled) return;
1087
1410
  settled = true;
1411
+ this.dismissActiveFlow = null;
1088
1412
  fn();
1089
1413
  };
1090
1414
  const onComplete = (result) => {
@@ -1110,6 +1434,12 @@ var Deposit = class {
1110
1434
  reject(error);
1111
1435
  });
1112
1436
  };
1437
+ this.dismissActiveFlow = () => {
1438
+ settle(() => {
1439
+ this.cleanup();
1440
+ reject(dismissedError());
1441
+ });
1442
+ };
1113
1443
  if (this.config.flowTimeoutMs != null && this.config.flowTimeoutMs > 0) {
1114
1444
  this.flowTimer = setTimeout(() => {
1115
1445
  onError(
@@ -1136,9 +1466,29 @@ var Deposit = class {
1136
1466
  /** No-op — retained for API compatibility with the popup-based SDK. */
1137
1467
  focus() {
1138
1468
  }
1139
- /** Close the deposit iframe without waiting for completion. */
1469
+ /**
1470
+ * Close the deposit iframe without waiting for completion — the host's own
1471
+ * back button or dialog chrome.
1472
+ *
1473
+ * This **settles a flow in progress** by rejecting its `requestDeposit()`
1474
+ * promise with `DEPOSIT_DISMISSED`, exactly as the flow's own close control
1475
+ * does. It has to: `cleanup()` destroys the frame through `destroy()`, which
1476
+ * drops the handle's close callback on purpose, so without this the caller's
1477
+ * `await` never returns. An aggregator whose back button awaited that promise
1478
+ * to restore its method list was left showing an empty panel — the flow gone,
1479
+ * its own list still not rendered, and no error to explain why.
1480
+ *
1481
+ * The rejection code is the same `DEPOSIT_DISMISSED` the in-flow control
1482
+ * produces, so a host writes one dismissal branch rather than one per
1483
+ * affordance. What it deliberately does not do is report an error: no `error`
1484
+ * event, and `status` lands on 'idle' as it always has, because the host
1485
+ * initiated this. With no flow in progress it is a plain teardown, unchanged.
1486
+ */
1140
1487
  close() {
1141
1488
  this.log("close() called");
1489
+ const dismiss = this.dismissActiveFlow;
1490
+ this.dismissActiveFlow = null;
1491
+ dismiss?.();
1142
1492
  this.cleanup();
1143
1493
  this.setStatus("idle");
1144
1494
  this.emit("close");
@@ -1191,10 +1541,80 @@ var Deposit = class {
1191
1541
  if (theme === "dark" || theme === "system") {
1192
1542
  url.searchParams.set("appearance", theme);
1193
1543
  }
1544
+ this.applyBrandParam(url);
1545
+ }
1546
+ /**
1547
+ * Appends the merchant's brand colors as `brand=p-0f62fe.bg-ffffff…`.
1548
+ *
1549
+ * On the URL rather than a message because the hosted flow's loading shell
1550
+ * paints from its entry chunk, hundreds of ms before React mounts: a palette
1551
+ * that arrived by `postMessage` would show Blink's own card color first and
1552
+ * then become the merchant's. Unreserved characters only, so the param costs
1553
+ * ~50 bytes of the URL budget rather than triple that in percent-encoding.
1554
+ *
1555
+ * Validated here purely so the merchant sees the complaint in their OWN
1556
+ * console — the hosted flow re-validates everything it decodes, since the URL
1557
+ * is host-controlled input that ends up in a stylesheet. Silence would be the
1558
+ * worst outcome: a dropped color that nobody is told about looks like the SDK
1559
+ * ignoring the config.
1560
+ */
1561
+ applyBrandParam(url) {
1562
+ const variables = this.config.appearance?.variables;
1563
+ if (!variables) return;
1564
+ const accepted = {};
1565
+ for (const [key, value] of Object.entries(variables)) {
1566
+ if (value === void 0 || value === null) continue;
1567
+ if (!BRAND_COLOR_PARAM_KEYS[key]) {
1568
+ console.error(
1569
+ `[blink] appearance.variables.${key} is not a supported brand color. Supported: ${Object.keys(BRAND_COLOR_PARAM_KEYS).join(", ")}.`
1570
+ );
1571
+ continue;
1572
+ }
1573
+ const hex = normalizeBrandHex(value);
1574
+ if (!hex) {
1575
+ console.error(
1576
+ `[blink] appearance.variables.${key} must be opaque hex (#rgb or #rrggbb); received ${JSON.stringify(value)}. Ignoring it.`
1577
+ );
1578
+ continue;
1579
+ }
1580
+ accepted[key] = hex;
1581
+ }
1582
+ const encoded = Object.keys(BRAND_COLOR_PARAM_KEYS).filter((key) => accepted[key] !== void 0).map((key) => `${BRAND_COLOR_PARAM_KEYS[key]}-${accepted[key].slice(1)}`).join(".");
1583
+ if (encoded) url.searchParams.set("brand", encoded);
1584
+ }
1585
+ /**
1586
+ * Normalizes the display-only {@link DepositRequest.balance} for the wire,
1587
+ * or returns `undefined` to omit it everywhere — the `blink:signed-payload`
1588
+ * sibling field and the legacy fallback URL param alike. Additive wire
1589
+ * discipline: an unset or rejected balance leaves both channels
1590
+ * byte-for-byte what they always were, and old webviews ignore the field
1591
+ * by construction.
1592
+ *
1593
+ * Validated here purely so the merchant sees the complaint in their OWN
1594
+ * console (same rationale as {@link applyBrandParam}); the hosted flow
1595
+ * re-validates whatever arrives, since both channels are host-controlled
1596
+ * input. A rejected balance never blocks the deposit — the flow proceeds
1597
+ * without the subtitle.
1598
+ */
1599
+ normalizeRequestBalance(balance) {
1600
+ if (balance === void 0 || balance === null) return void 0;
1601
+ const normalized = normalizeDisplayBalance(balance);
1602
+ if (normalized === null) {
1603
+ const received = typeof balance === "number" ? String(balance) : JSON.stringify(balance) ?? String(balance);
1604
+ console.error(
1605
+ `[blink] balance must be a finite number >= 0 and < 1e12 (USD, display only); received ${received}. Ignoring it.`
1606
+ );
1607
+ return void 0;
1608
+ }
1609
+ return normalized;
1194
1610
  }
1195
- /** Fluid is the default; `layout: 'fixed'` opts back into the legacy container. */
1611
+ /**
1612
+ * Fluid is the default; `layout: 'fixed'` opts back into the legacy
1613
+ * container. Embedded is a separate presentation and never fluid — the
1614
+ * constructor rejects the combination outright.
1615
+ */
1196
1616
  isFluidLayout() {
1197
- return this.config.layout !== "fixed";
1617
+ return !this.isEmbedded() && this.config.layout !== "fixed";
1198
1618
  }
1199
1619
  /**
1200
1620
  * Marks the hosted-flow URL as fluid-layout: the iframe spans the full
@@ -1204,6 +1624,10 @@ var Deposit = class {
1204
1624
  * assumed to understand fluid layout and gets the fixed container.
1205
1625
  */
1206
1626
  applyLayoutParam(url) {
1627
+ if (this.isEmbedded()) {
1628
+ url.searchParams.set("layout", "embed");
1629
+ return;
1630
+ }
1207
1631
  if (this.isFluidLayout()) {
1208
1632
  url.searchParams.set("layout", "fluid");
1209
1633
  }
@@ -1215,7 +1639,14 @@ var Deposit = class {
1215
1639
  * afterwards (rotation, window resize) — see iframe.ts.
1216
1640
  */
1217
1641
  applyViewportParams(url) {
1218
- if (!this.isFluidLayout()) return;
1642
+ const embedded = this.isEmbedded();
1643
+ if (!this.isFluidLayout() && !embedded) return;
1644
+ if (embedded) {
1645
+ const embedMaxHeightPx = this.resolveEmbedMaxHeightPx();
1646
+ if (embedMaxHeightPx !== void 0) {
1647
+ url.searchParams.set("embedMaxHeight", String(embedMaxHeightPx));
1648
+ }
1649
+ }
1219
1650
  const metrics = measureViewportMetrics();
1220
1651
  if (metrics.viewportLvh <= 0) return;
1221
1652
  url.searchParams.set("viewportLvh", String(metrics.viewportLvh));
@@ -1235,6 +1666,7 @@ var Deposit = class {
1235
1666
  signer: typeof this.config.signer === "string" ? this.config.signer : "<function>"
1236
1667
  });
1237
1668
  const signerRequest = buildSignerRequest(request, webviewBaseUrl);
1669
+ const displayBalance = this.normalizeRequestBalance(request.balance);
1238
1670
  let preloadIframe;
1239
1671
  let iframeReadyPromise;
1240
1672
  const warm = this.takeWarmIframe();
@@ -1252,21 +1684,12 @@ var Deposit = class {
1252
1684
  });
1253
1685
  this.log("Reusing warm preload iframe");
1254
1686
  } else {
1255
- preloadIframe = createIframe(
1256
- this.buildPreloadUrl(webviewBaseUrl),
1257
- this.config.containerElement,
1258
- { fluid: this.isFluidLayout() }
1259
- );
1687
+ preloadIframe = this.createPresentedIframe(this.buildPreloadUrl(webviewBaseUrl), {});
1260
1688
  iframeReadyPromise = this.waitForIframeReady(preloadIframe).promise;
1261
1689
  }
1262
1690
  this.iframe = preloadIframe;
1263
1691
  preloadIframe.onClose(() => {
1264
- onError(
1265
- new DepositError(
1266
- "DEPOSIT_DISMISSED",
1267
- "The deposit was dismissed before the transfer completed."
1268
- )
1269
- );
1692
+ onError(dismissedError());
1270
1693
  this.cleanup();
1271
1694
  this.emit("close");
1272
1695
  });
@@ -1296,7 +1719,8 @@ var Deposit = class {
1296
1719
  buildSignedPayloadMessage(
1297
1720
  signerResponse.merchantId,
1298
1721
  signerResponse.payload,
1299
- signerResponse.signature
1722
+ signerResponse.signature,
1723
+ displayBalance
1300
1724
  ),
1301
1725
  this.hostedOrigin
1302
1726
  );
@@ -1310,17 +1734,19 @@ var Deposit = class {
1310
1734
  hostedUrl.searchParams.set("merchantId", signerResponse.merchantId);
1311
1735
  hostedUrl.searchParams.set("payload", signerResponse.payload);
1312
1736
  hostedUrl.searchParams.set("signature", signerResponse.signature);
1737
+ if (displayBalance !== void 0) {
1738
+ hostedUrl.searchParams.set("balance", displayBalance);
1739
+ }
1313
1740
  this.applyFullWidgetParam(hostedUrl);
1741
+ if (this.isEmbedded()) {
1742
+ this.applyLayoutParam(hostedUrl);
1743
+ this.applyViewportParams(hostedUrl);
1744
+ }
1314
1745
  const targetUrl = hostedUrl.toString();
1315
- const iframeHandle = createIframe(targetUrl, this.config.containerElement);
1746
+ const iframeHandle = this.createPresentedIframe(targetUrl, {});
1316
1747
  this.iframe = iframeHandle;
1317
1748
  iframeHandle.onClose(() => {
1318
- onError(
1319
- new DepositError(
1320
- "DEPOSIT_DISMISSED",
1321
- "The deposit was dismissed before the transfer completed."
1322
- )
1323
- );
1749
+ onError(dismissedError());
1324
1750
  this.cleanup();
1325
1751
  this.emit("close");
1326
1752
  });
@@ -1396,6 +1822,16 @@ var Deposit = class {
1396
1822
  iframeHandle.close();
1397
1823
  return;
1398
1824
  }
1825
+ const contentHeight = parseContentHeight(event.data);
1826
+ if (contentHeight) {
1827
+ iframeHandle.applyContentHeight(contentHeight.heightPx);
1828
+ this.emit("resize", {
1829
+ heightPx: contentHeight.heightPx,
1830
+ ...contentHeight.preferredWidthPx === void 0 ? {} : { preferredWidthPx: contentHeight.preferredWidthPx },
1831
+ ...contentHeight.minWidthPx === void 0 ? {} : { minWidthPx: contentHeight.minWidthPx }
1832
+ });
1833
+ return;
1834
+ }
1399
1835
  const message = parseTransferComplete(event.data);
1400
1836
  if (!message) {
1401
1837
  return;
@@ -1473,5 +1909,5 @@ function detectMerchantColorScheme() {
1473
1909
  var Checkout = Deposit;
1474
1910
 
1475
1911
  export { ALLOWED_RPC_METHODS, BRIDGE_PROTOCOL_VERSION, Checkout, CheckoutError, DEFAULT_WEBVIEW_BASE_URL, Deposit, DepositError, SANDBOX_WEBVIEW_BASE_URL, attachRpcHost, createWalletDiscoverer, getDisplayMessage, parseBridgeMessage };
1476
- //# sourceMappingURL=chunk-S3SPMEXQ.js.map
1477
- //# sourceMappingURL=chunk-S3SPMEXQ.js.map
1912
+ //# sourceMappingURL=chunk-MJSD7JWR.js.map
1913
+ //# sourceMappingURL=chunk-MJSD7JWR.js.map