Order Status & Tracking UI in the Hyvä Customer Account
AI generated
Hyvä
phtml
Hyvä Theme
Order Status and Shipment Tracking in the Hyvä Customer Account
From tracking number to status timeline

Hyvä's stock order history shows a rough order status but no tracking number and no delivery timeline. This article shows how to load shipment and track data efficiently via GraphQL, how a carrier code to tracking URL mapping looks as a view model, and how an Alpine component turns that into a readable status timeline. Rounded off with partial deliveries, caching considerations, and the most relevant edge cases.

9 min read GraphQL Alpine.js Tracking URLs Status Timeline

1. What Hyvä Ships Out Of The Box, And Where The Gap Is

Hyvä's stock customer account ships a lean order history: order number, date, a status label, and a link to the detail view. That covers the listing itself, but it says nothing about where a shipment actually is right now. A customer who wants to know whether a package has reached the carrier still has to wait for a shipment confirmation email or copy the tracking number into an external portal by hand.

The stock Magento\Sales\Block\Order\History block, and its Hyvä counterpart, surfaces the aggregated order status such as processing or complete, but never connects it to the individual shipment records. That missing link, tracking number, carrier, and a visual status model, is exactly what this article adds.

This is deliberately scoped away from topics like the reorder button, dashboard tiles, or the order success page, which are already covered elsewhere. What follows is strictly about the detail view of an existing order and how far it has actually traveled.

2. The Magento Data Model: Shipment And Track Entities

Shipment tracking in Magento rests on two entities. An order can have several Shipment records for partial deliveries, and each shipment can reference multiple Track entries, since a single package could theoretically be split across carriers or parcels. The track object carries the actual tracking number, the carrier code, and an optional display title.

For the frontend, the relevant methods are ShipmentTrackInterface::getCarrierCode(), getTrackNumber(), and getTitle(). The carrier code is not a URL, it is an internal identifier such as dhl, ups, fedex, or custom for manually entered carriers. That raw value needs to be mapped to a clickable tracking URL in the frontend, covered in the view model section below.

It is also worth noting that a shipment only exists once the merchant has actually packed and shipped the order in the admin. Until then there is simply no track record, and the frontend timeline needs to treat that state as a normal step, not as an error condition.

3. A GraphQL Query For Order History With Shipments And Tracking

The stock customer.orders query already exposes nested tracking data through its shipments field, but Hyvä's default theme queries it only superficially. For the detail view it pays off to write a dedicated, lean query that loads exactly the fields the timeline and the tracking links actually need.

Pagination happens at the order level via pageSize and currentPage, not at the shipment level, since a single order rarely carries more than a handful of shipments in practice. The query should be scoped differently per page: the list view does not need tracking details, the detail view does.


query CustomerOrderTracking {
  customer {
    orders(pageSize: 5, currentPage: 1) {
      total_count
      page_info {
        current_page
        page_size
        total_pages
      }
      items {
        number
        order_date
        status
        shipments {
          number
          tracking {
            carrier
            title
            number
          }
        }
      }
    }
  }
}

4. Performance: Avoiding N+1 And Controlling Query Cost

Firing a separate detail query against customer.orders for every row in the order history to fetch tracking data creates a textbook N+1 problem: twenty orders on a page mean twenty extra round trips to the GraphQL endpoint. The fix is to load tracking data in the same query as the order list from the start, but with a reduced field selection.

A workable compromise is two tier loading: the list view only asks for number, status, and a lightweight shipments { tracking { carrier } } to render a small shipping icon per row. The full tracking number and status timestamps are only fetched once a customer actually expands a row, triggered via Alpine's x-init with a targeted fetch instead of re-running the full order history query.

On the server side it also helps to inspect the resolver behind shipments: by default Magento loads shipments through a collection object that, under an unfavorable configuration, can end up fetching per order individually. Enabling the query log via bin/magento dev:query-log:enable quickly shows whether each row triggers one SQL query or several.


query OrderShipmentDetail($orderNumber: String!) {
  customer {
    orders(filter: { number: { eq: $orderNumber } }) {
      items {
        number
        shipments {
          number
          tracking {
            carrier
            title
            number
          }
        }
      }
    }
  }
}

5. Building The Status Timeline As An Alpine Component

The timeline covers three to four steps: placed, processing, shipped, delivered. Magento does not expose a ready made timeline status, only the aggregated order status as a plain string. The mapping has to be built by hand, ideally inside a small Alpine component that takes the order status and the shipment count as input.

The aggregated status complete does not necessarily mean delivered, because Magento sets it as soon as every item is invoiced and shipped, regardless of the actual delivery state at the carrier. Showing a genuine delivery confirmation requires either a carrier tracking API integration or a manually maintained status field, since Magento itself has no automatic delivery status.


<div x-data="orderStatusTimeline({
        orderStatus: '<?= $block->escapeJs($order->getStatus()) ?>',
        shipmentCount: <?= (int) count($order->getShipmentsCollection()) ?>
    })" class="mt-6">
    <ol class="flex items-center w-full">
        <template x-for="(step, index) in steps" :key="step.key">
            <li class="flex-1 relative">
                <div class="flex items-center">
                    <div :class="step.done ? 'bg-primary text-white' : 'bg-gray-200 text-gray-500'"
                         class="w-8 h-8 rounded-full flex items-center justify-center text-sm">
                        <span x-text="index + 1"></span>
                    </div>
                    <div x-show="index < steps.length - 1" class="flex-1 h-0.5"
                         :class="step.done ? 'bg-primary' : 'bg-gray-200'"></div>
                </div>
                <p class="mt-2 text-sm" :class="step.done ? 'font-semibold text-gray-900' : 'text-gray-400'"
                   x-text="step.label"></p>
            </li>
        </template>
    </ol>
</div>

<script>
function orderStatusTimeline({ orderStatus, shipmentCount }) {
    return {
        steps: [],
        init() {
            const map = {
                pending: 0,
                processing: shipmentCount > 0 ? 2 : 1,
                complete: 2,
                closed: 3,
                canceled: -1
            };
            const reached = map[orderStatus] ?? 0;
            const labels = ['Placed', 'Processing', 'Shipped', 'Delivered'];
            this.steps = labels.map((label, index) => ({
                key: label,
                label,
                done: index <= reached
            }));
        }
    };
}
</script>
<?php $hyvaCsp->registerInlineScript(); ?>

6. Carrier Code To Tracking URL: The View Model Pattern

Since a carrier code alone does not produce a clickable URL, a small translation layer is needed. A view model implementing ArgumentInterface is the right place for this, because it can be injected cleanly into the template via layout XML without introducing a dedicated block.

The mapping itself should stay configurable, so new carriers can be added without a code deploy. In practice, a base array in the view model combined with an optional override through system.xml has worked well, useful for cases where a merchant suddenly needs a regional carrier such as Hermes or Evri.

For unknown carrier codes, the method has to return a sensible fallback, such as a search combining the tracking number and carrier name, instead of showing no link at all. That keeps customers from being left without any point of reference for less common carriers.


<?php

declare(strict_types=1);

namespace Mironsoft\OrderTracking\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;

/**
 * Maps carrier codes to clickable tracking URLs for the order history.
 */
class CarrierTrackingUrl implements ArgumentInterface
{
    /**
     * Base mapping of Magento carrier code to URL pattern with a placeholder for the tracking number.
     *
     * @var array<string, string>
     */
    private const CARRIER_URL_MAP = [
        'dhl' => 'https://www.dhl.de/de/privatkunden/dhl-sendungsverfolgung.html?piececode=%s',
        'ups' => 'https://www.ups.com/track?tracknum=%s',
        'dpd' => 'https://tracking.dpd.de/status/de_DE/parcel/%s',
        'hermes' => 'https://www.myhermes.de/empfangen/sendungsverfolgung/sendungsinformation/#%s',
        'fedex' => 'https://www.fedex.com/fedextrack/?trknbr=%s',
    ];

    /**
     * Builds the tracking URL for a given carrier code and tracking number.
     * Returns a search fallback when the carrier code is unknown.
     *
     * @param string $carrierCode
     * @param string $trackNumber
     * @return string
     */
    public function getTrackingUrl(string $carrierCode, string $trackNumber): string
    {
        $pattern = self::CARRIER_URL_MAP[strtolower($carrierCode)] ?? null;

        if ($pattern === null) {
            return sprintf(
                'https://www.google.com/search?q=%s',
                urlencode($carrierCode . ' tracking ' . $trackNumber)
            );
        }

        return sprintf($pattern, urlencode($trackNumber));
    }
}

7. Private Content And Caching In The Customer Account

The entire customer account runs through Magento's private content mechanism and is never served from the full page cache. That means the order history, timeline included, is rendered fresh server side on every request, which is uncritical for performance but does make client side caching of the GraphQL response worth considering.

For the timeline this is actually an advantage: there is no risk of a stale FPC entry showing an outdated status, unlike product pages with prices. The data comes straight from the database on every request. To still avoid unnecessary requests, a simple in memory cache scoped to the page session, for example via Alpine.store, is preferable to refetching on every tab switch.

It is also worth double checking that the GraphQL endpoint itself is not accidentally cached by Varnish. With the X-Magento-Cache-Debug header correctly configured, it takes seconds to confirm that order history requests come back as MISS or bypass the FPC entirely.

8. Handling Partial Deliveries And Multi Shipment UI

Once an order ships in multiple packages, a single timeline is no longer enough. The right approach is to group by shipment, each with its own tracking number, carrier, and status, while the parent order status still sits at the top as a summary.

An accordion pattern works well in practice: only the first or the most recent shipment is expanded by default, and the rest can be revealed on click. That keeps the detail view readable even for orders split into five or six individual packages, without forcing the customer to scroll through a long, unstructured list.


<?php /** @var \Magento\Sales\Model\Order $order */ ?>
<div class="space-y-4">
    <?php foreach ($order->getShipmentsCollection() as $index => $shipment): ?>
        <div x-data="{ open: <?= $index === 0 ? 'true' : 'false' ?> }" class="border rounded-lg">
            <button type="button" @click="open = !open" class="w-full flex items-center justify-between p-4">
                <span class="font-semibold">
                    <?= $block->escapeHtml(__('Shipment %1', $shipment->getIncrementId())) ?>
                </span>
                <span x-text="open ? '−' : '+'"></span>
            </button>
            <div x-show="open" x-collapse class="p-4 border-t">
                <?php foreach ($shipment->getTracksCollection() as $track): ?>
                    <p class="text-sm">
                        <?= $block->escapeHtml($track->getCarrierCode()) ?>:
                        <?= $block->escapeHtml($track->getTrackNumber()) ?>
                    </p>
                <?php endforeach; ?>
            </div>
        </div>
    <?php endforeach; ?>
</div>

9. Edge Cases: Missing Tracking Numbers, Carrier Free Shipments, Delayed Status

Not every shipment carries a tracking number. Store pickup, certain freight deliveries, or shipments through third parties without tracking integration leave the field simply empty. The timeline has to catch this case explicitly and show a plain text notice instead of an empty or broken link, explaining that no tracking is available for this shipment.

A similar situation arises when a tracking number exists but no recognized carrier code is attached, for example with manually entered carriers using the custom code. Here the view model from the earlier section should apply the fallback described there, rather than displaying the raw tracking number unlinked and without context.

A third case involves delayed status updates: when a carrier reports no new scan for days, a plain shipped label starts to feel misleading. Without an active tracking API integration, the shop can only soften this with a timestamp hint, such as the shipping date plus the last update, so customers do not get the impression their package has been lost.

Magento Order Status Shipment Present? Frontend Timeline Step
pending No Placed, payment pending
processing No Processing, picking in progress
processing Yes, at least one shipment Shipped, tracking number available
complete Yes, all items invoiced and shipped Shipped or delivered, without a carrier API only an assumption
closed Yes, possibly including a return Closed, including refund
canceled No Canceled, timeline is hidden

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

Order Status & Tracking

Data Model

Order, shipment, and track entities expose carrier code and tracking number, but no ready made timeline status.

GraphQL

A lean, two tier query separates the list view from the detail view to avoid unnecessary field selection.

Timeline UI

An Alpine component maps the aggregated order status and shipment count onto three to four timeline steps.

Edge Cases

Missing tracking numbers, unknown carrier codes, and delayed status all need explicit fallbacks instead of empty links.

11. FAQ: Order Status & Tracking

1Does Magento GraphQL ship a ready made status timeline?
No, the query only returns the aggregated order status and the raw shipment and track data, mapping that to timeline steps has to happen in the frontend.
2How do I tell shipped apart from delivered when Magento has no delivery status?
Magento has no native delivery status field, a genuine delivery confirmation requires either an integration with the carrier's tracking API or a manually maintained status field.
3Which field carries the carrier code in GraphQL?
Inside shipments.tracking, carrier returns the internal carrier code, while title is the display label the merchant configured.
4How do I avoid N+1 requests with many orders?
Load tracking data with a reduced field selection directly in the list query, and fetch full details lazily only once a row is expanded.
5Is the order history served from the full page cache?
No, the customer account runs through private content and is rendered fresh server side on every request, so stale FPC data showing an old status is not a concern.
6How should I handle shipments without a tracking number?
The timeline should detect this state explicitly and show a plain text notice instead of an empty or broken link.
7Where does the carrier code to tracking URL mapping belong?
In a view model implementing ArgumentInterface, injected via layout XML, with a configurable array and a fallback for unknown codes.
8How do I display partial deliveries with multiple shipments?
Group by shipment with its own tracking number and status, combined with an accordion pattern to keep the view readable.
9Do I need a dedicated line of code for every possible carrier?
A base array in the view model covers the common carriers, extended with an optional system.xml configuration for short notice regional carriers.
10How do I signal a delayed status without a tracking API integration?
With a timestamp hint showing when the last known status was set, so customers can judge whether the delay is normal or worth a support inquiry.