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:
<?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
<?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
<?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
<?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.