@phala/pay 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/CHANGELOG.md +25 -0
- package/LICENSE +202 -0
- package/README.md +142 -0
- package/dist/chains.d.ts +7 -0
- package/dist/chains.d.ts.map +1 -0
- package/dist/chains.js +16 -0
- package/dist/chains.js.map +1 -0
- package/dist/checkout.d.ts +63 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +146 -0
- package/dist/checkout.js.map +1 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +35 -0
- package/dist/client.js.map +1 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +37 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/payment.d.ts +19 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +34 -0
- package/dist/payment.js.map +1 -0
- package/dist/quote.d.ts +36 -0
- package/dist/quote.d.ts.map +1 -0
- package/dist/quote.js +59 -0
- package/dist/quote.js.map +1 -0
- package/dist/react/Checkout.d.ts +21 -0
- package/dist/react/Checkout.d.ts.map +1 -0
- package/dist/react/Checkout.js +168 -0
- package/dist/react/Checkout.js.map +1 -0
- package/dist/react/QrCode.d.ts +8 -0
- package/dist/react/QrCode.d.ts.map +1 -0
- package/dist/react/QrCode.js +20 -0
- package/dist/react/QrCode.js.map +1 -0
- package/dist/react/appearance.d.ts +24 -0
- package/dist/react/appearance.d.ts.map +1 -0
- package/dist/react/appearance.js +95 -0
- package/dist/react/appearance.js.map +1 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useCheckout.d.ts +13 -0
- package/dist/react/useCheckout.d.ts.map +1 -0
- package/dist/react/useCheckout.js +29 -0
- package/dist/react/useCheckout.js.map +1 -0
- package/dist/wallet.d.ts +42 -0
- package/dist/wallet.d.ts.map +1 -0
- package/dist/wallet.js +135 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +93 -0
- package/src/chains.ts +20 -0
- package/src/checkout.ts +225 -0
- package/src/client.ts +54 -0
- package/src/format.ts +41 -0
- package/src/index.ts +27 -0
- package/src/payment.ts +52 -0
- package/src/quote.ts +96 -0
- package/src/react/Checkout.tsx +409 -0
- package/src/react/QrCode.tsx +39 -0
- package/src/react/appearance.ts +114 -0
- package/src/react/index.ts +5 -0
- package/src/react/useCheckout.ts +48 -0
- package/src/wallet.ts +175 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
|
|
4
|
+
import type { Hash } from "viem";
|
|
5
|
+
import type { CheckoutStatus } from "../checkout.js";
|
|
6
|
+
import { networkName, transactionUrl } from "../chains.js";
|
|
7
|
+
import { formatAmount, formatCountdown, formatTokenAmount, tokenAmount } from "../format.js";
|
|
8
|
+
import { quoteTransfer } from "../payment.js";
|
|
9
|
+
import type { ClientQuote } from "../quote.js";
|
|
10
|
+
import { WalletError, payWithWallet, watchWallets, type Wallet } from "../wallet.js";
|
|
11
|
+
import { STYLES, appearanceStyle, type Appearance } from "./appearance.js";
|
|
12
|
+
import { QrCode } from "./QrCode.js";
|
|
13
|
+
import { useCheckout } from "./useCheckout.js";
|
|
14
|
+
|
|
15
|
+
export interface CheckoutProps {
|
|
16
|
+
/** The quote's `client_secret`, from your backend's `POST /v1/quotes`. */
|
|
17
|
+
clientSecret: string;
|
|
18
|
+
/** The service origin, for example `https://topup.example.com`. */
|
|
19
|
+
apiBase: string;
|
|
20
|
+
/** Called once when the payment is credited. Fulfil from the `deposit.credited` webhook, not here. */
|
|
21
|
+
onSuccess?: (quote: ClientQuote) => void;
|
|
22
|
+
/** Called once when the quote expires or is canceled without a payment. */
|
|
23
|
+
onExpire?: (quote: ClientQuote) => void;
|
|
24
|
+
appearance?: Appearance;
|
|
25
|
+
/** Milliseconds between status reads; default 3000. */
|
|
26
|
+
pollInterval?: number;
|
|
27
|
+
className?: string;
|
|
28
|
+
/** The wallet button's label; default "Pay with crypto". */
|
|
29
|
+
buttonText?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Method = "wallet" | "qr" | "manual";
|
|
33
|
+
|
|
34
|
+
const METHODS: { id: Method; label: string }[] = [
|
|
35
|
+
{ id: "wallet", label: "Browser wallet" },
|
|
36
|
+
{ id: "qr", label: "QR code" },
|
|
37
|
+
{ id: "manual", label: "Send manually" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** A checkout for one quote: pay from a browser wallet, by QR code, or manually, with live status. */
|
|
41
|
+
export function Checkout({
|
|
42
|
+
clientSecret,
|
|
43
|
+
apiBase,
|
|
44
|
+
onSuccess,
|
|
45
|
+
onExpire,
|
|
46
|
+
appearance,
|
|
47
|
+
pollInterval,
|
|
48
|
+
className,
|
|
49
|
+
buttonText = "Pay with crypto",
|
|
50
|
+
}: CheckoutProps) {
|
|
51
|
+
const { status, quote, error, refresh } = useCheckout({
|
|
52
|
+
clientSecret,
|
|
53
|
+
apiBase,
|
|
54
|
+
...(pollInterval === undefined ? {} : { pollInterval }),
|
|
55
|
+
});
|
|
56
|
+
const [txHash, setTxHash] = useState<Hash | null>(null);
|
|
57
|
+
const now = useNow(status === "waiting");
|
|
58
|
+
|
|
59
|
+
const callbacks = useRef({ onSuccess, onExpire });
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
callbacks.current = { onSuccess, onExpire };
|
|
62
|
+
});
|
|
63
|
+
const notified = useRef<string | null>(null);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (quote === null || notified.current === quote.id) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (status === "credited") {
|
|
69
|
+
notified.current = quote.id;
|
|
70
|
+
callbacks.current.onSuccess?.(quote);
|
|
71
|
+
} else if (status === "expired" || status === "canceled") {
|
|
72
|
+
notified.current = quote.id;
|
|
73
|
+
callbacks.current.onExpire?.(quote);
|
|
74
|
+
}
|
|
75
|
+
}, [status, quote]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className={className === undefined ? "pp-root" : `pp-root ${className}`}
|
|
80
|
+
data-theme={appearance?.theme ?? "light"}
|
|
81
|
+
style={appearanceStyle(appearance)}
|
|
82
|
+
>
|
|
83
|
+
<style>{STYLES}</style>
|
|
84
|
+
{quote !== null && (
|
|
85
|
+
<>
|
|
86
|
+
<p className="pp-amount">
|
|
87
|
+
{formatTokenAmount(quote)} {quote.asset.toUpperCase()}
|
|
88
|
+
</p>
|
|
89
|
+
<p className="pp-subtitle">
|
|
90
|
+
{formatAmount(quote)} top-up · {networkName(quote.chain_id)}
|
|
91
|
+
</p>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
<StatusLine status={status} quote={quote} now={now} reconnecting={error !== null} />
|
|
95
|
+
{txHash !== null && quote !== null && <Transaction hash={txHash} chainId={quote.chain_id} />}
|
|
96
|
+
{status === "waiting" && quote !== null && (
|
|
97
|
+
<PaymentOptions
|
|
98
|
+
quote={quote}
|
|
99
|
+
buttonText={buttonText}
|
|
100
|
+
now={now}
|
|
101
|
+
onSent={(hash) => {
|
|
102
|
+
setTxHash(hash);
|
|
103
|
+
refresh();
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function StatusLine({
|
|
112
|
+
status,
|
|
113
|
+
quote,
|
|
114
|
+
now,
|
|
115
|
+
reconnecting,
|
|
116
|
+
}: {
|
|
117
|
+
status: CheckoutStatus;
|
|
118
|
+
quote: ClientQuote | null;
|
|
119
|
+
now: number;
|
|
120
|
+
reconnecting: boolean;
|
|
121
|
+
}) {
|
|
122
|
+
const tone =
|
|
123
|
+
status === "credited"
|
|
124
|
+
? "success"
|
|
125
|
+
: ["rejected", "expired", "canceled", "error"].includes(status)
|
|
126
|
+
? "danger"
|
|
127
|
+
: "neutral";
|
|
128
|
+
return (
|
|
129
|
+
<div className="pp-status" data-tone={tone}>
|
|
130
|
+
<span role="status" aria-live="polite">
|
|
131
|
+
{statusMessage(status, quote)}
|
|
132
|
+
{reconnecting && status !== "error" ? " (reconnecting…)" : ""}
|
|
133
|
+
</span>
|
|
134
|
+
{status === "waiting" && quote !== null && (
|
|
135
|
+
<span className="pp-countdown" aria-label="Time left to pay">
|
|
136
|
+
{formatCountdown(quote.expires_at, now)}
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function statusMessage(status: CheckoutStatus, quote: ClientQuote | null): string {
|
|
144
|
+
switch (status) {
|
|
145
|
+
case "loading":
|
|
146
|
+
return "Loading payment details…";
|
|
147
|
+
case "waiting":
|
|
148
|
+
return "Waiting for your payment";
|
|
149
|
+
case "seen":
|
|
150
|
+
return quote?.confirmations == null
|
|
151
|
+
? "Payment received, waiting for confirmations"
|
|
152
|
+
: `Payment received, ${quote.confirmations} confirmation${quote.confirmations === 1 ? "" : "s"}`;
|
|
153
|
+
case "confirming":
|
|
154
|
+
return "Payment confirmed on chain, crediting…";
|
|
155
|
+
case "credited":
|
|
156
|
+
return quote === null ? "Payment credited" : `Payment credited: ${formatAmount(quote)}`;
|
|
157
|
+
case "rejected":
|
|
158
|
+
return "This payment cannot be credited. Contact support with your transaction.";
|
|
159
|
+
case "expired":
|
|
160
|
+
return "This quote has expired. Do not send funds to this address; start a new top-up.";
|
|
161
|
+
case "canceled":
|
|
162
|
+
return "This quote was canceled. Do not send funds to this address.";
|
|
163
|
+
case "error":
|
|
164
|
+
return "This payment link is not valid. Start a new top-up.";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function Transaction({ hash, chainId }: { hash: Hash; chainId: number }) {
|
|
169
|
+
const url = transactionUrl(chainId, hash);
|
|
170
|
+
const short = `${hash.slice(0, 10)}…${hash.slice(-8)}`;
|
|
171
|
+
return (
|
|
172
|
+
<p className="pp-tx">
|
|
173
|
+
Transaction sent:{" "}
|
|
174
|
+
{url === undefined ? (
|
|
175
|
+
<span className="pp-value" title={hash}>
|
|
176
|
+
{short}
|
|
177
|
+
</span>
|
|
178
|
+
) : (
|
|
179
|
+
<a href={url} target="_blank" rel="noreferrer" title={hash}>
|
|
180
|
+
{short}
|
|
181
|
+
</a>
|
|
182
|
+
)}
|
|
183
|
+
</p>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function PaymentOptions({
|
|
188
|
+
quote,
|
|
189
|
+
now,
|
|
190
|
+
buttonText,
|
|
191
|
+
onSent,
|
|
192
|
+
}: {
|
|
193
|
+
quote: ClientQuote;
|
|
194
|
+
now: number;
|
|
195
|
+
buttonText: string;
|
|
196
|
+
onSent: (hash: Hash) => void;
|
|
197
|
+
}) {
|
|
198
|
+
const [method, setMethod] = useState<Method>("wallet");
|
|
199
|
+
const id = useId();
|
|
200
|
+
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
201
|
+
const amount = `${formatTokenAmount(quote)} ${quote.asset.toUpperCase()}`;
|
|
202
|
+
|
|
203
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
204
|
+
const index = METHODS.findIndex((m) => m.id === method);
|
|
205
|
+
const next =
|
|
206
|
+
event.key === "ArrowRight"
|
|
207
|
+
? (index + 1) % METHODS.length
|
|
208
|
+
: event.key === "ArrowLeft"
|
|
209
|
+
? (index - 1 + METHODS.length) % METHODS.length
|
|
210
|
+
: event.key === "Home"
|
|
211
|
+
? 0
|
|
212
|
+
: event.key === "End"
|
|
213
|
+
? METHODS.length - 1
|
|
214
|
+
: null;
|
|
215
|
+
const target = next === null ? undefined : METHODS[next];
|
|
216
|
+
if (next !== null && target !== undefined) {
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
setMethod(target.id);
|
|
219
|
+
tabs.current[next]?.focus();
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<>
|
|
225
|
+
<p className="pp-notice">
|
|
226
|
+
Send <strong>exactly {amount}</strong> on {networkName(quote.chain_id)} in one transfer
|
|
227
|
+
before the timer ends. A different amount, or a payment after expiry, is credited at the
|
|
228
|
+
market price instead of this quote. Exchanges may deduct a withdrawal fee, so the amount
|
|
229
|
+
that arrives must be exact.
|
|
230
|
+
</p>
|
|
231
|
+
<div className="pp-tabs" role="tablist" aria-label="Payment method" onKeyDown={onKeyDown}>
|
|
232
|
+
{METHODS.map((m, index) => (
|
|
233
|
+
<button
|
|
234
|
+
key={m.id}
|
|
235
|
+
ref={(element) => {
|
|
236
|
+
tabs.current[index] = element;
|
|
237
|
+
}}
|
|
238
|
+
type="button"
|
|
239
|
+
role="tab"
|
|
240
|
+
className="pp-tab"
|
|
241
|
+
id={`${id}-tab-${m.id}`}
|
|
242
|
+
aria-selected={method === m.id}
|
|
243
|
+
aria-controls={`${id}-panel-${m.id}`}
|
|
244
|
+
tabIndex={method === m.id ? 0 : -1}
|
|
245
|
+
onClick={() => setMethod(m.id)}
|
|
246
|
+
>
|
|
247
|
+
{m.label}
|
|
248
|
+
</button>
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
<div
|
|
252
|
+
role="tabpanel"
|
|
253
|
+
id={`${id}-panel-${method}`}
|
|
254
|
+
aria-labelledby={`${id}-tab-${method}`}
|
|
255
|
+
tabIndex={0}
|
|
256
|
+
>
|
|
257
|
+
{method === "wallet" && <WalletPanel quote={quote} buttonText={buttonText} onSent={onSent} />}
|
|
258
|
+
{method === "qr" && (
|
|
259
|
+
<div className="pp-qr-panel">
|
|
260
|
+
<QrCode value={quote.payment_uri} label={`Payment request for ${amount}`} />
|
|
261
|
+
<p className="pp-message">
|
|
262
|
+
Scan with a wallet app that reads payment links, and check that it shows {amount} on{" "}
|
|
263
|
+
{networkName(quote.chain_id)} before you confirm.
|
|
264
|
+
</p>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
{method === "manual" && <ManualPanel quote={quote} now={now} />}
|
|
268
|
+
</div>
|
|
269
|
+
</>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
type WalletStep =
|
|
274
|
+
| { kind: "idle" }
|
|
275
|
+
| { kind: "pending"; wallet: string }
|
|
276
|
+
| { kind: "failed"; message: string };
|
|
277
|
+
|
|
278
|
+
function WalletPanel({
|
|
279
|
+
quote,
|
|
280
|
+
buttonText,
|
|
281
|
+
onSent,
|
|
282
|
+
}: {
|
|
283
|
+
quote: ClientQuote;
|
|
284
|
+
buttonText: string;
|
|
285
|
+
onSent: (hash: Hash) => void;
|
|
286
|
+
}) {
|
|
287
|
+
const [wallets, setWallets] = useState<Wallet[]>([]);
|
|
288
|
+
const [step, setStep] = useState<WalletStep>({ kind: "idle" });
|
|
289
|
+
useEffect(() => watchWallets(setWallets), []);
|
|
290
|
+
|
|
291
|
+
const pay = async (wallet: Wallet) => {
|
|
292
|
+
setStep({ kind: "pending", wallet: wallet.info.name });
|
|
293
|
+
try {
|
|
294
|
+
onSent(await payWithWallet(wallet.provider, quote));
|
|
295
|
+
setStep({ kind: "idle" });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
setStep({
|
|
298
|
+
kind: "failed",
|
|
299
|
+
message: error instanceof WalletError ? error.message : "The payment could not be sent",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (wallets.length === 0) {
|
|
305
|
+
return (
|
|
306
|
+
<p className="pp-message">
|
|
307
|
+
No browser wallet found. Scan the QR code with a mobile wallet, or send the payment
|
|
308
|
+
manually.
|
|
309
|
+
</p>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return (
|
|
313
|
+
<div className="pp-wallets">
|
|
314
|
+
{wallets.map((wallet) => (
|
|
315
|
+
<button
|
|
316
|
+
key={wallet.info.uuid}
|
|
317
|
+
type="button"
|
|
318
|
+
className="pp-button"
|
|
319
|
+
disabled={step.kind === "pending"}
|
|
320
|
+
onClick={() => void pay(wallet)}
|
|
321
|
+
aria-label={`${buttonText} (${wallet.info.name})`}
|
|
322
|
+
>
|
|
323
|
+
{wallet.info.icon !== "" && <img src={wallet.info.icon} alt="" />}
|
|
324
|
+
<span>{buttonText}</span>
|
|
325
|
+
<span className="pp-wallet-name">{wallet.info.name}</span>
|
|
326
|
+
</button>
|
|
327
|
+
))}
|
|
328
|
+
<p
|
|
329
|
+
className="pp-message"
|
|
330
|
+
data-tone={step.kind === "failed" ? "danger" : undefined}
|
|
331
|
+
aria-live="polite"
|
|
332
|
+
>
|
|
333
|
+
{step.kind === "pending" && `Confirm the payment in ${step.wallet}…`}
|
|
334
|
+
{step.kind === "failed" && step.message}
|
|
335
|
+
</p>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function ManualPanel({ quote, now }: { quote: ClientQuote; now: number }) {
|
|
341
|
+
const token = quoteTransfer(quote).token;
|
|
342
|
+
return (
|
|
343
|
+
<dl className="pp-fields">
|
|
344
|
+
<Field label="Network" value={`${networkName(quote.chain_id)} (chain ID ${quote.chain_id})`} />
|
|
345
|
+
<Field label={`Token (${quote.asset.toUpperCase()}) contract`} value={token} copy />
|
|
346
|
+
<Field label="Send to address" value={quote.address} copy />
|
|
347
|
+
<Field label="Exact amount" value={formatTokenAmount(quote)} copy={tokenAmount(quote)} />
|
|
348
|
+
<Field label="Time left" value={formatCountdown(quote.expires_at, now)} />
|
|
349
|
+
</dl>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** `copy` adds a copy button, copying `value`, or the given string when it differs from it. */
|
|
354
|
+
function Field({
|
|
355
|
+
label,
|
|
356
|
+
value,
|
|
357
|
+
copy = false,
|
|
358
|
+
}: {
|
|
359
|
+
label: string;
|
|
360
|
+
value: string;
|
|
361
|
+
copy?: boolean | string;
|
|
362
|
+
}) {
|
|
363
|
+
return (
|
|
364
|
+
<div className="pp-field">
|
|
365
|
+
<dt>{label}</dt>
|
|
366
|
+
<dd>
|
|
367
|
+
<span className={copy === false ? undefined : "pp-value"}>{value}</span>
|
|
368
|
+
{copy !== false && (
|
|
369
|
+
<CopyButton value={typeof copy === "string" ? copy : value} label={label} />
|
|
370
|
+
)}
|
|
371
|
+
</dd>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function CopyButton({ value, label }: { value: string; label: string }) {
|
|
377
|
+
const [copied, setCopied] = useState<boolean | null>(null);
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (copied === null) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const timer = setTimeout(() => setCopied(null), 2000);
|
|
383
|
+
return () => clearTimeout(timer);
|
|
384
|
+
}, [copied]);
|
|
385
|
+
const onClick = () => {
|
|
386
|
+
navigator.clipboard.writeText(value).then(
|
|
387
|
+
() => setCopied(true),
|
|
388
|
+
() => setCopied(false),
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
return (
|
|
392
|
+
<button type="button" className="pp-copy" onClick={onClick} aria-label={`Copy ${label}`}>
|
|
393
|
+
<span aria-live="polite">{copied === null ? "Copy" : copied ? "Copied" : "Copy failed"}</span>
|
|
394
|
+
</button>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The current time in milliseconds, updated every second while `active`. */
|
|
399
|
+
function useNow(active: boolean): number {
|
|
400
|
+
const [now, setNow] = useState(() => Date.now());
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
if (!active) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
406
|
+
return () => clearInterval(timer);
|
|
407
|
+
}, [active]);
|
|
408
|
+
return now;
|
|
409
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { encode } from "uqr";
|
|
3
|
+
|
|
4
|
+
export interface QrCodeProps {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A QR code as an SVG path, one unit per module. */
|
|
11
|
+
export function QrCode({ value, label, size = 208 }: QrCodeProps) {
|
|
12
|
+
const { path, width } = useMemo(() => {
|
|
13
|
+
const { data, size: modules } = encode(value, { ecc: "M", border: 2 });
|
|
14
|
+
let d = "";
|
|
15
|
+
data.forEach((row, y) => {
|
|
16
|
+
row.forEach((dark, x) => {
|
|
17
|
+
if (dark) {
|
|
18
|
+
d += `M${x} ${y}h1v1h-1z`;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
return { path: d, width: modules };
|
|
23
|
+
}, [value]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<svg
|
|
27
|
+
className="pp-qr"
|
|
28
|
+
role="img"
|
|
29
|
+
aria-label={label}
|
|
30
|
+
width={size}
|
|
31
|
+
height={size}
|
|
32
|
+
viewBox={`0 0 ${width} ${width}`}
|
|
33
|
+
shapeRendering="crispEdges"
|
|
34
|
+
>
|
|
35
|
+
<rect width={width} height={width} fill="#fff" />
|
|
36
|
+
<path d={path} fill="#000" />
|
|
37
|
+
</svg>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/** Theming in the spirit of Stripe's Appearance API: a base theme plus a few variables. */
|
|
4
|
+
export interface Appearance {
|
|
5
|
+
theme?: "light" | "dark";
|
|
6
|
+
variables?: {
|
|
7
|
+
colorPrimary?: string;
|
|
8
|
+
colorBackground?: string;
|
|
9
|
+
colorText?: string;
|
|
10
|
+
colorTextSecondary?: string;
|
|
11
|
+
colorBorder?: string;
|
|
12
|
+
colorDanger?: string;
|
|
13
|
+
colorSuccess?: string;
|
|
14
|
+
fontFamily?: string;
|
|
15
|
+
borderRadius?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const VARIABLES: Record<keyof NonNullable<Appearance["variables"]>, string> = {
|
|
20
|
+
colorPrimary: "--pp-color-primary",
|
|
21
|
+
colorBackground: "--pp-color-background",
|
|
22
|
+
colorText: "--pp-color-text",
|
|
23
|
+
colorTextSecondary: "--pp-color-text-secondary",
|
|
24
|
+
colorBorder: "--pp-color-border",
|
|
25
|
+
colorDanger: "--pp-color-danger",
|
|
26
|
+
colorSuccess: "--pp-color-success",
|
|
27
|
+
fontFamily: "--pp-font-family",
|
|
28
|
+
borderRadius: "--pp-border-radius",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The root element's custom properties for `appearance.variables`. */
|
|
32
|
+
export function appearanceStyle(appearance: Appearance | undefined): CSSProperties {
|
|
33
|
+
const variables = appearance?.variables ?? {};
|
|
34
|
+
const style: Record<string, string> = {};
|
|
35
|
+
for (const [name, property] of Object.entries(VARIABLES)) {
|
|
36
|
+
const value = variables[name as keyof typeof VARIABLES];
|
|
37
|
+
if (value !== undefined) {
|
|
38
|
+
style[property] = value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return style;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every rule is scoped under `.pp-root`; the variables can also be set from the page's own CSS on
|
|
46
|
+
* `.pp-root`.
|
|
47
|
+
*/
|
|
48
|
+
export const STYLES = `
|
|
49
|
+
.pp-root {
|
|
50
|
+
--pp-color-primary: #0f62fe;
|
|
51
|
+
--pp-color-background: #ffffff;
|
|
52
|
+
--pp-color-text: #1a1a1a;
|
|
53
|
+
--pp-color-text-secondary: #5c5f66;
|
|
54
|
+
--pp-color-border: #d9dce1;
|
|
55
|
+
--pp-color-danger: #c62828;
|
|
56
|
+
--pp-color-success: #1b7f3b;
|
|
57
|
+
--pp-font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
58
|
+
--pp-border-radius: 8px;
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
max-width: 440px;
|
|
61
|
+
padding: 20px;
|
|
62
|
+
border: 1px solid var(--pp-color-border);
|
|
63
|
+
border-radius: var(--pp-border-radius);
|
|
64
|
+
background: var(--pp-color-background);
|
|
65
|
+
color: var(--pp-color-text);
|
|
66
|
+
font-family: var(--pp-font-family);
|
|
67
|
+
font-size: 14px;
|
|
68
|
+
line-height: 1.45;
|
|
69
|
+
}
|
|
70
|
+
.pp-root[data-theme="dark"] {
|
|
71
|
+
--pp-color-primary: #78a9ff;
|
|
72
|
+
--pp-color-background: #161616;
|
|
73
|
+
--pp-color-text: #f4f4f4;
|
|
74
|
+
--pp-color-text-secondary: #a8a8a8;
|
|
75
|
+
--pp-color-border: #393939;
|
|
76
|
+
--pp-color-danger: #ff8389;
|
|
77
|
+
--pp-color-success: #42be65;
|
|
78
|
+
}
|
|
79
|
+
.pp-root *, .pp-root *::before, .pp-root *::after { box-sizing: inherit; }
|
|
80
|
+
.pp-amount { margin: 0; font-size: 22px; font-weight: 600; }
|
|
81
|
+
.pp-subtitle { margin: 2px 0 16px; color: var(--pp-color-text-secondary); }
|
|
82
|
+
.pp-status { display: flex; gap: 8px; align-items: baseline; justify-content: space-between;
|
|
83
|
+
margin-bottom: 12px; padding: 10px 12px; border-radius: var(--pp-border-radius);
|
|
84
|
+
border: 1px solid var(--pp-color-border); }
|
|
85
|
+
.pp-status[data-tone="success"] { border-color: var(--pp-color-success); color: var(--pp-color-success); }
|
|
86
|
+
.pp-status[data-tone="danger"] { border-color: var(--pp-color-danger); color: var(--pp-color-danger); }
|
|
87
|
+
.pp-countdown { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
88
|
+
.pp-notice { margin: 0 0 16px; color: var(--pp-color-text-secondary); font-size: 13px; }
|
|
89
|
+
.pp-notice strong { color: var(--pp-color-text); }
|
|
90
|
+
.pp-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--pp-color-border); margin-bottom: 16px; }
|
|
91
|
+
.pp-tab { appearance: none; border: 0; border-bottom: 2px solid transparent; background: none;
|
|
92
|
+
color: var(--pp-color-text-secondary); font: inherit; padding: 8px 10px; cursor: pointer; }
|
|
93
|
+
.pp-tab[aria-selected="true"] { color: var(--pp-color-text); border-bottom-color: var(--pp-color-primary); font-weight: 600; }
|
|
94
|
+
.pp-root button:focus-visible, .pp-root a:focus-visible { outline: 2px solid var(--pp-color-primary); outline-offset: 2px; }
|
|
95
|
+
.pp-wallets { display: grid; gap: 8px; }
|
|
96
|
+
.pp-button { display: flex; gap: 10px; align-items: center; justify-content: center; width: 100%;
|
|
97
|
+
appearance: none; border: 1px solid var(--pp-color-primary); border-radius: var(--pp-border-radius);
|
|
98
|
+
background: var(--pp-color-primary); color: #fff; font: inherit; font-weight: 600; padding: 10px 14px; cursor: pointer; }
|
|
99
|
+
.pp-button:disabled { opacity: 0.6; cursor: progress; }
|
|
100
|
+
.pp-button img { width: 20px; height: 20px; }
|
|
101
|
+
.pp-wallet-name { font-weight: 400; opacity: 0.85; }
|
|
102
|
+
.pp-message { margin: 10px 0 0; color: var(--pp-color-text-secondary); }
|
|
103
|
+
.pp-message[data-tone="danger"] { color: var(--pp-color-danger); }
|
|
104
|
+
.pp-qr-panel { display: grid; justify-items: center; gap: 10px; text-align: center; }
|
|
105
|
+
.pp-qr { border-radius: 4px; }
|
|
106
|
+
.pp-fields { display: grid; gap: 10px; margin: 0; }
|
|
107
|
+
.pp-field dt { color: var(--pp-color-text-secondary); font-size: 12px; }
|
|
108
|
+
.pp-field dd { display: flex; gap: 8px; align-items: center; margin: 2px 0 0; }
|
|
109
|
+
.pp-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
|
|
110
|
+
.pp-copy { flex: none; appearance: none; border: 1px solid var(--pp-color-border); border-radius: 6px;
|
|
111
|
+
background: none; color: var(--pp-color-text); font: inherit; font-size: 12px; padding: 3px 8px; cursor: pointer; }
|
|
112
|
+
.pp-tx { margin: 0 0 12px; font-size: 13px; }
|
|
113
|
+
.pp-tx a { color: var(--pp-color-primary); }
|
|
114
|
+
`;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { createCheckout, type CheckoutSession, type CheckoutState } from "../checkout.js";
|
|
3
|
+
|
|
4
|
+
export interface UseCheckoutOptions {
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
apiBase: string;
|
|
7
|
+
pollInterval?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseCheckoutResult extends CheckoutState {
|
|
11
|
+
/** Reads the quote now, for example right after the wallet broadcast the payment. */
|
|
12
|
+
refresh: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LOADING: CheckoutState = { status: "loading", quote: null, error: null };
|
|
16
|
+
|
|
17
|
+
/** Follows a quote's public view for as long as the component is mounted. */
|
|
18
|
+
export function useCheckout({
|
|
19
|
+
clientSecret,
|
|
20
|
+
apiBase,
|
|
21
|
+
pollInterval,
|
|
22
|
+
}: UseCheckoutOptions): UseCheckoutResult {
|
|
23
|
+
const [current, setCurrent] = useState({ key: "", state: LOADING });
|
|
24
|
+
const checkout = useRef<CheckoutSession | null>(null);
|
|
25
|
+
const key = `${apiBase} ${clientSecret}`;
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const instance = createCheckout({
|
|
29
|
+
clientSecret,
|
|
30
|
+
apiBase,
|
|
31
|
+
...(pollInterval === undefined ? {} : { pollInterval }),
|
|
32
|
+
});
|
|
33
|
+
checkout.current = instance;
|
|
34
|
+
const unsubscribe = instance.subscribe((state) => setCurrent({ key, state }));
|
|
35
|
+
return () => {
|
|
36
|
+
unsubscribe();
|
|
37
|
+
instance.destroy();
|
|
38
|
+
checkout.current = null;
|
|
39
|
+
};
|
|
40
|
+
}, [key, clientSecret, apiBase, pollInterval]);
|
|
41
|
+
|
|
42
|
+
const refresh = useCallback(() => {
|
|
43
|
+
void checkout.current?.refresh();
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
// A new client secret starts from `loading`, not from the previous quote's state.
|
|
47
|
+
return { ...(current.key === key ? current.state : LOADING), refresh };
|
|
48
|
+
}
|