@cardano-slips/flow 0.2.0 → 0.3.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/README.md +53 -9
- package/dist/address.d.ts +12 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +43 -0
- package/dist/address.js.map +1 -1
- package/dist/card.css +492 -0
- package/dist/card.d.ts +41 -0
- package/dist/card.d.ts.map +1 -0
- package/dist/card.js +69 -0
- package/dist/card.js.map +1 -0
- package/dist/complete-error.d.ts +8 -1
- package/dist/complete-error.d.ts.map +1 -1
- package/dist/complete-error.js +6 -1
- package/dist/complete-error.js.map +1 -1
- package/dist/complete.d.ts +29 -10
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +83 -9
- package/dist/complete.js.map +1 -1
- package/dist/derived.d.ts +8 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +2 -0
- package/dist/derived.js.map +1 -0
- package/dist/effects-panel.d.ts +37 -0
- package/dist/effects-panel.d.ts.map +1 -0
- package/dist/effects-panel.js +64 -0
- package/dist/effects-panel.js.map +1 -0
- package/dist/effects.css +629 -0
- package/dist/exchange.d.ts +54 -0
- package/dist/exchange.d.ts.map +1 -0
- package/dist/exchange.js +136 -0
- package/dist/exchange.js.map +1 -0
- package/dist/explain.d.ts +39 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +172 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +74 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +402 -0
- package/dist/ledger.js.map +1 -0
- package/dist/outcome-panel.d.ts +63 -0
- package/dist/outcome-panel.d.ts.map +1 -0
- package/dist/outcome-panel.js +40 -0
- package/dist/outcome-panel.js.map +1 -0
- package/dist/parameter-form.d.ts +26 -0
- package/dist/parameter-form.d.ts.map +1 -0
- package/dist/parameter-form.js +42 -0
- package/dist/parameter-form.js.map +1 -0
- package/dist/slip-card.d.ts +55 -0
- package/dist/slip-card.d.ts.map +1 -0
- package/dist/slip-card.js +89 -0
- package/dist/slip-card.js.map +1 -0
- package/dist/tokens.css +57 -32
- package/package.json +9 -5
- package/src/address.ts +53 -0
- package/src/card.css +492 -0
- package/src/card.ts +86 -0
- package/src/complete-error.ts +14 -2
- package/src/complete.ts +128 -19
- package/src/derived.ts +8 -0
- package/src/effects-panel.tsx +386 -0
- package/src/effects.css +629 -0
- package/src/exchange.ts +212 -0
- package/src/explain.ts +211 -0
- package/src/index.ts +39 -3
- package/src/ledger.ts +503 -0
- package/src/outcome-panel.tsx +263 -0
- package/src/parameter-form.tsx +138 -0
- package/src/slip-card.tsx +244 -0
- package/src/tokens.css +57 -32
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What happens after the person presses sign — `10 · Signing and outcomes`.
|
|
5
|
+
* Every state but the receipt keeps the effects on screen, because waiting is
|
|
6
|
+
* not a modal: a spinner over a hidden ledger asks for trust the panel exists
|
|
7
|
+
* to avoid needing.
|
|
8
|
+
*/
|
|
9
|
+
import type { Network } from "@cardano-slips/core"
|
|
10
|
+
import { useId } from "react"
|
|
11
|
+
|
|
12
|
+
import type { Derived } from "./derived.js"
|
|
13
|
+
import { Ledger } from "./effects-panel.js"
|
|
14
|
+
import { ledgerOf } from "./ledger.js"
|
|
15
|
+
|
|
16
|
+
export type Outcome =
|
|
17
|
+
/** The wallet is open over the page, asked for a signature. */
|
|
18
|
+
| { readonly _tag: "Waiting"; readonly wallet: string }
|
|
19
|
+
/** The person said no in the wallet. Not a failure: the offer stands. */
|
|
20
|
+
| { readonly _tag: "Declined" }
|
|
21
|
+
/** Signed, and on its way to the network. */
|
|
22
|
+
| { readonly _tag: "Submitting"; readonly transactionId: string }
|
|
23
|
+
/** The funds moved; a fresh transaction is being built and will be shown before it is signed. */
|
|
24
|
+
| { readonly _tag: "Rebuilding"; readonly number: number; readonly of: number }
|
|
25
|
+
/** The network refused it. `reason` is the node's own words, shown as they came. */
|
|
26
|
+
| { readonly _tag: "Refused"; readonly reason: string }
|
|
27
|
+
|
|
28
|
+
export type OutcomePanelProps = {
|
|
29
|
+
readonly claim: string
|
|
30
|
+
readonly derived: Derived
|
|
31
|
+
readonly outcome: Outcome
|
|
32
|
+
/** Cancel while waiting or rebuilding; Close once declined or refused. */
|
|
33
|
+
readonly onClose?: () => void
|
|
34
|
+
/** Review and sign again after a decline; start again after a refusal. */
|
|
35
|
+
readonly onAgain?: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Spinner = (): React.JSX.Element => <span className="slip-panel__spin" aria-hidden="true" />
|
|
39
|
+
|
|
40
|
+
const Head = ({
|
|
41
|
+
kicker,
|
|
42
|
+
lead,
|
|
43
|
+
title,
|
|
44
|
+
titleId,
|
|
45
|
+
tone
|
|
46
|
+
}: {
|
|
47
|
+
readonly kicker: string
|
|
48
|
+
readonly title: string
|
|
49
|
+
readonly titleId: string
|
|
50
|
+
readonly lead?: string
|
|
51
|
+
readonly tone?: "bad" | "warn"
|
|
52
|
+
}): React.JSX.Element => (
|
|
53
|
+
<div className="slip-panel__claim">
|
|
54
|
+
<p className="slip-panel__kicker" data-tone={tone}>
|
|
55
|
+
{kicker}
|
|
56
|
+
</p>
|
|
57
|
+
<h2 className="slip-panel__title" id={titleId}>
|
|
58
|
+
{title}
|
|
59
|
+
</h2>
|
|
60
|
+
{lead === undefined ? undefined : <p className="slip-panel__lead">{lead}</p>}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
export const OutcomePanel = ({ claim, derived, onAgain, onClose, outcome }: OutcomePanelProps): React.JSX.Element => {
|
|
65
|
+
const title = useId()
|
|
66
|
+
const rows = ledgerOf(derived)
|
|
67
|
+
|
|
68
|
+
switch (outcome._tag) {
|
|
69
|
+
case "Waiting":
|
|
70
|
+
return (
|
|
71
|
+
<section className="slip-root slip-panel" data-outcome="waiting" aria-labelledby={title} aria-busy="true">
|
|
72
|
+
<Head kicker="What you're signing" title={claim} titleId={title} />
|
|
73
|
+
<Ledger rows={rows} />
|
|
74
|
+
<div className="slip-panel__wallet" role="status">
|
|
75
|
+
<Spinner />
|
|
76
|
+
<div className="slip-panel__wallet-words">
|
|
77
|
+
<span>Approve in your wallet</span>
|
|
78
|
+
<span className="slip-panel__note">{outcome.wallet} is open. Nothing is sent until you do.</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<button type="button" className="slip-panel__button" data-kind="cancel" onClick={onClose}>
|
|
82
|
+
Cancel
|
|
83
|
+
</button>
|
|
84
|
+
</section>
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
case "Declined":
|
|
88
|
+
return (
|
|
89
|
+
<section className="slip-root slip-panel" data-outcome="declined" aria-labelledby={title}>
|
|
90
|
+
<Head
|
|
91
|
+
kicker="Not signed"
|
|
92
|
+
title="You declined in your wallet"
|
|
93
|
+
titleId={title}
|
|
94
|
+
lead="Nothing was sent and nothing left your wallet. The link still works if you want to look again."
|
|
95
|
+
/>
|
|
96
|
+
<div className="slip-panel__stale">
|
|
97
|
+
<Ledger rows={rows} />
|
|
98
|
+
</div>
|
|
99
|
+
<div className="slip-panel__buttons">
|
|
100
|
+
<button type="button" className="slip-panel__button" data-kind="close" onClick={onClose}>
|
|
101
|
+
Close
|
|
102
|
+
</button>
|
|
103
|
+
<button type="button" className="slip-panel__button" data-kind="primary" onClick={onAgain}>
|
|
104
|
+
Review and sign again
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
case "Submitting":
|
|
111
|
+
return (
|
|
112
|
+
<section className="slip-root slip-panel" data-outcome="submitting" aria-labelledby={title} aria-busy="true">
|
|
113
|
+
<Head kicker="Submitting" title="Sending your signed transaction" titleId={title} />
|
|
114
|
+
<ol className="slip-panel__steps">
|
|
115
|
+
<li className="slip-panel__step">
|
|
116
|
+
<span className="slip-panel__tick" aria-hidden="true">
|
|
117
|
+
✓
|
|
118
|
+
</span>
|
|
119
|
+
<span>Signature checked against the transaction you read</span>
|
|
120
|
+
</li>
|
|
121
|
+
<li className="slip-panel__step" role="status">
|
|
122
|
+
<Spinner />
|
|
123
|
+
<span>Handing it to the network</span>
|
|
124
|
+
</li>
|
|
125
|
+
</ol>
|
|
126
|
+
{/* The id is what a person can carry away if the page dies mid-submit, so it is here in full. */}
|
|
127
|
+
<div className="slip-panel__transaction">
|
|
128
|
+
<span className="slip-panel__note">Transaction</span>
|
|
129
|
+
<span className="slip-panel__transaction-id">{outcome.transactionId}</span>
|
|
130
|
+
</div>
|
|
131
|
+
</section>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
case "Rebuilding":
|
|
135
|
+
return (
|
|
136
|
+
<section className="slip-root slip-panel" data-outcome="rebuilding" aria-labelledby={title} aria-busy="true">
|
|
137
|
+
<Head
|
|
138
|
+
kicker="Rebuilding"
|
|
139
|
+
tone="warn"
|
|
140
|
+
title="Your funds moved while you were signing"
|
|
141
|
+
titleId={title}
|
|
142
|
+
lead="Something else spent the outputs this transaction used. Nothing was signed twice and nothing left your wallet."
|
|
143
|
+
/>
|
|
144
|
+
<div className="slip-panel__wallet" role="status">
|
|
145
|
+
<Spinner />
|
|
146
|
+
<div className="slip-panel__wallet-words">
|
|
147
|
+
<span>Building a fresh transaction</span>
|
|
148
|
+
<span className="slip-panel__note">
|
|
149
|
+
Attempt {outcome.number} of {outcome.of}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<p className="slip-panel__note">
|
|
154
|
+
It will have different amounts and a different id, so you'll see its effects checked before your wallet is
|
|
155
|
+
asked again.
|
|
156
|
+
</p>
|
|
157
|
+
<div className="slip-panel__stale">
|
|
158
|
+
<Ledger rows={rows} />
|
|
159
|
+
</div>
|
|
160
|
+
<button type="button" className="slip-panel__button" data-kind="cancel" onClick={onClose}>
|
|
161
|
+
Cancel
|
|
162
|
+
</button>
|
|
163
|
+
</section>
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
case "Refused":
|
|
167
|
+
return (
|
|
168
|
+
<section className="slip-root slip-panel" data-outcome="refused" aria-labelledby={title}>
|
|
169
|
+
<Head
|
|
170
|
+
kicker="Not submitted"
|
|
171
|
+
tone="bad"
|
|
172
|
+
title="The network wouldn't accept this transaction"
|
|
173
|
+
titleId={title}
|
|
174
|
+
lead="You signed it, but it never took effect and nothing left your wallet. Your signature cannot be reused."
|
|
175
|
+
/>
|
|
176
|
+
<pre className="slip-panel__reason">{outcome.reason}</pre>
|
|
177
|
+
<div className="slip-panel__buttons">
|
|
178
|
+
<button type="button" className="slip-panel__button" data-kind="close" onClick={onClose}>
|
|
179
|
+
Close
|
|
180
|
+
</button>
|
|
181
|
+
<button type="button" className="slip-panel__button" data-kind="primary" onClick={onAgain}>
|
|
182
|
+
Start again
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type SlipReceiptProps = {
|
|
191
|
+
readonly claim: string
|
|
192
|
+
readonly derived: Derived
|
|
193
|
+
readonly transactionId: string
|
|
194
|
+
readonly network: Network
|
|
195
|
+
/** Where this transaction can be looked up: the only durable copy anyone keeps. */
|
|
196
|
+
readonly explorer: { readonly name: string; readonly url: string }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A transaction id is hex with no separator, so it is shortened from both ends. */
|
|
200
|
+
const shortId = (id: string): string => (id.length <= 12 ? id : `${id.slice(0, 4)}…${id.slice(-4)}`)
|
|
201
|
+
|
|
202
|
+
/** One spelling for every surface that names a network, so the receipt and the top bar never disagree. */
|
|
203
|
+
export const networkNames: Readonly<Record<Network, string>> = {
|
|
204
|
+
mainnet: "Mainnet",
|
|
205
|
+
preprod: "Preprod",
|
|
206
|
+
preview: "Preview"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `10 · d`, back on the card surface. It says sent rather than confirmed: the
|
|
211
|
+
* node accepted the transaction, and nothing here watches the chain for it.
|
|
212
|
+
*/
|
|
213
|
+
export const SlipReceipt = ({
|
|
214
|
+
claim,
|
|
215
|
+
derived,
|
|
216
|
+
explorer,
|
|
217
|
+
network,
|
|
218
|
+
transactionId
|
|
219
|
+
}: SlipReceiptProps): React.JSX.Element => {
|
|
220
|
+
const title = useId()
|
|
221
|
+
return (
|
|
222
|
+
<section className="slip-root slip-receipt" aria-labelledby={title}>
|
|
223
|
+
<div className="slip-receipt__head">
|
|
224
|
+
<span className="slip-receipt__tick" aria-hidden="true">
|
|
225
|
+
✓
|
|
226
|
+
</span>
|
|
227
|
+
<div className="slip-receipt__identity">
|
|
228
|
+
<h2 className="slip-receipt__title" id={title}>
|
|
229
|
+
{claim}
|
|
230
|
+
</h2>
|
|
231
|
+
<span className="slip-receipt__note">Sent to {networkNames[network]}</span>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="slip-receipt__ledger">
|
|
235
|
+
{ledgerOf(derived).map((row) => (
|
|
236
|
+
<div key={row.key} className="slip-receipt__row">
|
|
237
|
+
<span className="slip-receipt__label">{row.label}</span>
|
|
238
|
+
<span className="slip-receipt__amounts">
|
|
239
|
+
{row.identifier ?? undefined}
|
|
240
|
+
{row.amounts.map((amount, index) => (
|
|
241
|
+
<span key={index} className="slip-receipt__amount" data-tone={amount.tone}>
|
|
242
|
+
{amount.text}
|
|
243
|
+
</span>
|
|
244
|
+
))}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
<div className="slip-receipt__transaction">
|
|
250
|
+
<span className="slip-receipt__kicker">Transaction</span>
|
|
251
|
+
<span className="slip-receipt__id" title={transactionId}>
|
|
252
|
+
{shortId(transactionId)}
|
|
253
|
+
</span>
|
|
254
|
+
<a className="slip-receipt__link" href={explorer.url} target="_blank" rel="noreferrer">
|
|
255
|
+
View on {explorer.name}
|
|
256
|
+
</a>
|
|
257
|
+
</div>
|
|
258
|
+
<p className="slip-receipt__note slip-receipt__foot">
|
|
259
|
+
No account was created and nothing was stored. This page keeps none of it.
|
|
260
|
+
</p>
|
|
261
|
+
</section>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The form a linked action's `parameters` describe. It holds no state: what a
|
|
5
|
+
* person typed, and which errors are ripe to show, belong to the card.
|
|
6
|
+
*/
|
|
7
|
+
import type { Parameter, ParameterValues } from "@cardano-slips/core"
|
|
8
|
+
|
|
9
|
+
import { valueOf } from "./card.js"
|
|
10
|
+
|
|
11
|
+
/** A sentence about one field. `code` is the endpoint's, and is for us rather than for the person. */
|
|
12
|
+
export type FieldError = {
|
|
13
|
+
readonly message: string
|
|
14
|
+
readonly code?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ParameterFormProps = {
|
|
18
|
+
readonly parameters: ReadonlyArray<Parameter>
|
|
19
|
+
readonly values: ParameterValues
|
|
20
|
+
/** By parameter name. What is here is shown; deciding when is the card's job. */
|
|
21
|
+
readonly errors?: Readonly<Record<string, FieldError>>
|
|
22
|
+
readonly onChange: (name: string, value: string) => void
|
|
23
|
+
/** Prefixes every generated id, so two cards on one page do not collide. */
|
|
24
|
+
readonly idPrefix?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The bounds line. The spec requires it beside the field rather than only on
|
|
29
|
+
* failure, so a person can tell what will be accepted before they type.
|
|
30
|
+
*/
|
|
31
|
+
export const boundsOf = (parameter: Parameter): string | undefined => {
|
|
32
|
+
if (parameter.type === "select") {
|
|
33
|
+
const labels = parameter.options.map((option) => option.label)
|
|
34
|
+
if (labels.length > 3) return `${labels.length} options`
|
|
35
|
+
if (labels.length === 1) return labels[0]
|
|
36
|
+
return `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// On `text` the bounds count characters; on `number` they bound the value.
|
|
40
|
+
const unit = parameter.type === "text" ? " characters" : ""
|
|
41
|
+
if (parameter.min !== undefined && parameter.max !== undefined) {
|
|
42
|
+
return `Between ${parameter.min} and ${parameter.max}${unit}`
|
|
43
|
+
}
|
|
44
|
+
// The same words `checkValues` uses, so the bounds and the sentence a broken
|
|
45
|
+
// value produces read as one voice rather than two.
|
|
46
|
+
if (parameter.min !== undefined) return `At least ${parameter.min}${unit}`
|
|
47
|
+
if (parameter.max !== undefined) return `At most ${parameter.max}${unit}`
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const errorOf = (errors: Readonly<Record<string, FieldError>> | undefined, name: string): FieldError | undefined =>
|
|
52
|
+
errors !== undefined && Object.hasOwn(errors, name) ? errors[name] : undefined
|
|
53
|
+
|
|
54
|
+
type FieldProps = {
|
|
55
|
+
readonly parameter: Parameter
|
|
56
|
+
readonly value: string
|
|
57
|
+
readonly error: FieldError | undefined
|
|
58
|
+
readonly id: string
|
|
59
|
+
readonly onChange: (name: string, value: string) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Field = ({ parameter, value, error, id, onChange }: FieldProps): React.JSX.Element => {
|
|
63
|
+
const helpId = `${id}-help`
|
|
64
|
+
const invalid = error === undefined ? undefined : ""
|
|
65
|
+
const change = (next: string): void => onChange(parameter.name, next)
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="slip-field" data-invalid={invalid}>
|
|
69
|
+
<label className="slip-field__label" htmlFor={id}>
|
|
70
|
+
{parameter.label}
|
|
71
|
+
</label>
|
|
72
|
+
<div className="slip-field__control">
|
|
73
|
+
{parameter.type === "select" ? (
|
|
74
|
+
<select
|
|
75
|
+
id={id}
|
|
76
|
+
className="slip-field__input"
|
|
77
|
+
value={value}
|
|
78
|
+
aria-describedby={helpId}
|
|
79
|
+
aria-invalid={error === undefined ? undefined : true}
|
|
80
|
+
onChange={(event) => change(event.target.value)}
|
|
81
|
+
>
|
|
82
|
+
{parameter.required === true && value !== "" ? undefined : <option value="" />}
|
|
83
|
+
{parameter.options.map((option) => (
|
|
84
|
+
<option key={option.value} value={option.value}>
|
|
85
|
+
{option.label}
|
|
86
|
+
</option>
|
|
87
|
+
))}
|
|
88
|
+
</select>
|
|
89
|
+
) : (
|
|
90
|
+
<input
|
|
91
|
+
id={id}
|
|
92
|
+
className={
|
|
93
|
+
parameter.type === "number" ? "slip-field__input slip-field__input--numeral" : "slip-field__input"
|
|
94
|
+
}
|
|
95
|
+
type="text"
|
|
96
|
+
// Never `type="number"`: it hands back an empty string for anything
|
|
97
|
+
// the browser dislikes, which hides the very value `checkValues` is
|
|
98
|
+
// there to judge — and the person never learns what was wrong.
|
|
99
|
+
inputMode={parameter.type === "number" ? "decimal" : undefined}
|
|
100
|
+
value={value}
|
|
101
|
+
aria-describedby={helpId}
|
|
102
|
+
aria-invalid={error === undefined ? undefined : true}
|
|
103
|
+
onChange={(event) => change(event.target.value)}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
<div className="slip-field__help" id={helpId}>
|
|
108
|
+
<span className="slip-field__bounds">{error?.message ?? boundsOf(parameter)}</span>
|
|
109
|
+
{error?.code === undefined ? (
|
|
110
|
+
<span className="slip-field__note">{parameter.required === true ? "Required" : "Optional"}</span>
|
|
111
|
+
) : (
|
|
112
|
+
<span className="slip-field__code">{error.code}</span>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const ParameterForm = ({
|
|
120
|
+
parameters,
|
|
121
|
+
values,
|
|
122
|
+
errors,
|
|
123
|
+
onChange,
|
|
124
|
+
idPrefix = "slip"
|
|
125
|
+
}: ParameterFormProps): React.JSX.Element => (
|
|
126
|
+
<div className="slip-fields">
|
|
127
|
+
{parameters.map((parameter) => (
|
|
128
|
+
<Field
|
|
129
|
+
key={parameter.name}
|
|
130
|
+
parameter={parameter}
|
|
131
|
+
value={valueOf(values, parameter.name)}
|
|
132
|
+
error={errorOf(errors, parameter.name)}
|
|
133
|
+
id={`${idPrefix}-${parameter.name}`}
|
|
134
|
+
onChange={onChange}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The rendered face of a Slip link — `2 · Action card` on the design sheet.
|
|
5
|
+
* It renders what the endpoint declared and collects what the endpoint asked
|
|
6
|
+
* for; it makes no claim about the transaction, which is the effects panel's
|
|
7
|
+
* job and is checked against the bytes rather than against this card.
|
|
8
|
+
*
|
|
9
|
+
* The Slip is assumed to have been decoded and passed `checkTemplates`: a
|
|
10
|
+
* response with a placeholder naming no parameter is one a client never renders.
|
|
11
|
+
*/
|
|
12
|
+
import { checkValues, fillHref, type LinkedAction, type ParameterValues, type Slip } from "@cardano-slips/core"
|
|
13
|
+
import { Either } from "effect"
|
|
14
|
+
import { useState } from "react"
|
|
15
|
+
|
|
16
|
+
import { actionLabel, actionsOf, blockedLabel, closureOf, monogramOf, originOf, valuesFor } from "./card.js"
|
|
17
|
+
import { type FieldError, ParameterForm } from "./parameter-form.js"
|
|
18
|
+
|
|
19
|
+
/** What the person chose, ready to `POST`. */
|
|
20
|
+
export type SlipSubmission = {
|
|
21
|
+
readonly action: LinkedAction
|
|
22
|
+
/** Only what this action asked for. A caller is handed no answer it did not request. */
|
|
23
|
+
readonly values: ParameterValues
|
|
24
|
+
/** Absolute, with every placeholder percent-encoded, so no typed value can move the request. */
|
|
25
|
+
readonly href: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SlipCardProps = {
|
|
29
|
+
readonly slip: Slip
|
|
30
|
+
/**
|
|
31
|
+
* Where the metadata came from. Every `href` resolves against it and its host
|
|
32
|
+
* is the publisher the card names, so the card cannot be rendered without it.
|
|
33
|
+
*/
|
|
34
|
+
readonly discoveryUrl: string
|
|
35
|
+
readonly onSubmit?: (submission: SlipSubmission) => void
|
|
36
|
+
/**
|
|
37
|
+
* What the endpoint refused, by parameter name. The only failure that lands
|
|
38
|
+
* on a field instead of replacing the card, because there is something to fix.
|
|
39
|
+
* It is the caller's to clear, since only the caller knows which answer the
|
|
40
|
+
* endpoint was refusing.
|
|
41
|
+
*/
|
|
42
|
+
readonly rejected?: Readonly<Record<string, FieldError>>
|
|
43
|
+
/** True while a request is in flight: every button stops taking presses. */
|
|
44
|
+
readonly busy?: boolean
|
|
45
|
+
/** Prefixes generated ids, so two cards on one page do not collide. */
|
|
46
|
+
readonly idPrefix?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The line that is the whole point of the card, and the reason it is worth pressing. */
|
|
50
|
+
const promise = "You'll see the exact effects, checked against this card, before you sign."
|
|
51
|
+
|
|
52
|
+
/** Nothing to choose between means the one action is already the open one. */
|
|
53
|
+
const opened = (count: number): number | undefined => (count === 1 ? 0 : undefined)
|
|
54
|
+
|
|
55
|
+
export const SlipCard = ({
|
|
56
|
+
slip,
|
|
57
|
+
discoveryUrl,
|
|
58
|
+
onSubmit,
|
|
59
|
+
rejected,
|
|
60
|
+
busy = false,
|
|
61
|
+
idPrefix = "slip"
|
|
62
|
+
}: SlipCardProps): React.JSX.Element => {
|
|
63
|
+
const actions = actionsOf(slip, discoveryUrl)
|
|
64
|
+
|
|
65
|
+
const [values, setValues] = useState<ParameterValues>({})
|
|
66
|
+
// Errors stay quiet until a send is attempted: a card that opens red is
|
|
67
|
+
// scolding someone for not having typed yet.
|
|
68
|
+
const [attempted, setAttempted] = useState(false)
|
|
69
|
+
const [open, setOpen] = useState<number | undefined>(opened(actions.length))
|
|
70
|
+
const [failedIcon, setFailedIcon] = useState<string | undefined>(undefined)
|
|
71
|
+
|
|
72
|
+
// Resetting during render is React's own answer to a prop moving out from
|
|
73
|
+
// under the state derived from it. A second Slip must not open pre-filled
|
|
74
|
+
// with the first one's answers, and remembering to pass a `key` is not a
|
|
75
|
+
// thing to leave to whoever embeds this.
|
|
76
|
+
const [shown, setShown] = useState(discoveryUrl)
|
|
77
|
+
if (shown !== discoveryUrl) {
|
|
78
|
+
setShown(discoveryUrl)
|
|
79
|
+
setValues({})
|
|
80
|
+
setAttempted(false)
|
|
81
|
+
setOpen(opened(actions.length))
|
|
82
|
+
setFailedIcon(undefined)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const closed = slip.disabled === true
|
|
86
|
+
const parameters = (open === undefined ? undefined : actions[open]?.parameters) ?? []
|
|
87
|
+
const issues = checkValues(parameters, values)
|
|
88
|
+
const blocked = attempted && issues.length > 0
|
|
89
|
+
|
|
90
|
+
// `??=` would be wrong here: a parameter may be called `constructor`, and a
|
|
91
|
+
// plain object already answers to that with something that is not nullish.
|
|
92
|
+
const errors: Record<string, FieldError> = {}
|
|
93
|
+
const note = (name: string, error: FieldError): void => {
|
|
94
|
+
if (!Object.hasOwn(errors, name)) errors[name] = error
|
|
95
|
+
}
|
|
96
|
+
if (attempted) for (const issue of issues) note(issue.name, { message: issue.message })
|
|
97
|
+
for (const [name, error] of Object.entries(rejected ?? {})) note(name, error)
|
|
98
|
+
|
|
99
|
+
const press = (index: number): void => {
|
|
100
|
+
const action = actions[index]
|
|
101
|
+
|
|
102
|
+
// The spec: an action without parameters is submitted as soon as it is
|
|
103
|
+
// chosen. One that has them opens its form instead of sending blind.
|
|
104
|
+
if (action.parameters !== undefined && index !== open) {
|
|
105
|
+
setOpen(index)
|
|
106
|
+
setAttempted(false)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const target = fillHref(action, values, discoveryUrl)
|
|
111
|
+
if (Either.isLeft(target)) {
|
|
112
|
+
setAttempted(true)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
onSubmit?.({ action, values: valuesFor(action, values), href: target.right })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const monogram = monogramOf(discoveryUrl)
|
|
119
|
+
const origin = originOf(discoveryUrl)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="slip-root slip-card" data-closed={closed ? "" : undefined} data-busy={busy ? "" : undefined}>
|
|
123
|
+
<div className="slip-card__head">
|
|
124
|
+
<div className="slip-card__icon">
|
|
125
|
+
{failedIcon !== slip.icon ? (
|
|
126
|
+
<img className="slip-card__image" src={slip.icon} alt="" onError={() => setFailedIcon(slip.icon)} />
|
|
127
|
+
) : monogram === undefined ? (
|
|
128
|
+
<span className="slip-card__pattern" />
|
|
129
|
+
) : (
|
|
130
|
+
<span className="slip-card__monogram">{monogram}</span>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
<div className="slip-card__identity">
|
|
134
|
+
<h2 className="slip-card__title">{slip.title}</h2>
|
|
135
|
+
{origin === undefined ? undefined : <span className="slip-card__origin">{origin}</span>}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Plain text by the spec: a description carries no markup, so none is parsed. */}
|
|
140
|
+
<p className="slip-card__description">{slip.description}</p>
|
|
141
|
+
|
|
142
|
+
{parameters.length === 0 ? undefined : (
|
|
143
|
+
<ParameterForm
|
|
144
|
+
parameters={parameters}
|
|
145
|
+
values={values}
|
|
146
|
+
errors={errors}
|
|
147
|
+
idPrefix={idPrefix}
|
|
148
|
+
onChange={(name, value) => setValues((held) => ({ ...held, [name]: value }))}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
<div className="slip-card__actions">
|
|
153
|
+
{actions.map((action, index) => {
|
|
154
|
+
const closure = closureOf(slip, action)
|
|
155
|
+
const stopped = index === (open ?? 0) && blocked
|
|
156
|
+
return (
|
|
157
|
+
<button
|
|
158
|
+
key={index}
|
|
159
|
+
type="button"
|
|
160
|
+
className="slip-card__action"
|
|
161
|
+
data-primary={index === (open ?? 0) ? "" : undefined}
|
|
162
|
+
disabled={busy || closure !== undefined || stopped}
|
|
163
|
+
onClick={() => press(index)}
|
|
164
|
+
>
|
|
165
|
+
{stopped ? blockedLabel(issues) : actionLabel(slip, action, values)}
|
|
166
|
+
</button>
|
|
167
|
+
)
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/*
|
|
172
|
+
A closed Slip states its reason once, however many actions it carries;
|
|
173
|
+
an open one states the reason of each option that is closed on its own.
|
|
174
|
+
Neither hides the action: a shared link is seen by many people at once.
|
|
175
|
+
*/}
|
|
176
|
+
{closed ? (
|
|
177
|
+
<p className="slip-card__reason">{slip.reason?.message}</p>
|
|
178
|
+
) : (
|
|
179
|
+
actions.map((action, index) =>
|
|
180
|
+
action.disabled === true && action.reason !== undefined ? (
|
|
181
|
+
<p className="slip-card__reason" key={index}>
|
|
182
|
+
{action.reason.message}
|
|
183
|
+
</p>
|
|
184
|
+
) : undefined
|
|
185
|
+
)
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{closed ? undefined : <p className="slip-card__note">{promise}</p>}
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The same box and the same rhythm while the metadata is in flight, so nothing shifts when it lands. */
|
|
194
|
+
export const SlipCardSkeleton = (): React.JSX.Element => (
|
|
195
|
+
<div className="slip-root slip-card slip-card--loading" role="status" aria-busy="true">
|
|
196
|
+
<span className="slip-hidden">Loading this Slip</span>
|
|
197
|
+
<div className="slip-card__head">
|
|
198
|
+
<span className="slip-skeleton slip-skeleton--icon" />
|
|
199
|
+
<div className="slip-card__identity">
|
|
200
|
+
<span className="slip-skeleton slip-skeleton--title" />
|
|
201
|
+
<span className="slip-skeleton slip-skeleton--origin" />
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="slip-skeleton__lines">
|
|
205
|
+
<span className="slip-skeleton slip-skeleton--line" />
|
|
206
|
+
<span className="slip-skeleton slip-skeleton--line slip-skeleton--short" />
|
|
207
|
+
</div>
|
|
208
|
+
<span className="slip-skeleton slip-skeleton--action" />
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
export type SlipCardErrorProps = {
|
|
213
|
+
/** The endpoint that did not answer, in full: the person is being asked to judge it. */
|
|
214
|
+
readonly url: string
|
|
215
|
+
readonly message: string
|
|
216
|
+
readonly onRetry?: () => void
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The endpoint did not answer, or answered with something that is not a Slip.
|
|
221
|
+
* This replaces the card rather than landing on a field, because there is
|
|
222
|
+
* nothing here for the person to fix.
|
|
223
|
+
*/
|
|
224
|
+
export const SlipCardError = ({ url, message, onRetry }: SlipCardErrorProps): React.JSX.Element => (
|
|
225
|
+
<div className="slip-root slip-card slip-card--failed" role="alert">
|
|
226
|
+
<div className="slip-card__head">
|
|
227
|
+
<span className="slip-card__alarm" aria-hidden="true">
|
|
228
|
+
!
|
|
229
|
+
</span>
|
|
230
|
+
<div className="slip-card__identity">
|
|
231
|
+
<h2 className="slip-card__title">This Slip didn't load</h2>
|
|
232
|
+
<span className="slip-card__origin">{url}</span>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<p className="slip-card__description">{message}</p>
|
|
236
|
+
{onRetry === undefined ? undefined : (
|
|
237
|
+
<div className="slip-card__actions">
|
|
238
|
+
<button type="button" className="slip-card__action" onClick={onRetry}>
|
|
239
|
+
Try again
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)
|