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.
Files changed (67) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +11 -9
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  6. package/dist/cli/plugins/tempo.js +3 -3
  7. package/dist/cli/plugins/tempo.js.map +1 -1
  8. package/dist/cli/utils.d.ts +2 -0
  9. package/dist/cli/utils.d.ts.map +1 -1
  10. package/dist/cli/utils.js +10 -5
  11. package/dist/cli/utils.js.map +1 -1
  12. package/dist/server/Transport.d.ts.map +1 -1
  13. package/dist/server/Transport.js +40 -21
  14. package/dist/server/Transport.js.map +1 -1
  15. package/dist/server/internal/html/config.d.ts +137 -0
  16. package/dist/server/internal/html/config.d.ts.map +1 -1
  17. package/dist/server/internal/html/config.js +300 -0
  18. package/dist/server/internal/html/config.js.map +1 -1
  19. package/dist/stripe/internal/types.d.ts +6 -0
  20. package/dist/stripe/internal/types.d.ts.map +1 -1
  21. package/dist/stripe/server/Charge.d.ts +25 -16
  22. package/dist/stripe/server/Charge.d.ts.map +1 -1
  23. package/dist/stripe/server/Charge.js +23 -2
  24. package/dist/stripe/server/Charge.js.map +1 -1
  25. package/dist/stripe/server/internal/html/types.d.ts +2 -0
  26. package/dist/stripe/server/internal/html/types.d.ts.map +1 -0
  27. package/dist/stripe/server/internal/html/types.js +2 -0
  28. package/dist/stripe/server/internal/html/types.js.map +1 -0
  29. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  30. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  31. package/dist/stripe/server/internal/html.gen.js +1 -1
  32. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  33. package/dist/tempo/Attribution.d.ts +24 -7
  34. package/dist/tempo/Attribution.d.ts.map +1 -1
  35. package/dist/tempo/Attribution.js +33 -7
  36. package/dist/tempo/Attribution.js.map +1 -1
  37. package/dist/tempo/client/Charge.js +1 -1
  38. package/dist/tempo/client/Charge.js.map +1 -1
  39. package/dist/tempo/server/Charge.d.ts +32 -27
  40. package/dist/tempo/server/Charge.d.ts.map +1 -1
  41. package/dist/tempo/server/Charge.js +68 -5
  42. package/dist/tempo/server/Charge.js.map +1 -1
  43. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  44. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  45. package/dist/tempo/server/internal/html.gen.js +1 -1
  46. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/cli/cli.ts +11 -8
  49. package/src/cli/plugins/tempo.ts +3 -2
  50. package/src/cli/utils.test.ts +64 -0
  51. package/src/cli/utils.ts +10 -4
  52. package/src/server/Transport.test.ts +216 -0
  53. package/src/server/Transport.ts +47 -24
  54. package/src/server/internal/html/config.ts +406 -0
  55. package/src/stripe/internal/types.ts +20 -0
  56. package/src/stripe/server/Charge.ts +46 -4
  57. package/src/stripe/server/internal/html/main.ts +87 -19
  58. package/src/stripe/server/internal/html/types.ts +5 -0
  59. package/src/stripe/server/internal/html.gen.ts +1 -1
  60. package/src/tempo/Attribution.test.ts +129 -23
  61. package/src/tempo/Attribution.ts +39 -10
  62. package/src/tempo/client/Charge.ts +1 -1
  63. package/src/tempo/server/Charge.test.ts +205 -5
  64. package/src/tempo/server/Charge.ts +100 -7
  65. package/src/tempo/server/internal/html/main.ts +51 -11
  66. package/src/tempo/server/internal/html/package.json +1 -1
  67. 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, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&#39;')
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 { StripeClient } from '../internal/types.js'
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: html ? { config: html, content: htmlContent } : undefined,
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?: { createTokenUrl: string; publishableKey: string } | undefined
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 data = JSON.parse(document.getElementById(Html.dataId)!.textContent!) as {
12
- config: NonNullable<charge.Parameters['html']>
13
- challenge: Challenge.FromMethods<[typeof Methods.charge]>
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('root')!
18
+ const root = document.getElementById(Html.rootId)!
17
19
 
18
- const h2 = document.createElement('h2')
19
- h2.textContent = 'stripe'
20
- root.appendChild(h2)
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 = 'Pay'
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: (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe',
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
- currency: data.challenge.request.currency as string,
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 = 'Pay'
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({ elements })
78
- if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method')
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()
@@ -0,0 +1,5 @@
1
+ export type {
2
+ CreatePaymentMethodFromElements,
3
+ StripeElementsOptionsMode,
4
+ StripePaymentElementOptions,
5
+ } from '@stripe/stripe-js'