@solvapay/react 1.0.7 → 1.0.8-preview.2

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.
@@ -0,0 +1,117 @@
1
+ # Contributing to @solvapay/react
2
+
3
+ This package ships two tiers:
4
+
5
+ - **`@solvapay/react`** — default tree. Drop-in components (`CheckoutLayout`, `PaymentForm`, …) that render a golden-path UI. Internally, they are thin shims over the primitives.
6
+ - **`@solvapay/react/primitives`** — unstyled compound primitives. Consumers compose these to build fully custom UIs.
7
+
8
+ This file is the canonical contract for primitives until the cross-repo Mintlify docs land. Every primitive you add or edit MUST follow these rules.
9
+
10
+ ## Primitive contract
11
+
12
+ ### Compound API
13
+
14
+ Every primitive is a compound: a `Root` plus named subcomponents.
15
+
16
+ ```tsx
17
+ <PlanSelector.Root>
18
+ <PlanSelector.Heading />
19
+ <PlanSelector.Grid>
20
+ <PlanSelector.Card>
21
+ <PlanSelector.CardName />
22
+ <PlanSelector.CardPrice />
23
+ </PlanSelector.Card>
24
+ </PlanSelector.Grid>
25
+ </PlanSelector.Root>
26
+ ```
27
+
28
+ - Export subcomponents as properties on the `Root` (`PlanSelector.Card`) **and** as named exports from `./primitives` for tree-shake-friendly use.
29
+ - No render-prop children. No `classNames` prop. No `unstyled` flag.
30
+
31
+ ### `asChild`
32
+
33
+ Every leaf subcomponent accepts `asChild?: boolean`. When `true`, it renders its single child via `<Slot>` and merges props/ref onto the child.
34
+
35
+ ```tsx
36
+ <PlanSelector.Card asChild>
37
+ <Card>{/* shadcn/ui card */}</Card>
38
+ </PlanSelector.Card>
39
+ ```
40
+
41
+ Use `Slot` from `./primitives/slot`. Refs are merged via `composeRefs`; event handlers are chained via `composeEventHandlers` (consumer runs first; `preventDefault()` skips the primitive handler).
42
+
43
+ ### `data-state` vocabulary
44
+
45
+ Every stateful subcomponent emits a `data-state` attribute from a fixed, documented vocabulary. States are driven by context (`PlanSelectionContext`, `PaymentFormContext`), never by subcomponent-local state.
46
+
47
+ | Primitive | `data-state` values |
48
+ | --- | --- |
49
+ | `PlanSelector.Card` | `idle` \| `selected` \| `current` \| `disabled` |
50
+ | `PaymentForm.SubmitButton` | `idle` \| `processing` \| `disabled` |
51
+ | `ActivationFlow.Root` | `summary` \| `activating` \| `selectAmount` \| `topupPayment` \| `retrying` \| `activated` \| `error` |
52
+ | `CreditGate.Root` | `allowed` \| `blocked` \| `loading` |
53
+ | `PurchaseGate.Root` | `allowed` \| `blocked` \| `loading` |
54
+ | `CancelledPlanNotice.Root` | `active` \| `expired` |
55
+ | `CancelPlanButton` | `idle` \| `cancelling` |
56
+ | `BalanceBadge` | `loading` \| `zero` \| `low` \| `ok` |
57
+ | `AmountPicker.Option` | `idle` \| `selected` \| `disabled` |
58
+
59
+ Secondary flags use `data-<flag>` (e.g. `data-free`, `data-popular`, `data-has-reason`).
60
+
61
+ ### Opaque selectors
62
+
63
+ Every primitive root emits `data-solvapay-<primitive>` (and subcomponents emit `data-solvapay-<primitive>-<part>` when they need to be targetable). Do not rely on internal class names; those are not a public API.
64
+
65
+ ### `.Loading` and `.Error`
66
+
67
+ Every async primitive exposes `.Loading` and `.Error` subcomponents that render when the context reports loading/error. They render `null` otherwise. Always provide a fallback UI path — don't rely on consumers remembering to add them.
68
+
69
+ ### Errors self-heal
70
+
71
+ Throw structured errors from `src/utils/errors.ts` for wiring mistakes:
72
+
73
+ - `MissingProviderError` — primitive rendered outside `<SolvaPayProvider>`.
74
+ - `MissingEnvVarError` — required env var not set.
75
+ - `MissingApiRouteError` — backend route not installed.
76
+ - `MissingProductRefError` — primitive needs `productRef` and none was provided.
77
+
78
+ Each error includes `code`, `message` (fix + docs URL), and `docsUrl`.
79
+
80
+ ## Prohibited patterns
81
+
82
+ When touching a primitive, these must stay gone:
83
+
84
+ - `classNames` prop, `unstyled` prop, `RenderArgs` / function-child overloads.
85
+ - Inline `style={{ ... }}` inside `src/primitives/**`.
86
+ - `next/*` imports (the package must bundle on Vite/Remix).
87
+ - `as any`, `as unknown as ...` except when unavoidable; justify with a comment.
88
+ - Implicit `enum`; use union types.
89
+
90
+ ## TDD checklist per primitive
91
+
92
+ Write the primitive and its test file in the same commit. Every primitive test covers:
93
+
94
+ 1. **Default render** — subcomponents render the expected DOM.
95
+ 2. **`asChild` composition** — refs merge, handlers chain, `data-*` + `aria-*` forward to the child element.
96
+ 3. **`data-state` transitions** — context-driven state flips the attribute correctly.
97
+ 4. **`.Loading` / `.Error`** — rendered when context signals loading/error.
98
+ 5. **Provider-missing + env-missing** — the correct structured error class is thrown with its docs URL.
99
+
100
+ Delete legacy tests that assert on `classNames` / function-child / `RenderArgs` **before** landing the rewrite.
101
+
102
+ ## Adding a new primitive (checklist)
103
+
104
+ 1. Write the test file following the TDD checklist above.
105
+ 2. Create `src/primitives/<Name>.tsx` with `Root` + subcomponents + `asChild` + `data-state`.
106
+ 3. Re-export from `src/primitives/index.ts`.
107
+ 4. If the primitive is part of the default tree, update the shim in `src/components/<Name>.tsx` to render it with the golden-path children.
108
+ 5. Update the `data-state` table in this file if you introduce a new state.
109
+ 6. Keep the package-local README cheat sheet in sync.
110
+
111
+ ## Commands
112
+
113
+ ```bash
114
+ pnpm --filter @solvapay/react test # run the primitive + component tests
115
+ pnpm --filter @solvapay/react build # tsup build (top-level + primitives entry)
116
+ pnpm --filter @solvapay/react lint # eslint
117
+ ```
package/README.md CHANGED
@@ -34,6 +34,126 @@ By default, `SolvaPayProvider` uses:
34
34
  - `/api/check-purchase` for purchase checks
35
35
  - `/api/create-payment-intent` for payment creation
36
36
  - `/api/process-payment` for payment processing
37
+ - `/api/merchant` for merchant identity (`useMerchant`, `MandateText`)
38
+ - `/api/get-product` for single-product lookup (`useProduct`, `CheckoutSummary`)
39
+
40
+ ## Golden path — `<CheckoutLayout>` one-liner
41
+
42
+ `<CheckoutLayout>` handles the full flow: plan selection, payment (paid/free),
43
+ and usage-based activation. For 90% of integrations this is the only
44
+ component you need.
45
+
46
+ ```tsx
47
+ import { SolvaPayProvider, CheckoutLayout } from '@solvapay/react'
48
+
49
+ export function BuyNow({ email }: { email: string }) {
50
+ return (
51
+ <SolvaPayProvider>
52
+ <CheckoutLayout
53
+ productRef="prd_myapi"
54
+ prefillCustomer={{ email }}
55
+ requireTermsAcceptance
56
+ onResult={result => {
57
+ // result.kind === 'paid' | 'activated'
58
+ }}
59
+ />
60
+ </SolvaPayProvider>
61
+ )
62
+ }
63
+ ```
64
+
65
+ What happens based on what `<CheckoutLayout>` resolves:
66
+
67
+ | Product shape | Flow |
68
+ |---|---|
69
+ | One active plan | Auto-skip selection → pay or activate |
70
+ | Multiple plans | Styled `<PlanSelector>` → pay or activate |
71
+ | Free plan (`requiresPayment: false`) | Skip Stripe entirely → `useActivation` |
72
+ | Usage-based plan | `<ActivationFlow>` (summary → top-up → retry → activated) |
73
+
74
+ Pass `planRef` explicitly to skip the selector and keep today's payment-only
75
+ behavior byte-for-byte (backwards compatible with pre-selector integrations).
76
+
77
+ ### Skipping plan selection
78
+
79
+ ```tsx
80
+ <CheckoutLayout
81
+ planRef="pln_premium"
82
+ productRef="prd_myapi"
83
+ prefillCustomer={{ email }}
84
+ requireTermsAcceptance
85
+ size="auto"
86
+ onSuccess={() => console.log('paid')}
87
+ />
88
+ ```
89
+
90
+ The section below ("Drop-in checkout with `<CheckoutLayout>`") is the original
91
+ single-plan form — still supported, still works the same.
92
+
93
+ ## Drop-in checkout with `<CheckoutLayout>`
94
+
95
+ For a fixed plan checkout (summary, SCA mandate, Stripe PaymentElement,
96
+ prefilled customer echo, optional terms checkbox):
97
+
98
+ ```tsx
99
+ import { SolvaPayProvider, CheckoutLayout } from '@solvapay/react'
100
+
101
+ export function BuyNow({ email }: { email: string }) {
102
+ return (
103
+ <SolvaPayProvider>
104
+ <CheckoutLayout
105
+ planRef="pln_premium"
106
+ productRef="prd_myapi"
107
+ prefillCustomer={{ email }}
108
+ requireTermsAcceptance
109
+ size="auto"
110
+ onSuccess={() => console.log('paid')}
111
+ />
112
+ </SolvaPayProvider>
113
+ )
114
+ }
115
+ ```
116
+
117
+ `size="auto"` uses a `ResizeObserver`, so the same component reflows cleanly
118
+ in chat bubbles, phone viewports, and desktop iframes.
119
+
120
+ ## Composition: slot subcomponents
121
+
122
+ When you need custom layout, compose `<PaymentForm>` with slot children:
123
+
124
+ ```tsx
125
+ <PaymentForm planRef="pln_premium" productRef="prd_myapi" prefillCustomer={{ email }}>
126
+ <PaymentForm.Summary />
127
+ <PaymentForm.CustomerFields />
128
+ <PaymentForm.PaymentElement />
129
+ <PaymentForm.MandateText />
130
+ <PaymentForm.TermsCheckbox />
131
+ <PaymentForm.SubmitButton />
132
+ </PaymentForm>
133
+ ```
134
+
135
+ Passing no children keeps the current default tree for backwards compatibility.
136
+
137
+ ## Localization
138
+
139
+ English ships by default. Swap the locale (which also flows through to Stripe
140
+ Elements) and override any strings you like:
141
+
142
+ ```tsx
143
+ import { SolvaPayProvider, CheckoutLayout, type PartialSolvaPayCopy } from '@solvapay/react'
144
+
145
+ const svSECopy: PartialSolvaPayCopy = {
146
+ cta: { subscribe: 'Prenumerera', processing: 'Bearbetar...' },
147
+ terms: { checkboxLabel: 'Jag godkänner villkoren och integritetspolicyn' },
148
+ }
149
+
150
+ <SolvaPayProvider config={{ locale: 'sv-SE', copy: svSECopy }}>
151
+ <CheckoutLayout planRef="..." productRef="..." />
152
+ </SolvaPayProvider>
153
+ ```
154
+
155
+ Currency formatting is already locale-correct — `formatPrice` uses
156
+ `Intl.NumberFormat` with the provider locale and the plan's `currency` field.
37
157
 
38
158
  ### Custom API Routes
39
159
 
@@ -316,9 +436,122 @@ const {
316
436
  All components and hooks are fully typed. Import types as needed:
317
437
 
318
438
  ```tsx
319
- import type { PaymentFormProps, PurchaseStatus, PaymentIntentResult } from '@solvapay/react'
439
+ import type {
440
+ PaymentFormProps,
441
+ PurchaseStatus,
442
+ PaymentIntentResult,
443
+ CheckoutResult,
444
+ PaymentResult,
445
+ ActivationResult,
446
+ } from '@solvapay/react'
320
447
  ```
321
448
 
449
+ ## Plan lifecycle
450
+
451
+ Beyond checkout itself, three thin styled-default components cover the
452
+ post-purchase experience without requiring custom UI.
453
+
454
+ ### `<CancelPlanButton>`
455
+
456
+ Wraps `usePurchaseActions.cancelRenewal` with a built-in confirm dialog,
457
+ loading state, and plan-type-aware copy (subscription vs usage-based).
458
+ Auto-reads the active purchase from `usePurchase()`.
459
+
460
+ ```tsx
461
+ import { CancelPlanButton } from '@solvapay/react'
462
+
463
+ <CancelPlanButton onCancelled={() => router.push('/')} />
464
+ ```
465
+
466
+ Use `confirm={false}` for a single-click cancel, or pass a string to override
467
+ the default copy. The render-prop form exposes `{ cancel, isCancelling,
468
+ disabled, purchase }` for fully custom UI.
469
+
470
+ ### `<CancelledPlanNotice>`
471
+
472
+ Surfaces automatically when the customer has a cancelled-but-still-active
473
+ purchase. Renders the expiration date, days remaining, cancellation reason,
474
+ and a reactivate CTA. Renders nothing when there's nothing to show.
475
+
476
+ ```tsx
477
+ import { CancelledPlanNotice } from '@solvapay/react'
478
+
479
+ <CancelledPlanNotice onReactivated={() => refetch()} />
480
+ ```
481
+
482
+ ### `<CreditGate>`
483
+
484
+ Companion to `<PurchaseGate>` for usage-based flows. Blocks access when the
485
+ customer's credit balance falls below a threshold; renders an embedded
486
+ `<TopupForm>` by default.
487
+
488
+ ```tsx
489
+ import { CreditGate } from '@solvapay/react'
490
+
491
+ <CreditGate minCredits={10}>
492
+ <ExpensiveFeature />
493
+ </CreditGate>
494
+ ```
495
+
496
+ Customize via the `fallback` prop, or use the render-prop form
497
+ (`children: ({ balance, hasCredits, topup }) => …`) for fully custom UI.
498
+
499
+ ## Server-side usage tracking
500
+
501
+ There is intentionally no `useTrackUsage()` client hook. Client-reported usage
502
+ is trivially gamed — a user can simply block the fetch to conserve credits.
503
+ Instead, record usage from your server when the expensive work actually runs:
504
+
505
+ ```ts
506
+ // app/api/do-thing/route.ts
507
+ import { trackUsage } from '@solvapay/next'
508
+
509
+ export async function POST(request: NextRequest) {
510
+ const result = await doTheExpensiveThing()
511
+ await trackUsage(request, { units: 1 })
512
+ return NextResponse.json(result)
513
+ }
514
+ ```
515
+
516
+ The same pattern works from a Supabase Edge Function — see below.
517
+
518
+ ## Using with Supabase Edge Functions
519
+
520
+ Lovable-style apps deploy React frontends against Supabase Edge Functions
521
+ (Deno runtime). The SDK's `api` URL overrides let the exact same
522
+ `<CheckoutLayout>` drop-in work against `/functions/v1/*` routes:
523
+
524
+ ```tsx
525
+ <SolvaPayProvider
526
+ config={{
527
+ api: {
528
+ checkPurchase: `${SUPABASE_URL}/functions/v1/check-purchase`,
529
+ createPayment: `${SUPABASE_URL}/functions/v1/create-payment-intent`,
530
+ processPayment: `${SUPABASE_URL}/functions/v1/process-payment`,
531
+ listPlans: `${SUPABASE_URL}/functions/v1/list-plans`,
532
+ getMerchant: `${SUPABASE_URL}/functions/v1/get-merchant`,
533
+ getProduct: `${SUPABASE_URL}/functions/v1/get-product`,
534
+ // …same pattern for every other endpoint
535
+ },
536
+ auth: {
537
+ adapter: createSupabaseAuthAdapter({ supabaseUrl, supabaseAnonKey }),
538
+ },
539
+ }}
540
+ >
541
+ <CheckoutLayout
542
+ productRef="prd_myapi"
543
+ prefillCustomer={{ email }}
544
+ requireTermsAcceptance
545
+ />
546
+ </SolvaPayProvider>
547
+ ```
548
+
549
+ The matching Deno edge functions ship in
550
+ [`@solvapay/supabase`](../../packages/supabase) — see
551
+ [`examples/supabase-edge`](../../examples/supabase-edge) for one-liner
552
+ `Deno.serve(handler)` files covering every handler including the new
553
+ `get-merchant` and `get-product` endpoints.
554
+
322
555
  ## More Information
323
556
 
324
557
  See [`docs/contributing/architecture.md`](../../docs/contributing/architecture.md) for contributor