@pulsebyshiga/collect-js 0.2.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/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/collect.iife.js +1 -0
- package/dist/format.d.ts +8 -0
- package/dist/format.js +29 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +162 -0
- package/dist/protocol.d.ts +94 -0
- package/dist/protocol.js +13 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shiga Digital
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @pulsebyshiga/collect-js
|
|
2
|
+
|
|
3
|
+
Web loader for Pulse Collect's embedded payment components. Mounts a Pulse-controlled
|
|
4
|
+
iframe into your page and gives you a typed event surface around it — the payment surface,
|
|
5
|
+
session token, and order data never enter your DOM. You own the layout, chrome, and brand.
|
|
6
|
+
|
|
7
|
+
> **Status: pre-release (0.1.0).** Published API may change until the Pulse contract is
|
|
8
|
+
> reconciled.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Any framework or none — the loader is dependency-free
|
|
13
|
+
- ESM (`import`) for the npm package; a script-tag IIFE build is also shipped
|
|
14
|
+
- A session token (`cs_*`) minted by your backend with [`@pulsebyshiga/node`](../node-sdk) —
|
|
15
|
+
secret keys never reach the browser
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install @pulsebyshiga/collect-js
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or without a bundler:
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<script src="https://embed.pulse.shiga.io/v1/collect.iife.js"></script>
|
|
27
|
+
<script>
|
|
28
|
+
window.PulseCollect.mount(target, { sessionToken });
|
|
29
|
+
</script>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { PulseCollect } from '@pulsebyshiga/collect-js';
|
|
36
|
+
|
|
37
|
+
const handle = PulseCollect.mount(document.getElementById('pay'), {
|
|
38
|
+
sessionToken, // cs_* from your backend
|
|
39
|
+
theme: { primaryColor: '#083a9a', fontFamily: 'YourFont, sans-serif' },
|
|
40
|
+
strings: { title: 'Add {target} to your safe' },
|
|
41
|
+
onSuccess: (orderId) => showFunded(orderId), // UX only — credit off the webhook
|
|
42
|
+
onError: (code, message) => report(code, message),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// later
|
|
46
|
+
handle.unmount();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
By default the component walks the user through **Select asset → Choose network → Pay**;
|
|
50
|
+
the selection re-targets the order (new address, QR, re-locked quote) before payment.
|
|
51
|
+
Pass `flow: "direct"` to pin the asset/network chosen at session creation and render the
|
|
52
|
+
single payment view.
|
|
53
|
+
|
|
54
|
+
## API
|
|
55
|
+
|
|
56
|
+
### `PulseCollect.mount(target, options) → EmbedHandle`
|
|
57
|
+
|
|
58
|
+
Creates the iframe inside `target` and wires the message bridge. Returns
|
|
59
|
+
`{ iframe, unmount() }`. Throws if `sessionToken` is missing or not a `cs_*` token.
|
|
60
|
+
|
|
61
|
+
### Options
|
|
62
|
+
|
|
63
|
+
| Option | Type | Default | Description |
|
|
64
|
+
| ---------------- | ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
65
|
+
| `sessionToken` | `string` | — (required) | Single-order session token from your backend. |
|
|
66
|
+
| `component` | `"payment" \| "quote" \| "status"` | `"payment"` | Full flow, live locked quote only, or progress/status only. |
|
|
67
|
+
| `flow` | `"select" \| "direct"` | `"select"` | In-embed asset/network selection, or pinned to the session's values. |
|
|
68
|
+
| `networks` | `string[]` | all enabled | Narrow the selectable networks for this mount. Intersected with your dashboard configuration — narrowing only, never widening. |
|
|
69
|
+
| `assets` | `string[]` | all enabled | Narrow the payable assets; same rules as `networks`. |
|
|
70
|
+
| `theme` | `EmbedTheme` | design defaults | Visual tokens — see [Theming](#theming). |
|
|
71
|
+
| `strings` | `EmbedStrings` | built-in copy | Copy overrides — see [Copy](#copy). |
|
|
72
|
+
| `readyTimeoutMs` | `number` | `15000` | Ms to wait for the embed's ready signal before `onError("embed_load_timeout")`. |
|
|
73
|
+
| `embedUrl` | `string` | production embed | Embed origin override (local development, partner-subdomain deployment). |
|
|
74
|
+
| `apiUrl` | `string` | embed default | API base the embed calls with the session token. |
|
|
75
|
+
|
|
76
|
+
### Events
|
|
77
|
+
|
|
78
|
+
| Callback | Signature | Notes |
|
|
79
|
+
| ------------------ | ------------------- | -------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `onReady` | `()` | Embed rendered and polling. |
|
|
81
|
+
| `onStatusChange` | `(status, orderId)` | Every lifecycle transition — see status values below. |
|
|
82
|
+
| `onSuccess` | `(orderId)` | Order settled. **UX signal only** — credit users from the signed `disbursement.completed` webhook. |
|
|
83
|
+
| `onQuoteExpired` | `()` | Locked quote lapsed; the embed offers recovery itself. |
|
|
84
|
+
| `onQuoteRefreshed` | `(lockedUntil)` | A new quote window was locked. |
|
|
85
|
+
| `onError` | `(code, message)` | Load, session, or action failures (e.g. `embed_load_timeout`, `session_load_failed`). |
|
|
86
|
+
|
|
87
|
+
Status values: `loading`, `awaiting_payment`, `deposit_detected`, `deposit_confirmed`,
|
|
88
|
+
`converting`, `completed`, `underpaid`, `overpaid`, `wrong_chain`, `expired`, `failed`.
|
|
89
|
+
|
|
90
|
+
## Theming
|
|
91
|
+
|
|
92
|
+
All tokens are optional; unset tokens fall back to your dashboard configuration, then to
|
|
93
|
+
the design defaults.
|
|
94
|
+
|
|
95
|
+
| Token | Applies to |
|
|
96
|
+
| ------------------------------ | ------------------------------------------------------------------------------------ |
|
|
97
|
+
| `primaryColor` | Buttons and accents — your brand color. |
|
|
98
|
+
| `buttonTextColor` | Text on primary buttons. |
|
|
99
|
+
| `mode` | `"light"` (default) or `"dark"` palette. |
|
|
100
|
+
| `fontFamily` | Whole component. |
|
|
101
|
+
| `inkColor` / `mutedColor` | Primary / secondary text. |
|
|
102
|
+
| `backgroundColor` | Panel background. |
|
|
103
|
+
| `lineColor` | Borders, dividers, inactive step markers. |
|
|
104
|
+
| `successColor` / `dangerColor` | Positive states / failures and final-minute countdown. |
|
|
105
|
+
| `borderRadius` | Control rounding, e.g. `"8px"`. |
|
|
106
|
+
| `pageBackgroundColor` | Full-page (Tier B) backdrop. |
|
|
107
|
+
| `density` | `"compact"` (default) or `"comfortable"`. |
|
|
108
|
+
| `attribution` | `"subtle"` (default) or `"none"` — the "Powered by Pulse" footer is never mandatory. |
|
|
109
|
+
|
|
110
|
+
## Copy
|
|
111
|
+
|
|
112
|
+
Override any user-facing string via `strings`: `title`, `subtitle`, `heroLabel`,
|
|
113
|
+
`successTitle`, `successSubtitle`, `expiredTitle`, `expiredSubtitle`, `refreshButton`,
|
|
114
|
+
`copyButton`, `copiedButton`. Values may use the placeholders `{target}` (formatted fiat),
|
|
115
|
+
`{assetAmount}`, `{asset}`, `{network}`, and `{settlementRef}`.
|
|
116
|
+
|
|
117
|
+
## Server-driven configuration
|
|
118
|
+
|
|
119
|
+
Networks, assets, theme, copy, attribution, and default flow can also be set once in the
|
|
120
|
+
Pulse dashboard (`partner_config`); the embed applies them with no code change. Precedence
|
|
121
|
+
is _defaults ← dashboard ← mount options_, except the network/asset lists, which only ever
|
|
122
|
+
narrow: a mount cannot re-enable what the dashboard disabled, and the server rejects
|
|
123
|
+
disabled values at session creation and re-selection.
|
|
124
|
+
|
|
125
|
+
## Security model
|
|
126
|
+
|
|
127
|
+
- The session token travels in the URL **fragment** — it is never sent to any server in a
|
|
128
|
+
request line or stored in logs.
|
|
129
|
+
- Messages are accepted only from the embed's origin and window; everything else is ignored.
|
|
130
|
+
- The iframe runs with a least-privilege `sandbox` (no top navigation, no downloads).
|
|
131
|
+
- Keep your `sk_*` key and any user PII on your backend; this package needs neither.
|
|
132
|
+
|
|
133
|
+
## Advanced
|
|
134
|
+
|
|
135
|
+
- **`buildEmbedSrc(options)`** — returns the embed URL + origin without mounting; used by
|
|
136
|
+
the React Native wrapper and useful for Tier B (full-page, `layout=page`) links.
|
|
137
|
+
- **`formatFiat(amount, currency)` / `formatAsset(amount)`** — the embed's own formatters,
|
|
138
|
+
exported so surrounding partner UI renders amounts identically.
|
|
139
|
+
|
|
140
|
+
## Related packages
|
|
141
|
+
|
|
142
|
+
React bindings: [`@pulsebyshiga/collect-react`](../collect-react) ·
|
|
143
|
+
React Native: [`@pulsebyshiga/collect-react-native`](../collect-react-native) ·
|
|
144
|
+
Backend: [`@pulsebyshiga/node`](../node-sdk)
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
[MIT](../../LICENSE) © Shiga Digital
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var PulseCollect=(()=>{var h=Object.defineProperty;var H=Object.getOwnPropertyDescriptor;var N=Object.getOwnPropertyNames;var _=Object.prototype.hasOwnProperty;var A=(t,e)=>{for(var s in e)h(t,s,{get:e[s],enumerable:!0})},L=(t,e,s,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of N(e))!_.call(t,r)&&r!==s&&h(t,r,{get:()=>e[r],enumerable:!(i=H(e,r))||i.enumerable});return t};var U=t=>L(h({},"__esModule",{value:!0}),t);var $={};A($,{CHANNEL:()=>l,PulseCollect:()=>M,buildEmbedSrc:()=>k,default:()=>F,formatAsset:()=>C,formatFiat:()=>w,isEmbedToHostMessage:()=>b,mount:()=>S,useSession:()=>v});var l="pulse-collect";function b(t){return typeof t=="object"&&t!==null&&"channel"in t&&t.channel===l&&"type"in t}function w(t,e){let s=Number(t);if(!Number.isFinite(s))return`${t} ${e}`;let i=s.toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2});return e==="NGN"?`\u20A6${i}`:e==="USD"?`$${i}`:`${i} ${e}`}function C(t){let e=Number(t);return Number.isFinite(e)?e.toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:6}):t}function v(t){return{mode:"session",sessionToken:t}}var P="https://embed.pulse.shiga.io/v1/",I=420,x=15e3,O=["primaryColor","borderRadius","mode","fontFamily","inkColor","mutedColor","backgroundColor","buttonTextColor","lineColor","successColor","dangerColor","pageBackgroundColor","density","attribution"],R=["title","subtitle","heroLabel","successTitle","successSubtitle","expiredTitle","expiredSubtitle","refreshButton","copyButton","copiedButton"];function k(t){var m,c,g,u,n,o;let e=(c=(m=t.auth)==null?void 0:m.sessionToken)!=null?c:t.sessionToken;if(e===void 0||e===""||!e.startsWith("cs_"))throw new Error("@pulsebyshiga/collect-js: a session token (cs_*) is required");let s=(g=t.embedUrl)!=null?g:P,i=new URL(s).origin,r=new URL(s);r.searchParams.set("component",(u=t.component)!=null?u:"payment"),t.parentOrigin!==void 0?r.searchParams.set("parentOrigin",t.parentOrigin):typeof window!="undefined"&&typeof window.location!="undefined"&&r.searchParams.set("parentOrigin",window.location.origin),t.apiUrl!==void 0&&r.searchParams.set("apiUrl",t.apiUrl),t.flow!==void 0&&r.searchParams.set("flow",t.flow),t.networks!==void 0&&t.networks.length>0&&r.searchParams.set("networks",t.networks.join(",")),t.assets!==void 0&&t.assets.length>0&&r.searchParams.set("assets",t.assets.join(","));for(let a of O){let d=(n=t.theme)==null?void 0:n[a];d!==void 0&&r.searchParams.set(a,d)}for(let a of R){let d=(o=t.strings)==null?void 0:o[a];d!==void 0&&r.searchParams.set(`str_${a}`,d)}return r.hash=`token=${encodeURIComponent(e)}`,{src:r.toString(),embedOrigin:i}}function S(t,e){var u;let{src:s,embedOrigin:i}=k(e),r=document.createElement("iframe");r.src=s,r.title="Payment",r.allow="clipboard-write",r.setAttribute("sandbox","allow-scripts allow-same-origin allow-forms allow-popups"),r.style.width="100%",r.style.border="0",r.style.display="block",r.style.height=`${I}px`;let m=n=>{n.origin===i&&n.source===r.contentWindow&&b(n.data)&&g(n.data)},c=window.setTimeout(()=>{var n,o;(o=e.onError)==null||o.call(e,"embed_load_timeout",`The payment component did not become ready within ${(n=e.readyTimeoutMs)!=null?n:x}ms`)},(u=e.readyTimeoutMs)!=null?u:x),g=n=>{var o,a,d,p,y,E,T;switch(n.type){case"ready":{if(window.clearTimeout(c),e.theme!==void 0||e.strings!==void 0){let f={channel:l,type:"configure"};e.theme!==void 0&&(f.theme=e.theme),e.strings!==void 0&&(f.strings=e.strings),(o=r.contentWindow)==null||o.postMessage(f,i)}(a=e.onReady)==null||a.call(e);break}case"resize":r.style.height=`${Math.max(n.height,0)}px`;break;case"status_change":(d=e.onStatusChange)==null||d.call(e,n.status,n.orderId);break;case"quote_expired":(p=e.onQuoteExpired)==null||p.call(e);break;case"quote_refreshed":(y=e.onQuoteRefreshed)==null||y.call(e,n.lockedUntil);break;case"success":(E=e.onSuccess)==null||E.call(e,n.orderId);break;case"error":(T=e.onError)==null||T.call(e,n.code,n.message);break}};return window.addEventListener("message",m),t.appendChild(r),{iframe:r,unmount:()=>{window.clearTimeout(c),window.removeEventListener("message",m),r.remove()}}}var M={mount:S},F=M;return U($);})();
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amount display helpers shared by the embed and offered to partners so their
|
|
3
|
+
* own screens format amounts identically to the payment component.
|
|
4
|
+
*/
|
|
5
|
+
/** "1000000.00" + "NGN" → "₦1,000,000.00"; unknown currencies fall back to a suffix. */
|
|
6
|
+
export declare function formatFiat(amount: string, currency: string): string;
|
|
7
|
+
/** "6000.00" → "6,000.00" (up to 6dp for small stablecoin amounts). */
|
|
8
|
+
export declare function formatAsset(amount: string): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amount display helpers shared by the embed and offered to partners so their
|
|
3
|
+
* own screens format amounts identically to the payment component.
|
|
4
|
+
*/
|
|
5
|
+
/** "1000000.00" + "NGN" → "₦1,000,000.00"; unknown currencies fall back to a suffix. */
|
|
6
|
+
export function formatFiat(amount, currency) {
|
|
7
|
+
const value = Number(amount);
|
|
8
|
+
if (!Number.isFinite(value))
|
|
9
|
+
return `${amount} ${currency}`;
|
|
10
|
+
const formatted = value.toLocaleString('en-US', {
|
|
11
|
+
minimumFractionDigits: 2,
|
|
12
|
+
maximumFractionDigits: 2,
|
|
13
|
+
});
|
|
14
|
+
if (currency === 'NGN')
|
|
15
|
+
return `₦${formatted}`;
|
|
16
|
+
if (currency === 'USD')
|
|
17
|
+
return `$${formatted}`;
|
|
18
|
+
return `${formatted} ${currency}`;
|
|
19
|
+
}
|
|
20
|
+
/** "6000.00" → "6,000.00" (up to 6dp for small stablecoin amounts). */
|
|
21
|
+
export function formatAsset(amount) {
|
|
22
|
+
const value = Number(amount);
|
|
23
|
+
if (!Number.isFinite(value))
|
|
24
|
+
return amount;
|
|
25
|
+
return value.toLocaleString('en-US', {
|
|
26
|
+
minimumFractionDigits: 2,
|
|
27
|
+
maximumFractionDigits: 6,
|
|
28
|
+
});
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type EmbedComponent, type EmbedStatus, type EmbedStrings, type EmbedTheme } from './protocol.js';
|
|
2
|
+
/**
|
|
3
|
+
* The browser-safe credential. Only a session token (`cs_*`) is ever accepted
|
|
4
|
+
* client-side — an API key must never ship to a browser. `useSession` is the
|
|
5
|
+
* client mirror of the backend's useSession; there is deliberately no
|
|
6
|
+
* `useApiKey` here.
|
|
7
|
+
*/
|
|
8
|
+
export type ClientAuth = {
|
|
9
|
+
readonly mode: 'session';
|
|
10
|
+
readonly sessionToken: string;
|
|
11
|
+
};
|
|
12
|
+
/** Wrap a single-order session token for `mount`/`<PulseCollectPayment>`. */
|
|
13
|
+
export declare function useSession(sessionToken: string): ClientAuth;
|
|
14
|
+
export type MountOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* The browser-safe credential: a single-order session token (`cs_*`) from
|
|
17
|
+
* the partner backend (collectionSessions.create). Pass it directly, or as
|
|
18
|
+
* `auth: useSession(cs_…)`.
|
|
19
|
+
*/
|
|
20
|
+
sessionToken?: string;
|
|
21
|
+
/** Alternative to `sessionToken` — the result of `useSession(cs_…)`. */
|
|
22
|
+
auth?: ClientAuth;
|
|
23
|
+
/** Embed origin override — local dev or a partner-subdomain deployment. */
|
|
24
|
+
embedUrl?: string;
|
|
25
|
+
/** API base the embed calls with the session token (dev: the local Pulse stand-in). */
|
|
26
|
+
apiUrl?: string;
|
|
27
|
+
/** Which piece to render: full "payment" flow (default), live "quote" only, or "status" only. */
|
|
28
|
+
component?: EmbedComponent;
|
|
29
|
+
/**
|
|
30
|
+
* "select" (default): the user picks asset + network inside the embed
|
|
31
|
+
* (Select asset → Choose network → Pay). "direct": skip selection — the
|
|
32
|
+
* values pinned at session creation are used (R5.3 partner pinning).
|
|
33
|
+
*/
|
|
34
|
+
flow?: 'select' | 'direct';
|
|
35
|
+
/**
|
|
36
|
+
* Narrow the networks selectable in this mount. Intersected with the
|
|
37
|
+
* partner's dashboard config (`partner_config.enabled_networks`) — a mount
|
|
38
|
+
* can only narrow further, never re-enable a network the config removed.
|
|
39
|
+
* Omit for every network the partner has enabled.
|
|
40
|
+
*/
|
|
41
|
+
networks?: string[];
|
|
42
|
+
/** Narrow the payable assets in this mount. Same narrowing rules as `networks`. */
|
|
43
|
+
assets?: string[];
|
|
44
|
+
theme?: EmbedTheme;
|
|
45
|
+
strings?: EmbedStrings;
|
|
46
|
+
/** Ms to wait for the embed's ready signal before onError("embed_load_timeout"). Default 15000. */
|
|
47
|
+
readyTimeoutMs?: number;
|
|
48
|
+
onReady?: () => void;
|
|
49
|
+
onStatusChange?: (status: EmbedStatus, orderId: string) => void;
|
|
50
|
+
onSuccess?: (orderId: string) => void;
|
|
51
|
+
onQuoteExpired?: () => void;
|
|
52
|
+
onQuoteRefreshed?: (lockedUntil: string) => void;
|
|
53
|
+
onError?: (code: string, message: string) => void;
|
|
54
|
+
};
|
|
55
|
+
export type EmbedHandle = {
|
|
56
|
+
iframe: HTMLIFrameElement;
|
|
57
|
+
unmount: () => void;
|
|
58
|
+
};
|
|
59
|
+
export type EmbedTarget = {
|
|
60
|
+
src: string;
|
|
61
|
+
embedOrigin: string;
|
|
62
|
+
};
|
|
63
|
+
export type BuildEmbedSrcOptions = Pick<MountOptions, 'sessionToken' | 'auth' | 'embedUrl' | 'apiUrl' | 'component' | 'flow' | 'networks' | 'assets' | 'theme' | 'strings'> & {
|
|
64
|
+
/** Origin of the page hosting the embed. Defaults to window.location.origin. */
|
|
65
|
+
parentOrigin?: string;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Build the embed URL. Theme and copy travel as query params so the first
|
|
69
|
+
* paint is already branded; the token travels in the fragment so it is never
|
|
70
|
+
* sent to any server in a URL or request log. Shared by the web loader and
|
|
71
|
+
* the React Native WebView wrapper.
|
|
72
|
+
*/
|
|
73
|
+
export declare function buildEmbedSrc(options: BuildEmbedSrcOptions): EmbedTarget;
|
|
74
|
+
/**
|
|
75
|
+
* Mount a Pulse Collect component into the partner's page. The component
|
|
76
|
+
* renders inside a Pulse-controlled iframe: the session token, order data and
|
|
77
|
+
* payment surface never enter the partner DOM. The partner owns everything
|
|
78
|
+
* around it — layout, chrome, branding (theme + strings).
|
|
79
|
+
*/
|
|
80
|
+
export declare function mount(target: HTMLElement, options: MountOptions): EmbedHandle;
|
|
81
|
+
export declare const PulseCollect: {
|
|
82
|
+
mount: typeof mount;
|
|
83
|
+
};
|
|
84
|
+
export default PulseCollect;
|
|
85
|
+
export { CHANNEL, isEmbedToHostMessage, type EmbedComponent, type EmbedStatus, type EmbedStrings, type EmbedTheme, type EmbedToHostMessage, type HostToEmbedMessage, } from './protocol.js';
|
|
86
|
+
export { formatAsset, formatFiat } from './format.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { CHANNEL, isEmbedToHostMessage, } from './protocol.js';
|
|
2
|
+
/** Wrap a single-order session token for `mount`/`<PulseCollectPayment>`. */
|
|
3
|
+
export function useSession(sessionToken) {
|
|
4
|
+
return { mode: 'session', sessionToken };
|
|
5
|
+
}
|
|
6
|
+
const DEFAULT_EMBED_URL = 'https://embed.pulse.shiga.io/v1/';
|
|
7
|
+
const INITIAL_HEIGHT_PX = 420;
|
|
8
|
+
const DEFAULT_READY_TIMEOUT_MS = 15_000;
|
|
9
|
+
const THEME_PARAM_KEYS = [
|
|
10
|
+
'primaryColor',
|
|
11
|
+
'borderRadius',
|
|
12
|
+
'mode',
|
|
13
|
+
'fontFamily',
|
|
14
|
+
'inkColor',
|
|
15
|
+
'mutedColor',
|
|
16
|
+
'backgroundColor',
|
|
17
|
+
'buttonTextColor',
|
|
18
|
+
'lineColor',
|
|
19
|
+
'successColor',
|
|
20
|
+
'dangerColor',
|
|
21
|
+
'pageBackgroundColor',
|
|
22
|
+
'density',
|
|
23
|
+
'attribution',
|
|
24
|
+
];
|
|
25
|
+
const STRING_PARAM_KEYS = [
|
|
26
|
+
'title',
|
|
27
|
+
'subtitle',
|
|
28
|
+
'heroLabel',
|
|
29
|
+
'successTitle',
|
|
30
|
+
'successSubtitle',
|
|
31
|
+
'expiredTitle',
|
|
32
|
+
'expiredSubtitle',
|
|
33
|
+
'refreshButton',
|
|
34
|
+
'copyButton',
|
|
35
|
+
'copiedButton',
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Build the embed URL. Theme and copy travel as query params so the first
|
|
39
|
+
* paint is already branded; the token travels in the fragment so it is never
|
|
40
|
+
* sent to any server in a URL or request log. Shared by the web loader and
|
|
41
|
+
* the React Native WebView wrapper.
|
|
42
|
+
*/
|
|
43
|
+
export function buildEmbedSrc(options) {
|
|
44
|
+
const sessionToken = options.auth?.sessionToken ?? options.sessionToken;
|
|
45
|
+
if (sessionToken === undefined || sessionToken === '' || !sessionToken.startsWith('cs_')) {
|
|
46
|
+
throw new Error('@pulsebyshiga/collect-js: a session token (cs_*) is required');
|
|
47
|
+
}
|
|
48
|
+
const embedUrl = options.embedUrl ?? DEFAULT_EMBED_URL;
|
|
49
|
+
const embedOrigin = new URL(embedUrl).origin;
|
|
50
|
+
const src = new URL(embedUrl);
|
|
51
|
+
src.searchParams.set('component', options.component ?? 'payment');
|
|
52
|
+
if (options.parentOrigin !== undefined) {
|
|
53
|
+
src.searchParams.set('parentOrigin', options.parentOrigin);
|
|
54
|
+
}
|
|
55
|
+
else if (typeof window !== 'undefined' && typeof window.location !== 'undefined') {
|
|
56
|
+
// Absent in React Native (the WebView bridge needs no origin) — browser only.
|
|
57
|
+
src.searchParams.set('parentOrigin', window.location.origin);
|
|
58
|
+
}
|
|
59
|
+
if (options.apiUrl !== undefined)
|
|
60
|
+
src.searchParams.set('apiUrl', options.apiUrl);
|
|
61
|
+
if (options.flow !== undefined)
|
|
62
|
+
src.searchParams.set('flow', options.flow);
|
|
63
|
+
if (options.networks !== undefined && options.networks.length > 0) {
|
|
64
|
+
src.searchParams.set('networks', options.networks.join(','));
|
|
65
|
+
}
|
|
66
|
+
if (options.assets !== undefined && options.assets.length > 0) {
|
|
67
|
+
src.searchParams.set('assets', options.assets.join(','));
|
|
68
|
+
}
|
|
69
|
+
for (const key of THEME_PARAM_KEYS) {
|
|
70
|
+
const value = options.theme?.[key];
|
|
71
|
+
if (value !== undefined)
|
|
72
|
+
src.searchParams.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
for (const key of STRING_PARAM_KEYS) {
|
|
75
|
+
const value = options.strings?.[key];
|
|
76
|
+
if (value !== undefined)
|
|
77
|
+
src.searchParams.set(`str_${key}`, value);
|
|
78
|
+
}
|
|
79
|
+
src.hash = `token=${encodeURIComponent(sessionToken)}`;
|
|
80
|
+
return { src: src.toString(), embedOrigin };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Mount a Pulse Collect component into the partner's page. The component
|
|
84
|
+
* renders inside a Pulse-controlled iframe: the session token, order data and
|
|
85
|
+
* payment surface never enter the partner DOM. The partner owns everything
|
|
86
|
+
* around it — layout, chrome, branding (theme + strings).
|
|
87
|
+
*/
|
|
88
|
+
export function mount(target, options) {
|
|
89
|
+
const { src, embedOrigin } = buildEmbedSrc(options);
|
|
90
|
+
const iframe = document.createElement('iframe');
|
|
91
|
+
iframe.src = src;
|
|
92
|
+
iframe.title = 'Payment';
|
|
93
|
+
iframe.allow = 'clipboard-write';
|
|
94
|
+
// Least privilege: scripts + own-origin fetch; no top-navigation, no downloads.
|
|
95
|
+
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
|
|
96
|
+
iframe.style.width = '100%';
|
|
97
|
+
iframe.style.border = '0';
|
|
98
|
+
iframe.style.display = 'block';
|
|
99
|
+
iframe.style.height = `${INITIAL_HEIGHT_PX}px`;
|
|
100
|
+
const onMessage = (event) => {
|
|
101
|
+
if (event.origin !== embedOrigin)
|
|
102
|
+
return;
|
|
103
|
+
if (event.source !== iframe.contentWindow)
|
|
104
|
+
return;
|
|
105
|
+
if (!isEmbedToHostMessage(event.data))
|
|
106
|
+
return;
|
|
107
|
+
handleMessage(event.data);
|
|
108
|
+
};
|
|
109
|
+
// A dead embed (network failure, bad deploy) must surface, not hang silently.
|
|
110
|
+
const readyTimer = window.setTimeout(() => {
|
|
111
|
+
options.onError?.('embed_load_timeout', `The payment component did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms`);
|
|
112
|
+
}, options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
113
|
+
const handleMessage = (message) => {
|
|
114
|
+
switch (message.type) {
|
|
115
|
+
case 'ready': {
|
|
116
|
+
window.clearTimeout(readyTimer);
|
|
117
|
+
if (options.theme !== undefined || options.strings !== undefined) {
|
|
118
|
+
const configure = { channel: CHANNEL, type: 'configure' };
|
|
119
|
+
if (options.theme !== undefined)
|
|
120
|
+
configure.theme = options.theme;
|
|
121
|
+
if (options.strings !== undefined)
|
|
122
|
+
configure.strings = options.strings;
|
|
123
|
+
iframe.contentWindow?.postMessage(configure, embedOrigin);
|
|
124
|
+
}
|
|
125
|
+
options.onReady?.();
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'resize':
|
|
129
|
+
iframe.style.height = `${Math.max(message.height, 0)}px`;
|
|
130
|
+
break;
|
|
131
|
+
case 'status_change':
|
|
132
|
+
options.onStatusChange?.(message.status, message.orderId);
|
|
133
|
+
break;
|
|
134
|
+
case 'quote_expired':
|
|
135
|
+
options.onQuoteExpired?.();
|
|
136
|
+
break;
|
|
137
|
+
case 'quote_refreshed':
|
|
138
|
+
options.onQuoteRefreshed?.(message.lockedUntil);
|
|
139
|
+
break;
|
|
140
|
+
case 'success':
|
|
141
|
+
options.onSuccess?.(message.orderId);
|
|
142
|
+
break;
|
|
143
|
+
case 'error':
|
|
144
|
+
options.onError?.(message.code, message.message);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
window.addEventListener('message', onMessage);
|
|
149
|
+
target.appendChild(iframe);
|
|
150
|
+
return {
|
|
151
|
+
iframe,
|
|
152
|
+
unmount: () => {
|
|
153
|
+
window.clearTimeout(readyTimer);
|
|
154
|
+
window.removeEventListener('message', onMessage);
|
|
155
|
+
iframe.remove();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export const PulseCollect = { mount };
|
|
160
|
+
export default PulseCollect;
|
|
161
|
+
export { CHANNEL, isEmbedToHostMessage, } from './protocol.js';
|
|
162
|
+
export { formatAsset, formatFiat } from './format.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postMessage protocol between the partner page (host) and the Pulse-controlled
|
|
3
|
+
* iframe (embed). Every message carries channel: "pulse-collect" so the bridge
|
|
4
|
+
* can ignore unrelated traffic on the same window.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CHANNEL: "pulse-collect";
|
|
7
|
+
export type EmbedStatus = 'loading' | 'awaiting_payment' | 'deposit_detected' | 'deposit_confirmed' | 'converting' | 'completed' | 'underpaid' | 'overpaid' | 'wrong_chain' | 'expired' | 'failed';
|
|
8
|
+
export type EmbedComponent = 'payment' | 'quote' | 'status';
|
|
9
|
+
export type EmbedTheme = {
|
|
10
|
+
/** CSS color for buttons/accents — the partner's brand color. */
|
|
11
|
+
primaryColor?: string;
|
|
12
|
+
/** CSS length for control rounding, e.g. "8px". */
|
|
13
|
+
borderRadius?: string;
|
|
14
|
+
mode?: 'light' | 'dark';
|
|
15
|
+
/** CSS font-family stack applied to the whole component. */
|
|
16
|
+
fontFamily?: string;
|
|
17
|
+
/** Primary text color. */
|
|
18
|
+
inkColor?: string;
|
|
19
|
+
/** Secondary text color. */
|
|
20
|
+
mutedColor?: string;
|
|
21
|
+
/** Panel background color. */
|
|
22
|
+
backgroundColor?: string;
|
|
23
|
+
/** Text color on primary buttons. */
|
|
24
|
+
buttonTextColor?: string;
|
|
25
|
+
/** Borders, dividers, and inactive step markers. */
|
|
26
|
+
lineColor?: string;
|
|
27
|
+
/** Success-state text (e.g. "Payment complete"). */
|
|
28
|
+
successColor?: string;
|
|
29
|
+
/** Failure/urgency accents (failed title, final-minute countdown). */
|
|
30
|
+
dangerColor?: string;
|
|
31
|
+
/** Tier B page-shell backdrop. */
|
|
32
|
+
pageBackgroundColor?: string;
|
|
33
|
+
/** "compact" (default for now) tightens padding and shrinks the QR; "comfortable" widens. */
|
|
34
|
+
density?: 'comfortable' | 'compact';
|
|
35
|
+
/**
|
|
36
|
+
* Greyed-out "Powered by Shiga Digital" at the foot of the component.
|
|
37
|
+
* Defaults to "subtle"; set "none" to remove (R3.4 — never mandatory).
|
|
38
|
+
*/
|
|
39
|
+
attribution?: 'subtle' | 'none';
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Copy overrides for the money-moment states. Values may use placeholders:
|
|
43
|
+
* {target} — formatted fiat target ("$6,000.00"), {assetAmount}, {asset},
|
|
44
|
+
* {network}, {settlementRef}.
|
|
45
|
+
*/
|
|
46
|
+
export type EmbedStrings = {
|
|
47
|
+
title?: string;
|
|
48
|
+
subtitle?: string;
|
|
49
|
+
/** Uppercase label above the fiat hero amount ("You're paying"). */
|
|
50
|
+
heroLabel?: string;
|
|
51
|
+
successTitle?: string;
|
|
52
|
+
successSubtitle?: string;
|
|
53
|
+
expiredTitle?: string;
|
|
54
|
+
expiredSubtitle?: string;
|
|
55
|
+
refreshButton?: string;
|
|
56
|
+
copyButton?: string;
|
|
57
|
+
copiedButton?: string;
|
|
58
|
+
};
|
|
59
|
+
export type EmbedToHostMessage = {
|
|
60
|
+
channel: typeof CHANNEL;
|
|
61
|
+
type: 'ready';
|
|
62
|
+
} | {
|
|
63
|
+
channel: typeof CHANNEL;
|
|
64
|
+
type: 'resize';
|
|
65
|
+
height: number;
|
|
66
|
+
} | {
|
|
67
|
+
channel: typeof CHANNEL;
|
|
68
|
+
type: 'status_change';
|
|
69
|
+
status: EmbedStatus;
|
|
70
|
+
orderId: string;
|
|
71
|
+
} | {
|
|
72
|
+
channel: typeof CHANNEL;
|
|
73
|
+
type: 'quote_expired';
|
|
74
|
+
} | {
|
|
75
|
+
channel: typeof CHANNEL;
|
|
76
|
+
type: 'quote_refreshed';
|
|
77
|
+
lockedUntil: string;
|
|
78
|
+
} | {
|
|
79
|
+
channel: typeof CHANNEL;
|
|
80
|
+
type: 'success';
|
|
81
|
+
orderId: string;
|
|
82
|
+
} | {
|
|
83
|
+
channel: typeof CHANNEL;
|
|
84
|
+
type: 'error';
|
|
85
|
+
code: string;
|
|
86
|
+
message: string;
|
|
87
|
+
};
|
|
88
|
+
export type HostToEmbedMessage = {
|
|
89
|
+
channel: typeof CHANNEL;
|
|
90
|
+
type: 'configure';
|
|
91
|
+
theme?: EmbedTheme;
|
|
92
|
+
strings?: EmbedStrings;
|
|
93
|
+
};
|
|
94
|
+
export declare function isEmbedToHostMessage(data: unknown): data is EmbedToHostMessage;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postMessage protocol between the partner page (host) and the Pulse-controlled
|
|
3
|
+
* iframe (embed). Every message carries channel: "pulse-collect" so the bridge
|
|
4
|
+
* can ignore unrelated traffic on the same window.
|
|
5
|
+
*/
|
|
6
|
+
export const CHANNEL = 'pulse-collect';
|
|
7
|
+
export function isEmbedToHostMessage(data) {
|
|
8
|
+
return (typeof data === 'object' &&
|
|
9
|
+
data !== null &&
|
|
10
|
+
'channel' in data &&
|
|
11
|
+
data.channel === CHANNEL &&
|
|
12
|
+
'type' in data);
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pulsebyshiga/collect-js",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Tier A embed loader — mounts Pulse Collect payment components into a partner page via a sandboxed iframe and a typed postMessage bridge",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Shiga Digital",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Pulse-Digital/pulse-collect.git",
|
|
10
|
+
"directory": "packages/collect-js"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"esbuild": "^0.24.2",
|
|
27
|
+
"jsdom": "^25.0.0",
|
|
28
|
+
"typescript": "^5.6.3",
|
|
29
|
+
"vitest": "^3.0.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json && pnpm run build:iife",
|
|
33
|
+
"build:iife": "esbuild src/index.ts --bundle --format=iife --global-name=PulseCollect --outfile=dist/collect.iife.js --minify --target=es2019",
|
|
34
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
}
|
|
37
|
+
}
|