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

Customer Section Data: Punktestand im Mini-Cart/Checkout per AJAX

Customer Section Data: Punktestand im Mini-Cart/Checkout per AJAX

~6 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Kapitel 80-83 haben den Punktestand für externe Clients über REST und GraphQL geöffnet. Für das eigene Hyvä-Frontend selbst ist beides überdimensioniert: Weder der Mini-Cart-Header noch der Checkout-Payment-Schritt aus Kapitel 63/65 sollen bei jedem Seitenaufruf eine eigene GraphQL-Anfrage abschicken, nur um den Punktestand anzuzeigen. Genau für diesen Fall existiert Magentos Customer Section Data-Mechanismus - derselbe, der Warenkorb, Wishlist und Vergleichsliste bereits im Header dieses Themes versorgt.

Wie Customer Section Data funktioniert

Ein zentraler AJAX-Aufruf (customer/section/load, aus dem Magento_Customer-Kern-Modul, hier nicht neu gebaut) lädt beim Seitenaufruf und nach bestimmten, in sections.xml deklarierten Aktionen alle registrierten "Sections" gebündelt nach und legt sie im localStorage ab. Ein private-content-loaded-Event auf window benachrichtigt anschließend jede Alpine-Komponente, die zuhören möchte - exakt das Muster, das header.phtml dieses Themes bereits für Vergleichsliste (initCompareHeader/receiveCompareData) und Wishlist nutzt.

Die neue Section: loyalty_points

sections.xml erklärt, nach welchen Controller-Aktionen die neue loyalty_points-Section (und die Core-cart-Section) als veraltet markiert und beim nächsten Request neu geladen werden - dem redeemenden Redeem\Index-Controller aus Kapitel 50 sowie dem Core-"In den Warenkorb"-Controller, da ein Kauf über den in Kapitel 76 gebauten "Punkte-Paket"-Produkttyp den Punktestand ebenfalls ändert:

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>

Die Datenquelle: CustomerData\PointsBalance

SectionSourceInterface verlangt genau eine Methode - getSectionData(): array - deren Rückgabewert später als JSON im localStorage landet. Bewusst CustomerSession::getCustomerData() statt CustomerRepositoryInterface::getById(): Sections laufen im Storefront-Request-Kontext, wo die Session ohnehin schon geladen ist und ein zusätzlicher Repository-Aufruf nur unnötige Datenbanklast wäre - anders als in den Observern/API-Klassen aus Block 4/10, die keinen Session-Kontext haben.

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,
        ];
    }
}

Registrierung: etc/frontend/di.xml

Der Section-Name loyalty_points verbindet sections.xml (oben) mit der PHP-Klasse über SectionPoolInterfaces sectionSourceMap-Argument - dasselbe Muster, mit dem Magento_Checkout intern seine eigene cart-Section registriert:

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 statt Knockout im übrigen Storefront

Anders als der Checkout-Payment-Schritt aus Kapitel 65 (dort zwingend Knockout, weil Magento_Checkout selbst eine Knockout-SPA ist) läuft der Mini-Cart-Header dieses Themes komplett auf Alpine.js - CLAUDE.mds Vorgabe "kein Knockout.js, kein jQuery" gilt hier uneingeschränkt. Ein Beispiel-Badge, das exakt initCompareHeader/receiveCompareData aus Magento_Theme::html/header.phtml nachbildet:

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'] greift exakt auf den Rückgabewert von PointsBalance::getSectionData() zu - derselbe Name, den di.xml gerade als Schlüssel der sectionSourceMap registriert hat. Jede spätere Umbenennung der Section muss an beiden Stellen synchron bleiben; eine falsch geschriebene Section im Alpine-Template scheitert dabei still - data['loyalty_points'] ist dann einfach undefined, ohne Fehlermeldung im Browser.

Achtung: Der @hyvaCsp->registerInlineScript()-Aufruf direkt nach dem <script>-Block ist Pflicht (CLAUDE.md), nicht optional - ohne ihn blockiert Hyväs Content-Security-Policy-Modul den Inline-Handler in Produktion, und die Badge-Komponente bleibt dauerhaft bei formattedBalance: '0' hängen, ohne dass die Browser-Konsole einen offensichtlichen Fehler zeigt - nur ein Refused to execute inline script-CSP-Report.

Mit Lese- (REST/GraphQL, Kapitel 80/82), Schreib- (REST/GraphQL, Kapitel 81/83) und jetzt Anzeige-Zugriff (Customer Section Data, dieses Kapitel) sind alle drei praktischen Zugriffswege auf den Punktestand abgedeckt. Kapitel 85 wechselt zur Meta-Ebene: Wie bleiben all diese Verträge über die Zeit stabil, wenn sich das Modul weiterentwickelt?