@fanfare-io/fanfare-sdk-shopify 0.2.0 → 0.4.0

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.
package/README.md CHANGED
@@ -1,35 +1,76 @@
1
1
  # @fanfare-io/fanfare-sdk-shopify
2
2
 
3
- The Shopify storefront surface for the Fanfare SDK: the product→experience
4
- resolver, the checkout verifier, and the `experienceIds` metafield primitives.
5
- It builds on the framework-free storefront-adapter contract published from
6
- `@fanfare-io/fanfare-sdk-core/storefront`.
3
+ The Shopify storefront surface for the Fanfare SDK. An integrator configures one
4
+ provider — `FanfareShopifyProvider` — with facts about their store, and the SDK
5
+ assembles the product→experience resolution and checkout verify/claim paths
6
+ internally. It builds on the framework-free storefront-adapter contract published
7
+ from `@fanfare-io/fanfare-sdk-core/storefront`.
8
+
9
+ > **Note:** the product→experience resolver and the checkout verifier/claimer
10
+ > are now **internal** to `FanfareShopifyProvider`. They are no longer exported
11
+ > as loose factories — there is nothing to wire by hand. Configure the provider.
12
+
13
+ ## Quick start
14
+
15
+ ```tsx
16
+ import { FanfareShopifyProvider } from "@fanfare-io/fanfare-sdk-shopify/react";
17
+
18
+ function App() {
19
+ return (
20
+ <FanfareShopifyProvider
21
+ organizationId="org_abc123"
22
+ publishableKey="pk_live_..."
23
+ shopify={{
24
+ proxyBaseUrl: "https://shop.myshopify.com/apps/fanfare/api",
25
+ }}
26
+ >
27
+ {/* your storefront tree; the Fanfare widget lives here, unchanged */}
28
+ </FanfareShopifyProvider>
29
+ );
30
+ }
31
+ ```
32
+
33
+ That is the whole integrator surface. `mode` defaults to `"production"` and
34
+ `experiences` defaults to `{ source: "metafield" }`, so the snippet above is the
35
+ complete production wiring. See the prop reference in
36
+ `src/react/shopify-provider.tsx` (the `FanfareShopifyProviderProps` JSDoc) and
37
+ the store-config field reference in `src/config.ts` (`ShopifyAdapterConfig`).
38
+
39
+ ### Where the `shopify` fields come from
40
+
41
+ `proxyBaseUrl` is the only `shopify` field. **Everything routes through the app
42
+ proxy** — verify, claim, AND experience resolution — so there are no Storefront-API
43
+ credentials to manage and no numeric app id to be told. The proxy resolves the
44
+ `$app:` metafield namespace server-side.
45
+
46
+ | Field | Required | Where to get it |
47
+ | -------------- | -------- | ------------------------------------------------------------------------------------------ |
48
+ | `proxyBaseUrl` | prod | The Fanfare Shopify app's app-proxy URL on your store (`https://<shop>/apps/fanfare/api`). |
49
+
50
+ ## Modes: `production` (default) vs `demo`
51
+
52
+ `mode` defaults to `"production"`: real verify + claim through the Fanfare
53
+ Shopify app proxy. Production **requires** `shopify.proxyBaseUrl`; if it is
54
+ missing, `FanfareShopifyProvider` throws `FanfareConfigurationError` at mount
55
+ (fail-loud — it never silently degrades).
56
+
57
+ `mode:"demo"` is the in-browser, harness-backed lane for **local development and
58
+ Storybook only** — never a live storefront. In demo the checkout verifier is a
59
+ client-only no-op: it enforces only client-side grant expiry and performs **no
60
+ server-side checkout enforcement**, so a gated product is not actually protected.
61
+ A gated product renders a visible "demo — not production-safe" state in this mode.
7
62
 
8
63
  ## Entry points
9
64
 
10
- | Import | Runtime | Contents |
11
- | --------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
- | `@fanfare-io/fanfare-sdk-shopify` | isomorphic | `createShopifyExperienceResolver`, `shopifyCheckoutVerifier`, `FANFARE_PRODUCT_METAFIELD`, `parseExperienceIds`, `parseFirstExperienceId`, and a re-export of core's `./storefront` vocabulary |
13
- | `@fanfare-io/fanfare-sdk-shopify/react` | client | `FanfareAdapterProvider`, `useFanfareAdapter`, `FanfareAdapterValue` (`"use client"`) |
65
+ | Import | Runtime | Contents |
66
+ | --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
67
+ | `@fanfare-io/fanfare-sdk-shopify` | isomorphic | `FanfareConfigurationError`, the `experienceIds` metafield primitives (`FANFARE_PRODUCT_METAFIELD`, `parseExperienceIds`, `parseFirstExperienceId`), config types (`FanfareMode`, `ShopifyAdapterConfig`), and a re-export of core's `./storefront` vocabulary |
68
+ | `@fanfare-io/fanfare-sdk-shopify/react` | client | `FanfareShopifyProvider` + its prop/type exports (`FanfareShopifyProviderProps`, `ExperienceSource`), `FanfareConfigurationError`, config types (`"use client"`) |
14
69
 
15
70
  The root entry imports zero React, so a server-only consumer (Hydrogen/Oxygen
16
71
  loader, Next/Remix server component) installs and uses it without React. React
17
72
  is an optional peer dependency, needed only for the `/react` subpath.
18
73
 
19
- ## Placeholder seams (final-shaped signatures)
20
-
21
- Two seam bodies are honest local placeholders today; their **signatures are
22
- final** and the wiring lands behind them with no surface change:
23
-
24
- - **`createShopifyExperienceResolver(config)`** — `resolve(product)` resolves
25
- from a config-supplied static `metafieldValues` map (handle → raw metafield
26
- value) so the parser path is exercised end-to-end without a network. The live
27
- Storefront-API read of the reified `app--<fanfareAppId>--product` /
28
- `experienceIds` metafield is the only change that lands later.
29
- - **`shopifyCheckoutVerifier`** — `verify(grant)` enforces only client-side grant
30
- expiry today. The ENG-913 server-validatable handoff (mint at claim, redeem +
31
- validate at checkout entry) slots in behind the same `verify(grant)`
32
- signature. `verify` is **total**: it resolves for every grant and never
33
- rejects, so a future throwing redeem path must be wrapped into a denied result.
34
-
35
- No exported identifier says mock/demo.
74
+ This provider is the **adapter** only. It does NOT subsume `fanfare-sdk-react`'s
75
+ `FanfareProvider` / `ExperienceWidget` SDK init — the drop-journey widget stays
76
+ the integrator's, exactly as today.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Shopify checkout CLAIMER — the on-grant, NON-CONSUMING sibling of
3
+ * `createShopifyCheckoutVerifier`.
4
+ *
5
+ * The claimer and the verifier relay the same `{ credential, distributionId,
6
+ * cartId }` body to the same proxy host, but they are deliberately SEPARATE
7
+ * factories — not one factory with an endpoint flag — because their result
8
+ * shapes and readiness policies diverge (see below). They differ in WHICH route
9
+ * they POST to, and that route is configurable per caller:
10
+ *
11
+ * - The CLAIMER's factory default is the `admissions/claim` route, which
12
+ * validates the credential, mints a short-lived gate token, and writes it to
13
+ * the cart metafield (mint-only; reserves and spends nothing). It returns the
14
+ * minted token's `expDate` so the storefront can surface a continuity signal
15
+ * — so its result carries a body the caller reads. Direct callers (e.g. the
16
+ * Liquid theme path) use this default.
17
+ * - The React adapter's headless buy action OVERRIDES `endpoint` to
18
+ * `admissions/reserve` (the #29 reserve-fold — see `react/use-fanfare-claim`).
19
+ * Reserve is a strict superset: it validates + mints + writes the gate token
20
+ * AND reserves the slot, so the slot can't be re-drawn mid-checkout. Its body
21
+ * is shape-compatible — the claimer reads only `expDate` (reserve's extra
22
+ * `reservationId` is ignored here), so `ClaimResult` is unchanged.
23
+ * - The VERIFIER fires *at checkout entry* against the `admissions/reserve`
24
+ * route as a separate factory and returns a bare gate decision whose body it
25
+ * ignores. (Reserve is not exclusively the verifier's route — the reserve-fold
26
+ * above points the claimer at it for the headless mint+reserve round-trip.)
27
+ *
28
+ * The claimer is NOT a `CheckoutVerifier`: different result shape (carries
29
+ * `expDate`), different context-readiness policy (a missing context is a benign
30
+ * no-op, not a construction error — the claimer fires automatically on every
31
+ * grant), and it is invoked directly by the storefront, never threaded through
32
+ * the React adapter's `recordGrant` seam.
33
+ *
34
+ * Fails closed by construction: a missing/`null` context, a non-2xx proxy
35
+ * response, a 2xx body without a parseable `expDate`, and any transport throw all
36
+ * resolve to a denied `{ ok:false, reason }`. It NEVER rejects.
37
+ */
38
+ /** The live context a claim needs. INTERNAL — the React adapter fills it for you
39
+ * from provider state; you never construct one. Separate from
40
+ * `ShopifyCheckoutContext` so the claim and verify seams can diverge without
41
+ * breaking each other. */
42
+ export interface ShopifyClaimContext {
43
+ /** The consumer's admission credential (the `admissionGrant` from the
44
+ * consumer-me snapshot). Relayed to the proxy; never persisted in Shopify. */
45
+ credential: string;
46
+ distributionId: string;
47
+ /**
48
+ * The cart identifier the gate token is written to. For the React adapter's
49
+ * headless path this is the Storefront cart GID you materialized and handed to
50
+ * `useFanfareClaim().claim(cartId)`. (Liquid-theme callers that build a claimer
51
+ * directly may instead pass an Ajax cart token incl. `?key=`; the proxy/cart
52
+ * bridge resolves either form to a cart GID.) */
53
+ cartId: string;
54
+ }
55
+ /**
56
+ * A machine-readable refusal code on `{ ok: false }`. The codes split into two
57
+ * groups for the integrator deciding whether to retry:
58
+ *
59
+ * - `cart_not_materialized` — RECOVERABLE. The proxy could not find a real cart
60
+ * behind your `cartId`. Materialize the Storefront cart (with the gated line on
61
+ * it), then call `claim(cart.id)` again. This is the one reason that maps to a
62
+ * concrete retry the integrator owns.
63
+ * - `no_claim_context` — NOT-YET-READY (not a bug in your `cartId`). The provider
64
+ * has no admission credential / distribution id to claim with: the shopper
65
+ * hasn't cleared the drop, the journey hasn't routed, or you're in demo mode
66
+ * with no `proxyBaseUrl`. Wire up the widget + grant first; re-claiming only
67
+ * helps once those are in place.
68
+ * - `claim_malformed` / `claim_unreachable` / `proxy_<status>` — TERMINAL for
69
+ * this attempt (a bad/unreadable mint response, a transport failure/timeout, or
70
+ * a non-2xx proxy status other than the 409 above). Surface to the shopper; a
71
+ * blind re-claim is unlikely to help.
72
+ */
73
+ export type ClaimRefusalReason = "cart_not_materialized" | "no_claim_context" | "claim_malformed" | "claim_unreachable" | `proxy_${number}`;
74
+ /**
75
+ * The result of a claim. On `{ ok: true }` the gate token was minted and written
76
+ * to your cart, and `expDate` is the token's ISO-8601 expiry — surface it as a
77
+ * continuity signal if you like. On `{ ok: false }` the claim was refused and
78
+ * `reason` is a stable `ClaimRefusalReason` code — see that type for which codes
79
+ * are retryable (`cart_not_materialized` → re-materialize + re-claim) vs terminal.
80
+ *
81
+ * Note: this result does NOT carry a checkout URL. You already hold
82
+ * `cart.checkoutUrl` from the cart you materialized; on `{ ok: true }` redirect
83
+ * the shopper there to enter the gated checkout.
84
+ */
85
+ export type ClaimResult = {
86
+ ok: true;
87
+ expDate: string;
88
+ } | {
89
+ ok: false;
90
+ reason: ClaimRefusalReason;
91
+ };
92
+ export interface ShopifyClaimConfig {
93
+ /** App-proxy base, e.g. "/apps/fanfare/api". `claim` POSTs to
94
+ * `${proxyBaseUrl}/${endpoint}`. */
95
+ proxyBaseUrl: string;
96
+ /**
97
+ * Supplies the live claim context. Returns `null` when the storefront is not
98
+ * ready to claim (no credential/cart yet), which `claim` surfaces as a benign
99
+ * denied result rather than a thrown error. Absent ⇒ always not-ready.
100
+ */
101
+ getClaimContext?: () => ShopifyClaimContext | null | Promise<ShopifyClaimContext | null>;
102
+ /** Proxy path under `proxyBaseUrl`. Defaults to the mint-only `admissions/claim`
103
+ * route. The React adapter's headless buy action overrides this to
104
+ * `admissions/reserve` (the #29 reserve-fold: validate + mint + write + RESERVE
105
+ * in one round-trip), which is body-compatible — the claimer reads only
106
+ * `expDate`. Direct callers (e.g. the Liquid theme) keep the default. */
107
+ endpoint?: string;
108
+ /** Injectable for SSR/test. Defaults to `globalThis.fetch`. */
109
+ fetchImpl?: typeof fetch;
110
+ /**
111
+ * Abort the proxy request after this many ms so a stalled (connected but
112
+ * unresponsive) proxy can't leave `claim()` pending — every outcome must
113
+ * resolve. The abort surfaces as a denied result. Defaults to 10s.
114
+ */
115
+ timeoutMs?: number;
116
+ }
117
+ export interface ShopifyCheckoutClaimer {
118
+ claim(): Promise<ClaimResult>;
119
+ }
120
+ /**
121
+ * Build a Shopify checkout claimer. The proxy — not this client — is the trust
122
+ * boundary; a `{ ok:true }` means the authority validated the admission and the
123
+ * gate token was minted+written for this cart, never a client assertion.
124
+ *
125
+ * A 409 `cart_not_materialized` is surfaced verbatim as the reason (not a generic
126
+ * `proxy_409`) so the storefront can materialize a real cart and re-claim — the
127
+ * claimer does NOT own materialization, which is context-specific (the Ajax cart
128
+ * bridge for themes vs the Storefront cart GID for headless) and lives in the
129
+ * caller that holds that knowledge.
130
+ */
131
+ export declare function createShopifyCheckoutClaimer(config: ShopifyClaimConfig): ShopifyCheckoutClaimer;
@@ -0,0 +1 @@
1
+ function t(t){if(!t.proxyBaseUrl||""===t.proxyBaseUrl.trim())throw new Error("createShopifyCheckoutClaimer: proxyBaseUrl is required");const e=`${t.proxyBaseUrl.replace(/\/+$/,"")}/${(t.endpoint??"admissions/claim").replace(/^\/+/,"")}`,r=t.fetchImpl??globalThis.fetch;return{async claim(){try{const a=t.getClaimContext?await t.getClaimContext():null;if(null==a)return{ok:!1,reason:"no_claim_context"};const n=await r(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({credential:a.credential,distributionId:a.distributionId,cartId:a.cartId}),signal:AbortSignal.timeout(t.timeoutMs??1e4)});if(!n.ok){const t=await async function(t){if(409===t.status)try{const e=await t.json();if("cart_not_materialized"===e?.error)return"cart_not_materialized"}catch{}return`proxy_${t.status}`}(n);return{ok:!1,reason:t}}const o=await async function(t){try{const e=await t.json();return"string"==typeof e?.expDate&&e.expDate.length>0?e.expDate:null}catch{return null}}(n);return null===o?{ok:!1,reason:"claim_malformed"}:{ok:!0,expDate:o}}catch{return{ok:!1,reason:"claim_unreachable"}}}}}export{t as createShopifyCheckoutClaimer};
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Operating mode for the adapter (the mode state machine).
3
+ *
4
+ * - `"production"` (the default): real verify + claim through the Fanfare
5
+ * Shopify app proxy. Requires `shopify.proxyBaseUrl`; a missing proxy throws
6
+ * `FanfareConfigurationError` at provider mount (fail-loud — never silently
7
+ * degrade to the client-only no-op verifier).
8
+ * - `"demo"`: for local development and Storybook ONLY — never a live
9
+ * storefront. The checkout verifier is a client-only no-op: it enforces only
10
+ * client-side grant expiry and does NO server-side checkout enforcement, so a
11
+ * gated product is not actually protected. A gated product renders a visible
12
+ * "demo — not production-safe" state in this mode, never a silent pass.
13
+ */
14
+ export type FanfareMode = "production" | "demo";
15
+ /**
16
+ * Facts about the integrator's Shopify store the adapter configures itself from.
17
+ * `proxyBaseUrl` is the single switch for the whole adapter: REAL verify + claim
18
+ * AND experience resolution all go through the Fanfare app proxy. When present the
19
+ * adapter drives the proxy; its absence is only valid in `"demo"`.
20
+ *
21
+ * In `mode:"production"` (the default), the default experience source is
22
+ * `{ source: "metafield" }`, whose live tier reads the product's experience ids
23
+ * through the app proxy (`GET {proxyBaseUrl}/experiences/ids-by-handle/{handle}`)
24
+ * — the proxy resolves the `$app:` namespace server-side, so NO Storefront creds
25
+ * or numeric app id are required. `proxyBaseUrl` is the only field and is
26
+ * mode-gated: required in production, optional in demo.
27
+ */
28
+ export interface ShopifyAdapterConfig {
29
+ /**
30
+ * App-proxy base, e.g. "https://shop.myshopify.com/apps/fanfare/api". The
31
+ * single switch for the adapter: real verify + claim AND experience resolution
32
+ * all route through it. Required in `mode:"production"`; absent only in
33
+ * `mode:"demo"`. This is the Fanfare Shopify app's app-proxy URL on your store.
34
+ */
35
+ proxyBaseUrl?: string;
36
+ }
37
+ /**
38
+ * A Shopify product as the Storefront API hands it to the storefront. This is the
39
+ * ONLY product shape the Shopify adapter path deals in — a Fanfare
40
+ * product/experience id never appears here. The adapter's job is to map this to a
41
+ * Fanfare experience id.
42
+ *
43
+ * Experience resolution keys on `handle` only (the live app-proxy read and
44
+ * the `map` / `metafieldValues` lookups all index by handle), so `handle` is the
45
+ * one required field. `id` is OPTIONAL: it is carried for forward-compat (it flows
46
+ * through to the claim/checkout path and may matter to future resolution
47
+ * strategies) but no current path reads it, so you can pass `{ handle }` alone.
48
+ */
49
+ export interface ShopifyProductRef {
50
+ /** The product handle, e.g. "limited-drop-tee". The key experience resolution
51
+ * uses. Required. */
52
+ handle: string;
53
+ /** The product GID, e.g. "gid://shopify/Product/123". Optional — carried for
54
+ * forward-compat; no current resolution path reads it. */
55
+ id?: string;
56
+ }
57
+ /**
58
+ * Where the Shopify→experience mapping comes from. The provider's `experiences`
59
+ * config; resolved by `useFanfareExperience(shopifyProduct)`.
60
+ *
61
+ * WHERE EXPERIENCE IDS COME FROM: a Fanfare experience id (e.g. `"exp_1"`) is
62
+ * minted in the Fanfare dashboard/admin when you create the experience. The
63
+ * Fanfare Shopify app writes the ids for a product into that product's
64
+ * `experienceIds` metafield. So for `"static"` / `"map"` you supply the id
65
+ * string yourself (copy it from the dashboard); for `"metafield"` the id is read
66
+ * back out of the metafield the app already wrote — you supply nothing.
67
+ *
68
+ * - `{ source: "static", experienceId }` — one experience for the whole store.
69
+ * `experienceId` is a BARE Fanfare id (`"exp_1"`). Resolves synchronously to
70
+ * that id for every product.
71
+ * - `{ source: "map", values }` — an SSR-preloaded `handle → experienceId` map.
72
+ * Each VALUE is a BARE Fanfare id (`"exp_1"`), NOT a raw metafield string —
73
+ * do not feed it the `["exp_1"]` JSON-array form (contrast `metafieldValues`
74
+ * below, whose values are the raw metafield string). Resolves synchronously by
75
+ * handle; an uncovered handle resolves to `null`.
76
+ * - `{ source: "metafield", ... }` (the default) — reads the product's
77
+ * `experienceIds` metafield (the one the Fanfare app wrote). Resolution order:
78
+ * a `resolveExperience` override if supplied; else a preloaded
79
+ * `metafieldValues[handle]` (SSR fast path, no network); else a live app-proxy
80
+ * read (`GET {proxyBaseUrl}/experiences/ids-by-handle/{handle}`) that resolves
81
+ * the `$app:` namespace server-side. Resolution is SSR-safe: any miss or
82
+ * failure yields `null`, never a throw.
83
+ *
84
+ * Note the singular/plural asymmetry: `static`/`map` carry a single
85
+ * `experienceId`, while the underlying metafield is `experienceIds` (plural, a
86
+ * JSON array). Resolution always honors the FIRST id only — the platform is
87
+ * single-experience-per-product today (see `parseFirstExperienceId`).
88
+ *
89
+ * The per-product knobs (`metafieldValues`, `resolveExperience`) live ONLY on the
90
+ * `"metafield"` variant below — there is no top-level override; reach for them by
91
+ * choosing `source: "metafield"`.
92
+ */
93
+ export type ExperienceSource = {
94
+ source: "static";
95
+ experienceId: string;
96
+ } | {
97
+ source: "map";
98
+ values: Record<string, string>;
99
+ } | {
100
+ source: "metafield";
101
+ /**
102
+ * SSR fast path: a preloaded `handle → raw experienceIds metafield value`
103
+ * map. Each VALUE is the RAW metafield string — a JSON array of ids like
104
+ * `'["exp_1"]'` (NOT a bare `"exp_1"`; contrast `map.values` above) — exactly
105
+ * as the Fanfare app wrote it / as you SSR-loaded it. Resolution parses it
106
+ * with `parseFirstExperienceId` (the first id wins) and performs NO network
107
+ * call when the handle is present; when absent, it falls through to the live
108
+ * app-proxy read.
109
+ */
110
+ metafieldValues?: Record<string, string>;
111
+ /**
112
+ * The escape hatch: your own product → experienceId mapping function. Use it
113
+ * when neither a static id, a handle map, nor the metafield read fits — e.g.
114
+ * you resolve from your own catalog/CMS. Return a BARE Fanfare id (`"exp_1"`)
115
+ * or `null` for an uncovered product. When supplied it takes precedence over
116
+ * both the preloaded map and the live metafield read. May be sync or async.
117
+ *
118
+ * It lives under `source: "metafield"` (rather than a separate top-level
119
+ * source) because it overrides the metafield resolution chain and composes
120
+ * with `metafieldValues` as the fallback path.
121
+ */
122
+ resolveExperience?: (product: ShopifyProductRef) => string | null | Promise<string | null>;
123
+ };
124
+ /** The mode + store config the guard inspects. The provider calls this at mount
125
+ * before constructing the internal verifier/claimer. */
126
+ export interface ShopifyConfigGuardInput {
127
+ mode: FanfareMode;
128
+ shopify: ShopifyAdapterConfig;
129
+ }
130
+ /**
131
+ * Pure fail-loud guard for the mode state machine. Throws
132
+ * `FanfareConfigurationError` when `mode:"production"` lacks
133
+ * `shopify.proxyBaseUrl` — the one invariant that must hold before the provider
134
+ * builds the real verifier/claimer. Internal: `FanfareShopifyProvider` calls it
135
+ * at mount; it is not part of the public surface itself.
136
+ */
137
+ export declare function assertShopifyConfig(config: ShopifyConfigGuardInput): void;
package/dist/config.js ADDED
@@ -0,0 +1 @@
1
+ import{FanfareConfigurationError as r}from"./errors.js";function o(o){if("production"!==o.mode)return;const e=o.shopify.proxyBaseUrl;if(void 0===e||""===e.trim())throw new r('FanfareShopifyProvider: mode "production" requires shopify.proxyBaseUrl for real verify + claim. Set shopify.proxyBaseUrl, or use mode "demo" for the harness-backed lane.')}export{o as assertShopifyConfig};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Adapter error surface.
3
+ *
4
+ * `FanfareConfigurationError` is the fail-loud artifact of the mode state
5
+ * machine: `FanfareShopifyProvider` throws it at mount when `mode:"production"`
6
+ * is missing the `shopify.proxyBaseUrl` it needs for real verify + claim. A
7
+ * thrown configuration error is deliberate — the no-op verifier path exists only
8
+ * in `mode:"demo"`, so production never silently degrades to a client-only check.
9
+ *
10
+ * It is its own subclass (not a bare `Error`) so integrators can branch on
11
+ * `instanceof FanfareConfigurationError` — e.g. surface a setup-time banner —
12
+ * without string-matching the message.
13
+ */
14
+ export declare class FanfareConfigurationError extends Error {
15
+ constructor(message: string);
16
+ }
package/dist/errors.js ADDED
@@ -0,0 +1 @@
1
+ class r extends Error{constructor(t){super(t),this.name="FanfareConfigurationError",Object.setPrototypeOf(this,r.prototype)}}export{r as FanfareConfigurationError};
package/dist/index.d.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * @fanfare-io/fanfare-sdk-shopify — the real Shopify storefront surface.
3
3
  *
4
- * ISOMORPHIC (SSR-safe) root barrel: the product→experience resolver, the
5
- * checkout verifier, and the `experienceIds` metafield primitives. Imports zero
6
- * React, so a server-only consumer (Hydrogen/Oxygen loader, Next/Remix server
7
- * component) installs and uses this package without React. The React provider /
8
- * hooks live in `@fanfare-io/fanfare-sdk-shopify/react`.
4
+ * ISOMORPHIC (SSR-safe) root barrel. Imports zero React, so a server-only
5
+ * consumer (Hydrogen/Oxygen loader, Next/Remix server component) installs and
6
+ * uses this package without React. The React provider / hooks live in
7
+ * `@fanfare-io/fanfare-sdk-shopify/react`.
9
8
  *
10
- * The resolver/verifier bodies are honest local placeholders pending the live
11
- * Storefront-API metafield read and the ENG-913 handoff (see the JSDoc on
12
- * createShopifyExperienceResolver / shopifyCheckoutVerifier) — their signatures
13
- * are final and the wiring lands behind them with no surface change.
9
+ * The resolver, verifier, and claimer are now INTERNAL: the
10
+ * `FanfareShopifyProvider` (in `./react`) assembles them from store config, so
11
+ * an integrator configures one provider rather than wiring loose factories. The
12
+ * public surface here is the `experienceIds` metafield primitives,
13
+ * `FanfareConfigurationError`, and the framework-free seam vocabulary.
14
14
  */
15
+ export type { ClaimRefusalReason, ClaimResult } from './claimer';
16
+ export type { ExperienceSource, FanfareMode, ShopifyAdapterConfig, ShopifyProductRef } from './config';
17
+ export { FanfareConfigurationError } from './errors';
15
18
  export { FANFARE_PRODUCT_METAFIELD, parseExperienceIds, parseFirstExperienceId } from './metafield';
16
- export { createShopifyExperienceResolver } from './resolver';
17
- export type { ShopifyExperienceResolverConfig } from './resolver';
18
- export { shopifyCheckoutVerifier } from './verifier';
19
19
  export { ACCESS_STATUSES, snapshotToAccessStatus } from '@fanfare-io/fanfare-sdk-core/storefront';
20
20
  export type { AccessStatus, CheckoutAccess, CheckoutVerifier, ExperienceResolver, GrantRecord, VerificationResult, } from '@fanfare-io/fanfare-sdk-core/storefront';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{FANFARE_PRODUCT_METAFIELD as r,parseExperienceIds as o,parseFirstExperienceId as e}from"./metafield.js";import{createShopifyExperienceResolver as f}from"./resolver.js";import{shopifyCheckoutVerifier as m}from"./verifier.js";import{ACCESS_STATUSES as i,snapshotToAccessStatus as t}from"@fanfare-io/fanfare-sdk-core/storefront";export{i as ACCESS_STATUSES,r as FANFARE_PRODUCT_METAFIELD,f as createShopifyExperienceResolver,o as parseExperienceIds,e as parseFirstExperienceId,m as shopifyCheckoutVerifier,t as snapshotToAccessStatus};
1
+ import{FanfareConfigurationError as r}from"./errors.js";import{FANFARE_PRODUCT_METAFIELD as o,parseExperienceIds as f,parseFirstExperienceId as e}from"./metafield.js";import{ACCESS_STATUSES as m,snapshotToAccessStatus as t}from"@fanfare-io/fanfare-sdk-core/storefront";export{m as ACCESS_STATUSES,o as FANFARE_PRODUCT_METAFIELD,r as FanfareConfigurationError,f as parseExperienceIds,e as parseFirstExperienceId,t as snapshotToAccessStatus};
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * The experienceIds product metafield. The Fanfare app DEFINES it under the
3
3
  * reserved namespace `$app:product`, which Shopify rewrites at runtime to
4
- * `app--<numericAppId>--product`. A headless Storefront-API caller must use the
5
- * reified namespace — the literal form returns null. The numeric app id is not
6
- * storefront-readable and is provisioned out-of-band.
4
+ * `app--<numericAppId>--product`. The `$app:` namespace is resolved SERVER-SIDE
5
+ * by the Fanfare app proxy; the storefront never addresses the reified numeric
6
+ * namespace directly.
7
7
  *
8
- * PLACEHOLDER NOTE: the reified-namespace read against the live Storefront API is
9
- * wired by createShopifyExperienceResolver; the constant and parser below are the
10
- * stable, final-shaped primitives that read does not change.
8
+ * The live read is an app-proxy fetch
9
+ * (`GET {proxyBaseUrl}/experiences/ids-by-handle/{handle}`) performed by
10
+ * createShopifyExperienceResolver; the constant and parser below are the stable
11
+ * primitives the preload tier builds on.
11
12
  */
12
13
  export declare const FANFARE_PRODUCT_METAFIELD: {
13
14
  readonly key: "experienceIds";
@@ -15,12 +16,13 @@ export declare const FANFARE_PRODUCT_METAFIELD: {
15
16
  readonly appRelativeNamespace: "$app:product";
16
17
  };
17
18
  /**
18
- * Parse the raw metafield value into an ordered experience-id list. PURE and
19
- * SSR-safe, TOTAL (never throws):
19
+ * Parse the raw metafield value into an ordered experience-id list. Pure,
20
+ * SSR-safe, and never throws — every input yields a list (empty on anything
21
+ * unusable):
20
22
  * - JSON array of strings → that array, order preserved
21
23
  * - array containing any non-string entry → [] (the platform contract is a
22
24
  * list of single-line text fields; a structurally mixed array is unusable)
23
- * - non-array / pre-migration scalar / malformed JSON → []
25
+ * - non-array / bare scalar / malformed JSON → []
24
26
  * - absent / empty → []
25
27
  */
26
28
  export declare function parseExperienceIds(metafieldValue: string | null | undefined): string[];
@@ -0,0 +1,39 @@
1
+ import { ReactNode } from 'react';
2
+ import { ShopifyProductRef } from '../config';
3
+ import { FanfareDenialReason } from './use-fanfare-grant';
4
+ export interface FanfareCheckoutGateProps {
5
+ /**
6
+ * Your cart's line items — each a Shopify product reference (the same
7
+ * `ShopifyProductRef` shape `useFanfareExperience` takes). The gate resolves
8
+ * each to its Fanfare experience to find out whether any line is gated.
9
+ *
10
+ * The gating decision is keyed on `handle` only — the live app-proxy read
11
+ * and the `map`/`metafieldValues` lookups all index by handle. `id` is optional
12
+ * (carried for forward-compat with the claim/checkout path); the gate never
13
+ * reads it, so omitting it — or an imprecise value — does NOT affect the gating
14
+ * decision here. `{ handle }` per line is enough.
15
+ */
16
+ cartItems: ShopifyProductRef[];
17
+ /**
18
+ * Rendered when a gated line is present but the shopper has no valid grant. A
19
+ * node, or a render prop passed the verifier's denial reason.
20
+ *
21
+ * `reason` is a `FanfareDenialReason` (an OPEN union) — a short machine-readable
22
+ * code you can branch on for tailored copy. The gate emits only the three
23
+ * client-side codes: `"no_grant"`, `"grant_expired"`, and `"verify_failed"`.
24
+ * The proxy/transport family (`"no_checkout_context"`, `"verify_unreachable"`,
25
+ * and the DYNAMIC `` `proxy_${status}` ``) is surfaced by `claim()` / the opt-in
26
+ * reserve verifier, NOT by this gate. `reason` is `undefined` only if a future
27
+ * verifier denies without a code. Send the shopper back to clear the drop.
28
+ */
29
+ onDenied: ReactNode | ((reason: FanfareDenialReason) => ReactNode);
30
+ /**
31
+ * Rendered while the gate is resolving the cart / verifying the grant. Keep it
32
+ * quiet (a spinner, or nothing) — defaults to rendering nothing so the gate does
33
+ * not flash. Never let the shopper proceed during this state.
34
+ */
35
+ fallback?: ReactNode;
36
+ /** Rendered once the shopper is allowed (no gated line, or a valid grant). */
37
+ children: ReactNode;
38
+ }
39
+ export declare function FanfareCheckoutGate({ cartItems, onDenied, fallback, children }: FanfareCheckoutGateProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ import{jsx as e,Fragment as r}from"react/jsx-runtime";import{useState as n,useRef as t,useMemo as o,useEffect as a}from"react";import{parseFirstExperienceId as i}from"../metafield.js";import{resolveShopifyExperienceId as s}from"../resolver.js";import{useShopifyAdapter as l}from"./shopify-provider.js";import{useFanfareGrant as c}from"./use-fanfare-grant.js";function d({cartItems:d,onDenied:m,fallback:u=null,children:f}){const{config:p}=l(),{experiences:h,shopify:g,mode:v}=p,[x,y]=n({phase:"resolving"}),j=t(0),k=o(()=>d.map(e=>`${e.handle} ${e.id}`).join(" "),[d]),P=t(d);P.current=d,a(()=>{const e=P.current,r=++j.current;if(y({phase:"resolving"}),0!==e.length)return Promise.all(e.map(e=>function(e,r,n){switch(r.source){case"static":return Promise.resolve(r.experienceId);case"map":return Promise.resolve(r.values[e.handle]??null);case"metafield":{const t=r.metafieldValues?.[e.handle];return void 0===r.resolveExperience&&void 0!==t?Promise.resolve(i(t)):s(e,function(e,r){return{proxyBaseUrl:e.proxyBaseUrl??"",metafieldValues:r.metafieldValues,resolveExperience:r.resolveExperience}}(n,r))}}}(e,h,g))).then(e=>{if(j.current!==r)return;const n=e.some(e=>null!==e);y({phase:n?"gated":"ungated"})}),()=>{j.current++};y({phase:"ungated"})},[k,h,g]);const w=c();return"resolving"===x.phase?/* @__PURE__ */e(r,{children:u}):"ungated"===x.phase?/* @__PURE__ */e(r,{children:f}):"demo"===v?/* @__PURE__ */e("div",{"data-testid":"fanfare-demo-gate-warning",role:"alert",children:'Fanfare demo mode — this gate is not production-safe. The checkout is not actually enforced; set mode="production" with a proxyBaseUrl for real gating.'}):"checking"===w.state?/* @__PURE__ */e(r,{children:u}):"blocked"===w.state?/* @__PURE__ */e(r,{children:"function"==typeof m?m(w.reason):m}):/* @__PURE__ */e(r,{children:f})}export{d as FanfareCheckoutGate};
@@ -2,6 +2,29 @@
2
2
  * React bindings for the Shopify storefront adapter. Carries the React peer dep
3
3
  * and the `"use client"` boundary; the isomorphic resolver/verifier/metafield
4
4
  * surface lives at the package root (`@fanfare-io/fanfare-sdk-shopify`).
5
+ *
6
+ * `FanfareShopifyProvider` is the single provider the integrator configures with
7
+ * store facts. `useFanfareExperience` is the Shopify→experience mapping the PDP
8
+ * calls. `useFanfareWidgetBridge` connects the drop journey widget to the provider
9
+ * (spread its `onJourneyChange` onto your `<ExperienceWidget>`), so the grant the
10
+ * shopper earns reaches the gate + claim. `useFanfareClaim` is the on-buy claim
11
+ * that writes the gate token onto a cart you materialized. `<FanfareCheckoutGate>`
12
+ * is the drop-in checkout guard, with `useFanfareGrant` exposed for manual control
13
+ * of the loading/allowed/blocked states. The internal `useShopifyAdapter` hook they
14
+ * build on is intentionally NOT exported.
5
15
  */
6
- export { FanfareAdapterProvider, useFanfareAdapter } from './adapter-context';
7
- export type { FanfareAdapterValue } from './adapter-context';
16
+ export type { ClaimRefusalReason, ClaimResult } from '../claimer';
17
+ export type { ExperienceSource, FanfareMode, ShopifyAdapterConfig, ShopifyProductRef } from '../config';
18
+ export { FanfareConfigurationError } from '../errors';
19
+ export { FanfareCheckoutGate } from './fanfare-checkout-gate';
20
+ export type { FanfareCheckoutGateProps } from './fanfare-checkout-gate';
21
+ export { FanfareShopifyProvider } from './shopify-provider';
22
+ export type { FanfareShopifyProviderProps } from './shopify-provider';
23
+ export { useFanfareClaim } from './use-fanfare-claim';
24
+ export type { FanfareClaimState, UseFanfareClaimResult } from './use-fanfare-claim';
25
+ export { useFanfareExperience } from './use-fanfare-experience';
26
+ export type { UseFanfareExperienceResult } from './use-fanfare-experience';
27
+ export { useFanfareGrant } from './use-fanfare-grant';
28
+ export type { FanfareDenialReason, FanfareGrantState } from './use-fanfare-grant';
29
+ export { useFanfareWidgetBridge } from './use-fanfare-widget-bridge';
30
+ export type { FanfareWidgetBridge } from './use-fanfare-widget-bridge';
@@ -1,2 +1,2 @@
1
1
  "use client";
2
- import{FanfareAdapterProvider as t,useFanfareAdapter as o}from"./adapter-context.js";export{t as FanfareAdapterProvider,o as useFanfareAdapter};
2
+ import{FanfareConfigurationError as r}from"../errors.js";import{FanfareCheckoutGate as e}from"./fanfare-checkout-gate.js";import{FanfareShopifyProvider as o}from"./shopify-provider.js";import{useFanfareClaim as f}from"./use-fanfare-claim.js";import{useFanfareExperience as m}from"./use-fanfare-experience.js";import{useFanfareGrant as a}from"./use-fanfare-grant.js";import{useFanfareWidgetBridge as i}from"./use-fanfare-widget-bridge.js";export{e as FanfareCheckoutGate,r as FanfareConfigurationError,o as FanfareShopifyProvider,f as useFanfareClaim,m as useFanfareExperience,a as useFanfareGrant,i as useFanfareWidgetBridge};
@@ -0,0 +1,71 @@
1
+ import { ReactNode } from 'react';
2
+ import { JourneySnapshot } from '@fanfare-io/fanfare-sdk-core/experiences';
3
+ import { AccessStatus, CheckoutVerifier, GrantRecord } from '@fanfare-io/fanfare-sdk-core/storefront';
4
+ import { ExperienceSource, FanfareMode, ShopifyAdapterConfig } from '../config';
5
+ import { ShopifyCheckoutContext } from '../verifier';
6
+ export type { ExperienceSource } from '../config';
7
+ /** The fully-resolved config the provider holds and exposes to the internal hooks. */
8
+ export interface ResolvedShopifyConfig {
9
+ organizationId: string;
10
+ publishableKey: string;
11
+ mode: FanfareMode;
12
+ shopify: ShopifyAdapterConfig;
13
+ experiences: ExperienceSource;
14
+ }
15
+ /**
16
+ * The INTERNAL adapter value. Carried by an internal context and read via the
17
+ * internal `useShopifyAdapter` hook — NOT a public `useFanfareAdapter`. The
18
+ * public hooks consume this; integrators never touch it.
19
+ */
20
+ export interface ShopifyAdapterValue {
21
+ /** The resolved provider config (mode + store + experience source). */
22
+ config: ResolvedShopifyConfig;
23
+ /** Latest journey snapshot reported by the widget driving the journey. */
24
+ snapshot: JourneySnapshot | null;
25
+ /** Access status derived from the snapshot. */
26
+ status: AccessStatus;
27
+ /** Grant recorded when the shopper cleared the drop (survives reloads). */
28
+ grant: GrantRecord | null;
29
+ reportSnapshot(snapshot: JourneySnapshot): void;
30
+ recordGrant(grant: GrantRecord): void;
31
+ clearGrant(): void;
32
+ /** The internal client-only verifier (the checkout gate consumes it). */
33
+ verifier: CheckoutVerifier;
34
+ /**
35
+ * Internal cart/checkout-context plumbing. `useFanfareClaim` sets the active
36
+ * cart here so the filled checkout context has a cartId to pair with.
37
+ */
38
+ setActiveCartId(cartId: string | null): void;
39
+ /**
40
+ * Returns the live checkout context `{ credential, distributionId, cartId }`
41
+ * from internal state, or `null` when any piece is missing (not yet ready).
42
+ * `credential` = the recorded grant token; `distributionId` = captured from the
43
+ * journey snapshot; `cartId` = the active cart slot.
44
+ */
45
+ getCheckoutContext(): ShopifyCheckoutContext | null;
46
+ }
47
+ export interface FanfareShopifyProviderProps {
48
+ organizationId: string;
49
+ publishableKey: string;
50
+ /** Operating mode. Defaults to `"production"` (real verify + claim via proxy). */
51
+ mode?: FanfareMode;
52
+ shopify: ShopifyAdapterConfig;
53
+ /**
54
+ * Where the Shopify→experience mapping comes from. Defaults to
55
+ * `{ source: "metafield" }`, whose live tier reads the product's `experienceIds`
56
+ * through the app proxy (`GET {proxyBaseUrl}/experiences/ids-by-handle/{handle}`).
57
+ * All three variants are fully resolved — `"static"` and `"map"` synchronously
58
+ * by handle, `"metafield"` via the preloaded fast path or a live read — by both
59
+ * `useFanfareExperience` and `<FanfareCheckoutGate>`. See `ExperienceSource` for
60
+ * the full resolution order per source.
61
+ */
62
+ experiences?: ExperienceSource;
63
+ children: ReactNode;
64
+ }
65
+ export declare function FanfareShopifyProvider({ organizationId, publishableKey, mode, shopify, experiences, children, }: FanfareShopifyProviderProps): import("react/jsx-runtime").JSX.Element;
66
+ /**
67
+ * INTERNAL hook over the adapter context. NOT public — the public hooks
68
+ * (`useFanfareExperience` / `useFanfareGrant` / `useFanfareClaim`) build on it.
69
+ * Throws when used outside `FanfareShopifyProvider`.
70
+ */
71
+ export declare function useShopifyAdapter(): ShopifyAdapterValue;
@@ -0,0 +1,3 @@
1
+ "use client";
2
+ import{jsx as r}from"react/jsx-runtime";import{useState as e,useRef as n,useCallback as t,useMemo as o,createContext as i,useContext as u}from"react";import{getParticipation as s,getDistribution as a}from"@fanfare-io/fanfare-sdk-core/internals";import{snapshotToAccessStatus as c}from"@fanfare-io/fanfare-sdk-core/storefront";import{assertShopifyConfig as l}from"../config.js";import{shopifyCheckoutVerifier as f}from"../verifier.js";const d={source:"metafield"},p="storefront_adapter_grant",m=i(null);function h(){if("undefined"==typeof window)return null;try{return function(r){if(null===r)return null;try{return JSON.parse(r)}catch{return null}}(window.sessionStorage.getItem(p))}catch{return null}}function y({organizationId:i,publishableKey:u,mode:y="production",shopify:g,experiences:w=d,children:v}){l({mode:y,shopify:g});const[I,S]=e(null),[b,x]=e(()=>h()),k=n(null),j=n(null);j.current=I;const C=n(null);C.current=b;const z=t(r=>{S(r)},[]),A=t(r=>{x(r);try{window.sessionStorage.setItem(p,JSON.stringify(r))}catch{}},[]),G=t(()=>{x(null);try{window.sessionStorage.removeItem(p)}catch{}},[]),J=t(r=>{k.current=r},[]),K=t(()=>{const r=C.current?.token,e=function(r){if(null!==r)return s(r)?.id??a(r)?.id}(j.current),n=k.current;return void 0===r||void 0===e||null===n?null:{credential:r,distributionId:e,cartId:n}},[]),N=o(()=>f,[]),O=o(()=>({organizationId:i,publishableKey:u,mode:y,shopify:g,experiences:w}),[i,u,y,g,w]),P=o(()=>({config:O,snapshot:I,status:c(I),grant:b,reportSnapshot:z,recordGrant:A,clearGrant:G,verifier:N,setActiveCartId:J,getCheckoutContext:K}),[O,I,b,z,A,G,N,J,K]);/* @__PURE__ */
3
+ return r(m.Provider,{value:P,children:v})}function g(){const r=u(m);if(null===r)throw new Error("useShopifyAdapter must be used within FanfareShopifyProvider");return r}export{y as FanfareShopifyProvider,g as useShopifyAdapter};
@@ -0,0 +1,21 @@
1
+ import { ClaimResult } from '../claimer';
2
+ /** The claim lifecycle, for driving buy-button UI (e.g. a spinner). */
3
+ export type FanfareClaimState = "idle" | "claiming" | "claimed" | "error";
4
+ export interface UseFanfareClaimResult {
5
+ /**
6
+ * Claim the spot for a cart you already materialized. In one post-cart
7
+ * round-trip this validates the admission, mints + writes the gate token to the
8
+ * cart, AND reserves the slot (so it can't be re-drawn mid-checkout). `cartId`
9
+ * is your Storefront cart's GID (the cart carrying the gated line). On
10
+ * `{ ok: true }`, redirect the shopper to that cart's `checkoutUrl`; `expDate`
11
+ * is the token's expiry (the reserve route's `reservationId` is handled
12
+ * internally and not surfaced). On `{ ok: false }`, branch on `result.reason`
13
+ * (a `ClaimRefusalReason` — `cart_not_materialized` is retryable;
14
+ * `no_claim_context` means not-yet-ready). Never rejects — every outcome
15
+ * resolves to a `ClaimResult`.
16
+ */
17
+ claim(cartId: string): Promise<ClaimResult>;
18
+ /** `"idle"` → `"claiming"` → `"claimed"` | `"error"`. */
19
+ state: FanfareClaimState;
20
+ }
21
+ export declare function useFanfareClaim(): UseFanfareClaimResult;
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ import{useState as r,useRef as t,useMemo as o,useCallback as e}from"react";import{createShopifyCheckoutClaimer as i}from"../claimer.js";import{useShopifyAdapter as n}from"./shopify-provider.js";function c(){const{config:c,setActiveCartId:a,getCheckoutContext:s}=n(),[m,l]=r("idle"),p=t(s);p.current=s;const u=c.shopify.proxyBaseUrl,f=o(()=>void 0===u||""===u.trim()?null:i({proxyBaseUrl:u,endpoint:"admissions/reserve",getClaimContext:()=>p.current()}),[u]);return{claim:e(async r=>{if(null===f)return l("error"),{ok:!1,reason:"no_claim_context"};a(r),l("claiming");const t=await f.claim();return l(t.ok?"claimed":"error"),t},[f,a]),state:m}}export{c as useFanfareClaim};
@@ -0,0 +1,8 @@
1
+ import { ShopifyProductRef } from '../config';
2
+ export interface UseFanfareExperienceResult {
3
+ /** The Fanfare experience id for this product, or `null` when not gated. */
4
+ experienceId: string | null;
5
+ /** `true` while an async (live-fetch / override) resolution is in flight. */
6
+ loading: boolean;
7
+ }
8
+ export declare function useFanfareExperience(shopifyProduct: ShopifyProductRef): UseFanfareExperienceResult;
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ import{useMemo as e,useState as r,useRef as n,useEffect as i}from"react";import{parseFirstExperienceId as c}from"../metafield.js";import{resolveShopifyExperienceId as d}from"../resolver.js";import{useShopifyAdapter as o}from"./shopify-provider.js";function s(s){const{config:t}=o(),{experiences:l,shopify:a}=t,{handle:p,id:u}=s,x=e(()=>function(e,r){switch(r.source){case"static":return{sync:!0,experienceId:r.experienceId};case"map":return{sync:!0,experienceId:r.values[e.handle]??null};case"metafield":{if(void 0!==r.resolveExperience)return{sync:!1};const n=r.metafieldValues?.[e.handle];return void 0!==n?{sync:!0,experienceId:c(n)}:{sync:!1}}}}({handle:p},l),[p,u,l]),[f,m]=r(()=>x.sync?{experienceId:x.experienceId,loading:!1}:{experienceId:null,loading:!0}),I=n(0);return i(()=>{if(x.sync)return void m({experienceId:x.experienceId,loading:!1});if("metafield"!==l.source)return;const e=++I.current;return m({experienceId:null,loading:!0}),d({handle:p,id:u},function(e,r){return{proxyBaseUrl:e.proxyBaseUrl??"",metafieldValues:r.metafieldValues,resolveExperience:r.resolveExperience}}(a,l)).then(r=>{I.current===e&&m({experienceId:r,loading:!1})}),()=>{I.current++}},[x,p,u,l,a]),x.sync?{experienceId:x.experienceId,loading:!1}:f}export{s as useFanfareExperience};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The short denial codes carried on a `"blocked"` grant. Use these to branch on
3
+ * WHY a shopper was denied (e.g. distinct copy for "never cleared the drop" vs.
4
+ * "grant expired").
5
+ *
6
+ * REACHABLE VIA THE GATE (the client-only verifier this hook wires):
7
+ * - `"no_grant"` — no grant was ever recorded (the shopper never cleared the drop).
8
+ * - `"grant_expired"` — the recorded grant's `expiresAt` is in the past, compared
9
+ * against `Date.now()`. CLOCK-SKEW CAVEAT: a fast client clock can mark a still-
10
+ * server-valid grant expired here. This is a defense-in-depth client pre-check
11
+ * only — `claim()` re-validates the grant server-side, so a skewed client cannot
12
+ * grant access it shouldn't, only deny access it might have had.
13
+ * - `"verify_failed"` — the client-only verifier threw while reading the grant.
14
+ *
15
+ * NOT reachable via the gate — surfaced by `claim()` / the opt-in reserve verifier
16
+ * (`createShopifyCheckoutVerifier`), which drives the app proxy:
17
+ * - `"no_checkout_context"` — no live checkout context (no credential/cart yet —
18
+ * the storefront is not ready to check out / reserve).
19
+ * - `"verify_unreachable"` — the proxy request threw / timed out (transport
20
+ * failure); the proxy could not be reached.
21
+ * - `` `proxy_${number}` `` — a non-2xx proxy response, templated on the HTTP
22
+ * status (e.g. `"proxy_403"`, `"proxy_500"`). DYNAMIC, so it is not
23
+ * exhaustively enumerable — match it with `reason?.startsWith("proxy_")`,
24
+ * not an equality case.
25
+ *
26
+ * The string fallback keeps this open for a future verifier that denies with a
27
+ * code not listed here; the literal members exist so an editor can autocomplete
28
+ * the known codes and so a `switch` over them is meaningful.
29
+ */
30
+ export type FanfareDenialReason = "no_grant" | "grant_expired" | "verify_failed" | "no_checkout_context" | "verify_unreachable" | `proxy_${number}` | (string & {}) | undefined;
31
+ /** The shopper's access state for the gated drop. */
32
+ export interface FanfareGrantState {
33
+ /**
34
+ * `"checking"` while verifying; `"allowed"` once a valid grant is confirmed;
35
+ * `"blocked"` when there is no valid grant.
36
+ */
37
+ state: "checking" | "allowed" | "blocked";
38
+ /**
39
+ * A short denial code from the verifier when `state === "blocked"`. See
40
+ * `FanfareDenialReason` for the canonical codes (note `proxy_*` is dynamic).
41
+ */
42
+ reason?: FanfareDenialReason;
43
+ }
44
+ export declare function useFanfareGrant(): FanfareGrantState;
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ import{useState as r,useRef as t,useEffect as e}from"react";import{useShopifyAdapter as n}from"./shopify-provider.js";const o={state:"checking"};function c(){const{grant:c,verifier:s}=n(),[a,i]=r(o),u=t(0);return e(()=>{if(null===c)return u.current++,void i({state:"blocked",reason:"no_grant"});const r=++u.current;return i(o),s.verify(c).then(t=>{u.current===r&&i(t.ok?{state:"allowed"}:{state:"blocked",reason:t.reason})}),()=>{u.current++}},[c,s]),a}export{c as useFanfareGrant};
@@ -0,0 +1,16 @@
1
+ import { JourneySnapshot } from '@fanfare-io/fanfare-sdk-core/experiences';
2
+ /**
3
+ * The handler set to spread onto your drop journey widget. Today this is a single
4
+ * `onJourneyChange`; it is returned as an object so the wiring is a stable
5
+ * `{...bridge}` spread that can carry additional widget handlers in future without
6
+ * an integrator-visible change.
7
+ */
8
+ export interface FanfareWidgetBridge {
9
+ /**
10
+ * Spread onto `<ExperienceWidget onJourneyChange={...} />`. Called with the
11
+ * latest `JourneySnapshot` on every journey update; it reports the snapshot to
12
+ * the provider and records the grant once the journey is granted.
13
+ */
14
+ onJourneyChange(snapshot: JourneySnapshot): void;
15
+ }
16
+ export declare function useFanfareWidgetBridge(): FanfareWidgetBridge;
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ import{useCallback as r,useMemo as e}from"react";import{getSequencePhase as o,getAdmissionGrant as n,getAdmissionGrantExpiresAt as t}from"@fanfare-io/fanfare-sdk-core/internals";import{useShopifyAdapter as i}from"./shopify-provider.js";function p(){const{reportSnapshot:p,recordGrant:a}=i(),f=r(r=>{if(p(r),"granted"!==o(r))return;const e=n(r);void 0!==e&&a({token:e,experienceId:r.experienceId,expiresAt:t(r)})},[p,a]);return e(()=>({onJourneyChange:f}),[f])}export{p as useFanfareWidgetBridge};
@@ -1,43 +1,57 @@
1
1
  import { ExperienceResolver } from '@fanfare-io/fanfare-sdk-core/storefront';
2
+ import { ShopifyProductRef } from './config';
2
3
  export interface ShopifyExperienceResolverConfig {
3
- /** Shop domain, e.g. "shop.myshopify.com". */
4
- shopDomain: string;
5
- /** Storefront API version, e.g. "2024-10". */
6
- apiVersion: string;
7
- /** PUBLIC Storefront-API access token (experienceIds is PUBLIC_READ). */
8
- storefrontAccessToken: string;
9
- /** Numeric Shopify app id, provisioned out-of-band; reifies the namespace. */
10
- fanfareAppId: string;
11
4
  /**
12
- * PLACEHOLDER-ONLY static map of product `handle` → raw `experienceIds`
13
- * metafield value. The placeholder body resolves `resolve(product)` from this
14
- * map via `parseFirstExperienceId`, so the contract + parser path is exercised
15
- * end-to-end without a network. The live Storefront-API read of the reified
16
- * `app--<fanfareAppId>--product` / `experienceIds` metafield replaces this map
17
- * (the only body change that lands later) — `resolve(product)`'s signature does
18
- * NOT change. Omit it once the live read is wired.
5
+ * App-proxy base, e.g. "https://shop.myshopify.com/apps/fanfare/api". The live
6
+ * resolution tier fetches `${proxyBaseUrl}/experiences/ids-by-handle/${handle}`;
7
+ * the proxy resolves the `$app:` namespace server-side, so no Storefront creds
8
+ * or numeric app id are needed here.
9
+ */
10
+ proxyBaseUrl: string;
11
+ /**
12
+ * SSR fast path: a preloaded map of product `handle` → raw `experienceIds`
13
+ * metafield value (the JSON-array string like `'["exp_1"]'`, not a bare id).
14
+ * When the product's handle is present, `resolve(product)` parses it via
15
+ * `parseFirstExperienceId` and performs NO network call. When the handle is
16
+ * absent, `resolve(product)` falls through to the live app-proxy read below.
17
+ * Provide it to resolve from values you have already SSR-loaded; omit it to
18
+ * always go to the network.
19
19
  */
20
20
  metafieldValues?: Record<string, string>;
21
21
  /**
22
- * RESERVED — not read by the placeholder body. The live Storefront-API read
23
- * will use this injectable (SSR/test seam) and default to `globalThis.fetch`.
24
- * Until that read lands, supplying it is a no-op: the placeholder resolves
25
- * from `metafieldValues` and never performs a network call. A contract test
26
- * pins this (a supplied `fetchImpl` is never invoked).
22
+ * Full-control override: your own product → experienceId function. When supplied
23
+ * it takes precedence over both the preloaded map and the live read. May be sync
24
+ * or async; return a bare Fanfare id (`"exp_1"`) or `null` for an uncovered
25
+ * product.
26
+ */
27
+ resolveExperience?: (product: ShopifyProductRef) => string | null | Promise<string | null>;
28
+ /**
29
+ * Injectable fetch (SSR/test seam) for the live app-proxy read of the product's
30
+ * experience ids by handle. Defaults to `globalThis.fetch`. Only exercised when
31
+ * neither `resolveExperience` nor a preloaded `metafieldValues[handle]` covers
32
+ * the product.
27
33
  */
28
34
  fetchImpl?: typeof fetch;
29
35
  }
30
36
  /**
31
- * Build a Shopify ExperienceResolver. `resolve(product)` returns the experience
32
- * id carried by the product's `experienceIds` metafield (first id wins), or null
33
- * when the product is not covered.
37
+ * Resolve a Shopify product to its Fanfare experience id (first id wins), or
38
+ * `null` when the product is not covered. The three-tier resolution:
39
+ *
40
+ * 1. `resolveExperience` override, if supplied — used verbatim.
41
+ * 2. else a preloaded `metafieldValues[handle]` (SSR fast path, no network).
42
+ * 3. else a live app-proxy read of the experience ids by handle.
43
+ *
44
+ * SSR-safe: returns `null` on any miss or failure, never throws. Shared by
45
+ * `createShopifyExperienceResolver` and the `useFanfareExperience` hook.
46
+ */
47
+ export declare function resolveShopifyExperienceId(product: ShopifyProductRef, config: ShopifyExperienceResolverConfig): Promise<string | null>;
48
+ /**
49
+ * Build a Shopify ExperienceResolver. `resolve(product)` returns the product's
50
+ * Fanfare experience id (first id wins), or null when the product is not covered.
34
51
  *
35
- * PLACEHOLDER (no signature impact): the body currently resolves from the
36
- * in-config static `metafieldValues` map (handle → raw metafield value) via
37
- * `parseFirstExperienceId`, so the contract and parser path are exercised
38
- * end-to-end without a network. Wiring the live Storefront-API read of the
39
- * reified `app--<fanfareAppId>--product` / `experienceIds` metafield is the only
40
- * change that lands later — the returned resolver's `resolve(product)` signature
41
- * does NOT change. See ENG-931 metafield-wiring TODO.
52
+ * Resolution follows the three-tier order in `resolveShopifyExperienceId`: a
53
+ * `resolveExperience` override, else a preloaded `metafieldValues[handle]` (SSR
54
+ * fast path, no network), else a live app-proxy read of
55
+ * `GET {proxyBaseUrl}/experiences/ids-by-handle/{handle}` via `fetchImpl`.
42
56
  */
43
57
  export declare function createShopifyExperienceResolver(config: ShopifyExperienceResolverConfig): ExperienceResolver;
package/dist/resolver.js CHANGED
@@ -1 +1 @@
1
- import{parseFirstExperienceId as e}from"./metafield.js";function r(r){if(!r.shopDomain||""===r.shopDomain.trim())throw new Error("createShopifyExperienceResolver: shopDomain is required");if(!r.apiVersion||""===r.apiVersion.trim())throw new Error("createShopifyExperienceResolver: apiVersion is required");if(!r.storefrontAccessToken||""===r.storefrontAccessToken.trim())throw new Error("createShopifyExperienceResolver: storefrontAccessToken is required");if(!r.fanfareAppId||!/^\d+$/.test(r.fanfareAppId))throw new Error("createShopifyExperienceResolver: fanfareAppId must be a non-empty numeric string");r.fanfareAppId,r.fetchImpl;const o=r.metafieldValues??{};return{resolve(r){const i=o[r.handle];return e(i)}}}export{r as createShopifyExperienceResolver};
1
+ import{parseFirstExperienceId as e}from"./metafield.js";async function n(e,n){const r=n.fetchImpl??globalThis.fetch,t=`${n.proxyBaseUrl.replace(/\/+$/,"")}/experiences/ids-by-handle/${encodeURIComponent(e.handle)}`;try{const e=await r(t,{method:"GET"});if(!e.ok)return null;return function(e){if("object"!=typeof e||null===e)return null;const n=e.experienceIds;if(!Array.isArray(n))return null;const r=n[0];return"string"==typeof r?r:null}(await e.json())}catch{return null}}async function r(r,t){if(void 0!==t.resolveExperience)try{return await t.resolveExperience(r)??null}catch{return null}const l=t.metafieldValues?.[r.handle];return void 0!==l?e(l):n(r,t)}export{r as resolveShopifyExperienceId};
@@ -3,19 +3,72 @@ import { CheckoutVerifier } from '@fanfare-io/fanfare-sdk-core/storefront';
3
3
  * Shopify checkout verifier. Called at checkout entry with the grant the adapter
4
4
  * recorded when the shopper cleared the drop.
5
5
  *
6
- * TOTAL: resolves for every grant — never rejects (the checkout gate consumes it
7
- * with `.then()` only, so a thrown failure would strand the gate in "checking" or
8
- * surface as an unhandled rejection). An expired grant resolves
6
+ * Never throws — it always resolves to a result for every grant (the checkout
7
+ * gate consumes it with `.then()` only, so a thrown failure would strand the gate
8
+ * in "checking" or surface as an unhandled rejection). An expired grant resolves
9
9
  * { ok:false, reason:"grant_expired" }; any unexpected throw is caught and
10
10
  * resolves { ok:false, reason:"verify_failed" }.
11
11
  *
12
- * PLACEHOLDER (no signature impact): the body currently enforces only client-side
13
- * expiry (rejects an expired grant, accepts an unexpired one). The ENG-913
14
- * server-validatable handoff — mint at claim, redeem + validate at checkout entry
15
- * — slots in behind this exact `verify(grant)` signature later; callers do not
16
- * change. The ENG-913 body redeems `grant.token` (the minted handoff token,
17
- * marked by `grant.handoffToken === true`) via core's `processHandoffToken`, which
18
- * THROWS on invalid/expired/replayed — the verifier MUST catch that and return
19
- * { ok:false, reason } so the contract stays total. See ENG-913.
12
+ * This zero-config default enforces only client-side grant expiry, so a
13
+ * storefront can render the gate without any server wiring. For server-side
14
+ * enforcement, use `createShopifyCheckoutVerifier(config)` below, which drives
15
+ * the Fanfare Shopify app proxy.
20
16
  */
21
17
  export declare const shopifyCheckoutVerifier: CheckoutVerifier;
18
+ /**
19
+ * The live checkout context the `verify(grant)` seam does not itself carry. The
20
+ * `CheckoutVerifier.verify` contract receives only a `GrantRecord` (UX/continuity
21
+ * state), but driving the proxy needs the consumer's admission credential, the
22
+ * distribution being checked out, and the cart to gate. The integration supplies
23
+ * these from its own journey state via `config.getCheckoutContext` — keeping the
24
+ * credential out of `GrantRecord` and the published surface minimal.
25
+ */
26
+ export interface ShopifyCheckoutContext {
27
+ /** The consumer's admission credential (the `admissionGrant` from the
28
+ * consumer-me snapshot). Proof of admission — relayed to the proxy, never
29
+ * persisted in Shopify. */
30
+ credential: string;
31
+ distributionId: string;
32
+ /** Ajax cart token (including `?key=`) for Liquid themes, or the Storefront
33
+ * cart GID for headless. The proxy/cart-bridge resolves it to a cart GID. */
34
+ cartId: string;
35
+ }
36
+ export interface ShopifyCheckoutVerifierConfig {
37
+ /** App-proxy base, e.g. "/apps/fanfare/api". `verify` POSTs to
38
+ * `${proxyBaseUrl}/${endpoint}`. */
39
+ proxyBaseUrl: string;
40
+ /**
41
+ * Supplies the live checkout context the grant seam omits. Returns `null` when
42
+ * the storefront is not ready to check out (no credential/cart yet), which
43
+ * `verify` surfaces as a denied result rather than a thrown error.
44
+ */
45
+ getCheckoutContext: () => ShopifyCheckoutContext | null | Promise<ShopifyCheckoutContext | null>;
46
+ /**
47
+ * Proxy path under `proxyBaseUrl`. Defaults to the checkout-entry reserve
48
+ * endpoint (validate + reserve + gate-token re-mint). A pre-reservation
49
+ * eligibility check can point this at the non-consuming claim/validate path.
50
+ */
51
+ endpoint?: string;
52
+ /** Injectable for SSR/test. Defaults to `globalThis.fetch`. */
53
+ fetchImpl?: typeof fetch;
54
+ /**
55
+ * Abort the proxy request after this many ms. Without it, a proxy that accepts
56
+ * the connection but stalls (never responds) leaves `verify()` pending forever
57
+ * — and the consumer is stuck in `checking`. The abort surfaces as a denied
58
+ * result so `verify()` always resolves. Defaults to 10s.
59
+ */
60
+ timeoutMs?: number;
61
+ }
62
+ /**
63
+ * Build a Shopify `CheckoutVerifier` that drives the Fanfare app proxy at
64
+ * checkout entry: it relays the admission credential + cart to the proxy, which
65
+ * validates against the admission authority, reserves the admission, and
66
+ * re-mints the platform gate token into the cart metafield. The proxy — not this
67
+ * client — is the trust boundary; a positive result means "the authority
68
+ * currently accepts this admission for this cart", never a client assertion.
69
+ *
70
+ * Never throws — it always resolves to a result: client-side expiry is a cheap
71
+ * pre-check; a missing context, a non-2xx proxy response, and any transport throw
72
+ * all resolve to a denied `{ ok:false, reason }`.
73
+ */
74
+ export declare function createShopifyCheckoutVerifier(config: ShopifyCheckoutVerifierConfig): CheckoutVerifier;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanfare-io/fanfare-sdk-shopify",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shopify storefront adapter for Fanfare SDK: product→experience resolver, checkout verifier, and experienceIds metafield primitives",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -39,7 +39,7 @@
39
39
  "peerDependencies": {
40
40
  "react": "^18.0.0 || ^19.1.1",
41
41
  "react-dom": "^18.0.0 || ^19.1.1",
42
- "@fanfare-io/fanfare-sdk-core": "0.2.0"
42
+ "@fanfare-io/fanfare-sdk-core": "0.4.0"
43
43
  },
44
44
  "peerDependenciesMeta": {
45
45
  "react": {
@@ -64,7 +64,7 @@
64
64
  "vite-plugin-dts": "^4.5.4",
65
65
  "vite-tsconfig-paths": "^5.1.4",
66
66
  "vitest": "^3.2.4",
67
- "@fanfare-io/fanfare-sdk-core": "0.2.0"
67
+ "@fanfare-io/fanfare-sdk-core": "0.4.0"
68
68
  },
69
69
  "sideEffects": false,
70
70
  "keywords": [
@@ -1,26 +0,0 @@
1
- import { ReactNode } from 'react';
2
- import { JourneySnapshot } from '@fanfare-io/fanfare-sdk-core/experiences';
3
- import { AccessStatus, CheckoutVerifier, GrantRecord } from '@fanfare-io/fanfare-sdk-core/storefront';
4
- export interface FanfareAdapterValue {
5
- /** Latest journey snapshot (reported by whatever drives the journey). */
6
- snapshot: JourneySnapshot | null;
7
- status: AccessStatus;
8
- /** Grant recorded when the shopper cleared the drop (survives reloads). */
9
- grant: GrantRecord | null;
10
- reportSnapshot(snapshot: JourneySnapshot): void;
11
- recordGrant(grant: GrantRecord): void;
12
- clearGrant(): void;
13
- verifier: CheckoutVerifier;
14
- }
15
- export declare function FanfareAdapterProvider({ verifier, legacyStorageKeys, children, }: {
16
- verifier?: CheckoutVerifier;
17
- /**
18
- * Prior sessionStorage keys to hydrate a grant from one time, on mount, when
19
- * the current key is empty. Lets a drop-in replacement of a bespoke adapter
20
- * preserve already-recorded grants. The matched legacy entry is migrated to
21
- * the current key and removed. Defaults to none.
22
- */
23
- legacyStorageKeys?: readonly string[];
24
- children: ReactNode;
25
- }): import("react/jsx-runtime").JSX.Element;
26
- export declare function useFanfareAdapter(): FanfareAdapterValue;
@@ -1,3 +0,0 @@
1
- "use client";
2
- import{jsx as r}from"react/jsx-runtime";import{useState as e,useCallback as t,useMemo as n,createContext as o,useContext as i}from"react";import{snapshotToAccessStatus as s}from"@fanfare-io/fanfare-sdk-core/storefront";import{shopifyCheckoutVerifier as a}from"../verifier.js";const u="storefront_adapter_grant",f=o(null);function l(r){if(null===r)return null;try{return JSON.parse(r)}catch{return null}}function c({verifier:o=a,legacyStorageKeys:i=[],children:c}){const[d,m]=e(null),[w,g]=e(()=>function(r){if("undefined"==typeof window)return null;try{const e=l(window.sessionStorage.getItem(u));if(null!==e)return e;for(const t of r){if(t===u)continue;const r=window.sessionStorage.getItem(t),e=l(r);if(null!==e)return window.sessionStorage.setItem(u,r),window.sessionStorage.removeItem(t),e}return null}catch{return null}}(i)),p=t(r=>{m(r)},[]),S=t(r=>{g(r);try{window.sessionStorage.setItem(u,JSON.stringify(r))}catch{}},[]),h=t(()=>{g(null);try{window.sessionStorage.removeItem(u);for(const r of i)r!==u&&window.sessionStorage.removeItem(r)}catch{}},[i]),v=n(()=>({snapshot:d,status:s(d),grant:w,reportSnapshot:p,recordGrant:S,clearGrant:h,verifier:o}),[d,w,p,S,h,o]);/* @__PURE__ */
3
- return r(f.Provider,{value:v,children:c})}function d(){const r=i(f);if(null===r)throw new Error("useFanfareAdapter must be used within FanfareAdapterProvider");return r}export{c as FanfareAdapterProvider,d as useFanfareAdapter};