Cart Sync
Everything the drop-in calculates — which benefits apply, what discount to show, how many points the order would earn — runs against the cart you give it. If the cart in the drop-in doesn't match the cart in your store, the customer sees a benefit they can't redeem (or worse, redeems a benefit your checkout doesn't honor).
This guide explains the shape SimplyClub expects, when to push updates, and how to avoid the most common pitfall: an infinite cart-update loop.
How it works
- On page load, push the current cart into the drop-in once.
- Every time your storefront mutates the cart (add, remove, change quantity, apply a coupon, etc.), push the updated cart again.
- When the user picks a benefit in the drop-in, persist the selection back onto your cart so the discount carries through to checkout. See API Reference →
On.benefitSelectionsChanged.
import { dropInLoader } from "https://dropins.simplyclub.co.il/drop-ins/drop-in-loader/index.mjs";
const instance = await dropInLoader.setConfig({ poiId: "YOUR_POI_ID" });
// 1. Initial sync
await instance.commands.setCart(await readMyCart());
// 2. Re-sync on every cart change
onMyCartChange(async () => {
await instance.commands.setCart(await readMyCart());
});
Cart shape
setCart takes one argument: the cart object. Pass null to clear.
type Cart = {
token: string; // any stable identifier — your cart hash, session id, etc.
items: CartItem[];
discount_codes?: string[]; // coupons the customer has already applied
};
Minimal item shape
type CartItem = {
id: string | number; // your platform's line-item or product id
quantity: number;
price: number; // unit price in MAJOR currency units (e.g. 49.90)
final_price: number; // price after line discounts (also major units)
title?: string;
sku?: string;
};
That's all you need to make benefits calculate correctly. The richer the item, the more SimplyClub can do — see the full shape below.
Full item shape
Everything optional unless marked. Anything missing is treated as "not provided".
| Field | Type | What it's for |
|---|---|---|
id (required) |
string | number | Line-item id — uniquely identifies this row in the cart. |
quantity (required) |
number | How many of this item. |
price (required) |
number | Unit price, major currency units. |
final_price (required) |
number | Per-unit price after any line discounts. |
title |
string | Display name shown in the drop-in. |
name |
string | Same idea — set both to the product name. |
description |
string | Short description. |
sku |
string | Product SKU — used by benefit rules that target specific SKUs. |
vendor |
string | Brand / vendor — used by rules that target specific vendors. |
image |
string | Image URL shown in the drop-in. |
url |
string | Product page URL. |
variant_id |
string | number | Variant identifier, if your platform has variants. |
variant_title |
string | Variant display name (e.g. "Large / Red"). |
variant_inventory_quantity |
number | Available stock — used by rules that consider stock. |
compare_at_price |
number | Original price before sale (major units). Lets the drop-in show "save X" labels. |
discounts |
{ title: string; amount: number }[] |
Per-line discounts already applied. Amounts in major units. |
total_discount |
number | Sum of discounts[].amount. Major units. |
line_price |
number | Total for this line before discounts. Major units. |
original_line_price |
number | Same as line_price for most platforms. |
discounted_price |
number | Per-unit price after line discounts (same as final_price for simple cases). |
final_line_price |
number | Line total after discounts. |
extended_attributes |
object | Optional extras — { tags: string[], collections: string[], metafields: any[] }. Targeted benefits can match on these. |
⚠️ Price units
This is the most common mistake. SimplyClub expects prices in major currency units — 49.90, not 4990.
- Shopify's
/cart.jsreturns prices in minor units (cents/agorot). The SimplyClub Theme App Block divides them by 100 before callingsetCart. If you build your own Shopify integration, do the same. - WooCommerce's
WC_Product::get_price()already returns major units — pass it through as-is. - Custom backends: send whatever your storefront displays to the customer.
If your numbers look 100× off, this is why.
When to sync
Call setCart at every point your cart can change:
| Trigger | Re-sync? |
|---|---|
| Page load (drop-in init) | Yes — initial state |
| User adds an item | Yes |
| User removes an item | Yes |
| User changes quantity | Yes |
| User applies a coupon | Yes |
| User picks a benefit in the drop-in | No — the drop-in already knows |
| User logs in or out | Yes (re-sync since cart attribution may change) |
| Returning to the page after navigation | Yes (cart may have changed elsewhere) |
The rule of thumb: if the customer would see a different cart subtotal, re-sync.
Avoiding the infinite loop
The drop-in writes back to your cart when the user picks a benefit (typically by setting cart attributes or coupon codes). If your "cart changed" detector treats those writes as a real cart change, you'll end up in this loop:
benefit selected → drop-in writes to cart → your detector fires → you call setCart →
drop-in recalculates → fires benefit again → write to cart → … (forever)
The fix is to tag writes that originate from SimplyClub so your detector can ignore them.
Shopify
The Theme App Block already does this — every cart write the drop-in performs goes through ?simplyclub=true. The block's PerformanceObserver watches /cart/* calls and only resets the discount when another script mutates the cart:
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.initiatorType !== 'fetch') return;
if (!entry.name.includes('/cart/')) return;
if (entry.name.includes('simplyclub=true')) return; // ignore our own writes
if (entry.name.includes('/cart/add.js')) return; // additive — keep the benefit
handleCartChange(); // real external mutation — re-sync
});
});
observer.observe({ type: 'resource' });
Custom integration
Apply the same idea to your stack:
async function updateCart(payload, opts = {}) {
payload._sc = opts.fromSimplyClub === true; // tag origin
const res = await fetch("/api/cart", { method: "POST", body: JSON.stringify(payload) });
if (!opts.fromSimplyClub) {
await instance.commands.setCart(await readMyCart()); // only re-sync on external writes
}
return res.json();
}
Two simple rules:
- Mark every write you make on behalf of SimplyClub so your detector can skip it.
- Re-sync only when the write is genuinely external — coming from the user's actions, another tab, or your own checkout code.
Recipes
Shopify
If you use the Theme App Block, cart sync is already wired — you don't need to do anything. If you're building your own Shopify integration, use the pattern the block uses:
async function syncShopifyCart() {
const res = await fetch('/cart.js?_sc=1', { cache: 'no-store' });
const cart = await res.json();
const items = cart.items.map((i) => ({
id: i.id,
quantity: i.quantity,
price: i.price / 100, // cents → major units
final_price: i.final_price / 100,
line_price: i.line_price / 100,
final_line_price: i.final_line_price / 100,
total_discount: i.total_discount / 100,
discounts: (i.discounts || []).map((d) => ({ title: d.title, amount: d.amount / 100 })),
title: i.product_title,
sku: i.sku,
vendor: i.vendor,
image: i.image,
url: i.url,
variant_id: i.variant_id,
variant_title: i.variant_title,
variant_inventory_quantity: i.variant_inventory_quantity,
}));
await instance.commands.setCart({
token: cart.token,
items,
discount_codes: cart.discount_codes || [],
});
}
See Install on Shopify for the full block source.
WooCommerce
Render the cart from PHP on page load, then re-sync on AJAX cart events (WooCommerce fires wc_fragment_refresh and updated_cart_totals):
<script>
window.simplyClub?.commands.setCart({
token: "wc-<?php echo esc_js(WC()->session?->get_customer_id()); ?>",
items: [
<?php foreach (WC()->cart->get_cart() as $item):
$product = $item['data']; ?>
{
id: "<?php echo esc_js($item['key']); ?>",
quantity: <?php echo (int) $item['quantity']; ?>,
price: <?php echo (float) $product->get_price(); ?>,
final_price: <?php echo (float) $product->get_price(); ?>,
title: "<?php echo esc_js($product->get_name()); ?>",
sku: "<?php echo esc_js($product->get_sku()); ?>",
image: "<?php echo esc_js(wp_get_attachment_url($product->get_image_id())); ?>",
url: "<?php echo esc_js(get_permalink($product->get_id())); ?>"
},
<?php endforeach; ?>
]
});
jQuery(document.body).on('updated_cart_totals wc_fragments_refreshed', () => {
fetch('/?wc-ajax=get_refreshed_fragments')
.then((r) => r.json())
.then(() => location.reload()); // simplest: reload to re-render PHP
});
</script>
For SPAs or AJAX-heavy themes, expose a small REST endpoint that returns the cart as JSON and call it from JS instead of reloading.
Custom integration / SPA
Wire setCart to your state-management layer. Any time the cart slice changes, push it.
import { dropInLoader } from "https://dropins.simplyclub.co.il/drop-ins/drop-in-loader/index.mjs";
import { useEffect } from "react";
import { useCart } from "./store/cart";
const instance = await dropInLoader.setConfig({ poiId: "YOUR_POI_ID" });
function CartSync() {
const cart = useCart();
useEffect(() => {
instance.commands.setCart({
token: cart.token,
items: cart.items.map((i) => ({
id: i.id,
quantity: i.qty,
price: i.unitPrice, // major units
final_price: i.unitPrice - i.lineDiscount / i.qty,
title: i.name,
sku: i.sku,
image: i.image,
})),
discount_codes: cart.couponCodes,
});
}, [cart]);
return null;
}
Verifying the sync
- Open your storefront and start the drop-in.
- In DevTools:
await window.simplyClub.commands.getCart()— confirm the returned cart matches what's in your storefront. - Add or remove an item. Re-run
getCart()— the change should be reflected. - Open the drop-in modal. The displayed totals and applicable benefits should match the current cart.
If the drop-in shows stale data, your re-sync trigger isn't firing on that mutation type.
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Benefits don't apply, but the cart looks right in the storefront | Prices off by 100× (sent cents instead of major units). | Divide by 100 before calling setCart — or use your platform's display price. |
| The drop-in shows the cart from a previous page | Initial setCart was skipped or setConfig ran before the cart was ready. |
Await both the cart fetch and setConfig before the first setCart. |
| Benefit gets reset every few seconds | Loop — your detector is treating SimplyClub's own writes as external. | Tag SimplyClub-originated writes and skip them in your detector. |
| Items appear in the drop-in with no name or price | Missing required fields (title/name, price, final_price). |
Provide at least id, quantity, price, final_price, plus a display name. |
| Cart sync stops after an AJAX update | Your re-sync trigger only fired the first time. | Subscribe to the event your platform emits on every cart change, not just the initial render. |
Related
- API Reference — full
commandsandOn.*surface. - Install on Shopify — the Theme App Block handles cart sync automatically.
- Install on WordPress & Web — where to put the sync code.
- Order Webhook (Generic) — what happens to the cart after checkout.