@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 +66 -25
- package/dist/claimer.d.ts +131 -0
- package/dist/claimer.js +1 -0
- package/dist/config.d.ts +137 -0
- package/dist/config.js +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +1 -0
- package/dist/index.d.ts +12 -12
- package/dist/index.js +1 -1
- package/dist/metafield.d.ts +11 -9
- package/dist/react/fanfare-checkout-gate.d.ts +39 -0
- package/dist/react/fanfare-checkout-gate.js +2 -0
- package/dist/react/index.d.ts +25 -2
- package/dist/react/index.js +1 -1
- package/dist/react/shopify-provider.d.ts +71 -0
- package/dist/react/shopify-provider.js +3 -0
- package/dist/react/use-fanfare-claim.d.ts +21 -0
- package/dist/react/use-fanfare-claim.js +2 -0
- package/dist/react/use-fanfare-experience.d.ts +8 -0
- package/dist/react/use-fanfare-experience.js +2 -0
- package/dist/react/use-fanfare-grant.d.ts +44 -0
- package/dist/react/use-fanfare-grant.js +2 -0
- package/dist/react/use-fanfare-widget-bridge.d.ts +16 -0
- package/dist/react/use-fanfare-widget-bridge.js +2 -0
- package/dist/resolver.d.ts +44 -30
- package/dist/resolver.js +1 -1
- package/dist/verifier.d.ts +64 -11
- package/package.json +3 -3
- package/dist/react/adapter-context.d.ts +0 -26
- package/dist/react/adapter-context.js +0 -3
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 | `
|
|
13
|
-
| `@fanfare-io/fanfare-sdk-shopify/react` | 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
package/dist/claimer.js
ADDED
|
@@ -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};
|
package/dist/config.d.ts
ADDED
|
@@ -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};
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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{
|
|
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};
|
package/dist/metafield.d.ts
CHANGED
|
@@ -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`.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
19
|
-
* SSR-safe,
|
|
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 /
|
|
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};
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
export type {
|
|
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';
|
package/dist/react/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import{
|
|
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};
|
package/dist/resolver.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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};
|
package/dist/verifier.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* with `.then()` only, so a thrown failure would strand the gate
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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};
|