mppx 0.5.3 → 0.5.5
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 +15 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +11 -9
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +3 -3
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +10 -5
- package/dist/cli/utils.js.map +1 -1
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +40 -21
- package/dist/server/Transport.js.map +1 -1
- package/dist/server/internal/html/config.d.ts +137 -0
- package/dist/server/internal/html/config.d.ts.map +1 -1
- package/dist/server/internal/html/config.js +300 -0
- package/dist/server/internal/html/config.js.map +1 -1
- package/dist/stripe/internal/types.d.ts +6 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts +25 -16
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +23 -2
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/stripe/server/internal/html/types.d.ts +2 -0
- package/dist/stripe/server/internal/html/types.d.ts.map +1 -0
- package/dist/stripe/server/internal/html/types.js +2 -0
- package/dist/stripe/server/internal/html/types.js.map +1 -0
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Attribution.d.ts +24 -7
- package/dist/tempo/Attribution.d.ts.map +1 -1
- package/dist/tempo/Attribution.js +33 -7
- package/dist/tempo/Attribution.js.map +1 -1
- package/dist/tempo/client/Charge.js +1 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +32 -27
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +68 -5
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/cli.ts +11 -8
- package/src/cli/plugins/tempo.ts +3 -2
- package/src/cli/utils.test.ts +64 -0
- package/src/cli/utils.ts +10 -4
- package/src/server/Transport.test.ts +216 -0
- package/src/server/Transport.ts +47 -24
- package/src/server/internal/html/config.ts +406 -0
- package/src/stripe/internal/types.ts +20 -0
- package/src/stripe/server/Charge.ts +46 -4
- package/src/stripe/server/internal/html/main.ts +87 -19
- package/src/stripe/server/internal/html/types.ts +5 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Attribution.test.ts +129 -23
- package/src/tempo/Attribution.ts +39 -10
- package/src/tempo/client/Charge.ts +1 -1
- package/src/tempo/server/Charge.test.ts +205 -5
- package/src/tempo/server/Charge.ts +100 -7
- package/src/tempo/server/internal/html/main.ts +51 -11
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -1,8 +1,414 @@
|
|
|
1
|
+
import type * as Challenge from '../../../Challenge.js'
|
|
2
|
+
import type * as Method from '../../../Method.js'
|
|
3
|
+
|
|
1
4
|
export type Options = {
|
|
2
5
|
config: Record<string, unknown>
|
|
3
6
|
content: string
|
|
7
|
+
formatAmount: (request: any) => string | Promise<string>
|
|
8
|
+
text: Text | undefined
|
|
9
|
+
theme: Theme | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Data<
|
|
13
|
+
method extends Method.Method = Method.Method,
|
|
14
|
+
config extends Record<string, unknown> = {},
|
|
15
|
+
> = {
|
|
16
|
+
config: config
|
|
17
|
+
challenge: Challenge.FromMethods<[method]>
|
|
18
|
+
text: { [k in keyof Text]-?: NonNullable<Text[k]> }
|
|
19
|
+
theme: {
|
|
20
|
+
[k in keyof Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>]-?: NonNullable<Theme[k]>
|
|
21
|
+
}
|
|
4
22
|
}
|
|
5
23
|
|
|
6
24
|
export const dataId = '__MPPX_DATA__'
|
|
7
25
|
|
|
26
|
+
export const errorId = 'root_error'
|
|
27
|
+
|
|
28
|
+
export const rootId = 'root'
|
|
29
|
+
|
|
8
30
|
export const serviceWorkerParam = '__mppx_worker'
|
|
31
|
+
|
|
32
|
+
export const classNames = {
|
|
33
|
+
error: 'mppx-error',
|
|
34
|
+
header: 'mppx-header',
|
|
35
|
+
logo: 'mppx-logo',
|
|
36
|
+
logoColorScheme: (colorScheme: string) =>
|
|
37
|
+
colorScheme === 'dark' || colorScheme === 'light'
|
|
38
|
+
? `${classNames.logo}--${colorScheme}`
|
|
39
|
+
: undefined,
|
|
40
|
+
summary: 'mppx-summary',
|
|
41
|
+
summaryAmount: 'mppx-summary-amount',
|
|
42
|
+
summaryDescription: 'mppx-summary-description',
|
|
43
|
+
summaryExpires: 'mppx-summary-expires',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sanitize(str: string): string {
|
|
47
|
+
return str
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, ''')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sanitizeRecord<type extends Record<string, string>>(record: type): type {
|
|
56
|
+
return Object.fromEntries(
|
|
57
|
+
Object.entries(record).map(([key, value]) => [key, sanitize(value)]),
|
|
58
|
+
) as type
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const html = String.raw
|
|
62
|
+
|
|
63
|
+
class CssVar {
|
|
64
|
+
readonly name: string
|
|
65
|
+
constructor(token: string) {
|
|
66
|
+
this.name = `--mppx-${token}`
|
|
67
|
+
}
|
|
68
|
+
toString() {
|
|
69
|
+
return `var(${this.name})`
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const vars = {
|
|
74
|
+
accent: new CssVar('accent'),
|
|
75
|
+
background: new CssVar('background'),
|
|
76
|
+
border: new CssVar('border'),
|
|
77
|
+
foreground: new CssVar('foreground'),
|
|
78
|
+
muted: new CssVar('muted'),
|
|
79
|
+
negative: new CssVar('negative'),
|
|
80
|
+
positive: new CssVar('positive'),
|
|
81
|
+
surface: new CssVar('surface'),
|
|
82
|
+
fontFamily: new CssVar('font-family'),
|
|
83
|
+
fontSizeBase: new CssVar('font-size-base'),
|
|
84
|
+
radius: new CssVar('radius'),
|
|
85
|
+
spacingUnit: new CssVar('spacing-unit'),
|
|
86
|
+
} as const
|
|
87
|
+
|
|
88
|
+
export function font(theme: Theme) {
|
|
89
|
+
if (!theme.fontUrl) return ''
|
|
90
|
+
return html`<link
|
|
91
|
+
rel="preconnect"
|
|
92
|
+
href="${sanitize(new URL(theme.fontUrl).origin)}"
|
|
93
|
+
crossorigin
|
|
94
|
+
/>
|
|
95
|
+
<link rel="stylesheet" href="${sanitize(theme.fontUrl)}" />`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function style(theme: {
|
|
99
|
+
[k in keyof Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>]-?: NonNullable<Theme[k]>
|
|
100
|
+
}) {
|
|
101
|
+
const colors = Object.fromEntries(
|
|
102
|
+
colorTokens.map((name) => [name, resolveColor(theme[name], defaultTheme[name])]),
|
|
103
|
+
) as Record<(typeof colorTokens)[number], readonly [light: string, dark: string]>
|
|
104
|
+
const lightVars = colorTokens
|
|
105
|
+
.map((token) => `${vars[token].name}: ${colors[token][0]};`)
|
|
106
|
+
.join('\n ')
|
|
107
|
+
const darkVars = colorTokens
|
|
108
|
+
.map((token) => `${vars[token].name}: ${colors[token][1]};`)
|
|
109
|
+
.join('\n ')
|
|
110
|
+
const isLightOnly = theme.colorScheme === 'light'
|
|
111
|
+
const isDarkOnly = theme.colorScheme === 'dark'
|
|
112
|
+
const rootVars = isDarkOnly ? darkVars : lightVars
|
|
113
|
+
const darkMedia =
|
|
114
|
+
!isLightOnly && !isDarkOnly
|
|
115
|
+
? `\n @media (prefers-color-scheme: dark) {\n :root {\n ${darkVars}\n }\n }`
|
|
116
|
+
: ''
|
|
117
|
+
return html`
|
|
118
|
+
<style>
|
|
119
|
+
${reset}
|
|
120
|
+
:root {
|
|
121
|
+
color-scheme: ${theme.colorScheme};
|
|
122
|
+
${vars.fontFamily.name}: ${theme.fontFamily};
|
|
123
|
+
${vars.fontSizeBase.name}: ${theme.fontSizeBase};
|
|
124
|
+
${vars.radius.name}: ${theme.radius};
|
|
125
|
+
${vars.spacingUnit.name}: ${theme.spacingUnit};
|
|
126
|
+
${rootVars}
|
|
127
|
+
}${darkMedia}
|
|
128
|
+
*:focus-visible {
|
|
129
|
+
outline-color: ${vars.accent};
|
|
130
|
+
outline-offset: 0.15rem;
|
|
131
|
+
outline-style: solid;
|
|
132
|
+
outline-width: 2px;
|
|
133
|
+
}
|
|
134
|
+
body {
|
|
135
|
+
-webkit-font-smoothing: antialiased;
|
|
136
|
+
-moz-osx-font-smoothing: grayscale;
|
|
137
|
+
background: ${vars.background};
|
|
138
|
+
color: ${vars.foreground};
|
|
139
|
+
font-family: ${vars.fontFamily};
|
|
140
|
+
font-size: ${vars.fontSizeBase};
|
|
141
|
+
}
|
|
142
|
+
main {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column;
|
|
145
|
+
gap: calc(${vars.spacingUnit} * 8);
|
|
146
|
+
margin-left: auto;
|
|
147
|
+
margin-right: auto;
|
|
148
|
+
max-width: clamp(300px, calc(${vars.spacingUnit} * 224), 896px);
|
|
149
|
+
padding: calc(${vars.spacingUnit} * 12) calc(${vars.spacingUnit} * 8) calc(${vars.spacingUnit} * 16);
|
|
150
|
+
}
|
|
151
|
+
.${classNames.header} {
|
|
152
|
+
align-items: center;
|
|
153
|
+
display: flex;
|
|
154
|
+
flex-wrap: wrap;
|
|
155
|
+
gap: calc(${vars.spacingUnit} * 4);
|
|
156
|
+
justify-content: space-between;
|
|
157
|
+
span {
|
|
158
|
+
background: ${vars.surface};
|
|
159
|
+
border: 1px solid ${vars.border};
|
|
160
|
+
border-radius: calc(${vars.spacingUnit} * 50);
|
|
161
|
+
font-size: 0.75rem;
|
|
162
|
+
font-weight: 500;
|
|
163
|
+
letter-spacing: 0.025em;
|
|
164
|
+
padding: calc(${vars.spacingUnit} * 1) calc(${vars.spacingUnit} * 4);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
.${classNames.logo} {
|
|
168
|
+
max-height: 1.75rem;
|
|
169
|
+
}
|
|
170
|
+
.${classNames.logoColorScheme('dark')} {
|
|
171
|
+
@media (prefers-color-scheme: light) {
|
|
172
|
+
display: none;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
.${classNames.logoColorScheme('light')} {
|
|
176
|
+
@media (prefers-color-scheme: dark) {
|
|
177
|
+
display: none;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
.${classNames.summary} {
|
|
181
|
+
background: ${vars.surface};
|
|
182
|
+
border: 1px solid ${vars.border};
|
|
183
|
+
border-radius: ${vars.radius};
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
gap: calc(${vars.spacingUnit} * 3);
|
|
187
|
+
padding: calc(${vars.spacingUnit} * 6) calc(${vars.spacingUnit} * 6);
|
|
188
|
+
}
|
|
189
|
+
.${classNames.summaryAmount} {
|
|
190
|
+
font-size: 2.5rem;
|
|
191
|
+
font-variant-numeric: tabular-nums;
|
|
192
|
+
font-weight: 700;
|
|
193
|
+
line-height: 1.2;
|
|
194
|
+
}
|
|
195
|
+
.${classNames.summaryDescription} {
|
|
196
|
+
font-size: 1.25rem;
|
|
197
|
+
}
|
|
198
|
+
.${classNames.summaryExpires} {
|
|
199
|
+
color: ${vars.muted};
|
|
200
|
+
}
|
|
201
|
+
.${classNames.error} {
|
|
202
|
+
color: ${vars.negative};
|
|
203
|
+
font-size: 0.95rem;
|
|
204
|
+
margin-top: calc(${vars.spacingUnit} * -1.5);
|
|
205
|
+
text-align: center;
|
|
206
|
+
}
|
|
207
|
+
</style>
|
|
208
|
+
`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function showError(message: string) {
|
|
212
|
+
const existing = document.getElementById(errorId)
|
|
213
|
+
if (existing) {
|
|
214
|
+
existing.textContent = message
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const el = document.createElement('p')
|
|
218
|
+
el.id = errorId
|
|
219
|
+
el.className = classNames.error
|
|
220
|
+
el.role = 'alert'
|
|
221
|
+
el.textContent = message
|
|
222
|
+
document.getElementById(rootId)?.after(el)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function favicon(theme: Theme, realm: string) {
|
|
226
|
+
if (typeof theme.favicon === 'string')
|
|
227
|
+
return html`<link rel="icon" href="${sanitize(theme.favicon)}" />`
|
|
228
|
+
if (typeof theme.favicon === 'object') {
|
|
229
|
+
return html`<link
|
|
230
|
+
rel="icon"
|
|
231
|
+
href="${sanitize(theme.favicon.light)}"
|
|
232
|
+
media="(prefers-color-scheme: light)"
|
|
233
|
+
/>
|
|
234
|
+
<link
|
|
235
|
+
rel="icon"
|
|
236
|
+
href="${sanitize(theme.favicon.dark)}"
|
|
237
|
+
media="(prefers-color-scheme: dark)"
|
|
238
|
+
/>`
|
|
239
|
+
}
|
|
240
|
+
// Fallback: use host's favicon via Google S2 service
|
|
241
|
+
try {
|
|
242
|
+
const domain = new URL(realm).hostname
|
|
243
|
+
return html`<link
|
|
244
|
+
rel="icon"
|
|
245
|
+
href="https://www.google.com/s2/favicons?domain=${domain}&sz=64"
|
|
246
|
+
/>`
|
|
247
|
+
} catch {
|
|
248
|
+
return ''
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function logo(value: Theme) {
|
|
253
|
+
if (typeof value.logo === 'undefined') return ''
|
|
254
|
+
if (typeof value.logo === 'string')
|
|
255
|
+
return html`<img alt="" class="${classNames.logo}" src="${sanitize(value.logo)}" />`
|
|
256
|
+
return Object.entries(value.logo)
|
|
257
|
+
.map(
|
|
258
|
+
(entry) =>
|
|
259
|
+
html`<img
|
|
260
|
+
alt=""
|
|
261
|
+
class="${classNames.logo} ${classNames.logoColorScheme(entry[0])}"
|
|
262
|
+
src="${sanitize(entry[1])}"
|
|
263
|
+
/>`,
|
|
264
|
+
)
|
|
265
|
+
.join('\n')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type Text = {
|
|
269
|
+
/** Prefix for the expiry line. @default 'Expires at' */
|
|
270
|
+
expires?: string | undefined
|
|
271
|
+
/** Pay button label. @default 'Pay' */
|
|
272
|
+
pay?: string | undefined
|
|
273
|
+
/** Badge label. @default 'Payment Required' */
|
|
274
|
+
paymentRequired?: string | undefined
|
|
275
|
+
/** Page title. @default text.paymentRequired */
|
|
276
|
+
title?: string | undefined
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const defaultText = {
|
|
280
|
+
expires: 'Expires at',
|
|
281
|
+
pay: 'Pay',
|
|
282
|
+
paymentRequired: 'Payment Required',
|
|
283
|
+
title: 'Payment Required',
|
|
284
|
+
} as const satisfies Required<Text>
|
|
285
|
+
|
|
286
|
+
export type Theme = {
|
|
287
|
+
/** Color scheme. @default 'light dark' */
|
|
288
|
+
colorScheme?: 'light' | 'dark' | 'light dark' | undefined
|
|
289
|
+
/** Font family. @default 'system-ui, -apple-system, sans-serif' */
|
|
290
|
+
fontFamily?: string | undefined
|
|
291
|
+
/** Base font size. @default '16px' */
|
|
292
|
+
fontSizeBase?: string | undefined
|
|
293
|
+
/** Font URL to inject (e.g. Google Fonts `<link>`). */
|
|
294
|
+
fontUrl?: string | undefined
|
|
295
|
+
/** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */
|
|
296
|
+
favicon?: string | { light: string; dark: string } | undefined
|
|
297
|
+
/** Logo URL shown in header. Light/dark variants supported. */
|
|
298
|
+
logo?: string | { light: string; dark: string } | undefined
|
|
299
|
+
/** Border radius. @default '6px' */
|
|
300
|
+
radius?: string | undefined
|
|
301
|
+
/** The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. @default '2px' */
|
|
302
|
+
spacingUnit?: string | undefined
|
|
303
|
+
|
|
304
|
+
/** Accent color (buttons, links). @default ['#171717', '#ededed'] */
|
|
305
|
+
accent?: LightDark | undefined
|
|
306
|
+
/** Page background. @default ['#ffffff', '#0a0a0a'] */
|
|
307
|
+
background?: LightDark | undefined
|
|
308
|
+
/** Border color. @default ['#e5e5e5', '#2e2e2e'] */
|
|
309
|
+
border?: LightDark | undefined
|
|
310
|
+
/** Primary text/content color. @default ['#0a0a0a', '#ededed'] */
|
|
311
|
+
foreground?: LightDark | undefined
|
|
312
|
+
/** Secondary/muted text. @default ['#666666', '#a1a1a1'] */
|
|
313
|
+
muted?: LightDark | undefined
|
|
314
|
+
/** Error/danger color. @default ['#e5484d', '#e5484d'] */
|
|
315
|
+
negative?: LightDark | undefined
|
|
316
|
+
/** Success color. @default ['#30a46c', '#30a46c'] */
|
|
317
|
+
positive?: LightDark | undefined
|
|
318
|
+
/** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */
|
|
319
|
+
surface?: LightDark | undefined
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export type LightDark = string | readonly [light: string, dark: string]
|
|
323
|
+
|
|
324
|
+
export const defaultTheme = {
|
|
325
|
+
colorScheme: 'light dark',
|
|
326
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
327
|
+
fontSizeBase: '16px',
|
|
328
|
+
radius: '6px',
|
|
329
|
+
spacingUnit: '2px',
|
|
330
|
+
|
|
331
|
+
accent: ['#171717', '#ededed'],
|
|
332
|
+
background: ['#ffffff', '#0a0a0a'],
|
|
333
|
+
border: ['#e5e5e5', '#2e2e2e'],
|
|
334
|
+
foreground: ['#0a0a0a', '#ededed'],
|
|
335
|
+
muted: ['#666666', '#a1a1a1'],
|
|
336
|
+
negative: ['#e5484d', '#e5484d'],
|
|
337
|
+
positive: ['#30a46c', '#30a46c'],
|
|
338
|
+
surface: ['#f5f5f5', '#1a1a1a'],
|
|
339
|
+
} as const satisfies Required<Omit<Theme, 'favicon' | 'fontUrl' | 'logo'>>
|
|
340
|
+
|
|
341
|
+
export const colorTokens = [
|
|
342
|
+
'accent',
|
|
343
|
+
'negative',
|
|
344
|
+
'positive',
|
|
345
|
+
'background',
|
|
346
|
+
'foreground',
|
|
347
|
+
'muted',
|
|
348
|
+
'surface',
|
|
349
|
+
'border',
|
|
350
|
+
] as const satisfies readonly (keyof typeof defaultTheme)[]
|
|
351
|
+
|
|
352
|
+
export function resolveColor(
|
|
353
|
+
value: Theme[(typeof colorTokens)[number]] | undefined,
|
|
354
|
+
fallback: readonly [string, string],
|
|
355
|
+
): readonly [light: string, dark: string] {
|
|
356
|
+
if (!value) return fallback
|
|
357
|
+
if (typeof value === 'string') return [value, value]
|
|
358
|
+
return value
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function mergeDefined<type>(defaults: type, value: DeepPartial<type> | undefined): type {
|
|
362
|
+
if (value === undefined) return defaults
|
|
363
|
+
if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type
|
|
364
|
+
|
|
365
|
+
const result: Record<string, unknown> = { ...defaults }
|
|
366
|
+
|
|
367
|
+
for (const [key, nextValue] of Object.entries(value)) {
|
|
368
|
+
if (nextValue === undefined) continue
|
|
369
|
+
|
|
370
|
+
const currentValue = result[key]
|
|
371
|
+
|
|
372
|
+
result[key] =
|
|
373
|
+
isPlainObject(currentValue) && isPlainObject(nextValue)
|
|
374
|
+
? mergeDefined(currentValue, nextValue)
|
|
375
|
+
: nextValue
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return result as type
|
|
379
|
+
}
|
|
380
|
+
type DeepPartial<type> = {
|
|
381
|
+
[key in keyof type]?: type[key] extends readonly unknown[]
|
|
382
|
+
? type[key] | undefined
|
|
383
|
+
: type[key] extends object
|
|
384
|
+
? DeepPartial<type[key]> | undefined
|
|
385
|
+
: type[key] | undefined
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
389
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Slimmed down Tailwind preflight
|
|
393
|
+
// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css
|
|
394
|
+
const reset = html`
|
|
395
|
+
*, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0;
|
|
396
|
+
padding: 0; border: 0 solid; border-color: ${vars.border}; } html, :host { line-height: 1.5;
|
|
397
|
+
-webkit-text-size-adjust: 100%; tab-size: 4; -webkit-tap-highlight-color: transparent; } h1, h2,
|
|
398
|
+
h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit;
|
|
399
|
+
-webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; }
|
|
400
|
+
code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
401
|
+
'Liberation Mono', 'Courier New', monospace; font-size: 1em; } small { font-size: 80%; } ol, ul,
|
|
402
|
+
menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block;
|
|
403
|
+
vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select,
|
|
404
|
+
optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit;
|
|
405
|
+
font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0;
|
|
406
|
+
background-color: transparent; opacity: 1; } ::file-selector-button { margin-inline-end: 4px; }
|
|
407
|
+
::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or
|
|
408
|
+
(contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%,
|
|
409
|
+
transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance:
|
|
410
|
+
none; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'],
|
|
411
|
+
[type='reset'], [type='submit']), ::file-selector-button { appearance: button; }
|
|
412
|
+
::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; }
|
|
413
|
+
[hidden]:where(:not([hidden='until-found'])) { display: none !important; }
|
|
414
|
+
`
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as StripeJsTypes from '../../stripe/server/internal/html/types.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Duck-typed interface for the Stripe Node SDK (`stripe` npm package).
|
|
3
5
|
* Matches the subset of the API used by mppx for server-side payment verification.
|
|
@@ -24,3 +26,21 @@ export type StripeJs = {
|
|
|
24
26
|
createPaymentMethod(...args: any[]): Promise<Record<string, unknown>>
|
|
25
27
|
elements(...args: any[]): unknown
|
|
26
28
|
}
|
|
29
|
+
|
|
30
|
+
export type CreatePaymentMethodFromElements = Omit<
|
|
31
|
+
StripeJsTypes.CreatePaymentMethodFromElements,
|
|
32
|
+
'elements'
|
|
33
|
+
> & {}
|
|
34
|
+
|
|
35
|
+
export type StripeElementsOptionsMode = Omit<
|
|
36
|
+
Extract<StripeJsTypes.StripeElementsOptionsMode, { mode: 'payment' }>,
|
|
37
|
+
| 'amount'
|
|
38
|
+
| 'currency'
|
|
39
|
+
| 'mode'
|
|
40
|
+
| 'excludedPaymentMethodTypes'
|
|
41
|
+
| 'paymentMethodCreation'
|
|
42
|
+
| 'paymentMethodTypes'
|
|
43
|
+
| 'payment_method_types'
|
|
44
|
+
> & {}
|
|
45
|
+
|
|
46
|
+
export type StripePaymentElementOptions = StripeJsTypes.StripePaymentElementOptions
|
|
@@ -3,7 +3,14 @@ import { PaymentActionRequiredError, VerificationFailedError } from '../../Error
|
|
|
3
3
|
import * as Expires from '../../Expires.js'
|
|
4
4
|
import type { LooseOmit, OneOf } from '../../internal/types.js'
|
|
5
5
|
import * as Method from '../../Method.js'
|
|
6
|
-
import type
|
|
6
|
+
import type * as Html from '../../server/internal/html/config.ts'
|
|
7
|
+
import type * as z from '../../zod.js'
|
|
8
|
+
import type {
|
|
9
|
+
StripeClient,
|
|
10
|
+
CreatePaymentMethodFromElements,
|
|
11
|
+
StripeElementsOptionsMode,
|
|
12
|
+
StripePaymentElementOptions,
|
|
13
|
+
} from '../internal/types.js'
|
|
7
14
|
import * as Methods from '../Methods.js'
|
|
8
15
|
import { html as htmlContent } from './internal/html.gen.js'
|
|
9
16
|
|
|
@@ -39,7 +46,7 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
|
|
|
39
46
|
decimals,
|
|
40
47
|
description,
|
|
41
48
|
externalId,
|
|
42
|
-
html,
|
|
49
|
+
html: { text: htmlText, theme: htmlTheme, ...htmlConfig } = {},
|
|
43
50
|
metadata,
|
|
44
51
|
networkId,
|
|
45
52
|
paymentMethodTypes,
|
|
@@ -61,7 +68,28 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
|
|
|
61
68
|
paymentMethodTypes,
|
|
62
69
|
} as unknown as Defaults,
|
|
63
70
|
|
|
64
|
-
html:
|
|
71
|
+
html:
|
|
72
|
+
'publishableKey' in htmlConfig && htmlConfig.publishableKey && htmlConfig.createTokenUrl
|
|
73
|
+
? {
|
|
74
|
+
config: htmlConfig,
|
|
75
|
+
content: htmlContent,
|
|
76
|
+
formatAmount: (request: z.output<typeof Methods.charge.schema.request>) => {
|
|
77
|
+
try {
|
|
78
|
+
const formatter = new Intl.NumberFormat('en', {
|
|
79
|
+
style: 'currency',
|
|
80
|
+
currency: request.currency,
|
|
81
|
+
currencyDisplay: 'narrowSymbol',
|
|
82
|
+
})
|
|
83
|
+
const decimals = formatter.resolvedOptions().maximumFractionDigits ?? 2
|
|
84
|
+
return formatter.format(Number(request.amount) / 10 ** decimals)
|
|
85
|
+
} catch {
|
|
86
|
+
return `${request.currency}${request.amount}`
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
text: htmlText,
|
|
90
|
+
theme: htmlTheme,
|
|
91
|
+
}
|
|
92
|
+
: undefined,
|
|
65
93
|
|
|
66
94
|
async verify({ credential, request }) {
|
|
67
95
|
const { challenge } = credential
|
|
@@ -121,7 +149,21 @@ export declare namespace charge {
|
|
|
121
149
|
|
|
122
150
|
type Parameters = {
|
|
123
151
|
/** Render payment page when Accept header is text/html (e.g. in browsers) */
|
|
124
|
-
html?:
|
|
152
|
+
html?:
|
|
153
|
+
| {
|
|
154
|
+
createTokenUrl: string
|
|
155
|
+
elements?:
|
|
156
|
+
| {
|
|
157
|
+
options?: StripeElementsOptionsMode | undefined
|
|
158
|
+
paymentOptions?: StripePaymentElementOptions | undefined
|
|
159
|
+
createPaymentMethodOptions?: CreatePaymentMethodFromElements | undefined
|
|
160
|
+
}
|
|
161
|
+
| undefined
|
|
162
|
+
publishableKey: string
|
|
163
|
+
text?: Html.Text
|
|
164
|
+
theme?: Html.Theme
|
|
165
|
+
}
|
|
166
|
+
| undefined
|
|
125
167
|
/** Optional metadata to include in SPT creation requests. */
|
|
126
168
|
metadata?: Record<string, string> | undefined
|
|
127
169
|
} & Defaults &
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { Appearance } from '@stripe/stripe-js'
|
|
1
2
|
import { loadStripe } from '@stripe/stripe-js/pure'
|
|
3
|
+
import { Json } from 'ox'
|
|
2
4
|
|
|
3
|
-
import type * as Challenge from '../../../../Challenge.js'
|
|
4
5
|
import { stripe } from '../../../../client/index.js'
|
|
5
6
|
import * as Html from '../../../../server/internal/html/config.js'
|
|
6
7
|
import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js'
|
|
@@ -8,21 +9,45 @@ import type { charge as chargeClient } from '../../../../stripe/client/Charge.js
|
|
|
8
9
|
import type { charge } from '../../../../stripe/server/Charge.js'
|
|
9
10
|
import type * as Methods from '../../../Methods.js'
|
|
10
11
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const dataElement = document.getElementById(Html.dataId)!
|
|
13
|
+
const data = Json.parse(dataElement.textContent) as Html.Data<
|
|
14
|
+
typeof Methods.charge,
|
|
15
|
+
NonNullable<charge.Parameters['html']>
|
|
16
|
+
>
|
|
15
17
|
|
|
16
|
-
const root = document.getElementById(
|
|
18
|
+
const root = document.getElementById(Html.rootId)!
|
|
17
19
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const css = String.raw
|
|
21
|
+
const style = document.createElement('style')
|
|
22
|
+
style.textContent = css`
|
|
23
|
+
form {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-direction: column;
|
|
26
|
+
gap: calc(${Html.vars.spacingUnit} * 8);
|
|
27
|
+
}
|
|
28
|
+
button {
|
|
29
|
+
background: ${Html.vars.accent};
|
|
30
|
+
border-radius: ${Html.vars.radius};
|
|
31
|
+
color: ${Html.vars.background};
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
font-weight: 500;
|
|
34
|
+
padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8);
|
|
35
|
+
width: 100%;
|
|
36
|
+
}
|
|
37
|
+
button:hover:not(:disabled) {
|
|
38
|
+
opacity: 0.85;
|
|
39
|
+
}
|
|
40
|
+
button:disabled {
|
|
41
|
+
cursor: default;
|
|
42
|
+
opacity: 0.5;
|
|
43
|
+
}
|
|
44
|
+
`
|
|
45
|
+
root.append(style)
|
|
21
46
|
|
|
22
47
|
;(async () => {
|
|
23
48
|
if (import.meta.env.MODE === 'test') {
|
|
24
49
|
const button = document.createElement('button')
|
|
25
|
-
button.textContent =
|
|
50
|
+
button.textContent = data.text.pay
|
|
26
51
|
root.appendChild(button)
|
|
27
52
|
button.onclick = async () => {
|
|
28
53
|
try {
|
|
@@ -33,6 +58,8 @@ root.appendChild(h2)
|
|
|
33
58
|
context: { paymentMethod: 'pm_card_visa' },
|
|
34
59
|
})
|
|
35
60
|
await submitCredential(credential)
|
|
61
|
+
} catch (e) {
|
|
62
|
+
Html.showError(e instanceof Error ? e.message : 'Payment failed')
|
|
36
63
|
} finally {
|
|
37
64
|
button.disabled = false
|
|
38
65
|
}
|
|
@@ -44,16 +71,48 @@ root.appendChild(h2)
|
|
|
44
71
|
if (!stripeJs) throw new Error('Failed to loadStripe')
|
|
45
72
|
|
|
46
73
|
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
47
|
-
const getAppearance = () =>
|
|
48
|
-
theme
|
|
49
|
-
|
|
74
|
+
const getAppearance = () => {
|
|
75
|
+
const theme = (() => {
|
|
76
|
+
if (data.config.elements?.options?.appearance?.theme)
|
|
77
|
+
return data.config.elements?.options?.appearance?.theme
|
|
78
|
+
switch (data.theme.colorScheme) {
|
|
79
|
+
case 'light dark':
|
|
80
|
+
return (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe'
|
|
81
|
+
case 'light':
|
|
82
|
+
return 'stripe' as const
|
|
83
|
+
case 'dark':
|
|
84
|
+
return 'night' as const
|
|
85
|
+
}
|
|
86
|
+
})()
|
|
87
|
+
const resolvedColorSchemeIndex = darkQuery.matches ? 1 : 0
|
|
88
|
+
return Html.mergeDefined(
|
|
89
|
+
{
|
|
90
|
+
disableAnimations: true,
|
|
91
|
+
theme,
|
|
92
|
+
variables: {
|
|
93
|
+
borderRadius: data.theme.radius,
|
|
94
|
+
colorBackground: data.theme.surface[resolvedColorSchemeIndex],
|
|
95
|
+
colorDanger: data.theme.negative[resolvedColorSchemeIndex],
|
|
96
|
+
colorPrimary: data.theme.accent[resolvedColorSchemeIndex],
|
|
97
|
+
colorText: data.theme.foreground[resolvedColorSchemeIndex],
|
|
98
|
+
colorTextSecondary: data.theme.muted[resolvedColorSchemeIndex],
|
|
99
|
+
fontSizeBase: data.theme.fontSizeBase,
|
|
100
|
+
fontFamily: data.theme.fontFamily,
|
|
101
|
+
spacingUnit: data.theme.spacingUnit,
|
|
102
|
+
},
|
|
103
|
+
} satisfies Appearance,
|
|
104
|
+
(data.config.elements?.options?.appearance as never) ?? {},
|
|
105
|
+
)
|
|
106
|
+
}
|
|
50
107
|
|
|
51
108
|
const elements = stripeJs.elements({
|
|
52
|
-
amount: Number(data.challenge.request.amount),
|
|
53
109
|
appearance: getAppearance(),
|
|
54
|
-
|
|
110
|
+
...data.config.elements?.options,
|
|
111
|
+
amount: Number(data.challenge.request.amount),
|
|
112
|
+
currency: data.challenge.request.currency,
|
|
55
113
|
mode: 'payment',
|
|
56
114
|
paymentMethodCreation: 'manual',
|
|
115
|
+
paymentMethodTypes: data.challenge.request.methodDetails.paymentMethodTypes,
|
|
57
116
|
})
|
|
58
117
|
|
|
59
118
|
darkQuery.addEventListener('change', () => {
|
|
@@ -61,27 +120,34 @@ root.appendChild(h2)
|
|
|
61
120
|
})
|
|
62
121
|
|
|
63
122
|
const form = document.createElement('form')
|
|
64
|
-
elements.create('payment').mount(form)
|
|
123
|
+
elements.create('payment', data.config.elements?.paymentOptions).mount(form)
|
|
65
124
|
root.appendChild(form)
|
|
66
125
|
|
|
67
126
|
const button = document.createElement('button')
|
|
68
|
-
button.textContent =
|
|
127
|
+
button.textContent = data.text.pay
|
|
69
128
|
button.type = 'submit'
|
|
70
129
|
form.appendChild(button)
|
|
71
130
|
|
|
72
131
|
form.onsubmit = async (event) => {
|
|
73
132
|
event.preventDefault()
|
|
133
|
+
document.getElementById(Html.errorId)?.remove()
|
|
74
134
|
button.disabled = true
|
|
75
135
|
try {
|
|
76
136
|
await elements.submit()
|
|
77
|
-
const { paymentMethod, error } = await stripeJs.createPaymentMethod({
|
|
78
|
-
|
|
137
|
+
const { paymentMethod, error: stripeError } = await stripeJs.createPaymentMethod({
|
|
138
|
+
...data.config.elements?.createPaymentMethodOptions,
|
|
139
|
+
elements,
|
|
140
|
+
})
|
|
141
|
+
if (stripeError || !paymentMethod)
|
|
142
|
+
throw stripeError ?? new Error('Failed to create payment method')
|
|
79
143
|
const method = stripe({ client: stripeJs, createToken })[0]
|
|
80
144
|
const credential = await method.createCredential({
|
|
81
145
|
challenge: data.challenge,
|
|
82
146
|
context: { paymentMethod: paymentMethod.id },
|
|
83
147
|
})
|
|
84
148
|
await submitCredential(credential)
|
|
149
|
+
} catch (e) {
|
|
150
|
+
Html.showError(e instanceof Error ? e.message : 'Payment failed')
|
|
85
151
|
} finally {
|
|
86
152
|
button.disabled = false
|
|
87
153
|
}
|
|
@@ -104,3 +170,5 @@ async function createToken(opts: chargeClient.OnChallengeParameters) {
|
|
|
104
170
|
const json = (await res.json()) as { spt: string }
|
|
105
171
|
return json.spt
|
|
106
172
|
}
|
|
173
|
+
|
|
174
|
+
dataElement.remove()
|