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

Responsive Frontend-Templates für Punktestand und Historie

Responsive Frontend-Templates für Punktestand und Historie

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

Alle Controller, Router, ViewModels und Layout-Dateien aus diesem Block stehen - dieses Kapitel füllt endlich die vier .phtml-Templates mit echtem Markup: die Punktestand-Widget-Vorlage, die Historie-Tabelle, die Katalog-Liste mit Filtern und die Detailseite samt Einlösen-Formular. Alle vier folgen demselben Tailwind-/Hyvä-Stil wie die separate Hyvä-Tutorial-Serie dieses Katalogs (tutorial-hyva-*) - Utility-Klassen direkt im Markup, kein eigenes CSS.

widget/points-balance.phtml

Wird zweimal eingebunden (Kapitel 51 und 52) - canShow() aus Kapitel 48 entscheidet als Erstes, ob überhaupt etwas gerendert wird:

app/code/Mironsoft/Loyalty/view/frontend/templates/widget/points-balance.phtml
<?php

declare(strict_types=1);

use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use Mironsoft\Loyalty\ViewModel\PointsBalance;

/**
 * @var Template $block
 * @var Escaper $escaper
 */
/** @var PointsBalance $viewModel */
$viewModel = $block->getViewModel();
?>
<?php if ($viewModel->canShow()): ?>
    <div class="my-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm sm:p-6">
        <dl class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
            <div>
                <dt class="text-sm font-medium text-gray-500">
                    <?= $escaper->escapeHtml(__('Your points balance')) ?>
                </dt>
                <dd class="text-3xl font-bold tracking-tight text-gray-900">
                    <?= $escaper->escapeHtml($viewModel->getFormattedPointsBalance()) ?>
                </dd>
            </div>
            <span class="inline-flex w-fit items-center rounded-full bg-amber-100 px-3 py-1 text-sm font-semibold capitalize text-amber-800">
                <?= $escaper->escapeHtml($viewModel->getTier()) ?>
            </span>
        </dl>
    </div>
<?php endif; ?>

history/index.phtml

app/code/Mironsoft/Loyalty/view/frontend/templates/history/index.phtml
<?php

declare(strict_types=1);

use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\ViewModel\PointsHistory;

/**
 * @var Template $block
 * @var Escaper $escaper
 */
/** @var PointsHistory $viewModel */
$viewModel = $block->getViewModel();
$entries = $viewModel->getEntries();
?>
<div class="mx-auto max-w-4xl px-4 sm:px-0">
    <h1 class="mb-6 text-2xl font-bold text-gray-900"><?= $escaper->escapeHtml(__('My Points History')) ?></h1>

    <?php if (empty($entries)): ?>
        <p class="rounded-lg bg-gray-50 p-6 text-center text-gray-500">
            <?= $escaper->escapeHtml(__('You have no points activity yet.')) ?>
        </p>
    <?php else: ?>
        <div class="overflow-x-auto rounded-xl border border-gray-200">
            <table class="w-full min-w-[36rem] text-left text-sm">
                <caption class="sr-only"><?= $escaper->escapeHtml(__('Points ledger history, most recent first')) ?></caption>
                <thead class="bg-gray-50 text-xs uppercase text-gray-500">
                    <tr>
                        <th scope="col" class="px-4 py-3"><?= $escaper->escapeHtml(__('Date')) ?></th>
                        <th scope="col" class="px-4 py-3"><?= $escaper->escapeHtml(__('Type')) ?></th>
                        <th scope="col" class="px-4 py-3 text-right"><?= $escaper->escapeHtml(__('Points')) ?></th>
                        <th scope="col" class="px-4 py-3 text-right"><?= $escaper->escapeHtml(__('Balance after')) ?></th>
                    </tr>
                </thead>
                <tbody class="divide-y divide-gray-100">
                    <?php foreach ($entries as $entry): /** @var PointsLedgerInterface $entry */ ?>
                        <tr class="odd:bg-white even:bg-gray-50">
                            <td class="px-4 py-3 text-gray-700">
                                <?= $escaper->escapeHtml($block->formatDate($entry->getCreatedAt(), \IntlDateFormatter::MEDIUM)) ?>
                            </td>
                            <td class="px-4 py-3 capitalize text-gray-700">
                                <?= $escaper->escapeHtml($entry->getType()) ?>
                            </td>
                            <td class="px-4 py-3 text-right font-medium <?= $entry->getPoints() >= 0 ? 'text-green-700' : 'text-red-700' ?>">
                                <?= $entry->getPoints() >= 0 ? '+' : '' ?><?= $escaper->escapeHtml((string) $entry->getPoints()) ?>
                            </td>
                            <td class="px-4 py-3 text-right text-gray-700">
                                <?= $escaper->escapeHtml((string) $entry->getBalanceAfter()) ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
    <?php endif; ?>
</div>

Tipp: overflow-x-auto auf dem umgebenden <div> statt eines JavaScript-Kartenlayouts für Mobilgeräte: Bei nur vier Spalten ist horizontales Scrollen einer echten <table> auf einem schmalen Bildschirm einfacher zu bedienen und semantisch sauberer als eine per CSS in Karten verwandelte Tabelle - letzteres verwirrt zudem Screenreader, die eine <table> erwarten, aber optisch keine mehr vorfinden. Kapitel 54 vertieft diesen Punkt.

catalog/index.phtml: Liste mit Filtern

app/code/Mironsoft/Loyalty/view/frontend/templates/catalog/index.phtml
<?php

declare(strict_types=1);

use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use Mironsoft\Loyalty\Model\Reward;
use Mironsoft\Loyalty\ViewModel\RewardCatalog;

/**
 * @var Template $block
 * @var Escaper $escaper
 */
/** @var RewardCatalog $viewModel */
$viewModel = $block->getViewModel();
?>
<div class="mx-auto max-w-6xl px-4 sm:px-0">
    <h1 class="mb-6 text-2xl font-bold text-gray-900"><?= $escaper->escapeHtml(__('Reward Catalog')) ?></h1>

    <form method="get" class="mb-6 flex flex-wrap items-end gap-4">
        <div>
            <label for="reward-type-filter" class="mb-1 block text-sm font-medium text-gray-700">
                <?= $escaper->escapeHtml(__('Reward type')) ?>
            </label>
            <select id="reward-type-filter" name="reward_type"
                    class="rounded-lg border-gray-300 text-sm focus:border-brand-accent focus:ring-brand-accent"
                    onchange="this.form.submit()">
                <option value=""><?= $escaper->escapeHtml(__('All types')) ?></option>
                <?php foreach ($viewModel->getRewardTypeOptions() as $value => $label): ?>
                    <option value="<?= $escaper->escapeHtmlAttr($value) ?>"
                            <?= $viewModel->getSelectedRewardType() === $value ? 'selected' : '' ?>>
                        <?= $escaper->escapeHtml($label) ?>
                    </option>
                <?php endforeach; ?>
            </select>
        </div>
        <noscript>
            <button type="submit" class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700">
                <?= $escaper->escapeHtml(__('Apply filter')) ?>
            </button>
        </noscript>
    </form>

    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
        <?php foreach ($viewModel->getRewards() as $reward): /** @var Reward $reward */ ?>
            <a href="<?= $escaper->escapeUrl($block->getUrl('mironsoft_loyalty/catalog/view', ['reward_identifier' => $reward->getIdentifier()])) ?>"
               class="flex flex-col rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition hover:border-brand-accent hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-accent">
                <h2 class="mb-2 text-lg font-semibold text-gray-900">
                    <?= $escaper->escapeHtml($reward->getTitle()) ?>
                </h2>
                <p class="mb-4 flex-1 text-sm text-gray-600">
                    <?= $escaper->escapeHtml($reward->getDescription()) ?>
                </p>
                <span class="inline-flex w-fit items-center rounded-full bg-brand-accent/10 px-3 py-1 text-sm font-semibold text-brand-accent-dim">
                    <?= $escaper->escapeHtml(__('%1 points', $reward->getPointsCost())) ?>
                </span>
            </a>
        <?php endforeach; ?>
    </div>
</div>

catalog/view.phtml: Detail und Einlösen-Formular

app/code/Mironsoft/Loyalty/view/frontend/templates/catalog/view.phtml
<?php

declare(strict_types=1);

use Magento\Framework\Escaper;
use Magento\Framework\View\Element\Template;
use Mironsoft\Loyalty\ViewModel\RewardDetail;

/**
 * @var Template $block
 * @var Escaper $escaper
 */
/** @var RewardDetail $viewModel */
$viewModel = $block->getViewModel();
$reward = $viewModel->getReward();
?>
<?php if ($reward === null): ?>
    <p class="mx-auto max-w-2xl px-4 py-12 text-center text-gray-500">
        <?= $escaper->escapeHtml(__('This reward is no longer available.')) ?>
    </p>
<?php else: ?>
    <div class="mx-auto max-w-2xl px-4 py-8 sm:px-0">
        <h1 class="mb-2 text-2xl font-bold text-gray-900"><?= $escaper->escapeHtml($reward->getTitle()) ?></h1>
        <p class="mb-6 text-gray-600"><?= $escaper->escapeHtml($reward->getDescription()) ?></p>

        <div class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
             x-data="{ confirmOpen: false }">
            <p class="mb-4 text-lg font-semibold text-gray-900">
                <?= $escaper->escapeHtml(__('Cost: %1 points', $reward->getPointsCost())) ?>
            </p>

            <?php if (!$viewModel->isRedeemable()): ?>
                <p class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800" role="status">
                    <?= $escaper->escapeHtml(
                        __('You need %1 more points to redeem this reward.', $viewModel->getMissingPoints())
                    ) ?>
                </p>
            <?php else: ?>
                <button type="button"
                        class="rounded-lg bg-brand-accent px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-accent-dim focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-accent"
                        @click="confirmOpen = true">
                    <?= $escaper->escapeHtml(__('Redeem now')) ?>
                </button>

                <div x-show="confirmOpen"
                     x-cloak
                     x-trap.noscroll="confirmOpen"
                     @keydown.escape.window="confirmOpen = false"
                     role="dialog"
                     aria-modal="true"
                     aria-labelledby="redeem-confirm-title"
                     class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
                    <div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
                        <h2 id="redeem-confirm-title" class="mb-2 text-lg font-semibold text-gray-900">
                            <?= $escaper->escapeHtml(__('Confirm redemption')) ?>
                        </h2>
                        <p class="mb-4 text-sm text-gray-600">
                            <?= $escaper->escapeHtml(
                                __('%1 points will be deducted from your balance. This cannot be undone.', $reward->getPointsCost())
                            ) ?>
                        </p>
                        <div class="flex justify-end gap-3">
                            <button type="button" class="rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
                                    @click="confirmOpen = false">
                                <?= $escaper->escapeHtml(__('Cancel')) ?>
                            </button>
                            <form method="post"
                                  action="<?= $escaper->escapeUrl($block->getUrl('mironsoft_loyalty/redeem/index')) ?>">
                                <input type="hidden" name="form_key" value="<?= $escaper->escapeHtmlAttr($viewModel->getFormKey()) ?>">
                                <input type="hidden" name="reward_id" value="<?= (int) $reward->getId() ?>">
                                <button type="submit" class="rounded-lg bg-brand-accent px-4 py-2 text-sm font-semibold text-white hover:bg-brand-accent-dim">
                                    <?= $escaper->escapeHtml(__('Confirm')) ?>
                                </button>
                            </form>
                        </div>
                    </div>
                </div>
            <?php endif; ?>
        </div>
    </div>
<?php endif; ?>

Tipp: x-trap.noscroll ist Hyväs gebündeltes Alpine-Plugin für Fokus-Fallen (@alpinejs/focus) - kein zusätzliches JavaScript-Paket nötig, exakt im Sinne der CLAUDE.md-Regel "Kein extra JavaScript laden - Alpine.js aus Hyvä nutzen". @keydown.escape.window schließt den Dialog per Escape-Taste von überall auf der Seite aus, nicht nur wenn ein Element innerhalb des Dialogs fokussiert ist.

Achtung: Jede __()-Übersetzungsfunktion mit einem dynamischen Platzhalter (hier $reward->getPointsCost()) läuft weiterhin durch $escaper->escapeHtml(), BEVOR sie ins Markup gelangt - __() selbst escaped nichts. Dieselbe Konvention wie in jeder anderen phtml-Datei dieses Projekts (siehe die JSX-HTML-Escaping-Konvention der React-Tutorials für dasselbe Prinzip in einer anderen Sprache): Rohdaten aus einem EAV-Attribut wie getTitle() oder getDescription() sind niemals implizit sicher, nur weil sie aus dem eigenen Admin-Grid stammen.

Tipp: Kapitel 54 schließt Block 6 mit einer gezielten Barrierefreiheits-Durchsicht genau dieser vier Templates ab - Fokus-Reihenfolge, ARIA-Attribute, Farbkontrast und prefers-reduced-motion.