Black Friday 2025
Meta Box

How to Create a Flash Sale Product Page with Advanced Filtering using Meta Box

In previous Meta Box tutorials, you’ve seen different ways to filter posts by custom fields, taxonomies, or relationships, usually as separate techniques. In this tutorial, we’ll work on a different, more practical scenario by combining multiple filters on a single Flash Sale page.

On this page, products are displayed in a grid layout, showing both the original price and the sale price, along with a clear discount percentage badge. Each product also includes a real-time countdown, so users know exactly when the deal will end.

Users can filter products by discount range using a slider, sort them by discount or sale price, and reset all filters easily. Everything updates instantly on the frontend without reloading the page.

Let’s dive in!

Video Version

Before Getting Started

Before getting started, let’s take a quick look at the structure of this page. We'll create a custom post type called "Product," and each post inside this post type will represent a single product you see here. This makes it easier to manage and display all products later on the frontend. The image is the featured image of the post.

All the product information such as the original price and all sale-related data are stored in custom fields.

So I recommend using Meta Box AIO, which includes the framework and all extensions to have advanced features.

  • MB Admin Columns: to display important information like original price directly in the admin dashboard, so we can manage products more easily;
  • MB Custom Post Type: to create the product post type;
  • MB Views: to create a template and handling the filter logic for the Flash Sale page;
  • MB Builder: to create custom field groups to store product information, including the original price and all sale-related fields;
  • MB Conditional Logic: to control how fields appear. For example, the End Time field will only be displayed when a specific sale type is selected, making the interface cleaner and easier to use;
  • MB Group: to organize sale information into a structured, repeatable group.

Create a Custom Post Type & Custom Fields

For this tutorial, I’ve already prepared a custom post type to store all product. Each product is saved as a post in this post type, as you can see here:

Post type
For the custom fields, I’ve created two separate field groups in advance.

Field groups

The first one is Product Detail. In real-world projects, you might already have a field group to store product information like description, specifications, gallery, or colors. But in this tutorial, to keep things simple, I only use one field, which is the price.

price

I also enable the option to show this field in the admin columns, so we can quickly see the price of each product right in the dashboard. This option is available when the MB Admin Columns extension is activated.

Admin for price

Don’t forget to set the location for this field group as a created post type.

Set location for product detail

Now, open any post inside the product post type; you’ll see this field right where we can enter the product price. After filling in the data, we have all products as all posts like this, with a column showing the original price.

Price column

Next, we move on to the second field group, which is Sale Information. Typically, we separate sale or promotion data from the main product information due to its frequent changes and potentially complex logic.

So instead of mixing everything into one group, we create a separate field group to manage all sale-related data more clearly. This helps keep everything structured and easier to manage, instead of placing all fields separately.

Here, I’ve already prepared the fields for the sale information. Before going into each field, we first create a group field to organize all the sale information.

Sale product group

We have some subfields:

  • Product: It allows you to select which product this sale entry is applied to. It helps link the sale data to a specific product.
  • Sale Percent: This is where you enter the discount percentage for the product. This value will later be used to calculate the sale price and also for filtering and sorting on the frontend.
  • Sale End Type: This field defines how the sale will end with several options.
  • End Time, and Sale Perisistent controls what happens after the sale ends. If it is turned off, the product will be hidden once the sale expires. Otherwise, it will remain visible.

Note that: The End Time field is only displayed when you select the Custom end time option. This is controlled using MB Conditional Logic, where the condition is set to show this field only when the sale end type equals “custom”. This helps keep the interface clean and only shows additional inputs when needed.

Conditional logic

Once done, move to the Settings tab and set the Location to the Products on Sale page, so this field group will only appear on that specific page where we manage all Flash Sale items.

Set location for sale infor

Now, you’ll see all the fields we’ve just created displayed under the page. You can now add as many sale products as you want.

Add sale products

Show Products on Sale

Before creating any filters, we first need to make sure that our products on sale are displayed correctly on the frontend.

Go to Meta Box > Views, and create a new template for the Product on Sale page.

With MB Views, you can add some lines of code directly in the Template tab, or insert fields from the right sidebar through this button to retrieve all the sale entries from the Sale Information group.

Insert fields
In there:

Since the group is cloneable, each entry represents a product on sale. So we’ll loop through each item in this group.

Inside the loop:

First, insert the Product field 3 times correspondingly:

  • To get the product ID by choosing Post ID as the input data. The ID helps identify the exact product so we can retrieve its title and thumbnail correctly.
  • To display the product name. So, choose the input data as the Post title.
  • Since this field also includes the product’s thumbnail, select the Thumbnail as the data source.

Then, we continue inserting the remaining fields one by one. These fields are important, as we’ll use them later for filtering and sorting.

After inserting all the fields, move to the Settings section and set the location for this template. Set the Type to Singular, and choose the Products on Sale page you created earlier.

Set location for template

On the frontend, you can see all the sale products displayed. At this stage, it may look like a basic list without styling. To improve the layout, you can add some div tags and classes in the Template tab.

template tab

In there, we’ll add a bit of custom code to handle the product data.

{% set product = clone.product[0] ?? null %}

This line retrieves the first selected product and prevents errors if none is selected.

{% set price = mb.get_post_meta(product.ID, 'price', true) %}

This one retrieves the original price of the product from the custom field.

Modify the code. We use this to calculate the sale price based on the original price and discount percentage.

{% set sale_percent = clone.sale_percent %}
{% set sale_price = price * (100 - sale_percent) / 100 %}

Then switch to the CSS tab to style the product grid layout.

CSS tab
.product-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 20px;
}

.product-card {
    background: #fff;
    border-radius: 12px;
    padding: 12px;
    position: relative;
    box-shadow: 0 6px 20px rgba(0, 0, 0, .06);
    transition: transform .25s ease, box-shadow .25s ease;
}

.product-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 30px rgba(0, 0, 0, .12);
}

.sale-badge {
    position: absolute;
    top: 10px;
    left: 10px;
    background: #ff3b30;
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    padding: 6px 10px;
    border-radius: 20px;
    z-index: 9999;
}

.product-image {
    aspect-ratio: 1/1;
    background: #f4f4f4;
    border-radius: 8px;
    overflow: hidden;
}

.product-image img {
    width: 100%;
    height: 100%;
    object-fit: contain;
    transition: transform .35s ease;
}

.product-card:hover img {
    transform: scale(1.08);
}

.product-title {
    font-size: 17px;
    font-weight: 600;
    margin: 10px 0 6px;
}

.price-regular {
    text-decoration: line-through;
    color: #999;
    font-size: 14px;
    margin-right: 6px;
}

.price-sale {
    color: #16a34a;
    font-weight: 700;
    font-size: 17px;
}

.countdown {
    font-size: 14px;
    color: #ff3b30;
    font-weight: 600;
    margin-top: 4px;
}

.product-card.no-sale .sale-badge,
    .product-card.no-sale .price-sale,
    .product-card.no-sale .countdown {
    display: none;
}

.product-card.no-sale .price-regular {
    text-decoration: none;
    color: #16a34a;
    font-weight: 700;
}

Now, as you can see, all the sale products are displayed in a clean grid. Each item shows the product image, along with both the original price and the discounted price, making it easy for users to quickly spot the deals.

Product page with no function

Let’s move on to creating filters and sorting for the Flash Sale page.

Create Filters for Flash Sale Page

In this step, users will be able to filter products by discount range, sort them by discount or sale price, and reset all filters easily. Back to the template created with MB Views, we’ll now add more code to enable filtering on the frontend.

First, we load the jQuery library from a CDN to handle interactions and filtering logic more easily.

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>

Next, we create a filter for the sale percentage. It includes a label and a range input that allows users to select a discount value from 0.

<div class="product-toolbar">
    <label>
        Sale %:
        <input type="range" id="saleRange" min="0" max="100" value="0">
        <span id="saleValue">0%</span>
    </label>
</div>

We also create 3 buttons. One to sort products by sale percentage in descending order. One to sort by sale price in ascending order. And One to reset all filters and return everything to the default state.

<button id="orderSaleBtn">Order sale % ↓</button>
<button id="orderPriceBtn">Order price ↑</button>
<button id="resetFilterBtn">Reset filter</button>

Then, we initialize a variable to store the sale end timestamp, starting from 0.

{% set sale_end_ts = 0 %}

The end time changes depending on the selected sale type, with predefined times for Hot, Afternoon, and Regular, while the Custom option uses the user’s input.

{% if clone.sale_end_type.value == 'hot' %}
    {% set sale_end_ts = "today 12:00"|date('U') %}
{% elseif clone.sale_end_type.value == 'afternoon' %}
    {% set sale_end_ts = "today 18:00"|date('U') %}
{% elseif clone.sale_end_type.value == 'regular' %}
    {% set sale_end_ts = "today 22:00"|date('U') %}
{% elseif clone.sale_end_type.value == 'custom' %}
    {% set sale_end_ts = clone.end_time ? clone.end_time|date('U') : 0 %}
{% endif %}

This following line handles the persistent option: products remain visible if it’s On, and are hidden after the sale ends if it’s Off.

{% set sale_persistent = clone.sale_persistent.value == 'On' ? 1 : 0 %}

We store all necessary data directly on each product element using data attributes, including the sale percentage, sale price, sale end time, and the persistent setting. These values will be used later in JavaScript.

data-sale="{{ sale_percent }}" data-sale-price="{{ sale_price }}" data-sale-end="{{ sale_end_ts }}" data-sale-persistent="{{ sale_persistent }}"

This part below is to display the countdown timer. It shows the remaining time until the sale ends, and it will be updated dynamically using JavaScript.

<div class="countdown">
    Sale end:
    <span class="countdown-timer">--:--:--</span>
</div>

Move to CSS tab, add some lines of code to style the filter.

.product-card.is-hidden,
.product-card.is-filter-hidden {
    display: none;
}

.product-toolbar {
    display: flex;
    gap: 12px;
    align-items: center;
    margin-bottom: 24px;
    padding: 12px 16px;
    background: #f9fafb;
    border-radius: 12px;
}

.product-toolbar button {
    padding: 8px 14px;
    border-radius: 8px;
    border: 1px solid #e5e7eb;
    background: #fff;
    cursor: pointer;
    font-weight: 600;
}

.product-toolbar button:hover {
    background: #16a34a;
    color: #fff;
    border-color: #16a34a;
}

Now comes the important part — switching to JavaScript tab to make everything work. We’ll add scripts to handle filtering, sorting, and updating the countdown dynamically.

Javascript tab

jQuery(function ($) {
    const $grid = $('#productGrid');
    if (!$grid.length) return;

    let $cards = $grid.children('.product-card');
    const originalOrder = $cards.toArray();

    const $range = $('#saleRange');
    const $rangeValue = $('#saleValue');

    const $orderSaleBtn = $('#orderSaleBtn');
    const $orderPriceBtn = $('#orderPriceBtn');
    const $resetBtn = $('#resetFilterBtn');
    function updateCountdown() {
    const now = Math.floor(Date.now() / 1000);
    $cards.each(function () {
        const $card = $(this);
        const end = parseInt($card.data('sale-end') || 0, 10);
        const salePersistent = String($card.data('sale-persistent')) === '1';
        const $countdown = $card.find('.countdown');
        const $timer = $card.find('.countdown-timer');
    if (!end) {
        $card.addClass('no-sale');
        return;
    }
    const diff = end - now;
    if (diff <= 0) {
        if (!salePersistent) {
            $card.addClass('is-hidden');
            return;
        }
        $card.addClass('no-sale');
        $countdown.remove();
        return;
    }
    if (!$timer.length) return;
    const days = Math.floor(diff / 86400);
    const hours = Math.floor((diff % 86400) / 3600);
    const minutes = Math.floor((diff % 3600) / 60);
    const seconds = diff % 60;
    let text = '';
    if (days > 0) {
        text += days + 'd ';
    }
    text += hours + 'h ' + minutes + 'm ' + seconds + 's';
    $timer.text(text);
    });
}
function filterSale() {
    const min = parseInt($range.val(), 10);
    $rangeValue.text(min + '%');
    $cards.each(function () {
        const sale = parseInt($(this).data('sale') || 0, 10);
        $(this).toggleClass('is-filter-hidden', sale < min);
    });
}
function orderSalePercent() {
    $cards = $cards.sort((a, b) => ($(b).data('sale') || 0) - ($(a).data('sale') || 0))
    .appendTo($grid);
}
function orderSalePrice() {
    $cards = $cards.sort((a, b) =>
        (parseFloat($(a).data('sale-price')) || 0) -
        (parseFloat($(b).data('sale-price')) || 0)
    ).appendTo($grid);
}
function resetAll() {
    $range.val(0);
    $rangeValue.text('0%');
    $cards.removeClass('is-filter-hidden');
    $cards = $(originalOrder).appendTo($grid);
}
updateCountdown();
setInterval(updateCountdown, 1000);

$range.on('input', filterSale);
$orderSaleBtn.on('click', orderSalePercent);
$orderPriceBtn.on('click', orderSalePrice);
$resetBtn.on('click', resetAll);
});

Clarification:

jQuery(function ($) {
    const $grid = $('#productGrid');
    if (!$grid.length) return;

We start by initializing jQuery and selecting the product grid element. If the grid is not found on the page, we stop the script to avoid errors.

let $cards = $grid.children('.product-card');

This is to get all the product cards inside the grid.

And save their initial order for resetting later.

const originalOrder = $cards.toArray();

After that, select the range input used to filter by sale percentage.

const $range = $('#saleRange');

We select the buttons for sorting by sale percentage, sorting by price, and resetting the filters.

const $orderSaleBtn = $('#orderSaleBtn');
const $orderPriceBtn = $('#orderPriceBtn');
const $resetBtn = $('#resetFilterBtn');

This flowing function updates the countdown timer for all products in real time.

function updateCountdown() {

This code below retrieves the current time to compare with the sale end time.

const now = Math.floor(Date.now() / 1000);

Next, we loop through each product and retrieve the necessary data, including the sale end time, the persistent setting, and the countdown elements.

const $card = $(this);
const end = parseInt($card.data('sale-end') || 0, 10);
const salePersistent = String($card.data('sale-persistent')) === '1';
const $countdown = $card.find('.countdown');
const $timer = $card.find('.countdown-timer');

If there is no sale end time, the product is skipped.

if (!end) {
    $card.addClass('no-sale');
    return;
}

If the sale has ended and the product is not persistent, we hide it.

if (diff <= 0) {
    if (!salePersistent) {
    $card.addClass('is-hidden');
    return;
}

Otherwise, it is visible.

Then, we calculate the remaining time in days, hours, minutes, and seconds.

const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = diff % 60;

Next, we format the remaining time into a readable string and display it in the countdown.

let text = '';
if (days > 0) {
    text += days + 'd ';
}
text += hours + 'h ' + minutes + 'm ' + seconds + 's';
$timer.text(text);

The next function filters products based on the selected sale percentage. It gets the value from the slider, updates the display, and shows or hides products depending on whether their discount meets the selected value.

function filterSale() {
    const min = parseInt($range.val(), 10);
    $rangeValue.text(min + '%');
    $cards.each(function () {
        const sale = parseInt($(this).data('sale') || 0, 10);
        $(this).toggleClass('is-filter-hidden', sale < min);
    });
}

This one is to sort products by sale percentage in descending order.

function orderSalePercent() {
    $cards = $cards.sort((a, b) => ($(b).data('sale') || 0) - ($(a).data('sale') || 0))
    .appendTo($grid);
}

And this sorts products by sale price in ascending order, from lowest to highest.

function orderSalePrice() {
    $cards = $cards.sort((a, b) =>
        (parseFloat($(a).data('sale-price')) || 0) -
        (parseFloat($(b).data('sale-price')) || 0)
    ).appendTo($grid);
}

We call the countdown function and update it every second to keep it running in real time.

Finally, we bind events to the slider and buttons so that when users interact with them, the corresponding filter, sort, or reset function is triggered instantly.

function resetAll() {
    $range.val(0);
    $rangeValue.text('0%');
    $cards.removeClass('is-filter-hidden');
    $cards = $(originalOrder).appendTo($grid);
}

That’s all for the code. I’ve uploaded it to GitHub for your reference. Move to the frontend to see the result.

Now everything works as expected. The countdown updates in real time, and users can filter products by discount, sort them by sale percentage or price, and reset all filters easily. All changes happen instantly without reloading the page, helping users quickly find the best deals. When updating the data in the backend, it will automatically appear on the frontend.

Last Words

You’ve seen how multiple filters can come together to build a practical Flash Sale page with Meta Box. The same filtering approach is also applied in our Job Listing and Real Estate tutorials, showing how flexible and powerful Meta Box can be for real-world scenarios.

Leave a Reply

Your email address will not be published. Required fields are marked *