# `@lockerverse/react`

Tree-shakable React UI for Lockerverse payment and signup widgets. The Core SDK is installed automatically as a normal package dependency.

## Install

```sh
npm install @lockerverse/react react react-dom
```

For payment UI, also install the optional Stripe peers:

```sh
npm install @stripe/react-stripe-js @stripe/stripe-js
```

## Use

```tsx
import {
  LockerversePayment,
  type LockerversePaymentCompletion,
  type LockerversePaymentSelectionItem,
} from "@lockerverse/react/payment";
import "@lockerverse/react/payment.css";
import { useLockerverseWidget } from "@lockerverse/react/widget";

export function Checkout() {
  const widget = useLockerverseWidget({
    communitySlug: "auburn",
    widgetSlug: "tailgate-party",
  });
  return (
    <LockerversePayment
      metadata={{ source: "community-site" }}
      onPaymentComplete={(payment: LockerversePaymentCompletion) => {
        console.info("Payment complete", payment.checkoutId);
        console.info("Customer", payment.email);
        console.info("Authoritative items", payment.lineItems);
      }}
      onPaymentUncertain={({ paymentReference, reason }) => {
        console.info("Payment needs status recovery", paymentReference, reason);
      }}
      selection={[{ productSlug: "adult", quantity: 1 }]}
      theme="dark"
      widget={widget}
    />
  );
}
```

The original `@lockerverse/react` and `@lockerverse/react/styles.css` payment imports remain supported.

## Resource identity

A mounted hook or widget keeps one resource identity. Do not change its API,
environment, community slug, widget slug, or signup slug. To show a different
resource, remount the owning component with a new React `key`:

```tsx
<Checkout
  key={`${communitySlug}:${widgetSlug}`}
  communitySlug={communitySlug}
  widgetSlug={widgetSlug}
/>
```

Selection, theme, callbacks, and other view properties can change without a
remount. `refresh()` reloads the same resource.

## Custom Product UI

Use `useLockerverseWidget` to build custom Product presentation from the live
Lockerverse catalog. This entry point does not import the payment UI or Stripe.

```tsx
import {
  LockerversePayment,
  preloadLockerversePayment,
  type LockerversePaymentSelectionItem,
} from "@lockerverse/react/payment";
import "@lockerverse/react/payment.css";
import { useLockerverseWidget } from "@lockerverse/react/widget";
import { useState } from "react";

const widgetOptions = {
  communitySlug: "auburn",
  widgetSlug: "tailgate-party",
};

export function CustomProductCheckout() {
  const widget = useLockerverseWidget(widgetOptions);
  const [selection, setSelection] =
    useState<LockerversePaymentSelectionItem | null>(null);

  if (widget.loading) {
    return <p>Loading checkout...</p>;
  }
  if (widget.error) {
    return <button onClick={widget.refresh}>Try again</button>;
  }
  if (!widget.catalog) {
    return null;
  }
  if (selection) {
    return (
      <LockerversePayment
        selection={[selection]}
        widget={widget}
      />
    );
  }

  return widget.catalog.products
    .filter((product) => product.pricingMode === "fixed")
    .map((product) => (
      <button
        key={product.id}
        onFocus={() => {
          void preloadLockerversePayment(widget.catalog);
        }}
        onClick={() =>
          setSelection(
            product.paymentOption === "one-time"
              ? { productSlug: product.slug, quantity: 1 }
              : { productSlug: product.slug }
          )
        }
        onPointerEnter={() => {
          void preloadLockerversePayment(widget.catalog);
        }}
      >
        {product.name}
      </button>
    ));
}
```

The hook returns immutable `catalog` data together with `loading`, `error`, and
`refresh`. The host owns presentation and selection state. Pass the
selected Product slugs to `LockerversePayment`; Lockerverse remains
authoritative for availability, pricing, and payment.

Pass the hook result to `LockerversePayment` with `widget={widget}`. The result
contains the loaded catalog and its client, so the component requests
only the authoritative quote without repeated configuration. The host can show
a local catalog total before checkout. The component uses the same local
estimate to mount the payment form and Stripe immediately, then replaces it
with the authoritative quote in the background. Payment confirmation still
requires the current authoritative quote.

This small example shows fixed-price Products. For a custom-price Product, ask
for the amount in the host UI and pass `amountCents` with `productSlug`.

For a monthly or annual Product, omit quantity. The component shows the
cadence and configures Stripe Elements for a subscription:

```tsx
<LockerversePayment
  selection={[{ productSlug: "monthly-supporter" }]}
  widget={widget}
/>
```

Recurring checkout accepts one fixed Product. It does not accept quantities,
custom amounts, or multiple Products.

## Donate launcher

`LockerverseDonateLauncher` turns an existing payment widget into a native
React dialog. The launcher selects its controls from each Product's existing
capabilities. Visitors can set quantities for fixed one-time Products and add
an inline amount for custom one-time Products. The launcher combines these
one-time choices in one checkout. Monthly and annual Products are exclusive:
the visitor selects one tier before the same `LockerversePayment` form opens in
subscription mode.

```tsx
import { LockerverseDonateLauncher } from "@lockerverse/react/donate-launcher";
import { useLockerverseWidget } from "@lockerverse/react/widget";
import "@lockerverse/react/payment.css";

export function Donate() {
  const widget = useLockerverseWidget({
    communitySlug: "auburn",
    widgetSlug: "general-donations",
  });
  return (
    <LockerverseDonateLauncher
      buttonLabel="Donate"
      position="bottom-right"
      widget={widget}
    />
  );
}
```

Use `position="inline"` to place the launcher in the normal page layout. Use
`bottom-left` or `bottom-right` for a fixed launcher. Payment callbacks,
branding, themes, metadata, and recovery properties are the same as
`LockerversePayment`. API and transport options belong to
`useLockerverseWidget`.

The launcher starts loading Stripe when the visitor opens it. Catalog-only
pages do not load Stripe.

## Signup

The signup entry does not import payment or Stripe code.

```tsx
import {
  LockerverseSignup,
  useLockerverseSignup,
} from "@lockerverse/react/signup";
import "@lockerverse/react/signup.css";

export function Signup() {
  const widget = useLockerverseSignup({
    communitySlug: "auburn",
    signupSlug: "tailgate-guests",
  });
  return (
    <LockerverseSignup
      branding={{
        accentColor: "#5751f2",
        imageUrl: "https://cdn.example.com/community.png",
        name: "Auburn",
      }}
      onSignupComplete={(submission) => {
        console.info("Signup complete", submission.id);
      }}
      widget={widget}
    />
  );
}
```

Pass `googlePlacesApiKey` to enable US address suggestions when the signup asks for an address. Manual address entry always remains available.

For a development HTTPS backend, provide `apiBaseUrl` to the hook. The backend
supplies the correct Stripe public key.

```tsx
const widget = useLockerverseWidget({
  apiBaseUrl: "https://portal-dev.lockerverse.com/api",
  communitySlug: "luis-c",
  environment: "development",
  widgetSlug: "new-payment-widget",
});

<LockerversePayment
  selection={[{ productSlug: "adult", quantity: 1 }]}
  widget={widget}
/>
```

## Styling

Auction checkout, payment, and signup use common email, phone, field-error, and checkbox controls. Payment and signup share the custom-field renderer. Phone fields use the lightweight payment/signup country list, normalization, and Valibot format validation. The shared phone control is imported directly, with no separate phone chunk or metadata library. Each flow keeps its existing field configuration and backend rules.

Payment and auction Stripe forms resolve the rendered SDK font and theme tokens before passing them to Stripe. Custom font files are not loaded into Stripe by a font family name alone. See [the shared form foundation](https://github.com/Lockerverse/lockerverse-sdk/blob/main/docs/shared-form-foundation.md) for module boundaries and bundle measurement.

Use `theme="dark"` or `theme="light"`. Lockerverse applies the community branding returned by the widget API. Pass the same `LockerverseStyle` object to payment, signup, and donate launcher components.

```tsx
import type { LockerverseStyle } from "@lockerverse/react";

const lockerverseStyle = {
  "--lockerverse-accent": "#ff6b35",
  "--lockerverse-bg": "#fffaf0",
  "--lockerverse-control": "#f5eddf",
  "--lockerverse-radius": "14px",
  "--lockerverse-text": "#211c17",
} satisfies LockerverseStyle;

<LockerversePayment
  selection={selection}
  style={lockerverseStyle}
  widget={widget}
/>
```

The canonical `--lockerverse-*` tokens control every component. Existing
`--lockerverse-signup-*` tokens remain supported as signup-only aliases.

The package keeps React, React DOM, and Stripe's React libraries as peer dependencies so it does not ship duplicate framework code. `LockerversePayment` leaves product selection to its host. `LockerverseDonateLauncher` provides selection from the existing widget catalog. Neither component depends on Lockerverse's Next.js application components.

## Payment outcomes

`onPaymentComplete` runs only after Lockerverse reports `success`. A successful payment is terminal, even if the host callback throws.

A direct completion includes the normalized customer `email` and authoritative
`lineItems`. A completion recovered after reload has `null` for both fields.

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.

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:

- `pending`
- `action_required`
- `recovery_failed`

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.

Persist `onPaymentRecoveryChange` synchronously before navigation and pass the saved value back through `resumePayment` after a refresh. The recovery value contains a Lockerverse reference and public connected-account ID, never a Stripe secret.

```tsx
import type { LockerversePaymentRecovery } from "@lockerverse/react";

const recoveryKey = "lockerverse:tailgate-party:payment";
const savedRecovery = sessionStorage.getItem(recoveryKey);
const recovery = savedRecovery
  ? (JSON.parse(savedRecovery) as LockerversePaymentRecovery)
  : null;

<LockerversePayment
  selection={selection}
  resumePayment={recovery}
  onPaymentRecoveryChange={(nextRecovery) => {
    if (nextRecovery) {
      sessionStorage.setItem(recoveryKey, JSON.stringify(nextRecovery));
    } else {
      sessionStorage.removeItem(recoveryKey);
    }
  }}
  widget={widget}
/>
```

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.

## Auctions

For a page containing one auction, use `LockerverseAuction` from
`@lockerverse/react/auction` and `@lockerverse/react/auction.css`. Its header,
filters, and lineup can be hidden independently; `renderItem` can keep the
built-in card or supply a custom card. See the [auction composition reference](https://sdk.lockerverse.com/react/auctions)
and [complete example](https://sdk.lockerverse.com/examples/auctions).
The [State Lab](https://sdk.lockerverse.com/examples/state-lab) provides visibly
labelled local simulations of bid uncertainty and purchase recovery.

Browse auctions and open the built-in item checkout. The catalog entry does not
load Stripe. The checkout entry uses the optional Stripe peers listed above.
Import `auctions.css` for both components. Built-in auction checkout accepts
cards only. The core SDK supports the backend confirmation-token flows for
custom payment UIs.

```tsx
import { useState } from "react";
import type { LockerverseAuction } from "@lockerverse/sdk/auctions";
import { LockerverseAuctions, useLockerverseAuctions } from "@lockerverse/react/auctions";
import { LockerverseAuctionItem } from "@lockerverse/react/auction-item";
import "@lockerverse/react/auctions.css";

export function AuctionPage() {
  const auctions = useLockerverseAuctions({ communitySlug: "auburn" });
  const [selected, setSelected] = useState<{
    auction: LockerverseAuction;
    itemId: string;
  } | null>(null);
  const [locked, setLocked] = useState(false);
  const [message, setMessage] = useState("");

  return <>
    <div hidden={Boolean(selected)}>
      <LockerverseAuctions
        auctions={auctions}
        onSelectItem={(auction, item) => {
          if (!locked) setSelected({ auction, itemId: item.id });
        }}
        theme="light"
      />
    </div>
    {selected && <LockerverseAuctionItem
      key={`${selected.auction.id}:${selected.itemId}`}
      auction={selected.auction}
      client={auctions.client}
      itemId={selected.itemId}
      onBack={locked ? undefined : () => setSelected(null)}
      onInteractionLockChange={setLocked}
      onBidSubmitted={() => { setMessage("Bid submitted; this is not a winning bid."); auctions.refresh(); }}
      onPurchaseComplete={() => { setMessage("Payment complete."); auctions.refresh(); }}
      onUncertain={(kind) => setMessage(`The ${kind} result is unknown. Keep this page open; do not submit another bid.`)}
      theme="light"
    />}
    <p role="status">{message}</p>
  </>;
}
```

Production is the default. Set `environment: "development"` on the hook only
when you intend to use the development backend. Auction data supplies the
publishable key and connected account for Stripe initialization. The backend
must include the auction `payment` configuration to use built-in checkout.

Both components accept `theme` and the existing `LockerverseStyle` CSS-variable
object. Use the hook data for your own item cards and open `LockerverseAuctionItem`
for checkout. No general headless workflow API is required.

`onBidSubmitted` means the bid request and any required Stripe action completed;
it does not mean the bid won or a charge occurred. `onPurchaseComplete` runs only
when Lockerverse reports `paid`. `onUncertain` means the result remains unknown.
Keep that state visible and use the checkout recovery action for a purchase.
Status checks call `client.getPurchaseStatus` without creating another payment;
the backend can reconcile Stripe status and send the purchase notification.
A `null` status lookup is not proof that an original in-flight request ended.
Any retry must keep the original `clientRequestId` and purchase details.
If a bid result is unknown, block a new bid and keep the support message visible.
Connect `onInteractionLockChange` to all host navigation, resource selectors, and
reset controls. A true lock covers submission and unresolved outcomes. Keep host
callback state above checkout. If checkout is unexpectedly removed after send,
known completion or uncertainty is still reported once for that operation;
no new Stripe action starts after unmount. Full page reload or browser shutdown
cannot guarantee callback delivery.
Never automatically retry a bid: the backend has no public bid recovery API.

For a host that needs recovery after navigation, connect
`onPurchaseRecoveryChange` and later pass its record as `resumePurchase`:

```tsx
<LockerverseAuctionItem
  auction={auction}
  client={client}
  itemId={itemId}
  onPurchaseRecoveryChange={savePrivateRecoveryRecord}
  resumePurchase={previousRecoveryRecord}
/>
```

The host owns storage. A recovery record captures the original item, payment
configuration, pricing, buyer contact data, and Stripe confirmation token. Keep it private; do not put it in URLs, analytics, logs, or
shared caches. Clear the stored record when the callback supplies `null`.
Resume only with the same auction, item, client, and payment environment. Keep
the original `clientRequestId` and purchase details. The public example stores
recovery in memory only and asks the customer to keep the page open.

The raw receipt endpoint returns amounts, not payment status. Do not use a
receipt response alone to show a payment as complete.

### Direct auction pages

Import `useLockerverseAuction` from `@lockerverse/react/auction-data` and call
`useLockerverseAuction({ communitySlug }, auctionSlug)` to load one auction.
This uses the detail endpoint; it never fetches the full community list.
Render `LockerverseAuction` from `@lockerverse/react/auction` with the result,
and import `@lockerverse/react/auction.css`. Use `auction-item` and its stylesheet
when opening built-in item details and checkout.

The singular hook returns `client`, `auction`, `loading`, `error`, and `refresh`.
Refresh retains the last good data. Keep checkout mounted across refresh and
errors. The item component applies new same-ID data while idle and preserves
its payment snapshot while payment is open. Connect `onInteractionLockChange`
to host navigation. Resource identity changes require a new React key.

[Complete direct-auction example](https://sdk.lockerverse.com/examples/auctions?community=lckrvrs-internal&auction=auction).
Built-in styled UI is the default for Lovable. There is no automatic headless switch.
