diff --git a/.changeset/thin-turtles-greet.md b/.changeset/thin-turtles-greet.md new file mode 100644 index 00000000..f92e9dde --- /dev/null +++ b/.changeset/thin-turtles-greet.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added theming to automatic HTML payment links. diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 796d22d8..20de44c4 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -10,8 +10,8 @@ const mppx = Mppx.create({ stripe.charge({ client: stripeClient, html: { - publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, createTokenUrl: '/api/create-spt', + publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, }, // Stripe Business Network profile ID. networkId: 'internal', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc7d3d62..551f0e2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,8 +289,8 @@ importers: src/tempo/server/internal/html: dependencies: accounts: - specifier: https://pkg.pr.new/tempoxyz/accounts@c339a21 - version: https://pkg.pr.new/tempoxyz/accounts@c339a21(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + specifier: 0.4.12 + version: 0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) mppx: specifier: workspace:* version: link:../../../../.. @@ -1685,9 +1685,8 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - accounts@https://pkg.pr.new/tempoxyz/accounts@c339a21: - resolution: {integrity: sha512-Blp1YJSFPCXHARZ1Glu+0itLeBWoL2QhVUGQBtCjjGiQ1fUIieEpiTQ4/dCo/zyMhNigNnUA0yiLBTH3SCl2+Q==, tarball: https://pkg.pr.new/tempoxyz/accounts@c339a21} - version: 0.4.7 + accounts@0.4.12: + resolution: {integrity: sha512-/Ze4Hm3XYD8wUEAaK8g5flNRA/ONbR/4En3xCs8TJMqnfbjZuwq8zJx2SHvA8lEZfDgFo6Kw4WBY8MowUryjDQ==} peerDependencies: '@wagmi/core': '>=2' react: '>=18' @@ -4970,7 +4969,7 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - accounts@https://pkg.pr.new/tempoxyz/accounts@c339a21(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): + accounts@0.4.12(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): dependencies: '@remix-run/fetch-router': 0.17.0 idb-keyval: 6.2.2 diff --git a/src/server/Transport.test.ts b/src/server/Transport.test.ts index 7403a2c3..8ec4399a 100644 --- a/src/server/Transport.test.ts +++ b/src/server/Transport.test.ts @@ -101,6 +101,222 @@ describe('http', () => { }) }) + describe('respondChallenge html', () => { + const htmlOptions = { + config: { foo: 'bar' }, + content: '', + formatAmount: () => '$10.00', + text: undefined, + theme: undefined, + } satisfies Parameters[0]['html'] + + test('returns html when Accept includes text/html', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: htmlOptions, + }) + + expect(response.status).toBe(402) + expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8') + expect(response.headers.get('WWW-Authenticate')).toContain('Payment') + expect(response.headers.get('Cache-Control')).toBe('no-store') + + const body = await response.text() + expect(body).toContain('') + expect(body).toContain('Payment Required') + expect(body).toContain('$10.00') + expect(body).toContain('Payment Required') + expect(body).toContain('') + expect(body).toContain('__MPPX_DATA__') + }) + + test('returns service worker script when __mppx_worker param is set', async () => { + const transport = Transport.http() + const request = new Request('https://example.com?__mppx_worker') + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: htmlOptions, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/javascript') + expect(response.headers.get('Cache-Control')).toBe('no-store') + + const body = await response.text() + expect(body).toContain('addEventListener') + }) + + test('does not return html when Accept does not include text/html', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'application/json' }, + }) + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: htmlOptions, + }) + + expect(response.status).toBe(402) + expect(response.headers.get('Content-Type')).toBeNull() + expect(await response.text()).toBe('') + }) + + test('renders description when challenge has one', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const challengeWithDescription = { + ...challenge, + description: 'Access to premium content', + } + + const response = await transport.respondChallenge({ + challenge: challengeWithDescription, + input: request, + html: htmlOptions, + }) + + const body = await response.text() + expect(body).toContain('Access to premium content') + expect(body).toContain('mppx-summary-description') + }) + + test('renders expires when challenge has one', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: htmlOptions, + }) + + const body = await response.text() + expect(body).toContain('Expires at') + expect(body).toContain('2025-01-01T00:00:00.000Z') + expect(body).toContain('mppx-summary-expires') + }) + + test('does not render description when challenge lacks one', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const challengeNoDescription = { ...challenge } + delete (challengeNoDescription as any).description + + const response = await transport.respondChallenge({ + challenge: challengeNoDescription, + input: request, + html: htmlOptions, + }) + + const body = await response.text() + expect(body).not.toMatch(/

{ + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: { + ...htmlOptions, + text: { title: 'Pay Up', paymentRequired: 'Gotta Pay' }, + }, + }) + + const body = await response.text() + expect(body).toContain('Pay Up') + expect(body).toContain('Gotta Pay') + }) + + test('applies custom theme logo', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: { + ...htmlOptions, + theme: { logo: 'https://example.com/logo.png' }, + }, + }) + + const body = await response.text() + expect(body).toContain('https://example.com/logo.png') + expect(body).toContain('mppx-logo') + }) + + test('embeds config and challenge in data script', async () => { + const transport = Transport.http() + const request = new Request('https://example.com', { + headers: { Accept: 'text/html' }, + }) + + const response = await transport.respondChallenge({ + challenge, + input: request, + html: htmlOptions, + }) + + const body = await response.text() + // Extract the JSON data from the script tag + const dataMatch = body.match( + /', + }, + }) + + const body = await response.text() + expect(body).not.toContain('') + expect(body).toContain('<script>') + }) + }) + describe('respondChallenge with error status codes', () => { test('BadRequestError returns 400', async () => { const transport = Transport.http() diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 21fe77c8..08ea6bbe 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -7,6 +7,7 @@ import type { Distribute, UnionToIntersection } from '../internal/types.js' import * as core_Mcp from '../Mcp.js' import * as Receipt from '../Receipt.js' import * as Html from './internal/html/config.js' +import { html } from './internal/html/config.js' import { serviceWorker } from './internal/html/serviceWorker.gen.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -126,7 +127,7 @@ export function http(): Http { return Credential.deserialize(payment) }, - respondChallenge(options) { + async respondChallenge(options) { const { challenge, error, input } = options if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam)) @@ -143,38 +144,60 @@ export function http(): Http { 'Cache-Control': 'no-store', } - const body = (() => { + const body = await (async () => { if (options.html && input.headers.get('Accept')?.includes('text/html')) { headers['Content-Type'] = 'text/html; charset=utf-8' - const html = String.raw + + const theme = Html.mergeDefined( + { + favicon: undefined as Html.Theme['favicon'], + fontUrl: undefined as Html.Theme['fontUrl'], + logo: undefined as Html.Theme['logo'], + ...Html.defaultTheme, + }, + (options.html.theme as never) ?? {}, + ) + const text = Html.sanitizeRecord( + Html.mergeDefined(Html.defaultText, (options.html.text as never) ?? {}), + ) + const amount = await options.html.formatAmount(challenge.request) + return html` - Payment Required - + + + ${text.title} + ${Html.favicon(theme, challenge.realm)} ${Html.font(theme)} ${Html.style(theme)} -

Payment Required

-
-${Json.stringify(challenge, null, 2)
-                    .replace(/&/g, '&')
-                    .replace(//g, '>')}
-
- - ${options.html.content} +
+
+ ${Html.logo(theme)} + ${text.paymentRequired} +
+
+

${Html.sanitize(amount)}

+ ${challenge.description + ? `

${Html.sanitize(challenge.description)}

` + : ''} + ${challenge.expires + ? `

${text.expires}

` + : ''} +
+
+ + ${options.html.content} +
` } diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 4c29208e..fde68327 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -1,8 +1,414 @@ +import type * as Challenge from '../../../Challenge.js' +import type * as Method from '../../../Method.js' + export type Options = { config: Record content: string + formatAmount: (request: any) => string | Promise + text: Text | undefined + theme: Theme | undefined +} + +export type Data< + method extends Method.Method = Method.Method, + config extends Record = {}, +> = { + config: config + challenge: Challenge.FromMethods<[method]> + text: { [k in keyof Text]-?: NonNullable } + theme: { + [k in keyof Omit]-?: NonNullable + } } export const dataId = '__MPPX_DATA__' +export const errorId = 'root_error' + +export const rootId = 'root' + export const serviceWorkerParam = '__mppx_worker' + +export const classNames = { + error: 'mppx-error', + header: 'mppx-header', + logo: 'mppx-logo', + logoColorScheme: (colorScheme: string) => + colorScheme === 'dark' || colorScheme === 'light' + ? `${classNames.logo}--${colorScheme}` + : undefined, + summary: 'mppx-summary', + summaryAmount: 'mppx-summary-amount', + summaryDescription: 'mppx-summary-description', + summaryExpires: 'mppx-summary-expires', +} + +export function sanitize(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function sanitizeRecord>(record: type): type { + return Object.fromEntries( + Object.entries(record).map(([key, value]) => [key, sanitize(value)]), + ) as type +} + +export const html = String.raw + +class CssVar { + readonly name: string + constructor(token: string) { + this.name = `--mppx-${token}` + } + toString() { + return `var(${this.name})` + } +} + +export const vars = { + accent: new CssVar('accent'), + background: new CssVar('background'), + border: new CssVar('border'), + foreground: new CssVar('foreground'), + muted: new CssVar('muted'), + negative: new CssVar('negative'), + positive: new CssVar('positive'), + surface: new CssVar('surface'), + fontFamily: new CssVar('font-family'), + fontSizeBase: new CssVar('font-size-base'), + radius: new CssVar('radius'), + spacingUnit: new CssVar('spacing-unit'), +} as const + +export function font(theme: Theme) { + if (!theme.fontUrl) return '' + return html` + ` +} + +export function style(theme: { + [k in keyof Omit]-?: NonNullable +}) { + const colors = Object.fromEntries( + colorTokens.map((name) => [name, resolveColor(theme[name], defaultTheme[name])]), + ) as Record<(typeof colorTokens)[number], readonly [light: string, dark: string]> + const lightVars = colorTokens + .map((token) => `${vars[token].name}: ${colors[token][0]};`) + .join('\n ') + const darkVars = colorTokens + .map((token) => `${vars[token].name}: ${colors[token][1]};`) + .join('\n ') + const isLightOnly = theme.colorScheme === 'light' + const isDarkOnly = theme.colorScheme === 'dark' + const rootVars = isDarkOnly ? darkVars : lightVars + const darkMedia = + !isLightOnly && !isDarkOnly + ? `\n @media (prefers-color-scheme: dark) {\n :root {\n ${darkVars}\n }\n }` + : '' + return html` + + ` +} + +export function showError(message: string) { + const existing = document.getElementById(errorId) + if (existing) { + existing.textContent = message + return + } + const el = document.createElement('p') + el.id = errorId + el.className = classNames.error + el.role = 'alert' + el.textContent = message + document.getElementById(rootId)?.after(el) +} + +export function favicon(theme: Theme, realm: string) { + if (typeof theme.favicon === 'string') + return html`` + if (typeof theme.favicon === 'object') { + return html` + ` + } + // Fallback: use host's favicon via Google S2 service + try { + const domain = new URL(realm).hostname + return html`` + } catch { + return '' + } +} + +export function logo(value: Theme) { + if (typeof value.logo === 'undefined') return '' + if (typeof value.logo === 'string') + return html`` + return Object.entries(value.logo) + .map( + (entry) => + html``, + ) + .join('\n') +} + +export type Text = { + /** Prefix for the expiry line. @default 'Expires at' */ + expires?: string | undefined + /** Pay button label. @default 'Pay' */ + pay?: string | undefined + /** Badge label. @default 'Payment Required' */ + paymentRequired?: string | undefined + /** Page title. @default text.paymentRequired */ + title?: string | undefined +} + +export const defaultText = { + expires: 'Expires at', + pay: 'Pay', + paymentRequired: 'Payment Required', + title: 'Payment Required', +} as const satisfies Required + +export type Theme = { + /** Color scheme. @default 'light dark' */ + colorScheme?: 'light' | 'dark' | 'light dark' | undefined + /** Font family. @default 'system-ui, -apple-system, sans-serif' */ + fontFamily?: string | undefined + /** Base font size. @default '16px' */ + fontSizeBase?: string | undefined + /** Font URL to inject (e.g. Google Fonts ``). */ + fontUrl?: string | undefined + /** Favicon URL. Light/dark variants supported. Falls back to host's favicon via Google S2 service. */ + favicon?: string | { light: string; dark: string } | undefined + /** Logo URL shown in header. Light/dark variants supported. */ + logo?: string | { light: string; dark: string } | undefined + /** Border radius. @default '6px' */ + radius?: string | undefined + /** 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' */ + spacingUnit?: string | undefined + + /** Accent color (buttons, links). @default ['#171717', '#ededed'] */ + accent?: LightDark | undefined + /** Page background. @default ['#ffffff', '#0a0a0a'] */ + background?: LightDark | undefined + /** Border color. @default ['#e5e5e5', '#2e2e2e'] */ + border?: LightDark | undefined + /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */ + foreground?: LightDark | undefined + /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */ + muted?: LightDark | undefined + /** Error/danger color. @default ['#e5484d', '#e5484d'] */ + negative?: LightDark | undefined + /** Success color. @default ['#30a46c', '#30a46c'] */ + positive?: LightDark | undefined + /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */ + surface?: LightDark | undefined +} + +export type LightDark = string | readonly [light: string, dark: string] + +export const defaultTheme = { + colorScheme: 'light dark', + fontFamily: 'system-ui, -apple-system, sans-serif', + fontSizeBase: '16px', + radius: '6px', + spacingUnit: '2px', + + accent: ['#171717', '#ededed'], + background: ['#ffffff', '#0a0a0a'], + border: ['#e5e5e5', '#2e2e2e'], + foreground: ['#0a0a0a', '#ededed'], + muted: ['#666666', '#a1a1a1'], + negative: ['#e5484d', '#e5484d'], + positive: ['#30a46c', '#30a46c'], + surface: ['#f5f5f5', '#1a1a1a'], +} as const satisfies Required> + +export const colorTokens = [ + 'accent', + 'negative', + 'positive', + 'background', + 'foreground', + 'muted', + 'surface', + 'border', +] as const satisfies readonly (keyof typeof defaultTheme)[] + +export function resolveColor( + value: Theme[(typeof colorTokens)[number]] | undefined, + fallback: readonly [string, string], +): readonly [light: string, dark: string] { + if (!value) return fallback + if (typeof value === 'string') return [value, value] + return value +} + +export function mergeDefined(defaults: type, value: DeepPartial | undefined): type { + if (value === undefined) return defaults + if (!isPlainObject(defaults) || !isPlainObject(value)) return (value ?? defaults) as type + + const result: Record = { ...defaults } + + for (const [key, nextValue] of Object.entries(value)) { + if (nextValue === undefined) continue + + const currentValue = result[key] + + result[key] = + isPlainObject(currentValue) && isPlainObject(nextValue) + ? mergeDefined(currentValue, nextValue) + : nextValue + } + + return result as type +} +type DeepPartial = { + [key in keyof type]?: type[key] extends readonly unknown[] + ? type[key] | undefined + : type[key] extends object + ? DeepPartial | undefined + : type[key] | undefined +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Slimmed down Tailwind preflight +// https://github.com/tailwindlabs/tailwindcss/blob/main/packages/tailwindcss/preflight.css +const reset = html` + *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; + padding: 0; border: 0 solid; border-color: ${vars.border}; } html, :host { line-height: 1.5; + -webkit-text-size-adjust: 100%; tab-size: 4; -webkit-tap-highlight-color: transparent; } h1, h2, + h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; + -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } + code, kbd, samp, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; font-size: 1em; } small { font-size: 80%; } ol, ul, + menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; + vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, + optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; + font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; + background-color: transparent; opacity: 1; } ::file-selector-button { margin-inline-end: 4px; } + ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or + (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, + transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: + none; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], + [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } + [hidden]:where(:not([hidden='until-found'])) { display: none !important; } +` diff --git a/src/stripe/internal/types.ts b/src/stripe/internal/types.ts index c36bf59b..94bfc014 100644 --- a/src/stripe/internal/types.ts +++ b/src/stripe/internal/types.ts @@ -1,3 +1,5 @@ +import * as StripeJsTypes from '../../stripe/server/internal/html/types.js' + /** * Duck-typed interface for the Stripe Node SDK (`stripe` npm package). * Matches the subset of the API used by mppx for server-side payment verification. @@ -24,3 +26,21 @@ export type StripeJs = { createPaymentMethod(...args: any[]): Promise> elements(...args: any[]): unknown } + +export type CreatePaymentMethodFromElements = Omit< + StripeJsTypes.CreatePaymentMethodFromElements, + 'elements' +> & {} + +export type StripeElementsOptionsMode = Omit< + Extract, + | 'amount' + | 'currency' + | 'mode' + | 'excludedPaymentMethodTypes' + | 'paymentMethodCreation' + | 'paymentMethodTypes' + | 'payment_method_types' +> & {} + +export type StripePaymentElementOptions = StripeJsTypes.StripePaymentElementOptions diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 12a8dd7c..ec3777ca 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -3,7 +3,14 @@ import { PaymentActionRequiredError, VerificationFailedError } from '../../Error import * as Expires from '../../Expires.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' -import type { StripeClient } from '../internal/types.js' +import type * as Html from '../../server/internal/html/config.ts' +import type * as z from '../../zod.js' +import type { + StripeClient, + CreatePaymentMethodFromElements, + StripeElementsOptionsMode, + StripePaymentElementOptions, +} from '../internal/types.js' import * as Methods from '../Methods.js' import { html as htmlContent } from './internal/html.gen.js' @@ -39,7 +46,7 @@ export function charge(parameters: p decimals, description, externalId, - html, + html: { text: htmlText, theme: htmlTheme, ...htmlConfig } = {}, metadata, networkId, paymentMethodTypes, @@ -61,7 +68,28 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, - html: html ? { config: html, content: htmlContent } : undefined, + html: + 'publishableKey' in htmlConfig && htmlConfig.publishableKey && htmlConfig.createTokenUrl + ? { + config: htmlConfig, + content: htmlContent, + formatAmount: (request: z.output) => { + try { + const formatter = new Intl.NumberFormat('en', { + style: 'currency', + currency: request.currency, + currencyDisplay: 'narrowSymbol', + }) + const decimals = formatter.resolvedOptions().maximumFractionDigits ?? 2 + return formatter.format(Number(request.amount) / 10 ** decimals) + } catch { + return `${request.currency}${request.amount}` + } + }, + text: htmlText, + theme: htmlTheme, + } + : undefined, async verify({ credential }) { const { challenge } = credential @@ -113,7 +141,21 @@ export declare namespace charge { type Parameters = { /** Render payment page when Accept header is text/html (e.g. in browsers) */ - html?: { createTokenUrl: string; publishableKey: string } | undefined + html?: + | { + createTokenUrl: string + elements?: + | { + options?: StripeElementsOptionsMode | undefined + paymentOptions?: StripePaymentElementOptions | undefined + createPaymentMethodOptions?: CreatePaymentMethodFromElements | undefined + } + | undefined + publishableKey: string + text?: Html.Text + theme?: Html.Theme + } + | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined } & Defaults & diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 13f7c578..bc230d48 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,6 +1,7 @@ +import type { Appearance } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js/pure' +import { Json } from 'ox' -import type * as Challenge from '../../../../Challenge.js' import { stripe } from '../../../../client/index.js' import * as Html from '../../../../server/internal/html/config.js' import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' @@ -8,21 +9,45 @@ import type { charge as chargeClient } from '../../../../stripe/client/Charge.js import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' -const data = JSON.parse(document.getElementById(Html.dataId)!.textContent!) as { - config: NonNullable - challenge: Challenge.FromMethods<[typeof Methods.charge]> -} +const dataElement = document.getElementById(Html.dataId)! +const data = Json.parse(dataElement.textContent) as Html.Data< + typeof Methods.charge, + NonNullable +> -const root = document.getElementById('root')! +const root = document.getElementById(Html.rootId)! -const h2 = document.createElement('h2') -h2.textContent = 'stripe' -root.appendChild(h2) +const css = String.raw +const style = document.createElement('style') +style.textContent = css` + form { + display: flex; + flex-direction: column; + gap: calc(${Html.vars.spacingUnit} * 8); + } + button { + background: ${Html.vars.accent}; + border-radius: ${Html.vars.radius}; + color: ${Html.vars.background}; + cursor: pointer; + font-weight: 500; + padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8); + width: 100%; + } + button:hover:not(:disabled) { + opacity: 0.85; + } + button:disabled { + cursor: default; + opacity: 0.5; + } +` +root.append(style) ;(async () => { if (import.meta.env.MODE === 'test') { const button = document.createElement('button') - button.textContent = 'Pay' + button.textContent = data.text.pay root.appendChild(button) button.onclick = async () => { try { @@ -33,6 +58,8 @@ root.appendChild(h2) context: { paymentMethod: 'pm_card_visa' }, }) await submitCredential(credential) + } catch (e) { + Html.showError(e instanceof Error ? e.message : 'Payment failed') } finally { button.disabled = false } @@ -44,16 +71,48 @@ root.appendChild(h2) if (!stripeJs) throw new Error('Failed to loadStripe') const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') - const getAppearance = () => ({ - theme: (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe', - }) + const getAppearance = () => { + const theme = (() => { + if (data.config.elements?.options?.appearance?.theme) + return data.config.elements?.options?.appearance?.theme + switch (data.theme.colorScheme) { + case 'light dark': + return (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe' + case 'light': + return 'stripe' as const + case 'dark': + return 'night' as const + } + })() + const resolvedColorSchemeIndex = darkQuery.matches ? 1 : 0 + return Html.mergeDefined( + { + disableAnimations: true, + theme, + variables: { + borderRadius: data.theme.radius, + colorBackground: data.theme.surface[resolvedColorSchemeIndex], + colorDanger: data.theme.negative[resolvedColorSchemeIndex], + colorPrimary: data.theme.accent[resolvedColorSchemeIndex], + colorText: data.theme.foreground[resolvedColorSchemeIndex], + colorTextSecondary: data.theme.muted[resolvedColorSchemeIndex], + fontSizeBase: data.theme.fontSizeBase, + fontFamily: data.theme.fontFamily, + spacingUnit: data.theme.spacingUnit, + }, + } satisfies Appearance, + (data.config.elements?.options?.appearance as never) ?? {}, + ) + } const elements = stripeJs.elements({ - amount: Number(data.challenge.request.amount), appearance: getAppearance(), - currency: data.challenge.request.currency as string, + ...data.config.elements?.options, + amount: Number(data.challenge.request.amount), + currency: data.challenge.request.currency, mode: 'payment', paymentMethodCreation: 'manual', + paymentMethodTypes: data.challenge.request.methodDetails.paymentMethodTypes, }) darkQuery.addEventListener('change', () => { @@ -61,27 +120,34 @@ root.appendChild(h2) }) const form = document.createElement('form') - elements.create('payment').mount(form) + elements.create('payment', data.config.elements?.paymentOptions).mount(form) root.appendChild(form) const button = document.createElement('button') - button.textContent = 'Pay' + button.textContent = data.text.pay button.type = 'submit' form.appendChild(button) form.onsubmit = async (event) => { event.preventDefault() + document.getElementById(Html.errorId)?.remove() button.disabled = true try { await elements.submit() - const { paymentMethod, error } = await stripeJs.createPaymentMethod({ elements }) - if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method') + const { paymentMethod, error: stripeError } = await stripeJs.createPaymentMethod({ + ...data.config.elements?.createPaymentMethodOptions, + elements, + }) + if (stripeError || !paymentMethod) + throw stripeError ?? new Error('Failed to create payment method') const method = stripe({ client: stripeJs, createToken })[0] const credential = await method.createCredential({ challenge: data.challenge, context: { paymentMethod: paymentMethod.id }, }) await submitCredential(credential) + } catch (e) { + Html.showError(e instanceof Error ? e.message : 'Payment failed') } finally { button.disabled = false } @@ -104,3 +170,5 @@ async function createToken(opts: chargeClient.OnChallengeParameters) { const json = (await res.json()) as { spt: string } return json.spt } + +dataElement.remove() diff --git a/src/stripe/server/internal/html/types.ts b/src/stripe/server/internal/html/types.ts new file mode 100644 index 00000000..90d27207 --- /dev/null +++ b/src/stripe/server/internal/html/types.ts @@ -0,0 +1,5 @@ +export type { + CreatePaymentMethodFromElements, + StripeElementsOptionsMode, + StripePaymentElementOptions, +} from '@stripe/stripe-js' diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 793be15a..ce816c91 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -1,4 +1,10 @@ -import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem' +import { + decodeFunctionData, + formatUnits, + keccak256, + parseEventLogs, + type TransactionReceipt, +} from 'viem' import { getTransactionReceipt, sendRawTransaction, @@ -8,13 +14,15 @@ import { call as viem_call, } from 'viem/actions' import { tempo as tempo_chain } from 'viem/chains' -import { Abis, Transaction } from 'viem/tempo' +import { Abis, Actions, Transaction } from 'viem/tempo' import * as Expires from '../../Expires.js' import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' +import type * as Html from '../../server/internal/html/config.ts' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' +import type * as z from '../../zod.js' import * as Account from '../internal/account.js' import * as TempoAddress from '../internal/address.js' import * as Charge_internal from '../internal/charge.js' @@ -76,7 +84,35 @@ export function charge( recipient, } as unknown as Defaults, - html: html ? { config: {}, content: htmlContent } : undefined, + html: html + ? { + config: {}, + content: htmlContent, + formatAmount: async (request: z.output) => { + try { + const chainId = request.methodDetails?.chainId + if (chainId === undefined) throw new Error('no chainId') + const client = await getClient({ chainId }) + const metadata = await Actions.token.getMetadata(client, { + token: request.currency as `0x${string}`, + }) + const symbol = + new Intl.NumberFormat('en', { + style: 'currency', + currency: metadata.currency, + currencyDisplay: 'narrowSymbol', + }) + .formatToParts(0) + .find((p) => p.type === 'currency')?.value ?? metadata.currency + return `${symbol}${formatUnits(BigInt(request.amount), metadata.decimals)}` + } catch { + return `$${request.amount}` + } + }, + text: typeof html === 'object' ? html.text : undefined, + theme: typeof html === 'object' ? html.theme : undefined, + } + : undefined, // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { @@ -297,7 +333,13 @@ export declare namespace charge { type Parameters = { /** Render payment page when Accept header is text/html (e.g. in browsers) */ - html?: boolean | undefined + html?: + | boolean + | { + text?: Html.Text + theme?: Html.Theme + } + | undefined /** Testnet mode. */ testnet?: boolean | undefined /** diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 561ce010..3d8c9f56 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -3,21 +3,50 @@ import { Json } from 'ox' import { createClient, custom, http } from 'viem' import { tempoModerato, tempoLocalnet } from 'viem/chains' -import type * as Challenge from '../../../../Challenge.js' import { tempo } from '../../../../client/index.js' import * as Html from '../../../../server/internal/html/config.js' import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' import type * as Methods from '../../../Methods.js' -const data = Json.parse(document.getElementById(Html.dataId)!.textContent) as { - challenge: Challenge.FromMethods<[typeof Methods.charge]> -} +const dataElement = document.getElementById(Html.dataId)! +const data = Json.parse(dataElement.textContent) as Html.Data -const root = document.getElementById('root')! +const root = document.getElementById(Html.rootId)! -const h2 = document.createElement('h2') -h2.textContent = 'tempo' -root.appendChild(h2) +const css = String.raw +const style = document.createElement('style') +style.textContent = css` + form { + display: flex; + flex-direction: column; + gap: calc(${Html.vars.spacingUnit} * 8); + } + button { + background: ${Html.vars.accent}; + border-radius: ${Html.vars.radius}; + color: ${Html.vars.background}; + cursor: pointer; + font-weight: 500; + padding: calc(${Html.vars.spacingUnit} * 4) calc(${Html.vars.spacingUnit} * 8); + width: 100%; + } + button:hover:not(:disabled) { + opacity: 0.85; + } + button:disabled { + cursor: default; + opacity: 0.5; + } + button svg { + display: inline; + fill: currentColor; + height: 0.85em; + transform: translateY(0.05em); + vertical-align: baseline; + width: auto; + } +` +root.append(style) const provider = Provider.create({ // Dead code eliminated from production bundle (including top-level imports) @@ -49,23 +78,34 @@ const provider = Provider.create({ }) const button = document.createElement('button') -button.textContent = 'Continue with Tempo' +button.innerHTML = + 'Continue with ' button.onclick = async () => { try { + document.getElementById(Html.errorId)?.remove() button.disabled = true const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( (x) => x.id === data.challenge.request.methodDetails?.chainId, ) const client = createClient({ chain, transport: custom(provider) }) - const result = await provider.request({ method: 'wallet_connect' }) - const account = result.accounts[0]?.address + const account = await (async () => { + const accounts = await provider.request({ method: 'eth_accounts' }) + if (accounts.length > 0) return accounts.at(0) + const result = await provider.request({ method: 'wallet_connect' }) + return result.accounts[0]?.address + })() const method = tempo({ account, getClient: () => client })[0] const credential = await method.createCredential({ challenge: data.challenge, context: {} }) await submitCredential(credential) + } catch (e) { + const message = e instanceof Error && 'shortMessage' in e ? (e as any).shortMessage : undefined + Html.showError(message ?? (e instanceof Error ? e.message : 'Payment failed')) } finally { button.disabled = false } } root.appendChild(button) + +dataElement.remove() diff --git a/src/tempo/server/internal/html/package.json b/src/tempo/server/internal/html/package.json index af5ea7e8..ccf2f198 100644 --- a/src/tempo/server/internal/html/package.json +++ b/src/tempo/server/internal/html/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "dependencies": { - "accounts": "https://pkg.pr.new/tempoxyz/accounts@c339a21", + "accounts": "0.4.12", "mppx": "workspace:*", "viem": "2.47.5" } diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index c3ad6541..cf057891 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -9,7 +9,7 @@ test('charge via stripe html payment page', async ({ page }, testInfo) => { }) // Verify 402 payment page rendered - await expect(page.locator('h1')).toHaveText('Payment Required') + await expect(page.getByText('Payment Required')).toBeVisible() await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 }) if (!testInfo.project.use.headless) { @@ -33,8 +33,8 @@ test('charge via stripe html payment page', async ({ page }, testInfo) => { await page.waitForTimeout(500) } - // Submit payment - await page.getByRole('button', { name: 'Pay' }).click() + // Submit payment (force needed — Stripe Link overlay can intercept click) + await page.getByRole('button', { name: 'Pay' }).click({ force: true }) // Wait for service worker to submit credential and page to reload with paid response await expect(page.locator('body')).toContainText('"fortune":', { timeout: 30_000 }) diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts index 07265e2b..b9f03bfa 100644 --- a/test/html/tempo.test.ts +++ b/test/html/tempo.test.ts @@ -1,17 +1,17 @@ import { expect, test } from '@playwright/test' -test('charge via html payment page', async ({ page }) => { +test('charge via tempo html payment page', async ({ page }) => { // Navigate to the payment endpoint as a browser await page.goto('/tempo/charge', { waitUntil: 'domcontentloaded', }) // Verify 402 payment page rendered - await expect(page.locator('h1')).toHaveText('Payment Required') - await expect(page.getByText('Continue with Tempo')).toBeVisible() + await expect(page.getByText('Payment Required')).toBeVisible() + await expect(page.getByRole('button', { name: /continue with tempo/i })).toBeVisible() // Click the pay button (local adapter signs without dialog) - await page.getByText('Continue with Tempo').click() + await page.getByRole('button', { name: /continue with tempo/i }).click() // Wait for service worker to submit credential and page to reload with paid response await expect(page.locator('body')).toContainText('"url":', { timeout: 30_000 })