@synfin/widget 0.1.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 +145 -0
- package/dist/api.d.ts +23 -0
- package/dist/api.js +66 -0
- package/dist/assets.d.ts +25 -0
- package/dist/assets.js +64 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +80 -0
- package/dist/element.d.ts +65 -0
- package/dist/element.js +542 -0
- package/dist/events.d.ts +16 -0
- package/dist/events.js +17 -0
- package/dist/format.d.ts +36 -0
- package/dist/format.js +57 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +21 -0
- package/dist/react.d.ts +25 -0
- package/dist/react.js +60 -0
- package/dist/render.d.ts +18 -0
- package/dist/render.js +163 -0
- package/dist/state.d.ts +60 -0
- package/dist/state.js +55 -0
- package/dist/styles.d.ts +9 -0
- package/dist/styles.js +226 -0
- package/dist/synfin-widget.global.js +290 -0
- package/package.json +81 -0
package/dist/element.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <synfin-widget>: the custom element. Owns the persistent chrome (pair + amount
|
|
3
|
+
* fields, the asset selector popover, the refresh countdown) and drives the
|
|
4
|
+
* quote lifecycle through @synfin/client, swapping only the result region per
|
|
5
|
+
* quote. Non-custodial: it holds no keys and signs nothing; creating a plan
|
|
6
|
+
* emits synfin:plan for the host to execute. Shadow DOM isolates styles so it is
|
|
7
|
+
* robust inside any host page.
|
|
8
|
+
*/
|
|
9
|
+
import { createClient, SynfinApiError, } from '@synfin/client';
|
|
10
|
+
import { requestQuote } from './api.js';
|
|
11
|
+
import { ASSETS, assetIcon, findAsset, searchAssets, } from './assets.js';
|
|
12
|
+
import { formatAmount } from './format.js';
|
|
13
|
+
import { normalizeConfig, } from './config.js';
|
|
14
|
+
import { EVENTS, emit } from './events.js';
|
|
15
|
+
import { resultHtml, selectedRow } from './render.js';
|
|
16
|
+
import { STYLES } from './styles.js';
|
|
17
|
+
import { displayQuote, viewFromQuote } from './state.js';
|
|
18
|
+
const ATTRS = [
|
|
19
|
+
'api-key',
|
|
20
|
+
'fee-bps',
|
|
21
|
+
'fee-recipient',
|
|
22
|
+
'from',
|
|
23
|
+
'to',
|
|
24
|
+
'amount',
|
|
25
|
+
'appearance',
|
|
26
|
+
'base-url',
|
|
27
|
+
'refresh-ms',
|
|
28
|
+
'show-fee-breakdown',
|
|
29
|
+
'show-attribution',
|
|
30
|
+
];
|
|
31
|
+
const DEBOUNCE_MS = 400;
|
|
32
|
+
export class SynfinWidgetElement extends HTMLElement {
|
|
33
|
+
static get observedAttributes() {
|
|
34
|
+
return ATTRS;
|
|
35
|
+
}
|
|
36
|
+
/** Injectable fetch (tests and advanced hosts); defaults to global fetch. */
|
|
37
|
+
fetchImpl;
|
|
38
|
+
cfg = normalizeConfig({}).config;
|
|
39
|
+
client = null;
|
|
40
|
+
view = { kind: 'idle' };
|
|
41
|
+
debounce = 0;
|
|
42
|
+
ticker = 0;
|
|
43
|
+
countdown = 0;
|
|
44
|
+
reqSeq = 0;
|
|
45
|
+
selectorOpen = null;
|
|
46
|
+
built = false;
|
|
47
|
+
syncing = false;
|
|
48
|
+
/** The venue the user has chosen (defaults to best); persists across refreshes
|
|
49
|
+
* while it stays available. */
|
|
50
|
+
selectedVenue = null;
|
|
51
|
+
/** Transient Create-a-plan feedback, cleared on a fresh quote. */
|
|
52
|
+
plan = null;
|
|
53
|
+
connectedCallback() {
|
|
54
|
+
this.build();
|
|
55
|
+
this.reconfigure();
|
|
56
|
+
}
|
|
57
|
+
disconnectedCallback() {
|
|
58
|
+
clearTimeout(this.debounce);
|
|
59
|
+
clearInterval(this.ticker);
|
|
60
|
+
}
|
|
61
|
+
attributeChangedCallback() {
|
|
62
|
+
if (this.built && !this.syncing)
|
|
63
|
+
this.reconfigure();
|
|
64
|
+
}
|
|
65
|
+
/** Set config programmatically (React wrapper / advanced hosts). */
|
|
66
|
+
configure(partial) {
|
|
67
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
68
|
+
const attr = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
|
69
|
+
if (v === undefined || v === null)
|
|
70
|
+
this.removeAttribute(attr);
|
|
71
|
+
else
|
|
72
|
+
this.setAttribute(attr, String(v));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ---- config ----------------------------------------------------------------
|
|
76
|
+
reconfigure() {
|
|
77
|
+
const a = (n) => this.getAttribute(n);
|
|
78
|
+
const raw = {
|
|
79
|
+
...(a('api-key') !== null ? { apiKey: a('api-key') } : {}),
|
|
80
|
+
...(a('fee-bps') !== null ? { feeBps: Number(a('fee-bps')) } : {}),
|
|
81
|
+
...(a('fee-recipient') !== null
|
|
82
|
+
? { feeRecipient: a('fee-recipient') }
|
|
83
|
+
: {}),
|
|
84
|
+
...(a('from') !== null ? { from: a('from') } : {}),
|
|
85
|
+
...(a('to') !== null ? { to: a('to') } : {}),
|
|
86
|
+
...(a('amount') !== null ? { amount: a('amount') } : {}),
|
|
87
|
+
...(a('appearance') === 'light' ? { appearance: 'light' } : {}),
|
|
88
|
+
...(a('base-url') !== null ? { baseUrl: a('base-url') } : {}),
|
|
89
|
+
...(a('refresh-ms') !== null
|
|
90
|
+
? { refreshMs: Number(a('refresh-ms')) }
|
|
91
|
+
: {}),
|
|
92
|
+
// Boolean attribute: present (any value other than "false") turns on the
|
|
93
|
+
// partner-facing fee detail; absent leaves the clean end-user default.
|
|
94
|
+
...(a('show-fee-breakdown') !== null
|
|
95
|
+
? { showFeeBreakdown: a('show-fee-breakdown') !== 'false' }
|
|
96
|
+
: {}),
|
|
97
|
+
// "Powered by Synfin" attribution is ON by default; a white-label partner
|
|
98
|
+
// sets show-attribution="false" to remove it.
|
|
99
|
+
...(a('show-attribution') !== null
|
|
100
|
+
? { showAttribution: a('show-attribution') !== 'false' }
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
const { config } = normalizeConfig(raw);
|
|
104
|
+
this.cfg = config;
|
|
105
|
+
if (this.getAttribute('appearance') !== config.appearance) {
|
|
106
|
+
this.syncing = true; // reflect without re-entering reconfigure
|
|
107
|
+
this.setAttribute('appearance', config.appearance);
|
|
108
|
+
this.syncing = false;
|
|
109
|
+
}
|
|
110
|
+
// Planning needs a key, so the client is created only when one is present.
|
|
111
|
+
// Quotes go through requestQuote, which sends no auth header when keyless.
|
|
112
|
+
this.client =
|
|
113
|
+
config.apiKey !== null
|
|
114
|
+
? createClient({
|
|
115
|
+
apiKey: config.apiKey,
|
|
116
|
+
baseUrl: config.baseUrl,
|
|
117
|
+
...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
|
|
118
|
+
})
|
|
119
|
+
: null;
|
|
120
|
+
this.paintChrome();
|
|
121
|
+
void this.quote(true);
|
|
122
|
+
}
|
|
123
|
+
// ---- shell -----------------------------------------------------------------
|
|
124
|
+
root() {
|
|
125
|
+
return this.shadowRoot;
|
|
126
|
+
}
|
|
127
|
+
$(sel) {
|
|
128
|
+
return this.root().querySelector(sel);
|
|
129
|
+
}
|
|
130
|
+
build() {
|
|
131
|
+
if (this.built)
|
|
132
|
+
return;
|
|
133
|
+
const sh = this.attachShadow({ mode: 'open' });
|
|
134
|
+
const style = document.createElement('style');
|
|
135
|
+
style.textContent = STYLES;
|
|
136
|
+
const card = document.createElement('div');
|
|
137
|
+
card.className = 'card';
|
|
138
|
+
card.innerHTML = `
|
|
139
|
+
<div class="head">
|
|
140
|
+
<span class="refresh">
|
|
141
|
+
<svg class="refresh__ring" viewBox="0 0 16 16" aria-hidden="true"><circle class="bg" cx="8" cy="8" r="6"></circle><circle class="fg" cx="8" cy="8" r="6" pathLength="100" stroke-dasharray="100" stroke-dashoffset="0"></circle></svg>
|
|
142
|
+
<button type="button" data-refresh aria-label="Refresh quote">refresh</button>
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="field" data-field="from">
|
|
146
|
+
<span class="field__label">You pay</span>
|
|
147
|
+
<div class="field__row">
|
|
148
|
+
<input class="amount" data-amount type="text" inputmode="decimal" autocomplete="off" placeholder="0.00" aria-label="Amount to pay" />
|
|
149
|
+
<button class="asset" data-asset="from" aria-haspopup="listbox" aria-label="Select the asset you pay"></button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="swapwrap"><button class="swap" data-swap type="button" aria-label="Swap the pair">⇅</button></div>
|
|
153
|
+
<div class="field" data-field="to">
|
|
154
|
+
<span class="field__label">You receive</span>
|
|
155
|
+
<div class="field__row">
|
|
156
|
+
<span class="receive receive--dim" data-receive>-</span>
|
|
157
|
+
<button class="asset" data-asset="to" aria-haspopup="listbox" aria-label="Select the asset you receive"></button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div class="result" data-result aria-live="polite"></div>
|
|
161
|
+
`;
|
|
162
|
+
sh.append(style, card);
|
|
163
|
+
this.built = true;
|
|
164
|
+
this.$('[data-amount]')?.addEventListener('input', () => this.onAmount());
|
|
165
|
+
this.$('[data-swap]')?.addEventListener('click', () => this.swap());
|
|
166
|
+
this.$('[data-refresh]')?.addEventListener('click', () => this.quote(true));
|
|
167
|
+
this.$('[data-asset="from"]')?.addEventListener('click', () => this.openSelector('from'));
|
|
168
|
+
this.$('[data-asset="to"]')?.addEventListener('click', () => this.openSelector('to'));
|
|
169
|
+
this.root().addEventListener('click', (e) => {
|
|
170
|
+
const t = e.target;
|
|
171
|
+
if (t.closest('[data-action="plan"]')) {
|
|
172
|
+
void this.createPlan();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const venue = t.closest('[data-venue]');
|
|
176
|
+
if (venue)
|
|
177
|
+
this.selectVenue(venue.getAttribute('data-venue') ?? '');
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
assetBtn(side, a) {
|
|
181
|
+
const btn = this.$(`[data-asset="${side}"]`);
|
|
182
|
+
if (btn)
|
|
183
|
+
btn.innerHTML = `${assetIcon(a.symbol)}<span class="asset__sym">${a.symbol}</span><span class="asset__caret" aria-hidden="true">▾</span>`;
|
|
184
|
+
}
|
|
185
|
+
paintChrome() {
|
|
186
|
+
const from = findAsset(this.cfg.from) ?? ASSETS[0];
|
|
187
|
+
const to = findAsset(this.cfg.to) ?? ASSETS[1];
|
|
188
|
+
this.assetBtn('from', from);
|
|
189
|
+
this.assetBtn('to', to);
|
|
190
|
+
const amt = this.$('[data-amount]');
|
|
191
|
+
if (amt && amt.value !== this.cfg.amount && document.activeElement !== amt)
|
|
192
|
+
amt.value = this.cfg.amount;
|
|
193
|
+
}
|
|
194
|
+
// ---- quoting ---------------------------------------------------------------
|
|
195
|
+
onAmount() {
|
|
196
|
+
const amt = this.$('[data-amount]');
|
|
197
|
+
const val = amt ? amt.value.trim() : '';
|
|
198
|
+
this.cfg = { ...this.cfg, amount: val };
|
|
199
|
+
clearTimeout(this.debounce);
|
|
200
|
+
// An empty field returns to neutral idle immediately, so no error banner or
|
|
201
|
+
// styling ever lingers from a previous invalid entry.
|
|
202
|
+
if (val === '') {
|
|
203
|
+
this.setView({ kind: 'idle' });
|
|
204
|
+
this.stopCountdown();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.debounce = window.setTimeout(() => void this.quote(true), DEBOUNCE_MS);
|
|
208
|
+
}
|
|
209
|
+
/** Change the pair from an INTERNAL action (swap/select): update live state
|
|
210
|
+
* and re-quote directly, never through observed attributes (which would
|
|
211
|
+
* reset the typed amount and re-enter reconfigure). */
|
|
212
|
+
setPair(from, to) {
|
|
213
|
+
this.cfg = { ...this.cfg, from, to };
|
|
214
|
+
this.paintChrome();
|
|
215
|
+
emit(this, EVENTS.asset, { from, to });
|
|
216
|
+
void this.quote(true);
|
|
217
|
+
}
|
|
218
|
+
swap() {
|
|
219
|
+
this.setPair(this.cfg.to, this.cfg.from);
|
|
220
|
+
}
|
|
221
|
+
setView(v) {
|
|
222
|
+
this.view = v;
|
|
223
|
+
this.paintResult();
|
|
224
|
+
}
|
|
225
|
+
/** Re-render the result region from the current view, selected venue, and plan
|
|
226
|
+
* feedback. Called on a new quote and on any selection / plan change. */
|
|
227
|
+
paintResult() {
|
|
228
|
+
const region = this.$('[data-result]');
|
|
229
|
+
if (region)
|
|
230
|
+
region.innerHTML = resultHtml(this.view, this.cfg, this.selectedVenue, this.plan);
|
|
231
|
+
this.paintReceive();
|
|
232
|
+
// Roving-tabindex arrow-key navigation for the venue radiogroup.
|
|
233
|
+
this.$('.venues[role="radiogroup"]')?.addEventListener('keydown', (e) => this.venueKeys(e));
|
|
234
|
+
}
|
|
235
|
+
/** The "You receive" chrome follows the SELECTED venue, same formatting as the
|
|
236
|
+
* summary line so the two never disagree. */
|
|
237
|
+
paintReceive() {
|
|
238
|
+
const recv = this.$('[data-receive]');
|
|
239
|
+
if (!recv)
|
|
240
|
+
return;
|
|
241
|
+
const sel = this.view.kind === 'quoted'
|
|
242
|
+
? selectedRow(this.view, this.selectedVenue)
|
|
243
|
+
: null;
|
|
244
|
+
if (sel) {
|
|
245
|
+
const got = sel.fees ? sel.fees.userReceives : sel.net;
|
|
246
|
+
recv.textContent = got !== null ? formatAmount(got) : '-';
|
|
247
|
+
recv.classList.remove('receive--dim');
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
recv.textContent = this.view.kind === 'loading' ? '...' : '-';
|
|
251
|
+
recv.classList.add('receive--dim');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Select a venue (click or keyboard). Repaints so the receive figure, fees,
|
|
255
|
+
* and plan all follow the choice, and emits synfin:venue for the host. */
|
|
256
|
+
selectVenue(id) {
|
|
257
|
+
if (this.view.kind !== 'quoted' || id === '')
|
|
258
|
+
return;
|
|
259
|
+
const row = this.view.venues.find((r) => r.venueId === id && r.available);
|
|
260
|
+
if (!row || this.selectedVenue === id)
|
|
261
|
+
return;
|
|
262
|
+
this.selectedVenue = id;
|
|
263
|
+
this.plan = null; // a shown plan was for the previously selected venue
|
|
264
|
+
this.paintResult();
|
|
265
|
+
this.$(`[data-venue="${id}"]`)?.focus();
|
|
266
|
+
emit(this, EVENTS.venue, {
|
|
267
|
+
venueId: id,
|
|
268
|
+
net: row.net,
|
|
269
|
+
isBest: row.isBest,
|
|
270
|
+
pair: this.view.pair,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
venueKeys(e) {
|
|
274
|
+
if (this.view.kind !== 'quoted')
|
|
275
|
+
return;
|
|
276
|
+
const avail = this.view.venues
|
|
277
|
+
.filter((r) => r.available)
|
|
278
|
+
.map((r) => r.venueId);
|
|
279
|
+
if (avail.length === 0)
|
|
280
|
+
return;
|
|
281
|
+
// Space/Enter select the FOCUSED radio (WAI-ARIA radiogroup), which may be a
|
|
282
|
+
// different, still-unchecked option than the currently-selected one.
|
|
283
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
284
|
+
const focused = e.target?.closest('[data-venue]');
|
|
285
|
+
const id = focused?.getAttribute('data-venue');
|
|
286
|
+
if (id !== null && id !== undefined && avail.includes(id)) {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
this.selectVenue(id);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Arrow keys move selection along with focus (selection-follows-focus).
|
|
293
|
+
const cur = this.selectedVenue !== null && avail.includes(this.selectedVenue)
|
|
294
|
+
? avail.indexOf(this.selectedVenue)
|
|
295
|
+
: 0;
|
|
296
|
+
let next = cur;
|
|
297
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight')
|
|
298
|
+
next = Math.min(avail.length - 1, cur + 1);
|
|
299
|
+
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft')
|
|
300
|
+
next = Math.max(0, cur - 1);
|
|
301
|
+
else
|
|
302
|
+
return;
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
const id = avail[next];
|
|
305
|
+
if (id)
|
|
306
|
+
this.selectVenue(id);
|
|
307
|
+
}
|
|
308
|
+
async quote(showLoading) {
|
|
309
|
+
const amt = this.cfg.amount.trim();
|
|
310
|
+
// Empty is a NEUTRAL idle state: no message, no error styling (distinct from
|
|
311
|
+
// an invalid amount, which shows a banner below).
|
|
312
|
+
if (amt === '') {
|
|
313
|
+
this.setView({ kind: 'idle' });
|
|
314
|
+
this.stopCountdown();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// A same-asset pair is a clear on-screen message, not a bare blank.
|
|
318
|
+
if (this.cfg.from === this.cfg.to) {
|
|
319
|
+
this.setView({
|
|
320
|
+
kind: 'invalid',
|
|
321
|
+
title: 'Choose two different assets',
|
|
322
|
+
message: 'Pick a different asset to receive.',
|
|
323
|
+
});
|
|
324
|
+
this.stopCountdown();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// A non-empty but invalid amount (letters, 1.2.3, negatives, zero) is
|
|
328
|
+
// surfaced honestly, never left as a blank that looks like loading.
|
|
329
|
+
if (!/^\d+(\.\d+)?$/.test(amt) || Number(amt) <= 0) {
|
|
330
|
+
this.setView({
|
|
331
|
+
kind: 'invalid',
|
|
332
|
+
title: 'Enter a valid amount',
|
|
333
|
+
message: 'Type a positive number, for example 100 or 12.5.',
|
|
334
|
+
});
|
|
335
|
+
this.stopCountdown();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const seq = ++this.reqSeq;
|
|
339
|
+
if (showLoading && this.view.kind !== 'quoted') {
|
|
340
|
+
this.setView({ kind: 'loading' });
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const quote = await requestQuote({
|
|
344
|
+
baseUrl: this.cfg.baseUrl,
|
|
345
|
+
apiKey: this.cfg.apiKey,
|
|
346
|
+
...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
|
|
347
|
+
}, {
|
|
348
|
+
from: this.cfg.from,
|
|
349
|
+
to: this.cfg.to,
|
|
350
|
+
amount: this.cfg.amount,
|
|
351
|
+
...(this.cfg.feeBps !== null ? { feeBps: this.cfg.feeBps } : {}),
|
|
352
|
+
...(this.cfg.feeRecipient !== null
|
|
353
|
+
? { feeRecipient: this.cfg.feeRecipient }
|
|
354
|
+
: {}),
|
|
355
|
+
});
|
|
356
|
+
if (seq !== this.reqSeq)
|
|
357
|
+
return; // a newer request superseded this one
|
|
358
|
+
const view = viewFromQuote(quote);
|
|
359
|
+
if (view.kind === 'quoted') {
|
|
360
|
+
// Keep the user's chosen venue if it is still available; otherwise
|
|
361
|
+
// default the selection to the best. A fresh quote clears plan feedback.
|
|
362
|
+
const avail = view.venues
|
|
363
|
+
.filter((r) => r.available)
|
|
364
|
+
.map((r) => r.venueId);
|
|
365
|
+
if (this.selectedVenue === null || !avail.includes(this.selectedVenue))
|
|
366
|
+
this.selectedVenue = view.best?.venueId ?? avail[0] ?? null;
|
|
367
|
+
this.plan = null;
|
|
368
|
+
}
|
|
369
|
+
this.setView(view);
|
|
370
|
+
if (view.kind === 'quoted') {
|
|
371
|
+
// Emit the display-mapped quote so the integrator sees the ticker (CC),
|
|
372
|
+
// never the internal canonical name (Amulet).
|
|
373
|
+
emit(this, EVENTS.quote, displayQuote(quote));
|
|
374
|
+
this.startCountdown();
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
// No usable route: emit a clean synfin:error, never a success-shaped
|
|
378
|
+
// synfin:quote carrying undefined best/net fields.
|
|
379
|
+
emit(this, EVENTS.error, {
|
|
380
|
+
code: 'no_route',
|
|
381
|
+
message: 'No venue can fill this pair at this size.',
|
|
382
|
+
});
|
|
383
|
+
this.stopCountdown();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
if (seq !== this.reqSeq)
|
|
388
|
+
return;
|
|
389
|
+
const code = err instanceof SynfinApiError ? err.code : 'network';
|
|
390
|
+
const message = err instanceof Error ? err.message : 'could not reach the Synfin API';
|
|
391
|
+
this.setView({ kind: 'error', code, message });
|
|
392
|
+
emit(this, EVENTS.error, { code, message });
|
|
393
|
+
this.stopCountdown();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
startCountdown() {
|
|
397
|
+
this.stopCountdown();
|
|
398
|
+
const total = Math.round(this.cfg.refreshMs / 1000);
|
|
399
|
+
this.countdown = total;
|
|
400
|
+
const paint = () => {
|
|
401
|
+
const fg = this.root().querySelector('.refresh__ring .fg');
|
|
402
|
+
if (fg)
|
|
403
|
+
fg.style.strokeDashoffset = String(100 - (this.countdown / total) * 100);
|
|
404
|
+
};
|
|
405
|
+
paint();
|
|
406
|
+
this.ticker = window.setInterval(() => {
|
|
407
|
+
this.countdown -= 1;
|
|
408
|
+
paint();
|
|
409
|
+
if (this.countdown <= 0)
|
|
410
|
+
void this.quote(false);
|
|
411
|
+
}, 1000);
|
|
412
|
+
}
|
|
413
|
+
stopCountdown() {
|
|
414
|
+
clearInterval(this.ticker);
|
|
415
|
+
}
|
|
416
|
+
async createPlan() {
|
|
417
|
+
if (this.client === null ||
|
|
418
|
+
this.view.kind !== 'quoted' ||
|
|
419
|
+
this.cfg.feeRecipient === null)
|
|
420
|
+
return;
|
|
421
|
+
const sel = selectedRow(this.view, this.selectedVenue);
|
|
422
|
+
if (sel === null || !sel.available)
|
|
423
|
+
return;
|
|
424
|
+
// Visible feedback (not a silent success), and pause auto-refresh so the
|
|
425
|
+
// confirmation is not overwritten by the next quote.
|
|
426
|
+
this.stopCountdown();
|
|
427
|
+
this.plan = { status: 'creating' };
|
|
428
|
+
this.paintResult();
|
|
429
|
+
try {
|
|
430
|
+
const plan = await this.client.createPlan({
|
|
431
|
+
from: this.cfg.from,
|
|
432
|
+
to: this.cfg.to,
|
|
433
|
+
amount: this.cfg.amount,
|
|
434
|
+
venueId: sel.venueId, // the venue the USER selected, not always best
|
|
435
|
+
takerParty: this.cfg.feeRecipient,
|
|
436
|
+
idempotencyKey: `w_${Date.now()}_${Math.round(Number(this.cfg.amount) * 1000)}`,
|
|
437
|
+
...(this.cfg.feeBps !== null ? { feeBps: this.cfg.feeBps } : {}),
|
|
438
|
+
feeRecipient: this.cfg.feeRecipient,
|
|
439
|
+
});
|
|
440
|
+
const planId = plan.planId;
|
|
441
|
+
this.plan = { status: 'created', ...(planId ? { planId } : {}) };
|
|
442
|
+
this.paintResult();
|
|
443
|
+
emit(this, EVENTS.plan, plan);
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
const code = err instanceof SynfinApiError ? err.code : 'network';
|
|
447
|
+
const message = err instanceof Error ? err.message : 'plan failed';
|
|
448
|
+
this.plan = { status: 'error', message };
|
|
449
|
+
this.paintResult();
|
|
450
|
+
emit(this, EVENTS.error, { code, message });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// ---- asset selector --------------------------------------------------------
|
|
454
|
+
openSelector(side) {
|
|
455
|
+
if (this.selectorOpen)
|
|
456
|
+
this.closeSelector();
|
|
457
|
+
this.selectorOpen = side;
|
|
458
|
+
const pop = document.createElement('div');
|
|
459
|
+
pop.className = 'pop';
|
|
460
|
+
pop.setAttribute('role', 'dialog');
|
|
461
|
+
pop.setAttribute('aria-modal', 'true');
|
|
462
|
+
pop.setAttribute('aria-label', `Select the ${side} asset`);
|
|
463
|
+
pop.innerHTML = `
|
|
464
|
+
<div class="pop__head">
|
|
465
|
+
<input class="pop__search" data-search placeholder="Search assets" aria-label="Search assets" role="combobox" aria-expanded="true" aria-controls="synfin-opts" />
|
|
466
|
+
<button class="pop__close" data-close aria-label="Close">✕</button>
|
|
467
|
+
</div>
|
|
468
|
+
<div class="pop__list" id="synfin-opts" role="listbox" data-list></div>`;
|
|
469
|
+
this.$('.card').append(pop);
|
|
470
|
+
const search = pop.querySelector('[data-search]');
|
|
471
|
+
const list = pop.querySelector('[data-list]');
|
|
472
|
+
const renderList = (q) => {
|
|
473
|
+
const other = side === 'from' ? this.cfg.to : this.cfg.from;
|
|
474
|
+
const items = searchAssets(q);
|
|
475
|
+
list.innerHTML =
|
|
476
|
+
items.length === 0
|
|
477
|
+
? `<div class="opt--empty">No assets match "${q.replace(/[<>&]/g, '')}"</div>`
|
|
478
|
+
: items
|
|
479
|
+
.map((a, i) => `<button class="opt" role="option" tabindex="-1" data-sym="${a.symbol}" ${a.symbol === other ? 'aria-disabled="true" disabled' : ''} ${i === 0 ? 'aria-selected="true"' : ''}>
|
|
480
|
+
${assetIcon(a.symbol)}
|
|
481
|
+
<span class="opt__name"><span class="opt__sym">${a.symbol}</span><span class="opt__full">${a.name}</span></span>
|
|
482
|
+
</button>`)
|
|
483
|
+
.join('');
|
|
484
|
+
};
|
|
485
|
+
renderList('');
|
|
486
|
+
const pick = (sym) => {
|
|
487
|
+
if (side === 'from' && sym === this.cfg.to)
|
|
488
|
+
return;
|
|
489
|
+
if (side === 'to' && sym === this.cfg.from)
|
|
490
|
+
return;
|
|
491
|
+
this.closeSelector();
|
|
492
|
+
if (side === 'from')
|
|
493
|
+
this.setPair(sym, this.cfg.to);
|
|
494
|
+
else
|
|
495
|
+
this.setPair(this.cfg.from, sym);
|
|
496
|
+
};
|
|
497
|
+
search.addEventListener('input', () => renderList(search.value));
|
|
498
|
+
list.addEventListener('click', (e) => {
|
|
499
|
+
const opt = e.target.closest('[data-sym]');
|
|
500
|
+
if (opt && !opt.hasAttribute('disabled'))
|
|
501
|
+
pick(opt.getAttribute('data-sym') ?? '');
|
|
502
|
+
});
|
|
503
|
+
pop
|
|
504
|
+
.querySelector('[data-close]')
|
|
505
|
+
?.addEventListener('click', () => this.closeSelector());
|
|
506
|
+
search.addEventListener('keydown', (e) => this.selectorKeys(e, list, pick, search));
|
|
507
|
+
setTimeout(() => search.focus(), 0);
|
|
508
|
+
}
|
|
509
|
+
selectorKeys(e, list, pick, search) {
|
|
510
|
+
const opts = [
|
|
511
|
+
...list.querySelectorAll('[data-sym]:not([disabled])'),
|
|
512
|
+
];
|
|
513
|
+
const cur = list.querySelector('[aria-selected="true"]');
|
|
514
|
+
let idx = cur ? opts.indexOf(cur) : -1;
|
|
515
|
+
if (e.key === 'Escape') {
|
|
516
|
+
e.preventDefault();
|
|
517
|
+
this.closeSelector();
|
|
518
|
+
}
|
|
519
|
+
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
520
|
+
e.preventDefault();
|
|
521
|
+
idx =
|
|
522
|
+
e.key === 'ArrowDown'
|
|
523
|
+
? Math.min(opts.length - 1, idx + 1)
|
|
524
|
+
: Math.max(0, idx - 1);
|
|
525
|
+
opts.forEach((o) => o.setAttribute('aria-selected', o === opts[idx] ? 'true' : 'false'));
|
|
526
|
+
opts[idx]?.scrollIntoView({ block: 'nearest' });
|
|
527
|
+
}
|
|
528
|
+
else if (e.key === 'Enter') {
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
const sel = (opts[idx] ?? opts[0])?.getAttribute('data-sym');
|
|
531
|
+
if (sel)
|
|
532
|
+
pick(sel);
|
|
533
|
+
}
|
|
534
|
+
void search;
|
|
535
|
+
}
|
|
536
|
+
closeSelector() {
|
|
537
|
+
this.selectorOpen = null;
|
|
538
|
+
this.root().querySelector('.pop')?.remove();
|
|
539
|
+
this.$(`[data-asset="${'from'}"]`)?.focus();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
//# sourceMappingURL=element.js.map
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM CustomEvents the widget emits, so ANY host (plain HTML, React, Vue,
|
|
3
|
+
* Svelte) reacts without a framework hook. composed:true lets them cross the
|
|
4
|
+
* Shadow DOM boundary.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EVENTS: {
|
|
7
|
+
readonly quote: "synfin:quote";
|
|
8
|
+
readonly asset: "synfin:asset";
|
|
9
|
+
readonly venue: "synfin:venue";
|
|
10
|
+
readonly plan: "synfin:plan";
|
|
11
|
+
readonly status: "synfin:status";
|
|
12
|
+
readonly error: "synfin:error";
|
|
13
|
+
};
|
|
14
|
+
export type SynfinEventName = (typeof EVENTS)[keyof typeof EVENTS];
|
|
15
|
+
export declare function emit(target: EventTarget, name: SynfinEventName, detail: unknown): void;
|
|
16
|
+
//# sourceMappingURL=events.d.ts.map
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM CustomEvents the widget emits, so ANY host (plain HTML, React, Vue,
|
|
3
|
+
* Svelte) reacts without a framework hook. composed:true lets them cross the
|
|
4
|
+
* Shadow DOM boundary.
|
|
5
|
+
*/
|
|
6
|
+
export const EVENTS = {
|
|
7
|
+
quote: 'synfin:quote',
|
|
8
|
+
asset: 'synfin:asset',
|
|
9
|
+
venue: 'synfin:venue',
|
|
10
|
+
plan: 'synfin:plan',
|
|
11
|
+
status: 'synfin:status',
|
|
12
|
+
error: 'synfin:error',
|
|
13
|
+
};
|
|
14
|
+
export function emit(target, name, detail) {
|
|
15
|
+
target.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=events.js.map
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display formatting and the fee breakdown. The fee lines are read STRAIGHT
|
|
3
|
+
* from the quote's clientFees (RFC-0007), never recomputed here: the widget
|
|
4
|
+
* shows exactly what the keyed quote disclosed.
|
|
5
|
+
*/
|
|
6
|
+
import type { ClientFees } from '@synfin/client';
|
|
7
|
+
/** Trim a decimal string to at most `dp` fraction digits for display. */
|
|
8
|
+
export declare function formatAmount(value: string | null, dp?: number): string;
|
|
9
|
+
/** Add two non-negative decimal strings exactly (no float error), for the fee
|
|
10
|
+
* total. Falls back to the first value if either is not a plain decimal. */
|
|
11
|
+
export declare function addAmounts(a: string, b: string): string;
|
|
12
|
+
export interface FeeLine {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly bps: number;
|
|
15
|
+
readonly amount: string;
|
|
16
|
+
}
|
|
17
|
+
/** The integrator (partner) fee, with its partner/Synfin split for the
|
|
18
|
+
* partner-facing detail mode only (never shown to the end user). */
|
|
19
|
+
export interface IntegratorLine extends FeeLine {
|
|
20
|
+
readonly partnerShare: string;
|
|
21
|
+
readonly synfinShare: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FeeBreakdown {
|
|
24
|
+
readonly service: FeeLine;
|
|
25
|
+
readonly integrator: IntegratorLine | null;
|
|
26
|
+
/** What the end user receives after ALL fees (verbatim from the quote). */
|
|
27
|
+
readonly userReceives: string;
|
|
28
|
+
/** The total fee the end user pays (service + integrator), exact. This is the
|
|
29
|
+
* ONLY fee figure the end-user default shows: no brand, no attribution. */
|
|
30
|
+
readonly total: string;
|
|
31
|
+
}
|
|
32
|
+
/** Derive the displayed breakdown from the quote's clientFees, verbatim. The
|
|
33
|
+
* labels here are PARTNER-facing (detail mode); the end-user default shows only
|
|
34
|
+
* the net receive and the neutral `total`, never these labels or the split. */
|
|
35
|
+
export declare function feeBreakdown(fees: ClientFees): FeeBreakdown;
|
|
36
|
+
//# sourceMappingURL=format.d.ts.map
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Trim a decimal string to at most `dp` fraction digits for display. */
|
|
2
|
+
export function formatAmount(value, dp = 6) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return '-';
|
|
5
|
+
if (!/^-?\d+(\.\d+)?$/.test(value))
|
|
6
|
+
return value;
|
|
7
|
+
const [int = '0', frac = ''] = value.split('.');
|
|
8
|
+
if (frac === '')
|
|
9
|
+
return int;
|
|
10
|
+
const kept = frac.slice(0, dp).replace(/0+$/, '');
|
|
11
|
+
return kept === '' ? int : `${int}.${kept}`;
|
|
12
|
+
}
|
|
13
|
+
/** Add two non-negative decimal strings exactly (no float error), for the fee
|
|
14
|
+
* total. Falls back to the first value if either is not a plain decimal. */
|
|
15
|
+
export function addAmounts(a, b) {
|
|
16
|
+
const dec = /^\d+(\.\d+)?$/;
|
|
17
|
+
if (!dec.test(a) || !dec.test(b))
|
|
18
|
+
return a;
|
|
19
|
+
const split = (s) => {
|
|
20
|
+
const [i = '0', f = ''] = s.split('.');
|
|
21
|
+
return [i, f];
|
|
22
|
+
};
|
|
23
|
+
const [ai, af] = split(a);
|
|
24
|
+
const [bi, bf] = split(b);
|
|
25
|
+
const dp = Math.max(af.length, bf.length);
|
|
26
|
+
const sum = BigInt(ai + af.padEnd(dp, '0')) + BigInt(bi + bf.padEnd(dp, '0'));
|
|
27
|
+
const s = sum.toString().padStart(dp + 1, '0');
|
|
28
|
+
const int = s.slice(0, s.length - dp);
|
|
29
|
+
const frac = dp > 0 ? s.slice(s.length - dp) : '';
|
|
30
|
+
return frac === '' ? int : `${int}.${frac}`;
|
|
31
|
+
}
|
|
32
|
+
/** Derive the displayed breakdown from the quote's clientFees, verbatim. The
|
|
33
|
+
* labels here are PARTNER-facing (detail mode); the end-user default shows only
|
|
34
|
+
* the net receive and the neutral `total`, never these labels or the split. */
|
|
35
|
+
export function feeBreakdown(fees) {
|
|
36
|
+
return {
|
|
37
|
+
service: {
|
|
38
|
+
label: 'Synfin service',
|
|
39
|
+
bps: fees.service.bps,
|
|
40
|
+
amount: fees.service.amount,
|
|
41
|
+
},
|
|
42
|
+
integrator: fees.integrator === null
|
|
43
|
+
? null
|
|
44
|
+
: {
|
|
45
|
+
label: 'Integrator fee',
|
|
46
|
+
bps: fees.integrator.bps,
|
|
47
|
+
amount: fees.integrator.amount,
|
|
48
|
+
partnerShare: fees.integrator.partnerShare,
|
|
49
|
+
synfinShare: fees.integrator.synfinShare,
|
|
50
|
+
},
|
|
51
|
+
userReceives: fees.userReceives,
|
|
52
|
+
total: fees.integrator === null
|
|
53
|
+
? fees.service.amount
|
|
54
|
+
: addAmounts(fees.service.amount, fees.integrator.amount),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=format.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { SynfinWidgetElement } from './element.js';
|
|
2
|
+
export { FEE_CAP_BPS, normalizeConfig, type ConfigIssue, type NormalizedConfig, type WidgetConfig, } from './config.js';
|
|
3
|
+
export { ASSETS, findAsset, searchAssets, type WidgetAsset } from './assets.js';
|
|
4
|
+
export { EVENTS, type SynfinEventName } from './events.js';
|
|
5
|
+
export { feeBreakdown, formatAmount, type FeeBreakdown, type FeeLine, } from './format.js';
|
|
6
|
+
export { viewFromQuote, type QuotedView, type VenueRow, type WidgetView, } from './state.js';
|
|
7
|
+
/** Register <synfin-widget> (idempotent, no-op without customElements). */
|
|
8
|
+
export declare function defineWidget(tag?: string): void;
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|