@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/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @synfin/widget: the embeddable Synfin quote-and-fee widget. Importing this
|
|
3
|
+
* module registers the <synfin-widget> custom element (the script-tag install).
|
|
4
|
+
* See https://synfin.xyz/docs/sdk. Non-custodial: the widget holds no keys.
|
|
5
|
+
*/
|
|
6
|
+
import { SynfinWidgetElement } from './element.js';
|
|
7
|
+
export { SynfinWidgetElement } from './element.js';
|
|
8
|
+
export { FEE_CAP_BPS, normalizeConfig, } from './config.js';
|
|
9
|
+
export { ASSETS, findAsset, searchAssets } from './assets.js';
|
|
10
|
+
export { EVENTS } from './events.js';
|
|
11
|
+
export { feeBreakdown, formatAmount, } from './format.js';
|
|
12
|
+
export { viewFromQuote, } from './state.js';
|
|
13
|
+
/** Register <synfin-widget> (idempotent, no-op without customElements). */
|
|
14
|
+
export function defineWidget(tag = 'synfin-widget') {
|
|
15
|
+
if (typeof customElements !== 'undefined' &&
|
|
16
|
+
customElements.get(tag) === undefined) {
|
|
17
|
+
customElements.define(tag, SynfinWidgetElement);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
defineWidget();
|
|
21
|
+
//# sourceMappingURL=index.js.map
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional React wrapper: <SynfinWidget ... /> for React hosts. A thin shim over
|
|
3
|
+
* the <synfin-widget> custom element (React is a peer dependency). Non-React
|
|
4
|
+
* hosts use the element directly; this just gives React partners typed props and
|
|
5
|
+
* on* callbacks. Uses React.createElement (no JSX) so the package needs no JSX
|
|
6
|
+
* build step.
|
|
7
|
+
*/
|
|
8
|
+
import { type CSSProperties, type ReactElement } from 'react';
|
|
9
|
+
export interface SynfinWidgetProps {
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
feeBps?: number;
|
|
12
|
+
feeRecipient?: string;
|
|
13
|
+
from?: string;
|
|
14
|
+
to?: string;
|
|
15
|
+
amount?: string;
|
|
16
|
+
appearance?: 'dark' | 'light';
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
onQuote?: (e: CustomEvent) => void;
|
|
19
|
+
onPlan?: (e: CustomEvent) => void;
|
|
20
|
+
onError?: (e: CustomEvent) => void;
|
|
21
|
+
className?: string;
|
|
22
|
+
style?: CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
export declare function SynfinWidget(props: SynfinWidgetProps): ReactElement;
|
|
25
|
+
//# sourceMappingURL=react.d.ts.map
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional React wrapper: <SynfinWidget ... /> for React hosts. A thin shim over
|
|
3
|
+
* the <synfin-widget> custom element (React is a peer dependency). Non-React
|
|
4
|
+
* hosts use the element directly; this just gives React partners typed props and
|
|
5
|
+
* on* callbacks. Uses React.createElement (no JSX) so the package needs no JSX
|
|
6
|
+
* build step.
|
|
7
|
+
*/
|
|
8
|
+
import { createElement, useEffect, useRef, } from 'react';
|
|
9
|
+
import { defineWidget } from './index.js';
|
|
10
|
+
export function SynfinWidget(props) {
|
|
11
|
+
const ref = useRef(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
defineWidget();
|
|
14
|
+
}, []);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
ref.current?.configure({
|
|
17
|
+
apiKey: props.apiKey,
|
|
18
|
+
feeBps: props.feeBps,
|
|
19
|
+
feeRecipient: props.feeRecipient,
|
|
20
|
+
from: props.from,
|
|
21
|
+
to: props.to,
|
|
22
|
+
amount: props.amount,
|
|
23
|
+
appearance: props.appearance,
|
|
24
|
+
baseUrl: props.baseUrl,
|
|
25
|
+
});
|
|
26
|
+
}, [
|
|
27
|
+
props.apiKey,
|
|
28
|
+
props.feeBps,
|
|
29
|
+
props.feeRecipient,
|
|
30
|
+
props.from,
|
|
31
|
+
props.to,
|
|
32
|
+
props.amount,
|
|
33
|
+
props.appearance,
|
|
34
|
+
props.baseUrl,
|
|
35
|
+
]);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const el = ref.current;
|
|
38
|
+
if (el === null)
|
|
39
|
+
return;
|
|
40
|
+
const pairs = [
|
|
41
|
+
['synfin:quote', props.onQuote],
|
|
42
|
+
['synfin:plan', props.onPlan],
|
|
43
|
+
['synfin:error', props.onError],
|
|
44
|
+
];
|
|
45
|
+
for (const [name, h] of pairs)
|
|
46
|
+
if (h)
|
|
47
|
+
el.addEventListener(name, h);
|
|
48
|
+
return () => {
|
|
49
|
+
for (const [name, h] of pairs)
|
|
50
|
+
if (h)
|
|
51
|
+
el.removeEventListener(name, h);
|
|
52
|
+
};
|
|
53
|
+
}, [props.onQuote, props.onPlan, props.onError]);
|
|
54
|
+
return createElement('synfin-widget', {
|
|
55
|
+
ref,
|
|
56
|
+
className: props.className,
|
|
57
|
+
style: props.style,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=react.js.map
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure render for the RESULT region: view model to an HTML string, one branch
|
|
3
|
+
* per state (loading skeleton, quoted with the fee breakdown, no-route, error,
|
|
4
|
+
* keyless). The persistent chrome (pair + amount) is owned by the element; only
|
|
5
|
+
* this region swaps per quote, so the amount input keeps focus while typing.
|
|
6
|
+
*/
|
|
7
|
+
import type { NormalizedConfig } from './config.js';
|
|
8
|
+
import type { QuotedView, VenueRow, WidgetView } from './state.js';
|
|
9
|
+
/** Transient feedback for the Create-a-plan flow (post-quote UI state). */
|
|
10
|
+
export interface PlanFeedback {
|
|
11
|
+
readonly status: 'creating' | 'created' | 'error';
|
|
12
|
+
readonly planId?: string;
|
|
13
|
+
readonly message?: string;
|
|
14
|
+
}
|
|
15
|
+
/** The venue the user has selected (defaults to best), or best if unknown. */
|
|
16
|
+
export declare function selectedRow(v: QuotedView, selectedVenueId: string | null): VenueRow | null;
|
|
17
|
+
export declare function resultHtml(view: WidgetView, config: NormalizedConfig, selectedVenueId?: string | null, plan?: PlanFeedback | null): string;
|
|
18
|
+
//# sourceMappingURL=render.d.ts.map
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { formatAmount } from './format.js';
|
|
2
|
+
/** The venue the user has selected (defaults to best), or best if unknown. */
|
|
3
|
+
export function selectedRow(v, selectedVenueId) {
|
|
4
|
+
const avail = v.venues.filter((r) => r.available);
|
|
5
|
+
return (avail.find((r) => r.venueId === selectedVenueId) ??
|
|
6
|
+
v.best ??
|
|
7
|
+
avail[0] ??
|
|
8
|
+
null);
|
|
9
|
+
}
|
|
10
|
+
function esc(s) {
|
|
11
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
12
|
+
}
|
|
13
|
+
function cap(s) {
|
|
14
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
15
|
+
}
|
|
16
|
+
export function resultHtml(view, config, selectedVenueId = null, plan = null) {
|
|
17
|
+
switch (view.kind) {
|
|
18
|
+
case 'idle':
|
|
19
|
+
return '';
|
|
20
|
+
case 'loading':
|
|
21
|
+
return loadingHtml();
|
|
22
|
+
case 'invalid':
|
|
23
|
+
return msgHtml('!', view.title, esc(view.message), '');
|
|
24
|
+
case 'error':
|
|
25
|
+
return msgHtml('!', 'Could not load a quote', esc(view.message), 'error');
|
|
26
|
+
case 'no-route':
|
|
27
|
+
return msgHtml('∅', 'No route right now', `No venue can fill ${esc(view.pair.give)} to ${esc(view.pair.want)} at this size. Try a different amount or pair.`, '');
|
|
28
|
+
case 'quoted':
|
|
29
|
+
return quotedHtml(view, config, selectedVenueId, plan);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function loadingHtml() {
|
|
33
|
+
return `<div class="quote" aria-busy="true" aria-label="Loading a quote">
|
|
34
|
+
<div class="skel skel-big"></div>
|
|
35
|
+
<div class="skel skel-row"></div>
|
|
36
|
+
<div class="skel skel-row"></div>
|
|
37
|
+
<div class="skel skel-row"></div>
|
|
38
|
+
</div>`;
|
|
39
|
+
}
|
|
40
|
+
function msgHtml(icon, title, body, kind) {
|
|
41
|
+
return `<div class="msg ${kind === 'error' ? 'msg--error' : ''}" role="status">
|
|
42
|
+
<div class="msg__icon" aria-hidden="true">${esc(icon)}</div>
|
|
43
|
+
<div class="msg__title">${title}</div>
|
|
44
|
+
<div class="msg__body">${body}</div>
|
|
45
|
+
</div>`;
|
|
46
|
+
}
|
|
47
|
+
function quotedHtml(v, config, selectedVenueId, plan) {
|
|
48
|
+
// The headline, fees, and plan all follow the USER's selected venue (default
|
|
49
|
+
// best), not always the top-ranked one.
|
|
50
|
+
const sel = selectedRow(v, selectedVenueId);
|
|
51
|
+
const selId = sel?.venueId ?? null;
|
|
52
|
+
const youGet = sel?.fees ? sel.fees.userReceives : (sel?.net ?? null);
|
|
53
|
+
const rows = v.venues
|
|
54
|
+
.map((r) => venueRow(r, v.pair.want, r.venueId === selId))
|
|
55
|
+
.join('');
|
|
56
|
+
const fees = !sel?.fees
|
|
57
|
+
? ''
|
|
58
|
+
: config.showFeeBreakdown
|
|
59
|
+
? feesDetailHtml(sel.fees, v.pair.want)
|
|
60
|
+
: feeSimpleHtml(sel.fees, v.pair.want);
|
|
61
|
+
// The "add a key to see fees" hint is integrator guidance, so it belongs only
|
|
62
|
+
// to the partner detail mode; the end-user default just shows the net quote.
|
|
63
|
+
const keyless = config.showFeeBreakdown && !v.keyed
|
|
64
|
+
? `<div class="note note--key">Add your API key to see the fee breakdown (the service fee and your integrator fee). Keyless quotes show the venue net only.</div>`
|
|
65
|
+
: '';
|
|
66
|
+
return `<div class="quote">
|
|
67
|
+
<div class="you-get">
|
|
68
|
+
<span class="you-get__k">You receive</span>
|
|
69
|
+
<span class="you-get__v">${formatAmount(youGet)} ${esc(v.pair.want)}</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="venues" role="radiogroup" aria-label="Choose a venue, best net receive first">${rows}</div>
|
|
72
|
+
${fees}${keyless}${ctaHtml(sel, config, plan)}
|
|
73
|
+
<div class="foot">Non-custodial${config.showAttribution
|
|
74
|
+
? ` <span class="foot__sep" aria-hidden="true">|</span> <a class="foot__brand" href="https://synfin.xyz" target="_blank" rel="noopener">Powered by Synfin</a>`
|
|
75
|
+
: ', fees included'}</div>
|
|
76
|
+
</div>`;
|
|
77
|
+
}
|
|
78
|
+
// Map a venue rejection/status enum from the API to human copy. No raw API
|
|
79
|
+
// enum ever reaches the end user; an unknown code degrades to "unavailable".
|
|
80
|
+
const VENUE_STATUS = {
|
|
81
|
+
maintenance: 'under maintenance',
|
|
82
|
+
no_liquidity: 'no liquidity at this size',
|
|
83
|
+
insufficient_liquidity: 'no liquidity at this size',
|
|
84
|
+
invalid_request: 'unavailable',
|
|
85
|
+
pair_unsupported: 'pair not supported',
|
|
86
|
+
venue_error: 'temporarily unavailable',
|
|
87
|
+
timeout: 'did not respond in time',
|
|
88
|
+
rate_limited: 'temporarily unavailable',
|
|
89
|
+
};
|
|
90
|
+
function venueStatusText(code) {
|
|
91
|
+
return (code !== null ? VENUE_STATUS[code] : undefined) ?? 'unavailable';
|
|
92
|
+
}
|
|
93
|
+
function venueRow(r, want, isSelected) {
|
|
94
|
+
if (!r.available) {
|
|
95
|
+
// Unavailable venues are shown honestly but are not selectable.
|
|
96
|
+
return `<div class="venue venue--out" role="presentation">
|
|
97
|
+
<span class="venue__id">${esc(cap(r.venueId))}</span>
|
|
98
|
+
<span class="venue__out">${esc(venueStatusText(r.rejectionCode))}</span>
|
|
99
|
+
</div>`;
|
|
100
|
+
}
|
|
101
|
+
// Available venues are radio options: pick with click, Space/Enter, or arrows.
|
|
102
|
+
return `<div class="venue venue--pick ${r.isBest ? 'venue--best' : ''} ${isSelected ? 'venue--sel' : ''}" role="radio" aria-checked="${isSelected ? 'true' : 'false'}" tabindex="${isSelected ? '0' : '-1'}" data-venue="${esc(r.venueId)}">
|
|
103
|
+
<span class="venue__radio" aria-hidden="true"></span>
|
|
104
|
+
<span class="venue__id">${esc(cap(r.venueId))}${r.isBest ? '<span class="badge">best</span>' : ''}</span>
|
|
105
|
+
<span class="venue__net">${formatAmount(r.net)} ${esc(want)}</span>
|
|
106
|
+
</div>`;
|
|
107
|
+
}
|
|
108
|
+
function feeLine(label, bps, amount, want, cls = '') {
|
|
109
|
+
return `<div class="fee ${cls}">
|
|
110
|
+
<span class="fee__k">${esc(label)}<span class="bps">${bps} bps</span></span>
|
|
111
|
+
<span class="fee__v">${formatAmount(amount)} ${esc(want)}</span>
|
|
112
|
+
</div>`;
|
|
113
|
+
}
|
|
114
|
+
/** End-user default: one neutral line, the total fee actually taken from the
|
|
115
|
+
* live quote. No brand, no "service" vs "your fee" attribution, no split. */
|
|
116
|
+
function feeSimpleHtml(f, want) {
|
|
117
|
+
return `<div class="fees fees--simple">
|
|
118
|
+
<div class="fee">
|
|
119
|
+
<span class="fee__k">Fee</span>
|
|
120
|
+
<span class="fee__v">${formatAmount(f.total)} ${esc(want)}</span>
|
|
121
|
+
</div>
|
|
122
|
+
</div>`;
|
|
123
|
+
}
|
|
124
|
+
/** Partner detail mode (show-fee-breakdown): the full attribution, for the
|
|
125
|
+
* integrator's own testing. Never the end-user default. */
|
|
126
|
+
function feesDetailHtml(f, want) {
|
|
127
|
+
const integ = f.integrator === null
|
|
128
|
+
? ''
|
|
129
|
+
: feeLine(f.integrator.label, f.integrator.bps, f.integrator.amount, want) +
|
|
130
|
+
`<div class="fee fee--split"><span class="fee__k">partner ${formatAmount(f.integrator.partnerShare)} ${esc(want)}, Synfin ${formatAmount(f.integrator.synfinShare)} ${esc(want)}</span></div>`;
|
|
131
|
+
return `<div class="fees">
|
|
132
|
+
${feeLine(f.service.label, f.service.bps, f.service.amount, want)}
|
|
133
|
+
${integ}
|
|
134
|
+
<div class="fee fee--net">
|
|
135
|
+
<span class="fee__k">You receive</span>
|
|
136
|
+
<span class="fee__v">${formatAmount(f.userReceives)} ${esc(want)}</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>`;
|
|
139
|
+
}
|
|
140
|
+
// The honest v1 execution note, shown before and after a plan is created.
|
|
141
|
+
const EXEC_NOTE = `<p class="cta__help">On create, the widget emits <code>synfin:plan</code> for your app to execute via its WalletAdapter. In-widget wallet execution is coming.</p>`;
|
|
142
|
+
function ctaHtml(sel, config, plan) {
|
|
143
|
+
if (sel === null || !sel.available)
|
|
144
|
+
return '';
|
|
145
|
+
if (config.apiKey === null) {
|
|
146
|
+
// Disabled without a key. The helper is real, visible text (not a title
|
|
147
|
+
// tooltip), linked via aria-describedby, and the button reads as disabled.
|
|
148
|
+
return `<button class="cta cta--disabled" disabled aria-disabled="true" aria-describedby="synfin-cta-help">Create a plan</button>
|
|
149
|
+
<p class="cta__help" id="synfin-cta-help">Add an API key to create a plan.</p>`;
|
|
150
|
+
}
|
|
151
|
+
if (plan?.status === 'creating') {
|
|
152
|
+
return `<button class="cta cta--disabled" disabled aria-disabled="true">Creating plan...</button>${EXEC_NOTE}`;
|
|
153
|
+
}
|
|
154
|
+
if (plan?.status === 'created') {
|
|
155
|
+
const id = plan.planId ? plan.planId.slice(0, 14) : '';
|
|
156
|
+
return `<div class="plan-ok" role="status"><span class="plan-ok__icon" aria-hidden="true">✓</span> Plan created${id ? ` <code>${esc(id)}</code>` : ''}, handed off to your app on <code>synfin:plan</code> (venue ${esc(cap(sel.venueId))}).</div>${EXEC_NOTE}<button class="cta cta--ghost" data-action="plan">Create a new plan</button>`;
|
|
157
|
+
}
|
|
158
|
+
if (plan?.status === 'error') {
|
|
159
|
+
return `<div class="msg msg--error" role="status"><div class="msg__title">Could not create the plan</div><div class="msg__body">${esc(plan.message ?? 'Please try again.')}</div></div><button class="cta" data-action="plan">Try again</button>`;
|
|
160
|
+
}
|
|
161
|
+
return `<button class="cta" data-action="plan">Create a plan</button>${EXEC_NOTE}`;
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=render.js.map
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure view model: reduce a QuoteResponse (or a loading/error signal) to
|
|
3
|
+
* exactly what the widget renders, with every state represented. Ranking and
|
|
4
|
+
* fees come from the API; this only shapes them for display and marks the best
|
|
5
|
+
* venue, the unavailable venues (kept, never dropped, so a venue under
|
|
6
|
+
* maintenance is shown honestly and never breaks the widget), and the
|
|
7
|
+
* keyed-vs-keyless fee state.
|
|
8
|
+
*/
|
|
9
|
+
import type { QuoteResponse } from '@synfin/client';
|
|
10
|
+
import { type FeeBreakdown } from './format.js';
|
|
11
|
+
export interface VenueRow {
|
|
12
|
+
readonly venueId: string;
|
|
13
|
+
readonly available: boolean;
|
|
14
|
+
readonly net: string | null;
|
|
15
|
+
readonly rejectionCode: string | null;
|
|
16
|
+
readonly isBest: boolean;
|
|
17
|
+
readonly fees: FeeBreakdown | null;
|
|
18
|
+
}
|
|
19
|
+
export interface QuotedView {
|
|
20
|
+
readonly kind: 'quoted';
|
|
21
|
+
readonly pair: {
|
|
22
|
+
readonly give: string;
|
|
23
|
+
readonly want: string;
|
|
24
|
+
};
|
|
25
|
+
readonly amount: string;
|
|
26
|
+
readonly asOf: string;
|
|
27
|
+
readonly best: VenueRow | null;
|
|
28
|
+
readonly venues: readonly VenueRow[];
|
|
29
|
+
readonly keyed: boolean;
|
|
30
|
+
readonly edgeBps: number | null;
|
|
31
|
+
}
|
|
32
|
+
export type WidgetView = {
|
|
33
|
+
readonly kind: 'idle';
|
|
34
|
+
} | {
|
|
35
|
+
readonly kind: 'loading';
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'invalid';
|
|
38
|
+
readonly title: string;
|
|
39
|
+
readonly message: string;
|
|
40
|
+
} | {
|
|
41
|
+
readonly kind: 'error';
|
|
42
|
+
readonly code: string;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
} | {
|
|
45
|
+
readonly kind: 'no-route';
|
|
46
|
+
readonly pair: {
|
|
47
|
+
readonly give: string;
|
|
48
|
+
readonly want: string;
|
|
49
|
+
};
|
|
50
|
+
} | QuotedView;
|
|
51
|
+
/**
|
|
52
|
+
* Map every token symbol in a raw QuoteResponse to the user-facing ticker, for
|
|
53
|
+
* the integrator-facing synfin:quote event: a partner who requested CC must see
|
|
54
|
+
* CC, never the internal canonical "Amulet", anywhere a symbol appears (the
|
|
55
|
+
* pair, and any venue fee asset).
|
|
56
|
+
*/
|
|
57
|
+
export declare function displayQuote(q: QuoteResponse): QuoteResponse;
|
|
58
|
+
/** Shape a live quote into the view. Venues arrive ranked best-net-first. */
|
|
59
|
+
export declare function viewFromQuote(q: QuoteResponse): WidgetView;
|
|
60
|
+
//# sourceMappingURL=state.d.ts.map
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { displaySymbol } from './assets.js';
|
|
2
|
+
import { feeBreakdown } from './format.js';
|
|
3
|
+
/**
|
|
4
|
+
* Map every token symbol in a raw QuoteResponse to the user-facing ticker, for
|
|
5
|
+
* the integrator-facing synfin:quote event: a partner who requested CC must see
|
|
6
|
+
* CC, never the internal canonical "Amulet", anywhere a symbol appears (the
|
|
7
|
+
* pair, and any venue fee asset).
|
|
8
|
+
*/
|
|
9
|
+
export function displayQuote(q) {
|
|
10
|
+
return {
|
|
11
|
+
...q,
|
|
12
|
+
pair: {
|
|
13
|
+
give: displaySymbol(q.pair.give),
|
|
14
|
+
want: displaySymbol(q.pair.want),
|
|
15
|
+
},
|
|
16
|
+
venues: q.venues.map((v) => v.fee
|
|
17
|
+
? { ...v, fee: { ...v.fee, asset: displaySymbol(v.fee.asset) } }
|
|
18
|
+
: v),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Shape a live quote into the view. Venues arrive ranked best-net-first. */
|
|
22
|
+
export function viewFromQuote(q) {
|
|
23
|
+
// The API echoes the canonical instrumentId (e.g. "Amulet"); map to the
|
|
24
|
+
// user-facing ticker so the internal name never reaches the UI.
|
|
25
|
+
const pair = {
|
|
26
|
+
give: displaySymbol(q.pair.give),
|
|
27
|
+
want: displaySymbol(q.pair.want),
|
|
28
|
+
};
|
|
29
|
+
const available = q.venues.filter((v) => v.available && v.net !== null);
|
|
30
|
+
if (available.length === 0) {
|
|
31
|
+
return { kind: 'no-route', pair };
|
|
32
|
+
}
|
|
33
|
+
const bestId = q.best?.venueId ?? null;
|
|
34
|
+
const keyed = available.some((v) => v.clientFees !== undefined);
|
|
35
|
+
const rows = q.venues.map((v) => ({
|
|
36
|
+
venueId: v.venueId,
|
|
37
|
+
available: v.available && v.net !== null,
|
|
38
|
+
net: v.net,
|
|
39
|
+
rejectionCode: v.rejectionCode,
|
|
40
|
+
isBest: v.venueId === bestId,
|
|
41
|
+
fees: v.clientFees !== undefined ? feeBreakdown(v.clientFees) : null,
|
|
42
|
+
}));
|
|
43
|
+
const best = rows.find((r) => r.isBest) ?? null;
|
|
44
|
+
return {
|
|
45
|
+
kind: 'quoted',
|
|
46
|
+
pair,
|
|
47
|
+
amount: q.amount,
|
|
48
|
+
asOf: q.asOf,
|
|
49
|
+
best,
|
|
50
|
+
venues: rows,
|
|
51
|
+
keyed,
|
|
52
|
+
edgeBps: q.edgeBps,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=state.js.map
|
package/dist/styles.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widget's styles, scoped inside the Shadow DOM. Theming is a small set of
|
|
3
|
+
* --synfin-* custom properties (which pierce the shadow boundary), defaulted to
|
|
4
|
+
* the Synfin cream/ember dark system; [appearance="light"] flips to a warm
|
|
5
|
+
* off-white. A partner overrides any of --synfin-accent / -bg / -surface / -ink
|
|
6
|
+
* / -ink-dim / -radius / -font to match their site.
|
|
7
|
+
*/
|
|
8
|
+
export declare const STYLES = "\n:host {\n --synfin-accent: #ff6a3d;\n --synfin-accent-hi: #ff8a5c;\n --synfin-bg: #0b0b0d;\n --synfin-surface: #191a1e;\n --synfin-surface-2: #202126;\n --synfin-ink: #f1efea;\n --synfin-ink-dim: #a8a59d;\n --synfin-ink-faint: #8b887f;\n --synfin-line: rgba(241, 239, 234, 0.12);\n --synfin-radius: 16px;\n --synfin-font: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n --synfin-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;\n --synfin-ok: #57c78a;\n --synfin-warn: #e0b24a;\n\n display: block;\n max-width: 420px;\n width: 100%;\n color: var(--synfin-ink);\n font-family: var(--synfin-font);\n font-size: 14px;\n line-height: 1.45;\n box-sizing: border-box;\n -webkit-font-smoothing: antialiased;\n}\n:host([appearance=\"light\"]) {\n --synfin-bg: #faf7f1;\n --synfin-surface: #ffffff;\n --synfin-surface-2: #f3efe7;\n --synfin-ink: #1a1814;\n --synfin-ink-dim: #5c584f;\n --synfin-ink-faint: #868075;\n --synfin-line: rgba(20, 18, 14, 0.12);\n}\n*, *::before, *::after { box-sizing: border-box; }\n\n.card {\n background: var(--synfin-bg);\n border: 1px solid var(--synfin-line);\n border-radius: var(--synfin-radius);\n padding: 16px;\n position: relative;\n overflow: hidden;\n /* Reflow on the widget's OWN width, so a narrow column on a wide viewport\n still restacks (a viewport media query would not fire). */\n container-type: inline-size;\n container-name: synfinw;\n}\n.head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 12px; }\n.refresh { display: inline-flex; align-items: center; gap: 6px; font-family: var(--synfin-mono); font-size: 11px; color: var(--synfin-ink-faint); }\n.refresh__ring { width: 13px; height: 13px; transform: rotate(-90deg); }\n.refresh__ring circle { fill: none; stroke-width: 2; }\n.refresh__ring .bg { stroke: var(--synfin-line); }\n.refresh__ring .fg { stroke: var(--synfin-accent); transition: stroke-dashoffset 1s linear; }\n.refresh button {\n all: unset; cursor: pointer; color: var(--synfin-ink-faint);\n display: inline-flex; padding: 3px; border-radius: 6px;\n}\n.refresh button:hover { color: var(--synfin-ink); }\n.refresh button:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: 1px; }\n\n/* pair + amount */\n.field { background: var(--synfin-surface); border: 1px solid var(--synfin-line); border-radius: 12px; padding: 12px 14px; }\n.field + .swapwrap { position: relative; height: 0; }\n.swap {\n all: unset; cursor: pointer; position: absolute; left: 50%; top: -14px; transform: translateX(-50%);\n width: 30px; height: 30px; display: grid; place-items: center;\n background: var(--synfin-surface-2); border: 3px solid var(--synfin-bg); border-radius: 9px; color: var(--synfin-ink-dim);\n transition: transform .15s ease, color .15s ease; z-index: 2;\n}\n.swap:hover { color: var(--synfin-accent); transform: translateX(-50%) rotate(180deg); }\n.swap:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: 2px; }\n.field__label { font-size: 11px; color: var(--synfin-ink-faint); text-transform: uppercase; letter-spacing: 0.08em; }\n.field__row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }\n.amount { all: unset; flex: 1; font-family: var(--synfin-mono); font-size: 22px; font-weight: 500; color: var(--synfin-ink); min-width: 0; border-radius: 5px; }\n.amount::placeholder { color: var(--synfin-ink-faint); }\n.amount:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: 3px; }\n.receive { font-family: var(--synfin-mono); font-size: 22px; font-weight: 500; color: var(--synfin-ink); }\n.receive--dim { color: var(--synfin-ink-faint); }\n\n.asset {\n all: unset; cursor: pointer; display: inline-flex; align-items: center; gap: 8px;\n background: var(--synfin-surface-2); border: 1px solid var(--synfin-line); border-radius: 999px; padding: 6px 12px 6px 6px;\n font-weight: 600; color: var(--synfin-ink); white-space: nowrap; transition: border-color .15s ease;\n /* never shrink below the label: the amount input (min-width:0) absorbs the\n squeeze instead, so asset symbols never clip at narrow widths. */\n flex-shrink: 0;\n}\n.asset:hover { border-color: var(--synfin-accent); }\n.asset:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: 2px; }\n.asset__icon { width: 22px; height: 22px; border-radius: 50%; display: block; flex-shrink: 0; }\n.asset__sym { white-space: nowrap; }\n.asset__caret { color: var(--synfin-ink-faint); font-size: 10px; }\n\n/* selector popover */\n.pop { position: absolute; inset: 0; background: var(--synfin-bg); z-index: 5; display: flex; flex-direction: column;\n animation: pop-in .14s ease; }\n@keyframes pop-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }\n.pop__head { display: flex; align-items: center; gap: 8px; padding: 14px 16px; border-bottom: 1px solid var(--synfin-line); }\n.pop__search { all: unset; flex: 1; color: var(--synfin-ink); font-size: 15px; }\n.pop__search::placeholder { color: var(--synfin-ink-faint); }\n.pop__close { all: unset; cursor: pointer; color: var(--synfin-ink-dim); padding: 4px; border-radius: 6px; }\n.pop__close:hover { color: var(--synfin-ink); }\n.pop__close:focus-visible { outline: 2px solid var(--synfin-accent); }\n.pop__list { overflow-y: auto; padding: 6px; }\n.opt { all: unset; cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; }\n.opt:hover, .opt[aria-selected=\"true\"] { background: var(--synfin-surface); }\n.opt:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: -2px; }\n.opt__name { display: flex; flex-direction: column; }\n.opt__sym { font-weight: 600; }\n.opt__full { font-size: 12px; color: var(--synfin-ink-faint); }\n.opt--empty { padding: 20px; text-align: center; color: var(--synfin-ink-faint); }\n\n/* quote panel */\n.quote { margin-top: 14px; }\n.you-get { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 12px; }\n.you-get__k { font-size: 12px; color: var(--synfin-ink-dim); }\n.you-get__v { font-family: var(--synfin-mono); font-size: 20px; font-weight: 600; }\n.venues { display: flex; flex-direction: column; gap: 6px; }\n.venue { display: flex; align-items: center; gap: 9px; padding: 10px 12px;\n border: 1px solid var(--synfin-line); border-radius: 11px; background: var(--synfin-surface); transition: border-color .15s ease; }\n.venue--pick { cursor: pointer; }\n.venue--pick:hover { border-color: var(--synfin-accent); }\n.venue--pick:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: 2px; }\n.venue--best { background: color-mix(in srgb, var(--synfin-surface) 84%, var(--synfin-accent) 16%); }\n.venue--sel { border-color: var(--synfin-accent); }\n.venue--out { opacity: 0.55; }\n/* the radio affordance: outline circle that fills accent when selected */\n.venue__radio { width: 15px; height: 15px; border-radius: 50%; border: 2px solid var(--synfin-line); flex-shrink: 0; position: relative; transition: border-color .15s ease; }\n.venue--sel .venue__radio { border-color: var(--synfin-accent); }\n.venue--sel .venue__radio::after { content: \"\"; position: absolute; inset: 2px; border-radius: 50%; background: var(--synfin-accent); }\n.venue__id { display: inline-flex; align-items: center; gap: 7px; font-weight: 600; text-transform: capitalize; }\n.badge { font-family: var(--synfin-mono); font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--synfin-accent);\n border: 1px solid var(--synfin-accent); border-radius: 999px; padding: 1px 6px; }\n.venue__net { font-family: var(--synfin-mono); font-weight: 600; margin-left: auto; }\n.venue__out { font-size: 11px; color: var(--synfin-ink-faint); font-family: var(--synfin-mono); margin-left: auto; }\n\n.fees { margin-top: 12px; border-top: 1px dashed var(--synfin-line); padding-top: 12px; display: flex; flex-direction: column; gap: 7px; }\n.fee { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; }\n.fee__k { color: var(--synfin-ink-dim); }\n.fee__k .bps { font-family: var(--synfin-mono); font-size: 11px; color: var(--synfin-ink-faint); margin-left: 5px; }\n.fee__v { font-family: var(--synfin-mono); }\n.fee--split { margin-top: -3px; }\n.fee--split .fee__k { font-size: 11px; color: var(--synfin-ink-faint); }\n.fee--net { border-top: 1px solid var(--synfin-line); padding-top: 8px; margin-top: 2px; }\n.fee--net .fee__k { color: var(--synfin-ink); font-weight: 600; font-size: 13px; }\n.fee--net .fee__v { color: var(--synfin-ink); font-weight: 700; }\n\n.note { margin-top: 12px; padding: 10px 12px; border-radius: 10px; font-size: 12px; line-height: 1.5;\n background: var(--synfin-surface); border: 1px solid var(--synfin-line); color: var(--synfin-ink-dim); }\n.note--key { border-left: 2px solid var(--synfin-accent); }\n.note a { color: var(--synfin-accent); }\n\n.cta { all: unset; box-sizing: border-box; cursor: pointer; display: block; text-align: center; width: 100%; margin-top: 14px;\n padding: 13px; border-radius: 12px; font-weight: 600; background: var(--synfin-accent); color: #0b0b0d; transition: background .15s ease; }\n.cta:hover { background: var(--synfin-accent-hi); }\n.cta:focus-visible { outline: 2px solid var(--synfin-ink); outline-offset: 2px; }\n/* a genuinely disabled look: muted surface, not the accent at reduced opacity */\n.cta--disabled { background: var(--synfin-surface-2); color: var(--synfin-ink-faint); border: 1px solid var(--synfin-line); cursor: not-allowed; }\n.cta--disabled:hover { background: var(--synfin-surface-2); }\n.cta__help { margin: 8px 0 0; font-size: 11px; line-height: 1.5; text-align: center; color: var(--synfin-ink-faint); }\n.cta__help code { font-family: var(--synfin-mono); font-size: 10.5px; color: var(--synfin-ink-dim); }\n/* visible confirmation that a plan was created and handed off */\n.plan-ok { margin-top: 14px; padding: 11px 12px; border-radius: 10px; display: flex; align-items: flex-start; gap: 8px;\n font-size: 12.5px; line-height: 1.5; color: var(--synfin-ink);\n background: color-mix(in srgb, var(--synfin-surface) 82%, var(--synfin-ok) 18%); border: 1px solid var(--synfin-ok); }\n.plan-ok__icon { color: var(--synfin-ok); font-weight: 700; }\n.plan-ok code { font-family: var(--synfin-mono); font-size: 11px; color: var(--synfin-ink-dim); }\n\n.foot { margin-top: 12px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 10.5px; color: var(--synfin-ink-faint); }\n.foot__sep { opacity: 0.5; }\n.foot__brand { color: var(--synfin-ink-faint); text-decoration: none; border-radius: 3px; }\n.foot__brand:hover { color: var(--synfin-accent); }\n.foot__brand:focus-visible { outline: 2px solid var(--synfin-accent); outline-offset: 2px; }\n.foot a { color: var(--synfin-ink-faint); }\n.foot a:hover { color: var(--synfin-ink-dim); }\n\n/* states */\n.msg { margin-top: 14px; text-align: center; padding: 26px 16px; }\n.msg__icon { font-size: 22px; margin-bottom: 8px; }\n.msg__title { font-weight: 600; margin-bottom: 4px; }\n.msg__body { font-size: 12.5px; color: var(--synfin-ink-dim); }\n.msg--error .msg__title { color: var(--synfin-warn); }\n\n.skel { border-radius: 10px; background:\n linear-gradient(100deg, var(--synfin-surface) 30%, var(--synfin-surface-2) 50%, var(--synfin-surface) 70%);\n background-size: 200% 100%; animation: shimmer 1.3s ease-in-out infinite; }\n@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }\n.skel-row { height: 44px; margin-top: 6px; }\n.skel-big { height: 26px; width: 55%; margin-bottom: 12px; }\n\n@media (prefers-reduced-motion: reduce) {\n .skel { animation: none; } .swap { transition: none; } .pop { animation: none; }\n}\n/* Narrow embeds: reflow on the widget's own width (container query), with a\n viewport media-query fallback for engines without container support. Both\n only shrink type and tighten spacing; the asset flex-shrink:0 above is what\n actually stops labels clipping, so legibility holds even without either. */\n@container synfinw (max-width: 360px) {\n .amount, .receive, .you-get__v { font-size: 18px; }\n .you-get__v { font-size: 17px; }\n .card { padding: 13px; }\n .field { padding: 11px 12px; }\n .asset { padding: 5px 10px 5px 5px; gap: 6px; }\n .asset__icon { width: 20px; height: 20px; }\n .venue, .fee { font-size: 12px; }\n}\n@container synfinw (max-width: 320px) {\n .amount, .receive { font-size: 16px; }\n .field__row { gap: 6px; }\n}\n@media (max-width: 360px) {\n .amount, .receive, .you-get__v { font-size: 18px; }\n .card { padding: 13px; }\n}\n";
|
|
9
|
+
//# sourceMappingURL=styles.d.ts.map
|