SA Motorlease
Combined SA Motorlease plugin. Imports vehicles from the PaceApp feed into WooCommerce (create/update/prune + image repair), and provides lead qualification (REST + DB table), Gravity Forms #5 forwarding, application/qualification frontend scripts, vehicle-locations carousel data, sold-product/duplicate/missing-feed cleanup utilities, attribute backfills and CSV export.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/justin-netage/sa-motorlease/archive/refs/heads/main.zipCombined SA Motorlease plugin: PaceApp vehicle importer plus lead-qualification, application forwarding and frontend helpers for the SA Motorlease site.
Description
This plugin merges two previously-separate plugins (sa-motorlease-product-importer and sa-motorlease-vehicle-importer) into a single bundle.
*Vehicle importer (vi_ pipeline)**
- Hourly create + update crons (offset 32 min) that pull from the PaceApp feed
- Per-run create/update caps and wall-clock budgets
- Embedded-image decoding from the feed and diff-based gallery sync
- Image-repair cron and bulk image-sync admin UI
- Vehicle-filter index rebuild and host page-cache purge after each run that changed the catalogue
- Optional pruning of products not in the feed
- Rolling log in
wp-content/uploads/sa-motorlease-logs/(survives plugin updates; not reachable over HTTP)
Lead qualification and application flow
lead_qualificationsDB table (created on activation)- REST endpoints:
/samotorlease/v1/qualify-lead,/qualified-vehicles,/partial-save,/update-deposit-special,/custom-api/v1/sold-products - Gravity Form #5 forwarding to PaceApp
paceWebUpdateLead - Frontend script enqueues for the qualification, results and application pages
- Vehicle-locations carousel data (Flickity)
[qualified_results_vehicles]and[cheapest_price]shortcodes- WooCommerce "no products found" override
Site controls (SA Motorlease → Settings)
- Optional separate "Leads base URL" so lead traffic can post to a different PACE host to the vehicle feed (blank = inherit the main base URL)
- Maintenance mode — branded HTTP 503 holding page with editable heading and message, behind an "are you sure?" confirmation; administrators, wp-admin, wp-login.php, cron and the REST API are never blocked
Maintenance / admin URL triggers (all gated by manage_options or manage_woocommerce)
?update_license_plates,?deduplicate_products,?export_attributes?fix_broken_images,?test_image_feed=<sku>?run_payments_backfill,?format_deposit_special,?cleanup_initiation_fee_special?cleanup_sold_products,?update_sold_from_feed,?remove_by_reg_cleanup?log_missing_products,?remove_missing_products,?backfill_image_alt
Installation
- Upload the
sa-motorleasefolder to/wp-content/plugins/. - Deactivate any previous
SA Motorlease Product ImporterorVehicle Importerplugins. - Activate SA Motorlease in Plugins.
Updates
This plugin self-updates via Plugin Update Checker, pointed at https://github.com/justin-netage/sa-motorlease (branch main, release assets). To ship an update: bump the Version: header and SA_MOTORLEASE_VERSION constant, commit, then publish a GitHub Release whose tag matches the new version. A workflow attaches the build zip automatically.
Changelog
2.6.39
- "See what vehicles you qualify for" works with the new vehicle filter again. The button on the qualification results page still linked with the old WooCommerce Product Filter's
?wpf_min_price=0&wpf_max_price=<limit>params, which the new filter ignores, so visitors landed on the unfiltered catalogue. The button now links to?price=0-<limit>, and the filter accepts any whole-rand max-price ceiling in the URL (not just the ones on the dropdown ladder), adding it to the Max Price dropdown as "Up to R". Oldwpf_max_pricelinks are still honoured and mapped to the same ceiling.
2.6.38
- The NEW badge is now white with orange text, on the listing cards and the single product page — the inverse of the orange SOLD badge rather than the navy it shipped with in 2.6.37. Filter asset version bumped to 2.0.5.
2.6.37
- NEW badge on the single product page. The vehicle's own page now carries the same NEW badge as its listing card, in the corner of the image gallery where the theme puts its Sold label, styled to match it (navy rather than orange). Same rule as the cards: a sold vehicle shows Sold only. Hooked into the theme's
flatsome_sale_flashaction inside the gallery box, so the child theme's product-image template needs no change and the CSS is printed by the plugin on product pages.
2.6.36
- NEW badge on vehicle cards. Cards for vehicles whose New or Used attribute is New carry a navy NEW badge in the corner, in the same spot and style as the orange SOLD badge. A card shows at most one badge: a sold vehicle shows SOLD only, whatever its condition. Read from the cached index (the condition facet), with the same direct-term fallback the SOLD badge uses. Filter asset version bumped to 2.0.4, which also rebuilds the cached card HTML.
- Carousels can be scoped by vehicle attribute.
[sa_featured_vehicles]takescondition,make,model,transmission,body_type,fuelandyear— e.g.[sa_featured_vehicles condition="new" title="New Vehicles"]shows only new vehicles: the featured ones first, topped up with the newest new vehicles. A comma lists alternatives (fuel="petrol,diesel"), attributes combine with AND, andattr="new-or-used:new; transmission:automatic"reaches any other product attribute by taxonomy (;between attributes,|between values). Values match as term slugs after sanitising, soNewandnewboth work. The scope applies alongsidecategory, and the strip is omitted when nothing matches.
2.6.35
- "Recently Added" is the default sort. The featured strip already sits above the grid, so opening the grid with the same featured vehicles again just repeated them. The grid now opens newest-first; "Featured" stays in the dropdown for anyone who wants it. The dropdown lists Recently Added first, the URL omits
sort=for the default (so?sort=featuredis what a shared Featured view carries), and Clear resets to it. Filter asset version bumped to 2.0.3.
2.6.34
- The "Featured" sort now actually puts featured vehicles first. The default sort option in the vehicle filter was catalogue order — date-descending, i.e. "Recently Added" under another name — and the WooCommerce Featured flag was not even in the filter's index, so flagging a vehicle changed nothing in the grid. The flag is now indexed and the Featured sort leads with the flagged vehicles (available ones only — a sold vehicle does not head the catalogue), then everything else in catalogue order. Applied to both the server-side sort and its client-side mirror. Filter asset version bumped to 2.0.2, which also rebuilds the cached index and client dataset so the flag is present.
- Releases now go live for visitors without a manual cache clear. A plugin update changes what pages render, but only catalogue changes purged the host's full-page cache, so after every release the public site kept serving the pre-update HTML (old script version, old carousel markup) until the next import happened to flush it — 2.6.33 sat behind a cached homepage for exactly this reason. The first request that runs under a new plugin version now drops the vehicle caches and purges the page cache itself.
2.6.33
- Featured carousels now show every featured vehicle. The strip took the vehicles flagged "Featured" in WooCommerce, then applied the same one-card-per-make collapse and the
limitcap (8 by default) that were meant for the top-up stock. With 17 vehicles flagged across 9 makes the homepage carousel showed 8 — a single Haval, Polo, Jaecoo, Kwid and C3 each — and dropped the rest, so flagging a vehicle often had no visible effect. Flagged vehicles are now a hand-picked set and always all appear, newest first;limitis the minimum card count that unflagged stock tops up to, anddedupeonly shapes that top-up (seeded with the makes the flagged vehicles already cover, so the filler adds variety rather than repeats). - Carousel cards load on demand. Only the first
limitcards (8) are in the page; the remaining vehicle IDs ride along on the strip and the next batch is fetched (newsa_vf_cardsAJAX action) and appended as the visitor keeps clicking Next or drags towards the end, so the initial HTML stays the same size however many vehicles are flagged. The Next arrow stays live while more remain; a failed fetch re-queues its batch for the next click. Filter asset version bumped to 2.0.1.
2.6.32
- Dead vehicle URLs now redirect to live stock instead of a 404. Vehicles are hard-deleted when they drop out of the PACE feed (prune) or a week after they sell, and nothing told anyone holding the old link: Google kept the pages indexed for weeks (every top result for
site:samotorlease.co.za/listing/was a 404), shared WhatsApp links and browser history went dead, and an anonymous visitor could be served a cached listing grid rendered before the deletion. Any 404 under the product permalink base (/listing/…) is now a 301 to the listings page, narrowed to the vehicle's make when the slug names one that still has stock (/listings/?make=citroen), so the visitor lands on comparable vehicles and search engines are told to drop the URL. Sent with no-cache headers so a slug reissued to a new vehicle later is not shadowed by a browser- or edge-cached redirect. - Purge the page cache while deleting, not only afterwards. The prune pass and the daily expired-sold cron each delete vehicles one at a time and flushed once at the end. A product page is 404 the instant its row is gone, but the cached grid kept linking to it until that final flush — minutes later when the loop is slow (each deletion also removes attachments), or never when the run was killed first. This is the window a visitor hit when clicking five Citroens from a listing served at 08:38 that had all already been deleted. Both loops now purge after each deletion, throttled to once per 30 s so a mass prune does not hammer the host purge API, and the end-of-request catch-all stays armed for anything deleted after the last mid-run purge.
2.6.31
- Sold vehicles are shown by default again. The "Available Only" toggle introduced in 2.5.0 was pre-checked, so an anonymous visitor's first view of the catalogue silently excluded every sold vehicle — the site looked smaller than the stock actually is, and the SOLD badge was effectively unreachable without knowing to untick a box. The toggle now starts unchecked: sold vehicles are listed alongside available ones, badged SOLD, and ticking "Available Only" hides them. The URL parameter inverts to match — the deviation from the default is now
?available_only=1rather than?show_sold=1. Links shared while the old default was in force still resolve to the same view, sinceshow_sold=1asked for what is now the default anyway. - Sold vehicles are no longer pushed to the last page. They previously sorted below every available vehicle regardless of the chosen sort, which was tolerable while they were hidden by default but not once they are shown: sorting by price produced a price-ordered list whose sold entries were all stranded at the end, so the order the visitor asked for was not the order they got. Sold vehicles now take their natural place in whichever sort is active and are distinguished by the badge on the card rather than by position. Applied to both the server-side sort and its client-side mirror, which had the same rule duplicated.
- Filter asset version bumped to 2.0.0, which also busts the cached vehicle index, client dataset and per-query payload caches — those embed the rendered order, so they have to be rebuilt for the sort change to take effect rather than waiting out the hour TTL.
2.6.30
- Purge the page cache on any catalogue change, not just imports. 2.6.29 wired the purge into the two import passes, which was not enough: the 5-minute image sync, the image-repair and broken-image crons, the daily sold-date pass, the daily expired-sold deletion (which changes the vehicle count) and any manual product edit in wp-admin all bump the index version without going near an import pass. Each left the index correct and the cached HTML stale — the same "right when logged in, behind when not" symptom 2.6.29 was meant to end. The purge now hangs off the version bump itself via a
shutdownhook, so a future cron cannot reintroduce this by forgetting to flush. It fires at most once per request and only on requests that actually wrote to a vehicle, so ordinary page views are unaffected. - Purge before warming the index, not after. The warm renders a card for every vehicle and is by far the most expensive step in a flush — and an import run is exactly where the PHP timeout bites. A purge queued behind the warm never happens when the request is killed part-way through it, leaving the transients dropped but the stale HTML still served. Purging first risks only a short window in which one visitor triggers a cold build; purging last risked a day of stale stock for everyone.
- A failing feed no longer hides its reason. A non-200 from PACE logged
Feed HTTP not 200; aborting., which matched none of the log classifier's keywords, so it was filed as INFO and dropped at the default WARN threshold — leaving nothing in the log butCreate END (feed error)and no status code to diagnose from. It now logs the status code, the elapsed time and a short body snippet at WARN, so an HTML error page and a JSON error body are distinguishable at a glance.
2.6.29
- New vehicles now appear on the site as soon as they import, instead of hours or a day later. They were being created and published correctly — visible in the product list, and visible on the front end to anyone logged in — but anonymous visitors kept seeing the old catalogue. The vehicle grid is not fetched at render time: the whole catalogue is inlined into the page HTML and filtered client-side, so a cached page is a frozen vehicle list, and nothing in the plugin ever told the host to purge it.
sa_vf_flush_caches()now purges the host full-page cache (Kinsta's mu-plugin, behind class/method guards so it is inert elsewhere) after rebuilding the index, and fires asa_vf_page_cache_purgedaction for any other cache layer to hook. - Fixed the stale-index race that could hide a vehicle for a full day. The index version was debounced to one bump per request so a bulk import stamped a single version — but any front-end request that rebuilt the index midway through a run then cached an incomplete catalogue under that same version, where it read as fresh until the transient expired. The end-of-run flush now force-bumps past any version a mid-run rebuild could be holding, and the per-request memos (index, client dataset, price ladder) are pooled so they can be dropped together rather than surviving the flush and being re-cached stale.
- Both import passes now refresh the filter in-request. Previously only the update pass triggered a reindex, via a
wbw_custom_index_cronevent scheduled 30 minutes after the create pass — so a create-only hour never flushed at all, and a truncated update run never got as far as scheduling it. Each pass now calls the refresh directly, and only when the catalogue actually changed, so a quiet feed no longer discards a warm page cache. - Removed a dead 60-second call from the end of every update run. With Woo Product Filter retired,
wbw_index_after_import_smart()always failed on the missingFrameWpfclass and fell through to a loopback HTTP POST that nothing answers — burning up to a minute immediately before the line that scheduled the only automatic cache flush. On a run already near the PHP timeout, that is what killed the request before the flush was scheduled. - The WP-Cron-disabled fallback works again. The deferred
admin_initreindex called only the retired WBW indexer, so on sites running system cron it had silently stopped refreshing the custom filter. It now flushes the filter caches, and the pending-refresh flag is raised on entry to a pass and cleared once the refresh resolves — so a flag still standing means a run died before finishing, and the next admin page load recovers it. - Index cache TTL cut from 24 hours to 1 (
SA_VF_CACHE_TTL). The version stamp is the real freshness mechanism; the TTL is a fail-safe, and at a day a missed bump meant a stale catalogue survived a full day. A rebuild is a handful of bulk queries, so expiring more often costs little.
2.6.18
- Fixes "Feed fetch failed this slice" in the 2.6.17 batched import runner. The runner calls
vi_fetch_feed()once per slice, which turned a twice-hourly request into one every ~20s for the length of a run — enough to get throttled or time out against the PACE host, and every failure aborted a slice that would otherwise have done useful work. The parsed feed is now cached across the slices of a single run (VI_FEED_CACHE_SEC, default 180s, compressed before storing since the payload is close enough to the ~1 MB value ceiling some object caches enforce that storing it raw risks being silently dropped). Opening the progress page clears the cache first, so a run always starts against fresh data — you still see a change made in PACE seconds earlier. Both hourly crons are unaffected: the TTL defaults to 0, which always goes to the network. - Force-refresh for vehicles PACE changed without bumping
write_date. The update pass gates every field — price, attributes, images, categories — on the feed'swrite_datediffering from the stored_vi_write_datemarker. Anything PACE changes without touchingwrite_date(deposit amounts are the known case) is therefore invisible to the importer, and the run reports success having skipped the vehicle; editing or deleting the product's attributes changes nothing, because the code that rewrites them sits behind the same gate. The progress UI now accepts&force_sku=SKU(clear the marker for one vehicle) or&force=1(all vehicles) so the next pass refreshes them in full. Note a full-fleet force re-syncs images too, so expect it to take a while. - The runner now says why the feed failed. "Feed fetch failed" covered three quite different causes — transport error, a non-200 from PACE, or an unparseable body — and finding out which meant going to the log. The specific reason is now shown inline, and after 5 consecutive failures the run stops with an explanation instead of retrying forever. Retry backoff went from 5s to 10s.
2.6.17
- "Run import now" no longer times out. It ran the whole create+update pass inside one HTTP request. The budgets allow up to
VI_MAX_CREATE_SEC+VI_MAX_UPDATE_SEC(600s by default) and the import writes progress to the log rather than the response, so the connection sat silent for minutes — which nothing in the chain tolerates. Cloudflare cuts the origin off at 100s (524), and PHP-FPM'srequest_terminate_timeoutand nginx'sproxy_read_timeoutare both outside whatset_time_limit()can raise, so the request died before the import finished every time. There's now a Run import now (progress UI) action that runs the same two functions in ~20s slices with the browser driving the loop — the same pattern the image-sync progress page already used. No single request is long enough to hit a proxy or FPM limit, and no WP-Cron loopback is involved. Live created/updated counts, a slice log, and a Stop button that finishes the slice in flight. Both passes are resumable by construction (create skips existing SKUs, update skips products whose_vi_write_datematches the feed), so stopping or losing the tab costs nothing — reopening carries on. If a scheduled run holds the lock the UI says so and retries rather than failing. Slice length isVI_IMPORT_SLICE_SEC(default 20). vi_create_new_products()andvi_update_existing_products()take optional budget and cap arguments and return a summary (created/updated, andmore— whether the pass stopped on its cap/budget or reached the end of the feed). Both default to the existing constants, so the hourly crons and the legacy wrapper behave exactly as before.- The old single-request trigger is kept as Run import now (single request) for the case where a browser tab isn't available, with its timeout caveat stated on the button.
2.6.16
- The PACE base URL setting now actually applies to the vehicle feeds. It only ever reached the three lead endpoints. Every data-feed (
paceWebPrepData) and image-feed (paceWebPrepImages) call hadhttps://paceapp-server.azurewebsites.netwritten in as a literal and never read the setting at all — so pointing the field at the preview host still imported from production, and a query string like?env=prodwas silently dropped from every feed request while being correctly applied to leads. All 13 feed and image call sites now resolve throughsa_motorlease_pace_url(): the importer (vi_fetch_feed,vi_collect_usable_images,vi_vehicle_has_images,vi_prepare_images_from_feed_for_vehicle), the image rebuilder and?test_image_feed, and the sold/missing/backfill utilities. Query strings survive the/id/{sku}path segment on image URLs, which naive concatenation would have corrupted. With the setting at its default the resolved URLs are byte-identical to the old literals, so nothing changes until the field is changed. The setting's help text no longer claims the query string reaches "every endpoint" when it reached three, and the Status page's "Example feed endpoint" row (added in 2.6.13) now shows a URL the importer genuinely calls rather than a hypothetical one. process_images_for_existing_vehicle()lost its$domainparameter, which only ever carried that same hardcoded literal. PHP ignores extra arguments to userland functions, so any stale two-argument call still works.
2.6.15
Fixes the "Invalid API response (missing lead_id)" error users saw on /qualify-lead when the PACE API was the thing that failed, and adds a site notice for telling visitors about an outage without taking the site down.
-
Site notice (new). A Settings section that puts a thin banner above the navbar and a popup on the public site while leaving it fully online — the soft alternative to maintenance mode, for a PACE outage or a heads-up ahead of a maintenance window. The popup shows once per visitor session (tracked in
sessionStorage, so the markup stays cacheable) and the banner's More info button reopens it on demand. Heading and message default to the maintenance-mode copy, so there's nothing to write twice; editing either counts as a new notice and re-shows the popup to people who dismissed the old one. Banner and popup toggle independently, admins get a?sa_notice_preview=1preview link and a persistent admin notice while it's live. -
Honest error reporting. A PACE 5xx (or any unparseable response) now returns 502 with "Our lead system is temporarily unavailable" instead of a 400 reading "Invalid API response (missing lead_id)". PACE returns a bare 500 with a non-JSON body when it's unhealthy, which
json_decodeturned into null — that fell through the missing-lead_id branch and told the user their submission was invalid. The three failure classes (transport/5xx, 4xx rejection, 2xx without a lead_id) are now distinguished, and each logs one line instead of two near-identical WARNs. -
Retry on fast failures.
paceWebCreateLeadis retried once, 1.5s later, when the failure came back in under 3 seconds. A normal create takes 10-15s (PACE allocates the lead, then runs the qualification calculation), so a sub-second 500 means nothing was created and is safe to replay. Slow 500s and timeouts are never retried — PACE may already have created the lead, and a retry would allocate a second one. -
Failed submissions are recorded. Failures are kept in a structured list on the Status page (name, contact, ID, reason, HTTP status, attempts) so they can be reconciled against PACE — a 500 after lead creation leaves a lead upstream that we have no local record of. Rows age out on the log retention window.
2.6.14
- The log no longer gets wiped by every plugin update — and is no longer publicly readable. It lived at
wp-content/plugins/sa-motorlease/sa-motorlease.log, inside the folder WordPress deletes and re-unpacks on each update, so every release threw away the entire history right when it was most useful for checking whether the release behaved. It now lives inwp-content/uploads/sa-motorlease-logs/, which updates don't touch. That old path was also fetchable over HTTP at a guessable URL, and WARN/ERROR lines can carry full lead request/response bodies (unmasked, by design, so failures are debuggable) — so the new directory gets a deny-all.htaccessand anindex.html, and the filename carries a per-site hash derived from the WP salts, which is what protects it on nginx where.htaccessis ignored. A log left at the old path by a manual folder replacement (rather than the WP updater, which deletes it) is moved across on first write. SetSA_MOTORLEASE_LOG_DIRinwp-config.phpto keep logs somewhere else entirely, e.g. outside the webroot. The Status page shows the resolved path and whether the directory guard is in place.
2.6.13
- Separate base URL for lead traffic. New optional "Leads base URL" setting under PACE API. When set, the three lead calls (
paceWebCreateLeadfrom qualify-lead, andpaceWebUpdateLeadfrom partial-save and the Gravity Forms forwarder) post to that host while the vehicle feed and image endpoints keep using the main PACE base URL — so lead traffic can be moved to a different PACE server independently of the importer. Left blank (the default) it inherits the main base URL, so existing installs are unchanged. Query strings on the base are preserved on every endpoint exactly as before, and the Status page now shows both bases plus an example endpoint for each. - Maintenance mode. New Maintenance Mode section with an on/off toggle, an editable heading and message, and an "are you sure?" confirmation dialog in front of the toggle (enabling only — switching it back off is never gated). While active, front-end visitors get a self-contained holding page styled to the SA Motorlease brand (navy/orange, site logo) with an HTTP 503 status,
Retry-Afterandnoindexheaders. The gate runs ontemplate_redirect, so wp-admin, wp-login.php, WP-Cron, admin-ajax and the REST API are untouched — the import crons keep running and anyone withmanage_optionsstill browses the real site, which makes it impossible to lock yourself out. Admins can preview the page without taking the site down via a link on the Settings page (?sa_maintenance_preview=1), and every admin screen carries a warning notice while maintenance mode is on.
2.6.12
- Fix: the 2.6.10 fetchpriority filter never ran in production. It was placed in
includes/vehicle-filter.php, which only loads when the (currently disabled) custom vehicle-filter module is enabled — so live listings pages still only had core's first-image-onlyfetchpriorityand Lighthouse kept flagging the LCP image. Thewp_get_attachment_image_attributesfilter (first 5woocommerce_thumbnailimages getfetchpriority="high"+loading="eager") now lives in the always-loadedincludes/webp-converter.php.
2.6.11
- Built-in background WebP conversion — the CompressX plugin can be retired. A batched, time-budgeted cron (every 5 min, newest attachments first) converts JPEG/PNG originals and all sub-sizes to
{file}.webpsiblings (Imagick preferred, GD fallback, quality 80, copies only kept when actually smaller). Originals and attachment URLs are never modified, so the importer's MD5-based image diff sync is untouched. At render time, image src/srcset URLs are swapped to the.webpsibling when it exists — distinct URLs, so Cloudflare caches each variant correctly (no Accept-header pitfalls). WebP siblings are cleaned up when WP deletes the underlying file, and stale copies are re-converted after thumbnail regeneration. New "WebP conversion" section on the Status page shows encoder, converted/pending counts and next cron tick. Config viaSA_WEBP_QUALITY,SA_WEBP_BATCH_SIZE,SA_WEBP_MAX_SECONDS,SA_WEBP_DISABLEconstants.
2.6.10
- Faster LCP on listing pages via
fetchpriority="high"on first-row vehicle images. PageSpeed Insights flagged the /listings/ LCP image for missingfetchpriority=high. WordPress core only marks the very first image in the theme's WooCommerce loop, but with a 5-column (2 on mobile) first row the LCP element is often a later card — and which vehicle sits where changes with stock, so the auto-tag kept missing it. The first 5 theme-loop product thumbnails now getfetchpriority="high"+loading="eager"via awp_get_attachment_image_attributesfilter, and the plugin's own card renderer does the same for the first 2 featured-strip cards and first 4 grid cards on initial (non-AJAX) render.
2.6.9
- Give the iPhone/Safari partial-save retry (2.6.7) time to actually work. That retry fired instantly with no delay, which on-device testing (post-2.6.8 cache fix confirmed deployed) showed still failed both attempts back-to-back — only a manually-delayed second tap succeeded. The retry now waits ~1s/1.5s before each of up to 2 attempts, giving the underlying connection time to actually recover instead of hitting it again immediately.
2.6.8
- Fixed stale cached JS surviving plugin updates.
lead-qualification.js,qualification-results.js,application-form.js, andvehicle-locations.jswere enqueued with hand-maintained version strings that hadn't changed in several releases, so a site-level cache/CDN kept serving byte-old JS from the same cache-keyed URL even after the plugin itself was updated to a newer version. These are now versioned from the file's own modification time, so every deploy automatically busts the cache for any file that actually changed — no more silently-stale JS in front of a CDN.
2.6.7
- Fixed remaining iPhone/Safari "Something went wrong" error on the application form. The 2.6.6 fix removed a duplicate-submission race, which revealed the underlying cause: the first
partial-saverequest genuinely fails on iPhone/Safari (the duplicate had been accidentally providing a working retry). The request now retries once automatically before showing an error.
2.6.6
- Fixed iPhone/Safari application form bugs. The "Next" button on page 1 had no in-flight guard, so a slow mobile connection plus no visual feedback could let a second tap fire a duplicate partial-save request; if the two raced, one could return an error while the other silently succeeded and advanced the form, showing a false "Something went wrong" error even though the application went through. The final Submit button on page 2 also showed its loading overlay before Gravity Forms validation ran and never cleared it on a validation failure, leaving it stuck over the page so a corrected resubmission's tap didn't register — it's now cleared on every form re-render.
2.6.5
- Mobile filter button is bigger and more noticeable. The floating "Filters" button is wider (min-width + more horizontal padding), has a bolder shadow, and gets a subtle pulsing glow so it's easier to spot once it appears past the featured carousel.
2.6.4
- Slimmer mobile filter button, hidden over the featured carousel. The floating "Filters" button on mobile is now more compact (less padding/font size) and stays hidden until the visitor scrolls past the featured-vehicles carousel at the top of the listings page, instead of floating over it immediately.
2.6.3
- Featured / other sliders de-dupe by brand by default. The
[sa_featured_vehicles]carousels now show at most one card per make (brand) — so a strip won't fill with three Citroens even when they're different model lines (C3, C3 Plus, …). The granularity is configurable per slider via a newdedupeattribute:dedupe="make"(default, one per brand),dedupe="make_model"(one per make+model — the 2.6.2 behaviour), ordedupe="none". Make/model still come from the cached index, so there's no extra query cost.
2.6.2
- Featured / other sliders no longer repeat the same vehicle. The
[sa_featured_vehicles]carousels show at most one card per make + model. It draws from the WooCommerce "Featured" products first, then tops up with the newest vehicles in scope, skipping duplicates (make/model taken from the cached index, so no extra queries). Vehicles with no make/model set are still shown. (Superseded by 2.6.3's brand-level default.)
2.6.1
- Region filters by location category, not the attribute. The Region dropdown now filters on the product-category location tree the importer assigns (province → area) instead of the
pa_regionproduct attribute. Choosing a parent province (e.g. Gauteng) now correctly returns vehicles in all of its child areas (Sandton, Pretoria, …), and the dropdown lists provinces with their areas indented beneath them. On the location archive pages the Region field keeps behaving as the province navigator. - Cache busts on plugin update. The index and browser dataset caches now key on the plugin's asset version as well as the data version, so a plugin update that changes their shape refreshes them on the next page load instead of waiting for the next stock change.
2.6.0
Makes the vehicle filter instant on any host by moving filtering into the browser.
- Client-side filtering. The whole published catalogue (each vehicle's price, sold status, mileage, year, facet slugs, category tree and pre-rendered card HTML) is shipped to the browser once with the page. Changing a filter, sort or page now happens entirely in JavaScript with no
admin-ajax.phpround-trip — so it's instant even on sites where WordPress takes a couple of seconds to bootstrap each request (which diagnostics showed was the real cause of the multi-second waits, not the filter's own query). The first page is still server-rendered for SEO and the no-JS fallback, and shareable filter URLs still work. - Dataset cache. That browser dataset is built once, stored compressed alongside the index, versioned the same way, and re-warmed after each import — so it always matches current stock without a per-request rebuild.
2.5.3
Adds timing instrumentation to pinpoint slow filter responses.
- Per-request timing in the AJAX response. The filter's AJAX reply now carries a
_tobject withboot_ms(WordPress + theme + all other plugins initialising before the handler runs) andwork_ms(this plugin's own query/render). Viewing it in the Network tab shows immediately whether slowness is the site's bootstrap or this filter. - Deeper index diagnostic. When
?sa_vf_diag=1reports the index isn't being reused, it now also reads the raw transient fromwp_optionsand reports whether the write is missing entirely or stored at a mismatched version — distinguishing a cache-persistence problem from something bumping the version every request.
2.5.2
Diagnoses and hardens the filter's index cache after reports of slow filtering on sites with a persistent object cache.
- Compressed index cache. The vehicle index is now stored gzip-compressed (and base64-encoded so it's safe in both
wp_optionsand object caches). A large catalogue previously produced an index bigger than the ~1 MB value limit some object caches (e.g. Memcached) enforce; the backend silently dropped it, so the index was rebuilt on every request — which is what made filtering take several seconds. Compression brings it well under that limit so it actually persists and is reused. - Timing diagnostic. Admins can visit any wp-admin URL with
?sa_vf_rebuild_index=1(rebuild) or?sa_vf_diag=1(diagnose). The diagnostic reports whether the cached index is being reused or rebuilt each request, the build time, the stored blob size, whether that blob survives a round-trip through the cache, and a per-request timing breakdown — so the source of any slowness is unambiguous.
2.5.1
- Monthly Payment is now a dropdown. The monthly-payment filter matches the other facets — a single select field ("Monthly Payment" → the bucket options) instead of a radio list. Impossible ranges grey out based on the other active filters, exactly like the Kilometers dropdown.
- Manual index rebuild. Admins can force the filter's vehicle index to rebuild by visiting any wp-admin URL with
?sa_vf_rebuild_index=1(it reports how many vehicles were indexed). The index still rebuilds automatically after any vehicle change and is warmed after each import, so this is only for a one-off refresh.
2.5.0
Custom vehicle filter: new Monthly Payment filter, an admin-only enable switch, and an instant-loading rebuild.
Filter UX:
- Monthly Payment filter. The dual price slider is replaced with a set of monthly-payment buckets (Under R6,000, R6,001–R7,999, R8,000–R9,999, R10,000–R12,999, R13,000–R15,999, R16,000+) chosen with radio buttons — clearer and faster to tap than dragging a slider.
- "Available Only" toggle. The "Hide sold vehicles" checkbox becomes a pre-checked Available Only toggle: sold vehicles are hidden by default, and unchecking it shows them.
- Floating mobile filter button. On phones the filter opener is now a floating action button pinned to the bottom of the screen, with a badge showing how many filters are active.
- Admin-only enable switch. The custom vehicle filter can be turned on from the Vehicle Listings settings section. That switch is only visible to — and only changeable by — the designated admin (jake@netage.co.za); the
SA_MOTORLEASE_ENABLE_VEHICLE_FILTERconstant still forces it on when defined.
Performance:
- In-memory vehicle index. The whole published catalogue (price, sold status, mileage, year, every facet slug and the category tree) is pre-computed once into a single cached array. Filtering, faceting ("which options are still possible") and sorting now run entirely in memory with no per-request database queries — previously each request ran a full unpaginated
WP_Queryper selected facet plus aget_post_meta/get_the_termsfor every matching vehicle, which is what made the filter slow. - Versioned cache that tracks the importer. The index carries a version stamp that bumps on any vehicle change — create/update/delete, term reassignment (make/model/sold/…), and the price/image meta the PaceApp importer rewrites — so filter results never go stale. After an import the reindex signal rebuilds and re-warms the cache immediately, so the first visitor afterwards doesn't pay a cold rebuild.
- Response cache. Each filter response (results HTML + available options) is cached per index-version, so paging and common filter combinations return with no rendering at all. It clears automatically whenever the index version changes.
2.4.3
Cache-busting for changed vehicle images. The client updates photos often, and reusing the same media URL let Cloudflare / browsers keep serving the previous picture even after the file was correctly replaced on the server.
- Content-addressed image filenames.
vp_save_bytes_as_attachment()now appends a short hash of the image bytes to the filename (e.g.2021-renault-kwid-front-feature-photo-a1b2c3d4e5.jpg). A changed image therefore always lands on a brand-new URL the CDN/browser has never cached, so it appears immediately with no cache purge required. Identical bytes still resolve to the same filename, so re-imports of an unchanged image never create duplicates. Existing attachments keep their current URLs; this app
This README is longer than the copy stored here. Read the rest on GitHub →