@magic-spells/gift-with-purchase
v2.0.0
Published
Gift with purchase web component for e-commerce threshold promotions.
Maintainers
Readme
Gift with Purchase
A powerful, e-commerce web component for automatic gift-with-purchase threshold promotions. Seamlessly integrates with Shopify and automatically manages gift items in the cart based on spending thresholds.
Features
- 🎁 Automatic Gift Management - Adds/removes gifts based on cart thresholds using smart pricing logic
- 🛒 Shopify Integration - Built-in Cart API support with proper line item properties
- 📱 Cart Panel Sync - Automatically syncs with cart-panel components using
calculated_subtotal - 🎨 Highly Customizable - CSS custom properties and swappable content elements
- ⚡ Event-Driven - Custom events for gift addition, removal, and errors
- 🔧 Flexible Content - Your own markup inside the element; the component only writes the
[data-content-gwp-message]element - 📱 Responsive - Mobile-optimized with responsive design
Installation
npm install @magic-spells/gift-with-purchaseThe package is ESM-only. Import it once, anywhere in your bundle, to register the custom element:
import '@magic-spells/gift-with-purchase';The CSS is shipped as a separate file rather than injected, so load it too:
/* unminified */
@import '@magic-spells/gift-with-purchase/css';
/* or minified */
@import '@magic-spells/gift-with-purchase/css/min';Without a bundler, use the UMD build (exposes the GiftWithPurchase global):
<link
rel="stylesheet"
href="https://unpkg.com/@magic-spells/gift-with-purchase/dist/gift-with-purchase.min.css" />
<script src="https://unpkg.com/@magic-spells/gift-with-purchase/dist/gift-with-purchase.min.js"></script>Basic Usage
<gift-with-purchase
threshold="75.00"
variant-id="12345678"
money-format="${{amount}}"
message-above="🎉 Congratulations! You've qualified for your FREE gift!"
message-below="Add [amount] more to unlock your free gift! 🎁">
<div class="gwp-product">
<img src="gift-image.jpg" alt="Free Gift" />
<div class="gwp-content">
<h4 class="gwp-title">Free Sample Set</h4>
<p class="gwp-variant">Travel Size Collection</p>
</div>
</div>
<p data-content-gwp-message class="text-sm font-medium"></p>
</gift-with-purchase>Cart Integration
The component automatically listens for cart data changes when placed inside a <cart-panel> component from the @magic-spells/cart-panel package:
<cart-panel>
<gift-with-purchase
threshold="75.00"
variant-id="12345678"
money-format={{ shop.money_format | json }}
message-above="🎉 Congratulations! You've qualified for your FREE gift!"
message-below="Add [amount] more to unlock your free gift! 🎁">
<!-- Gift content -->
</gift-with-purchase>
</cart-panel>When the cart-panel emits a cart-panel:data-changed event (typically from Shopify cart updates), the gift component will automatically:
- Update the current cart amount using
calculated_subtotalfor accurate threshold calculation - Check if the threshold is met (excludes other gifts and honors pricing exclusions)
- Add the gift to cart if threshold is reached
- Remove the gift if cart falls below threshold
Smart Pricing Logic
The component uses intelligent threshold calculation:
- Uses
calculated_subtotalfrom cart-panel which properly handles item exclusions - Excludes gifts: Other gifts with purchase won't count toward this threshold
- Includes bundle items: Hidden bundle components that should count are included
- Multi-currency support: Automatically converts threshold using
Shopify.currency.ratefor stores with multiple currencies - Requires cart-panel: The component requires
calculated_subtotalin cart events to function
JavaScript API
const gwp = document.querySelector('gift-with-purchase');
// Setters - update state programmatically
gwp.setCurrentAmount(85.5);
gwp.setThreshold(100.0);
gwp.setVariantId('87654321');
// Get full state object
const state = gwp.getState();
console.log(state.isActive, state.isAdded, state.remainingAmount);
// Readonly getters - access individual properties
console.log(gwp.currentAmount); // number - current cart amount
console.log(gwp.threshold); // number - threshold amount
console.log(gwp.variantId); // string - gift variant ID
console.log(gwp.isActive); // boolean - threshold met
console.log(gwp.isAdded); // boolean - gift in cart
console.log(gwp.promoEnded); // boolean - promo has ended
console.log(gwp.productAvailable); // boolean - gift product available
console.log(gwp.isDisabled); // boolean - promo ended OR product unavailableAttributes
| Attribute | Description | Example |
| --------------- | -------------------------------------------------------------------- | ------------------------------------------------------------ |
| threshold | Spending threshold to unlock the gift (auto-converts for multi-currency) | "75.00" |
| current | Optional starting amount. The component tracks the live cart total internally from cart-panel:data-changed and never writes this attribute back, so don't template it from Liquid | "45.00" |
| variant-id | Shopify variant ID for the gift product | "12345678" |
| promo-ended | Disables the promo and hides the component | "true" |
| product-available | Whether the gift product is available (disables if false) | "true" |
| message-above | Message shown when threshold is met | "🎉 Congratulations! You've qualified for your FREE gift!" |
| message-below | Message shown below threshold; use [amount] for remaining amount | "Add [amount] more to unlock your free gift! 🎁" |
| money-format | Shopify-style money format for currency display | "${{amount}}" |
The [amount] Placeholder
In the message-below attribute, use [amount] (with square brackets) to insert the remaining amount needed to reach the threshold. The component automatically:
- Calculates the remaining amount (
threshold - currentAmount) - Converts to the customer's currency using
Shopify.currency.rate - Formats using your
money-formatattribute
<!-- Example: If threshold is $75 and cart is $45, displays "Add $30 more..." -->
<gift-with-purchase
threshold="75.00"
money-format="${{amount}}"
message-below="Add [amount] more to unlock your free gift!">
</gift-with-purchase>Why square brackets? We use [amount] instead of {{amount}} to avoid conflicts with Shopify Liquid templates and JavaScript template literals.
Multi-Currency Support
The component automatically handles currency conversion for multi-currency Shopify stores:
- Set
thresholdin your store's base currency (e.g., USD) - The component reads
Shopify.currency.ratefrom the browser - Threshold is automatically converted to the customer's selected currency
- The
[amount]placeholder displays in the converted currency
Message Element
The component requires a message element to display threshold messages:
<gift-with-purchase threshold="75.00" variant-id="12345678">
<!-- Your gift content with any styling -->
<div class="gift-content">
<h4 class="font-bold">Free Gift</h4>
<p class="text-gray-600">Sample Description</p>
</div>
<!-- Required: Element where messages will be injected -->
<p data-content-gwp-message class="bg-green-100 p-2 rounded"></p>
</gift-with-purchase>Currency Formatting
Use the money-format attribute to format amounts with the correct currency symbol and formatting:
<gift-with-purchase
threshold="75.00"
variant-id="12345678"
money-format="${{amount}}"
message-below="Add [amount] more to unlock your free gift!">
<!-- Gift content -->
</gift-with-purchase>In Shopify themes, you can pass the shop's money format using the json filter:
<gift-with-purchase
threshold="75.00"
variant-id="12345678"
money-format={{ shop.money_format | json }}
message-below="Add [amount] more to unlock your free gift!">
<!-- Gift content -->
</gift-with-purchase>Available Shopify money formats:
shop.money_format- Basic format (e.g.,${{amount}})shop.money_with_currency_format- With currency code (e.g.,${{amount}} USD)
Supported Format Placeholders
| Placeholder | Output | Example |
| ----------- | ------ | ------- |
| {{amount}} | Amount with decimals | 19.99 |
| {{amount_no_decimals}} | Rounded whole number | 20 |
| {{amount_with_comma_separator}} | Comma as decimal | 19,99 |
| {{amount_no_decimals_with_comma_separator}} | Whole with comma thousands | 1,000 |
Examples by Currency
<!-- USD -->
money-format="${{amount}}" <!-- $30.00 -->
<!-- EUR -->
money-format="€{{amount}}" <!-- €30.00 -->
money-format="{{amount}} €" <!-- 30.00 € -->
<!-- GBP -->
money-format="£{{amount}}" <!-- £30.00 -->
<!-- JPY (no decimals) -->
money-format="¥{{amount_no_decimals}}" <!-- ¥3000 -->Events
The component emits custom events for integration:
// Gift successfully added to cart
gwp.addEventListener('gwp:added', (event) => {
console.log('Gift added:', event.detail.variantId);
});
// Gift successfully removed from cart
gwp.addEventListener('gwp:removed', (event) => {
console.log('Gift removed:', event.detail.variantId);
});
// Error occurred during add/remove
gwp.addEventListener('gwp:error', (event) => {
console.error('Error:', event.detail.error);
console.log('Action:', event.detail.action); // 'add', 'remove', or 'trim'
});Customization
Use CSS custom properties to customize the appearance:
gift-with-purchase {
--gwp-border-radius: 12px;
--gwp-padding: 1.5rem;
--gwp-bg-active: #e8f5e8;
--gwp-border-active: #28a745;
--gwp-text-active: #155724;
--gwp-gap: 1.5rem;
}Available CSS Custom Properties
| Property | Description | Default |
| --------------------- | ---------------------- | --------- |
| --gwp-border-radius | Border radius | 8px |
| --gwp-padding | Internal padding | 1rem |
| --gwp-bg-active | Background when active | #e8f5e8 |
| --gwp-bg-added | Background when added | #d4edda |
| --gwp-border-active | Border when active | #28a745 |
| --gwp-border-added | Border when added | #155724 |
| --gwp-text-active | Text color when active | #155724 |
| --gwp-text-added | Text color when added | #155724 |
| --gwp-gap | Gap between elements | 1rem |
Component States
The component automatically applies a state attribute based on its current condition:
state="active"- Threshold met, gift available to addstate="added"- Gift successfully added to cartstate="inactive"- Below threshold (component idle)state="ended"- Promo ended (component hidden)state="disabled"- Gift unavailable (component hidden)
Note: ended and disabled both hide the component while still removing the gift line for this
component's variant-id. Another tier's gift is left alone, so disabling one tier never clears
another's.
Shopify Integration Details
Cart API Calls
The component uses Shopify's Cart API endpoints:
POST /cart/add.js- Adds the gift to cartGET /cart.js- Gets current cart state (only when the parent cart-panel cannot supply it)POST /cart/change.js- Removes the gift line, and trims a doubled gift line back toquantity: 1
Line Item Properties
When adding gifts to the cart, the component automatically sets these properties for proper cart integration:
{
id: variantId,
quantity: 1,
properties: {
_gwp_item: "true",
_hide_in_cart: "true",
_ignore_price_in_subtotal: "true"
}
}Property Functions:
_gwp_item: "true"- Identifies gift items in cart templates and for removal logic_hide_in_cart: "true"- Hides gifts from cart display (handled by@magic-spells/cart-panel)_ignore_price_in_subtotal: "true"- Excludes gift price from subtotal calculations and threshold checks- Automatic management - These properties ensure gifts don't interfere with other promotions or cart totals
Cart Template Integration
In your Shopify cart template, you can identify and handle gift items:
{% for item in cart.items %}
{% if item.properties._gwp_item == 'true' %}
<!-- This is a gift item -->
<div class="cart-gift-item">
<span class="gift-badge">FREE GIFT</span>
{{ item.product.title }}
</div>
{% else %}
<!-- Regular cart item -->
{% endif %}
{% endfor %}Advanced Usage
Multiple Thresholds
<!-- Low tier gift -->
<gift-with-purchase
threshold="50.00"
variant-id="111111"
message-above="🎉 You've unlocked a free tote bag!"
message-below="Add [amount] more for a free tote bag! 👜">
<div class="gwp-product">
<img src="tote-bag.jpg" alt="Free Tote Bag" />
<h4 class="gwp-title">Free Tote Bag</h4>
</div>
<p data-content-gwp-message class="message-low-tier"></p>
</gift-with-purchase>
<!-- High tier gift -->
<gift-with-purchase
threshold="100.00"
variant-id="222222"
message-above="✨ Amazing! You've earned our premium gift set!"
message-below="Spend [amount] more for our exclusive premium gift set! ✨">
<div class="gwp-product">
<img src="gift-set.jpg" alt="Premium Gift Set" />
<h4 class="gwp-title">Premium Gift Set</h4>
</div>
<p data-content-gwp-message class="message-premium"></p>
</gift-with-purchase>Custom Styling Examples
/* Minimal style */
.gwp-minimal {
--gwp-padding: 0.75rem;
--gwp-border-radius: 4px;
--gwp-bg-active: #f0f8f0;
}
/* Bold style */
.gwp-bold {
--gwp-padding: 1.5rem;
--gwp-border-radius: 12px;
--gwp-bg-active: #28a745;
--gwp-text-active: white;
}
/* Compact mobile style */
@media (max-width: 768px) {
gift-with-purchase {
--gwp-padding: 0.5rem;
--gwp-gap: 0.5rem;
--gwp-border-radius: 4px;
}
}Browser Support
- Chrome/Edge 88+
- Firefox 85+
- Safari 14+
- All modern browsers with Custom Elements support
State Shape
getState() returns:
interface GiftWithPurchaseState {
currentAmount: number;
threshold: number;
convertedThreshold: number;
variantId: string | null;
isActive: boolean;
isAdded: boolean;
promoEnded: boolean;
productAvailable: boolean;
isDisabled: boolean;
remainingAmount: number;
currencyRate: number;
}
// Component automatically handles message injection
// Style your content and message elements with any CSS classesDevelopment
npm run dev # build to demo/dist/ in watch mode and serve demo/ on http://localhost:3000
npm run build # build the published dist/ (ESM, minified UMD, CSS, minified CSS)
npm run lint # ESLint over src/ and scripts/The build is a plain Node script (scripts/build.mjs) driving Vite's JS API — there is no
vite.config.js. It emits exactly four files into dist/:
| File | Format |
| ----------------------------- | --------------------------------------- |
| gift-with-purchase.esm.js | ES module (unminified) |
| gift-with-purchase.min.js | UMD, minified, global GiftWithPurchase |
| gift-with-purchase.css | Stylesheet |
| gift-with-purchase.min.css | Stylesheet, minified |
Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
License
MIT License - see LICENSE file for details.
