building robust tier pricing and countdown timers
A quantity discount that is understandable at a glance, and a sale countdown that cannot be defeated by a wrongly set client clock, sound like small details. In practice, exactly those details decide whether customers trust a price display or dismiss it as a trick.
Table of Contents
- 1. Why price display in Hyvä is more than a single number
- 2. Fetching and preparing tier price data from GraphQL correctly
- 3. UI patterns for quantity discounts: table versus tier badges
- 4. A sale countdown as a self-contained Alpine component
- 5. Server time synchronization: why the client clock cannot be trusted
- 6. The Full Page Cache and countdown display: why the timer must not be frozen
- 7. Implementing accessible special price labeling
- 8. A practical example: tier price, countdown, and special price combined on the PDP
- 9. Checklist for robust price display in a Hyvä theme
- 10. Summary
- 11. FAQ
1. Why price display in Hyvä is more than a single number
A modern product detail page rarely shows just one price: tier pricing for larger order quantities, a time-limited special price with a countdown, and a clear indication that a reduction is even happening all need to be shown together, without contradicting each other. The GraphQL data structure delivers price_range, price_tiers, and special price fields as separate building blocks that need to be interpreted independently, and the theme has to shape them into one coherent picture.
Hyvä deliberately ships no single, universal price UI pattern, because the right presentation depends heavily on the business model: a B2B catalog with many tier levels needs a different approach than a B2C shop with a single discount badge. If that decision is not made deliberately, price displays end up technically correct but confusing to customers, or in the worst case create the wrong impression of goods that are not actually discounted.
2. Fetching and preparing tier price data from GraphQL correctly
A product's price_tiers list returns the final price valid from a given quantity threshold, but not directly the percentage discount against the regular price. That has to be computed in the frontend from the ratio between the tier price and the regular price, with careful rounding so displayed percentages like twenty instead of nineteen point seven percent do not create the impression of an imprecise calculation.
Sort order matters too: the API does not reliably return tier levels in ascending order of quantity, so the theme should explicitly sort the list by quantity before rendering it. Otherwise a table with swapped rows can appear, giving the impression that larger quantities cost more than smaller ones, which damages trust in the whole price display.
query ProductTierPrices($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
sku
price_range {
minimum_price {
regular_price { value currency }
}
}
price_tiers {
quantity
final_price { value currency }
discount { percent_off }
}
}
}
}
3. UI patterns for quantity discounts: table versus tier badges
For B2B-heavy catalogs with four or more tier levels, a compact table showing quantity, unit price, and savings side by side has proven effective, since buyers often need those numbers directly for their own calculations. A real table structure with correctly set scope attributes also produces a clean screen reader output, instead of only arranging the values visually with flexbox or grid.
For B2C shops with usually only two or three tiers, a single, prominently placed badge like from 5 units minus ten percent tends to be more persuasive than a full table, because it supports the purchase decision with one easily digestible piece of information instead of confronting the customer with a table they never intended to fully read.
<table class="w-full text-sm border-collapse">
<caption class="sr-only">Tier prices for this product</caption>
<thead>
<tr class="border-b border-slate-200">
<th scope="col" class="text-left py-2">Quantity</th>
<th scope="col" class="text-left py-2">Unit Price</th>
<th scope="col" class="text-left py-2">Savings</th>
</tr>
</thead>
<tbody>
<template x-for="tier in sortedTiers" :key="tier.quantity">
<tr class="border-b border-slate-100">
<td class="py-2" x-text="`from ${tier.quantity} units`"></td>
<td class="py-2" x-text="formatPrice(tier.final_price.value)"></td>
<td class="py-2 text-green-700" x-text="`minus ${tier.discount.percent_off}%`"></td>
</tr>
</template>
</tbody>
</table>
4. A sale countdown as a self-contained Alpine component
A countdown timer can be cleanly encapsulated in Hyvä as a self-contained, reusable Alpine component that takes a target time as a data attribute from the server and counts down the remaining time client-side, in seconds. It matters to start the interval in x-init and stop it again in a matching cleanup, so navigating between Alpine components in the same DOM tree does not leave an orphaned timer running in the background.
The target time should always be passed as an absolute Unix timestamp, not as a relative remaining time like two days and three hours, because a relative value would already be stale the moment it renders and would show an increasingly wrong remaining time on every Full Page Cache hit.
<div x-data="saleCountdown(<?= (int) $block->getData('sale_end_timestamp') ?>)" x-init="start()">
<div class="flex gap-4 text-2xl font-bold" x-show="!expired">
<div><span x-text="days"></span><span class="text-xs font-normal block">Days</span></div>
<div><span x-text="hours"></span><span class="text-xs font-normal block">Hrs</span></div>
<div><span x-text="minutes"></span><span class="text-xs font-normal block">Min</span></div>
<div><span x-text="seconds"></span><span class="text-xs font-normal block">Sec</span></div>
</div>
<p x-show="expired" class="text-sm text-slate-500">This offer has ended.</p>
</div>
<script>
function saleCountdown(targetTimestamp) {
return {
days: 0, hours: 0, minutes: 0, seconds: 0, expired: false, timer: null,
start() {
this.tick();
this.timer = setInterval(() => this.tick(), 1000);
},
tick() {
const remaining = targetTimestamp - Math.floor(Date.now() / 1000);
if (remaining <= 0) {
this.expired = true;
clearInterval(this.timer);
return;
}
this.days = Math.floor(remaining / 86400);
this.hours = Math.floor((remaining % 86400) / 3600);
this.minutes = Math.floor((remaining % 3600) / 60);
this.seconds = remaining % 60;
},
};
}
</script>
5. Server time synchronization: why the client clock cannot be trusted
A countdown that relies purely on Date.now() in the browser implicitly trusts that the customer's system clock is set correctly. In practice, client clocks drift for many reasons, from the wrong timezone to unsynchronized system clocks to deliberately manipulated time settings, which can make a customer see an already-expired offer as still active, or the reverse, perceive a still-running offer as already over.
A more reliable approach synchronizes the client clock against the server time once when the page loads, for example through the Date response header of a request that runs anyway, and derives an offset from that which gets applied to Date.now() on every subsequent remaining-time calculation. The countdown stays correct even with a wrongly set client clock, as long as the server time itself is reliable.
// web/js/countdown/server-time-offset.js
export async function getServerTimeOffset() {
const response = await fetch('/graphql', { method: 'HEAD' });
const serverDate = new Date(response.headers.get('Date')).getTime();
return serverDate - Date.now();
}
// Usage inside the Alpine component
async function saleCountdown(targetTimestamp) {
const offset = await getServerTimeOffset();
return {
tick() {
const correctedNow = Math.floor((Date.now() + offset) / 1000);
const remaining = targetTimestamp - correctedNow;
// ... rest of the calculation as before
},
};
}
6. The Full Page Cache and countdown display: why the timer must not be frozen
If the remaining time is already rendered server-side as finished text like two days and three hours left, the Full Page Cache freezes that value for the entire cache lifetime of the page, so customers see an increasingly wrong remaining time as real time passes. For that reason, only the fixed target timestamp as a raw value belongs in the cached HTML, while the actual conversion into days, hours, and minutes happens entirely client-side.
This principle, rendering only immutable raw data server-side and leaving every time-dependent display to the client, does not just apply to the countdown itself but also to adjacent elements, such as a hint text summarizing the remaining time in words. That text should also be generated client-side from the target timestamp instead of appearing as a static sentence frozen in the cached markup.
7. Implementing accessible special price labeling
A struck-through regular price next to a highlighted special price is a widespread pattern, but it must not rely solely on visual cues like color or strikethrough to be equally understandable for screen reader users and customers with limited color vision. The semantic s element for the struck-through price, combined with visually hidden but screen-reader-available text, makes the reduction equally understandable to every user.
A textual framing that goes beyond a bare number is just as important, such as instead of previously before the struck-through price, so without any visual context it is clear which of the two prices is current and which is the earlier one. These details look minor at first glance, but they decide whether a price reduction is even recognizable as such to assistive technology.
<div class="flex items-baseline gap-2" aria-label="Special price 39.90 euros instead of regular 59.90 euros">
<span class="text-2xl font-bold text-red-600">39.90 €</span>
<span class="sr-only">instead of previously</span>
<s class="text-slate-400 text-sm" aria-hidden="true">59.90 €</s>
<span class="bg-red-100 text-red-700 text-xs font-semibold px-2 py-0.5 rounded">-33%</span>
</div>
8. A practical example: tier price, countdown, and special price combined on the PDP
In a real PDP layout, the three building blocks appear in a fixed order: first the special price label with the struck-through regular price directly under the product name, below it the countdown with a target timestamp derived from the server time offset, and below that the tier price table for anyone considering a larger quantity. Each building block stays isolated as its own Alpine component, so individual parts can be tested independently and disabled one at a time if needed.
Since all three components bring their own script blocks, $hyvaCsp->registerInlineScript() has to be called after every single inline script, so the Content Security Policy correctly assigns the generated nonces. If that step is forgotten for one of the three components, the browser blocks exactly that one script while the other two keep working unnoticed, which makes debugging in live operation unnecessarily harder.
9. Checklist for robust price display in a Hyvä theme
At first glance, price display looks like pure styling, but in practice it plays a major role in customer trust and conversion rate. Treating tier pricing, countdown, and special price labeling as one connected system, instead of implementing each element in isolation, avoids contradictions like a countdown showing a remaining time of minus three hours because server time synchronization was missing.
The overview below summarizes the key technical and design requirements that should be checked before a new price display pattern goes live.
| Building Block | Technical Requirement | Risk if Wrong | Priority |
|---|---|---|---|
| Tier price sorting | Sort tier levels by quantity client-side | Confusing, seemingly wrong price table | High |
| Countdown target timestamp | Absolute Unix timestamp instead of relative remaining time in the markup | Wrong remaining time due to the Full Page Cache | Very high |
| Server time synchronization | Offset against the Date header instead of raw Date.now() | Wrongly displayed countdown on a drifting client clock | High |
| Special price labeling | Semantic s element plus hidden context text | Reduction not recognizable to screen reader users | Very high |
| CSP registration per component | registerInlineScript after every inline script block | One component gets blocked by the CSP | High |
| Isolated Alpine components | Encapsulate tier price, countdown, and special price separately | A bug in one component drags down the others | Medium |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Price Display in Hyvä
Core idea
Tier pricing, countdown, and special price need to be treated as one connected, contradiction-free system.
Countdown rule
Only the absolute target timestamp belongs in cached markup, the conversion happens entirely client-side.
Time trust
An offset against server time prevents wrong countdown displays on a drifting client clock.
Accessibility
Special prices cannot rely on color alone, they need semantic markup and context text.