Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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:

app/code/Mironsoft/Loyalty/etc/frontend/sections.xml
<?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.

app/code/Mironsoft/Loyalty/CustomerData/PointsBalance.php
<?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:

app/code/Mironsoft/Loyalty/etc/frontend/di.xml
<?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:

app/code/Mironsoft/Loyalty/view/frontend/templates/customer-data/points-badge.phtml
<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?