# @fanfare-io/fanfare-sdk-solid

SolidJS components and framework-agnostic web components for [Fanfare](https://www.fanfare.io) controlled-access experiences, built on [@fanfare-io/fanfare-sdk-core](https://www.npmjs.com/package/@fanfare-io/fanfare-sdk-core).

Use it in Solid applications directly, or anywhere mounting React is not practical (static HTML, CMS pages, non-React hosts) via the `<fanfare-experience-widget>` custom element.

## States Covered

The component surface is derived from the SDK journey model: `journeyStage`, then `sequence.phase`, then `sequence.mechanism`. The server stays authoritative over what is permitted.

- `ready`: renders the start view and exposes `start(opts?)`.
- `routing`: renders loading UI while the route is selected.
- `gated`: renders auth, access-code, or challenge UI for the active gate.
- `routed`: renders the sequence UI for `unavailable`, `scheduled`, `enterable`, `participating`, `settling`, `granted`, and `ended`.

For routed journeys, the default widget renders `queue`, `draw`, `auction`, `timed_release`, and `appointment` modules across their supported phases. It renders waitlist entry through `UpcomingModule` and waitlist participation through `WaitlistView`. `settling` renders for `draw` and `auction`. `granted` renders for `queue`, `draw`, `auction`, and `timed_release`.

## Install

```bash
npm install @fanfare-io/fanfare-sdk-core @fanfare-io/fanfare-sdk-solid solid-js
```

## Usage

```html
<script type="module">
  import { registerWebComponents } from "@fanfare-io/fanfare-sdk-solid";

  registerWebComponents({
    organizationId: "org_123",
    publishableKey: "pk_live_123",
  });
</script>

<fanfare-experience-widget experience-id="exp_123" auto-start checkout-url="/checkout"></fanfare-experience-widget>
```

Solid applications can use `FanfareProvider`, `ExperienceWidget`, and `useExperienceJourney` directly: the same journey contract as the React adapter, with Solid reactivity.

`auto-start` (`autoStart` in Solid) routes the journey on mount; entering is a separate matter. A queue, draw or timed release the merchant set to enter on arrival needs no Enter click: the SDK enters the consumer inside the routing operation that lands on it, and the widget shows a joining wait — no Enter control — until they have their place, then the participating screen. If the entry is turned down, the widget falls back to the ordinary Enter screen with the refusal beside it. A host that wants every entry to stay an explicit press passes `automaticEntry={false}` to the Solid `ExperienceWidget`, the same `automaticEntry` option to `useExperienceJourney`, or `automatic-entry="false"` on `<fanfare-experience-widget>`; all three reach the ready view's `start`, and omitting it keeps the SDK default of entering on arrival. The contract is in the core package's `docs/UPGRADING-automatic-entry.md` in the SDK repository.

`loadFonts` (`load-fonts` on `<fanfare-experience-widget>`) is off by default. Turned on, the widget fetches the Google Fonts its theme and variant name after mount, through a link that never blocks rendering and fails silently to the fallback stack. A host that already loads its own fonts — or serves them from its own origin — leaves it off.

`slots` override targeted states while keeping the default widget flow. `children` receives `{ journey, view, snapshot, error, start, isStarting }` when you want to render the full state yourself. Solid `ExperienceWidget` accepts both `class` and `className`.

Marketing consent in `AuthForm` requires both `showMarketingSmsConsent` and a nonempty
`marketingSmsMerchantName`. The built-in widget reads the merchant name from the public experience
response, including before an authenticated experience session exists. Custom auth slots receive
`marketingSmsMerchantName`; pass it through to `AuthForm` or `JourneyGate` to offer consent for that
merchant. Sign-in remains available when the merchant name is unavailable.

`AuthForm` keeps marketing copy off the initial phone screen. Continue opens an optional panel
with the named merchant, Fanfare sender, phone number and full disclosure before any OTP request.
Only "Yes, text me" opts in. It captures the displayed merchant, phone, disclosure, legal text,
policy links and affirmative label in `marketingSmsConsentText`, then preserves that snapshot through
OTP verification and resend. "No thanks" continues without marketing consent. Change number,
returning from verification or changing the consent scope clears the choice and snapshot.
Implicit submission can open the panel but cannot choose consent. Custom auth handlers must
preserve the received choice and text through verification; a custom consent UI supplies its own
actual displayed agreement. The server requires a nonblank text snapshot of at most 4,096 characters
to record consent. Missing or invalid evidence leaves sign-in available without granting consent.
This is client-reported audit evidence, not proof of what pixels were displayed.
Release a matching core and adapter together: the built-in panel uses core's shared policy URLs
and consent-text formatter. Deploy the generated database migration and server support before
releasing SDK callers that supply the text.

Mounted inside a shadow root, the widget's styleable elements export CSS parts named after their
`data-slot`, so a page styles them with `::part()` — `fanfare-host::part(auction-cta)`. The
attribution footer and every element that contains its "Powered by Fanfare" row are
deliberately not parts: the widget root and the panel cards that carry that row. The payment
card field is not a part either. A panel whose footer holds no attribution row keeps its card
as a part — `auction-module` is one; [`docs/STYLING.md`](./docs/STYLING.md#parts) lists every
part and every exception.

### Experience timing

`useExperienceTiming(() => experienceId)` takes the experience id as an accessor and reads that experience's public
timing once when it mounts, and again when the id changes or you call `refresh()`:

| `status()`  | `timing()`                                                                |
| ----------- | ------------------------------------------------------------------------- |
| `"loading"` | `undefined` — the first read for this experience has not finished         |
| `"error"`   | `undefined` — the read failed; call `refresh()` to try again              |
| `"ready"`   | the `ExperienceTiming`, or `null` when the experience publishes no timing |

```tsx
import { useExperienceTiming } from "@fanfare-io/fanfare-sdk-solid";
import { Show } from "solid-js";

function DropStatus(props: { experienceId: string }) {
  const timing = useExperienceTiming(() => props.experienceId);
  return <Show when={timing.status() === "ready" && timing.timing()}>{(t) => <span>{t().status}</span>}</Show>;
}
```

`ExperienceTiming` is a type exported from `@fanfare-io/fanfare-sdk-core/experiences`. Its `status` is `upcoming`,
`open` or `closed`, with `nextOpensAt` and `lastClosedAt` around it. `closesAt` is
present only while the experience is open, and is `null` when the open run has no end. The primitive never polls
or schedules a re-read at a boundary, and adds no retry of its own: once `ready`, the result stays as read until
you call `refresh()`.
During a refresh the previous result stays in place — a `ready` result keeps its timing and an `error` stays
`error` — until the new read settles. Primitives that read the same experience at the same time share one request.

## Selection

Some experiences capture which product and variant a consumer gets. `ExperienceWidget` renders that
flow itself on a granted sequence — the pick, a mystery grant's reveal, the wait for an assignment
and the locked summary — and the package also exports the two building blocks of the picker for you
to compose in a granted slot instead.

- `ProductSelector` — loads a catalogue through its `loadOptions` callback, renders the product
  rows, the chosen product's option chips and the resolved variant, and reports a complete
  `{ productId, variantId }` pair through `onSelectionChange`. Anything less than a complete pair
  reports `null`. It never submits a selection itself.
- `SelectionSummary` — a presentational read-out of a pick that has already been captured, with an
  optional change affordance and the locked and unavailable copy.

Prices arrive from the catalogue as exact strings and render in the widget's locale and the
catalogue's `currencyCode`: on a product row when every variant of that product agrees on one
amount, and on the resolved variant's own line beneath the chips. The formatter is internal — the
public surface is exactly these two components plus their prop and handle types.

Selection is configured per sequence, under either checkout arrangement. With merchant
(`external`) checkout the pick is the host's to cart: the slot that composed `ProductSelector` holds
the resolved `{ productId, variantId }` pair and the catalogue it came from, and carries it into its
own checkout. With Fanfare (`internal`) checkout the granted panel continues into Fanfare's own
checkout instead, under both checkout modes, `at_end` and `pre_auth`. Entry-time picking is live:
when the server declares that a draw, timed release or auction bid needs a product choice before
entry, the module renders the picker and `enter` / `bid` carry the pair. A host reaching the view
directly checks `"loadSelectionOptions" in view` for the required arm and reads
`view.selection.required` for display. On the widget-rendered flow the hand-off arrives through
`onCheckoutClick(details?: SelectionCheckoutDetails)`: the pick the grant settled on, with the
catalogue product and variant behind it whenever the widget has loaded them. For external checkout,
use their `externalReference` identities when provided, rather than reconstructing identity from
handles, SKUs or display options. Shopify references identify the product and real variant even
when the default variant has empty option values. Handle absent references before starting checkout;
these fields require a compatible backend and SDK release. Returning a rejected promise returns the consumer
to the CTA with generic inline copy, so a hand-off that never happened is never reported as started.
When a checkout pick was recorded before the panel mounted, the panel loads the catalogue details
for it itself. With its `awaitCheckoutDetails` prop set, `GrantedPanel` holds the checkout CTA
disabled, in its loading state, until those details load, and a failed load replaces the CTA with
a retry. The callback is never invoked without them, and it runs synchronously inside the click, so
it keeps the click's user activation — a host can open a new window straight from it.
`ExperienceWidget` sets the prop whenever `onCheckoutClick` is provided. A slot that composes
`GrantedPanel` directly and reads the details in `onCtaClick` passes `awaitCheckoutDetails` itself;
without it the CTA stays enabled and hands off whatever the panel has already loaded, so a click
during a slow catalogue load hands off with `details` undefined.
Every local pick-surface transition is also announced on the widget root as a bubbling, composed
`fanfare:selection-change` event.

Three integration tiers, in increasing specificity:

1. **Slot override, keeping our pieces.** Override the `enterable`, `participating` or `granted`
   slot with a render function and compose `ProductSelector`, `SelectionSummary` and `GrantedPanel`
   directly. The selection methods live on the sequence the slot hands you as `sequence`, not on
   `view` — `sequence.loadSelectionOptions()` fetches the catalogue and `sequence.select(choice)`
   confirms. Not every arm of the sequence union carries them, so narrow with
   `"loadSelectionOptions" in sequence` first. The view is rebuilt on every journey emission, so
   `sequence.loadSelectionOptions` is a different function each time and passing it straight to
   `loadOptions` re-fetches the catalogue; so is an arrow written inline. Create the loader once,
   outside the JSX, and have it read the journey's current sequence when it is called.
2. **Standalone on your own page.** Import `ProductSelector` / `SelectionSummary` from the package
   root and render them anywhere, driven by `useExperienceJourney`'s view. Both are self-contained
   blocks themed through the ambient `--ff-*` variables and `ThemeProvider`, like every other
   exported component.
3. **Fully custom picker.** Render anything you like, then still call `view.sequence.select(choice)`
   for a granted pick, or pass `selection` to `enter` / `bid` for an entry pick. `resolveVariantFromOptions`
   and `isOptionValueSelectable` are importable from `@fanfare-io/fanfare-sdk-core/selections`, so a
   custom UI resolves a choice exactly as the server does.

The row and option-grid units inside `ProductSelector` are internal; a host that wants different
row rendering uses tier 3.

`loadOptions` must be referentially stable — a new function identity is a new loader and re-fetches
the catalogue, so define it outside the JSX rather than inline. Solid has no `forwardRef`, so
`ProductSelector` hands its `ProductSelectorHandle` (`focus()`, `refresh()`) to a `ref` callback
prop; `refresh()` is what a host calls after a sold-out failure. Both components accept `class`
alongside `className`.

## Documentation

- [Core SDK page observation](https://www.npmjs.com/package/@fanfare-io/fanfare-sdk-core): use `useExperienceJourney` for Solid state and `useSdkEvent` for typed SDK events. For surrounding CSS/non-framework scripts, install core's `createDomBridge` on a stable mounted target and dispose it before destroying the journey or SDK; Solid has no `useFanfareDomBridge` hook.
- [Web components guide](https://docs.fanfare.io/sdk/components/web-components)
- [Theming](https://docs.fanfare.io/sdk/components/theming) · [Slots](https://docs.fanfare.io/sdk/components/slots)
- [SDK overview](https://docs.fanfare.io/sdk/overview)
- [Website](https://www.fanfare.io)
- [Source & issues](https://github.com/fanfare-io/fanfare-sdk)

## License

[Apache-2.0](./LICENSE)
