@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 +64 -16
- package/dist/config.d.ts +15 -4
- package/dist/proxy-base-warning.d.ts +31 -0
- package/dist/proxy-base-warning.js +1 -0
- package/dist/react/fanfare-checkout-gate.js +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/shopify-provider.d.ts +23 -1
- package/dist/react/shopify-provider.js +2 -2
- package/dist/react/use-fanfare-experience.d.ts +12 -6
- package/dist/react/use-fanfare-experience.js +1 -1
- package/dist/resolver.d.ts +40 -16
- package/dist/resolver.js +1 -1
- package/package.json +59 -5
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
87
|
-
*
|
|
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{
|
|
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};
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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,
|
|
3
|
-
return r(
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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,
|
|
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};
|
package/dist/resolver.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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 `
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|