Customer Section Data: The Points Balance in the Mini-Cart/Checkout via AJAX
Customer Section Data: The Points Balance in the Mini-Cart/Checkout via AJAX
~6 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapters 80-83 opened the points balance up to external clients via REST and GraphQL. For the storefront's own Hyvä frontend, both are overkill: neither the mini-cart header nor the checkout payment step from chapter 63/65 should fire a dedicated GraphQL request on every page load just to display the points balance. That's exactly what Magento's customer section data mechanism exists for - the same one already feeding the cart, wishlist, and compare list in this theme's header.
How customer section data works
A central AJAX call (customer/section/load, from the core Magento_Customer module, not rebuilt here) loads all registered "sections" in one bundled request on page load and after specific actions declared in sections.xml, storing them in localStorage. A private-content-loaded event on window then notifies every Alpine component that wants to listen - exactly the pattern this theme's header.phtml already uses for the compare list (initCompareHeader/receiveCompareData) and the wishlist.
The new section: loyalty_points
sections.xml declares after which controller actions the new loyalty_points section (and the core cart section) get marked stale and reloaded on the next request - the redemption Redeem\Index controller from chapter 50, as well as the core "add to cart" controller, since a purchase through the "points package" product type built in chapter 76 also changes the points balance:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Customer:etc/sections.xsd">
<action name="mironsoft_loyalty/redeem/index">
<section name="loyalty_points"/>
<section name="cart"/>
</action>
<action name="checkout/cart/add">
<section name="loyalty_points"/>
</action>
</config>
The data source: CustomerData\PointsBalance
SectionSourceInterface requires exactly one method - getSectionData(): array - whose return value ends up as JSON in localStorage. Deliberately CustomerSession::getCustomerData() instead of CustomerRepositoryInterface::getById(): sections run in the storefront request context, where the session is already loaded anyway, and an extra repository call would just be unnecessary database load - unlike the observers/API classes from blocks 4/10, which have no session context.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\CustomerData;
use Magento\Customer\CustomerData\SectionSourceInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Mironsoft\Loyalty\Model\Util\PointsFormatter;
/**
* Exposes the current customer's points balance and tier as customer
* section data - consumed by Alpine components in the mini-cart and
* checkout without a dedicated AJAX round trip.
*/
class PointsBalance implements SectionSourceInterface
{
/**
* @param CustomerSession $customerSession Provides the currently logged-in customer.
*/
public function __construct(
private readonly CustomerSession $customerSession,
) {
}
/**
* @inheritDoc
*/
public function getSectionData(): array
{
if (!$this->customerSession->isLoggedIn()) {
return [
'balance' => 0,
'formatted_balance' => PointsFormatter::formatPoints(0),
'tier' => null,
];
}
$customer = $this->customerSession->getCustomerData();
$attribute = $customer?->getCustomAttribute('loyalty_points_balance');
$balance = $attribute !== null ? (int) $attribute->getValue() : 0;
$tierAttribute = $customer?->getCustomAttribute('loyalty_tier');
$tier = $tierAttribute !== null ? (string) $tierAttribute->getValue() : null;
return [
'balance' => $balance,
'formatted_balance' => PointsFormatter::formatPoints($balance),
'tier' => $tier,
];
}
}
Registration: etc/frontend/di.xml
The section name loyalty_points connects sections.xml (above) to the PHP class via SectionPoolInterface's sectionSourceMap argument - the same pattern Magento_Checkout uses internally to register its own cart section:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Customer\CustomerData\SectionPoolInterface">
<arguments>
<argument name="sectionSourceMap" xsi:type="array">
<item name="loyalty_points" xsi:type="string">Mironsoft\Loyalty\CustomerData\PointsBalance</item>
</argument>
</arguments>
</type>
</config>
Alpine instead of Knockout across the rest of the storefront
Unlike the checkout payment step from chapter 65 (Knockout there is mandatory, since Magento_Checkout itself is a Knockout SPA), this theme's mini-cart header runs entirely on Alpine.js - CLAUDE.md's "no Knockout.js, no jQuery" rule applies here without exception. An example badge that mirrors initCompareHeader/receiveCompareData from Magento_Theme::html/header.phtml exactly:
<div
x-data="initLoyaltyPointsBadge"
@private-content-loaded.window="receiveLoyaltyPointsData"
class="flex items-center gap-1 text-sm text-brand-slate"
>
<span x-text="formattedBalance"></span>
<span x-show="tier" x-text="tier" class="uppercase text-xs font-semibold"></span>
</div>
<script>
function initLoyaltyPointsBadge() {
return {
formattedBalance: '0',
tier: null,
receiveLoyaltyPointsData() {
const data = this.$event.detail.data;
if (data['loyalty_points']) {
this.formattedBalance = data['loyalty_points'].formatted_balance;
this.tier = data['loyalty_points'].tier;
}
}
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('initLoyaltyPointsBadge', initLoyaltyPointsBadge);
}, { once: true });
</script>
<?php $hyvaCsp->registerInlineScript() ?>
this.$event.detail.data['loyalty_points'] reaches exactly the return value of PointsBalance::getSectionData() - the same name di.xml just registered as the key of the sectionSourceMap. Any later renaming of the section has to stay in sync at both spots; a misspelled section name in the Alpine template fails silently - data['loyalty_points'] is then simply undefined, with no error message in the browser.
Achtung: The @hyvaCsp->registerInlineScript() call right after the <script> block is mandatory (CLAUDE.md), not optional - without it, Hyvä's content security policy module blocks the inline handler in production, and the badge component stays stuck at formattedBalance: '0' forever, with no obvious error in the browser console - just a Refused to execute inline script CSP report.
With read access (REST/GraphQL, chapters 80/82), write access (REST/GraphQL, chapters 81/83), and now display access (customer section data, this chapter) all covered, every practical way of reaching the points balance is now in place. Chapter 85 shifts to the meta level: how do all these contracts stay stable over time as the module keeps evolving?