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
- Keep the deal and merchant content accurate — availability, categories, product type, and expiry all feed this page (see Deals and Merchants).
- Go to Elementor → Editor → Templates.
- Search for
Find a retailer(or the specific template name below). - Open the template and edit the static content, such as headings and callouts.
- 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
| Template | What it is |
|---|---|
CK – Find a retailer | The page layout, filters, and results grid |
CK – Find a retailer – Card | The 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:
| Filter | Name in the address | Selection |
|---|---|---|
| Deal type | deal_type | Several at once |
| Shopping options | shopping_options | Several at once |
| Interest-free terms | interest | Several at once |
| Categories | categories | Several at once |
| Merchants | merchants | One at a time |
| Sort | sort | One at a time |
| Search | s | Free text |
Callouts
Two callouts appear depending on what is selected:
| Callout | Shows when |
|---|---|
| Shop online | Q Card merchants are selected and the Online shopping option is not |
| Online only | Q 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 ID | What it's for |
|---|---|
ck-retailers | Main wrapper |
retailers_form | The filter form ID |
ck-retailers__sidebar | The filter sidebar ID |
ck-retailers__results | Results grid |
ck-filters-loading | Applied to the results while new results load |
ck-filters-open | Applied while the mobile filter panel is open |
ck-retailers__trigger-filter / ck-retailers__trigger-close-filter / mobile-apply-filters | Open and close the mobile filter panel |
ck-sticky | Applied to the filter button while the directory is in view |
ck-collapsed | A collapsed filter group on mobile |
ck-retailers__filters-view-more / ck-show-all | Show more categories |
ck-filter-btn | Merchant type buttons |
ck-retailers__sort-by-option / sort-by-option | Sort options and the selected label |
ck-retailers__clear-all | Clear all filters |
shop-online-callout / online-only-callout / ck-retailers__callout-btn | The callouts and their buttons |
ck-in-grid-card | Card 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
- A change on
#retailers_form, a merchant type button, or a sort option updateswindow.location.search. history.replaceStatewrites the new address without a full navigation.- jQuery
.load()requests that address and replaces the children of.ck-retailers__results. - WordPress renders the page again. The Dynamic Posts widget runs the PHP query against
$_GET. - 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
| Parameter | Exclusive | How the script writes it | What the PHP reads it as |
|---|---|---|---|
deal_type | No | Comma-separated, slugified | interest-free-offers, payment-holidays, standard-purchases |
shopping_options | No | Comma-separated, slugified | Compared to availability terms after normalisation |
interest | No | Comma-separated, slugified | A min-max range, for example 6-12 from 6-12 months |
categories | No | Comma-separated, not slugified | Compared to category names after normalisation |
merchants | Yes | One of qcard, qmastercard, or omitted (all) | Card type on the post, not a merchant post |
sort | Yes | One of newest, oldest, title_asc, title_desc | Defaults to newest if missing or unknown |
s | — | Free text | Passed 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
| Filter | Deals field | Merchants field |
|---|---|---|
| Deal type | payment_holiday | Post type only (standard-purchases) |
| Shopping options | deal_availability | merchant_availability and merchant_payment_options |
| Interest-free terms | deal_interest_free_term | Not read |
| Categories | deal_category | merchant_category |
| Card type | deal_product | merchant_product |
| A–Z sort title | Related deal_merchant title | The merchant's own title |
Renaming any of those fields, or the deals / merchants post types, will break the directory.
Deal type (deal_type)
| Selected value | Deals | Merchants |
|---|---|---|
interest-free-offers | Always match | Never match |
payment-holidays | Match when payment_holiday has any term | Never match |
standard-purchases | Never match | Always 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 value | Also matches when merchant_payment_options contains |
|---|---|
online | online |
in-store | swipe |
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 value | Stored product value |
|---|---|
qcard | qc |
qmastercard | qmc |
omitted / all | No filter |
Sort
| URL value | First pass | Then |
|---|---|---|
newest | orderby => date, DESC | Deals, then merchants |
oldest | orderby => date, ASC | Deals, then merchants |
title_asc | Re-sorted with hqc_get_sort_title | Deals, then merchants |
title_desc | Same, reversed | Deals, 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.
| Condition | Shown |
|---|---|
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-storematching looks forswipeinmerchant_payment_options, notin-storeoreftpos. - The
merchantsparameter 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_onlineis only appended for Q Card + Online and is not filtered by the same ACF rules as deals and merchants.