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

Responsive Frontend Templates for Points Balance and History

Responsive Frontend Templates for Points Balance and History

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

Every controller, router, view model, and layout file from this block is in place - this chapter finally fills the four .phtml templates with real markup: the points balance widget template, the history table, the catalog list with filters, and the detail page with its redeem form. All four follow the same Tailwind/Hyvä style as this catalog's separate Hyvä tutorial series (tutorial-hyva-*) - utility classes directly in the markup, no custom CSS.

widget/points-balance.phtml

Included twice (chapters 51 and 52) - canShow() from chapter 48 decides first whether anything renders at all:

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 on the surrounding <div> instead of a JavaScript card layout for mobile: with only four columns, horizontally scrolling a real <table> on a narrow screen is easier to operate and semantically cleaner than a table turned into cards via CSS - the latter also confuses screen readers that expect a <table> but visually find none anymore. Chapter 54 goes deeper on this point.

catalog/index.phtml: list with filters

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 and redeem form

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 is Hyvä's bundled Alpine plugin for focus trapping (@alpinejs/focus) - no extra JavaScript package needed, exactly per the CLAUDE.md rule "don't load extra JavaScript - use Hyvä's Alpine.js". @keydown.escape.window closes the dialog with the escape key from anywhere on the page, not only when an element inside the dialog is focused.

Achtung: Every __() translation call with a dynamic placeholder (here $reward->getPointsCost()) still runs through $escaper->escapeHtml() BEFORE it reaches the markup - __() itself escapes nothing. The same convention as in every other phtml file in this project (see the JSX HTML-escaping convention of the React tutorials for the same principle in a different language): raw data from an EAV attribute like getTitle() or getDescription() is never implicitly safe just because it came from the module's own admin grid.

Tipp: Chapter 54 closes out block 6 with a targeted accessibility pass over exactly these four templates - focus order, ARIA attributes, color contrast, and prefers-reduced-motion.