{"_id":"@abra-promotions/headless","_rev":"4-57f47d063c8a03ce8b63ca4ef50012ca","name":"@abra-promotions/headless","dist-tags":{"latest":"1.0.0"},"versions":{"0.1.0":{"name":"@abra-promotions/headless","version":"0.1.0","keywords":["shopify","hydrogen","headless","abra","promotions","banner","gift-with-purchase"],"author":{"name":"Abra"},"license":"Apache-2.0","_id":"@abra-promotions/headless@0.1.0","maintainers":[{"name":"danielpatricio","email":"daniel@abrapromotions.com"},{"name":"alon-mota-abra","email":"alon@abrapromotions.com"}],"homepage":"https://github.com/merchantinresidence/abra-headless-banners#readme","bugs":{"url":"https://github.com/merchantinresidence/abra-headless-banners/issues"},"dist":{"shasum":"b319391cef0a5f3784250d1708b61f981bf9d695","tarball":"https://registry.npmjs.org/@abra-promotions/headless/-/headless-0.1.0.tgz","fileCount":166,"integrity":"sha512-sbGczJPrRG+haRoerkrlCd8g1UhkuNyMmTV4HohCICWyCO1G4wXT4v2+bBN6/Dtj7dJK8xiFJVdA/qtlOqfvyw==","signatures":[{"sig":"MEQCIHtoztE/LEz+zZgO82G6bxZDxYh3m9cQLs9OQgsqRDRHAiBXNMhxvetJlkGOJ3UuhOaBkKXLfPpfcmAG0llTxsJTNw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":826586},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./loader":{"types":"./dist/loader/index.d.ts","import":"./dist/loader/index.js"},"./pricing":{"types":"./dist/pricing/index.d.ts","import":"./dist/pricing/index.js"}},"gitHead":"87ac47bd8d6a882635bd9ab3210b80a3f86f9b79","scripts":{"lint":"eslint .","test":"vitest run","build":"node scripts/build-package.mjs","format":"prettier --write .","typecheck":"tsc --noEmit","test:watch":"vitest","build:bundle":"node scripts/build-bundle.mjs","format:check":"prettier --check .","verify:bundle":"node scripts/build-bundle.mjs && git diff --exit-code -- package/bundle/abra-banners.js","prepublishOnly":"npm run build"},"_npmUser":{"name":"danielpatricio","email":"daniel@abrapromotions.com"},"repository":{"url":"git+https://github.com/merchantinresidence/abra-headless-banners.git","type":"git"},"_npmVersion":"10.8.2","description":"Abra tier & gift promotion banners and dynamic pricing for headless Hydrogen / React storefronts. Reads storefront-readable metafields via your own Storefront API — no Abra API token.","directories":{},"sideEffects":["./dist/bundle/abra-banners.js"],"_nodeVersion":"20.20.2","dependencies":{"big.js":"^6.2.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"lit":"3.3.1","vite":"^6.0.0","jsdom":"^25.0.0","react":"18.3.1","eslint":"^9.39.4","terser":"^5.48.0","vitest":"^2.1.0","fishery":"^2.2.0","globals":"^17.7.0","prettier":"^3.8.4","react-dom":"^18.3.1","@eslint/js":"^9.39.4","typescript":"5.9.2","@types/react":"^18.3.0","@types/big.js":"^6.2.2","typescript-eslint":"^8.62.0","@testing-library/dom":"^10.4.1","@testing-library/react":"^16.3.2","eslint-config-prettier":"^9.1.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^5.2.0"},"peerDependencies":{"react":">=18","react-dom":">=18"},"peerDependenciesMeta":{"react-dom":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/headless_0.1.0_1782498783406_0.6592251669284022","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@abra-promotions/headless","version":"0.2.0","keywords":["shopify","hydrogen","headless","abra","promotions","banner","gift-with-purchase"],"author":{"name":"Abra"},"license":"Apache-2.0","_id":"@abra-promotions/headless@0.2.0","maintainers":[{"name":"danielpatricio","email":"daniel@abrapromotions.com"},{"name":"alon-mota-abra","email":"alon@abrapromotions.com"}],"homepage":"https://github.com/merchantinresidence/abra-headless-banners#readme","bugs":{"url":"https://github.com/merchantinresidence/abra-headless-banners/issues"},"dist":{"shasum":"c32deb95021b66b416d33492130477eb43f758b5","tarball":"https://registry.npmjs.org/@abra-promotions/headless/-/headless-0.2.0.tgz","fileCount":186,"integrity":"sha512-YcXjJT35yOq9jp4z9mPtVlMXv8FRgpxjIVCroPcPtd7bMnTWZGrM3gB/YAezp6s3+YsdtMt5cz6Pp5irRtborA==","signatures":[{"sig":"MEYCIQCWJzNhMud1R8xIFfOAwFP0P/U50QpvykH6Gz2z5V710AIhAIbKRALpPgVi7YuvhMgmwTLwg4xrI0cfjIoMRWt0LLMh","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":890351},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./cart":{"types":"./dist/cart-actions/index.d.ts","import":"./dist/cart-actions/index.js"},"./loader":{"types":"./dist/loader/index.d.ts","import":"./dist/loader/index.js"},"./pricing":{"types":"./dist/pricing/index.d.ts","import":"./dist/pricing/index.js"}},"gitHead":"b349c43fe80a69a00ecf926a0e0822b51d6becb8","scripts":{"lint":"eslint .","test":"vitest run","build":"node scripts/build-package.mjs","format":"prettier --write .","typecheck":"tsc --noEmit","test:watch":"vitest","build:bundle":"node scripts/build-bundle.mjs","format:check":"prettier --check .","verify:bundle":"node scripts/build-bundle.mjs && git diff --exit-code -- package/bundle/abra-banners.js","prepublishOnly":"npm run build"},"_npmUser":{"name":"danielpatricio","email":"daniel@abrapromotions.com"},"repository":{"url":"git+https://github.com/merchantinresidence/abra-headless-banners.git","type":"git"},"_npmVersion":"10.8.2","description":"Abra tier & gift promotion banners and dynamic pricing for headless Hydrogen / React storefronts. Reads storefront-readable metafields via your own Storefront API — no Abra API token.","directories":{},"sideEffects":["./dist/bundle/abra-banners.js"],"_nodeVersion":"20.20.2","dependencies":{"big.js":"^6.2.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"lit":"3.3.1","vite":"^6.0.0","jsdom":"^25.0.0","react":"18.3.1","eslint":"^9.39.4","terser":"^5.48.0","vitest":"^2.1.0","fishery":"^2.2.0","globals":"^17.7.0","prettier":"^3.8.4","react-dom":"^18.3.1","@eslint/js":"^9.39.4","typescript":"5.9.2","@types/react":"^18.3.0","@types/big.js":"^6.2.2","typescript-eslint":"^8.62.0","@testing-library/dom":"^10.4.1","@testing-library/react":"^16.3.2","eslint-config-prettier":"^9.1.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^5.2.0"},"peerDependencies":{"react":">=18","react-dom":">=18"},"peerDependenciesMeta":{"react-dom":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/headless_0.2.0_1784213865556_0.7868826275186445","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@abra-promotions/headless","version":"0.2.1","keywords":["shopify","hydrogen","headless","abra","promotions","banner","gift-with-purchase"],"author":{"name":"Abra"},"license":"Apache-2.0","_id":"@abra-promotions/headless@0.2.1","maintainers":[{"name":"danielpatricio","email":"daniel@abrapromotions.com"},{"name":"alon-mota-abra","email":"alon@abrapromotions.com"}],"homepage":"https://github.com/merchantinresidence/abra-headless-banners#readme","bugs":{"url":"https://github.com/merchantinresidence/abra-headless-banners/issues"},"dist":{"shasum":"48bd796cc5bb5d1803d64f76ab9e75fb4070b614","tarball":"https://registry.npmjs.org/@abra-promotions/headless/-/headless-0.2.1.tgz","fileCount":188,"integrity":"sha512-l/pOVVRHICi3CXC/SKI/DgQtV3BsE7hEfz2Nr6gm5g7Khf0Gbnk6kSs2e020HGObFUInRtJia9DMRZfC8YCPMg==","signatures":[{"sig":"MEUCIDrPLb7E1U+zKLRcxnXqJ5q13Ml1VRechTe5AqxvZ++tAiEA6i7LbIoWzUbGBk4tf9AI+IeHFDNRb5ym3mKffiMAOGI=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":901237},"main":"./dist/index.js","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=18"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./cart":{"types":"./dist/cart-actions/index.d.ts","import":"./dist/cart-actions/index.js"},"./loader":{"types":"./dist/loader/index.d.ts","import":"./dist/loader/index.js"},"./pricing":{"types":"./dist/pricing/index.d.ts","import":"./dist/pricing/index.js"}},"gitHead":"67aeb60c95e60979437bb0119889225256ce52a5","scripts":{"lint":"eslint .","test":"vitest run","build":"node scripts/build-package.mjs","format":"prettier --write .","typecheck":"tsc --noEmit","test:watch":"vitest","build:bundle":"node scripts/build-bundle.mjs","format:check":"prettier --check .","verify:bundle":"node scripts/build-bundle.mjs && git diff --exit-code -- package/bundle/abra-banners.js","prepublishOnly":"npm run build"},"_npmUser":{"name":"danielpatricio","email":"daniel@abrapromotions.com"},"repository":{"url":"git+https://github.com/merchantinresidence/abra-headless-banners.git","type":"git"},"_npmVersion":"10.8.2","description":"Abra tier & gift promotion banners and dynamic pricing for headless Hydrogen / React storefronts. Reads storefront-readable metafields via your own Storefront API — no Abra API token.","directories":{},"sideEffects":["./dist/bundle/abra-banners.js"],"_nodeVersion":"20.20.2","dependencies":{"big.js":"^6.2.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"lit":"3.3.1","vite":"^6.0.0","jsdom":"^25.0.0","react":"18.3.1","eslint":"^9.39.4","terser":"^5.48.0","vitest":"^2.1.0","fishery":"^2.2.0","globals":"^17.7.0","prettier":"^3.8.4","react-dom":"^18.3.1","@eslint/js":"^9.39.4","typescript":"5.9.2","@types/react":"^18.3.0","@types/big.js":"^6.2.2","typescript-eslint":"^8.62.0","@testing-library/dom":"^10.4.1","@testing-library/react":"^16.3.2","eslint-config-prettier":"^9.1.2","@testing-library/jest-dom":"^6.9.1","eslint-plugin-react-hooks":"^5.2.0"},"peerDependencies":{"react":">=18","react-dom":">=18"},"peerDependenciesMeta":{"react-dom":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/headless_0.2.1_1784217215060_0.9628129666206171","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@abra-promotions/headless","version":"1.0.0","description":"Abra tier & gift promotion banners and dynamic pricing for headless Hydrogen / React storefronts. Reads storefront-readable metafields via your own Storefront API — no Abra API token.","license":"Apache-2.0","author":{"name":"Abra"},"homepage":"https://github.com/merchantinresidence/abra-headless-banners#readme","repository":{"type":"git","url":"git+https://github.com/merchantinresidence/abra-headless-banners.git"},"bugs":{"url":"https://github.com/merchantinresidence/abra-headless-banners/issues"},"keywords":["shopify","hydrogen","headless","abra","promotions","banner","gift-with-purchase"],"type":"module","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./loader":{"types":"./dist/loader/index.d.ts","import":"./dist/loader/index.js"},"./pricing":{"types":"./dist/pricing/index.d.ts","import":"./dist/pricing/index.js"},"./cart":{"types":"./dist/cart-actions/index.d.ts","import":"./dist/cart-actions/index.js"}},"main":"./dist/index.js","module":"./dist/index.js","types":"./dist/index.d.ts","sideEffects":["./dist/bundle/abra-banners.js"],"engines":{"node":">=20"},"publishConfig":{"access":"public"},"scripts":{"build":"node scripts/build-package.mjs","build:bundle":"node scripts/build-bundle.mjs","verify:bundle":"node scripts/build-bundle.mjs && git diff --exit-code -- package/bundle/abra-banners.js","test":"vitest run","test:watch":"vitest","lint":"eslint .","format":"prettier --write .","format:check":"prettier --check .","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"dependencies":{"big.js":"^6.2.1"},"peerDependencies":{"react":">=18","react-dom":">=18"},"peerDependenciesMeta":{"react-dom":{"optional":true}},"devDependencies":{"@eslint/js":"^9.39.4","@testing-library/dom":"^10.4.1","@testing-library/jest-dom":"^6.9.1","@testing-library/react":"^16.3.2","@types/big.js":"^6.2.2","@types/react":"^18.3.0","eslint":"^9.39.4","eslint-config-prettier":"^9.1.2","eslint-plugin-react-hooks":"^5.2.0","fishery":"^2.2.0","globals":"^17.7.0","jsdom":"^25.0.0","lit":"3.3.1","prettier":"^3.8.4","react":"18.3.1","react-dom":"^18.3.1","terser":"^5.48.0","typescript":"5.9.2","typescript-eslint":"^8.62.0","vite":"^6.0.0","vitest":"^2.1.0"},"_id":"@abra-promotions/headless@1.0.0","gitHead":"2417f0a2c4f0a905935e49a3ce334af9e387d289","_nodeVersion":"20.20.2","_npmVersion":"10.8.2","dist":{"integrity":"sha512-5UJlb/y2dTXoQhiNp4VMZhV7oVmvw3wLteOGohdgvJs/EnOdJQn/kK/g70aYk2kZia2C+YxnEX1p8i+tRg5fOQ==","shasum":"6b46afde1f692f06c5fa0e09b08286d5829e2e8b","tarball":"https://registry.npmjs.org/@abra-promotions/headless/-/headless-1.0.0.tgz","fileCount":194,"unpackedSize":615882,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIAzy4QUkql166uKal6DCOvN4ywEoAHyTPzaRw3ecqvFYAiAi+SLjLKbvhR3HEiQI2cSLKwH8m0fqddihAkuoHXwLVQ=="}]},"_npmUser":{"name":"danielpatricio","email":"daniel@abrapromotions.com"},"directories":{},"maintainers":[{"name":"danielpatricio","email":"daniel@abrapromotions.com"},{"name":"alon-mota-abra","email":"alon@abrapromotions.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/headless_1.0.0_1787836376046_0.34914517750078633"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-26T18:33:03.247Z","modified":"2026-08-27T13:12:56.374Z","0.1.0":"2026-06-26T18:33:03.557Z","0.2.0":"2026-07-16T14:57:45.752Z","0.2.1":"2026-07-16T15:53:35.258Z","1.0.0":"2026-08-27T13:12:56.214Z"},"bugs":{"url":"https://github.com/merchantinresidence/abra-headless-banners/issues"},"author":{"name":"Abra"},"license":"Apache-2.0","homepage":"https://github.com/merchantinresidence/abra-headless-banners#readme","keywords":["shopify","hydrogen","headless","abra","promotions","banner","gift-with-purchase"],"repository":{"type":"git","url":"git+https://github.com/merchantinresidence/abra-headless-banners.git"},"description":"Abra tier & gift promotion banners and dynamic pricing for headless Hydrogen / React storefronts. Reads storefront-readable metafields via your own Storefront API — no Abra API token.","maintainers":[{"name":"danielpatricio","email":"daniel@abrapromotions.com"},{"name":"alon-mota-abra","email":"alon@abrapromotions.com"}],"readme":"# @abra-promotions/headless\n\nRender Abra's promotion banners, compute Abra discounted prices, and replicate the theme extension's cart behaviour (attribution, discounts, gifts, bundles) in a headless [Hydrogen](https://hydrogen.shopify.dev/) / React storefront. Ships prebuilt web components plus small typed React adapters, a pricing helper, and cart-action tools.\n\n**No Abra API token is needed.** The package reads the active promotion from your shop's storefront-readable metafields through your own Storefront API. Supports gift-tiered (`MultiEffectTiers`), gift-with-purchase (`Gift`), buy-X-get-Y (`BxgyDiscount`), and volume/spend tiers. Single currency / locale.\n\nThe HTML banner, announcement bar, and dynamic text load for **any** discount type (matching the theme extension); only the gift/tiered banners need one of the types above. Shopify-native BXGY promotions (`BXGY` / `DiscountCodeBxgy`) get config banners only — identical to the theme extension, whose native-BXGY support is display-only (Shopify itself manages the free item at checkout, and the promotion metafield carries no gift data).\n\n## Contents\n\n- [Install](#install)\n- [Getting started](#getting-started)\n- [Banner components](#banner-components)\n  - [`money` config](#money-config) · [GiftTieredBanner](#gifttieredbanner) · [TieredBanner](#tieredbanner) · [HtmlBanner](#htmlbanner) · [AnnouncementBar](#announcementbar) · [DynamicText](#dynamictext) · [AffiliateBanner](#affiliatebanner)\n- [Pricing](#pricing)\n- [Cart actions](#cart-actions)\n  - [useAbraCartActions](#3-use-the-hook) · [getAbraCartPlan](#full-cart-control-getabracartplan) · [useAbraCartSync](#let-the-package-keep-the-cart-in-sync-useabracartsync) · [getAbraBundlePlan](#bxgy-bundles-getabrabundleplan)\n- [Styling](#styling)\n- [Security & trust model](#security--trust-model)\n- [Contributing](#contributing)\n\n---\n\n## Install\n\n```bash\nnpm install @abra-promotions/headless\n```\n\n`react` and `react-dom` are peer dependencies — your app already provides them.\n\n> **Prerequisite — Tapcart must be enabled for the promotion in Abra.** That is what publishes the promotion to the storefront-readable metafields this package reads. If a promotion was set up _before_ Tapcart was enabled, re-save it in Abra so the metafields get written — otherwise there is nothing to read and the banners render nothing.\n\n---\n\n## Getting started\n\nEverything is driven by promotion data read from your storefront. Wire that up once in a route loader using the Hydrogen `context`, then the components and the pricing helper have what they need.\n\n### 1. Fetch promotion data in a route loader\n\nCall the loader helpers on the route the banner appears on (a product route, or `root` so it's available everywhere) and return the result at the **top level**:\n\n```ts\nimport type {LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {loadAbraBannerData} from '@abra-promotions/headless/loader';\nimport {loadAbraPricingData} from '@abra-promotions/headless/pricing';\n\nexport async function loader({context}: LoaderFunctionArgs) {\n  // `context` carries the Storefront API client. The 2nd arg is the promotionKey:\n  // 'PUBLIC' is the auto-applied public promotion; pass a code or a link param to\n  // target a specific promotion.\n  const abra = await loadAbraBannerData(context, 'PUBLIC');\n  const pricingData = await loadAbraPricingData(context, {\n    promotionKey: 'PUBLIC',\n  });\n\n  // Keep `abraBanner` at the top level so components can read useLoaderData().abraBanner.\n  return {...(abra ?? {abraBanner: null}), pricingData};\n}\n```\n\n`loadAbraBannerData` returns `{abraBanner}` (or `null` when there's no active promotion). `abraBanner` holds everything the banners consume:\n\n```ts\nabraBanner: {\n  discountValue, discountTitle, giftProducts, giftPriceMap, configStates,\n  htmlBanner,        // → HtmlBanner      `content`\n  announcementBar,   // → AnnouncementBar `content`\n  dynamicText,       // → DynamicText     `content`\n}\n```\n\n`ABRA_APP_ID` is also exported from `@abra-promotions/headless/loader` if you need it.\n\n#### Promotion keys\n\nThe second argument to `loadAbraBannerData` / the `promotionKey` option is one of:\n\n- **`'PUBLIC'`** (the default) — the auto-applied public promotion. Most stores only need this.\n- **a discount code** (e.g. `'SUMMER20'`) — targets the promotion behind that code, typically read from a `?code=` / referral-link param.\n\nThere is no API to _enumerate_ a store's promotions — you target them by a key you already know (`'PUBLIC'` or your own discount codes). If you want to showcase several (e.g. a picker), call the loader once per key and keep the results in an array.\n\n#### TypeScript types\n\nThe loader and pricing entry points ship types for everything they return — import them instead of re-declaring shapes by hand:\n\n```ts\nimport type {\n  AbraBannerData,\n  AbraBanner,\n} from '@abra-promotions/headless/loader';\nimport type {AbraPricingData} from '@abra-promotions/headless/pricing';\n// AbraBannerData is the loader's `{abraBanner}` wrapper; AbraBanner is the `abraBanner` object itself.\n```\n\n> **Remix/`useLoaderData` casting.** Loader data is JSON-serialized, so `useLoaderData<typeof loader>()` returns Remix's `Jsonify<…>` wrappers. These are structurally compatible at runtime but **not** assignable to the package's exported types under `tsc`. Cast at the boundary — `pricingData as AbraPricingData`, and the banner `config` props (see each component below).\n\n### 2. Render\n\nPick the [component](#banner-components) for your promotion and feed it the loader data. Only [`AffiliateBanner`](#affiliatebanner) needs no loader. Pass the **resolved** cart (use `useOptimisticCart`) to cart-aware banners:\n\n```tsx\nimport {useLoaderData} from 'react-router';\nimport {useOptimisticCart} from '@shopify/hydrogen';\nimport {GiftTieredBanner} from '@abra-promotions/headless';\n\nconst {abraBanner} = useLoaderData<typeof loader>();\nconst cart = useOptimisticCart(rawCart);\n// → see the GiftTieredBanner section for the full config + onAddGift wiring.\n```\n\n### 3. Verify\n\nOpen a page with an active, Tapcart-enabled promotion — the banner shows tier/gift progress. Add an eligible product and progress advances; claim a gift and it's added to the cart.\n\n> **Client-only.** The web-component bundle touches `window`/`customElements` on import, so a top-level import would crash the server render. Every wrapper lazy-loads the bundle inside a `useEffect` — no work needed from you. The `<abra-*>` custom-element tag _is_ emitted during SSR (with its resolved style props), but stays empty until the bundle upgrades it on the client. Do **not** add a top-level `import '@abra-promotions/headless/bundle'`.\n\n---\n\n## Banner components\n\n| Component                               | Use when                                                   | Data                       | Transacts?                                            |\n| --------------------------------------- | ---------------------------------------------------------- | -------------------------- | ----------------------------------------------------- |\n| [`GiftTieredBanner`](#gifttieredbanner) | Multi-effect tiered progress with an earnable gift         | Loader                     | Yes — `onAddGift` adds the gift                       |\n| [`TieredBanner`](#tieredbanner)         | Volume / spend discount tiers, or classic tiered discounts | Loader                     | No — Yes with `onAddGift` for classic FREE_GIFT tiers |\n| [`HtmlBanner`](#htmlbanner)             | A merchant-configured HTML snippet                         | Loader (`htmlBanner`)      | No                                                    |\n| [`AnnouncementBar`](#announcementbar)   | Top-of-page announcement strip                             | Loader (`announcementBar`) | No                                                    |\n| [`DynamicText`](#dynamictext)           | Inline text with live promotion/price tokens               | Loader (`dynamicText`)     | No                                                    |\n| [`AffiliateBanner`](#affiliatebanner)   | \"Recommended by\" referral banner                           | Props only                 | No                                                    |\n\nEvery banner dispatches `…:show` / `…:hide` events on `window` (event name per banner below) and renders nothing until the bundle is client-loaded. `name` defaults to `'default'` and scopes the event name.\n\n### `money` config\n\nCart-aware banners (`GiftTieredBanner`, `TieredBanner`, `DynamicText`) take a `money` prop for currency formatting:\n\n```ts\nmoney={{\n  currencyCode: 'USD', // e.g. 'USD', 'AUD'\n  symbol: '$',\n  zeroLabel: '$0.00',  // shown when an amount is zero\n  freeLabel: 'Free',   // shown when an item is free\n}}\n```\n\n### GiftTieredBanner\n\nMulti-effect tiered-discount progress with a gift the customer can add to cart.\n\n```tsx\nimport {GiftTieredBanner} from '@abra-promotions/headless';\n\n<GiftTieredBanner\n  config={{\n    discountValue: abraBanner.discountValue,\n    discountTitle: abraBanner.discountTitle,\n    giftProducts: abraBanner.giftProducts,\n    priceMap: abraBanner.giftPriceMap, // serializable, pass straight through\n    configStates: abraBanner.configStates,\n  }}\n  hydrogenCart={cart}\n  money={money}\n  onAddGift={handleAddGift}\n  rewardTierDisplay=\"next\"\n/>;\n```\n\n| Prop                | Type                                           | Required | Notes                                                                                                                                                                                                               |\n| ------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `config`            | `GiftTieredBannerConfig`                       | Yes      | `{discountValue, discountTitle, giftProducts, priceMap, configStates?}` — all from the loader. `priceMap` is the loader's `giftPriceMap` (cents, keyed `` `${handle}:${variantId}` ``).                             |\n| `hydrogenCart`      | `unknown`                                      | Yes      | The resolved Hydrogen cart (`useOptimisticCart`).                                                                                                                                                                   |\n| `money`             | `MoneyConfig`                                  | Yes      | See [`money` config](#money-config).                                                                                                                                                                                |\n| `onAddGift`         | `(variantId: string) => Promise<void> \\| void` | Yes      | Claim handler — adds the gift variant to the cart with the `__abra` line attribute. Abra's Shopify Function reads it to make the gift free, and the banner reads it back to detect the claim. See the wiring below. |\n| `rewardTierDisplay` | `'current' \\| 'next'`                          | No       | Default `'next'` (keeps a goal on screen after a claim); `'current'` emphasizes the earned tier.                                                                                                                    |\n| `styles`            | `GiftTieredBannerStyles`                       | No       | Per-instance style overrides.                                                                                                                                                                                       |\n\nEvents: `abra:gift-tiered-banner:${name}:show` / `:hide`. Auto-removing stale gift lines when the customer drops below a threshold is wired separately — see `findGiftLineIdsToRemove` and `isGiftEarned` (exported from the root).\n\n#### Wiring `onAddGift`\n\n`onAddGift` must add the chosen variant with a `__abra` line attribute whose value is\n`JSON.stringify({discount: discountTitle})`. The easiest wiring is the cart-actions hook — see\n[Cart actions](#cart-actions):\n\n```tsx\nimport {useFetcher} from 'react-router';\nimport {useAbraCartActions} from '@abra-promotions/headless/cart';\n\nconst fetcher = useFetcher();\nconst {addGift} = useAbraCartActions({\n  promotion: abraCart,\n  storeDomain,\n  submit: fetcher.submit,\n  cartId: cart?.id,\n});\n\n<GiftTieredBanner /* … */ onAddGift={addGift} />;\n```\n\nIf the promotion has more than one discount, tag the line with the gift discount's own title\n(the loader's `abraBanner.discountTitle`) — that's the title Abra's function matches when\nzeroing the line: `onAddGift={(id) => addGift(id, abraBanner.discountTitle)}`.\n\nTo hand-roll it instead, build the line with `buildAbraGiftLines(variantId, discountTitle)` from\n`@abra-promotions/headless/cart` and submit it as a `LinesAdd` `CartForm` payload through a\nfetcher.\n\n> **TypeScript:** `abraBanner.discountValue` is a union, but `config.discountValue` here wants the multi-effect-tiered member specifically. Combined with the `Jsonify` wrapper from `useLoaderData` (see [TypeScript types](#typescript-types)), cast the config: `config={{…} as unknown as GiftTieredBannerConfig}`.\n\n### TieredBanner\n\nVolume / spend discount tiers (e.g. \"spend $X for Y% off\") **and classic tiered discounts (`TieredDiscount`), including FREE_GIFT tiers.** Display-only unless you pass `onAddGift`.\n\n```tsx\nimport {TieredBanner} from '@abra-promotions/headless';\n\n<TieredBanner\n  config={{\n    discountValue: abraBanner.discountValue,\n    discountTitle: abraBanner.discountTitle,\n  }}\n  hydrogenCart={cart}\n  money={money}\n  rewardTierDisplay=\"next\"\n/>;\n```\n\n| Prop                | Type                                           | Required | Notes                                                                                                                                                                                                                                                                                                                                                              |\n| ------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `config`            | `TieredBannerConfig`                           | Yes      | `{discountValue, discountTitle}` (volume-tier or classic-tier discount value). `discountValue` is the tiered member of the loader union — with the `useLoaderData` `Jsonify` wrapper, cast: `config={{…} as unknown as TieredBannerConfig}`. `giftProducts` (optional) — pass `abraBanner.giftProducts` for classic FREE_GIFT tiers so the banner shows the gifts. |\n| `hydrogenCart`      | `unknown`                                      | Yes      | Resolved Hydrogen cart.                                                                                                                                                                                                                                                                                                                                            |\n| `money`             | `MoneyConfig`                                  | Yes      | See [`money` config](#money-config).                                                                                                                                                                                                                                                                                                                               |\n| `rewardTierDisplay` | `'current' \\| 'next'`                          | No       | Default `'next'`.                                                                                                                                                                                                                                                                                                                                                  |\n| `onAddGift`         | `(variantId: string) => Promise<void> \\| void` | No       | Gift-claim handler for classic FREE_GIFT tiers — same wiring as GiftTieredBanner's. Omit for display-only tiers.                                                                                                                                                                                                                                                   |\n| `styles`            | `TieredBannerStyles`                           | No       | Per-instance style overrides.                                                                                                                                                                                                                                                                                                                                      |\n\nEvents: `abra:tiered-banner:${name}:show` / `:hide`.\n\n### HtmlBanner\n\nRenders the promotion's HTML block. The content comes from the loader (`abraBanner.htmlBanner`).\n\n```tsx\nimport {HtmlBanner} from '@abra-promotions/headless';\n\n<HtmlBanner content={abraBanner.htmlBanner} />;\n```\n\n| Prop       | Type                        | Required | Notes                                                                                                                        |\n| ---------- | --------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `content`  | `HtmlBannerContent \\| null` | Yes      | `{html}` from the loader. Hides when null/empty.                                                                             |\n| `sanitize` | `(html: string) => string`  | No       | Applied before render. Markup renders with `unsafeHTML` (no built-in sanitization) — see [Security](#security--trust-model). |\n| `blockId`  | `string`                    | No       | Forwarded to the element as `block-id`.                                                                                      |\n\nEvents: `abra:html-banner:${name}:show` / `:hide`.\n\n### AnnouncementBar\n\nTop-of-page announcement strip. Content comes from the loader (`abraBanner.announcementBar`).\n\n```tsx\nimport {AnnouncementBar} from '@abra-promotions/headless';\n\n<AnnouncementBar content={abraBanner.announcementBar} target=\"body\" />;\n```\n\n| Prop      | Type                             | Required | Notes                                                                                                                             |\n| --------- | -------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `content` | `AnnouncementBarContent \\| null` | Yes      | `{text, icon?, link?}` from the loader. `link` is sanitized (see [Security](#security--trust-model)). Hides when `text` is empty. |\n| `target`  | `'section' \\| 'body'`            | No       | Where the bar inserts in the DOM.                                                                                                 |\n| `blockId` | `string`                         | No       | Forwarded as `block-id`.                                                                                                          |\n\n> **Name-less event.** Unlike the others, `AnnouncementBar` dispatches a fixed `abra:announcement-bar:show` / `:hide` with **no** `${name}` segment.\n\n### DynamicText\n\nInline text with live promotion/price token substitution. Content comes from the loader (`abraBanner.dynamicText`); the cart drives the live token values.\n\n```tsx\nimport {DynamicText} from '@abra-promotions/headless';\n\n<DynamicText\n  content={abraBanner.dynamicText}\n  hydrogenCart={cart}\n  money={money}\n  align=\"center\"\n/>;\n```\n\n| Prop           | Type                            | Required | Notes                                                                                            |\n| -------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------ |\n| `content`      | `DynamicTextContent \\| null`    | Yes      | `{states, discountTitle, prerequisite?}` from the loader. Hides when the resolved text is empty. |\n| `hydrogenCart` | `unknown`                       | Yes      | Resolved Hydrogen cart (drives the live tokens).                                                 |\n| `money`        | `MoneyConfig`                   | Yes      | See [`money` config](#money-config).                                                             |\n| `align`        | `'left' \\| 'center' \\| 'right'` | No       | Text alignment.                                                                                  |\n| `blockId`      | `string`                        | No       | Forwarded as `block-id`.                                                                         |\n\nEvents: `abra:dynamic-text:${name}:show` / `:hide`.\n\n### AffiliateBanner\n\nA \"recommended by\" referral banner. The **only** banner with no loader — pass affiliate fields directly (typically read from a URL param or cookie set by the referral link).\n\n```tsx\nimport {AffiliateBanner} from '@abra-promotions/headless';\n\n<AffiliateBanner\n  affiliate={{\n    recommendedBy: 'Jane Doe',\n    message: 'Jane recommends this store!',\n    discountMessage: 'Use code JANE10 for 10% off',\n    discountCode: 'JANE10',\n  }}\n  align=\"center\"\n/>;\n```\n\n| Prop        | Type                            | Required | Notes                                                                                                                                                                                                   |\n| ----------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `affiliate` | `AffiliateConfig`               | Yes      | `{recommendedBy?, fullName?, referralLink?, avatarUrl?, message?, discountMessage?, discountCode?}`. `referralLink` is sanitized. Hides unless `recommendedBy`, `message`, or `discountMessage` is set. |\n| `align`     | `'left' \\| 'center' \\| 'right'` | No       | Text alignment.                                                                                                                                                                                         |\n| `blockId`   | `string`                        | No       | Forwarded as `block-id`.                                                                                                                                                                                |\n| `styles`    | `AffiliateBannerStyles`         | No       | Per-instance style overrides.                                                                                                                                                                           |\n\nEvents: `abra:banner:${name}:show` / `:hide`.\n\n---\n\n## Pricing\n\n`@abra-promotions/headless/pricing` computes the Abra discounted price for a product variant given the current cart — the same engine Abra applies at checkout, so your PDP / cart UI matches. No banner is involved.\n\n### loadAbraPricingData\n\n```ts\nimport {loadAbraPricingData} from '@abra-promotions/headless/pricing';\n\nconst pricingData = await loadAbraPricingData(context, {\n  promotionKey: 'PUBLIC',\n});\n```\n\n`(context, opts?)` → `Promise<AbraPricingData | null>`. `opts`: `{promotionKey?: string; namespace?: string; logger?: PricingLogger}` (`promotionKey` defaults to `'PUBLIC'`). Returns `null` when there's no active pricing promotion. Call it in your loader (see [Getting started](#getting-started)).\n\n### getAbraDiscountedPrice\n\n```ts\nimport {getAbraDiscountedPrice} from '@abra-promotions/headless/pricing';\n\nconst result = getAbraDiscountedPrice({\n  pricingData, // from loadAbraPricingData\n  product: {\n    handle: product.handle,\n    variants: product.variants.map((v) => ({\n      id: v.id,\n      price: v.price, // {amount, currencyCode}\n      compareAtPrice: v.compareAtPrice ?? null,\n    })),\n  },\n  variantId: selectedVariantId, // optional; defaults to the first variant\n  cart, // the Hydrogen cart\n});\n```\n\n**Input** — `GetAbraDiscountedPriceInput`:\n\n| Field         | Type                      | Required | Notes                                                                                                                                                                                                    |\n| ------------- | ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `pricingData` | `AbraPricingData`         | Yes      | From `loadAbraPricingData`.                                                                                                                                                                              |\n| `product`     | `AbraPricingProductInput` | Yes      | `{handle, variants: [{id, price, compareAtPrice?}]}`.                                                                                                                                                    |\n| `variantId`   | `string \\| null`          | No       | Which variant to price; defaults to the first.                                                                                                                                                           |\n| `cart`        | `HydrogenCart \\| null`    | Yes      | Drives cart-gated discounts (volume / tiers / BXGY). Pass `null` (or the unresolved cart) for a fresh visitor with no cart — only cart-gated discounts are skipped; product-level discounts still apply. |\n| `runtime`     | `PricingRuntime`          | No       | Custom logger / runtime (`createRuntime`, `NOOP_LOGGER` exported).                                                                                                                                       |\n\n**Returns** — `AbraDiscountedPrice`:\n\n| Field              | Type                           | Notes                                                                   |\n| ------------------ | ------------------------------ | ----------------------------------------------------------------------- |\n| `discounted`       | `boolean`                      | Whether any Abra discount applied.                                      |\n| `original`         | `AbraMoney`                    | Price before Abra discounts.                                            |\n| `final`            | `AbraMoney`                    | Price after Abra discounts.                                             |\n| `discountAmount`   | `AbraMoney`                    | `original − final`.                                                     |\n| `compareAt`        | `AbraMoney`                    | Effective compare-at to strike through.                                 |\n| `appliedDiscounts` | `{id, title, discountClass}[]` | The Abra discounts that applied (`'PRODUCT' \\| 'ORDER' \\| 'SHIPPING'`). |\n\n`AbraMoney` is `{cents, amount, currencyCode}`.\n\n---\n\n## Cart actions\n\n`@abra-promotions/headless/cart` emulates the cart behaviour of Abra's theme-extension SDK:\npromotion **attribution** (so orders count toward the promotion in Abra), **discount-code\napplication**, **gift lines** (add, auto-remove, quantity sync), **gift-state sync**, and\n**fixed-price BXGY bundles**. Same trust model as the rest of the package — everything comes\nfrom storefront-readable metafields; no Abra API token.\n\nThree ways to consume it, from most to least batteries-included: `useAbraCart` (one hook —\nactivation on landing, gift adds, and cart sync wired together), the individual hooks\n(`useAbraCartActions`/`useAbraCartSync`), or the pure planners (`getAbraCartPlan`,\n`getAbraBundlePlan`) when you want a diff of what the cart needs and full control over\napplying it.\n\n### 1. Load cart data in your loader\n\n```ts\nimport {loadAbraCartData} from '@abra-promotions/headless/cart';\n\nexport async function loader({context}: LoaderFunctionArgs) {\n  const abraCart = await loadAbraCartData(context, {promotionKey: 'PUBLIC'});\n  return {abraCart /*, ...banner/pricing data */};\n}\n```\n\nReturns `AbraCartPromotion | null`: `{promotionId, promotionTitle, code, discountTitles,\nabraDiscountSlugs, abraStorefrontToken}`. `discountTitles` are what actually get applied to the\ncart (via `getAbraDiscountCodes`/`cartDiscountCodesUpdate`); `abraDiscountSlugs` are Abra's\ninternal discount slugs (`$app:discounts` metafield keys) and are **not** codes you can apply —\nthey're exposed for reference only. (`discountCodes` is a deprecated alias of\n`abraDiscountSlugs`, kept until the next major.)\n`abraStorefrontToken` is Abra's storefront token (published\nto a shop metafield by the Abra app); when it's absent, cart-metafield writes are skipped — the\n`__abra` cart attribute still attributes orders.\n\n### 2. Wire your cart route and cart query\n\n`activatePromotion`/`deactivatePromotion` submit a single custom CartForm action\n(`AbraPromotionUpdate`). Handle it with one call at the top of your `/cart` route's `action` —\nit returns `null` for every other action, so your existing switch is untouched:\n\n```ts\nimport {handleAbraCartAction} from '@abra-promotions/headless/cart';\n\nexport async function action({request, context}: ActionFunctionArgs) {\n  const {cart} = context;\n  const {action, inputs} = CartForm.getFormInput(await request.formData());\n\n  let result = await handleAbraCartAction(cart, action, inputs);\n  if (!result) {\n    switch (\n      action\n      // ...your existing cases\n    ) {\n    }\n  }\n  // ...set cart id from result, return json — unchanged\n}\n```\n\nIt updates attributes + discount codes, and on a fresh session **creates** the cart carrying\nthe attribution — Hydrogen's handlers never create a cart on their own, so without that branch\nactivation on landing (before anything is in the cart) would silently do nothing. It's kept as\na plain call rather than a switch case on purpose: Hydrogen types custom cart actions as\n`` `Custom${string}` ``, which makes a hand-written `case 'AbraPromotionUpdate':` a TS error.\n\nYour cart query must return `lines { nodes }` plus cart-level and line-level `attributes` and\n`discountCodes { code applicable }`. For promotions that target one-time vs subscription\npurchases, also include line-level `sellingPlanAllocation { sellingPlan { id } }` — without it\nevery line is treated as a one-time purchase. Hydrogen's built-in default fragment returns\n`lines { edges }` — Abra's cart mappers tolerate that, but Hydrogen's own `useOptimisticCart`\ncrashes on it, so pass the exported known-good fragment to `createCartHandler`:\n\n```ts\nimport {ABRA_CART_QUERY_FRAGMENT} from '@abra-promotions/headless/cart';\n\nconst cart = createCartHandler({\n  storefront,\n  getCartId: cartGetIdDefault(request.headers),\n  setCartId: cartSetIdDefault(),\n  cartQueryFragment: ABRA_CART_QUERY_FRAGMENT,\n});\n```\n\n(`addGift` and the sync hook use the standard `LinesAdd`/`LinesRemove`/`LinesUpdate` actions —\nno further route changes needed.)\n\n### 3. Use the hook\n\nFor Hydrogen storefronts, one hook does the whole job — activates the promotion on landing,\nkeeps the cart in sync (stale-gift removal, quantity corrections, auto-adds), and hands you the\ngift-claim callback with the right discount title already bound:\n\n```tsx\nimport {useFetcher} from 'react-router';\nimport {useAbraCart} from '@abra-promotions/headless/cart';\n\nconst fetcher = useFetcher();\nconst {onAddGift, deactivatePromotion, plan} = useAbraCart({\n  promotion: abraCart, // from loadAbraCartData\n  banner: abraBanner, // from loadAbraBannerData (optional)\n  cart, // the RAW loader cart, not useOptimisticCart's\n  storeDomain: 'your-shop.myshopify.com', // e.g. env.PUBLIC_STORE_DOMAIN\n  submit: fetcher.submit,\n});\n\n<GiftTieredBanner /* … */ onAddGift={onAddGift} />;\n```\n\nActivation fires once per promotion (set `autoActivate: false` to call `activatePromotion()`\nyourself), customer-typed coupons survive both activation and deactivation, and `plan` is the\ncurrent `getAbraCartPlan` result if you want to render from it.\n\nPrefer wiring the pieces yourself? The actions hook underneath:\n\n```tsx\nimport {useFetcher} from 'react-router';\nimport {useAbraCartActions} from '@abra-promotions/headless/cart';\n\nconst fetcher = useFetcher();\nconst {activatePromotion, deactivatePromotion, addGift} = useAbraCartActions({\n  promotion: abraCart, // from the loader\n  storeDomain: 'your-shop.myshopify.com', // e.g. env.PUBLIC_STORE_DOMAIN\n  submit: fetcher.submit,\n  cartId: cart?.id, // enables metafield attribution parity\n  redeemCode, // optional referral/redeem code\n});\n```\n\n- `activatePromotion()` — writes the `__abra` cart attribute, applies the promotion's discount\n  codes, and mirrors attribution to cart metafields. Call once when the storefront decides the\n  promotion is active (e.g. on landing with a promo link). With `currentDiscountCodes` set,\n  codes the customer typed themselves are kept; codes a previous activation applied (recorded\n  per session) are swapped out.\n- `addGift(variantId, discountTitle?, sellingPlanId?)` — adds the gift with the `__abra` line\n  attribute (Abra's Shopify Function makes it free). Pass it as `GiftTieredBanner`'s `onAddGift`.\n  When the promotion has several discounts, pass the **gift discount's** title — the function only\n  zeroes lines tagged with its own discount's title (`plan.addableGifts[].lines` already carries\n  it). For SUBSCRIPTION-purchase-type gifts also pass `plan.addableGifts[].sellingPlanId` — a\n  subscription gift added without its selling plan stays at full price.\n- `deactivatePromotion()` — clears the attribute, discount codes, and metafields. Pass the\n  hook's `currentDiscountCodes` option (`cart.discountCodes.map((d) => d.code)`) so only Abra's\n  codes are removed and a coupon the customer typed themselves survives; without it, all codes\n  are cleared.\n\nPrefer plain functions? Everything the hook does is exported: `buildAbraCartAttributes`,\n`buildClearAbraCartAttributes`, `getAbraDiscountCodes`, `buildAbraGiftLines`,\n`setAbraCartMetafields`, `clearAbraCartMetafields`, `syncAbraGiftState`, plus\n`getOrCreateAttributedAt`. Gift auto-removal (`findGiftLineIdsToRemove`, `isGiftEarned`) and\n`mapHydrogenCart` are re-exported here too.\n\n### Full cart control: `getAbraCartPlan`\n\nIf you don't want anything — hook included — touching your cart, one pure function returns the\nwhole diff for a given cart state and you apply it however you like:\n\n```ts\nimport {getAbraCartPlan} from '@abra-promotions/headless/cart';\n\nconst plan = getAbraCartPlan({\n  promotion: abraCart, // from loadAbraCartData (or null: skip attribution/codes)\n  banner: abraBanner, // from loadAbraBannerData (or null: skip the gift diff)\n  cart: rawCart, // your Hydrogen cart (lines.nodes or lines.edges both work)\n  redeemCode, // optional\n});\n\nplan.attributes; // cart attributes to set (cartAttributesUpdate)\nplan.discountCodes; // discount codes to apply (cartDiscountCodesUpdate)\nplan.lineIdsToRemove; // stale/excess gift lines to remove (cartLinesRemove)\nplan.lineQuantityUpdates; // in-cart gift lines to re-quantify (cartLinesUpdate) — compound /\n//   multi-quantity gifts and cumulative tier gifts\nplan.addableGifts; // gifts earned but not in the cart:\n//   {variantId, handle, productTitle, variantTitle, autoAdd,\n//    lines, swapLineIdsToRemove, swapLineQuantityUpdates}\n//   `lines` is a ready cartLinesAdd input with the __abra attribute,\n//   already scaled to the earned gift units\nplan.giftEligible; // whether the gift threshold is currently met\nplan.preventAutoReAdd; // merchant setting: never re-add a gift the shopper removed by hand\nplan.state; // raw engine state (e.g. 'PENDING_SELECTION', 'SUCCESS', 'TIER_1')\nplan.giftState; // raw gift-tier state for MultiEffectTiers promotions (else null)\n```\n\nGift behaviour matches the theme extension's decision logic:\n\n- **Quantities** — gift-with-purchase units follow `compound` and the discount's per-award\n  `quantity`; gift-tier (`MultiEffectTiers`) units accumulate across the tiers the cart has\n  achieved. `lineIdsToRemove`/`lineQuantityUpdates` keep in-cart gift lines at exactly the\n  earned units (a claimed tier gift is removed when the cart falls back below every tier).\n- **Swaps** — when claiming a variant would exceed the shared gift-unit limit (a different\n  variant already holds it), that gift's `swapLineIdsToRemove`/`swapLineQuantityUpdates` say\n  what must give way. **Apply them together with the add** — otherwise the second gift line\n  stays at full price, because Abra's function only zeroes one gift line.\n- **Auto-add** — `autoAdd` marks gifts the theme would add without asking (the offer has exactly\n  one product with one variant; nothing to select).\n- **Re-add policy** — if you auto-add gifts yourself, honour `plan.preventAutoReAdd` using the\n  exported tracking helpers: `trackAbraGiftAdded(variantId, discountTitle)` after every add, and\n  skip the add when `wasAbraGiftAdded(...)` is true (added recently but now absent = the shopper\n  removed it). `useAbraCartSync` and `addGift` do both automatically. Sold-out gift variants are\n  already pruned by the loader (`availableForSale`).\n\nNo mutations, no React — call it wherever you react to cart changes (server or client) and feed\nthe results to your own cart handling. `money` is optional and only shapes progress strings.\n\n### Let the package keep the cart in sync: `useAbraCartSync`\n\nThe automatic half of the plan can run hands-off — pass the current plan and the hook submits\nthe cart mutations the theme extension would make on its own, through your fetcher, whenever\nthe diff changes (each distinct diff is submitted once; the revalidated cart produces the next\nplan):\n\n```tsx\nimport {getAbraCartPlan, useAbraCartSync} from '@abra-promotions/headless/cart';\n\nconst plan = getAbraCartPlan({promotion: abraCart, banner: abraBanner, cart});\nuseAbraCartSync({plan, submit: fetcher.submit}); // cartAction defaults to '/cart'\n```\n\nBuild the plan from the **loader's cart**, not `useOptimisticCart`'s result: an optimistic cart\nalready shows the correction applied while the submit is in flight, so the plan flickers empty\nand corrections lag one revalidation behind.\n\nPer pass it applies, in priority order: stale/excess gift removals (`LinesRemove`), gift\nquantity corrections (`LinesUpdate`), then — when nothing needs correcting — auto-adding a\nno-selection-needed gift (`LinesAdd`), honouring `preventAutoReAdd` via the gift tracking cache\n(a gift the shopper removed by hand stays removed). Pass `autoAddGifts: false` to keep gifting\nstrictly claim-based. Uses only standard CartForm actions — no cart-route changes needed.\nSelection-required gifts are never auto-added; those stay explicit via `addGift`.\n\n### BXGY bundles: `getAbraBundlePlan`\n\nFixed-price BXGY promotions (\"shirt + hat for $50\") are assembled by Abra's cart-transform\nfunction from two pieces of cart state: a `_abra_bxgy_bundle` cart attribute (the bundle\nconfigs) and `_abra_bundled` line-attribute markers grouping the component lines.\n`getAbraBundlePlan` maintains both, declaratively:\n\n```ts\nimport {getAbraBundlePlan} from '@abra-promotions/headless/cart';\n\nconst bundlePlan = getAbraBundlePlan({\n  discountValue: abraBanner.discountValue, // must be the BxgyDiscount value\n  discountTitle: abraBanner.discountTitle,\n  cart: rawCart, // include cart `attributes` in your cart query\n});\n\nbundlePlan.attribute; // _abra_bxgy_bundle write (cartAttributesUpdate); '' clears; null = no change\nbundlePlan.linesToUpdate; // marker sets/clears (cartLinesUpdate; full attribute replacement)\nbundlePlan.bundlesEarned; // how many bundles the cart currently earns\n```\n\nWhen the cart earns the bundle it marks the component lines and writes the config (the\ntransform then merges them into one fixed-price line); when it stops qualifying it clears this\npromotion's markers and config, leaving other promotions' bundles untouched. Marker allocation\nis whole-line: quantity stacked on a single line can't split across two bundle groups — add\nitems as separate lines (the storefront default) for multi-bundle carts. Percentage/amount BXGY\nneeds none of this (the discount functions price it directly); the plan is inert for those.\n\n---\n\n## Styling\n\nBanners are styled out of the box — the stylesheet ships in the bundle and self-injects on import. To match your brand, override the CSS custom properties (e.g. on `:root`):\n\n```css\n:root {\n  --abra-gift-tiered-banner-background-color: #fff;\n  --abra-gift-tiered-progress-bar-active-color: #119e52;\n  /* …see each element's CSS for its full token set */\n}\n```\n\nFor per-instance overrides, most components accept a `styles` prop (`resolveBannerStyles` and the `*Styles` types are exported from the root).\n\n---\n\n## Security & trust model\n\nBanner content (text, links, the HTML banner's markup) comes from your store's promotion **metafields**, configured in the Abra app. The package treats that content as **merchant-controlled and trusted**, with two guardrails:\n\n- **Links** (announcement-bar `link`, affiliate `referralLink`) pass through `sanitizeUrl`, which permits only `http`, `https`, `mailto`, `tel`, and relative / anchor URLs. Dangerous schemes like `javascript:` and `data:` are dropped. `sanitizeUrl` is exported for reuse.\n- **HTML banner** markup is rendered as-is via `unsafeHTML`. If your metafield content is **not** fully under your control (user-generated or third-party submissions), pass a `sanitize` function to strip scripts before render:\n\n  ```tsx\n  import DOMPurify from 'dompurify';\n\n  <HtmlBanner\n    content={abraBanner.htmlBanner}\n    sanitize={(html) => DOMPurify.sanitize(html)}\n  />;\n  ```\n\n  No sanitizer is bundled, so the dependency isn't forced on every consumer.\n\nIf you store anything other than trusted merchant content in these metafields, sanitize or validate it upstream before it reaches the banners.\n\n---\n\n## Contributing\n\nBuild, test, and release instructions for maintainers live in [CONTRIBUTING.md](./CONTRIBUTING.md).\n","readmeFilename":"README.md"}