Price Display in Hyvä: Building Robust Tier Pricing and Countdown Timers
AI generated
Hyvä
phtml
Hyvä Theme
Price Display in Hyvä
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.

13 min read Tier Price Countdown Timer

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 &euro;</span>
  <span class="sr-only">instead of previously</span>
  <s class="text-slate-400 text-sm" aria-hidden="true">59.90 &euro;</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.

11. FAQ: Price Display in Hyvä

1Why doesn't Hyvä ship a ready-made price display pattern?
Because the right presentation differs strongly by business model, a B2B catalog with many tier levels needs a different solution than a B2C shop with a single discount badge. This decision should be made deliberately and per project, instead of adopting a generic pattern without thinking it through.
2Why do tier prices need to be sorted before display?
The GraphQL API does not guarantee ascending order of tier levels by quantity. Without explicit sorting in the frontend, a table can appear that wrongly makes larger quantities look more expensive than smaller ones.
3Why should the countdown target time be passed as an absolute timestamp?
A relative value like two days remaining would already be stale the moment it renders and would get permanently frozen by the Full Page Cache. An absolute Unix timestamp, in contrast, stays correct regardless of when the cache entry was created.
4Why isn't Date.now() in the browser enough for a reliable countdown?
Client clocks drift from the actual time for many reasons, such as wrong timezones or unsynchronized system clocks. An offset against server time compensates for that drift and keeps the countdown correct even with a wrongly set client clock.
5How can server time be determined without an extra endpoint?
The Date response header of a request that runs anyway, for example to the GraphQL endpoint, provides the server time without an additional call. The difference against the local client time gives an offset that gets applied to every subsequent time calculation.
6Why must the remaining time not be rendered as finished text server-side?
A server-rendered text like two days remaining would get frozen by the Full Page Cache for the entire cache lifetime and become increasingly wrong as real time passes. Only the immutable target timestamp may sit in the cache, the conversion happens client-side.
7Is striking through the regular price enough for accessible special price labeling?
No, a purely visual strikethrough is invisible to screen reader users. A semantic s element combined with hidden context text like instead of previously makes the reduction equally recognizable to every user.
8Why should tier price, countdown, and special price be built as separate Alpine components?
Separate components can be tested, debugged, and disabled independently as needed, without affecting the other price building blocks. A single all-encompassing component quickly becomes hard to follow and hard to maintain.
9What happens if registerInlineScript is forgotten for an inline script?
The Content Security Policy blocks exactly that one script while the other inline scripts on the same page keep working. That makes debugging harder because only part of the page is affected and the bug is not immediately obvious.
10When is a table worth using over individual discount badges for tier pricing?
From about four tier levels onward, and for an audience that is more B2B-leaning and uses the numbers for its own calculations, a full table is usually easier to understand. With two to three tiers in a B2C context, a single, prominent badge is often more persuasive instead.