eigener Tab mit ViewModel und Alpine.js statt Core-Override
Ein eigener Treuepunkte-Tab im Kundenkonto-Dashboard zeigt exemplarisch, wie sich Magento_Customer sauber erweitern lässt: eine Layout-XML-Ergänzung für die Sidebar-Navigation, ein Controller mit Kunden-Authentifizierung, ein ViewModel für die Daten und Alpine.js für Filterung und Detailansicht ohne Page-Reload, alles ohne ein einziges Core-Template zu kopieren.
Inhaltsverzeichnis
- 1. Warum Unternehmen das Kundenkonto-Dashboard erweitern
- 2. Kontonavigation in Magento_Customer: customer_account.xml und die Sidebar
- 3. Layout-XML: neuen Tab in der Sidebar registrieren
- 4. Controller-Klasse: eigene Route für den neuen Tab
- 5. ViewModel: Daten für den neuen Tab bereitstellen
- 6. Template: das Markup des neuen Tabs
- 7. Alpine.js: Progressive Enhancement ohne Page-Reload
- 8. ACL und Sichtbarkeit: wer sieht den neuen Tab
- 9. Kundenkonto-Dashboard-Patterns im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Unternehmen das Kundenkonto-Dashboard erweitern
Das Standard-Mein-Konto von Magento deckt Bestellungen, Adressen und Kontodaten ab, reicht aber für viele Projekte nicht aus. Sobald ein Shop Treuepunkte, B2B-Freigabeprozesse oder kundenspezifische Dokumente anbieten will, braucht das Kundenkonto-Dashboard zusätzliche Tabs, die sich nahtlos in die bestehende Sidebar-Navigation einfügen. In diesem Beitrag bauen wir exemplarisch einen Treuepunkte-Tab, das Muster lässt sich jedoch eins zu eins auf einen Dokumente-Bereich oder eine B2B-Freigabeübersicht übertragen.
Entscheidend ist, wie diese Erweiterung technisch umgesetzt wird. Wer Core-Templates von Magento_Customer kopiert oder Blöcke per Preference ersetzt, verliert bei jedem Magento-Update den Anschluss und riskiert Merge-Konflikte. Die folgenden Abschnitte zeigen den additiven Weg über eigenes Modul, Layout-XML, einen Controller mit korrekter Kunden-Authentifizierung, ein ViewModel für die Daten sowie Alpine.js für die Interaktivität im neuen Bereich.
2. Kontonavigation in Magento_Customer: customer_account.xml und die Sidebar
Die Sidebar-Navigation des Kundenkonto-Dashboards wird von Magento_Customer über die Layout-Handle customer_account.xml aufgebaut. Zentral ist der Container customer_account_navigation, in den jeder einzelne Menüpunkt als eigener Block vom Typ Magento\Framework\View\Element\Html\Link\Current eingehängt wird. Jeder Link erhält als Argumente einen path, ein label und einen sortOrder, über den die Reihenfolge in der Sidebar gesteuert wird. Es gibt also gar keine zentrale, hartkodierte Liste von Menüpunkten, sondern ein per Layout-XML zusammengestecktes Set von Blöcken.
In Luma wird dieser Container über ein Widget mit Knockout-Bindings gerendert, inklusive Beobachtbarkeit und Client-Templates. Hyvä ersetzt das durch ein einfaches phtml-Template, das die sortierten Kind-Blöcke iteriert und als reines HTML mit Tailwind-Klassen ausgibt, ganz ohne Knockout.js und ohne UI-Components. Für die Erweiterung dieses Bereichs bedeutet das: Ein neuer Menüpunkt ist einfach ein weiterer Html\Link\Current-Block im selben Container, keine Anpassung des Navigation-Templates selbst nötig.
3. Layout-XML: neuen Tab in der Sidebar registrieren
Der additive Weg beginnt mit zwei Layout-Dateien im eigenen Modul. Die erste referenziert customer_account_navigation und hängt den neuen Link für den Treuepunkte-Tab ein, inklusive Sortierposition relativ zu einem bestehenden Link. Die zweite Datei ist an die Layout-Handle der neuen Route gebunden und definiert, welcher Block mit welchem Template im Content-Bereich gerendert wird, sobald der Tab im Kundenkonto-Dashboard aufgerufen wird.
<!-- File: app/code/Vendor/LoyaltyPoints/view/frontend/layout/customer_account.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="customer_account_navigation">
<!-- Insert a new sidebar link into the existing Hyvä navigation container -->
<block class="Magento\Framework\View\Element\Html\Link\Current"
name="customer-account-navigation-loyalty-points-link"
after="customer-account-navigation-orders-link">
<arguments>
<argument name="path" xsi:type="string">loyaltypoints/index/index</argument>
<argument name="label" xsi:type="string" translate="true">Loyalty Points</argument>
<argument name="sortOrder" xsi:type="number">25</argument>
</arguments>
</block>
</referenceBlock>
</body>
</page>
<!-- File: app/code/Vendor/LoyaltyPoints/view/frontend/layout/loyaltypoints_index_index.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<!-- Inherit the account wrapper, header and sidebar from customer_account.xml -->
<update handle="customer_account"/>
<body>
<referenceContainer name="content">
<block class="Magento\Framework\View\Element\Template"
name="customer.loyalty.points.tab"
template="Vendor_LoyaltyPoints::tab/history.phtml">
<arguments>
<argument name="view_model" xsi:type="object">Vendor\LoyaltyPoints\ViewModel\LoyaltyPointsProvider</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
Die Zeile <update handle="customer_account"/> ist der Schlüssel dafür, dass der neue Tab dieselbe Sidebar, denselben Header und dasselbe Wrapper-Markup erhält wie jeder andere Bereich des Kontos. Ohne diese Zeile würde die neue Seite zwar existieren, aber optisch isoliert wirken, ohne Navigation und ohne Konto-Kontext.
4. Controller-Klasse: eigene Route für den neuen Tab
Damit der neue Tab im Kundenkonto-Dashboard nur für angemeldete Kunden erreichbar ist, muss der Controller das Marker-Interface Magento\Customer\Controller\AccountInterface implementieren. Ein Plugin von Magento_Customer prüft bei jedem Dispatch, ob die aufgerufene Action dieses Interface implementiert, und leitet nicht angemeldete Besucher automatisch zur Login-Seite um, inklusive Rücksprung zur ursprünglich angeforderten Seite nach erfolgreichem Login. Diese Prüfung muss man nicht selbst schreiben.
<?php
declare(strict_types=1);
namespace Vendor\LoyaltyPoints\Controller\Account;
use Magento\Customer\Controller\AbstractAccount;
use Magento\Customer\Controller\AccountInterface;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\Result\PageFactory;
use Magento\Framework\Controller\ResultInterface;
/**
* Renders the loyalty points tab inside the customer account dashboard.
*/
class Index extends AbstractAccount implements HttpGetActionInterface, AccountInterface
{
/**
* @param Context $context Request/response context required by AbstractAccount.
* @param PageFactory $resultPageFactory Builds the CMS-style result page.
*/
public function __construct(
Context $context,
private readonly PageFactory $resultPageFactory
) {
parent::__construct($context);
}
/**
* Build the page result for the loyalty points tab.
*
* @return ResultInterface
*/
public function execute(): ResultInterface
{
$resultPage = $this->resultPageFactory->create();
$resultPage->getConfig()->getTitle()->set((string) __('Loyalty Points'));
return $resultPage;
}
}
Weil der Controller von AbstractAccount erbt, erhält er dieselbe Basis-Infrastruktur wie jede andere Aktion im Kontobereich. Konstruktor-Property-Promotion aus PHP 8.4 hält die Klasse kompakt, ohne dass Abhängigkeiten manuell auf Property-Felder abgebildet werden müssen. Die eigentliche Datenlogik gehört bewusst nicht in den Controller, sondern in ein ViewModel, das im nächsten Abschnitt entsteht.
5. ViewModel: Daten für den neuen Tab bereitstellen
Ein ViewModel, das ArgumentInterface implementiert, ist der bevorzugte Weg, um Daten in ein phtml-Template zu reichen, ohne Geschäftslogik in eine Block-Klasse zu zwingen. Für den neuen Tab im Kundenkonto-Dashboard übernimmt das ViewModel zwei Aufgaben: den aktuellen Punktestand berechnen und die chronologische Historie der Punktebewegungen liefern, jeweils gebunden an den eingeloggten Kunden aus der Customer-Session.
<?php
declare(strict_types=1);
namespace Vendor\LoyaltyPoints\ViewModel;
use Magento\Customer\Model\Session;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Vendor\LoyaltyPoints\Model\PointsHistoryRepository;
/**
* Supplies loyalty points balance and history to the account dashboard tab.
*/
class LoyaltyPointsProvider implements ArgumentInterface
{
/**
* @param Session $customerSession Current frontend customer session.
* @param PointsHistoryRepository $pointsHistoryRepository Reads point transactions.
*/
public function __construct(
private readonly Session $customerSession,
private readonly PointsHistoryRepository $pointsHistoryRepository
) {
}
/**
* Current point balance of the logged-in customer.
*
* @return int
*/
public function getBalance(): int
{
$customerId = (int) $this->customerSession->getCustomerId();
return $this->pointsHistoryRepository->getBalanceForCustomer($customerId);
}
/**
* Chronological list of point transactions for the current customer.
*
* @return array<int, array{date: string, points: int, reason: string}>
*/
public function getHistory(): array
{
$customerId = (int) $this->customerSession->getCustomerId();
return $this->pointsHistoryRepository->getHistoryForCustomer($customerId);
}
}
Diese Trennung zahlt sich vor allem bei Tests aus: Das ViewModel lässt sich isoliert instanziieren und prüfen, ohne einen kompletten Layout-Baum zu rendern. Für das Template des neuen Tabs bedeutet es außerdem, dass keinerlei Repository- oder Session-Aufrufe direkt im phtml auftauchen, das Template bleibt reine Präsentationsschicht.
6. Template: das Markup des neuen Tabs
Im Template wird das ViewModel über $block->getData('view_model') abgerufen. Die Historie wird als JSON in den Alpine-Ausdruck geschrieben, damit die Komponente ohne zusätzlichen Ajax-Request direkt mit den bereits vom Server gelieferten Daten startet. Das entspricht dem üblichen Hyvä-Muster: Server liefert Daten, Alpine übernimmt ausschließlich die Interaktion im Browser, kein Knockout, kein jQuery.
<!-- File: app/code/Vendor/LoyaltyPoints/view/frontend/templates/tab/history.phtml -->
<?php
/** @var \Vendor\LoyaltyPoints\ViewModel\LoyaltyPointsProvider $viewModel */
$viewModel = $block->getData('view_model');
$history = $viewModel->getHistory();
?>
<div class="customer-account-dashboard-loyalty"
x-data="loyaltyPointsTab(<?= /* @noEscape */ json_encode($history) ?>)">
<p class="text-lg font-semibold mb-4">
<?= $escaper->escapeHtml(__('Current balance')) ?>:
<span x-text="balance"></span> <?= $escaper->escapeHtml(__('points')) ?>
</p>
<input type="text" x-model="query"
placeholder="<?= $escaper->escapeHtmlAttr(__('Filter by reason')) ?>"
class="border rounded-lg px-3 py-2 mb-4 w-full sm:w-64">
<ul class="divide-y divide-slate-200">
<template x-for="entry in filtered" :key="entry.date + entry.reason">
<li class="py-3 cursor-pointer" @click="entry.open = !entry.open">
<div class="flex justify-between">
<span x-text="entry.date"></span>
<span x-text="entry.points"></span>
</div>
<div x-show="entry.open" x-collapse class="text-sm text-slate-500 mt-1" x-text="entry.reason"></div>
</li>
</template>
</ul>
</div>
<script>
// Alpine.js component: client-side filtering and expand/collapse, no page reload
document.addEventListener('alpine:init', () => {
Alpine.data('loyaltyPointsTab', (history) => ({
history: history.map((entry) => ({ ...entry, open: false })),
query: '',
get balance() {
return this.history.reduce((sum, entry) => sum + entry.points, 0);
},
get filtered() {
return this.history.filter((entry) =>
entry.reason.toLowerCase().includes(this.query.toLowerCase())
);
}
}));
});
</script>
<?php $hyvaCsp->registerInlineScript(); ?>
Auffällig ist die letzte Zeile: Jeder Inline-<script>-Block in einem Hyvä-Template muss unmittelbar danach mit $hyvaCsp->registerInlineScript() registriert werden, sonst blockiert die Content-Security-Policy das Skript im Browser lautlos. Ohne diese Zeile bleibt der Treuepunkte-Tab im Kundenkonto-Dashboard optisch vorhanden, aber komplett unreaktiv, ein Fehler, der in der Entwicklung leicht übersehen wird, weil er in der PHPStorm-Vorschau nicht auffällt.
7. Alpine.js: Progressive Enhancement ohne Page-Reload
Die Alpine.js-Komponente aus dem vorherigen Abschnitt bearbeitet zwei typische Anforderungen an interaktive Bereiche im Kundenkonto-Dashboard: Filterung einer Liste per Texteingabe über den berechneten Getter filtered, und das Auf- und Zuklappen einzelner Zeilen per Klick über entry.open in Kombination mit x-show und x-collapse. Beides passiert vollständig im Browser, ohne einen einzigen zusätzlichen Server-Request, was sich insbesondere bei langen Historienlisten spürbar schneller anfühlt als ein Formular-Submit mit Seiten-Reload.
// File: app/code/Vendor/LoyaltyPoints/view/frontend/web/js/loyalty-points-tab.js
// Alternative to the inline script: an external module needs no CSP hash
// registration at all, because Hyvä's CSP only restricts inline scripts.
document.addEventListener('alpine:init', () => {
Alpine.data('loyaltyPointsTab', (history) => ({
history: history.map((entry) => ({ ...entry, open: false })),
query: '',
sortKey: 'date',
get balance() {
return this.history.reduce((sum, entry) => sum + entry.points, 0);
},
get filtered() {
return this.history
.filter((entry) => entry.reason.toLowerCase().includes(this.query.toLowerCase()))
.sort((a, b) => (a[this.sortKey] > b[this.sortKey] ? 1 : -1));
},
toggleSort(key) {
this.sortKey = key;
}
}));
});
Wird die Komponente stattdessen als externe Datei im web/js-Verzeichnis des Moduls ausgeliefert und per Layout-XML im Head referenziert, entfällt der registerInlineScript()-Aufruf vollständig, weil die Hyvä-CSP ausschließlich Inline-Skripte einschränkt, nicht extern geladene Dateien. Für eine Komponente, die nur in diesem einen Tab gebraucht wird, ist die Inline-Variante meist pragmatischer, für wiederverwendbare Komponenten über mehrere Tabs hinweg lohnt sich die externe Datei.
8. ACL und Sichtbarkeit: wer sieht den neuen Tab
Zwei unterschiedliche Zugriffskonzepte spielen hier zusammen. Die klassische acl.xml regelt, welche Administratoren im Backend die Konfiguration des Treuepunkte-Moduls unter Stores > Configuration überhaupt sehen und ändern dürfen, das ist reine Admin-Berechtigung und hat mit dem Frontend nichts zu tun. Ob der Tab im Kundenkonto-Dashboard für einen konkreten Endkunden sichtbar ist, entscheidet stattdessen eine Kombination aus Scope-Config-Flag und optional der Kundengruppe.
Praktisch landet diese Prüfung im ViewModel als Methode isVisible(): bool, die den Konfigurationswert per ScopeConfigInterface und bei Bedarf die Kundengruppen-ID aus der Session auswertet. Das Template umschließt den kompletten Tab-Inhalt mit <?php if ($viewModel->isVisible()): ?>. Wichtiger ist jedoch, den Navigationslink selbst zu unterdrücken, wenn der Tab deaktiviert ist, denn ein sichtbarer Link zu einer leeren Seite wirkt im Kundenkonto-Dashboard unfertig. Dafür überschreibt eine eigene Block-Klasse anstelle des generischen Html\Link\Current die Methode _toHtml() und gibt einen leeren String zurück, sobald die Konfiguration den Tab deaktiviert, der Link verschwindet dann komplett aus der Sidebar statt nur unsichtbar per CSS zu sein.
9. Kundenkonto-Dashboard-Patterns im Vergleich
Die folgende Tabelle stellt für jede der vorgestellten Aufgaben den unsicheren beziehungsweise veralteten Ansatz dem empfohlenen Hyvä-Pattern gegenüber. Die Unterschiede wirken auf den ersten Blick klein, summieren sich aber über ein Projekt hinweg zu deutlich weniger Wartungsaufwand bei jedem Magento-Update.
| Aufgabe | Unsicher / veralteter Ansatz | Empfohlenes Hyvä-Pattern | Vorteil |
|---|---|---|---|
| Neuer Menüpunkt | Luma-Navigation-Widget kopieren | Block in customer_account_navigation |
Kein Core-Template dupliziert |
| Daten fürs Template | Logik direkt in der Block-Klasse | ViewModel (ArgumentInterface) |
Trennung von Rendering und Datenzugriff |
| Filtern/Sortieren | Formular-Submit mit Page-Reload | Alpine.js x-data / x-for |
Sofortiges Feedback im Browser |
| Sichtbarkeit steuern | If/else-Ketten im Template | ViewModel::isVisible() + Scope-Config | Zentral je Store-Ansicht konfigurierbar |
| Inline-JS im Tab | Script ohne CSP-Registrierung | $hyvaCsp->registerInlineScript() |
Kein CSP-Verstoß, funktioniert mit strict Policy |
Auffällig ist, dass keines der empfohlenen Patterns zusätzlichen Aufwand gegenüber dem unsicheren Ansatz bedeutet, es ist lediglich der andere Startpunkt in der Layout-XML oder Block-Struktur. Wer diese fünf Muster konsequent anwendet, reduziert die Angriffsfläche für kaputte Deployments bei jedem Magento-Minor-Update erheblich.
10. Zusammenfassung
Ein zusätzlicher Tab im Kundenkonto-Dashboard lässt sich in Hyvä vollständig additiv umsetzen: eine Layout-XML-Ergänzung hängt den Navigationslink in customer_account_navigation ein, eine zweite Layout-Datei bindet Controller und Template an die neue Route, ein Controller mit AccountInterface sorgt automatisch für die Kunden-Authentifizierung, und ein ViewModel trennt Datenzugriff von Präsentation. Für die Interaktivität im Tab genügt Alpine.js, es ersetzt Formular-Submits und Page-Reloads durch reaktive Filterung und Klapp-Elemente direkt im Browser.
Zwei Punkte werden in der Praxis am häufigsten übersehen: die Zeile <update handle="customer_account"/>, ohne die der neue Tab optisch aus dem Rahmen fällt, und der Aufruf $hyvaCsp->registerInlineScript() nach jedem Inline-Skript, ohne den die Content-Security-Policy die Interaktivität lautlos blockiert. Wer diese beiden Details beachtet, kann der Kontobereich beliebig oft um weitere Tabs erweitert werden, ohne dass sich das Wartungsrisiko mit jedem neuen Bereich erhöht.
Kundenkonto-Dashboard erweitern: das Wichtigste auf einen Blick
Navigation
Neuer Menüpunkt als Html\Link\Current-Block im Container customer_account_navigation, kein Template-Override nötig.
Controller & Auth
AbstractAccount und AccountInterface übernehmen die Kunden-Authentifizierung automatisch beim Dispatch.
ViewModel
ArgumentInterface-ViewModel liefert Daten an das Template, getrennt von Rendering-Logik und leicht testbar.
Alpine.js & CSP
x-data, x-for, x-show für Interaktivität, registerInlineScript() nach jedem Inline-Skript.
11. FAQ: Kundenkonto-Dashboard in Hyvä erweitern
1Was ist ein Kundenkonto-Dashboard in Hyvä genau?
2Wie füge ich einen neuen Menüpunkt hinzu?
3Muss ich Core-Templates überschreiben?
4Wofür braucht es das ViewModel?
5Wie funktioniert Filterung ohne Page-Reload?
6Warum keine doppelten geschweiften Klammern in Alpine.js?
7Wie steuere ich die Sichtbarkeit?
8Was, wenn registerInlineScript() fehlt?
9Auch für B2B-Freigaben oder Dokumente nutzbar?
10Wie teste ich die Einbindung?
Mironsoft
Hyvä-Entwicklung, ViewModels und maßgeschneiderte Kundenkonto-Bereiche
Eigenes Kundenkonto-Dashboard für Ihren Hyvä-Shop?
Wir erweitern Ihr Kundenkonto-Dashboard um Treuepunkte, Dokumente oder B2B-Freigaben, sauber über Layout-XML, ViewModel und Alpine.js, ohne ein einziges Core-Template zu überschreiben.
Konzeption
Analyse der bestehenden Kontonavigation und Planung neuer Tabs im Kundenkonto-Dashboard
Umsetzung
Modul, Layout-XML, Controller, ViewModel und Alpine.js-Komponenten nach Hyvä-Standard
ACL & CSP
Sichtbarkeitssteuerung, Berechtigungen und CSP-konforme Inline-Skripte