@fanfare-io/fanfare-sdk-shopify 0.16.0 → 0.18.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
@@ -15,16 +15,14 @@ from `@fanfare-io/fanfare-sdk-core/storefront`.
15
15
  ```tsx
16
16
  import { FanfareShopifyProvider } from "@fanfare-io/fanfare-sdk-shopify/react";
17
17
 
18
- function App() {
18
+ // Hoisted so the config keeps one identity for the life of the page. The provider keys its
19
+ // work on the values inside, so an inline literal is also safe — a constant is simply clearer.
20
+ const SHOPIFY_CONFIG = { proxyBaseUrl: "https://shop.example.com/apps/fanfare/api" };
21
+
22
+ function App({ children }: { children: React.ReactNode }) {
19
23
  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 */}
24
+ <FanfareShopifyProvider organizationId="org_abc123" publishableKey="pk_live_..." shopify={SHOPIFY_CONFIG}>
25
+ {children}
28
26
  </FanfareShopifyProvider>
29
27
  );
30
28
  }
@@ -43,9 +41,59 @@ proxy** — verify, claim, AND experience resolution — so there are no Storefr
43
41
  credentials to manage and no numeric app id to be told. The proxy resolves the
44
42
  `$app:` metafield namespace server-side.
45
43
 
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`). |
44
+ | Field | Required | Where to get it |
45
+ | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
46
+ | `proxyBaseUrl` | prod | The Fanfare Shopify app's app-proxy URL on your Online Store's primary domain (`https://<primary-domain>/apps/fanfare/api`). |
47
+
48
+ #### Use the Online Store's primary domain
49
+
50
+ `proxyBaseUrl` must name the domain your Shopify **Online Store** serves from — the
51
+ store's primary domain as shown under Settings → Domains. That is the host that
52
+ answers the app-proxy path (`/apps/fanfare/api`) directly, with no redirect. It is
53
+ not necessarily the host your headless page is served from; a headless
54
+ application does not serve the app-proxy route.
55
+
56
+ When the store has no custom domain, its primary domain is its `*.myshopify.com`
57
+ host and that is the correct value. Once a custom domain is set as primary,
58
+ Shopify answers every request on the myshopify host with a `301` to it, the
59
+ app-proxy path included. A browser cannot follow a redirect on a cross-origin
60
+ request, so every proxy call — experience resolution, `claim`, `reserve` — fails
61
+ before a response is received and surfaces as an opaque "CORS error".
62
+
63
+ Two independent conditions have to hold for browser calls to succeed:
64
+
65
+ - `proxyBaseUrl` names the primary domain (answers `200`, not `301`).
66
+ - The page's origin is permitted by the app's **Allowed Domains** setting (an
67
+ empty list permits every origin).
68
+
69
+ A password-protected or development store additionally requires the storefront
70
+ password before the app proxy answers. The SDK logs one console warning per base
71
+ when a proxy request fails before a response is received; it names the host and
72
+ both conditions, and cannot tell which one failed.
73
+
74
+ ## Resolve a product
75
+
76
+ ```tsx
77
+ import { useFanfareExperience } from "@fanfare-io/fanfare-sdk-shopify/react";
78
+
79
+ function ProductPage({ product }: { product: { handle: string; id: string } }) {
80
+ const experience = useFanfareExperience({ handle: product.handle, id: product.id });
81
+
82
+ switch (experience.status) {
83
+ case "loading":
84
+ return <Spinner />;
85
+ case "gated":
86
+ return <GatedPurchaseUI experienceId={experience.experienceId} />;
87
+ case "ungated":
88
+ return <NativePurchaseUI />;
89
+ case "error":
90
+ // 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.
91
+ return <NativePurchaseUI />;
92
+ }
93
+ }
94
+ ```
95
+
96
+ 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.
49
97
 
50
98
  ## Modes: `production` (default) vs `demo`
51
99
 
@@ -62,10 +110,10 @@ A gated product renders a visible "demo — not production-safe" state in this m
62
110
 
63
111
  ## Entry points
64
112
 
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"`) |
113
+ | Import | Runtime | Contents |
114
+ | --------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `@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 |
116
+ | `@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"`) |
69
117
 
70
118
  The root entry imports zero React, so a server-only consumer (Hydrogen/Oxygen
71
119
  loader, Next/Remix server component) installs and uses it without React. React
package/dist/config.d.ts CHANGED
@@ -27,10 +27,20 @@ export type FanfareMode = "production" | "demo";
27
27
  */
28
28
  export interface ShopifyAdapterConfig {
29
29
  /**
30
- * App-proxy base, e.g. "https://shop.myshopify.com/apps/fanfare/api". The
30
+ * App-proxy base, e.g. "https://shop.example.com/apps/fanfare/api". The
31
31
  * single switch for the adapter: real verify + claim AND experience resolution
32
32
  * all route through it. Required in `mode:"production"`; absent only in
33
33
  * `mode:"demo"`. This is the Fanfare Shopify app's app-proxy URL on your store.
34
+ *
35
+ * Use the domain the Shopify Online Store serves from — its primary domain
36
+ * under Settings → Domains, which is `*.myshopify.com` until a custom domain
37
+ * is made primary. It is the host that answers the app-proxy path without
38
+ * redirecting, and is not necessarily the host of a headless page. Once a
39
+ * custom primary domain exists, the myshopify host answers with a redirect,
40
+ * and a browser cannot follow a redirect on a cross-origin request, so every
41
+ * proxy call fails before a response is received. The page's origin must
42
+ * also be permitted by the app's Allowed Domains setting. Such a failure logs
43
+ * one console warning naming the configured host and both conditions.
34
44
  */
35
45
  proxyBaseUrl?: string;
36
46
  }
@@ -77,14 +87,15 @@ export type ShopifyProductRef = {
77
87
  * Each VALUE is a BARE Fanfare id (`"exp_1"`), NOT a raw metafield string —
78
88
  * do not feed it the `["exp_1"]` JSON-array form (contrast `metafieldValues`
79
89
  * below, whose values are the raw metafield string). Resolves synchronously by
80
- * normalized lookup key; an uncovered product resolves to `null`.
90
+ * normalized lookup key; an uncovered product resolves `ungated`.
81
91
  * - `{ source: "metafield", ... }` (the default) — reads the product's
82
92
  * `experienceIds` metafield (the one the Fanfare app wrote). Resolution order:
83
93
  * a `resolveExperience` override if supplied; else a preloaded
84
94
  * `metafieldValues[key]` (SSR fast path, no network); else a live app-proxy
85
95
  * read (`GET {proxyBaseUrl}/experiences/ids-by-handle/{key}`) that resolves
86
- * the `$app:` namespace server-side. Resolution is SSR-safe: any miss or
87
- * failure yields `null`, never a throw.
96
+ * the `$app:` namespace server-side. Resolution never throws: an uncovered
97
+ * product resolves `ungated`; a failed live read resolves `error` with a typed
98
+ * reason (see `resolveShopifyExperience`).
88
99
  *
89
100
  * Note the singular/plural asymmetry: `static`/`map` carry a single
90
101
  * `experienceId`, while the underlying metafield is `experienceIds` (plural, a
@@ -0,0 +1,31 @@
1
+ /**
2
+ * One-time console diagnostic for an app-proxy request that never reached a
3
+ * response.
4
+ *
5
+ * A `fetch` that REJECTS — rather than resolving with a non-2xx `Response` — means
6
+ * the browser refused or could not complete the request: it failed before a
7
+ * response was received. On a Shopify storefront two independent conditions an
8
+ * integrator controls can each produce it — a `proxyBaseUrl` on a host that
9
+ * redirects elsewhere (a browser cannot follow a redirect on a cross-origin
10
+ * request), and a page origin not permitted by the app's Allowed Domains setting —
11
+ * and the SDK cannot observe which, if either, applies. The message therefore
12
+ * asks for both to be checked and asserts nothing about what was detected.
13
+ *
14
+ * The experience resolver's live read is the only warning site. When a product is
15
+ * not covered by a preloaded value or an override, it is the first proxy request a
16
+ * page makes, ahead of any verify or claim, so one site reports the condition for
17
+ * the whole adapter; the same warning on the verifier and claimer would only
18
+ * repeat it.
19
+ *
20
+ * Only an ABSOLUTE base can reach the condition. A relative base is same-origin by
21
+ * construction — never redirected to another origin, never subject to a
22
+ * cross-origin check — so a rejection there has some other cause and earns no
23
+ * advice.
24
+ */
25
+ /**
26
+ * Warn that a request to the configured app-proxy base failed before a response
27
+ * was received. A no-op for a relative base and for a base already warned about.
28
+ */
29
+ export declare function warnProxyRequestFailed(configuredBase: string): void;
30
+ /** Drop the warned bases so a test starts from a page that has warned about nothing. */
31
+ export declare function resetProxyRequestWarningForTests(): void;
@@ -0,0 +1 @@
1
+ const e=/* @__PURE__ */new Set;function t(t){const r=function(e){return e.replace(/\/+$/,"")}(t);let o;try{o=new URL(r).host}catch{return}e.has(r)||(e.add(r),console.warn(`[fanfare] Request to ${o} failed before a response was received. Check both: proxyBaseUrl must be the Shopify Online Store's primary domain (a host that redirects there cannot be used, because a browser cannot follow a redirect on a cross-origin request), and this page's origin must be permitted by the app's Allowed Domains setting.`))}export{t as warnProxyRequestFailed};
@@ -1,2 +1,2 @@
1
1
  "use client";
2
- import{jsx as e,Fragment as r}from"react/jsx-runtime";import{useState as o,useRef as t,useMemo as n,useEffect as i}from"react";import{parseFirstExperienceId as s}from"../metafield.js";import{resolveProductLookupKey as a}from"../product-identifier.js";import{resolveShopifyExperienceId as l}from"../resolver.js";import{useShopifyAdapter as c}from"./shopify-provider.js";import{useFanfareGrant as d}from"./use-fanfare-grant.js";function u({cartItems:u,onDenied:m,fallback:f=null,children:p}){const{config:h}=c(),{experiences:v,shopify:g,mode:x}=h,[j,y]=o({phase:"resolving"}),k=t(0),P=n(()=>u.map(e=>`${e.handle} ${e.id}`).join(" "),[u]),w=t(u);w.current=u,i(()=>{const e=w.current,r=++k.current;if(y({phase:"resolving"}),0!==e.length)return Promise.all(e.map(e=>function(e,r,o){switch(r.source){case"static":return Promise.resolve(r.experienceId);case"map":{const o=a(e);return Promise.resolve((null===o?void 0:r.values[o])??null)}case"metafield":{const t=a(e),n=null===t?void 0:r.metafieldValues?.[t];return void 0===r.resolveExperience&&void 0!==n?Promise.resolve(s(n)):l(e,function(e,r){return{proxyBaseUrl:e.proxyBaseUrl??"",metafieldValues:r.metafieldValues,resolveExperience:r.resolveExperience}}(o,r))}}}(e,v,g))).then(e=>{if(k.current!==r)return;const o=e.some(e=>null!==e);y({phase:o?"gated":"ungated"})}),()=>{k.current++};y({phase:"ungated"})},[P,v,g]);const B=d();return"resolving"===j.phase?/* @__PURE__ */e(r,{children:f}):"ungated"===j.phase?/* @__PURE__ */e(r,{children:p}):"demo"===x?/* @__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"===B.state?/* @__PURE__ */e(r,{children:f}):"blocked"===B.state?/* @__PURE__ */e(r,{children:"function"==typeof m?m(B.reason):m}):/* @__PURE__ */e(r,{children:p})}export{u as FanfareCheckoutGate};
2
+ import{jsx as e,Fragment as r}from"react/jsx-runtime";import{useRef as o,useMemo as t,useState as n,useEffect as i}from"react";import{parseFirstExperienceId as a}from"../metafield.js";import{resolveProductLookupKey as s}from"../product-identifier.js";import{resolveShopifyExperience as c}from"../resolver.js";import{useShopifyAdapter as u}from"./shopify-provider.js";import{useFanfareGrant as l}from"./use-fanfare-grant.js";function d({cartItems:d,onDenied:m,fallback:p=null,children:f}){const{config:h,inFlightResolutions:g,reportResolutionError:v}=u(),{experiences:x,shopify:y,mode:j}=h,b=o(0),I=t(()=>d.map(e=>`${e.handle} ${e.id}`).join(" "),[d]),$=o(d);$.current=d;const E=o(x);E.current=x;const k=x.source,w=y.proxyBaseUrl??"",P="metafield"===x.source&&void 0!==x.resolveExperience,V=t(()=>{switch(x.source){case"static":return x.experienceId;case"map":return d.map(e=>{const r=s(e);return null===r?"":x.values[r]??""}).join("\0");case"metafield":return d.map(e=>{const r=s(e);return null===r?"":x.metafieldValues?.[r]??""}).join("\0")}},[I,k,d,x]),B=t(()=>`${I}\n${w}\n${P}\n${k}`,[I,w,P,k]),[F,U]=n({boundary:B,phase:"resolving"});i(()=>{const e=$.current,r=E.current,o=++b.current;if(0!==e.length)return Promise.all(e.map(e=>function(e,r,o,t){switch(r.source){case"static":return Promise.resolve(r.experienceId?{outcome:"gated",experienceId:r.experienceId}:{outcome:"ungated"});case"map":{const o=s(e),t=null===o?void 0:r.values[o];return Promise.resolve(t?{outcome:"gated",experienceId:t}:{outcome:"ungated"})}case"metafield":{const n=s(e),i=null===n?void 0:r.metafieldValues?.[n];if(void 0===r.resolveExperience&&void 0!==i){const e=a(i);return Promise.resolve(null===e?{outcome:"ungated"}:{outcome:"gated",experienceId:e})}return c(e,{proxyBaseUrl:o,metafieldValues:r.metafieldValues,resolveExperience:r.resolveExperience,inFlight:t})}}}(e,r,w,g))).then(e=>{if(b.current!==o)return;const r=e.some(e=>"gated"===e.outcome)?"gated":e.some(e=>"error"===e.outcome)?"error":"ungated";U(e=>"error"===r&&e.boundary===B&&"resolving"!==e.phase&&"error"!==e.phase?e:{boundary:B,phase:r});for(const o of e)"error"===o.outcome&&v(o.error)}),()=>{b.current++};U({boundary:B,phase:"ungated"})},[B,I,k,w,P,V,g,v]);const R=l(),D=F.boundary===B?F.phase:"resolving";return"resolving"===D?/* @__PURE__ */e(r,{children:p}):"ungated"===D||"error"===D?/* @__PURE__ */e(r,{children:f}):"demo"===j?/* @__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"===R.state?/* @__PURE__ */e(r,{children:p}):"blocked"===R.state?/* @__PURE__ */e(r,{children:"function"==typeof m?m(R.reason):m}):/* @__PURE__ */e(r,{children:f})}export{d as FanfareCheckoutGate};
@@ -20,6 +20,7 @@ export type { FanfareCheckoutCode, FanfareCheckoutFailureKind, FanfareGatedCheck
20
20
  export type { ClaimRefusalReason, ClaimResult } from '../claimer';
21
21
  export type { ExperienceSource, FanfareMode, ShopifyAdapterConfig, ShopifyProductRef } from '../config';
22
22
  export { FanfareConfigurationError } from '../errors';
23
+ export type { ExperienceResolutionError, ExperienceResolutionErrorReason } from '../resolver';
23
24
  export { FanfareCheckoutGate } from './fanfare-checkout-gate';
24
25
  export type { FanfareCheckoutGateProps } from './fanfare-checkout-gate';
25
26
  export { FanfareShopifyProvider } from './shopify-provider';
@@ -2,6 +2,7 @@ import { ReactNode } from 'react';
2
2
  import { JourneySnapshot } from '@fanfare-io/fanfare-sdk-core/experiences';
3
3
  import { AccessStatus, CheckoutVerifier, GrantRecord } from '@fanfare-io/fanfare-sdk-core/storefront';
4
4
  import { ExperienceSource, FanfareMode, ShopifyAdapterConfig } from '../config';
5
+ import { ExperienceResolutionError, ShopifyExperienceResolution } from '../resolver';
5
6
  import { ShopifyCheckoutContext } from '../verifier';
6
7
  export type { ExperienceSource } from '../config';
7
8
  /** The fully-resolved config the provider holds and exposes to the internal hooks. */
@@ -20,6 +21,14 @@ export interface ResolvedShopifyConfig {
20
21
  export interface ShopifyAdapterValue {
21
22
  /** The resolved provider config (mode + store + experience source). */
22
23
  config: ResolvedShopifyConfig;
24
+ /**
25
+ * Internal. Live reads in flight for this provider, keyed by request URL; both
26
+ * consumers share it so simultaneous asks for one product issue one request.
27
+ * Entries live only until their request settles.
28
+ */
29
+ inFlightResolutions: Map<string, Promise<ShopifyExperienceResolution>>;
30
+ /** Internal. Report a settled consumer failure without interrupting resolution. */
31
+ reportResolutionError(error: ExperienceResolutionError): void;
23
32
  /** Latest journey snapshot reported by the widget driving the journey. */
24
33
  snapshot: JourneySnapshot | null;
25
34
  /** Access status derived from the snapshot. */
@@ -58,11 +67,24 @@ export interface FanfareShopifyProviderProps {
58
67
  * by normalized lookup key, `"metafield"` via the preloaded fast path or a live read — by both
59
68
  * `useFanfareExperience` and `<FanfareCheckoutGate>`. See `ExperienceSource` for
60
69
  * the full resolution order per source.
70
+ * Resolution outcomes are `loading | gated | ungated | error`, and `error` is reported through `onResolutionError`.
71
+ * The resolveExperience override is keyed on presence, not identity — swapping one function for another does not re-resolve.
61
72
  */
62
73
  experiences?: ExperienceSource;
74
+ /**
75
+ * Called once per consumer resolution that fails: lookupKey is the product's
76
+ * handle or id and reason is the typed cause; two consumers of one product
77
+ * each receive a call. Supplying a handler replaces the default warn. A
78
+ * throwing handler is caught and falls through to the warn, never breaking
79
+ * resolution. When omitted, the SDK warns once per (product, reason) in the
80
+ * developer console only — nothing reaches Fanfare. A request to an absolute
81
+ * proxy base that fails before a response is received is reported by the
82
+ * resolver's once-per-base advisory whether or not a handler is supplied.
83
+ */
84
+ onResolutionError?: (error: ExperienceResolutionError) => void;
63
85
  children: ReactNode;
64
86
  }
65
- export declare function FanfareShopifyProvider({ organizationId, publishableKey, mode, shopify, experiences, children, }: FanfareShopifyProviderProps): import("react/jsx-runtime").JSX.Element;
87
+ export declare function FanfareShopifyProvider({ organizationId, publishableKey, mode, shopify, experiences, onResolutionError, children, }: FanfareShopifyProviderProps): import("react/jsx-runtime").JSX.Element;
66
88
  /**
67
89
  * INTERNAL hook over the adapter context. NOT public — the public hooks
68
90
  * (`useFanfareExperience` / `useFanfareGrant` / `useFanfareClaim`) build on it.
@@ -1,3 +1,3 @@
1
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,useContext as i,createContext 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=u(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=i(m);if(null===r)throw new Error("useShopifyAdapter must be used within FanfareShopifyProvider");return r}export{y as FanfareShopifyProvider,g as useShopifyAdapter};
2
+ import{jsx as r}from"react/jsx-runtime";import{useState as e,useMemo as n,useRef as t,useCallback as o,useContext as i,createContext as s}from"react";import{getParticipation as u,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",h=s(null);function m(){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:s,mode:y="production",shopify:w,experiences:v=d,onResolutionError:g,children:S}){l({mode:y,shopify:w});const[k,x]=e(null),[I,b]=e(()=>m()),[$]=e(()=>/* @__PURE__ */new Map),E=w.proxyBaseUrl,R=n(()=>function(r){if(void 0===r)return!1;try{return""!==new URL(r).href}catch{return!1}}(E),[E]),P=t(g);P.current=g;const[j]=e(()=>/* @__PURE__ */new Set),C=o(r=>{const e=P.current;if(void 0!==e)try{return void e(r)}catch{}if("network"===r.reason&&R)return;const{lookupKey:n,reason:t,status:o}=r,i=`${n}\n${t}`;j.has(i)||(j.add(i),console.warn(`[fanfare-sdk-shopify] Experience resolution failed for product "${n}" (${t}${void 0===o?"":` ${o}`}). Gating for this product is unknown until a later resolution succeeds. Pass onResolutionError to FanfareShopifyProvider to observe these failures.`))},[j,R]),F=t(null),G=t(null);G.current=k;const K=t(null);K.current=I;const U=o(r=>{x(r)},[]),z=o(r=>{b(r);try{window.sessionStorage.setItem(p,JSON.stringify(r))}catch{}},[]),A=o(()=>{b(null);try{window.sessionStorage.removeItem(p)}catch{}},[]),B=o(r=>{F.current=r},[]),J=o(()=>{const r=K.current?.token,e=function(r){if(null!==r)return u(r)?.id??a(r)?.id}(G.current),n=F.current;return void 0===r||void 0===e||null===n?null:{credential:r,distributionId:e,cartId:n}},[]),N=n(()=>f,[]),O=n(()=>({proxyBaseUrl:E}),[E]),_=n(()=>({organizationId:i,publishableKey:s,mode:y,shopify:O,experiences:v}),[i,s,y,O,v]),L=n(()=>({config:_,inFlightResolutions:$,reportResolutionError:C,snapshot:k,status:c(k),grant:I,reportSnapshot:U,recordGrant:z,clearGrant:A,verifier:N,setActiveCartId:B,getCheckoutContext:J}),[_,k,I,U,z,A,N,B,J,$,C]);/* @__PURE__ */
3
+ return r(h.Provider,{value:L,children:S})}function w(){const r=i(h);if(null===r)throw new Error("useShopifyAdapter must be used within FanfareShopifyProvider");return r}export{y as FanfareShopifyProvider,w as useShopifyAdapter};
@@ -1,8 +1,14 @@
1
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
- }
2
+ import { ExperienceResolutionError } from '../resolver';
3
+ export type UseFanfareExperienceResult = {
4
+ status: "loading";
5
+ } | {
6
+ status: "gated";
7
+ experienceId: string;
8
+ } | {
9
+ status: "ungated";
10
+ } | {
11
+ status: "error";
12
+ error: ExperienceResolutionError;
13
+ };
8
14
  export declare function useFanfareExperience(shopifyProduct: ShopifyProductRef): UseFanfareExperienceResult;
@@ -1,2 +1,2 @@
1
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{resolveProductLookupKey as o}from"../product-identifier.js";import{resolveShopifyExperienceId as d}from"../resolver.js";import{useShopifyAdapter as t}from"./shopify-provider.js";function s(s){const{config:l}=t(),{experiences:p,shopify:u}=l,{handle:a,id:f}=s,x=e(()=>void 0===a?{id:f??""}:{handle:a,id:f},[a,f]),m=e(()=>function(e,r){switch(r.source){case"static":return{sync:!0,experienceId:r.experienceId};case"map":{const n=o(e);return{sync:!0,experienceId:(null===n?void 0:r.values[n])??null}}case"metafield":{if(void 0!==r.resolveExperience)return{sync:!1};const n=o(e),i=null===n?void 0:r.metafieldValues?.[n];return void 0!==i?{sync:!0,experienceId:c(i)}:{sync:!1}}}}(x,p),[x,p]),[I,v]=r(()=>m.sync?{experienceId:m.experienceId,loading:!1}:{experienceId:null,loading:!0}),y=n(0);return i(()=>{if(m.sync)return void v({experienceId:m.experienceId,loading:!1});if("metafield"!==p.source)return;const e=++y.current;return v({experienceId:null,loading:!0}),d(x,function(e,r){return{proxyBaseUrl:e.proxyBaseUrl??"",metafieldValues:r.metafieldValues,resolveExperience:r.resolveExperience}}(u,p)).then(r=>{y.current===e&&v({experienceId:r,loading:!1})}),()=>{y.current++}},[m,x,p,u]),m.sync?{experienceId:m.experienceId,loading:!1}:I}export{s as useFanfareExperience};
2
+ import{useMemo as e,useRef as r,useState as t,useCallback as s,useEffect as o}from"react";import{parseFirstExperienceId as u}from"../metafield.js";import{resolveProductLookupKey as l}from"../product-identifier.js";import{resolveShopifyExperience as i}from"../resolver.js";import{useShopifyAdapter as n}from"./shopify-provider.js";function a(a){const{config:c,inFlightResolutions:d,reportResolutionError:p}=n(),{experiences:m,shopify:f}=c,{handle:v,id:x}=a,g=e(()=>void 0===v?{id:x??""}:{handle:v,id:x},[v,x]),y=e(()=>l(g),[g]),h=m.source,B=f.proxyBaseUrl??"",U="static"===m.source?m.experienceId:void 0,I="map"===m.source&&null!==y?m.values[y]:void 0,k="metafield"===m.source&&null!==y?m.metafieldValues?.[y]:void 0,K="metafield"===m.source&&void 0!==m.resolveExperience,O=r(m);O.current=m;const j=e(()=>{switch(h){case"static":return U?{status:"gated",experienceId:U}:{status:"ungated"};case"map":return I?{status:"gated",experienceId:I}:{status:"ungated"};case"metafield":{if(void 0===k||K)return null;const e=u(k);return null===e?{status:"ungated"}:{status:"gated",experienceId:e}}}},[h,U,I,k,K]),[E,V]=t({lookupKey:y,proxyBaseUrl:B,hasOverride:K,source:h,result:null}),F=s(e=>e.lookupKey===y&&e.proxyBaseUrl===B&&e.hasOverride===K&&e.source===h,[y,B,K,h]),R=F(E)?E.result:null,w=r(0);return o(()=>{if(V(e=>F(e)?e:{lookupKey:y,proxyBaseUrl:B,hasOverride:K,source:h,result:null}),null!==j)return void V(e=>F(e)&&e.result===j?e:{lookupKey:y,proxyBaseUrl:B,hasOverride:K,source:h,result:j});const e=O.current;if("metafield"!==e.source)return;const r=++w.current;return i(g,{proxyBaseUrl:B,metafieldValues:e.metafieldValues,resolveExperience:e.resolveExperience,inFlight:d}).then(e=>{if(w.current!==r)return;const t="gated"===e.outcome?{status:"gated",experienceId:e.experienceId}:"ungated"===e.outcome?{status:"ungated"}:{status:"error",error:e.error};V(e=>{const r=F(e)?e.result:null;return"error"===t.status&&null!==r&&"error"!==r.status?e:{lookupKey:y,proxyBaseUrl:B,hasOverride:K,source:h,result:t}}),"error"===e.outcome&&p(e.error)}),()=>{w.current++}},[j,F,y,g,B,k,K,h,d,p]),j??R??{status:"loading"}}export{a as useFanfareExperience};
@@ -1,8 +1,28 @@
1
1
  import { ExperienceResolver } from '@fanfare-io/fanfare-sdk-core/storefront';
2
2
  import { ShopifyProductRef } from './config';
3
+ export type ExperienceResolutionErrorReason = "network" | "http" | "malformed" | "unavailable" | "override";
4
+ export interface ExperienceResolutionError {
5
+ /** The normalized product lookup key (a handle or product id — never a consumer identifier). */
6
+ lookupKey: string;
7
+ reason: ExperienceResolutionErrorReason;
8
+ /** HTTP status code when `reason === "http"`. */
9
+ status?: number;
10
+ /** The thrown value when `reason` is `"network"` or `"override"`. */
11
+ cause?: unknown;
12
+ }
13
+ export type ShopifyExperienceResolution = {
14
+ outcome: "gated";
15
+ experienceId: string;
16
+ } | {
17
+ outcome: "ungated";
18
+ } | {
19
+ outcome: "error";
20
+ error: ExperienceResolutionError;
21
+ };
3
22
  export interface ShopifyExperienceResolverConfig {
4
23
  /**
5
- * App-proxy base, e.g. "https://shop.myshopify.com/apps/fanfare/api". The live
24
+ * App-proxy base on the Online Store's primary domain, e.g.
25
+ * "https://shop.example.com/apps/fanfare/api". The live
6
26
  * resolution tier fetches `${proxyBaseUrl}/experiences/ids-by-handle/${key}`;
7
27
  * the proxy resolves the `$app:` namespace server-side, so no Storefront creds
8
28
  * or numeric app id are needed here.
@@ -32,28 +52,32 @@ export interface ShopifyExperienceResolverConfig {
32
52
  * the product.
33
53
  */
34
54
  fetchImpl?: typeof fetch;
55
+ /**
56
+ * Shared in-flight live reads keyed by request URL; an entry is removed when
57
+ * its request settles. Supply a shared map to coalesce concurrent live reads
58
+ * across callers; absent means no coalescing. A request that never settles keeps
59
+ * its entry, so every later read of the same URL joins it; no deadline is applied.
60
+ */
61
+ inFlight?: Map<string, Promise<ShopifyExperienceResolution>>;
35
62
  }
36
63
  /**
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[key]` (SSR fast path, no network).
42
- * 3. else a live app-proxy read of the experience ids by key.
43
- *
44
- * The lookup key is normalized from the product ref by `resolveProductLookupKey`
45
- * (a handle as-is, a storefront URL reduced to its handle, a global/numeric id
46
- * forwarded for the proxy to classify), so an integrator passing the wrong shape
47
- * of "product id" still resolves. SSR-safe: returns `null` on any miss, an
48
- * unresolvable ref, or failure — never throws. Shared by
49
- * `createShopifyExperienceResolver` and the `useFanfareExperience` hook.
64
+ * Resolve a Shopify product to a gated, ungated, or typed error outcome.
65
+ * Resolution uses the override first, then a preloaded metafield value, then
66
+ * a live app-proxy read. The normalized lookup key accepts handles, storefront
67
+ * product URLs, and product ids. An unresolvable ref is ungated; failures are
68
+ * returned as error outcomes rather than thrown.
69
+ */
70
+ export declare function resolveShopifyExperience(product: ShopifyProductRef, config: ShopifyExperienceResolverConfig): Promise<ShopifyExperienceResolution>;
71
+ /**
72
+ * Core's `ExperienceResolver` seam has no error slot, so error outcomes collapse
73
+ * to `null` there.
50
74
  */
51
75
  export declare function resolveShopifyExperienceId(product: ShopifyProductRef, config: ShopifyExperienceResolverConfig): Promise<string | null>;
52
76
  /**
53
77
  * Build a Shopify ExperienceResolver. `resolve(product)` returns the product's
54
- * Fanfare experience id (first id wins), or null when the product is not covered.
78
+ * Fanfare experience id (first id wins), or null when the product is not covered or resolution fails.
55
79
  *
56
- * Resolution follows the three-tier order in `resolveShopifyExperienceId`: a
80
+ * Resolution follows the three-tier order in `resolveShopifyExperience`: a
57
81
  * `resolveExperience` override, else a preloaded `metafieldValues[key]` (SSR
58
82
  * fast path, no network), else a live app-proxy read of
59
83
  * `GET {proxyBaseUrl}/experiences/ids-by-handle/{key}` via `fetchImpl`.
package/dist/resolver.js CHANGED
@@ -1 +1 @@
1
- import{parseFirstExperienceId as e}from"./metafield.js";import{resolveProductLookupKey as n}from"./product-identifier.js";async function r(e,n){const r=n.fetchImpl??globalThis.fetch,t=`${n.proxyBaseUrl.replace(/\/+$/,"")}/experiences/ids-by-handle/${encodeURIComponent(e)}`;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 t(t,l){if(void 0!==l.resolveExperience)try{return await l.resolveExperience(t)??null}catch{return null}const o=n(t);if(null===o)return null;const i=l.metafieldValues?.[o];return void 0!==i?e(i):r(o,l)}export{t as resolveShopifyExperienceId};
1
+ import{parseFirstExperienceId as e}from"./metafield.js";import{resolveProductLookupKey as r}from"./product-identifier.js";import{warnProxyRequestFailed as o}from"./proxy-base-warning.js";async function t(t,n){if(void 0!==n.resolveExperience)try{const e=await n.resolveExperience(t);return null==e?{outcome:"ungated"}:{outcome:"gated",experienceId:e}}catch(c){return{outcome:"error",error:{lookupKey:r(t)??"",reason:"override",cause:c}}}const u=r(t);if(null===u)return{outcome:"ungated"};const a=n.metafieldValues?.[u];if(void 0!==a){const r=e(a);return null===r?{outcome:"ungated"}:{outcome:"gated",experienceId:r}}return function(e,r){const t=r.fetchImpl??globalThis.fetch,n=r.proxyBaseUrl.replace(/\/+$/,""),u=`${n}/experiences/ids-by-handle/${encodeURIComponent(e)}`,a=r.inFlight?.get(u);if(void 0!==a)return a;const i=(async()=>{let r,a;try{if(r=await t(u,{method:"GET"}),!r.ok)return{outcome:"error",error:{lookupKey:e,reason:"http",status:r.status}}}catch(c){return o(n),{outcome:"error",error:{lookupKey:e,reason:"network",cause:c}}}try{a=await r.json()}catch{return{outcome:"error",error:{lookupKey:e,reason:"malformed"}}}if("object"!=typeof a||null===a)return{outcome:"error",error:{lookupKey:e,reason:"malformed"}};const{experienceIds:i,liveness:l}=a;if("unavailable"===l)return{outcome:"error",error:{lookupKey:e,reason:"unavailable"}};if(!Array.isArray(i))return{outcome:"error",error:{lookupKey:e,reason:"malformed"}};const s=i[0];return"string"==typeof s?{outcome:"gated",experienceId:s}:0===i.length?{outcome:"ungated"}:{outcome:"error",error:{lookupKey:e,reason:"malformed"}}})().finally(()=>{r.inFlight?.delete(u)});return r.inFlight?.set(u,i),i}(u,n)}export{t as resolveShopifyExperience};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanfare-io/fanfare-sdk-shopify",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",
@@ -46,7 +46,7 @@
46
46
  "peerDependencies": {
47
47
  "react": "^18.0.0 || ^19.1.1",
48
48
  "react-dom": "^18.0.0 || ^19.1.1",
49
- "@fanfare-io/fanfare-sdk-core": "0.16.0"
49
+ "@fanfare-io/fanfare-sdk-core": "0.18.0"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "react": {
@@ -57,6 +57,7 @@
57
57
  }
58
58
  },
59
59
  "devDependencies": {
60
+ "@size-limit/preset-small-lib": "^11.1.8",
60
61
  "@types/react": "^19.1.12",
61
62
  "@types/react-dom": "^19.1.9",
62
63
  "@vitejs/plugin-react": "^5.0.2",
@@ -66,13 +67,15 @@
66
67
  "react": "^19.1.1",
67
68
  "react-dom": "^19.1.1",
68
69
  "rimraf": "^6.0.1",
70
+ "size-limit": "^11.1.8",
71
+ "terser": "^5.46.1",
69
72
  "typescript": "^5.9.2",
70
73
  "typescript-native": "npm:typescript@^7.0.2",
71
74
  "vite": "^7.3.5",
72
75
  "vite-plugin-dts": "^4.5.4",
73
76
  "vite-tsconfig-paths": "^5.1.4",
74
77
  "vitest": "^5.0.0",
75
- "@fanfare-io/fanfare-sdk-core": "0.16.0"
78
+ "@fanfare-io/fanfare-sdk-core": "0.18.0"
76
79
  },
77
80
  "sideEffects": false,
78
81
  "keywords": [
@@ -84,8 +87,58 @@
84
87
  "headless"
85
88
  ],
86
89
  "license": "Apache-2.0",
90
+ "size-limit": [
91
+ {
92
+ "name": "Root (isomorphic)",
93
+ "path": "dist/index.js",
94
+ "limit": "3 KB",
95
+ "ignore": [
96
+ "react",
97
+ "react-dom",
98
+ "react/jsx-runtime",
99
+ "jose",
100
+ "@fanfare-io/fanfare-sdk-core",
101
+ "@fanfare-io/fanfare-sdk-core/admission-proof",
102
+ "@fanfare-io/fanfare-sdk-core/experiences",
103
+ "@fanfare-io/fanfare-sdk-core/internals",
104
+ "@fanfare-io/fanfare-sdk-core/storefront"
105
+ ]
106
+ },
107
+ {
108
+ "name": "React bindings",
109
+ "path": "dist/react/index.js",
110
+ "limit": "6 KB",
111
+ "ignore": [
112
+ "react",
113
+ "react-dom",
114
+ "react/jsx-runtime",
115
+ "jose",
116
+ "@fanfare-io/fanfare-sdk-core",
117
+ "@fanfare-io/fanfare-sdk-core/admission-proof",
118
+ "@fanfare-io/fanfare-sdk-core/experiences",
119
+ "@fanfare-io/fanfare-sdk-core/internals",
120
+ "@fanfare-io/fanfare-sdk-core/storefront"
121
+ ]
122
+ },
123
+ {
124
+ "name": "Auth (server-only)",
125
+ "path": "dist/auth/index.js",
126
+ "limit": "5 KB",
127
+ "ignore": [
128
+ "react",
129
+ "react-dom",
130
+ "react/jsx-runtime",
131
+ "jose",
132
+ "@fanfare-io/fanfare-sdk-core",
133
+ "@fanfare-io/fanfare-sdk-core/admission-proof",
134
+ "@fanfare-io/fanfare-sdk-core/experiences",
135
+ "@fanfare-io/fanfare-sdk-core/internals",
136
+ "@fanfare-io/fanfare-sdk-core/storefront"
137
+ ]
138
+ }
139
+ ],
87
140
  "scripts": {
88
- "build": "vite build",
141
+ "build": "vite build && pnpm size",
89
142
  "build:deps": "vite build --emptyOutDir false",
90
143
  "build:incremental": "vite build --emptyOutDir false",
91
144
  "build:dist": "rimraf dist && vite build",
@@ -100,6 +153,7 @@
100
153
  "test:watch": "vitest",
101
154
  "test:unit": "pnpm build:test-deps && vitest run .unit.test.ts --passWithNoTests",
102
155
  "test:contract": "pnpm build:test-deps && vitest run .contract.test.ts --passWithNoTests",
103
- "typecheck": "./node_modules/typescript-native/bin/tsc --noEmit"
156
+ "typecheck": "./node_modules/typescript-native/bin/tsc --noEmit",
157
+ "size": "size-limit"
104
158
  }
105
159
  }