Q Card Docs
Modules

Find a retailer

Filterable deals and merchants directory with live results.

Find a retailer

Description

Find a retailer is the interactive directory of deals and merchants. Visitors filter and sort the list using the sidebar, and the results update without reloading the whole page.

How to edit

  1. Keep the deal and merchant content accurate — availability, categories, product type, and expiry all feed this page (see Deals and Merchants).
  2. Go to Elementor → Editor → Templates.
  3. Search for Find a retailer (or the specific template name below).
  4. Open the template and edit the static content, such as headings and callouts.
  5. Save, then check the Find a retailer page.

Most of the content inside the cards is controlled by fields on the deal and merchant posts, and that same content is reused by other modules. Editing a deal updates it everywhere.

Variants

TemplateWhat it is
CK – Find a retailerThe page layout, filters, and results grid
CK – Find a retailer – CardThe individual result card

Filters

The filters are built as an Elementor form on the page.

When a filter changes, the script below fetches the matching results using the filter values in the page address, then swaps in the new results.

The matching itself is handled by a PHP query on the Dynamic Posts widget. That query reads the filter values and decides which deals and merchants to return.

If you add a filter, copy the format used by the existing filters. Anything that does not follow that format will need a developer to update the PHP query before it works.

These are the filters, and the names they use in the page address:

FilterName in the addressSelection
Deal typedeal_typeSeveral at once
Shopping optionsshopping_optionsSeveral at once
Interest-free termsinterestSeveral at once
CategoriescategoriesSeveral at once
MerchantsmerchantsOne at a time
SortsortOne at a time
SearchsFree text

Callouts

Two callouts appear depending on what is selected:

CalloutShows when
Shop onlineQ Card merchants are selected and the Online shopping option is not
Online onlyQ Card merchants are selected together with the Online shopping option

A matching card is also inserted into the results grid after the last result.

To add additional callouts, you need to duplicate the existing callout element and update the content and give it a unique ID. You then need to update the JS script to show it when your conditions are met. This will need to be done by a developer.

Classes

Primary classes and IDs used by the find a retailer component:

Class or IDWhat it's for
ck-retailersMain wrapper
retailers_formThe filter form ID
ck-retailers__sidebarThe filter sidebar ID
ck-retailers__resultsResults grid
ck-filters-loadingApplied to the results while new results load
ck-filters-openApplied while the mobile filter panel is open
ck-retailers__trigger-filter / ck-retailers__trigger-close-filter / mobile-apply-filtersOpen and close the mobile filter panel
ck-stickyApplied to the filter button while the directory is in view
ck-collapsedA collapsed filter group on mobile
ck-retailers__filters-view-more / ck-show-allShow more categories
ck-filter-btnMerchant type buttons
ck-retailers__sort-by-option / sort-by-optionSort options and the selected label
ck-retailers__clear-allClear all filters
shop-online-callout / online-only-callout / ck-retailers__callout-btnThe callouts and their buttons
ck-in-grid-cardCard inserted into the results grid

Styling is done through a combination of the built-in page builder options as well as custom CSS where needed.

Script

This script is added using the HTML editor template. It handles the filters, sorting, pagination, the mobile filter panel, and the callouts.

<script>
    const EXCLUSIVE_FILTERS = ["merchants", "sort"];
    const FORM_INPUTS = "#retailers_form input:not([type='hidden'])";

    function getQueryParam(name) {
        return new URLSearchParams(window.location.search).get(name);
    }

    function slugify(value, filterOption) {
        if (filterOption === "categories") {
            return value;
        }

        return value.toLowerCase().replaceAll(" ", "-");
    }

    function getFilterOption(input) {
        return input.getAttribute("name").split("[")[1].replace("]", "");
    }

    function isExclusiveFilter(filterOption) {
        return EXCLUSIVE_FILTERS.includes(filterOption);
    }

    function setFiltersOpen(open) {
        document
            .getElementById("ck-retailers__sidebar")
            .classList.toggle("active", open);
        document.documentElement.classList.toggle("ck-filters-open", open);
    }

    function buildRequestPath(url) {
        const query = url.searchParams.toString().replaceAll("+", "%20");
        return `${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
    }

    function reloadResults(url) {
        const requestPath = buildRequestPath(url);
        const $results = jQuery(".ck-retailers__results");
        $results.addClass("ck-filters-loading");
        $results.load(`${requestPath} .ck-retailers__results > *`, function () {
            $results.removeClass("ck-filters-loading");
            updatePaginationLinks();
            insertInGridCard();
            updateCalloutVisibility();
        });

        // Scroll to the .ck-retailers__results section after navigation
        const resultsSection = document.querySelector(".ck-retailers__results");
        if (resultsSection) {
            const yOffset = window.innerWidth >= 1025 ? -300 : -180;
            const y =
                resultsSection.getBoundingClientRect().top +
                window.pageYOffset +
                yOffset;
            window.scrollTo({ top: y, behavior: "smooth" });
        }
    }

    function navigate(url) {
        window.history.replaceState({}, "", buildRequestPath(url));
        updateCalloutVisibility();
        reloadResults(url);
    }

    function updatePaginationLinks() {
        document.querySelectorAll(".dce-pagination a").forEach((link) => {
            const parts = link.pathname.split("/").filter(Boolean);
            const page = parts.pop();
            if (!isNaN(page)) {
                link.href = `/find-a-retailer/page/${page}`;
            }

            link.addEventListener("click", (e) => {
                e.preventDefault();
                reloadResults(
                    new URL(
                        `${link.href.split("?")[0]}${window.location.search}`,
                        window.location.origin,
                    ),
                );
            });
        });
    }

    function updateCalloutVisibility() {
        const params = new URLSearchParams(window.location.search);
        const shopOnline = document.getElementById("shop-online-callout");
        const onlineOnly = document.getElementById("online-only-callout");
        if (!shopOnline || !onlineOnly) return;

        const isQcard = params.get("merchants") === "qcard";
        const isOnline = params.get("shopping_options")?.includes("online");

        shopOnline.setAttribute(
            "style",
            isQcard && !isOnline ? "display: flex !important" : "display: none",
        );
        onlineOnly.setAttribute(
            "style",
            isQcard && isOnline ? "display: flex !important" : "display: none",
        );

        const gridCard = document.querySelector(
            ".ck-retailers__results #ck-in-grid-card",
        );
        if (gridCard) {
            gridCard.setAttribute(
                "style",
                isQcard && isOnline
                    ? "display: flex !important"
                    : "display: none",
            );
        }
    }

    function insertInGridCard() {
        const lastResult = document.querySelector(
            ".ck-retailers__results article:last-of-type",
        );
        const gridCard = document.querySelector("#ck-in-grid-card");

        if (!lastResult || !gridCard) return;

        if (document.querySelector(".ck-retailers__results #ck-in-grid-card"))
            return;

        lastResult.after(gridCard.cloneNode(true));
    }

    function uncheckSiblingFilters(filter) {
        const group = filter.closest(".elementor-field-group");
        if (!group) return;

        group.querySelectorAll(FORM_INPUTS).forEach((input) => {
            if (input !== filter) input.checked = false;
        });
    }

    function syncMerchantButtons(value) {
        document.querySelectorAll(".ck-filter-btn").forEach((btn) => {
            btn.classList.toggle("active", btn.dataset.filter === value);
        });
    }

    function syncSortOptions(value) {
        const summaryEl = document.getElementById("sort-by-option");
        let matched = null;

        document
            .querySelectorAll(".ck-retailers__sort-by-option")
            .forEach((option) => {
                const isActive = option.dataset.sort === value;
                option.classList.toggle("active", isActive);
                if (isActive) matched = option;
            });

        if (matched && summaryEl) {
            summaryEl.textContent = matched.textContent.trim();
        }
    }

    function syncFormExclusiveFilter(filterOption, value) {
        const group = document.querySelector(
            `#retailers_form .elementor-field-group-${filterOption}`,
        );
        if (!group) return;

        group.querySelectorAll(FORM_INPUTS).forEach((input) => {
            input.checked = slugify(input.value) === value;
        });
    }

    function setExclusiveFilter(filterOption, value) {
        if (filterOption === "merchants") syncMerchantButtons(value);
        if (filterOption === "sort") syncSortOptions(value);
        syncFormExclusiveFilter(filterOption, value);
    }

    function updateFilter(filterOption, value, checked, exclusive = false) {
        const url = new URL(window.location.href);
        const params = url.searchParams;

        if (exclusive) {
            if (checked && value && value !== "all") {
                params.set(filterOption, value);
            } else {
                params.delete(filterOption);
            }
        } else {
            let values = (params.get(filterOption) || "")
                .split(",")
                .map((v) => v.trim())
                .filter(Boolean);

            values = checked
                ? values.includes(value)
                    ? values
                    : [...values, value]
                : values.filter((v) => v !== value);

            if (values.length) {
                params.set(filterOption, values.join(","));
            } else {
                params.delete(filterOption);
            }
        }

        navigate(url);
    }

    function clearAllFilters() {
        document.querySelectorAll(FORM_INPUTS).forEach((input) => {
            if (input.type === "checkbox" || input.type === "radio") {
                input.checked = false;
            } else {
                input.value = "";
            }
        });

        setExclusiveFilter("merchants", "all");

        const defaultSort = document.querySelector(
            ".ck-retailers__sort-by-option:first-of-type",
        );
        if (defaultSort) {
            setExclusiveFilter("sort", defaultSort.dataset.sort);
        }

        const url = new URL(window.location.href);
        url.pathname =
            url.pathname.replace(/\/page\/\d+\/?$/, "") || url.pathname;
        url.search = "";
        navigate(url);
    }

    function initializeDefaultFilters() {
        const merchantType = getQueryParam("merchants") || "all";
        setExclusiveFilter("merchants", merchantType);

        const sortOption =
            document.querySelector(
                `.ck-retailers__sort-by-option[data-sort="${getQueryParam("sort")}"]`,
            ) ||
            document.querySelector(
                ".ck-retailers__sort-by-option:first-of-type",
            );

        if (sortOption) {
            setExclusiveFilter("sort", sortOption.dataset.sort);
        }

        document.querySelectorAll(FORM_INPUTS).forEach((filter) => {
            const filterOption = getFilterOption(filter);
            const value = slugify(filter.value, filterOption);
            const paramValue = getQueryParam(filterOption);

            if (paramValue?.includes(value)) {
                filter.checked = true;
            }

            filter.addEventListener("change", (e) => {
                const exclusive = isExclusiveFilter(filterOption);

                if (exclusive && e.target.checked) {
                    uncheckSiblingFilters(filter);
                    setExclusiveFilter(filterOption, value);
                }

                updateFilter(filterOption, value, e.target.checked, exclusive);
            });
        });
    }

    function setupSidebar() {
        document
            .querySelector("#ck-retailers__trigger-filter a")
            ?.addEventListener("click", (e) => {
                e.preventDefault();
                setFiltersOpen(true);
            });

        document
            .querySelectorAll(".ck-retailers__trigger-close-filter a")
            .forEach((link) => {
                link.addEventListener("click", (e) => {
                    e.preventDefault();
                    setFiltersOpen(false);
                });
            });

        document
            .querySelector("#mobile-apply-filters")
            ?.addEventListener("click", (e) => {
                e.preventDefault();
                setFiltersOpen(false);
            });

        if (window.innerWidth >= 1025) return;

        document
            .querySelectorAll("#retailers_form .elementor-field-group")
            .forEach((group) => {
                const label = group.querySelector("label");
                if (!label) return;

                label.style.cursor = "pointer";
                group.classList.add("ck-collapsed");
                label.addEventListener("click", (e) => {
                    e.preventDefault();
                    group.classList.toggle("ck-collapsed");
                });
            });
    }

    function setupCategoriesViewMore() {
        const viewMore = document.querySelector(
            ".ck-retailers__filters-view-more",
        );
        const categories = document.querySelector(
            "#retailers_form .elementor-field-group-categories",
        );
        if (!viewMore || !categories) return;

        categories.appendChild(viewMore);
        viewMore.addEventListener("click", (e) => {
            e.preventDefault();
            categories.classList.toggle("ck-show-all");
            viewMore.querySelector("p").textContent =
                categories.classList.contains("ck-show-all")
                    ? "View less"
                    : "View more";
        });
    }

    function bindEventListeners() {
        const retailers = document.querySelector(".ck-retailers");
        const filterButton = document.querySelector(
            "#ck-retailers__trigger-filter",
        );

        window.addEventListener("scroll", () => {
            const { top, bottom } = retailers.getBoundingClientRect();
            const isSticky = top <= 0 && bottom > 0;

            filterButton.classList.toggle("ck-sticky", isSticky);
        });

        document
            .querySelectorAll(".ck-retailers__clear-all")
            .forEach((button) => {
                button.addEventListener("click", (e) => {
                    e.preventDefault();
                    clearAllFilters();
                });
            });

        document.querySelectorAll(".ck-filter-btn").forEach((btn) => {
            btn.addEventListener("click", (e) => {
                e.preventDefault();
                const value = btn.dataset.filter;
                setExclusiveFilter("merchants", value);
                updateFilter("merchants", value, true, true);
            });
        });

        document
            .querySelectorAll(".ck-retailers__sort-by-option")
            .forEach((option) => {
                option.addEventListener("click", (e) => {
                    e.preventDefault();
                    const value = option.dataset.sort;
                    setExclusiveFilter("sort", value);
                    updateFilter("sort", value, true, true);
                    const details = option.closest("details");
                    if (details) details.open = false;
                });
            });

        const onlineInput = document.querySelector(
            '#retailers_form .elementor-field-group-shopping_options input[value="Online"]',
        );

        document
            .querySelector("#shop-online-callout .ck-retailers__callout-btn")
            ?.addEventListener("click", (e) => {
                e.preventDefault();
                onlineInput.checked = true;
                onlineInput.dispatchEvent(new Event("change"));
            });

        document
            .querySelector("#online-only-callout .ck-retailers__callout-btn")
            ?.addEventListener("click", (e) => {
                e.preventDefault();
                onlineInput.checked = false;
                onlineInput.dispatchEvent(new Event("change"));
            });
    }

    document.addEventListener("DOMContentLoaded", () => {
        updatePaginationLinks();
        setupSidebar();
        initializeDefaultFilters();
        setupCategoriesViewMore();
        insertInGridCard();
        updateCalloutVisibility();
        bindEventListeners();
    });
</script>

Technical notes

For developers. The directory is two halves: the HTML editor script updates the page address and reloads the results container, and a PHP query on the Dynamic Posts widget reads those same query parameters and decides which posts to return.

How a filter change works

  1. A change on #retailers_form, a merchant type button, or a sort option updates window.location.search.
  2. history.replaceState writes the new address without a full navigation.
  3. jQuery .load() requests that address and replaces the children of .ck-retailers__results.
  4. WordPress renders the page again. The Dynamic Posts widget runs the PHP query against $_GET.
  5. After the fragment lands, the script rewires pagination links, clones the in-grid callout card, and updates callout visibility.

The form never submits. The query string is the contract between the script and the PHP.

Query parameters

ParameterExclusiveHow the script writes itWhat the PHP reads it as
deal_typeNoComma-separated, slugifiedinterest-free-offers, payment-holidays, standard-purchases
shopping_optionsNoComma-separated, slugifiedCompared to availability terms after normalisation
interestNoComma-separated, slugifiedA min-max range, for example 6-12 from 6-12 months
categoriesNoComma-separated, not slugifiedCompared to category names after normalisation
merchantsYesOne of qcard, qmastercard, or omitted (all)Card type on the post, not a merchant post
sortYesOne of newest, oldest, title_asc, title_descDefaults to newest if missing or unknown
s—Free textPassed into get_posts() as the WordPress search

all and empty values are dropped. Exclusive filters (merchants, sort) replace the previous value; the rest append or remove from a CSV list.

slugify() lowercases the input and turns spaces into hyphens, except for categories, which are sent as typed. The PHP then strips punctuation and spaces so In-store and in-store both become instore.

Form field names must keep the name="…[param]" shape. The script reads the name inside the brackets to decide which parameter to update.

PHP query

The Dynamic Posts widget uses a custom query. It does not filter in SQL. It loads every published deals and merchants post (optionally narrowed by s), applies the selected sort, then loops in PHP and keeps the IDs that match every active filter. Filters combine with AND. Options inside one filter combine with OR.

It returns:

[
	'post_type'      => $query_post_types,          // deals, merchants; plus qcard_online when Q Card + Online
	'post_status'    => 'publish',
	'post__in'       => ! empty( $filtered_ids ) ? $filtered_ids : [ 0 ],
	'orderby'        => 'post__in',
	'posts_per_page' => 12,
]

post__in => [ 0 ] is deliberate: an empty match must still return no rows. orderby => post__in keeps the PHP sort order.

Fields the query reads

FilterDeals fieldMerchants field
Deal typepayment_holidayPost type only (standard-purchases)
Shopping optionsdeal_availabilitymerchant_availability and merchant_payment_options
Interest-free termsdeal_interest_free_termNot read
Categoriesdeal_categorymerchant_category
Card typedeal_productmerchant_product
A–Z sort titleRelated deal_merchant titleThe merchant's own title

Renaming any of those fields, or the deals / merchants post types, will break the directory.

Deal type (deal_type)

Selected valueDealsMerchants
interest-free-offersAlways matchNever match
payment-holidaysMatch when payment_holiday has any termNever match
standard-purchasesNever matchAlways match

Shopping options (shopping_options)

Availability is a taxonomy. The query compares normalised term names, so the URL value does not have to match the term slug.

On merchants, merchant_payment_options is an extra match:

URL valueAlso matches when merchant_payment_options contains
onlineonline
in-storeswipe

swipe is the stored value for in-store / Eftpos-style payment, not the label shown in the admin. Long Term Finance and Eftpos labels are not used by this filter.

Interest-free terms (interest)

Only deals are considered. A selected value is treated as a numeric range: 6-12-months becomes min 6, max 12. Each deal term name is split on a space and the first number is compared to that range, so a term named 10 months matches 6-12.

If the URL value does not produce two numbers, the deal is excluded. Merchants never match, even when merchant_interest_terms is filled in.

Categories (categories)

Compared against deal_category or merchant_category term names after hqc_normalize_category_value(): HTML entities decoded, lowercased, then everything that is not a letter or number is stripped.

Card type (merchants)

Despite the parameter name, this is Q Card vs Q Mastercard, not a merchant picker.

URL valueStored product value
qcardqc
qmastercardqmc
omitted / allNo filter

Sort

URL valueFirst passThen
newestorderby => date, DESCDeals, then merchants
oldestorderby => date, ASCDeals, then merchants
title_ascRe-sorted with hqc_get_sort_titleDeals, then merchants
title_descSame, reversedDeals, then merchants

hqc_get_sort_title() uses the related merchant title for deals, so A–Z groups a deal under its retailer rather than the deal title. If two titles match, the post title is the tie-break.

Deals always sit above merchants after that sort. The selected order only applies inside each type.

Unknown sort values fall back to newest.

Q Card + Online extra post type

When merchants=qcard and shopping_options includes online, the query adds qcard_online to post_type. That extra type is not part of the PHP filter loop, so it only appears in the final WP_Query. There is a TODO in the query to confirm that post type is the one you need.

JavaScript behaviour

Results reload. reloadResults() adds ck-filters-loading to .ck-retailers__results, then .load()s path + search + ' .ck-retailers__results > *'. Spaces in the query string are encoded as %20, not +. After load it scrolls to the results (offset -300 from 1025px up, -180 below that).

Pagination. Dynamic Posts links are rewritten to /find-a-retailer/page/{n}. Clicks keep the current query string and go through reloadResults(), so filters survive paging. Clearing filters also strips /page/{n} from the path.

Merchant buttons and sort. .ck-filter-btn uses data-filter. .ck-retailers__sort-by-option uses data-sort. Both are exclusive. #sort-by-option is updated to the selected label.

Callouts. Driven only by the query string, not by the PHP.

ConditionShown
merchants=qcard and Online is not selected#shop-online-callout
merchants=qcard and Online is selected#online-only-callout and #ck-in-grid-card

insertInGridCard() clones #ck-in-grid-card after the last article in the results. The clone has to happen after every reload because .load() replaces the grid.

The Shop online button checks the form input with value="Online" and fires change. The Online only button unchecks it the same way.

Mobile. Below 1025px, each .elementor-field-group starts as ck-collapsed and the label toggles it. #ck-retailers__trigger-filter opens the sidebar (#ck-retailers__sidebar.active plus ck-filters-open on html). The filter button gets ck-sticky while .ck-retailers is in view.

Categories “View more”. .ck-retailers__filters-view-more is moved into the categories field group and toggles ck-show-all on that group.

Things to watch

  • Adding a filter means updating the form field name, the script (if it is exclusive or needs a different slug), and the PHP loop. The PHP will ignore a parameter it does not read.
  • Do not rename ck-retailers__results, retailers_form, ck-retailers__sidebar, or the callout IDs. The script and the .load() selector depend on them.
  • Category values must stay readable names. Slugifying them on the JS side would stop the PHP name comparison from matching.
  • Interest values must slugify into a number-number-… shape so the PHP can read a min and max.
  • Merchant in-store matching looks for swipe in merchant_payment_options, not in-store or eftpos.
  • The merchants parameter is card type (qc / qmc). It does not filter by merchant post.
  • The query loads every published deal and merchant before filtering, then pages 12 at a time in the widget. Large catalogues will feel that.
  • qcard_online is only appended for Q Card + Online and is not filtered by the same ACF rules as deals and merchants.

On this page