npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@abra-promotions/headless

v0.2.1

Published

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.

Downloads

414

Readme

@abra-promotions/headless

Render Abra's promotion banners, compute Abra discounted prices, and replicate the theme extension's cart behaviour (attribution, discounts, gifts, bundles) in a headless Hydrogen / React storefront. Ships prebuilt web components plus small typed React adapters, a pricing helper, and cart-action tools.

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.

The 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).

Contents


Install

npm install @abra-promotions/headless

react and react-dom are peer dependencies — your app already provides them.

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.


Getting started

Everything 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.

1. Fetch promotion data in a route loader

Call 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:

import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {loadAbraBannerData} from '@abra-promotions/headless/loader';
import {loadAbraPricingData} from '@abra-promotions/headless/pricing';

export async function loader({context}: LoaderFunctionArgs) {
  // `context` carries the Storefront API client. The 2nd arg is the promotionKey:
  // 'PUBLIC' is the auto-applied public promotion; pass a code or a link param to
  // target a specific promotion.
  const abra = await loadAbraBannerData(context, 'PUBLIC');
  const pricingData = await loadAbraPricingData(context, {promotionKey: 'PUBLIC'});

  // Keep `abraBanner` at the top level so components can read useLoaderData().abraBanner.
  return {...(abra ?? {abraBanner: null}), pricingData};
}

loadAbraBannerData returns {abraBanner} (or null when there's no active promotion). abraBanner holds everything the banners consume:

abraBanner: {
  discountValue, discountTitle, giftProducts, giftPriceMap, configStates,
  htmlBanner,        // → HtmlBanner      `content`
  announcementBar,   // → AnnouncementBar `content`
  dynamicText,       // → DynamicText     `content`
}

ABRA_APP_ID is also exported from @abra-promotions/headless/loader if you need it.

Promotion keys

The second argument to loadAbraBannerData / the promotionKey option is one of:

  • 'PUBLIC' (the default) — the auto-applied public promotion. Most stores only need this.
  • a discount code (e.g. 'SUMMER20') — targets the promotion behind that code, typically read from a ?code= / referral-link param.

There 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.

TypeScript types

The loader and pricing entry points ship types for everything they return — import them instead of re-declaring shapes by hand:

import type {AbraBannerData, AbraBanner} from '@abra-promotions/headless/loader';
import type {AbraPricingData} from '@abra-promotions/headless/pricing';
// AbraBannerData is the loader's `{abraBanner}` wrapper; AbraBanner is the `abraBanner` object itself.

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).

2. Render

Pick the component for your promotion and feed it the loader data. Only AffiliateBanner needs no loader. Pass the resolved cart (use useOptimisticCart) to cart-aware banners:

import {useLoaderData} from 'react-router';
import {useOptimisticCart} from '@shopify/hydrogen';
import {GiftTieredBanner} from '@abra-promotions/headless';

const {abraBanner} = useLoaderData<typeof loader>();
const cart = useOptimisticCart(rawCart);
// → see the GiftTieredBanner section for the full config + onAddGift wiring.

3. Verify

Open 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.

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'.


Banner components

| Component | Use when | Data | Transacts? | |---|---|---|---| | GiftTieredBanner | Multi-effect tiered progress with an earnable gift | Loader | Yes — onAddGift adds the gift | | TieredBanner | Volume / spend discount tiers, or classic tiered discounts | Loader | No — Yes with onAddGift for classic FREE_GIFT tiers | | HtmlBanner | A merchant-configured HTML snippet | Loader (htmlBanner) | No | | AnnouncementBar | Top-of-page announcement strip | Loader (announcementBar) | No | | DynamicText | Inline text with live promotion/price tokens | Loader (dynamicText) | No | | AffiliateBanner | "Recommended by" referral banner | Props only | No |

Every 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.

money config

Cart-aware banners (GiftTieredBanner, TieredBanner, DynamicText) take a money prop for currency formatting:

money={{
  currencyCode: 'USD', // e.g. 'USD', 'AUD'
  symbol: '$',
  zeroLabel: '$0.00',  // shown when an amount is zero
  freeLabel: 'Free',   // shown when an item is free
}}

GiftTieredBanner

Multi-effect tiered-discount progress with a gift the customer can add to cart.

import {GiftTieredBanner} from '@abra-promotions/headless';

<GiftTieredBanner
  config={{
    discountValue: abraBanner.discountValue,
    discountTitle: abraBanner.discountTitle,
    giftProducts: abraBanner.giftProducts,
    priceMap: abraBanner.giftPriceMap,   // serializable, pass straight through
    configStates: abraBanner.configStates,
  }}
  hydrogenCart={cart}
  money={money}
  onAddGift={handleAddGift}
  rewardTierDisplay="next"
/>

| Prop | Type | Required | Notes | |---|---|---|---| | config | GiftTieredBannerConfig | Yes | {discountValue, discountTitle, giftProducts, priceMap, configStates?} — all from the loader. priceMap is the loader's giftPriceMap (cents, keyed `${handle}:${variantId}`). | | hydrogenCart | unknown | Yes | The resolved Hydrogen cart (useOptimisticCart). | | money | MoneyConfig | Yes | See money config. | | 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. | | rewardTierDisplay | 'current' \| 'next' | No | Default 'next' (keeps a goal on screen after a claim); 'current' emphasizes the earned tier. | | styles | GiftTieredBannerStyles | No | Per-instance style overrides. |

Events: 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).

Wiring onAddGift

onAddGift must add the chosen variant with a __abra line attribute whose value is JSON.stringify({discount: discountTitle}). The easiest wiring is the cart-actions hook — see Cart actions:

import {useFetcher} from 'react-router';
import {useAbraCartActions} from '@abra-promotions/headless/cart';

const fetcher = useFetcher();
const {addGift} = useAbraCartActions({
  promotion: abraCart,
  storeDomain,
  submit: fetcher.submit,
  cartId: cart?.id,
});

<GiftTieredBanner /* … */ onAddGift={addGift} />;

If the promotion has more than one discount, tag the line with the gift discount's own title (the loader's abraBanner.discountTitle) — that's the title Abra's function matches when zeroing the line: onAddGift={(id) => addGift(id, abraBanner.discountTitle)}.

To hand-roll it instead, build the line with buildAbraGiftLines(variantId, discountTitle) from @abra-promotions/headless/cart and submit it as a LinesAdd CartForm payload through a fetcher.

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), cast the config: config={{…} as unknown as GiftTieredBannerConfig}.

TieredBanner

Volume / 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.

import {TieredBanner} from '@abra-promotions/headless';

<TieredBanner
  config={{discountValue: abraBanner.discountValue, discountTitle: abraBanner.discountTitle}}
  hydrogenCart={cart}
  money={money}
  rewardTierDisplay="next"
/>

| Prop | Type | Required | Notes | |---|---|---|---| | 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. | | hydrogenCart | unknown | Yes | Resolved Hydrogen cart. | | money | MoneyConfig | Yes | See money config. | | rewardTierDisplay | 'current' \| 'next' | No | Default 'next'. | | 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. | | styles | TieredBannerStyles | No | Per-instance style overrides. |

Events: abra:tiered-banner:${name}:show / :hide.

HtmlBanner

Renders the promotion's HTML block. The content comes from the loader (abraBanner.htmlBanner).

import {HtmlBanner} from '@abra-promotions/headless';

<HtmlBanner content={abraBanner.htmlBanner} />

| Prop | Type | Required | Notes | |---|---|---|---| | content | HtmlBannerContent \| null | Yes | {html} from the loader. Hides when null/empty. | | sanitize | (html: string) => string | No | Applied before render. Markup renders with unsafeHTML (no built-in sanitization) — see Security. | | blockId | string | No | Forwarded to the element as block-id. |

Events: abra:html-banner:${name}:show / :hide.

AnnouncementBar

Top-of-page announcement strip. Content comes from the loader (abraBanner.announcementBar).

import {AnnouncementBar} from '@abra-promotions/headless';

<AnnouncementBar content={abraBanner.announcementBar} target="body" />

| Prop | Type | Required | Notes | |---|---|---|---| | content | AnnouncementBarContent \| null | Yes | {text, icon?, link?} from the loader. link is sanitized (see Security). Hides when text is empty. | | target | 'section' \| 'body' | No | Where the bar inserts in the DOM. | | blockId | string | No | Forwarded as block-id. |

Name-less event. Unlike the others, AnnouncementBar dispatches a fixed abra:announcement-bar:show / :hide with no ${name} segment.

DynamicText

Inline text with live promotion/price token substitution. Content comes from the loader (abraBanner.dynamicText); the cart drives the live token values.

import {DynamicText} from '@abra-promotions/headless';

<DynamicText content={abraBanner.dynamicText} hydrogenCart={cart} money={money} align="center" />

| Prop | Type | Required | Notes | |---|---|---|---| | content | DynamicTextContent \| null | Yes | {states, discountTitle, prerequisite?} from the loader. Hides when the resolved text is empty. | | hydrogenCart | unknown | Yes | Resolved Hydrogen cart (drives the live tokens). | | money | MoneyConfig | Yes | See money config. | | align | 'left' \| 'center' \| 'right' | No | Text alignment. | | blockId | string | No | Forwarded as block-id. |

Events: abra:dynamic-text:${name}:show / :hide.

AffiliateBanner

A "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).

import {AffiliateBanner} from '@abra-promotions/headless';

<AffiliateBanner
  affiliate={{
    recommendedBy: 'Jane Doe',
    message: 'Jane recommends this store!',
    discountMessage: 'Use code JANE10 for 10% off',
    discountCode: 'JANE10',
  }}
  align="center"
/>

| Prop | Type | Required | Notes | |---|---|---|---| | affiliate | AffiliateConfig | Yes | {recommendedBy?, fullName?, referralLink?, avatarUrl?, message?, discountMessage?, discountCode?}. referralLink is sanitized. Hides unless recommendedBy, message, or discountMessage is set. | | align | 'left' \| 'center' \| 'right' | No | Text alignment. | | blockId | string | No | Forwarded as block-id. | | styles | AffiliateBannerStyles | No | Per-instance style overrides. |

Events: abra:banner:${name}:show / :hide.


Pricing

@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.

loadAbraPricingData

import {loadAbraPricingData} from '@abra-promotions/headless/pricing';

const pricingData = await loadAbraPricingData(context, {promotionKey: 'PUBLIC'});

(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).

getAbraDiscountedPrice

import {getAbraDiscountedPrice} from '@abra-promotions/headless/pricing';

const result = getAbraDiscountedPrice({
  pricingData,                     // from loadAbraPricingData
  product: {
    handle: product.handle,
    variants: product.variants.map((v) => ({
      id: v.id,
      price: v.price,              // {amount, currencyCode}
      compareAtPrice: v.compareAtPrice ?? null,
    })),
  },
  variantId: selectedVariantId,    // optional; defaults to the first variant
  cart,                            // the Hydrogen cart
});

InputGetAbraDiscountedPriceInput:

| Field | Type | Required | Notes | |---|---|---|---| | pricingData | AbraPricingData | Yes | From loadAbraPricingData. | | product | AbraPricingProductInput | Yes | {handle, variants: [{id, price, compareAtPrice?}]}. | | variantId | string \| null | No | Which variant to price; defaults to the first. | | 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. | | runtime | PricingRuntime | No | Custom logger / runtime (createRuntime, NOOP_LOGGER exported). |

ReturnsAbraDiscountedPrice:

| Field | Type | Notes | |---|---|---| | discounted | boolean | Whether any Abra discount applied. | | original | AbraMoney | Price before Abra discounts. | | final | AbraMoney | Price after Abra discounts. | | discountAmount | AbraMoney | original − final. | | compareAt | AbraMoney | Effective compare-at to strike through. | | appliedDiscounts | {id, title, discountClass}[] | The Abra discounts that applied ('PRODUCT' \| 'ORDER' \| 'SHIPPING'). |

AbraMoney is {cents, amount, currencyCode}.


Cart actions

@abra-promotions/headless/cart emulates the cart behaviour of Abra's theme-extension SDK: promotion attribution (so orders count toward the promotion in Abra), discount-code application, gift lines (add, auto-remove, quantity sync), gift-state sync, and fixed-price BXGY bundles. Same trust model as the rest of the package — everything comes from storefront-readable metafields; no Abra API token.

Two ways to consume it: useAbraCartActions/useAbraCartSync when the package should drive the cart, or the pure planners (getAbraCartPlan, getAbraBundlePlan) when you want a diff of what the cart needs and full control over applying it.

1. Load cart data in your loader

import {loadAbraCartData} from '@abra-promotions/headless/cart';

export async function loader({context}: LoaderFunctionArgs) {
  const abraCart = await loadAbraCartData(context, {promotionKey: 'PUBLIC'});
  return {abraCart /*, ...banner/pricing data */};
}

Returns AbraCartPromotion | null: {promotionId, promotionTitle, code, discountTitles, discountCodes, abraStorefrontToken}. discountTitles are what actually get applied to the cart (via getAbraDiscountCodes/cartDiscountCodesUpdate); discountCodes are Abra's internal discount slugs and are not codes you can apply — they're exposed for reference only. abraStorefrontToken is Abra's storefront token (published to a shop metafield by the Abra app); when it's absent, cart-metafield writes are skipped — the __abra cart attribute still attributes orders.

2. Add one case to your cart route

activatePromotion/deactivatePromotion submit a single custom CartForm action. In your /cart route's action:

case 'AbraPromotionUpdate': {
  if (!cart.getCartId()) {
    // Fresh session: create the cart carrying the attribution, so activation
    // on landing (before anything is in the cart) still sticks.
    result = await cart.create({
      attributes: inputs.attributes,
      discountCodes: inputs.discountCodes,
    });
    break;
  }
  await cart.updateAttributes(inputs.attributes);
  result = await cart.updateDiscountCodes(inputs.discountCodes);
  break;
}

Hydrogen's cart handlers never create a cart on their own — without the cart.create branch, calling activatePromotion() before anything is in the cart would silently do nothing.

(addGift uses the standard LinesAdd action — no route change needed.)

3. Use the hook

import {useFetcher} from 'react-router';
import {useAbraCartActions} from '@abra-promotions/headless/cart';

const fetcher = useFetcher();
const {activatePromotion, deactivatePromotion, addGift} = useAbraCartActions({
  promotion: abraCart,                        // from the loader
  storeDomain: 'your-shop.myshopify.com',     // e.g. env.PUBLIC_STORE_DOMAIN
  submit: fetcher.submit,
  cartId: cart?.id,                           // enables metafield attribution parity
  redeemCode,                                 // optional referral/redeem code
});
  • activatePromotion() — writes the __abra cart attribute, applies the promotion's discount codes, and mirrors attribution to cart metafields. Call once when the storefront decides the promotion is active (e.g. on landing with a promo link).
  • addGift(variantId, discountTitle?) — adds the gift with the __abra line attribute (Abra's Shopify Function makes it free). Pass it as GiftTieredBanner's onAddGift. When the promotion has several discounts, pass the gift discount's title — the function only zeroes lines tagged with its own discount's title (plan.addableGifts[].lines already carries it).
  • deactivatePromotion() — clears the attribute, discount codes, and metafields. Pass the hook's currentDiscountCodes option (cart.discountCodes.map((d) => d.code)) so only Abra's codes are removed and a coupon the customer typed themselves survives; without it, all codes are cleared.

Prefer plain functions? Everything the hook does is exported: buildAbraCartAttributes, buildClearAbraCartAttributes, getAbraDiscountCodes, buildAbraGiftLines, setAbraCartMetafields, clearAbraCartMetafields, syncAbraGiftState, plus getOrCreateAttributedAt. Gift auto-removal (findGiftLineIdsToRemove, isGiftEarned) and mapHydrogenCart are re-exported here too.

Full cart control: getAbraCartPlan

If you don't want anything — hook included — touching your cart, one pure function returns the whole diff for a given cart state and you apply it however you like:

import {getAbraCartPlan} from '@abra-promotions/headless/cart';

const plan = getAbraCartPlan({
  promotion: abraCart,        // from loadAbraCartData (or null: skip attribution/codes)
  banner: abraBanner,         // from loadAbraBannerData (or null: skip the gift diff)
  cart: rawCart,              // your Hydrogen cart
  redeemCode,                 // optional
});

plan.attributes;           // cart attributes to set (cartAttributesUpdate)
plan.discountCodes;        // discount codes to apply (cartDiscountCodesUpdate)
plan.lineIdsToRemove;      // stale/excess gift lines to remove (cartLinesRemove)
plan.lineQuantityUpdates;  // in-cart gift lines to re-quantify (cartLinesUpdate) — compound /
                           //   multi-quantity gifts and cumulative tier gifts
plan.addableGifts;         // gifts earned but not in the cart:
                           //   {variantId, handle, productTitle, variantTitle, autoAdd,
                           //    lines, swapLineIdsToRemove, swapLineQuantityUpdates}
                           //   `lines` is a ready cartLinesAdd input with the __abra attribute,
                           //   already scaled to the earned gift units
plan.giftEligible;         // whether the gift threshold is currently met
plan.preventAutoReAdd;     // merchant setting: never re-add a gift the shopper removed by hand
plan.state;                // raw engine state (e.g. 'PENDING_SELECTION', 'SUCCESS', 'TIER_1')
plan.giftState;            // raw gift-tier state for MultiEffectTiers promotions (else null)

Gift behaviour matches the theme extension's decision logic:

  • Quantities — gift-with-purchase units follow compound and the discount's per-award quantity; gift-tier (MultiEffectTiers) units accumulate across the tiers the cart has achieved. lineIdsToRemove/lineQuantityUpdates keep in-cart gift lines at exactly the earned units (a claimed tier gift is removed when the cart falls back below every tier).
  • Swaps — when claiming a variant would exceed the shared gift-unit limit (a different variant already holds it), that gift's swapLineIdsToRemove/swapLineQuantityUpdates say what must give way. Apply them together with the add — otherwise the second gift line stays at full price, because Abra's function only zeroes one gift line.
  • Auto-addautoAdd marks gifts the theme would add without asking (the offer has exactly one product with one variant; nothing to select).
  • Re-add policy — if you auto-add gifts yourself, honour plan.preventAutoReAdd using the exported tracking helpers: trackAbraGiftAdded(variantId, discountTitle) after every add, and skip the add when wasAbraGiftAdded(...) is true (added recently but now absent = the shopper removed it). useAbraCartSync and addGift do both automatically. Sold-out gift variants are already pruned by the loader (availableForSale).

No mutations, no React — call it wherever you react to cart changes (server or client) and feed the results to your own cart handling. money is optional and only shapes progress strings.

Let the package keep the cart in sync: useAbraCartSync

The automatic half of the plan can run hands-off — pass the current plan and the hook submits the cart mutations the theme extension would make on its own, through your fetcher, whenever the diff changes (each distinct diff is submitted once; the revalidated cart produces the next plan):

import {getAbraCartPlan, useAbraCartSync} from '@abra-promotions/headless/cart';

const plan = getAbraCartPlan({promotion: abraCart, banner: abraBanner, cart});
useAbraCartSync({plan, submit: fetcher.submit}); // cartAction defaults to '/cart'

Per pass it applies, in priority order: stale/excess gift removals (LinesRemove), gift quantity corrections (LinesUpdate), then — when nothing needs correcting — auto-adding a no-selection-needed gift (LinesAdd), honouring preventAutoReAdd via the gift tracking cache (a gift the shopper removed by hand stays removed). Pass autoAddGifts: false to keep gifting strictly claim-based. Uses only standard CartForm actions — no cart-route changes needed. Selection-required gifts are never auto-added; those stay explicit via addGift.

BXGY bundles: getAbraBundlePlan

Fixed-price BXGY promotions ("shirt + hat for $50") are assembled by Abra's cart-transform function from two pieces of cart state: a _abra_bxgy_bundle cart attribute (the bundle configs) and _abra_bundled line-attribute markers grouping the component lines. getAbraBundlePlan maintains both, declaratively:

import {getAbraBundlePlan} from '@abra-promotions/headless/cart';

const bundlePlan = getAbraBundlePlan({
  discountValue: abraBanner.discountValue, // must be the BxgyDiscount value
  discountTitle: abraBanner.discountTitle,
  cart: rawCart,                           // include cart `attributes` in your cart query
});

bundlePlan.attribute;      // _abra_bxgy_bundle write (cartAttributesUpdate); '' clears; null = no change
bundlePlan.linesToUpdate;  // marker sets/clears (cartLinesUpdate; full attribute replacement)
bundlePlan.bundlesEarned;  // how many bundles the cart currently earns

When the cart earns the bundle it marks the component lines and writes the config (the transform then merges them into one fixed-price line); when it stops qualifying it clears this promotion's markers and config, leaving other promotions' bundles untouched. Marker allocation is whole-line: quantity stacked on a single line can't split across two bundle groups — add items as separate lines (the storefront default) for multi-bundle carts. Percentage/amount BXGY needs none of this (the discount functions price it directly); the plan is inert for those.


Styling

Banners 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):

:root {
  --abra-gift-tiered-banner-background-color: #fff;
  --abra-gift-tiered-progress-bar-active-color: #119e52;
  /* …see each element's CSS for its full token set */
}

For per-instance overrides, most components accept a styles prop (resolveBannerStyles and the *Styles types are exported from the root).


Security & trust model

Banner 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:

  • 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.

  • 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:

    import DOMPurify from 'dompurify';
    
    <HtmlBanner content={abraBanner.htmlBanner} sanitize={(html) => DOMPurify.sanitize(html)} />

    No sanitizer is bundled, so the dependency isn't forced on every consumer.

If you store anything other than trusted merchant content in these metafields, sanitize or validate it upstream before it reaches the banners.


Contributing

Build, test, and release instructions for maintainers live in CONTRIBUTING.md.