Skip to content
  1. Home
  2. Blog

how-to · · 8 min read

how to show "bought in the last 24 hours" in woocommerce

Show "Bought 12 times in the last 24 hours" on WooCommerce products, counted from real orders, hidden below a minimum. Classic and block themes, full code.

By Big Bad Agency

“Bought 14 times in the last 24 hours.” It’s one line under the price, and it answers a question every shopper has and can’t ask: does anyone actually buy this?

WooCommerce doesn’t have it. Core shows stock levels and, if you turn them on, review counts. Nothing in core tells a shopper what other people bought today. That’s why people go looking for a plugin.

You don’t need one for this. It’s one count, one line, and one hook. Here’s the code, and the four decisions that keep the number honest.

the rule: count real orders or show nothing

A sales line is a claim, made to a customer, at the moment they’re deciding whether to trust you. If the number is padded, rounded up or made up, the line is a lie, and the first customer who notices it stops believing the rest of the page too.

So everything below counts real orders from your store’s own order table, and when the real number is small it shows nothing rather than something better-sounding. That’s the whole design.

the decisions

  • What “bought” means. We count orders that are processing or completed: paid, or done. On-hold (a bank transfer or cheque that hasn’t cleared) isn’t bought yet, and pending, failed, cancelled and refunded never were. A partly refunded order stays counted, because the customer did buy.
  • Orders, not units. One customer buying ten counts as one. “Bought 10 times” after a single bulk order would be true in units and misleading in every way a shopper reads it. Two lines of the same product in one order also count once.
  • A minimum. Below three, show nothing. “Bought 1 time in the last 24 hours” tells people the product barely sells. Silence tells them nothing, which is better.
  • A rolling window. “The last 24 hours” means from this moment back 24 hours, not “since midnight”. It’s what the words say, so it’s what the code does.

Variations count toward their product: a small and a large in the same day is two for the T-shirt, not one each for sizes nobody sees separately.

1. the settings

Paste the code into a small plugin or your child theme’s functions.php. The whole thing, as one file, is at the end.

function bb_recent_sales_settings() {
	return array(
		'hours'   => 24, // The window.
		'minimum' => 3,  // Below this, show nothing.
		'cache'   => 10 * MINUTE_IN_SECONDS,
	);
}

Change hours to 48 or 168 for a slower store; the text follows it (“in the last 168 hours” reads badly, so for a week you’d also change the wording in step 3). Raise minimum if three still sounds thin for your shop.

2. count the orders, once for the whole store

function bb_recent_sales_counts() {
	$counts = get_transient( 'bb_recent_sales_counts' );
	if ( is_array( $counts ) ) {
		return $counts;
	}

	$settings = bb_recent_sales_settings();
	$since    = time() - (int) $settings['hours'] * HOUR_IN_SECONDS;

	$orders = wc_get_orders(
		array(
			'type'         => 'shop_order',
			'status'       => array( 'wc-processing', 'wc-completed' ),
			'date_created' => '>=' . $since,
			'limit'        => -1,
		)
	);

	$counts = array();
	foreach ( $orders as $order ) {
		// One order counts once per product, however many of it were in the order.
		$in_this_order = array();
		foreach ( $order->get_items() as $item ) {
			$in_this_order[ (int) $item->get_product_id() ] = true;
		}
		foreach ( array_keys( $in_this_order ) as $product_id ) {
			$counts[ $product_id ] = ( $counts[ $product_id ] ?? 0 ) + 1;
		}
	}

	set_transient( 'bb_recent_sales_counts', $counts, (int) $settings['cache'] );
	return $counts;
}

Three details that matter:

  • One query for every product, cached. The first product page anyone opens counts the last 24 hours of orders for the whole store and keeps the result for ten minutes. Every other product page in those ten minutes reads the cache. Counting per product, per page view, is how this kind of snippet slows a store down.
  • 'date_created' => '>=' . $since. WooCommerce reads a bare number as a Unix timestamp in UTC, to the second. That’s exactly “24 hours ago”, whatever time zone your store is in, so there’s nothing to convert.
  • 'limit' => -1. Unless you say otherwise, wc_get_orders returns only as many orders as your Blog pages show at most setting (Settings → Reading, 10 by default). Without this line, a store with eleven orders today would quietly undercount.

get_product_id() on a variation’s line returns the parent product, which is what makes sizes count toward their T-shirt. wc_get_orders works the same on the older posts storage and on High-Performance Order Storage (HPOS), so nothing changes if you switch.

3. the wording

function bb_recent_sales_text( $count, $hours ) {
	return sprintf(
		/* translators: 1: number of orders, 2: number of hours. */
		_n( 'Bought %1$d time in the last %2$d hours', 'Bought %1$d times in the last %2$d hours', $count, 'bb-recent-sales' ),
		$count,
		$hours
	);
}

“Bought 14 times” is exactly what we counted: fourteen orders. “14 people bought this” sounds friendlier, but one person ordering twice would make it false. Keep the words as precise as the number.

4. show it on the product page

add_action(
	'woocommerce_before_add_to_cart_form',
	function () {
		global $product;
		static $shown = false;

		if ( $shown || ! is_product() || ! $product instanceof WC_Product ) {
			return;
		}
		if ( $product->get_id() !== get_queried_object_id() ) {
			return; // Only the product the page is about.
		}

		$settings = bb_recent_sales_settings();
		$count    = bb_recent_sales_counts()[ $product->get_id() ] ?? 0;
		if ( $count < (int) $settings['minimum'] ) {
			return;
		}

		$shown = true;
		printf(
			'<p class="bb-recent-sales">%s</p>',
			esc_html( bb_recent_sales_text( $count, (int) $settings['hours'] ) )
		);
	}
);

We picked woocommerce_before_add_to_cart_form because it’s the one spot that works in every kind of theme. We checked WooCommerce 10.7’s source for each:

  • Classic themes fire it from the add-to-cart templates for simple, variable, grouped and external products.
  • Block themes using the Add to Cart Form block render those same templates, so it fires there too.
  • The newer Add to Cart with Options block fires it itself, for WooCommerce 10.1 and later.

The line lands just above the quantity box and button, which is where a shopper’s eye is when they decide.

The two guards stop it showing in the wrong place. get_queried_object_id() is the product the page is about, so a related product or upsell rendered further down never gets a line of its own. $shown keeps it to one line per page.

Style it in your theme’s CSS with .bb-recent-sales. Keep it small and plain. It’s a fact, not a banner.

5. keep it fresh

add_action(
	'woocommerce_order_status_changed',
	function () {
		delete_transient( 'bb_recent_sales_counts' );
	}
);

When an order is paid, completed, cancelled or refunded, the cache is thrown away and the next page view recounts. So a new sale shows on the next page load, and a refund drops out just as fast. The one lag left is time itself: an order from 24 hours and 5 minutes ago can stay counted until the ten-minute cache runs out.

before it goes live

  • Page caching. If your host or a caching plugin stores whole product pages, visitors see the line as it was when the page was cached. Either exclude product pages from the page cache, or keep the page cache short, an hour or less, so the number can’t drift far from the truth.
  • Busy stores. The count loads every paid order from the last 24 hours, once per ten minutes. That’s nothing for a few dozen orders a day. At several hundred a day, count from WooCommerce Analytics’ order-product lookup table with one SQL query instead, or run the count on a schedule.
  • Products with no form. The line sits above the add-to-cart form, so a simple product that’s out of stock, which has no form, shows no line. That’s usually what you want.
  • Turn it off per product by checking a product tag in the hook, the same way the custom input field post limits its field to tagged products.
  • Test on staging. Place three test orders for one product and mark them processing. The line should appear. Refund one: it should disappear on the next page load, because two is under the minimum.
  • Mind your text domain. Replace bb-recent-sales with your plugin’s or theme’s.

when to use a plugin instead

This is one line on the product page. If you want sales pop-ups across the whole site, counts on shop and category pages, or “N people are looking at this”, those are bigger tools. If you shop for one, check what it counts before you install it. Make sure the number on screen comes from your orders.

None of our plugins does this, so we’re not selling you one. If what you need is a true “bought in the last 24 hours” on your product pages, the code above is the whole job.

the full file

Save it as wp-content/plugins/recent-sales-line/recent-sales-line.php and activate it under Plugins:

<?php
/**
 * Plugin Name: Recent Sales Line
 * Description: Shows "Bought N times in the last 24 hours" on a product page, counted from real orders. Shows nothing below a minimum.
 * Version:     1.0.0
 */

defined( 'ABSPATH' ) || exit;

function bb_recent_sales_settings() {
	return array(
		'hours'   => 24, // The window.
		'minimum' => 3,  // Below this, show nothing.
		'cache'   => 10 * MINUTE_IN_SECONDS,
	);
}

function bb_recent_sales_counts() {
	$counts = get_transient( 'bb_recent_sales_counts' );
	if ( is_array( $counts ) ) {
		return $counts;
	}

	$settings = bb_recent_sales_settings();
	$since    = time() - (int) $settings['hours'] * HOUR_IN_SECONDS;

	$orders = wc_get_orders(
		array(
			'type'         => 'shop_order',
			'status'       => array( 'wc-processing', 'wc-completed' ),
			'date_created' => '>=' . $since,
			'limit'        => -1,
		)
	);

	$counts = array();
	foreach ( $orders as $order ) {
		$in_this_order = array();
		foreach ( $order->get_items() as $item ) {
			$in_this_order[ (int) $item->get_product_id() ] = true;
		}
		foreach ( array_keys( $in_this_order ) as $product_id ) {
			$counts[ $product_id ] = ( $counts[ $product_id ] ?? 0 ) + 1;
		}
	}

	set_transient( 'bb_recent_sales_counts', $counts, (int) $settings['cache'] );
	return $counts;
}

function bb_recent_sales_text( $count, $hours ) {
	return sprintf(
		/* translators: 1: number of orders, 2: number of hours. */
		_n( 'Bought %1$d time in the last %2$d hours', 'Bought %1$d times in the last %2$d hours', $count, 'bb-recent-sales' ),
		$count,
		$hours
	);
}

add_action(
	'woocommerce_before_add_to_cart_form',
	function () {
		global $product;
		static $shown = false;

		if ( $shown || ! is_product() || ! $product instanceof WC_Product ) {
			return;
		}
		if ( $product->get_id() !== get_queried_object_id() ) {
			return;
		}

		$settings = bb_recent_sales_settings();
		$count    = bb_recent_sales_counts()[ $product->get_id() ] ?? 0;
		if ( $count < (int) $settings['minimum'] ) {
			return;
		}

		$shown = true;
		printf(
			'<p class="bb-recent-sales">%s</p>',
			esc_html( bb_recent_sales_text( $count, (int) $settings['hours'] ) )
		);
	}
);

add_action(
	'woocommerce_order_status_changed',
	function () {
		delete_transient( 'bb_recent_sales_counts' );
	}
);

We checked every hook here against the WooCommerce 10.7 source, and tested the counting, the minimum, the cache and the page guards against stubbed WordPress functions. We haven’t run it on your store. Staging first.

Disagree? Good.

Write to a person: hello@bigbad.agency. We answer within a business day.

Start a project