From b4d2548d79b9eaba4dfaecbfe6d444678e338d95 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 1 Apr 2026 14:11:09 -0500 Subject: [PATCH 01/11] wip(html): theme + stripe options --- examples/charge/src/server.ts | 6 ++++- examples/stripe/src/server.ts | 17 +++++++++++- src/server/Transport.ts | 5 ++++ src/server/internal/html/config.ts | 32 ++++++++++++++++++++++ src/stripe/internal/types.ts | 20 ++++++++++++++ src/stripe/server/Charge.ts | 34 +++++++++++++++++++++--- src/stripe/server/internal/html/main.ts | 13 ++++++--- src/stripe/server/internal/html/types.ts | 5 ++++ src/tempo/server/Charge.ts | 16 +++++++++-- test/html/tempo.test.ts | 2 +- 10 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 src/stripe/server/internal/html/types.ts diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index f94cb111..a77a215e 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -13,7 +13,11 @@ const mppx = Mppx.create({ account, currency, feePayer: true, - html: true, + html: { + theme: { + logo: 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22white%22/%3E%0A%3C/svg%3E', + }, + }, recipient: account.address, testnet: true, }), diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 796d22d8..dc06c7db 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -10,8 +10,23 @@ const mppx = Mppx.create({ stripe.charge({ client: stripeClient, html: { - publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, createTokenUrl: '/api/create-spt', + elements: { + options: {}, + paymentOptions: { + fields: { + billingDetails: { address: { postalCode: 'never', country: 'never' } }, + }, + }, + createPaymentMethodOptions: { + params: { + billing_details: { + address: { postal_code: '10001', country: 'US' }, + }, + }, + }, + }, + publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, }, // Stripe Business Network profile ID. networkId: 'internal', diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 21fe77c8..86563462 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -146,6 +146,7 @@ export function http(): Http { const body = (() => { if (options.html && input.headers.get('Accept')?.includes('text/html')) { headers['Content-Type'] = 'text/html; charset=utf-8' + const theme = options.html.theme const html = String.raw return html` @@ -161,6 +162,10 @@ export function http(): Http {

Payment Required

+ + ${theme?.logo && + ``} +
 ${Json.stringify(challenge, null, 2)
                     .replace(/&/g, '&')
diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts
index 4c29208e..9d1950c7 100644
--- a/src/server/internal/html/config.ts
+++ b/src/server/internal/html/config.ts
@@ -1,8 +1,40 @@
 export type Options = {
   config: Record
   content: string
+  theme: Theme | undefined
 }
 
 export const dataId = '__MPPX_DATA__'
 
 export const serviceWorkerParam = '__mppx_worker'
+
+export type Theme = {
+  /** Color scheme. @default 'light dark' */
+  colorScheme?: 'light' | 'dark' | 'light dark' | undefined
+  /** Accent color (buttons, links). @default ['#171717', '#ededed'] */
+  accent?: LightDark | undefined
+  /** Error/danger color. @default ['#e5484d', '#e5484d'] */
+  negative?: LightDark | undefined
+  /** Success color. @default ['#30a46c', '#30a46c'] */
+  positive?: LightDark | undefined
+  /** Page background. @default ['#ffffff', '#0a0a0a'] */
+  background?: LightDark | undefined
+  /** Primary text/content color. @default ['#0a0a0a', '#ededed'] */
+  foreground?: LightDark | undefined
+  /** Secondary/muted text. @default ['#666666', '#a1a1a1'] */
+  muted?: LightDark | undefined
+  /** Input/card surface. @default ['#f5f5f5', '#1a1a1a'] */
+  surface?: LightDark | undefined
+  /** Border color. @default ['#e5e5e5', '#2e2e2e'] */
+  border?: LightDark | undefined
+  /** Border radius. @default '6px' */
+  radius?: string | undefined
+  /** Font family. @default 'system-ui, -apple-system, sans-serif' */
+  fontFamily?: string | undefined
+  /** Font URL to inject (e.g. Google Fonts ``). */
+  fontUrl?: string | undefined
+  /** Logo URL shown in header. Light/dark variants supported. */
+  logo?: string | { light: string; dark: string } | undefined
+}
+
+export type LightDark = string | readonly [light: string, dark: string]
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..57d5d11d 100644
--- a/src/stripe/server/Charge.ts
+++ b/src/stripe/server/Charge.ts
@@ -3,7 +3,13 @@ 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 {
+  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 +45,7 @@ export function charge(parameters: p
     decimals,
     description,
     externalId,
-    html,
+    html: { theme: htmlTheme, ...htmlConfig } = {},
     metadata,
     networkId,
     paymentMethodTypes,
@@ -61,7 +67,14 @@ 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,
+            theme: htmlTheme,
+          }
+        : undefined,
 
     async verify({ credential }) {
       const { challenge } = credential
@@ -113,7 +126,20 @@ 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
+          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..967e0601 100644
--- a/src/stripe/server/internal/html/main.ts
+++ b/src/stripe/server/internal/html/main.ts
@@ -49,11 +49,13 @@ root.appendChild(h2)
   })
 
   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,7 +63,7 @@ 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')
@@ -74,7 +76,10 @@ root.appendChild(h2)
     button.disabled = true
     try {
       await elements.submit()
-      const { paymentMethod, error } = await stripeJs.createPaymentMethod({ elements })
+      const { paymentMethod, error } = await stripeJs.createPaymentMethod({
+        ...data.config.elements?.createPaymentMethodOptions,
+        elements,
+      })
       if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method')
       const method = stripe({ client: stripeJs, createToken })[0]
       const credential = await method.createCredential({
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..0ad8cd3b 100644
--- a/src/tempo/server/Charge.ts
+++ b/src/tempo/server/Charge.ts
@@ -13,6 +13,7 @@ import { Abis, 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 * as Account from '../internal/account.js'
@@ -76,7 +77,13 @@ export function charge(
       recipient,
     } as unknown as Defaults,
 
-    html: html ? { config: {}, content: htmlContent } : undefined,
+    html: html
+      ? {
+          config: {},
+          content: htmlContent,
+          theme: typeof html === 'object' ? html.theme : undefined,
+        }
+      : undefined,
 
     // TODO: dedupe `{charge,session}.request`
     async request({ credential, request }) {
@@ -297,7 +304,12 @@ export declare namespace charge {
 
   type Parameters = {
     /** Render payment page when Accept header is text/html (e.g. in browsers) */
-    html?: boolean | undefined
+    html?:
+      | boolean
+      | {
+          theme?: Html.Theme
+        }
+      | undefined
     /** Testnet mode. */
     testnet?: boolean | undefined
     /**
diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts
index 07265e2b..b31472f6 100644
--- a/test/html/tempo.test.ts
+++ b/test/html/tempo.test.ts
@@ -1,6 +1,6 @@
 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',

From eaa09ae6bb97e214ba2b96b5eeb7294541ab9d82 Mon Sep 17 00:00:00 2001
From: tmm 
Date: Wed, 1 Apr 2026 15:59:07 -0500
Subject: [PATCH 02/11] wip: elements theme

---
 examples/charge/src/server.ts           |   9 +-
 examples/stripe/src/server.ts           |  10 ++
 src/server/Transport.ts                 |  38 +++---
 src/server/internal/html/config.ts      | 152 ++++++++++++++++++++++++
 src/stripe/server/Charge.ts             |   4 +-
 src/stripe/server/internal/html/main.ts |  41 ++++++-
 src/tempo/server/Charge.ts              |   2 +
 src/tempo/server/internal/html/main.ts  |   4 +
 8 files changed, 239 insertions(+), 21 deletions(-)

diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts
index a77a215e..0cb914b3 100644
--- a/examples/charge/src/server.ts
+++ b/examples/charge/src/server.ts
@@ -14,8 +14,15 @@ const mppx = Mppx.create({
       currency,
       feePayer: true,
       html: {
+        text: {
+          title: 'MPP Payment Required',
+        },
         theme: {
-          logo: 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22white%22/%3E%0A%3C/svg%3E',
+          logo: {
+            dark: 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22white%22/%3E%0A%3C/svg%3E',
+            light:
+              'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22black%22/%3E%0A%3C/svg%3E',
+          },
         },
       },
       recipient: account.address,
diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts
index dc06c7db..f538a2df 100644
--- a/examples/stripe/src/server.ts
+++ b/examples/stripe/src/server.ts
@@ -27,6 +27,16 @@ const mppx = Mppx.create({
           },
         },
         publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
+        text: {
+          title: 'MPP Payment Required',
+        },
+        theme: {
+          logo: {
+            dark: 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22white%22/%3E%0A%3C/svg%3E',
+            light:
+              'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22black%22/%3E%0A%3C/svg%3E',
+          },
+        },
       },
       // Stripe Business Network profile ID.
       networkId: 'internal',
diff --git a/src/server/Transport.ts b/src/server/Transport.ts
index 86563462..993cea18 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'
@@ -146,25 +147,29 @@ export function http(): Http {
       const body = (() => {
         if (options.html && input.headers.get('Accept')?.includes('text/html')) {
           headers['Content-Type'] = 'text/html; charset=utf-8'
-          const theme = options.html.theme
-          const html = String.raw
+
+          const theme = Html.mergeDefined(
+            {
+              logo: undefined as Html.Theme['logo'],
+              ...Html.defaultTheme,
+            },
+            (options.html.theme as never) ?? {},
+          )
+          const text = Html.mergeDefined(Html.defaultText, (options.html.text as never) ?? {})
+
           return html`
             
               
                 
                 
-                Payment Required
-                
+                ${text.title}
+                ${Html.style(theme)}
               
               
-                

Payment Required

- - ${theme?.logo && - ``} +
+ ${Html.logo(theme.logo)} +

${text.title}

+
 ${Json.stringify(challenge, null, 2)
@@ -174,10 +179,11 @@ ${Json.stringify(challenge, null, 2)
                 >
                 
${options.html.content} diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 9d1950c7..d7f6fe72 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -1,6 +1,7 @@ export type Options = { config: Record content: string + text: Text | undefined theme: Theme | undefined } @@ -8,6 +9,92 @@ export const dataId = '__MPPX_DATA__' export const serviceWorkerParam = '__mppx_worker' +export const classNames = { + logo: 'mppx-logo', + logoColorScheme: (colorScheme: string) => + colorScheme === 'dark' || colorScheme === 'light' + ? `${classNames.logo}--${colorScheme}` + : undefined, +} + +export const html = String.raw + +export function style(theme: Theme) { + 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) => `--mppx-${token}: ${colors[token][0]};`) + .join('\n ') + const darkVars = colorTokens + .map((token) => `--mppx-${token}: ${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 logo(value: Theme['logo']) { + if (typeof value === 'undefined') return `` + if (typeof value === 'string') return html`` + return Object.entries(value) + .map( + ([key, value]) => + html``, + ) + .join('\n') +} + +export type Text = { + /** Page title. @default 'Payment Required' */ + title?: string | undefined +} + +export const defaultText = { + title: 'Payment Required', +} as const satisfies Required + export type Theme = { /** Color scheme. @default 'light dark' */ colorScheme?: 'light' | 'dark' | 'light dark' | undefined @@ -38,3 +125,68 @@ export type Theme = { } export type LightDark = string | readonly [light: string, dark: string] + +export const defaultTheme = { + colorScheme: 'light dark', + accent: ['#171717', '#ededed'], + negative: ['#e5484d', '#e5484d'], + positive: ['#30a46c', '#30a46c'], + background: ['#ffffff', '#0a0a0a'], + foreground: ['#0a0a0a', '#ededed'], + muted: ['#666666', '#a1a1a1'], + surface: ['#f5f5f5', '#1a1a1a'], + border: ['#e5e5e5', '#2e2e2e'], + radius: '6px', + fontFamily: 'system-ui, -apple-system, sans-serif', +} 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) +} diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 57d5d11d..7686b3ad 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -45,7 +45,7 @@ export function charge(parameters: p decimals, description, externalId, - html: { theme: htmlTheme, ...htmlConfig } = {}, + html: { text: htmlText, theme: htmlTheme, ...htmlConfig } = {}, metadata, networkId, paymentMethodTypes, @@ -72,6 +72,7 @@ export function charge(parameters: p ? { config: htmlConfig, content: htmlContent, + text: htmlText, theme: htmlTheme, } : undefined, @@ -137,6 +138,7 @@ export declare namespace charge { } | undefined publishableKey: string + text?: Html.Text theme?: Html.Theme } | undefined diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 967e0601..4002abd9 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,3 +1,4 @@ +import type { Appearance } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js/pure' import type * as Challenge from '../../../../Challenge.js' @@ -11,6 +12,9 @@ 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]> + theme: { + [k in keyof Omit]-?: NonNullable + } } const root = document.getElementById('root')! @@ -44,9 +48,40 @@ 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 ? 1 : 0 + console.log({ theme, resolvedColorSchemeIndex, darkQuery }) + return Html.mergeDefined( + { + theme, + variables: { + colorPrimary: data.theme.accent[resolvedColorSchemeIndex], + colorBackground: data.theme.surface[resolvedColorSchemeIndex], + colorText: data.theme.foreground[resolvedColorSchemeIndex], + colorTextSecondary: data.theme.muted[resolvedColorSchemeIndex], + colorDanger: data.theme.negative[resolvedColorSchemeIndex], + fontFamily: data.theme.fontFamily, + fontWeightNormal: '400', + fontSizeSm: '0.875rem', + borderRadius: data.theme.radius, + spacingUnit: '2px', + }, + } satisfies Appearance, + (data.config.elements?.options?.appearance as never) ?? {}, + ) + } const elements = stripeJs.elements({ appearance: getAppearance(), diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 0ad8cd3b..dd5de174 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -81,6 +81,7 @@ export function charge( ? { config: {}, content: htmlContent, + text: typeof html === 'object' ? html.text : undefined, theme: typeof html === 'object' ? html.theme : undefined, } : undefined, @@ -307,6 +308,7 @@ export declare namespace charge { html?: | boolean | { + text?: Html.Text theme?: Html.Theme } | undefined diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 561ce010..16a3e5d9 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -10,7 +10,11 @@ import { submitCredential } from '../../../../server/internal/html/serviceWorker import type * as Methods from '../../../Methods.js' const data = Json.parse(document.getElementById(Html.dataId)!.textContent) as { + config: {} challenge: Challenge.FromMethods<[typeof Methods.charge]> + theme: { + [k in keyof Omit]-?: NonNullable + } } const root = document.getElementById('root')! From 57bf8ab81c5ebf99762e64c8bc7545dc3c7da4f9 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 1 Apr 2026 16:09:57 -0500 Subject: [PATCH 03/11] chore: tweaks --- src/server/Transport.ts | 1 + src/server/internal/html/config.ts | 8 ++++++-- src/stripe/server/internal/html/main.ts | 17 ++++++++++------- src/tempo/server/internal/html/main.ts | 5 ++++- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 993cea18..c69c2c94 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -150,6 +150,7 @@ export function http(): Http { const theme = Html.mergeDefined( { + fontUrl: undefined as Html.Theme['fontUrl'], logo: undefined as Html.Theme['logo'], ...Html.defaultTheme, }, diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index d7f6fe72..9b6a169c 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -19,7 +19,9 @@ export const classNames = { export const html = String.raw -export function style(theme: Theme) { +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]> @@ -36,6 +38,7 @@ export function style(theme: Theme) { !isLightOnly && !isDarkOnly ? `\n @media (prefers-color-scheme: dark) {\n :root {\n ${darkVars}\n }\n }` : '' + // TODO: basic stylesheet reset return html` ` } export function logo(value: Theme['logo']) { if (typeof value === 'undefined') return `` - if (typeof value === 'string') return html`` + if (typeof value === 'string') + return html`` return Object.entries(value) .map( ([key, value]) => - html``, + html``, ) .join('\n') } @@ -102,46 +175,54 @@ export const defaultText = { 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 + /** 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 - /** Error/danger color. @default ['#e5484d', '#e5484d'] */ - negative?: LightDark | undefined - /** Success color. @default ['#30a46c', '#30a46c'] */ - positive?: 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 - /** Border color. @default ['#e5e5e5', '#2e2e2e'] */ - border?: LightDark | undefined - /** Border radius. @default '6px' */ - radius?: string | undefined - /** Font family. @default 'system-ui, -apple-system, sans-serif' */ - fontFamily?: string | undefined - /** Font URL to inject (e.g. Google Fonts ``). */ - fontUrl?: string | undefined - /** Logo URL shown in header. Light/dark variants supported. */ - logo?: string | { light: string; dark: string } | 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'], - negative: ['#e5484d', '#e5484d'], - positive: ['#30a46c', '#30a46c'], background: ['#ffffff', '#0a0a0a'], + border: ['#e5e5e5', '#2e2e2e'], foreground: ['#0a0a0a', '#ededed'], muted: ['#666666', '#a1a1a1'], + negative: ['#e5484d', '#e5484d'], + positive: ['#30a46c', '#30a46c'], surface: ['#f5f5f5', '#1a1a1a'], - border: ['#e5e5e5', '#2e2e2e'], - radius: '6px', - fontFamily: 'system-ui, -apple-system, sans-serif', } as const satisfies Required> export const colorTokens = [ @@ -194,3 +275,27 @@ type DeepPartial = { 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/server/Charge.ts b/src/stripe/server/Charge.ts index 7686b3ad..8a541364 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -4,6 +4,7 @@ import * as Expires from '../../Expires.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' import type * as Html from '../../server/internal/html/config.ts' +import type * as z from '../../zod.js' import type { StripeClient, CreatePaymentMethodFromElements, @@ -72,6 +73,8 @@ export function charge(parameters: p ? { config: htmlConfig, content: htmlContent, + formatAmount: (request: z.output) => + `${request.currency}${request.amount}`, text: htmlText, theme: htmlTheme, } diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 6d1f7e26..9f3c3f56 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,7 +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' @@ -10,19 +10,38 @@ import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' const dataElement = document.getElementById(Html.dataId)! -const data = JSON.parse(dataElement.textContent) as { - config: NonNullable - challenge: Challenge.FromMethods<[typeof Methods.charge]> - theme: { - [k in keyof Omit]-?: NonNullable - } -} +const data = Json.parse(dataElement.textContent) as Html.Data< + typeof Methods.charge, + NonNullable +> const root = document.getElementById('root')! -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); + } + button:hover:not(:disabled) { + opacity: 0.85; + } + button:disabled { + cursor: default; + opacity: 0.5; + } +` +root.append(style) ;(async () => { if (import.meta.env.MODE === 'test') { @@ -74,10 +93,9 @@ root.appendChild(h2) colorPrimary: data.theme.accent[resolvedColorSchemeIndex], colorText: data.theme.foreground[resolvedColorSchemeIndex], colorTextSecondary: data.theme.muted[resolvedColorSchemeIndex], + fontSizeBase: data.theme.fontSizeBase, fontFamily: data.theme.fontFamily, - fontSizeSm: '0.875rem', - fontWeightNormal: '400', - spacingUnit: '2px', + spacingUnit: data.theme.spacingUnit, }, } satisfies Appearance, (data.config.elements?.options?.appearance as never) ?? {}, diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index dd5de174..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,7 +14,7 @@ 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' @@ -16,6 +22,7 @@ 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' @@ -81,6 +88,27 @@ export function charge( ? { 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, } diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index c18e96d7..483dcc6e 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -3,26 +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 dataElement = document.getElementById(Html.dataId)! -const data = Json.parse(dataElement.textContent) as { - config: {} - challenge: Challenge.FromMethods<[typeof Methods.charge]> - theme: { - [k in keyof Omit]-?: NonNullable - } -} +const data = Json.parse(dataElement.textContent) as Html.Data const root = document.getElementById('root')! -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) @@ -54,7 +78,8 @@ const provider = Provider.create({ }) const button = document.createElement('button') -button.textContent = 'Continue with Tempo' +button.innerHTML = + 'Continue with ' button.onclick = async () => { try { button.disabled = true From b2ffc202e9b79a8bb3b76442cba68af094e4b56c Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 01:02:47 -0500 Subject: [PATCH 05/11] wip: styles --- examples/charge/src/server.ts | 13 +----- examples/stripe/src/server.ts | 25 ----------- src/server/Transport.ts | 26 ++++++++---- src/server/internal/html/config.ts | 56 +++++++++++++++++++++++-- src/stripe/server/Charge.ts | 15 ++++++- src/stripe/server/internal/html/main.ts | 16 ++++--- src/tempo/server/internal/html/main.ts | 5 ++- 7 files changed, 100 insertions(+), 56 deletions(-) diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index 0cb914b3..f94cb111 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -13,18 +13,7 @@ const mppx = Mppx.create({ account, currency, feePayer: true, - html: { - text: { - title: 'MPP Payment Required', - }, - theme: { - logo: { - dark: 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22white%22/%3E%0A%3C/svg%3E', - light: - 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22black%22/%3E%0A%3C/svg%3E', - }, - }, - }, + html: true, recipient: account.address, testnet: true, }), diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index f538a2df..20de44c4 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -11,32 +11,7 @@ const mppx = Mppx.create({ client: stripeClient, html: { createTokenUrl: '/api/create-spt', - elements: { - options: {}, - paymentOptions: { - fields: { - billingDetails: { address: { postalCode: 'never', country: 'never' } }, - }, - }, - createPaymentMethodOptions: { - params: { - billing_details: { - address: { postal_code: '10001', country: 'US' }, - }, - }, - }, - }, publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, - text: { - title: 'MPP Payment Required', - }, - theme: { - logo: { - dark: 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22white%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22white%22/%3E%0A%3C/svg%3E', - light: - 'data:image/svg+xml,%3Csvg%20width%3D%22184%22%20height%3D%2241%22%20viewBox%3D%220%200%20184%2041%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%0A%3Cpath%20d%3D%22M13.6424%2040.3635H2.80251L12.8492%209.60026H0L2.80251%200.58344H38.6006L35.7981%209.60026H23.6362L13.6424%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M53.9809%2040.3635H28.2824L41.1846%200.58344H66.7773L64.3449%208.16818H49.4863L46.7896%2016.7076H61.1723L58.7399%2024.1863H44.3043L41.6076%2032.7788H56.3604L53.9809%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M65.6123%2040.3635H56.9933L69.9483%200.58344H84.331L83.8551%2022.0647L97.8676%200.58344H113.625L100.723%2040.3635H89.936L98.5021%2013.6313H98.3435L80.7353%2040.3635H74.3371L74.6015%2013.3131H74.4957L65.6123%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M125.758%207.95602L121.581%2020.7917H122.744C125.388%2020.7917%20127.592%2020.1729%20129.354%2018.9353C131.117%2017.6624%20132.262%2015.859%20132.791%2013.5252C133.249%2011.5097%20133.003%2010.0776%20132.051%209.22898C131.099%208.38034%20129.513%207.95602%20127.292%207.95602H125.758ZM115.289%2040.3635H104.449L117.351%200.58344H130.517C133.549%200.58344%20136.158%201.07848%20138.343%202.06856C140.564%203.02328%20142.186%204.40233%20143.208%206.20569C144.266%207.97369%20144.618%2010.0423%20144.266%2012.4114C143.807%2015.5231%20142.609%2018.2635%20140.67%2020.6326C138.731%2023.0017%20136.211%2024.8405%20133.108%2026.1488C130.042%2027.4217%20126.604%2028.0582%20122.797%2028.0582H119.255L115.289%2040.3635Z%22%20fill%3D%22black%22/%3E%0A%3Cpath%20d%3D%22M170.103%2037.8176C166.507%2039.9392%20162.682%2041%20158.628%2041H158.523C154.927%2041%20151.895%2040.2044%20149.428%2038.6132C146.995%2036.9866%20145.25%2034.7943%20144.193%2032.0362C143.171%2029.2781%20142.924%2026.2549%20143.453%2022.9664C144.122%2018.8292%20145.656%2015.0103%20148.053%2011.5097C150.45%208.00906%20153.446%205.21561%20157.042%203.12937C160.638%201.04312%20164.48%200%20168.569%200H168.675C172.412%200%20175.496%200.795602%20177.929%202.38681C180.396%203.97801%20182.106%206.15265%20183.058%208.91074C184.045%2011.6335%20184.256%2014.6921%20183.692%2018.0867C183.023%2022.0824%20181.489%2025.8482%20179.092%2029.3842C176.695%2032.8849%20173.699%2035.696%20170.103%2037.8176ZM155.138%2030.9754C156.09%2032.7788%20157.747%2033.6805%20160.109%2033.6805H160.215C162.154%2033.6805%20163.951%2032.9556%20165.608%2031.5058C167.3%2030.0207%20168.728%2028.0405%20169.891%2025.5653C171.09%2023.0901%20171.971%2020.332%20172.535%2017.2911C173.064%2014.3208%20172.852%2011.934%20171.901%2010.1307C170.949%208.29194%20169.31%207.37257%20166.983%207.37257H166.877C165.079%207.37257%20163.335%208.11514%20161.642%209.60026C159.986%2011.0854%20158.54%2013.0832%20157.306%2015.5938C156.073%2018.1044%20155.174%2020.8271%20154.61%2023.762C154.046%2026.7322%20154.222%2029.1367%20155.138%2030.9754Z%22%20fill%3D%22black%22/%3E%0A%3C/svg%3E', - }, - }, }, // Stripe Business Network profile ID. networkId: 'internal', diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 181d7add..a2598387 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -163,28 +163,38 @@ export function http(): Http { + + ${text.title} + ${theme.fontUrl + ? ` + ` + : ''} ${Html.style(theme)}
${Html.logo(theme.logo)} -

${text.title}

+ ${text.paymentRequired}
- -
-
${await options.html.formatAmount(challenge.request)}
- ${challenge.description ? `
${challenge.description}
` : ''} +
+

+ ${await options.html.formatAmount(challenge.request)} +

+ ${challenge.description + ? `

${challenge.description}

` + : ''} ${challenge.expires - ? `
Expires at ${new Date(challenge.expires).toLocaleString()}
` + ? `

${text.expires}

` : ''} -
-
+ +
diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 728f37f6..96d690de 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -15,6 +15,7 @@ export type Data< > = { config: config challenge: Challenge.FromMethods<[method]> + text: { [k in keyof Text]-?: NonNullable } theme: { [k in keyof Omit]-?: NonNullable } @@ -22,9 +23,12 @@ export type Data< export const dataId = '__MPPX_DATA__' +export const rootId = 'root' + export const serviceWorkerParam = '__mppx_worker' export const classNames = { + error: 'mppx-error', header: 'mppx-header', logo: 'mppx-logo', logoColorScheme: (colorScheme: string) => @@ -109,12 +113,15 @@ export function style(theme: { margin-left: auto; margin-right: auto; max-width: clamp(300px, calc(${vars.spacingUnit} * 224), 896px); + padding: calc(${vars.spacingUnit} * 12) calc(${vars.spacingUnit} * 8) calc(${vars.spacingUnit} * 16); } .${classNames.header} { align-items: center; display: flex; + flex-wrap: wrap; + gap: calc(${vars.spacingUnit} * 4); justify-content: space-between; - h1 { + span { background: ${vars.surface}; border: 1px solid ${vars.border}; border-radius: calc(${vars.spacingUnit} * 50); @@ -141,12 +148,46 @@ export function style(theme: { background: ${vars.surface}; border: 1px solid ${vars.border}; border-radius: ${vars.radius}; - padding: calc(${vars.spacingUnit} * 1) calc(${vars.spacingUnit} * 4); + display: flex; + flex-direction: column; + gap: calc(${vars.spacingUnit} * 3); + padding: calc(${vars.spacingUnit} * 6) calc(${vars.spacingUnit} * 6); + } + .${classNames.summaryAmount} { + font-size: 2.5rem; + font-variant-numeric: tabular-nums; + font-weight: 700; + line-height: 1.2; + } + .${classNames.summaryDescription} { + font-size: 1.25rem; + } + .${classNames.summaryExpires} { + color: ${vars.muted}; + } + .${classNames.error} { + color: ${vars.negative}; + font-size: 0.95rem; + text-align: center; } ` } +export function showError(message: string) { + const existing = document.getElementById('__MPPX_ERROR__') + if (existing) { + existing.textContent = message + return + } + const el = document.createElement('p') + el.id = 'root_error' + el.className = classNames.error + el.role = 'alert' + el.textContent = message + document.getElementById(rootId)?.after(el) +} + export function logo(value: Theme['logo']) { if (typeof value === 'undefined') return `` if (typeof value === 'string') @@ -164,11 +205,20 @@ export function logo(value: Theme['logo']) { } export type Text = { - /** Page title. @default 'Payment Required' */ + /** 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 diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 8a541364..ec3777ca 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -73,8 +73,19 @@ export function charge(parameters: p ? { config: htmlConfig, content: htmlContent, - formatAmount: (request: z.output) => - `${request.currency}${request.amount}`, + 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, } diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 9f3c3f56..372aba33 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -15,7 +15,7 @@ const data = Json.parse(dataElement.textContent) as Html.Data< NonNullable > -const root = document.getElementById('root')! +const root = document.getElementById(Html.rootId)! const css = String.raw const style = document.createElement('style') @@ -32,6 +32,7 @@ style.textContent = css` 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; @@ -46,7 +47,7 @@ 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 { @@ -57,6 +58,8 @@ root.append(style) context: { paymentMethod: 'pm_card_visa' }, }) await submitCredential(credential) + } catch (e) { + Html.showError(e instanceof Error ? e.message : 'Payment failed') } finally { button.disabled = false } @@ -121,7 +124,7 @@ root.append(style) root.appendChild(form) const button = document.createElement('button') - button.textContent = 'Pay' + button.textContent = data.text.pay button.type = 'submit' form.appendChild(button) @@ -130,17 +133,20 @@ root.append(style) button.disabled = true try { await elements.submit() - const { paymentMethod, error } = await stripeJs.createPaymentMethod({ + const { paymentMethod, error: stripeError } = await stripeJs.createPaymentMethod({ ...data.config.elements?.createPaymentMethodOptions, elements, }) - if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method') + 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 } diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 483dcc6e..d6b5e7e7 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -11,7 +11,7 @@ import type * as Methods from '../../../Methods.js' 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 css = String.raw const style = document.createElement('style') @@ -94,6 +94,9 @@ button.onclick = async () => { 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 } From dcbf3771cc25904a563ca6150de58fa570d05a62 Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 01:21:22 -0500 Subject: [PATCH 06/11] test: up --- test/html/stripe.test.ts | 6 +++--- test/html/tempo.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) 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 b31472f6..b9f03bfa 100644 --- a/test/html/tempo.test.ts +++ b/test/html/tempo.test.ts @@ -7,11 +7,11 @@ test('charge via tempo html payment page', async ({ page }) => { }) // 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 }) From 1e6ef92f4553f2ed1e565626161d02f06262c838 Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 01:24:05 -0500 Subject: [PATCH 07/11] chore: changeset --- .changeset/thin-turtles-greet.md | 5 +++++ pnpm-lock.yaml | 11 +++++------ src/tempo/server/internal/html/package.json | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 .changeset/thin-turtles-greet.md 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/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/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" } From 4b2a4c156bf6f87e8f7713721442ac72a7055966 Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 10:30:30 -0500 Subject: [PATCH 08/11] chore: tweaks --- src/server/Transport.ts | 15 +++------ src/server/internal/html/config.ts | 44 +++++++++++++++++++------ src/stripe/server/internal/html/main.ts | 1 + src/tempo/server/internal/html/main.ts | 9 +++-- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/server/Transport.ts b/src/server/Transport.ts index a2598387..76b946f9 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -157,6 +157,7 @@ export function http(): Http { (options.html.theme as never) ?? {}, ) const text = Html.mergeDefined(Html.defaultText, (options.html.text as never) ?? {}) + const amount = await options.html.formatAmount(challenge.request) return html` @@ -166,24 +167,18 @@ export function http(): Http { ${text.title} - ${theme.fontUrl - ? ` - ` - : ''} - ${Html.style(theme)} + ${Html.font(theme)} ${Html.style(theme)}
- ${Html.logo(theme.logo)} + ${Html.logo(theme)} ${text.paymentRequired}
-

- ${await options.html.formatAmount(challenge.request)} -

+

${amount}

${challenge.description - ? `

${challenge.description}

` + ? `

${Html.sanitize(challenge.description)}

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

${text.expires}

` diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 96d690de..54b1dd3e 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -23,6 +23,8 @@ export type Data< export const dataId = '__MPPX_DATA__' +export const errorId = 'root_error' + export const rootId = 'root' export const serviceWorkerParam = '__mppx_worker' @@ -41,6 +43,15 @@ export const classNames = { summaryExpires: 'mppx-summary-expires', } +export function sanitize(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + export const html = String.raw class CssVar { @@ -68,6 +79,12 @@ export const vars = { 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 }) { @@ -98,6 +115,12 @@ export function style(theme: { ${vars.spacingUnit.name}: ${theme.spacingUnit}; ${rootVars} }${darkMedia} + *:focus-visible { + outline-color: ${vars.accent}; + outline-offset: 0.15rem; + outline-style: solid; + outline-width: 2px; + } body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -168,6 +191,7 @@ export function style(theme: { .${classNames.error} { color: ${vars.negative}; font-size: 0.95rem; + margin-top: calc(${vars.spacingUnit} * -1.5); text-align: center; } @@ -175,30 +199,30 @@ export function style(theme: { } export function showError(message: string) { - const existing = document.getElementById('__MPPX_ERROR__') + const existing = document.getElementById(errorId) if (existing) { existing.textContent = message return } const el = document.createElement('p') - el.id = 'root_error' + el.id = errorId el.className = classNames.error el.role = 'alert' el.textContent = message document.getElementById(rootId)?.after(el) } -export function logo(value: Theme['logo']) { - if (typeof value === 'undefined') return `` - if (typeof value === 'string') - return html`` - return Object.entries(value) +export function logo(value: Theme) { + if (typeof value.logo === 'undefined') return '' + if (typeof value.logo === 'string') + return html`` + return Object.entries(value.logo) .map( - ([key, value]) => + (entry) => html``, ) .join('\n') diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 372aba33..bc230d48 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -130,6 +130,7 @@ root.append(style) form.onsubmit = async (event) => { event.preventDefault() + document.getElementById(Html.errorId)?.remove() button.disabled = true try { await elements.submit() diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index d6b5e7e7..3d8c9f56 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -82,14 +82,19 @@ 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: {} }) From a53179be40ee9aded71e2a52ebbf030e851688e0 Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 10:35:44 -0500 Subject: [PATCH 09/11] feat: favicon --- src/server/Transport.ts | 3 ++- src/server/internal/html/config.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 76b946f9..8a412484 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -150,6 +150,7 @@ export function http(): Http { const theme = Html.mergeDefined( { + favicon: undefined as Html.Theme['favicon'], fontUrl: undefined as Html.Theme['fontUrl'], logo: undefined as Html.Theme['logo'], ...Html.defaultTheme, @@ -167,7 +168,7 @@ export function http(): Http { ${text.title} - ${Html.font(theme)} ${Html.style(theme)} + ${Html.favicon(theme, challenge.realm)} ${Html.font(theme)} ${Html.style(theme)}
diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 54b1dd3e..6a538a1c 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -17,7 +17,7 @@ export type Data< challenge: Challenge.FromMethods<[method]> text: { [k in keyof Text]-?: NonNullable } theme: { - [k in keyof Omit]-?: NonNullable + [k in keyof Omit]-?: NonNullable } } @@ -86,7 +86,7 @@ export function font(theme: Theme) { } export function style(theme: { - [k in keyof Omit]-?: NonNullable + [k in keyof Omit]-?: NonNullable }) { const colors = Object.fromEntries( colorTokens.map((name) => [name, resolveColor(theme[name], defaultTheme[name])]), @@ -212,6 +212,28 @@ export function showError(message: string) { 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') @@ -255,6 +277,8 @@ export type Theme = { 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' */ @@ -297,7 +321,7 @@ export const defaultTheme = { negative: ['#e5484d', '#e5484d'], positive: ['#30a46c', '#30a46c'], surface: ['#f5f5f5', '#1a1a1a'], -} as const satisfies Required> +} as const satisfies Required> export const colorTokens = [ 'accent', From e350028fe9134607576455bf09e189a1a610683a Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 10:40:41 -0500 Subject: [PATCH 10/11] chore: sanitize --- src/server/Transport.ts | 6 ++++-- src/server/internal/html/config.ts | 29 ++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 8a412484..08ea6bbe 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -157,7 +157,9 @@ export function http(): Http { }, (options.html.theme as never) ?? {}, ) - const text = Html.mergeDefined(Html.defaultText, (options.html.text 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` @@ -177,7 +179,7 @@ export function http(): Http { ${text.paymentRequired}
-

${amount}

+

${Html.sanitize(amount)}

${challenge.description ? `

${Html.sanitize(challenge.description)}

` : ''} diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 6a538a1c..fde68327 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -52,6 +52,12 @@ export function sanitize(str: string): string { .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 { @@ -81,8 +87,12 @@ export const vars = { export function font(theme: Theme) { if (!theme.fontUrl) return '' - return html` - ` + return html` + ` } export function style(theme: { @@ -213,14 +223,19 @@ export function showError(message: string) { } export function favicon(theme: Theme, realm: string) { - if (typeof theme.favicon === 'string') return html`` + if (typeof theme.favicon === 'string') + return html`` if (typeof theme.favicon === 'object') { return html` - ` + ` } // Fallback: use host's favicon via Google S2 service try { @@ -237,14 +252,14 @@ export function favicon(theme: Theme, realm: string) { export function logo(value: Theme) { if (typeof value.logo === 'undefined') return '' if (typeof value.logo === 'string') - return html`` + return html`` return Object.entries(value.logo) .map( (entry) => html``, ) .join('\n') From 759cd3adf31932d248d3351f97988e3d31e527bf Mon Sep 17 00:00:00 2001 From: tmm Date: Thu, 2 Apr 2026 10:43:45 -0500 Subject: [PATCH 11/11] test: transport html --- src/server/Transport.test.ts | 216 +++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) 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()