@solvapay/react 1.1.4 → 1.2.0-preview-4931d1e51e774cede74a33b7f5349c6cf0ff0e95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,262 @@
1
1
  # @solvapay/react changelog
2
2
 
3
- ## 1.1.4
3
+ ## 1.2.0-preview-4931d1e51e774cede74a33b7f5349c6cf0ff0e95
4
+
5
+ ### Minor Changes
6
+
7
+ - b53abcb: Add step-aware heading + subheading primitives to `<CheckoutSteps>`. `<CheckoutSteps.StepHeading>` and `<CheckoutSteps.StepMessage>` resolve copy from the active `flow.step` and `flow.branch` (and the selected plan's `billingCycle`), so the chrome at the top of the embedded checkout updates as the customer progresses through plan -> amount -> payment. New `checkout.stepHeading.{plan,amount,payment}` and `checkout.stepMessage.{plan,amount,paymentRecurring,paymentOneTime,paymentPayg}` keys ship with sensible English defaults; the `paymentRecurring` key interpolates `{planName}` from the selected plan.
8
+
9
+ ```tsx
10
+ <CheckoutSteps.Root productRef={productRef} returnUrl={url}>
11
+ <CheckoutSteps.StepHeading className="my-heading" />
12
+ <CheckoutSteps.StepMessage className="my-subheading" />
13
+ <CheckoutSteps.IfStep step="plan">{/* … */}</CheckoutSteps.IfStep>
14
+ {/* … */}
15
+ </CheckoutSteps.Root>
16
+ ```
17
+
18
+ When nested inside `<PaywallNotice.Root>`, the `plan` step heading + message defer to the existing `paywall.{paymentRequired,activationRequired,topupRequired}Heading` / `resolvePaywallMessage` so the gate-reason framing the customer saw on entry stays intact. Outside paywall context the fallback `checkout.stepHeading.plan` ("Choose your plan") is used.
19
+
20
+ `<PaywallNotice.EmbeddedCheckout>` now renders `<CheckoutSteps.StepHeading>` + `<CheckoutSteps.StepMessage>` at the top of its internal stepped composition by default, passing through the consumer's `classNames.heading` / `classNames.message` overrides. Integrators who already render `<PaywallNotice.Heading>` + `<PaywallNotice.Message>` as siblings of `<EmbeddedCheckout>` (the previous documented pattern) will see duplicated text — drop the outer parts and rely on the in-flow defaults, or stop using `<PaywallNotice.EmbeddedCheckout>` and compose `<CheckoutSteps.*>` directly to keep full layout control.
21
+
22
+ Resolves the stale "Pick a plan below to keep chatting." subheading shown on the lifetime-access checkout once the user had progressed past the plan step, and the equivalent staleness on the proactive upgrade path in the chat-checkout demo.
23
+
24
+ - f0ee414: Add first-class chatbot / streaming primitives across `@solvapay/server` and `@solvapay/react`.
25
+
26
+ ### `@solvapay/server`
27
+ - **New `solvaPay.payable({ productRef }).gate(req, opts)` primitive.** Decision-shaped paywall surface for streaming, SSE, and multi-step agent flows that don't fit the one-shot `.http()` / `.next()` / `.mcp()` adapter contract. Returns a discriminated union — `{ kind: 'paywall', response, content }` (pre-built 402) or `{ kind: 'allow', decision, customerRef, trackSuccess, trackFail }` with bound usage closures. `trackSuccess` / `trackFail` pre-fill `productRef`, `customerRef`, `requestId` and route through `ctx.waitUntil` when an `ExecutionContext`-shaped `ctx` is provided so Workers keep `trackUsage` alive past the response close. Multiple `trackSuccess` calls per allow decision are supported (per-step metering for AI SDK `onStepFinish`, LangChain `handleLLMEnd`, OpenAI `response.completed`).
28
+ - **New `solvaPay.paywall.decide()` factory exposure.** The kernel `paywall.decide()` routine — already used internally by adapters — is now reachable on the public factory return so streaming handlers can consume the verdict directly without re-implementing limit checks + gate construction.
29
+ - **New `buildPaywallGate(productRef, limits)` export.** Pure helper that converts a `LimitResponseWithPlan` (or any subset compatible with `apiClient.checkLimits`) into a `PaywallStructuredContent`. Extracted from `paywall.decide()`; both paths share the helper so wire shapes stay in lockstep. Exported from both `./` and `./edge` entrypoints.
30
+ - **New types:** `PayableGateOptions`, `PayableGateResult`, `PayablePaywallResult`, `PayableAllowResult`.
31
+ - The handler-shaped adapters (`.http`, `.next`, `.mcp`, `.function`) and `paywall.protect()` are unchanged.
32
+
33
+ ### `@solvapay/react`
34
+ - **`usePlans({ productRef })` `fetcher` is now optional.** When omitted, the hook reads `_config` via `SolvaPayContext` and routes through `defaultListPlans` — preferring `config.transport.listPlans` when available, falling back to `GET ${config.api.listPlans ?? '/api/list-plans'}`. Matches the existing fallback in `<PlanSelector>` / `<CheckoutLayout>` so consumers no longer need to hand-roll `useTransport()` + a fetcher just to call `usePlans`. Explicit `fetcher` overrides remain supported for advanced cases.
35
+ - **New anonymous-auth helpers (`./adapters/auth`):**
36
+ - `getOrCreateAnonymousCustomerRef(storageKey?)` — mints / persists an `anon_<uuid>` customer ref under `localStorage`. Falls back to the deterministic `'anon_ssr'` placeholder server-side.
37
+ - `createAnonymousAuthAdapter(customerRef)` — returns an `AuthAdapter` whose `getToken()` and `getUserId()` both yield the supplied ref. Used to keep the SDK's auth-poll heuristic happy in apps without real authentication.
38
+ - `resetAnonymousCustomerRef(storageKey?)` — clears the persisted ref plus the SDK's cached customer-ref entries so the next call mints a fresh identity.
39
+
40
+ These are additive — no existing exports change. `chat-checkout-demo` is now built on `payable.gate()` + `<PaywallNotice>` and demonstrates the JWT real-auth migration in its README.
41
+
42
+ - b53abcb: Make every checkout entry require a deliberate plan click + Continue, and ship baseline CSS for the `solvapay-checkout-*` namespace so the recommended-default paywall and `<CheckoutSteps.*>` surfaces look correct out of the box.
43
+
44
+ ### Behavioural changes
45
+ - **`usePlans` no longer pre-selects the first plan when auto-selection is opted out.** When `autoSelectFirstPaid: false` and no `initialPlanRef` is supplied (the configuration `<CheckoutSteps.Root>` uses), `selectedPlanIndex` is `-1` and `selectedPlan` is `null` until the user picks a card. Previously the hook fell through to `0`, which silently highlighted the first card and enabled `<CheckoutSteps.PlanContinueButton>` — defeating the explicit-consent intent of `autoSelectFirstPaid: false`. `<PlanSelector.Root>` direct consumers default to `autoSelectFirstPaid: true` and are unaffected.
46
+ - **`<PaywallNotice.EmbeddedCheckout>` and `<CheckoutSteps.*>` always render the plan step.** The user clicks a card and presses Continue; nothing auto-advances. This applies even when only one plan is selectable — the plan step is where the rate / commitment is disclosed, and consenting to a paid action shouldn't be silent.
47
+ - **`@solvapay/react/styles.css` ships baseline rules for `solvapay-checkout-*` parts** — continue button, back link, order summary, amount picker, success receipt. Apps overriding the per-part `className` are unaffected; apps relying on no styling will see new defaults. The pay button (`.solvapay-checkout-pay-button`) and form errors (`.solvapay-checkout-error`) are deliberately not styled here — the underlying `<PaymentForm>` / `<TopupForm>` primitives already cover them via `[data-solvapay-*-submit]` and `[data-solvapay-*-error]`.
48
+
49
+ ### Additions
50
+ - **`UseCheckoutFlowReturn.canGoBack`** — derived boolean read by `<CheckoutSteps.BackLink>`. With every progression user-driven, `canGoBack` is `true` whenever `step` is `'amount'` or `'payment'`, and `false` on `'plan'`.
51
+
52
+ - e83cae5: Allow customers on an active usage-based (PAYG) plan to top up without bouncing off the plan step.
53
+
54
+ ### Behavioural changes
55
+ - **`<PlanSelector.Grid>` keeps PAYG `currentPlanRef` cards selectable.** Previously every "Current" card was disabled (`disabled = isCurrent || isFree`), which on topup products — where `buildDefaultCheckoutPlanFilter` collapses the catalogue down to a single PAYG card — left the customer staring at an inert grid with no way forward. Recurring/one-time current cards stay disabled because re-selecting them would re-charge the customer; the PAYG branch is the topup conduit and re-entering it is the expected next action. The "Current" badge still renders so the customer sees their active plan.
56
+ - **`<PlanSelector.Root>` auto-selects the customer's PAYG `currentPlanRef`** when it lands in the visible plan list. Topup checkout (`<CheckoutSteps.Root>` → `autoSelectFirstPaid: false`) now opens with `<CheckoutSteps.PlanContinueButton>` enabled instead of greyed-out. One-shot per `productRef` so a deliberate `clearSelection` doesn't immediately re-snap. Recurring/one-time current plans are not auto-selected — staying with `autoSelectFirstPaid: false`'s explicit-consent contract.
57
+ - **`useCheckoutFlow.advance()` skips `transport.activatePlan` when the selected plan is already the customer's current plan.** PAYG re-activation is a no-op on the backend and the round-trip just adds latency plus a transient `status: 'activating'` flicker. The flow steps straight to the amount picker.
58
+
59
+ - cca77fb: Align `<CheckoutSteps>` PAYG topup flow with the hosted topup page and `<McpTopupView>` — drive amount + payment off the merchant's base currency, optimistically bump credits on success, and add the `topupCurrency` prop as a forward-compat hook for multi-currency topups.
60
+
61
+ ### Currency invariant
62
+
63
+ Credits are merchant-wide, not plan-specific. The PAYG topup branch (`<CheckoutSteps.AmountPicker>`, `<CheckoutSteps.AmountContinueButton>`, `<CheckoutSteps.Payment>` PAYG variant) now resolves currency strictly from:
64
+
65
+ ```
66
+ topupCurrency prop → merchant.defaultCurrency → null (UI gates)
67
+ ```
68
+
69
+ `plan.currency` is **never** consulted for the topup branch — using it would mismatch what the wallet actually settles in. Recurring/one-time plan purchases keep using `plan.currency` (correct for those, since they settle in the plan's denominated currency).
70
+
71
+ ### Additions
72
+ - **`topupCurrency?: string`** prop on `<CheckoutSteps.Root>` and `<PaywallNotice.EmbeddedCheckout>`. Defaults to `merchant.defaultCurrency`. Pass an explicit value when integrators surface a per-customer currency picker (multi-currency topup support, future).
73
+ - **`flow.topupCurrency: string | null`** and **`flow.topupCurrencyReady: boolean`** on the `useCheckoutFlow` return value. Step components consume both via `useCheckoutContext`; while `!topupCurrencyReady`, the AmountPicker and Continue button render skeleton/disabled state instead of a misleading default.
74
+
75
+ ### Behavioural changes
76
+ - **Optimistic balance bump on PAYG success.** `useCheckoutFlow.recordPaygSuccess` now calls `balance.adjustBalance(creditsAdded)` so the header pill / `<BalanceBadge>` reflect the topup before the Stripe webhook lands. The `SolvaPayProvider`'s 8s grace window auto-reconciles via the deferred fetch — no race against the real webhook. Mirrors `<McpTopupView>`.
77
+ - **`<RecurringPayment>` distinguishes recurring vs one-time** plans in submit-button + order-summary copy. Plans with `billingCycle` render `Subscribe — $X/cycle`; plans without (e.g. lifetime / one-time) render `Pay $X` with no `/cycle` suffix. Fixes a regression where lifetime plans rendered as `Subscribe — $X/mo`.
78
+ - **`<CheckoutSteps.AmountPicker>` renders a skeleton row** while `useMerchant` is in flight and no explicit `topupCurrency` prop is passed. Ensures the picker never paints a misleading USD preset on a non-USD merchant during initial load. The merchant fetch is fast in practice (5-minute cache, often seeded), so this is rarely visible.
79
+
80
+ ### Migration
81
+
82
+ No breaking changes for existing single-currency integrators. Custom transports without a `getMerchant` adapter must pass `topupCurrency` explicitly (the only currency source available without merchant data) — same shape future multi-currency pickers will use.
83
+
84
+ - ae1f0aa: Add headless `useCheckoutFlow` hook + opt-in `<CheckoutSteps.*>` parts so MCP, paywall, and chatbot/web checkouts share one state engine while each owns its own layout. Also fix MCP-flavored copy bleed in `<PaywallNotice.Message>` for web integrators.
85
+
86
+ ### Additions
87
+ - **`useCheckoutFlow({ productRef, … })`** — headless state engine for the four-step activation flow (plan → amount [PAYG only] → payment → success). Owns step state, transitions, lifecycle callbacks (`onPlanSelect`, `onAmountSelect`, `onPurchaseSuccess`, `onError`), and the `transport.activatePlan` side-effect on the PAYG plan→amount edge. Must be called inside `<PlanSelector.Root>`. Exported from `@solvapay/react` and `@solvapay/react/primitives`.
88
+ - **`<CheckoutSteps.*>`** — opt-in pre-styled parts (`Root`, `IfStep`, `PlanGrid`, `PlanContinueButton`, `AmountPicker`, `AmountContinueButton`, `Payment`, `BackLink`, `Success`) that compose on `useCheckoutFlow`. Class names follow the `solvapay-checkout-*` namespace. MCP and paywall surfaces add their own ancestor selectors (`.solvapay-mcp-shell .solvapay-checkout-card`) rather than remapping `classNames` per call site. Exported from `@solvapay/react` and `@solvapay/react/primitives`.
89
+ - **i18n keys** — new `paywall.activationRequiredMessage`, `paywall.paymentRequiredMessageNoBalance`, `paywall.topupRequiredMessage` for web-friendly paywall copy that doesn't leak MCP tool names ("Call the `upgrade` tool…") into web UIs.
90
+
91
+ ### Behavioural changes
92
+ - **`<PaywallNotice.EmbeddedCheckout>` is now a stepped composition** of `<CheckoutSteps.*>` (plan → amount → payment → success with an explicit Continue between plan and the form). This is the SDK's recommended default for paywall surfaces. Apps wanting a different layout compose `<CheckoutSteps.*>` directly. Removes the previous one-shot composition where the PAYG amount picker and payment form coexisted on the same surface.
93
+ - **`<PaywallNotice.Message>` resolves a kind-specific i18n string first**, falling back to `content.message` only when no kind-specific copy exists. Strict improvement: web UIs no longer surface MCP-flavored "Call the `upgrade` tool…" copy that was authored for CLI / MCP hosts. The MCP layer routes `content.message` through `content[0].text` (its actual consumer), so MCP behaviour is unchanged.
94
+ - **`<PaywallNotice.EmbeddedCheckout>` and `<CheckoutSteps.Root>` ship a smart default plan filter.** Aligns the SDK with the hosted-checkout topup pattern (one usage-based plan + `<AmountPicker>` with currency presets — no separate "100 Credits" / "250 Credits" pack plans). The new `buildDefaultCheckoutPlanFilter(plans)` always hides Free plans, and hides PAYG when the product also exposes a non-PAYG paid plan so legacy / mixed configs render only the packs. PAYG-only products surface a single PAYG card on the plan step that the user clicks before continuing into the AmountPicker. Consumers passing an explicit `filter` prop keep their existing behaviour.
95
+
96
+ ### MCP
97
+ - `<McpCheckoutView>` and `mcp/views/checkout/EmbeddedCheckout` are now thin layout wrappers around `useCheckoutFlow`. The state machine moved to the hook; the MCP wrapper owns the bridge wiring (`notifyModelContext` on plan commit, `notifySuccess` on success, `sendMessage` on Stay-on-Free) and the MCP-specific chrome (banner, Stay-on-Free button). All existing `<McpCheckoutView>` tests pass unchanged.
98
+
99
+ - b53abcb: Add `useLimits` — a backend-authoritative hook for rendering "X left" pills against any (product, meter) pair.
100
+
101
+ The runtime portion of the backend's `LimitResponse` (the same data `paywall.decide()` consults internally on every gated request) is now exposed read-only so consumers can render an honest counter without reinventing the math client-side. Replaces two common patterns:
102
+ - `floor(useBalance().credits / plan.creditsPerUnit)` for prepaid usage-based products.
103
+ - `messageLimit - userMessageCount` local refs for free-tier products.
104
+
105
+ Both collapse onto one source of truth.
106
+
107
+ ### `@solvapay/react`
108
+
109
+ ```tsx
110
+ import { useLimits } from '@solvapay/react'
111
+
112
+ const { remaining, withinLimits, refetch, adjustRemaining } = useLimits({
113
+ productRef: 'prd_api',
114
+ meterName: 'requests', // optional; defaults to 'requests'
115
+ })
116
+ ```
117
+
118
+ The minimal projection (`remaining`, `withinLimits`, `meterName`, `activationRequired`) is intentional — `plans` / `balance` / `productDetails` are already surfaced by `usePlans` / `useBalance` / paywall structured content.
119
+
120
+ `activationRequired: true` distinguishes "free tier waiting to be claimed" from "exhausted" — both look like `remaining: 0` on the wire, but only the latter should drive an "Upgrade" CTA. Pair with `useActivation` to flip the customer onto the free tier when the backend's default plan needs explicit activation:
121
+
122
+ ```tsx
123
+ const { activationRequired } = useLimits({ productRef })
124
+ const { activate } = useActivation()
125
+ const freePlan = plans.find(p => !p.requiresPayment && (p.freeUnits ?? 0) > 0)
126
+
127
+ useEffect(() => {
128
+ if (activationRequired === true && freePlan?.reference) {
129
+ activate({ productRef, planRef: freePlan.reference })
130
+ }
131
+ }, [activationRequired, freePlan?.reference, productRef, activate])
132
+ ```
133
+
134
+ `adjustRemaining(delta)` mirrors `useBalance().adjustBalance` — applies an 8 s optimistic grace window then auto-refetches. Use after a successful gated action so the pill snaps before the trailing refetch lands. Module-level cache keyed by `customerRef:productRef:meterName` with a 10 s TTL that mirrors the backend paywall's `limitsCacheTTL`. When the transport doesn't implement `getLimits` (e.g. an MCP adapter without the route), the hook returns `null` for `remaining` / `withinLimits` with `loading: false` — graceful fallback matching `useUsage`'s behaviour when `getUsage` is absent.
135
+
136
+ ### `@solvapay/server`
137
+
138
+ New `checkLimitsCore(request, options)` route helper mirrors `listPlansCore` — reads `productRef` (required) and `meterName` (optional) from query string, authenticates via `getAuthenticatedUserCore`, returns the full `LimitResponseWithPlan`. Reachable from both `@solvapay/server` and `@solvapay/server/edge`.
139
+
140
+ ### Transport layer
141
+
142
+ `SolvaPayTransport` gains an optional `getLimits({ productRef, meterName? })` method (parallel to `getBalance` / `getUsage`). The default HTTP transport routes to `GET /api/limits` (configurable via `SolvaPayConfig.api.getLimits`).
143
+
144
+ ### `useAutoActivateFreePlan`
145
+
146
+ New hook that encapsulates the "silently activate the free plan when the backend reports `activationRequired: true`" pattern from the demo. Pairs `useLimits`, `usePlans`, and `useActivation` behind a one-shot guard keyed by `${customerRef}:${productRef}` so failed activations don't retry on every render. Returns `{ pending, activated, error }` — use `pending` as a skeleton gate so the UI doesn't flash "0 left" between the limits fetch and the post-activation refetch.
147
+
148
+ ```tsx
149
+ import { useAutoActivateFreePlan } from '@solvapay/react'
150
+
151
+ const { pending: autoActivating } = useAutoActivateFreePlan({ productRef })
152
+
153
+ <UsagePill loading={autoActivating || limitsLoading} remaining={limitRemaining} />
154
+ ```
155
+
156
+ When the product has no free plan to activate (e.g. a PAYG-only product whose default plan needs activation but is paid), `pending` stays `false` so the consumer commits to the backend's actual `remaining` instead of stalling on a skeleton.
157
+
158
+ ### `usePlans` in-flight cache fix
159
+
160
+ Reordered the cache check so the in-flight branch wins over the fresh-cache branch. Previously two sibling `usePlans` calls against the same `productRef` could race: the second caller hit the fresh-cache branch (the in-flight slot carries `plans: []` + a fresh timestamp) and locked itself into "loading=false, plans=[]" until the TTL expired. The in-flight branch now coalesces correctly, and the fresh-cache branch only matches when `plans.length > 0`. Behaviour is unchanged for single-mount use; concurrent callers no longer need a workaround.
161
+
162
+ ### Non-breaking
163
+
164
+ All additions are additive: `useBalance` / `useUsage` are unchanged, `getLimits` is optional on the transport interface so existing custom transports keep working without modification, and the `usePlans` cache reorder is a strict bugfix (no API change).
4
165
 
5
166
  ### Patch Changes
6
167
 
168
+ - b53abcb: Soften amount-picker quick-pick chips from fully-rounded capsules (`border-radius: 999px`) to subtly rounded rectangles (`border-radius: 8px`) in both the SDK base stylesheet (`.solvapay-amount-picker-pill` / `[data-solvapay-amount-picker-option]`) and the MCP variant (`.solvapay-mcp-amount-option`). Keeps `<CheckoutSteps.AmountPicker>`, `<AmountPicker>`, the hosted topup page, and every `<Mcp*View>` topup/checkout surface visually consistent — the previous capsule shape read inconsistently between narrow MCP iframes (~520px) and wider web containers (~672px+).
169
+
170
+ Consumers who prefer the previous capsule shape can restore it with a single override:
171
+
172
+ ```css
173
+ .solvapay-amount-picker-pill,
174
+ [data-solvapay-amount-picker-option],
175
+ .solvapay-mcp-amount-option {
176
+ border-radius: 999px;
177
+ }
178
+ ```
179
+
180
+ Quick-pick chips inside `.solvapay-amount-picker-pills` (the SDK's default amount-picker row used by `<AmountPicker>`, `<ActivationFlow.AmountPicker>`, and `<CheckoutSteps.AmountPicker>`) now stretch evenly across the row via `flex: 1; min-width: 0` instead of hugging their text content. Matches the dense, even-row look that hosted MCP surfaces already render via host CSS. The container's `flex-wrap: wrap` is preserved so chips wrap to a new line when the row is too narrow to fit them. Consumers who want the previous content-width behaviour can opt out with:
181
+
182
+ ```css
183
+ .solvapay-amount-picker-pills > [data-solvapay-amount-picker-option],
184
+ .solvapay-amount-picker-pills > .solvapay-amount-picker-pill {
185
+ flex: 0 0 auto;
186
+ }
187
+ ```
188
+
189
+ The default amount-picker tree (`<AmountPicker>` shim and `<CheckoutSteps.AmountPicker>` `DefaultAmountTree`) now reserves vertical space for the credit-estimate line at all times instead of mounting it conditionally on first valid amount entry. Previously the form would jump downward by ~1 line the moment a user typed a custom amount or selected a quick-pick chip; the line is now always present, rendered with a non-breaking-space placeholder and `aria-hidden="true"` until an estimate is available. Behaviour is unchanged for hosts that pass `showCreditEstimate={false}` to the standalone `<AmountPicker>` shim — the line is omitted entirely there as before.
190
+
191
+ - 0240670: Fix `<McpApp>` shell content width on wide-iframe hosts. The previous CSS-only "fill iframe width" change removed the `max-width: 520px` cap from `.solvapay-mcp-main` so unbounded-iframe hosts (MCP Inspector, MCP Jam, full-browser previews) could use the column they hand us, but it overshot — at iframe widths beyond ~700px the AppHeader, body card, and LegalFooter all stretched to the iframe edges, splaying receipt rows with hundreds of pixels of dead air, blowing up the `width: 100%` button to ~800px, and producing >95-character body-copy line lengths.
192
+
193
+ Re-introduce a single `max-inline-size: 520px` on `.solvapay-mcp-main` so the whole shell renders as one visually unified block centred in the iframe. The earlier `@media (min-width: 900px) { max-width: 960px }` sidebar-mode override is intentionally not carried forward — it was the cause of the felt-cramped problem on 1200px+ iframes.
194
+
195
+ Also convert the wide-shell sidebar `@media (min-width: 900px)` query (`.solvapay-mcp-shell-sidebar` + `.solvapay-mcp-shell-layout` grid switch) to a `@container (min-width: 900px)` query, with `container-type: inline-size` on `.solvapay-mcp-main`. Without this, the sidebar grid would still fire on iframe viewport width — meaning at iframe widths ≥900px the `1fr + 320px` grid would activate inside the 520px capped shell, squeezing the body column to ~184px and breaking payment forms. Container queries fire on the shell's own width.
196
+
197
+ When the shell renders the sidebar (authenticated customers — `isShellSidebarEligible` in `McpAppShell.tsx`), lift the cap to 900px via `.solvapay-mcp-main:has(.solvapay-mcp-shell-sidebar)`. Without this lift, `McpAppShell` would suppress the inline customer/seller detail cards in the body (assuming the sidebar takes them) while the sidebar would stay `display: none` (because the container threshold is never reached inside the 520 cap) — both copies hidden, customer details invisible for every authenticated user. With the lift, the 1fr + 320 sidebar grid has room and the container query fires correctly. Unauthenticated flows keep the tighter 520 cap.
198
+
199
+ Below 520px (Claude Desktop ~400-500px, ChatGPT inline ~400px, Mobile Claude ~300-360px) the cap is a no-op and content fills the host's container, matching Anthropic's "apps fill the container width" guidance for the primary use cases. Above 520px the cap activates.
200
+
201
+ Source-grounded basis for 520:
202
+ - SEP-1865 §"Container Dimensions" — when the host omits `containerDimensions.width`/`maxWidth` (Unbounded mode) the spec delegates the size choice to the View.
203
+ - OpenAI Apps SDK guidelines mandate "set a max width and design layouts that collapse gracefully on small screens".
204
+ - OpenAI's `apps-sdk-ui` reservation card uses `max-w-sm` (384px); `openai-apps-sdk-examples` cluster sits at 220-360px.
205
+ - Anthropic's PRIMARY use case is Desktop Claude at 400-500px. 520 is a small composite-flow buffer above that range — our `PlanStep → AmountStep → PaymentStep → SuccessStep` is slightly larger than a single-tool inline card.
206
+ - At our 14px body, 520px ≈ 62ch — inside the 65-75ch optimal reading line length.
207
+
7
208
  - 8dd8638: Fix `McpAppFull.addEventListener`/`removeEventListener` types to accept the tighter `K extends keyof AppEventMap` shape introduced in `@modelcontextprotocol/ext-apps@^1.7`. Previously the loose `(evt: string, …)` signature on the interface didn't match what ext-apps exposes from 1.7+, so consumers seeing types from both `@solvapay/react` and `ext-apps@^1.7` would hit `TS2322: Type 'App' is not assignable to type 'McpAppFull'` at every `<McpApp app={...} />` mount site.
8
209
 
9
210
  The interface now declares `(evt: any, …)` for both event-listener fields, which is permissive enough to satisfy the new tightened generic without giving up the legacy `ontoolresult` setter fallback. Fixes [PR #169](https://github.com/solvapay/solvapay-sdk/pull/169).
211
+
212
+ - b53abcb: `<MandateText>` now explicitly calls out that one-time charges are one-time. The `oneTime` mandate template (rendered by `<MandateText>`, `<PaywallNotice.EmbeddedCheckout>`, and `<CheckoutSteps.PaymentForm>` whenever the resolved plan is a one-time / lifetime plan) was previously indistinguishable from the recurring template at a glance: _"By confirming, you authorize Acme Inc to charge $99 for Widget API. Payments are processed by SolvaPay."_ — no signal that this is a single charge versus a subscription. Inserts "a one-time" before the amount so the sentence now reads _"...to charge a one-time $99 for Widget API..."_. Mirrors the disambiguation the `recurring` template gets via `every {period} until you cancel`.
213
+
214
+ Integrators with snapshot tests pinned to the previous wording will need to update their fixtures. Integrators using a custom `copy.mandate.oneTime` override are unaffected.
215
+
216
+ - b53abcb: Fix `<PaywallNotice.EmbeddedCheckout>` so it dismisses synchronously on successful payment instead of waiting for `usePaywallResolver` to flip. Pre-fix, the checkout's `onPurchaseSuccess` only triggered a refetch and the parent's `onResolved` was fired by an effect that watched the resolver — backend / webhook lag (sandbox or local dev) could leave the success card stuck for 10s+ even after Stripe had confirmed the payment.
217
+
218
+ `<PaywallNotice.Root>` now dedupes `onResolved` per-mount and exposes the deduped trigger via context, so `<EmbeddedCheckout>` can signal completion immediately on payment success while the resolver-driven path keeps working as a backstop. Inline `onResolved` arrows on the parent no longer re-trigger the effect either.
219
+
220
+ - cca77fb: `<PaywallNotice>` copy is now host-neutral and topup-aware.
221
+
222
+ The default i18n strings used to leak MCP framing ("This tool needs an active plan…") into web surfaces. They've been rewritten in second person and split so the heading/message track whether the gate calls for a real plan or just more credits.
223
+
224
+ ### Copy changes
225
+
226
+ | Key | Before | After |
227
+ | ------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------ |
228
+ | `paywall.activationRequiredHeading` | `Add credits to continue` | `Activate a plan to continue` |
229
+ | `paywall.activationRequiredMessage` | `This tool needs an active plan{forProduct}. Pick one below to keep going.` | `You need an active plan{forProduct} to continue. Pick one below.` |
230
+ | `paywall.topupRequiredHeading` (new) | — | `Add credits to continue` |
231
+
232
+ `paywall.topupRequiredMessage` (`You're out of credits{forProduct}. Add more below to keep going.`) is unchanged — it now actually gets used.
233
+
234
+ ### Routing
235
+
236
+ `<PaywallNotice.Heading>` and `<PaywallNotice.Message>` distinguish the topup variant of an `activation_required` gate from a subscription/lifetime activation. When every plan on `content.plans` has `type: 'usage-based' | 'hybrid'` (PAYG-only), they resolve to the topup heading + message; otherwise they resolve to the activation heading + message. `payment_required` is unchanged.
237
+
238
+ ### Migration
239
+
240
+ Integrators relying on the default `activationRequiredHeading` text get the corrected copy automatically. Integrators overriding `paywall.*` strings via the i18n bundle keep working — only one new optional key (`topupRequiredHeading`) was added; the runtime falls back to it for PAYG-only gates and `activationRequiredHeading` everywhere else.
241
+
242
+ - b53abcb: Topup-only paywalls now emit `kind: 'activation_required'` (with the PAYG plans attached) instead of `kind: 'payment_required'`. Fixes the React SDK rendering "Upgrade to continue" / "Pick a plan below to keep chatting" on a surface that only shows an amount picker — the existing `<PaywallNotice.Heading>` / `<PaywallNotice.Message>` resolvers now pick the topup-flavored copy automatically ("Add credits to continue" / "You're out of credits. Add more below to keep going.").
243
+
244
+ The swap is conservative — `buildPaywallGate` only re-discriminates when:
245
+ 1. `activationRequired` is not set (the customer already has an active plan), AND
246
+ 2. `classifyPaywallState` returns `topup_required` (active usage-based plan, out of credits), AND
247
+ 3. `limits.plans` contains at least one paid plan AND every paid plan is PAYG (`type: 'usage-based' | 'hybrid'`). Free plans are filtered out before the check, so a typical Free + PAYG product still counts as topup-only.
248
+
249
+ When any of those conditions doesn't hold (no plans on the response, mixed PAYG + recurring product, etc.) the gate stays on `payment_required` so the heading / message stay accurate. The internal `paywall-state` classifier and the MCP narration text generated by `buildGateMessage` were already producing topup copy for this case via the `topup_required` state — only the structured `kind` discriminant on the wire changes.
250
+
251
+ ### Backward compat
252
+
253
+ HTTP / Next adapter consumers branching on `gate.kind === 'payment_required'` for the topup case will now see `gate.kind === 'activation_required'`. The shape is otherwise identical (with `plans`, `balance`, `productDetails` all populated when available). MCP `<McpCheckoutView>` / `PlanStep` `paywallKind` branching is unaffected because the MCP topup flow routes through `<McpTopupView>` (a separate view discriminator) rather than the upgrade plan-step UI.
254
+
255
+ Consumers who relied on `kind === 'payment_required'` to mean "this customer is over their free quota and needs to choose a plan" should switch to checking `kind === 'payment_required'` OR (`kind === 'activation_required'` AND not every paid plan is PAYG) for the same intent.
256
+
257
+ - cca77fb: Fix `usePaywallResolver` so `payment_required` gates carrying a `balance.creditsPerUnit` block resolve once the customer's wallet covers the next unit. This makes topup-shaped 402s (where the customer already has an active usage-based plan) dismiss automatically after a successful topup, instead of leaving consumers stuck on a static success surface.
258
+
259
+ A topup creates a balance transaction, not a paid plan purchase, so `hasPaidPurchase` would never flip on subsequent topups. Treating the attached `balance` block the same way the `activation_required` branch does fixes the post-topup auto-dismiss for `<PaywallNotice.Root onResolved>` consumers.
10
260
  - @solvapay/mcp-core@0.2.4
11
261
 
12
262
  ## 1.1.3
@@ -20,7 +20,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/adapters/auth.ts
21
21
  var auth_exports = {};
22
22
  __export(auth_exports, {
23
- defaultAuthAdapter: () => defaultAuthAdapter
23
+ createAnonymousAuthAdapter: () => createAnonymousAuthAdapter,
24
+ defaultAuthAdapter: () => defaultAuthAdapter,
25
+ getOrCreateAnonymousCustomerRef: () => getOrCreateAnonymousCustomerRef,
26
+ resetAnonymousCustomerRef: () => resetAnonymousCustomerRef
24
27
  });
25
28
  module.exports = __toCommonJS(auth_exports);
26
29
  var defaultAuthAdapter = {
@@ -43,7 +46,37 @@ var defaultAuthAdapter = {
43
46
  return null;
44
47
  }
45
48
  };
49
+ var DEFAULT_ANON_STORAGE_KEY = "solvapay:anonymousCustomerRef";
50
+ var SSR_PLACEHOLDER_REF = "anon_ssr";
51
+ function getOrCreateAnonymousCustomerRef(storageKey = DEFAULT_ANON_STORAGE_KEY) {
52
+ if (typeof window === "undefined") return SSR_PLACEHOLDER_REF;
53
+ const stored = window.localStorage.getItem(storageKey);
54
+ if (stored) return stored;
55
+ const next = `anon_${crypto.randomUUID()}`;
56
+ window.localStorage.setItem(storageKey, next);
57
+ return next;
58
+ }
59
+ function resetAnonymousCustomerRef(storageKey = DEFAULT_ANON_STORAGE_KEY) {
60
+ if (typeof window === "undefined") return;
61
+ window.localStorage.removeItem(storageKey);
62
+ window.localStorage.removeItem("solvapay_customerRef");
63
+ window.localStorage.removeItem("solvapay_customerRef_expiry");
64
+ window.localStorage.removeItem("solvapay_customerRef_userId");
65
+ }
66
+ function createAnonymousAuthAdapter(customerRef) {
67
+ return {
68
+ async getToken() {
69
+ return customerRef;
70
+ },
71
+ async getUserId() {
72
+ return customerRef;
73
+ }
74
+ };
75
+ }
46
76
  // Annotate the CommonJS export names for ESM import in node:
47
77
  0 && (module.exports = {
48
- defaultAuthAdapter
78
+ createAnonymousAuthAdapter,
79
+ defaultAuthAdapter,
80
+ getOrCreateAnonymousCustomerRef,
81
+ resetAnonymousCustomerRef
49
82
  });
@@ -53,5 +53,67 @@ interface AuthAdapter {
53
53
  * It checks for a token in localStorage under the 'auth_token' key.
54
54
  */
55
55
  declare const defaultAuthAdapter: AuthAdapter;
56
+ /**
57
+ * Generate (and persist) a stable per-browser customer reference.
58
+ *
59
+ * Backed by `window.localStorage` under `storageKey` (default
60
+ * `solvapay:anonymousCustomerRef`). Calling this from a non-browser
61
+ * context returns the deterministic SSR placeholder `anon_ssr` —
62
+ * useful for keeping server-rendered output stable but never used as
63
+ * an actual customer identifier.
64
+ *
65
+ * Pair with `createAnonymousAuthAdapter(ref)` to wire an SDK-friendly
66
+ * `AuthAdapter` for apps without real authentication. When you migrate
67
+ * to a real identity provider, swap in your own `AuthAdapter` and
68
+ * leave this helper behind — the SolvaPay backend deduplicates on
69
+ * `externalRef`, so the move is non-destructive.
70
+ *
71
+ * @param storageKey Optional override for the persistence key.
72
+ * @returns The persisted (or freshly minted) anonymous customer ref.
73
+ *
74
+ * @since 1.2.0
75
+ */
76
+ declare function getOrCreateAnonymousCustomerRef(storageKey?: string): string;
77
+ /**
78
+ * Clear the persisted anonymous customer ref. The next call to
79
+ * `getOrCreateAnonymousCustomerRef()` mints a fresh one — useful for
80
+ * "switch demo identity" affordances and for end-to-end tests that
81
+ * need a clean slate per run.
82
+ *
83
+ * Also wipes the SDK's customer-ref cache (`solvapay_customerRef`,
84
+ * `solvapay_customerRef_expiry`, `solvapay_customerRef_userId`) so a
85
+ * post-reset `checkPurchase` doesn't send the stale
86
+ * `x-solvapay-customer-ref` header before the SDK's userId-mismatch
87
+ * invalidation has had a chance to run.
88
+ *
89
+ * @param storageKey Optional override matching the key passed to
90
+ * `getOrCreateAnonymousCustomerRef`.
91
+ *
92
+ * @since 1.2.0
93
+ */
94
+ declare function resetAnonymousCustomerRef(storageKey?: string): void;
95
+ /**
96
+ * Build an `AuthAdapter` that pretends every browser is a logged-in
97
+ * user identified by the supplied anonymous customer ref. Both
98
+ * `getToken()` and `getUserId()` return the ref — the userId doubles
99
+ * as the SolvaPay `externalRef` so visiting customers are
100
+ * deduplicated across reloads.
101
+ *
102
+ * `getToken` returns the ref instead of `null` because the SDK polls
103
+ * auth every 30s; a `null` token causes it to wipe the cached
104
+ * customer ref, which in turn nulls out local balance state and the
105
+ * UI snaps back to a "0 credits" view between polls. The synthetic
106
+ * token only keeps the SDK's session-alive heuristic happy — the
107
+ * server reads `x-customer-ref` (or your own `x-user-id`-style
108
+ * header), so an `Authorization: Bearer anon_<uuid>` is harmless on
109
+ * the wire.
110
+ *
111
+ * Once you wire real auth, replace this with the JWT adapter your
112
+ * identity provider gives you (Clerk, Supabase, Auth0, …) — see
113
+ * `defaultAuthAdapter` for the JWT-token convention.
114
+ *
115
+ * @since 1.2.0
116
+ */
117
+ declare function createAnonymousAuthAdapter(customerRef: string): AuthAdapter;
56
118
 
57
- export { type AuthAdapter, defaultAuthAdapter };
119
+ export { type AuthAdapter, createAnonymousAuthAdapter, defaultAuthAdapter, getOrCreateAnonymousCustomerRef, resetAnonymousCustomerRef };
@@ -53,5 +53,67 @@ interface AuthAdapter {
53
53
  * It checks for a token in localStorage under the 'auth_token' key.
54
54
  */
55
55
  declare const defaultAuthAdapter: AuthAdapter;
56
+ /**
57
+ * Generate (and persist) a stable per-browser customer reference.
58
+ *
59
+ * Backed by `window.localStorage` under `storageKey` (default
60
+ * `solvapay:anonymousCustomerRef`). Calling this from a non-browser
61
+ * context returns the deterministic SSR placeholder `anon_ssr` —
62
+ * useful for keeping server-rendered output stable but never used as
63
+ * an actual customer identifier.
64
+ *
65
+ * Pair with `createAnonymousAuthAdapter(ref)` to wire an SDK-friendly
66
+ * `AuthAdapter` for apps without real authentication. When you migrate
67
+ * to a real identity provider, swap in your own `AuthAdapter` and
68
+ * leave this helper behind — the SolvaPay backend deduplicates on
69
+ * `externalRef`, so the move is non-destructive.
70
+ *
71
+ * @param storageKey Optional override for the persistence key.
72
+ * @returns The persisted (or freshly minted) anonymous customer ref.
73
+ *
74
+ * @since 1.2.0
75
+ */
76
+ declare function getOrCreateAnonymousCustomerRef(storageKey?: string): string;
77
+ /**
78
+ * Clear the persisted anonymous customer ref. The next call to
79
+ * `getOrCreateAnonymousCustomerRef()` mints a fresh one — useful for
80
+ * "switch demo identity" affordances and for end-to-end tests that
81
+ * need a clean slate per run.
82
+ *
83
+ * Also wipes the SDK's customer-ref cache (`solvapay_customerRef`,
84
+ * `solvapay_customerRef_expiry`, `solvapay_customerRef_userId`) so a
85
+ * post-reset `checkPurchase` doesn't send the stale
86
+ * `x-solvapay-customer-ref` header before the SDK's userId-mismatch
87
+ * invalidation has had a chance to run.
88
+ *
89
+ * @param storageKey Optional override matching the key passed to
90
+ * `getOrCreateAnonymousCustomerRef`.
91
+ *
92
+ * @since 1.2.0
93
+ */
94
+ declare function resetAnonymousCustomerRef(storageKey?: string): void;
95
+ /**
96
+ * Build an `AuthAdapter` that pretends every browser is a logged-in
97
+ * user identified by the supplied anonymous customer ref. Both
98
+ * `getToken()` and `getUserId()` return the ref — the userId doubles
99
+ * as the SolvaPay `externalRef` so visiting customers are
100
+ * deduplicated across reloads.
101
+ *
102
+ * `getToken` returns the ref instead of `null` because the SDK polls
103
+ * auth every 30s; a `null` token causes it to wipe the cached
104
+ * customer ref, which in turn nulls out local balance state and the
105
+ * UI snaps back to a "0 credits" view between polls. The synthetic
106
+ * token only keeps the SDK's session-alive heuristic happy — the
107
+ * server reads `x-customer-ref` (or your own `x-user-id`-style
108
+ * header), so an `Authorization: Bearer anon_<uuid>` is harmless on
109
+ * the wire.
110
+ *
111
+ * Once you wire real auth, replace this with the JWT adapter your
112
+ * identity provider gives you (Clerk, Supabase, Auth0, …) — see
113
+ * `defaultAuthAdapter` for the JWT-token convention.
114
+ *
115
+ * @since 1.2.0
116
+ */
117
+ declare function createAnonymousAuthAdapter(customerRef: string): AuthAdapter;
56
118
 
57
- export { type AuthAdapter, defaultAuthAdapter };
119
+ export { type AuthAdapter, createAnonymousAuthAdapter, defaultAuthAdapter, getOrCreateAnonymousCustomerRef, resetAnonymousCustomerRef };
@@ -1,6 +1,12 @@
1
1
  import {
2
- defaultAuthAdapter
3
- } from "../chunk-OUSEQRCT.js";
2
+ createAnonymousAuthAdapter,
3
+ defaultAuthAdapter,
4
+ getOrCreateAnonymousCustomerRef,
5
+ resetAnonymousCustomerRef
6
+ } from "../chunk-UMXOAUW7.js";
4
7
  export {
5
- defaultAuthAdapter
8
+ createAnonymousAuthAdapter,
9
+ defaultAuthAdapter,
10
+ getOrCreateAnonymousCustomerRef,
11
+ resetAnonymousCustomerRef
6
12
  };
@@ -13,7 +13,7 @@ import {
13
13
  usePurchaseStatus,
14
14
  useSolvaPay,
15
15
  useTransport
16
- } from "./chunk-WQ3P3PK7.js";
16
+ } from "./chunk-WAJBPTYG.js";
17
17
 
18
18
  // src/hooks/usePaymentMethod.ts
19
19
  import { useCallback, useEffect, useState } from "react";