# @fanfare-io/fanfare-sdk-shopify

The Shopify storefront surface for the Fanfare SDK. An integrator configures one
provider — `FanfareShopifyProvider` — with facts about their store, and the SDK
assembles the product→experience resolution and checkout verify/claim paths
internally. It builds on the framework-free storefront-adapter contract published
from `@fanfare-io/fanfare-sdk-core/storefront`.

> **Note:** the product→experience resolver and the checkout verifier/claimer
> are now **internal** to `FanfareShopifyProvider`. They are no longer exported
> as loose factories — there is nothing to wire by hand. Configure the provider.

## Quick start

```tsx
import { FanfareShopifyProvider } from "@fanfare-io/fanfare-sdk-shopify/react";

// Hoisted so the config keeps one identity for the life of the page. The provider keys its
// work on the values inside, so an inline literal is also safe — a constant is simply clearer.
const SHOPIFY_CONFIG = { proxyBaseUrl: "https://shop.example.com/apps/fanfare/api" };

function App({ children }: { children: React.ReactNode }) {
  return (
    <FanfareShopifyProvider organizationId="org_abc123" publishableKey="pk_live_..." shopify={SHOPIFY_CONFIG}>
      {children}
    </FanfareShopifyProvider>
  );
}
```

That is the whole integrator surface. `mode` defaults to `"production"` and
`experiences` defaults to `{ source: "metafield" }`, so the snippet above is the
complete production wiring. See the prop reference in
`src/react/shopify-provider.tsx` (the `FanfareShopifyProviderProps` JSDoc) and
the store-config field reference in `src/config.ts` (`ShopifyAdapterConfig`).

### Where the `shopify` fields come from

`proxyBaseUrl` is the only `shopify` field. **Everything routes through the app
proxy** — verify, claim, AND experience resolution — so there are no Storefront-API
credentials to manage and no numeric app id to be told. The proxy resolves the
`$app:` metafield namespace server-side.

| Field          | Required | Where to get it                                                                                                              |
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `proxyBaseUrl` | prod     | The Fanfare Shopify app's app-proxy URL on your Online Store's primary domain (`https://<primary-domain>/apps/fanfare/api`). |

#### Use the Online Store's primary domain

`proxyBaseUrl` must name the domain your Shopify **Online Store** serves from — the
store's primary domain as shown under Settings → Domains. That is the host that
answers the app-proxy path (`/apps/fanfare/api`) directly, with no redirect. It is
not necessarily the host your headless page is served from; a headless
application does not serve the app-proxy route.

When the store has no custom domain, its primary domain is its `*.myshopify.com`
host and that is the correct value. Once a custom domain is set as primary,
Shopify answers every request on the myshopify host with a `301` to it, the
app-proxy path included. A browser cannot follow a redirect on a cross-origin
request, so every proxy call — experience resolution, `claim`, `reserve` — fails
before a response is received and surfaces as an opaque "CORS error".

Two independent conditions have to hold for browser calls to succeed:

- `proxyBaseUrl` names the primary domain (answers `200`, not `301`).
- The page's origin is permitted by the app's **Allowed Domains** setting (an
  empty list permits every origin).

A password-protected or development store additionally requires the storefront
password before the app proxy answers. The SDK logs one console warning per base
when a proxy request fails before a response is received; it names the host and
both conditions, and cannot tell which one failed.

## Resolve a product

```tsx
import { useFanfareExperience } from "@fanfare-io/fanfare-sdk-shopify/react";

function ProductPage({ product }: { product: { handle: string; id: string } }) {
  const experience = useFanfareExperience({ handle: product.handle, id: product.id });

  switch (experience.status) {
    case "loading":
      return <Spinner />;
    case "gated":
      return <GatedPurchaseUI experienceId={experience.experienceId} />;
    case "ungated":
      return <NativePurchaseUI />;
    case "error":
      // Fail open: gating is unknown, so the native buy path renders and Shopify's checkout validation still enforces; a drop page may choose to hold instead.
      return <NativePurchaseUI />;
  }
}
```

Pass onResolutionError={(error) => report(error)} to observe failed resolutions yourself; error.lookupKey is the product handle or id and error.reason is one of "network" | "http" | "malformed" | "unavailable" | "override". Without it the provider logs one console.warn per product and reason to the developer console — nothing is reported to Fanfare. Supplying a handler replaces that fallback. Separately, a request to an absolute proxy base that fails before a response gets one advisory per base naming the host and the configuration to check; that advisory is emitted by the resolver whether or not a handler is supplied, and the provider does not repeat it as a per-product warning.

## Checkout identity from widget details

React's `ExperienceWidget.onCheckoutClick` receives optional
`SelectionCheckoutDetails`. When present, its product and variant are the catalogue
objects for the committed Fanfare pair, not necessarily the current PDP's pair.

For Shopify checkout, use `details.product.externalReference.id` as
`gatedLine.shopifyProductId` and `details.variant.externalReference.id` as
`gatedLine.merchandiseId`, through the existing `productGid` and `variantGid` helpers.
First require both references, `provider === "shopify"`, and the appropriate complete
Product/ProductVariant GID shapes. Those helpers normalize IDs; they are not substitutes
for validating an optional external reference. Reject missing or invalid identity
before calling `gatedCheckout`.

Do not reconstruct identity from handles, SKUs, option labels, or a local variant list.
A Shopify default variant has `optionValues: []` but still has a variant reference.
The same checkout call applies to fixed, consumer-selected, and system-assigned pairs.
The cart adapter still owns Storefront API calls; `gatedCheckout` still owns the
create-cart → claim → add-line ordering and returns the checkout result.

This requires a backend and SDK that retain external references. SDK 0.19.0 strips
the fields. Details remain optional while the catalogue is unavailable or when no
catalogue pair can be resolved; this change does not redesign catalogue readiness.

## Priced admissions: dedicated carts

An admission that carries a price (an auction win) can only be claimed on a
**dedicated cart**: a cart that holds nothing but that admission's line, in a
market whose currency is the shop currency, and that no other admission is bound
to. `createDedicatedCart` asks the Fanfare app proxy to create one:

```ts
import { createDedicatedCart } from "@fanfare-io/fanfare-sdk-shopify";

const { cartId, checkoutUrl } = await createDedicatedCart({ proxyBaseUrl: SHOPIFY_CONFIG.proxyBaseUrl });
```

It POSTs `{ "dedicated": true }` to `${proxyBaseUrl}/admissions/checkout-url` and
resolves the new empty cart's Storefront GID and checkout URL. It **rejects** on
any failure (the error message carries the proxy's error code, or
`proxy_<status>`), so a `createCart()` that awaits it surfaces as
`cart_create_failed` from `gatedCheckout`. An empty cart your own `createCart()`
makes in a shop-currency market also qualifies.

`createDedicatedCart` needs a Fanfare Shopify app version with dedicated-cart
support. An older app version ignores `dedicated: true` and returns an ordinary
empty cart.

Two claim refusals belong to priced admissions. Neither is retryable as-is
(`retryable: false` from `gatedCheckout`):

| Code                            | Meaning                                                                                           | What to do                                                                                                                                                                                                                                         |
| ------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dedicated_cart_required`       | The cart is not dedicated (other lines, another market's currency, or another admission's token). | Have your `FanfareCartAdapter.createCart()` return the cart from `createDedicatedCart()`, then call `gatedCheckout` again.                                                                                                                         |
| `admission_bound_to_other_cart` | The admission is already bound to another cart.                                                   | When the result carries `boundCart`, call `claim({ cartId: boundCart.cartId, checkoutUrl: boundCart.checkoutUrl, purchase })`, add the gated line only if that cart lacks it, and redirect to that cart. Without `boundCart`, surface the refusal. |

Pass both `boundCart` fields to `claim`: the claim signs its admission proof over
`checkoutUrl`, and the proof must cover the cart that receives the gate token. A
claim on the bound cart never binds the admission to a second cart.

## Modes: `production` (default) vs `demo`

`mode` defaults to `"production"`: real verify + claim through the Fanfare
Shopify app proxy. Production **requires** `shopify.proxyBaseUrl`; if it is
missing, `FanfareShopifyProvider` throws `FanfareConfigurationError` at mount
(fail-loud — it never silently degrades).

`mode:"demo"` is the in-browser, harness-backed lane for **local development and
Storybook only** — never a live storefront. In demo the checkout verifier is a
client-only no-op: it enforces only client-side grant expiry and performs **no
server-side checkout enforcement**, so a gated product is not actually protected.
A gated product renders a visible "demo — not production-safe" state in this mode.

## Entry points

| Import                                  | Runtime    | Contents                                                                                                                                                                                                                                                                                                             |
| --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@fanfare-io/fanfare-sdk-shopify`       | isomorphic | `FanfareConfigurationError`, `createDedicatedCart`, the `experienceIds` metafield primitives (`FANFARE_PRODUCT_METAFIELD`, `parseExperienceIds`, `parseFirstExperienceId`), config types (`FanfareMode`, `ShopifyAdapterConfig`), and a re-export of core's `./storefront` vocabulary                                |
| `@fanfare-io/fanfare-sdk-shopify/react` | client     | `FanfareShopifyProvider` + its prop/type exports (`FanfareShopifyProviderProps`, whose `onResolutionError` prop observes failed resolutions; `ExperienceSource`), `useFanfareExperience`, `ExperienceResolutionError`, `ExperienceResolutionErrorReason`, `FanfareConfigurationError`, config types (`"use client"`) |

The root entry imports zero React, so a server-only consumer (Hydrogen/Oxygen
loader, Next/Remix server component) installs and uses it without React. React
is an optional peer dependency, needed only for the `/react` subpath.

This provider is the **adapter** only. It does NOT subsume `fanfare-sdk-react`'s
`FanfareProvider` / `ExperienceWidget` SDK init — the drop-journey widget stays
the integrator's, exactly as today.
