Documentation

Shopping Cart Sync

Synchronize the Xinfer AI shopping cart with your store's native cart using DOM events. The widget runs on your page (not an iframe), so both sides share window.

Shopping Cart Demo — Interactive cart sync demo — see events firing in real-time between the widget and this simulated host cart.


Quick Start

1. Enable Cart Sync

Set cartSync before the widget script loads:

<script>
  window.XinferChat = { cartSync: true, cartInitMerge: true };
</script>
<script src="https://[subdomain].xinfer.ai/widget.js" async></script>
OptionDefaultDescription
cartSyncfalseEnable cart sync events between widget and host
cartInitMergetrueBi-directional merge of host and widget carts on first sync

cartInitMerge

When the widget first connects to the host adapter, it performs a bi-directional merge between the host cart and the widget's server-side cart. This ensures both sides start in sync — items that were added via full chat, voice chat, or a previous page visit are merged into the host cart, and host-only items are saved to the widget.

The merge runs once per widget lifecycle when the host's first xinfer:cart:response arrives (triggered by the ready → request → response handshake). For each item, the higher quantity wins:

  • Host-only items are added to the widget cart
  • Widget-only items are added to the host cart (via xinfer:cart:action add)
  • Both sides have the item — the side with the larger quantity wins

cartInitMerge is enabled by default. To disable it:

<script>
  window.XinferChat = { cartSync: true, cartInitMerge: false };
</script>

When disabled, the widget will still sync cart state via events, but will skip the bi-directional merge. Instead, the widget adopts the host cart as its starting state on page load (one-way copy from host to widget).

2. Build an Adapter

Your adapter bridges the widget's events to your store's cart API. It must:

  1. Fire xinfer:cart:ready on mount
  2. Respond to xinfer:cart:request with your current cart
  3. Handle xinfer:cart:action events from the widget (add, remove, update, empty, sync)
  4. Detect host cart changes and dispatch xinfer:cart:action with action: "sync" and the full items array

Key rule: The sync action is designed to be safe — when in doubt, send a sync with your full cart. The receiver compares the incoming items with its own cart and only applies changes if there's an actual difference. A skip counter or flag can further reduce unnecessary round-trips, but is not strictly required when using sync.


Events

All events are CustomEvents on window. Each includes a source field ("host" or "widget") to prevent infinite loops — only handle events from the other side.

EventDirectionPayload
xinfer:cart:readyHost -> Widget{ source: "host" }
xinfer:cart:requestEither -> Either{ source }
xinfer:cart:responseEither -> Either{ source, items: XinferCartItem[] }
xinfer:cart:actionEither -> Either{ source, action, item?, items? }

Actions

ActionDescription
addAdd item to cart. Resolved via id, sku, title.
removeRemove item by id (variant ID)
updateUpdate item quantity
emptyClear all items (checkout or manual clear)
syncReplace cart with the provided items array. See Sync Action.

XinferCartItem

type XinferCartItem = {
  id?: string;         // product id
  sku?: string;        // fallback match key
  title: string;       // may be used in match
  quantity: number;
  unit_price: number;
  currency?: string;
  image_url?: string;
  url?: string;
};

The widget passes whichever identifiers the AI returned — typically id, sku, title. Your adapter should resolve these to a platform-native sellable item. For Shopify, it is a product variant - GID (gid://shopify/ProductVariant/${id}). Fall back to sku and title search.


Sync Action

The sync action declares "here is my full cart — make yours match". The sender provides the entire items array; the receiver diffs it against its own cart and applies only the necessary changes (add missing items, remove extra items, update quantities that differ). If the carts already match, the receiver does nothing.

xinfer:cart:action { source, action: "sync", items: XinferCartItem[] }

When to use sync:

  • Your cart framework (e.g. React's useCart) gives you the full item list when the cart changes, but not what changed
  • You don't have the previous state to compute a diff
  • You want a simpler integration that doesn't require diffing logic
  • When in doubt — sync is always safe to send

How the receiver handles it:

  • Widget receives sync from host — diffs against the widget cart and applies changes (the widget uses a PUT for efficiency since its cart is a simple JSON store)
  • Host receives sync from widget — diffs against its current cart, then only adds missing items, removes extra items, and updates changed quantities. Do not empty the cart and re-add everything — that causes unnecessary API calls and UI flicker in frameworks like React.

Key rule: The sync action is inherently safe from infinite loops — when the carts already match, the receiver has nothing to change and generates no outbound events. A skip counter can reduce unnecessary diff work, but the protocol is safe without one.

Both the React and Vanilla JS reference adapters use sync as their primary outbound action.


Reference Adapter (React + Shopify)

Below is a complete adapter for a Next.js Shopify store. Adapt the cart API calls to your platform.

The adapter uses a server-side findItem function that resolves any combination of identifiers (id, sku, title, url) to a Shopify variant GID. This keeps resolution logic on the server where it has access to the Storefront API.

The adapter uses the sync action to notify the widget whenever the host cart changes. This avoids diffing — useCart already provides the full item list, so we just send it. A mounted ref skips the very first sync effect run — the ready → request → response handshake handles initial state, so firing a sync before listeners are registered is wasteful. A skip counter reduces unnecessary no-op syncs: before each Shopify mutation triggered by a widget event, the counter is incremented. When the sync effect sees the resulting cart change, it decrements the counter and skips dispatching. This is an optimization — the protocol is safe without it since the receiver ignores syncs that don't change anything.

"use client";

import type { CartItem } from "lib/shopify/types";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import {
  addItem,
  emptyCartAction,
  findItem,
  removeItem,
  updateItemQuantity,
} from "./cart/actions";
import { useCart } from "./cart/cart-context";

type XinferCartItem = {
  id?: string;         // product id
  sku?: string;        // fallback match key
  title: string;       // may be used in match
  quantity: number;
  unit_price: number;
  currency?: string;
  image_url?: string;
  url?: string;
  metadata?: Record<string, unknown>;
};

function dispatch(eventName: string, detail: unknown) {
  window.dispatchEvent(new CustomEvent(eventName, { detail }));
}

function toXinferItem(item: CartItem): XinferCartItem {
  const unitPrice =
    Number(item.cost.totalAmount.amount) / Math.max(item.quantity, 1);

  return {
    id: item.merchandise.id,
    sku: item.merchandise.sku ?? undefined,
    title:
      item.merchandise.product.title +
      (item.merchandise.title !== "Default Title"
        ? ` - ${item.merchandise.title}`
        : ""),
    quantity: item.quantity,
    unit_price: unitPrice,
    currency: item.cost.totalAmount.currencyCode,
    image_url: item.merchandise.product.featuredImage?.url,
    url: `/products/${item.merchandise.product.handle}?variant=${item.merchandise.id}`,
  };
}

export function XinferCartAdapter() {
  const { cart } = useCart();
  const router = useRouter();
  const lines = cart?.lines ?? [];
  const linesRef = useRef(lines);

  // Skip counter: incremented before each Shopify mutation triggered by a
  // widget action, decremented when the sync effect sees the resulting cart
  // change. This prevents echoing events back for changes caused by
  // processing received events.
  const skipRef = useRef(0);
  const mountedRef = useRef(false);

  // Keep ref in sync so event handlers always read fresh data
  useEffect(() => {
    linesRef.current = lines;
  }, [lines]);

  // Sync host cart to widget whenever it changes.
  // Skip the very first run — the ready event + widget request flow
  // handles the initial state, so we don't need a premature sync.
  useEffect(() => {
    if (!mountedRef.current) {
      mountedRef.current = true;
      return;
    }

    if (skipRef.current > 0) {
      skipRef.current--;
      return;
    }

    dispatch("xinfer:cart:action", {
      source: "host",
      action: "sync",
      items: lines.map(toXinferItem),
    });
  }, [lines]);

  // Set up event listeners (once) and signal ready
  useEffect(() => {
    function onRequest(e: Event) {
      const { source } = (e as CustomEvent).detail ?? {};
      if (source === "widget") {
        dispatch("xinfer:cart:response", {
          source: "host",
          items: linesRef.current.map(toXinferItem),
        });
      }
    }

    async function resolveVariant(xinferItem: XinferCartItem) {
      return (
        (await findItem({
          id: xinferItem.id,
          sku: xinferItem.sku,
          title: xinferItem.title,
          url: xinferItem.url,
        })) ?? undefined
      );
    }

    async function applySync(syncItems: XinferCartItem[]) {
      const currentLines = linesRef.current;

      // Resolve incoming items to variant IDs
      const resolved: { variantId: string; quantity: number }[] = [];
      for (const si of syncItems) {
        const vid = await resolveVariant(si);
        if (vid) resolved.push({ variantId: vid, quantity: si.quantity });
      }

      // Diff current cart vs incoming sync
      const currentMap = new Map(
        currentLines.map((l) => [l.merchandise.id, l.quantity]),
      );
      const syncMap = new Map(
        resolved.map((r) => [r.variantId, r.quantity]),
      );

      // Add missing items / update changed quantities
      for (const { variantId, quantity } of resolved) {
        const cur = currentMap.get(variantId);
        if (cur === undefined) {
          await addItem(undefined, variantId);
          if (quantity > 1) {
            await updateItemQuantity(undefined, {
              merchandiseId: variantId,
              quantity,
            });
          }
        } else if (cur !== quantity) {
          await updateItemQuantity(undefined, {
            merchandiseId: variantId,
            quantity,
          });
        }
      }

      // Remove items not in sync
      for (const line of currentLines) {
        if (!syncMap.has(line.merchandise.id)) {
          await removeItem(undefined, line.merchandise.id);
        }
      }
    }

    async function onAction(e: Event) {
      const { action, item, items, source } = (e as CustomEvent).detail ?? {};
      if (source !== "widget") return;

      // Increment skip counter — the resulting cart change is caused by
      // processing this event, not by the host user.
      skipRef.current++;

      if (action === "empty") {
        await emptyCartAction();
      } else if (action === "sync") {
        await applySync(Array.isArray(items) ? items : []);
      } else if (action === "add" && item) {
        const variantId = await resolveVariant(item);
        if (!variantId) { skipRef.current--; return; }
        await addItem(undefined, variantId);
      } else if (action === "remove" && item) {
        const variantId = await resolveVariant(item);
        if (!variantId) { skipRef.current--; return; }
        await removeItem(undefined, variantId);
      } else if (action === "update" && item) {
        const variantId = await resolveVariant(item);
        if (!variantId) { skipRef.current--; return; }
        await updateItemQuantity(undefined, {
          merchandiseId: variantId,
          quantity: item.quantity ?? 0,
        });
      } else {
        skipRef.current--;
        return;
      }

      router.refresh();
    }

    window.addEventListener("xinfer:cart:request", onRequest);
    window.addEventListener("xinfer:cart:action", onAction);

    dispatch("xinfer:cart:ready", { source: "host" });

    return () => {
      window.removeEventListener("xinfer:cart:request", onRequest);
      window.removeEventListener("xinfer:cart:action", onAction);
    };
  }, [router]);

  return null;
}

The findItem server action resolves identifiers in priority order:

  1. ID — constructs the GID directly (gid://shopify/ProductVariant/${id})
  2. SKU — searches Shopify products by SKU
  3. URL — extracts ?variant= param or derives product handle from the path
  4. Title — searches Shopify products by title (least reliable)

Mount inside your cart provider so it has access to cart state:

// app/layout.tsx
<CartProvider>
  <XinferCartAdapter />
  {children}
</CartProvider>

Vanilla JS Adapter

If you're not using React, the same pattern works with plain JavaScript. The processingWidgetAction flag is an optional optimization that skips no-op syncs for cart changes caused by processing widget actions.

<script>
  window.XinferChat = { cartSync: true };

  let processingWidgetAction = false;

  function dispatch(event, detail) {
    window.dispatchEvent(new CustomEvent(event, { detail }));
  }

  function getCartItems() {
    // Replace with your cart API
    return getYourCartItems().map(item => ({
      id: item.variantId,
      sku: item.sku,
      title: item.name,
      quantity: item.qty,
      unit_price: item.price,
      image_url: item.image,
    }));
  }

  // Send full cart to widget via sync — no diffing needed.
  // Call this whenever your cart changes (e.g. after AJAX cart updates,
  // MutationObserver on cart DOM, or your framework's cart events).
  function syncCart() {
    if (processingWidgetAction) {
      processingWidgetAction = false;
      return;
    }

    const items = getCartItems();
    dispatch("xinfer:cart:action", { source: "host", action: "sync", items });
  }

  // Respond to widget cart requests with current host cart
  window.addEventListener("xinfer:cart:request", (e) => {
    if (e.detail?.source === "widget") {
      dispatch("xinfer:cart:response", { source: "host", items: getCartItems() });
    }
  });

  // Handle widget cart mutations (AI added/removed/updated items)
  window.addEventListener("xinfer:cart:action", async (e) => {
    const { action, item, items, source } = e.detail ?? {};
    if (source !== "widget") return;

    // Flag to suppress the sync for this widget-triggered change
    processingWidgetAction = true;

    switch (action) {
      case "add":
        await yourCartApi.add(item.id, item.quantity ?? 1);
        break;
      case "remove":
        await yourCartApi.remove(item.id);
        break;
      case "update":
        await yourCartApi.update(item.id, item.quantity);
        break;
      case "empty":
        await yourCartApi.clear();
        break;
      case "sync":
        await yourCartApi.clear();
        for (const syncItem of items || []) {
          await yourCartApi.add(syncItem.id, syncItem.quantity ?? 1);
        }
        break;
    }
  });

  // Signal ready — widget will request cart via xinfer:cart:request
  dispatch("xinfer:cart:ready", { source: "host" });
</script>
<script src="https://[subdomain].xinfer.ai/widget.js" async></script>

How It Works

Initial Sync

Host adapter mounts
  → fires xinfer:cart:ready { source: "host" }
  → widget hears ready, fires xinfer:cart:request { source: "widget" }
  → host responds with xinfer:cart:response { source: "host", items: [...] }
  → widget stores host cart in memory
  → init merge: aligns both carts (higher quantity wins)

Cart Request / Response

Either side fires xinfer:cart:request { source: "host"|"widget" }
  → the other side responds with xinfer:cart:response
  → widget always fetches from server (GET /api/embed/cart), never local cache
  → host responds from in-memory state

The source field identifies who is sending the request, not who should respond. Each side only handles requests from the other side:

  • source: "widget" → host's onRequest fires, host responds
  • source: "host" → widget's onRequest fires, widget responds

AI Mutates Cart

AI calls shopping_cart tool (add/remove/update)
  → widget updated server-side
  → widget fires xinfer:cart:action { source: "widget", action, item }
  → host adapter resolves item and applies the mutation

For remove actions, the widget sends { id } directly (the item is already gone from the post-removal cart). Your adapter should match by id to find the item to remove.

AI Checkout

AI calls shopping_cart "submit" → order created, cart emptied
  → widget fires xinfer:cart:action { source: "widget", action: "empty" }
  → host adapter empties its cart

Host User Mutates Cart

User adds/removes/updates item on the host site
  → adapter fires xinfer:cart:action { source: "host", action: "sync", items: [...] }
  → widget PUTs the full items array to its DB cart

The sync action sends the full cart — no diffing required. The receiver only applies changes if the items differ from its current cart, so echoing back is naturally suppressed. A skip counter can further optimize by avoiding no-op syncs.

Host Checkout

User checks out on host site → cart empties
  → adapter detects lines went from >0 to 0
  → fires xinfer:cart:action { source: "host", action: "empty" }
  → widget empties its DB cart via DELETE /api/embed/cart

Cross-Tab Sync (Socket.IO)

When a user opens full chat in a new tab or uses voice chat, cart mutations are pushed to the widget tab via Socket.IO cart-updated events. The widget tab then dispatches the same DOM events to the host adapter — no extra code needed in your adapter. All cart actions (add, remove, update, empty, checkout) are synced in real-time across tabs.


Live Demo

See cart sync in action on the Shopping Cart Demo page. It shows the event flow between host and widget in real time and serves as a playground for testing sync between the host cart and the AI agent cart.


Related Pages