@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/headlessreact 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/
useLoaderDatacasting. Loader data is JSON-serialized, souseLoaderData<typeof loader>()returns Remix'sJsonify<…>wrappers. These are structurally compatible at runtime but not assignable to the package's exported types undertsc. Cast at the boundary —pricingData as AbraPricingData, and the bannerconfigprops (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/customElementson import, so a top-level import would crash the server render. Every wrapper lazy-loads the bundle inside auseEffect— 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-levelimport '@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.discountValueis a union, butconfig.discountValuehere wants the multi-effect-tiered member specifically. Combined with theJsonifywrapper fromuseLoaderData(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,
AnnouncementBardispatches a fixedabra:announcement-bar:show/:hidewith 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
});Input — GetAbraDiscountedPriceInput:
| 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). |
Returns — AbraDiscountedPrice:
| 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__abracart 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__abraline attribute (Abra's Shopify Function makes it free). Pass it asGiftTieredBanner'sonAddGift. 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[].linesalready carries it).deactivatePromotion()— clears the attribute, discount codes, and metafields. Pass the hook'scurrentDiscountCodesoption (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
compoundand the discount's per-awardquantity; gift-tier (MultiEffectTiers) units accumulate across the tiers the cart has achieved.lineIdsToRemove/lineQuantityUpdateskeep 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/swapLineQuantityUpdatessay 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-add —
autoAddmarks 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.preventAutoReAddusing the exported tracking helpers:trackAbraGiftAdded(variantId, discountTitle)after every add, and skip the add whenwasAbraGiftAdded(...)is true (added recently but now absent = the shopper removed it).useAbraCartSyncandaddGiftdo 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 earnsWhen 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, affiliatereferralLink) pass throughsanitizeUrl, which permits onlyhttp,https,mailto,tel, and relative / anchor URLs. Dangerous schemes likejavascript:anddata:are dropped.sanitizeUrlis 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 asanitizefunction 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.
