@lockerverse/react 0.2.156 → 0.2.158

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,704 @@
1
+ # Lockerverse payment-widgets guide
2
+
3
+ Package: @lockerverse/react@0.2.158
4
+
5
+ Generated from this release's maintained documentation. Do not edit this copy.
6
+
7
+ Read [the shared integration guide](integration.md) first.
8
+
9
+ ## Payment widget host integration
10
+
11
+ ## Keep the requested integration mode
12
+
13
+ Read the shared integration guide first. If the resource prompt specifies
14
+ `integrationMode: "hosted-checkout"`, keep the hosted checkout modal described
15
+ below. Do not silently replace it with native checkout. If an existing site
16
+ already uses native SDK checkout, keep that mode unless a change was requested.
17
+ For a new native integration, use the catalog hook and payment component
18
+ contract later in this guide. Do not add a duplicate checkout page or send the
19
+ visitor away from the existing page.
20
+
21
+ Use the saved `communitySlug`, `widgetSlug`, `apiBaseUrl`, and `environment` to
22
+ load live catalog data with `useLockerverseWidget` from
23
+ `@lockerverse/react/widget`, or `createLockerverseCheckoutClient` from
24
+ `@lockerverse/sdk/checkout` and its `load()` method. Do not infer active products
25
+ or current prices from an old prompt snapshot. Preserve inactive/unavailable
26
+ states and do not enable payment when the live resource cannot be purchased.
27
+ Use the saved `widgetUrl` for hosted navigation; do not build another
28
+ payment API. Hosted checkout does not need native Stripe peers in the host.
29
+
30
+ ## Match existing controls
31
+
32
+ Follow any explicit button or section target in the prompt. Otherwise inspect
33
+ nearby product name, price, billing period, and section heading. Reuse a suitable
34
+ button instead of adding another. If several targets remain equally plausible,
35
+ ask one short question before changing the page. Keep the page's product cards,
36
+ cart, quantities, selected tier, and entered amounts. Names, prices, limits,
37
+ recurring cadence, inventory, and shared capacity come from the live catalog.
38
+
39
+ For separate product buttons, select the matching product. For a cart with one
40
+ pay button, combine only one-time products. Monthly and annual products require
41
+ an exclusive single-product flow; never combine them with each other or a
42
+ one-time cart. Disable checkout when no product is selected or a value violates
43
+ live limits. Local totals are feedback only; Lockerverse decides the final
44
+ payable amount.
45
+
46
+ ## Hosted checkout URLs
47
+
48
+ Given the configured public widget URL
49
+ `https://host.example/community/widgets/v2/widget/donate`, a product's direct
50
+ URL is the same resource with `/options/{encodedProductSlug}/donate` in place
51
+ of its final `/donate`. Use the live product's `slug` and `encodeURIComponent`.
52
+ Use `URL` and `URLSearchParams`; preserve the supplied widget origin and route.
53
+
54
+ - For a fixed-price direct product, set `quantity` only when the page collects
55
+ a quantity. Do not set `amount` or `paymentOption`.
56
+ - For a custom-price direct product, set `amount` to the selected dollar amount.
57
+ - For a combined one-time cart, start from the widget base URL. Set
58
+ `quantity.{productSlug}` to each selected fixed product's positive integer
59
+ quantity and `amount.{productSlug}` to each selected custom product's positive
60
+ dollar amount with at most two decimals. Omit zero or unselected products.
61
+ - Do not pass one aggregate `amount`; the server computes the total.
62
+ - For editable custom-field prefill, use `custom.<fieldId>` with real configured
63
+ field IDs. Text uses one string, a checkbox uses `true` or `false`, a single
64
+ choice uses its option ID, and multiple choices repeat the parameter. Do not
65
+ use `meta.*` for form fields. Keep personal and sensitive data out of URLs.
66
+
67
+ ## Hosted modal
68
+
69
+ Use the existing site-owned Radix/shadcn Dialog or equivalent accessible modal.
70
+ Provide an accessible title and description, a visible close button, an iframe
71
+ title, and `allow="payment *"` on the iframe. Do not add resize-message logic.
72
+ Make DialogContent a flex column with `gap-0`, `overflow-hidden`, and `p-0`.
73
+ Use a compact header with room for the close control. Its hit area must be at
74
+ least 40 by 40 CSS pixels.
75
+
76
+ On mobile, use a small gutter: `w-[calc(100vw-1rem)]`,
77
+ `h-[calc(100dvh-1rem)]`, and `max-h-[calc(100dvh-1rem)]`. On desktop, use
78
+ `sm:w-full`, about 720px height, and a maximum width near 640px. The iframe
79
+ wrapper uses `min-h-0 flex-1 overflow-hidden`; the iframe uses
80
+ `h-full w-full border-0`. Only the iframe scrolls. Do not apply viewport-height
81
+ sizing to its wrapper or leave a blank strip below the header. Closing and
82
+ reopening must preserve the host cart. Closing is not payment completion.
83
+ Do not infer paid access from an iframe URL or close event.
84
+
85
+ ## Native SDK checkout
86
+
87
+ Keep existing product presentation. Use `useLockerverseWidget` for the complete
88
+ live catalog, and store only customer selection in host state. Load
89
+ `LockerversePayment` from `@lockerverse/react/payment` and its CSS when checkout
90
+ is near or visible; pass the full `selection` and `widget` hook result. Use
91
+ `LockerverseDonateLauncher` only when a product picker is needed; a button that
92
+ already selects a product can open that product's checkout directly. Do not
93
+ add an iframe around native SDK checkout or another Stripe Elements wrapper.
94
+
95
+ Use supported `initialCustomFieldValues` for editable prefill. For explicitly
96
+ requested custom checkout, reuse payment primitives with the Core SDK; the
97
+ host then owns quote, confirmation, validation, and recovery duties. Retain
98
+ recovery in an owner that stays mounted when a custom dialog closes. Read the
99
+ payment outcome and recovery contracts in this guide before implementing
100
+ success or retries.
101
+
102
+ ## Public attendee list and checks
103
+
104
+ If the prompt includes an event ID and requests a public attendee list, follow
105
+ [Public attendee lists](events.md#public-attendee-lists). Show names only;
106
+ do not show party size, total counts, or private registration data.
107
+
108
+ Build and inspect desktop and 390 by 844 layouts. Verify the matching button,
109
+ selection, direct or combined URL parameters, modal close target, no header gap,
110
+ and no horizontal overflow. Open checkout to check saved settings, but do not
111
+ submit payment. For native SDK checkout, inspect callback and recovery handling
112
+ without creating live transactions.
113
+
114
+ ## Custom Product UI
115
+
116
+ Use `useLockerverseWidget` to build custom Product presentation from the live
117
+ Lockerverse catalog. This entry point does not import the payment UI or Stripe.
118
+
119
+ ```tsx
120
+ import {
121
+ LockerversePayment,
122
+ preloadLockerversePayment,
123
+ type LockerversePaymentSelectionItem,
124
+ } from "@lockerverse/react/payment";
125
+ import "@lockerverse/react/payment.css";
126
+ import { useLockerverseWidget } from "@lockerverse/react/widget";
127
+ import { useState } from "react";
128
+
129
+ const widgetOptions = {
130
+ communitySlug: "auburn",
131
+ widgetSlug: "tailgate-party",
132
+ };
133
+
134
+ export function CustomProductCheckout() {
135
+ const widget = useLockerverseWidget(widgetOptions);
136
+ const [selection, setSelection] =
137
+ useState<LockerversePaymentSelectionItem | null>(null);
138
+
139
+ if (widget.loading) {
140
+ return <p>Loading checkout...</p>;
141
+ }
142
+ if (widget.error) {
143
+ return <button onClick={widget.refresh}>Try again</button>;
144
+ }
145
+ if (!widget.catalog) {
146
+ return null;
147
+ }
148
+ if (selection) {
149
+ return (
150
+ <LockerversePayment
151
+ selection={[selection]}
152
+ widget={widget}
153
+ />
154
+ );
155
+ }
156
+
157
+ return widget.catalog.products
158
+ .filter((product) => product.pricingMode === "fixed")
159
+ .map((product) => (
160
+ <button
161
+ key={product.id}
162
+ onFocus={() => {
163
+ void preloadLockerversePayment(widget.catalog);
164
+ }}
165
+ onClick={() =>
166
+ setSelection(
167
+ product.paymentOption === "one-time"
168
+ ? { productSlug: product.slug, quantity: 1 }
169
+ : { productSlug: product.slug }
170
+ )
171
+ }
172
+ onPointerEnter={() => {
173
+ void preloadLockerversePayment(widget.catalog);
174
+ }}
175
+ >
176
+ {product.name}
177
+ </button>
178
+ ));
179
+ }
180
+ ```
181
+
182
+ The hook returns immutable `catalog` data together with `loading`, `error`, and
183
+ `refresh`. The host owns presentation and selection state. Pass the
184
+ selected Product slugs to `LockerversePayment`; Lockerverse remains
185
+ authoritative for availability, pricing, and payment.
186
+
187
+ Pass the hook result to `LockerversePayment` with `widget={widget}`. The result
188
+ contains the loaded catalog and its client, so the component requests
189
+ only the authoritative quote without repeated configuration. The host can show
190
+ a local catalog total before checkout. The component uses the same local
191
+ estimate to mount the payment form and Stripe immediately, then replaces it
192
+ with the authoritative quote in the background. Payment confirmation still
193
+ requires the current authoritative quote.
194
+
195
+ This small example shows fixed-price Products. For a custom-price Product, ask
196
+ for the amount in the host UI and pass `amountCents` with `productSlug`.
197
+
198
+ For a monthly or annual Product, omit quantity. The component shows the
199
+ cadence and configures Stripe Elements for a subscription:
200
+
201
+ ```tsx
202
+ <LockerversePayment
203
+ selection={[{ productSlug: "monthly-supporter" }]}
204
+ widget={widget}
205
+ />
206
+ ```
207
+
208
+ Recurring checkout accepts one fixed Product. It does not accept quantities,
209
+ custom amounts, or multiple Products.
210
+
211
+ ### Live product availability
212
+
213
+ Use the live `widget.catalog` for names, prices, and limits. Find the requested
214
+ product by its `listingId` or `slug`; do not keep a separate static price list.
215
+ Amounts are integer cents. Format them with `catalog.payment.currency`, not a
216
+ currency field on the product.
217
+
218
+ Before enabling a new purchase, check the selected product's `minimumQuantity`,
219
+ `maximumQuantity`, and `inventoryQuantity`; nullable limits mean no limit.
220
+ For custom amounts, use `getLockerverseMinimumCustomAmountCents(product)` from
221
+ `@lockerverse/sdk/checkout` to include the SDK minimum and the product minimum.
222
+ Also check:
223
+
224
+ - The matching `catalog.availability.listings` entry: `status`,
225
+ `maxPurchasableQuantity`, and `remainingQuantity`.
226
+ - Each `catalog.availability.sharedLimits` entry whose `listingIds` includes
227
+ the selected product's listing: `status` and `remainingQuantity`. Add the
228
+ quantities of all selected listings in that shared group before checking its
229
+ remaining quantity.
230
+
231
+ A missing listing availability entry means no listing-specific limit was
232
+ reported. Other product and shared limits still apply. These checks help the
233
+ host show an unavailable state; the authoritative quote decides whether the
234
+ selection can be purchased. Show loading, retry, and empty states. Keep an
235
+ unresolved payment available for recovery even if a new purchase is unavailable.
236
+
237
+ If an existing button already selects one product, open its checkout directly.
238
+ Use the donate launcher when the visitor needs a product picker. For a custom
239
+ dialog, use an accessible title, close button, focus handling, and mobile scroll
240
+ area. Keep recovery state in an owner that remains mounted when the dialog
241
+ closes. Closing a dialog does not complete or cancel a payment.
242
+
243
+ ## Donate launcher
244
+
245
+ `LockerverseDonateLauncher` turns an existing payment widget into a native
246
+ React dialog. The launcher selects its controls from each Product's existing
247
+ capabilities. Visitors can set quantities for fixed one-time Products and add
248
+ an inline amount for custom one-time Products. The launcher combines these
249
+ one-time choices in one checkout. Monthly and annual Products are exclusive:
250
+ the visitor selects one tier before the same `LockerversePayment` form opens in
251
+ subscription mode.
252
+
253
+ ```tsx
254
+ import { LockerverseDonateLauncher } from "@lockerverse/react/donate-launcher";
255
+ import { useLockerverseWidget } from "@lockerverse/react/widget";
256
+ import "@lockerverse/react/payment.css";
257
+
258
+ export function Donate() {
259
+ const widget = useLockerverseWidget({
260
+ communitySlug: "auburn",
261
+ widgetSlug: "general-donations",
262
+ });
263
+ return (
264
+ <LockerverseDonateLauncher
265
+ buttonLabel="Donate"
266
+ position="bottom-right"
267
+ widget={widget}
268
+ />
269
+ );
270
+ }
271
+ ```
272
+
273
+ Use `position="inline"` to place the launcher in the normal page layout. Use
274
+ `bottom-left` or `bottom-right` for a fixed launcher. Payment callbacks,
275
+ branding, themes, metadata, and recovery properties are the same as
276
+ `LockerversePayment`. API and transport options belong to
277
+ `useLockerverseWidget`.
278
+
279
+ The launcher starts loading Stripe when the visitor opens it. Catalog-only
280
+ pages do not load Stripe.
281
+
282
+ ## Catalog hook
283
+
284
+ ```tsx
285
+ import { useLockerverseWidget } from "@lockerverse/react/widget";
286
+ ```
287
+
288
+ ## Input
289
+
290
+ ```ts
291
+ type CreateLockerverseWidgetOptions = {
292
+ communitySlug: string;
293
+ widgetSlug: string;
294
+ requestTimeoutMs?: number;
295
+ sentryDsn?: string | null;
296
+ fetch?: typeof globalThis.fetch;
297
+ onError?: (error: LockerverseSdkError) => void;
298
+ } & (
299
+ | { apiBaseUrl: string; environment: "development" | "production" }
300
+ | { apiBaseUrl?: undefined; environment?: "production" }
301
+ );
302
+ ```
303
+
304
+ ## Result
305
+
306
+ ```ts
307
+ type UseLockerverseWidgetResult = {
308
+ catalog: LockerverseCatalog | null;
309
+ client: LockerverseCheckoutClient;
310
+ error: Error | null;
311
+ loading: boolean;
312
+ refresh: () => void;
313
+ };
314
+ ```
315
+
316
+ The loaded catalog includes an optional event:
317
+
318
+ ```ts
319
+ type LockerverseEvent = {
320
+ description: string | null;
321
+ endAt: string;
322
+ id: string;
323
+ location: string | null;
324
+ name: string;
325
+ startAt: string;
326
+ status: "draft" | "active" | "finished" | "cancelled";
327
+ };
328
+
329
+ type LockerverseCatalog = {
330
+ event: LockerverseEvent | null;
331
+ products: LockerverseProduct[];
332
+ // Community, payment, pricing policy, and widget configuration are also present.
333
+ };
334
+ ```
335
+
336
+ `startAt` and `endAt` are event timestamps. Use `status` to determine the
337
+ event lifecycle state.
338
+
339
+ ## Product interface
340
+
341
+ ```ts
342
+ type LockerverseProduct = {
343
+ id: string;
344
+ listingId: string;
345
+ slug: string;
346
+ name: string;
347
+ description: string | null;
348
+ fulfillmentType: "none" | "event_admission";
349
+ pricingMode: "fixed" | "custom";
350
+ paymentOption: "one-time" | "monthly" | "annually";
351
+ amountCents: number | null;
352
+ minimumAmountCents: number | null;
353
+ minimumQuantity: number | null;
354
+ maximumQuantity: number | null;
355
+ inventoryQuantity: number | null;
356
+ acceptsDiscounts: boolean;
357
+ };
358
+ ```
359
+
360
+ An `event_admission` Product is fulfilled by the catalog event.
361
+
362
+ The hook treats all options as initialization values and keeps one client for
363
+ its mounted lifetime. `refresh()` requests that same widget again. To use a
364
+ different widget, remount the component that owns the hook with a new React
365
+ `key`. Selection and other view state can change without a remount. This entry
366
+ does not import Stripe or payment UI.
367
+
368
+ ## Reuse the loaded catalog
369
+
370
+ ```tsx
371
+ const widget = useLockerverseWidget(options);
372
+
373
+ <LockerversePayment
374
+ selection={selection}
375
+ widget={widget}
376
+ />
377
+ ```
378
+
379
+ The hook result carries its catalog and client. The host can calculate
380
+ selection totals from the catalog while the payment component uses that client to request the
381
+ current, authoritative quote. The payment form mounts immediately with a local
382
+ estimate. The authoritative quote replaces that estimate in the background
383
+ before payment confirmation.
384
+
385
+ ## Payment component
386
+
387
+ ```tsx
388
+ import { LockerversePayment } from "@lockerverse/react/payment";
389
+ import "@lockerverse/react/payment.css";
390
+ ```
391
+
392
+ ## Required properties
393
+
394
+ | Property | Type | Description |
395
+ | --- | --- | --- |
396
+ | `widget` | `UseLockerverseWidgetResult` | Result from `useLockerverseWidget` |
397
+ | `selection` | `LockerversePaymentSelectionItem[]` | Products to quote and pay |
398
+
399
+ ## Payment properties
400
+
401
+ ```ts
402
+ type LockerversePaymentProps = {
403
+ widget: UseLockerverseWidgetResult;
404
+ selection: LockerversePaymentSelectionItem[];
405
+ branding?: {
406
+ accentColor?: string;
407
+ imageUrl?: string | null;
408
+ name: string;
409
+ };
410
+ metadata?: Record<string, string>;
411
+ initialCustomFieldValues?: Record<string, boolean | string | string[]>;
412
+ style?: LockerverseStyle;
413
+ theme?: "dark" | "light";
414
+ resumePayment?: {
415
+ connectedAccountId: string | null;
416
+ paymentReference: string;
417
+ } | null;
418
+ onPaymentComplete?: (payment: LockerversePaymentCompletion) => void;
419
+ onPaymentRecoveryChange?: (recovery: LockerversePaymentRecovery | null) => void;
420
+ onPaymentUncertain?: (payment: LockerversePaymentUncertain) => void;
421
+ };
422
+ ```
423
+
424
+ The component also accepts `<section>` properties and `className`. See [Theming](integration.md#styling) for the shared typed CSS tokens.
425
+
426
+ ## Completion
427
+
428
+ ```tsx
429
+ import type { LockerversePaymentCompletion } from "@lockerverse/react/payment";
430
+
431
+ const widget = useLockerverseWidget({
432
+ communitySlug: "your-community",
433
+ widgetSlug: "your-widget",
434
+ });
435
+
436
+ const handlePaymentComplete = (payment: LockerversePaymentCompletion) => {
437
+ const admissionItems = payment.lineItems?.filter(
438
+ (item) => item.fulfillmentType === "event_admission"
439
+ );
440
+ console.info("Payment complete; admission item count", admissionItems?.length ?? 0);
441
+ };
442
+
443
+ <LockerversePayment
444
+ onPaymentComplete={handlePaymentComplete}
445
+ selection={selection}
446
+ widget={widget}
447
+ />
448
+ ```
449
+
450
+ Use `onPaymentComplete` as the confirmed success signal. A direct completion
451
+ includes the normalized customer `email` and authoritative `lineItems`. A
452
+ completion recovered after reload has `null` for both fields.
453
+
454
+ ## Recovery
455
+
456
+ Store `onPaymentRecoveryChange` and pass it back through `resumePayment` after reload. Keep `onPaymentUncertain` unresolved.
457
+
458
+ ## Prefill custom fields
459
+
460
+ Pass configured field IDs to `initialCustomFieldValues`:
461
+
462
+ ```tsx
463
+ <LockerversePayment
464
+ widget={widget}
465
+ selection={selection}
466
+ initialCustomFieldValues={{
467
+ field_muw2q7vx_sf7r94: "Autographed basketball",
468
+ }}
469
+ />
470
+ ```
471
+
472
+ The form reads these values when a checkout form starts. Buyers can edit them;
473
+ parent re-renders and price refreshes do not overwrite their edits. To apply a
474
+ new set of initial values, start a new form with a new React `key`. This also
475
+ resets the other form inputs.
476
+
477
+ Text and textarea fields take strings. A checkbox takes a boolean. Radio and
478
+ select fields take an option ID. Checkboxes and multi-select fields take arrays
479
+ of option IDs. Unknown fields, wrong value types, and unknown options are
480
+ ignored. Only fields for the selected products are used. Required-field
481
+ validation and server validation still apply.
482
+
483
+ `LockerverseDonateLauncher` accepts the same property. For live control over
484
+ individual values and a custom submission flow, use the
485
+ [payment primitives](payment-widgets.md) with the Core SDK.
486
+
487
+ ## Payment outcomes
488
+
489
+ `onPaymentComplete` runs only after Lockerverse reports `success`. A successful payment is terminal, even if the host callback throws.
490
+
491
+ A direct completion includes the normalized customer `email` and authoritative
492
+ `lineItems`. A completion recovered after reload has `null` for both fields.
493
+
494
+ If the authoritative total, discount, currency, line items, or connected Stripe account changes, the component displays the new quote before creating a Stripe token. The customer must explicitly submit again.
495
+
496
+ An authoritative `failed` result is retryable and the next submit receives a new payment reference. `pending`, unresolved `action_required`, and failed same-reference recovery stay locked so the customer cannot accidentally create a second payment. The optional `onPaymentUncertain` callback receives the retained `paymentReference` and one of these reasons:
497
+
498
+ - `pending`
499
+ - `action_required`
500
+ - `recovery_failed`
501
+
502
+ To abandon a locked attempt and intentionally start a new checkout, the host must remount `LockerversePayment` with a new React `key` after resolving the payment status through its own workflow.
503
+
504
+ ### Payment recovery
505
+
506
+ Persist `onPaymentRecoveryChange` synchronously before navigation and pass the
507
+ saved value back through `resumePayment` after a refresh. Do not defer the save
508
+ to an effect. A recovery record has only `paymentReference: string` and
509
+ `connectedAccountId: string | null`; it contains no Stripe secret. Scope its
510
+ storage key to the environment, community, widget, and selection.
511
+
512
+ This hook reads storage after mount, validates the saved record, and keeps an
513
+ in-memory record if storage is unavailable:
514
+
515
+ ```tsx
516
+ import { useEffect, useState } from "react";
517
+ import type { LockerversePaymentRecovery } from "@lockerverse/react/payment";
518
+
519
+ function usePaymentRecovery(recoveryKey: string) {
520
+ const [recovery, setRecovery] = useState<LockerversePaymentRecovery | null>(null);
521
+ const [loaded, setLoaded] = useState(false);
522
+
523
+ useEffect(() => {
524
+ try {
525
+ const raw = sessionStorage.getItem(recoveryKey);
526
+ const saved: unknown = raw ? JSON.parse(raw) : null;
527
+ if (
528
+ saved !== null && typeof saved === "object" &&
529
+ "paymentReference" in saved && typeof saved.paymentReference === "string" &&
530
+ "connectedAccountId" in saved &&
531
+ (saved.connectedAccountId === null || typeof saved.connectedAccountId === "string")
532
+ ) {
533
+ setRecovery({
534
+ paymentReference: saved.paymentReference,
535
+ connectedAccountId: saved.connectedAccountId,
536
+ });
537
+ }
538
+ } catch {
539
+ // Invalid or unavailable storage must not break checkout.
540
+ }
541
+ setLoaded(true);
542
+ }, [recoveryKey]);
543
+
544
+ function saveRecovery(next: LockerversePaymentRecovery | null) {
545
+ setRecovery(next);
546
+ try {
547
+ if (next) sessionStorage.setItem(recoveryKey, JSON.stringify(next));
548
+ else sessionStorage.removeItem(recoveryKey);
549
+ } catch {
550
+ // Keep the in-memory record if storage is unavailable.
551
+ }
552
+ }
553
+
554
+ return { loaded, recovery, saveRecovery };
555
+ }
556
+ ```
557
+
558
+ Use it in the payment owner, keyed by the recovery key. Render checkout only
559
+ after `loaded` is true:
560
+
561
+ ```tsx
562
+ const { loaded, recovery, saveRecovery } = usePaymentRecovery(recoveryKey);
563
+
564
+ return loaded ? (
565
+ <LockerversePayment
566
+ selection={selection}
567
+ resumePayment={recovery}
568
+ onPaymentRecoveryChange={saveRecovery}
569
+ widget={widget}
570
+ />
571
+ ) : <p>Loading checkout...</p>;
572
+ ```
573
+
574
+ Retain the record on `onPaymentUncertain` and let the component recover the
575
+ same payment. Do not create a replacement charge for an unknown result.
576
+ `onPaymentComplete` confirms success; its `paymentRequired` distinguishes a
577
+ paid purchase from a no-charge order. This browser callback does not grant paid
578
+ access; access checks require server verification. Keep private contact and
579
+ payment data out of URLs, analytics, logs, and public metadata.
580
+
581
+ Malformed host selections and invalid custom tips render customer-safe validation messages and do not call the Lockerverse API. Product selection remains owned by the host application.
582
+
583
+ ## Payment primitives
584
+
585
+ Use `LockerversePayment` for the complete checkout. Use these controlled components
586
+ when your website needs its own layout and payment flow. The ready-made checkout
587
+ uses the same fields and `PaymentSummary`; this entry does not import Stripe or
588
+ the ready-made payment controller.
589
+
590
+ ```tsx
591
+ import {
592
+ buildCustomFieldAnswers,
593
+ createCustomFieldValues,
594
+ CustomField,
595
+ CustomFields,
596
+ EmailField,
597
+ getCheckoutCustomFields,
598
+ PaymentSummary,
599
+ reconcileCustomFieldValues,
600
+ validateCustomFields,
601
+ } from "@lockerverse/react/payment-primitives";
602
+ import "@lockerverse/react/payment.css";
603
+ ```
604
+
605
+ See the [live composition example](https://sdk.lockerverse.com/examples/payment-primitives). It loads an
606
+ authoritative quote with the Core SDK and lets a website input and an SDK field
607
+ share the same React state. It previews submission fields and does not charge.
608
+
609
+ ## Controlled custom fields
610
+
611
+ ```tsx
612
+ const fields = getCheckoutCustomFields(catalog, checkout.items);
613
+ const [values, setValues] = useState(() =>
614
+ createCustomFieldValues(fields, { [itemNameFieldId]: "Autographed basketball" })
615
+ );
616
+ const [errors, setErrors] = useState<Record<string, string>>({});
617
+
618
+ <CustomFields
619
+ errors={errors}
620
+ fields={fields}
621
+ onChange={setValues}
622
+ values={values}
623
+ />
624
+ ```
625
+
626
+ `CustomFields` renders all selected fields. Use `CustomField` for one field at a
627
+ time, so you can put your own UI between them. Pass its `field`, `value`,
628
+ `onChange`, and optional `error`. The caller owns state; a website input can
629
+ change the same state at any time. The components supply labels, control IDs,
630
+ required states, and error associations.
631
+
632
+ Use field IDs as keys. Text uses strings, a single checkbox uses a boolean,
633
+ single-choice fields use an option ID, and multiple-choice fields use an array
634
+ of option IDs. `createCustomFieldValues` filters initial values against the field
635
+ definitions. Call `reconcileCustomFieldValues(fields, values)` when the selected
636
+ products or field definitions change. This removes fields and options that no
637
+ longer apply. These helpers do not change the input object.
638
+
639
+ Before submission, call `validateCustomFields`. It returns messages keyed by
640
+ field ID for missing required values. Render those messages and focus the first
641
+ invalid field. Then use `buildCustomFieldAnswers(fields, values)` for the
642
+ Core SDK submission shape. The backend remains the final validation authority.
643
+
644
+ ## Submit through the Core SDK
645
+
646
+ The UI components do not submit or approve payments. Keep the existing Core SDK
647
+ flow: obtain an authoritative quote for the final selection, email, fees, tip,
648
+ and promo code; show any changed total; configure Stripe for that quote; and
649
+ complete Stripe Elements validation and confirmation-token creation.
650
+
651
+ At the submission step, add the shared field values to the normal request:
652
+
653
+ ```ts
654
+ const fieldErrors = validateCustomFields(fields, values);
655
+ if (Object.keys(fieldErrors).length > 0) {
656
+ setErrors(fieldErrors);
657
+ return;
658
+ }
659
+
660
+ // finalCheckout comes from client.calculateTotal() for the final pricing inputs.
661
+ // confirmationToken comes from Stripe after Elements validation. Omit it only
662
+ // for a free checkout. Include required phone/shipping fields in contactDetails.
663
+ const result = await client.submitPayment({
664
+ checkout: finalCheckout,
665
+ submission: {
666
+ ...contactDetails,
667
+ email,
668
+ confirmationToken,
669
+ customFieldAnswers: buildCustomFieldAnswers(fields, values),
670
+ },
671
+ });
672
+ ```
673
+
674
+ This is the submission step, not a complete payment controller. Keep Stripe
675
+ authentication, payment-status recovery, duplicate-submit protection, and
676
+ confirmed completion handling in your host flow. A returned result can need
677
+ more action; do not show success merely because this promise resolves. If an
678
+ error has `recoveryRecommended: true`, call
679
+ `client.getPaymentStatus(finalCheckout.paymentReference)` before allowing a
680
+ new payment attempt.
681
+
682
+ ## Public components and styles
683
+
684
+ | Export | Purpose |
685
+ | --- | --- |
686
+ | `FormField`, `FieldError` | Label, content wrapper, and associated error message |
687
+ | `TextField`, `EmailField`, `Checkbox` | Standard controlled form inputs |
688
+ | `PhoneInput`, `getInternationalPhone` | Country-aware phone input and normalization before submission |
689
+ | `CustomField`, `CustomFields` | Widget-defined text and choice fields |
690
+ | `PaymentSummary` | The same order summary used by ready-made checkout |
691
+
692
+ Each component has a named `...Props` type. `CustomFieldValues` and
693
+ `PhoneInputValue` describe the controlled state.
694
+
695
+ Import `@lockerverse/react/payment.css` and use a `lockerverse-payment` wrapper
696
+ with `data-lockerverse-theme="light"` or `"dark"` to retain the checkout
697
+ appearance. For a custom design, omit that stylesheet and style the components
698
+ in your host. Their labels, input behavior, and error associations remain.
699
+
700
+ `PaymentSummary` needs only `quote`: `<PaymentSummary quote={checkout} />`.
701
+ Its optional `displayedBaseCents`, `displayedTipCents`, and `displayedTotalCents`
702
+ properties let a host show pending price changes, as the ready-made checkout
703
+ does. They default to the quote's total minus tip, tip, and total. Display
704
+ overrides affect the UI only; they never change the payable amount.