# AuctionForge Changelog

All notable changes to the AuctionForge plugin are documented in this file.

---

## [3.7.4.7] — 2026-08-09

### Fixed — the honeypot blocked a real bidder that browser autofill had filled in

A genuine registration was rejected because Chrome autofill matched the
honeypot's field name, `company_url`, to "organisation" and typed the visitor's
company into it. Every other signal said human — real name, real address,
genuine Chrome, 19 seconds on the form — and the attempt was still hard-blocked
at score 99, shown the decoy confirmation, and left waiting for an email that
was never sent. Silent, terminal, and invisible to the auction house.

Three changes:

1. **The honeypot is no longer decisive on its own.** It scores 5 against a
   threshold of 6, so a filled honeypot blocks as soon as anything else is off
   and is survivable when nothing else is. Measured:

   | Submission | Before | Now |
   | --- | --- | --- |
   | Human, autofill in honeypot | blocked (99) | **allowed (5)** |
   | Machine-made name + honeypot | blocked | blocked (16) |
   | Human name + honeypot + 500ms fill | blocked | blocked (9) |
   | Human name + honeypot + headless UA | blocked | blocked (10) |
   | Human name + honeypot + shuffled Gmail | blocked | blocked (14) |

   Every bot wave observed scores 11–29 on the name model alone, so none of
   them depended on the honeypot to be caught.

2. **Field renamed** `company_url` → `af_ref_code`, with `data-lpignore` and
   `data-1p-ignore`, so autofill has nothing to match. The old name is still
   accepted server-side for visitors on a cached page.

3. **Records can be deleted.** Lots → Honeypot now has a per-row Delete and a
   Delete selected bulk action, both POST-only behind `check_admin_referer` —
   a GET delete link would be followed by prefetchers. A false positive should
   not be stuck in the log for good, and the row holds the person's name,
   email, address and device fingerprint.

### Fixed — the registration confirmation covered its own recovery links

Success showed a pop-up reading "You're almost done / Please check your email",
word for word the heading of the panel behind it, while covering the part that
matters. The pop-up is gone; the panel is the single surface, and it names the
address the link went to.

Underneath, the three recovery links were **white text on the white modal** —
computed `rgb(255,255,255)` on `rgb(255,255,255)` — so *Resend it*, *Wrong
email?* and *Still nothing? Let us know* have been invisible since they shipped.
Exactly the actions a bidder needs when the verification email does not arrive.
Now readable, with a real "Continue browsing" button in place of the pop-up's
OK.

### Fixed — the confirmation was invisible to screen readers

`afHandleRegResponse()` closed the register modal and then wrote the success
message into it, leaving `display: block` with `aria-hidden="true"`. The
confirmation and every recovery link were hidden from assistive technology. The
register modal now stays open (only the login modal closes), the panel carries
`role="status"` / `aria-live="polite"`, and focus moves to it.

---

## [3.7.4.6] — 2026-08-09

Findings below came from a 74-agent audit of the registration form; every one
was independently re-verified against the source before being acted on.

### Fixed — keyboard and screen-reader users could not register at all

The personal-data consent checkbox was styled with `display: none` and a
pseudo-element label standing in for it. That removes the control from the tab
order *and* the accessibility tree, so it could not be ticked without a mouse —
and `submitRegister()` refuses to proceed until it is. Anyone registering by
keyboard or screen reader was locked out completely, on a consent control that
carries legal weight.

Replaced with a visually-hidden-but-focusable pattern plus a visible focus ring
on the styled stand-in. Fixed at source (`src/sass/_forms.scss`) and in both
built bundles. Proven by A/B in a real browser: with the old rule reinstated
focus fails, with the fix it succeeds.

### Fixed — combined mode could permanently brick the form

The country `<select>` is `required` but ships empty, populated by
`settings/getCountriesList`. That call had no error handler and `ajaxQuery`'s
`.fail` is a no-op, so a single failed request left a required control with
nothing selectable — the browser then blocked submission with no message and no
log. The country and US-state loaders now degrade: they drop `required` and say
the list is unavailable, so the bidder can still register and add the address
later.

### Fixed — a blocked human was left half-logged-in

`afHandleRegResponse()` assigned the decoy response to the global
`bidspiritUser`. A real registration always returns an `authToken`; the decoy
never does. Someone blocked in error ended up in a state where the bid form
stopped offering login and every recovery link failed against an anonymous
session. The decoy now shows the identical confirmation — the deception is the
point — without adopting an identity that was never issued.

### Fixed — two reachable TypeErrors in `checkErrorForm()`

`!!submitButton` is always true for a jQuery set, so `submitButton[0].disabled`
threw whenever the button was gone — which is every response arriving after the
form has been replaced. And the empty-response branch had no `return`, falling
straight into `responseData.response.errorCode` on a null. Both fixed;
`.prop()` is a no-op on an empty set.

### Fixed — no submit lock and no timeout

The Register button stayed live for the whole round trip, so an impatient bidder
could fire several registrations, exhaust the 5-per-hour IP limit and be shown
the rate-limit decoy — told they had registered when they had not. The button
now locks for the duration and the request has a 45s ceiling instead of hanging
indefinitely.

### Fixed — single-step address was being sent on the register call

The combined-mode billing and shipping fields, the consent checkbox and the
unnamed submit button were all forwarded verbatim to `users/register.api` in
both proxy modes. Sending the address there defeats the entire deferred design,
which exists because applying it advances the account past the email gate.
Exactly nine fields now reach BidSpirit: firstName, lastName, email, password,
phone, phoneCountryCode, allowInvitations, sessionId, postConfirmationLink.

### Fixed — a failed shipping update silently destroyed the address

The `updateShippingAddress` response was discarded and billing ran regardless.
Billing flips the account to PENDING, BidSpirit then locks edits, and `finish()`
cleared the stash — so a transient shipping failure lost the address for good.
The apply now stops on failure and keeps the stash, retrying on the next
authenticated load.

### Fixed — phone and US state were optional but mandatory downstream

Both are required for a complete BidSpirit profile and are listed by the
plugin's own `afComputeMissingProfile()`, yet neither was required on the form.
In single-step mode the automatic profile completion failed on a blank value and
retried forever. Phone is now `required` and `type="tel"`; US state is required
only when the country is US, toggled in JS because a hidden `required` control
makes the whole form unsubmittable.

### Changed — single-step registration is ON for this site

`combined_registration` enabled on blackhawktrading.net. The form now collects
billing and shipping address up front: 23 controls instead of 10, 256 countries
loading, phone required. **Default for other sites remains off** — this is the
first site to run it, and no bidder has completed the full single-step flow yet.

---

### Also in this release

### Fixed — the bot shield's form-timing signal had never fired

The fill-time tracker armed on `form[name="login-form"]` and
`form[name="register-form"]`. The plugin renders neither: the register form is
named `registerSection`. So `_afFormOpenTime` was never set, `afGetFormFillMs()`
always returned `0`, and the `form_timing` rule in the bot shield — worth up to
4 points, the difference between blocking and allowing a borderline attempt —
has been dead since it shipped. Now bound to `.bp-register-api` /
`.bp-login-api`, which are stable regardless of the form's name attribute.

This also corrects the note in 3.7.4.5: the zero seen during browser testing was
not an artefact of scripted input, it was this bug.

### Fixed — anyone could plant a shipping address on another bidder's account

The read and delete paths were locked down earlier in this release, but
`auctionforge_stash_reg_address` — the **write** path — still accepted any
email with only the public nonce. Between a bidder signing up and confirming
their email, an attacker could overwrite their stashed address; on confirmation
it is applied to their BidSpirit account automatically, redirecting where that
bidder's winnings are shipped. That is worse than the disclosure fixed above,
and it was missed on the first pass. The write path now requires the same
ownership proof, which the caller has because registration returned an auth
token moments earlier.

Verified against the live site: an anonymous POST with the real page nonce
returns `{"success":false,"data":"not permitted"}` and writes no row.

Because this proof is now required at stash time, **the combined-registration
flow must be re-tested end to end the first time a house enables it** — if the
token is not yet valid at that instant the address will not be stashed, and the
bidder falls back to completing their profile manually.

### Fixed — anyone could read, or delete, a registrant's stored address

`auctionforge_get_reg_address` and `auctionforge_clear_reg_address` are
`wp_ajax_nopriv` and were guarded only by the `auctionforge_pending_reg` nonce,
which is printed into every public page. Posting an arbitrary email returned
that person's billing and shipping address; posting it to the clear endpoint
deleted their stash. Both now require a BidSpirit auth token that the server
resolves back to an account (`users/initSession` with the token) and compares to
the requested email with `hash_equals`. Failures return exactly the same
response as "nothing stashed", so the endpoint cannot be used to test whether an
address exists for a given address.

This was latent — `combined_registration` has never been enabled on any of the
37 sites on this server, so no address has ever been stored. It is fixed here
because the same release makes that feature switchable from the admin, and
shipping a one-click switch that arms a data leak is not acceptable.

Verified by exploit attempt against the live site: with the real public nonce
lifted from the page, reading a seeded address returns `null` and the delete is
refused while reporting success.

### New — three settings that existed in code but had no way to switch them on

An audit of every key read through `SettingsHelper::getOption()` against the
keys registered in `Settings.php` found three with no admin control anywhere.
All are now on the settings screen:

| Setting | Where | Default | Was |
| --- | --- | --- | --- |
| `combined_registration` — "Collect address during registration (single step)" | Settings → Profile | off | shipped in 3.7.4.2, wp-cli only, never enabled on any site |
| `hot_lot_threshold` — "Hot Lot view threshold" | Settings → Features | 5 | the Popular badge toggle existed but its threshold was permanently stuck at 5 |
| `house_name` — "Auction house name on invoices" | Settings → Profile | site title | no field, so the fallback was always empty |

`house_name` is only a fallback — the name parsed out of BidSpirit's invoice HTML
wins when present — but with no field to set it, an invoice whose house block
failed to parse printed a blank heading and an empty `<strong>`. It now falls
back to the site title rather than an empty string.

### Note — nothing is missing from the registration form itself

Diffed the registration surface against the shipped 3.7.4.2 release:
`form-register.php` has no removed lines (the only change is the honeypot added
in 3.7.4.4), `profile-templates.php` and `approval-info.php` are byte-identical,
and every combined-registration JavaScript function (`afStashRegAddress`,
`afApplyStashedAddress`, `afRenderRegSuccess`, `afSuggestEmail`,
`afShowRegError`) is still present. The single-step fields were never lost —
they were only ever unreachable.

---

## [3.7.4.5] — 2026-08-09

### Changed — server-side registration is on by default and has a settings toggle

3.7.4.4 shipped `server_side_registration` with no UI and defaulting to `off`,
which made it effectively dead code — no house could enable it without wp-cli.
It is now a checkbox under **Settings → Profile** ("Send registrations from this
site, not the browser") and defaults to **on**, so a registration cannot reach
BidSpirit without first passing the bot checks.

The default lives in the calling code
(`SettingsHelper::getOption(Settings::SERVER_SIDE_REGISTRATION, 'on')`), so
sites that have never saved this field get it enabled. Once the settings page is
saved, `sanitize_bidspirit_callback()` writes an explicit `off` for an unticked
box, so switching it off sticks — verified both directions.

Verified in a real browser against the live form before release, not only over
curl: the rewritten `submitRegister`/`sendToProxy` path runs with no JavaScript
errors, a blocked attempt makes **no** call to `bidspirit.com`, the decoy
success dialog renders, and the server records the genuine device fingerprint
(GPU string, screen, cores, timezone, locale) with the password absent. An
end-to-end registration through the server-side path was confirmed separately
against the live BidSpirit tenant, returning a usable `authToken`.

Note `form_fill_ms` is `0` for programmatically filled forms because the timer
starts on a real focus event; the scorer treats `0` as "not reported" and
applies no penalty, so this only affects synthetic submissions.

> **Correction (see Unreleased).** That explanation was wrong. `form_fill_ms` is
> `0` for *every* submission, human or scripted, because the timer armed on form
> names the plugin never renders. The observation during testing was real; the
> reason given for it was not.

---

## [3.7.4.4] — 2026-08-09

### Fixed — the honeypot was never on the form

`afCheckHoneypot()`, the registration proxy and the bot shield have all looked
for a `company_url` field since 3.7.0, but no template ever rendered it, so the
check could not fire and never did. The field is now on the register form,
off-screen and out of the tab order, as a plain text input rather than
`type="hidden"` — form-filling bots routinely skip hidden inputs. It is stripped
from the payload before any BidSpirit call so the field name is not advertised
upstream.

### Fixed — client-side rejections were logged nowhere

The honeypot and name checks short-circuited in the browser, showed a fake
success and called `afRecordRejection()`, which posts to
`auctionforge_record_rejection` — an AJAX action that is not registered
anywhere. Those rejections silently vanished. Both checks already exist
server-side where they are logged, so the submission now always reaches the
proxy and the server decides. A client-side check cannot bind a bot anyway; its
only real effect here was to destroy the evidence.

### New — server-side registration (setting `server_side_registration`, default `off`)

When on, WordPress performs the `users/register` call itself and returns
BidSpirit's own response, so the account is created by a party that has actually
run the checks. When off, behaviour is unchanged: the browser makes the call and
the proxy only returns a verdict, which a client can ignore. There is
deliberately no fallback to a browser-side attempt when the server-side call
fails — that would restore the unchecked path the setting exists to remove.

BidSpirit requires `sessionId` on this endpoint; the browser already supplies it
and it is forwarded. WordPress-only fields (nonce, honeypot, fingerprint
telemetry) are stripped before the upstream call.

### New — Honeypot admin screen (was "Blocked Signups")

Lots → Honeypot. Each blocked attempt expands to the full submission: the
signals that fired, network details (IP, user agent, referer, accept-language),
device identifiers, the complete fingerprint (GPU, canvas, audio, screen,
cores, WebRTC, timezone, locale) and every form field that was typed. Adds
search by email/IP/name/fingerprint and a repeat-offender summary by fingerprint
and IP over 30 days.

**The password is never recorded** — not hashed, not truncated, not at all.
`form_data` and `fingerprint_data` are captured as JSON on the existing
rejection table, which gains `visitor_hash`, `form_fill_ms`, `form_data`,
`fingerprint_data`, `referer` and `accept_language`; existing rows migrate in
place. Note this stores submitted contact details for blocked attempts, so it is
worth agreeing a retention period.

---

## [3.7.4.3] — 2026-08-08

### Changed
- The verification-email report dialog ("Let us know") now prompts the registrant to check their junk/spam folder first — *"Please be sure to check your junk/spam folders for the verification email."* — and the confirm button reads **"No email received"** (was "Notify the auction house").

---

## [3.7.4.2] — 2026-08-08

### New (optional, flag-gated `off`) — combined registration

New `combined_registration` setting. When on, the register form collects the
billing + shipping address up front. Because completing the address advances the
account past the email-verification gate, the address is **stashed server-side**
at sign-up (table `<prefix>auctionforge_pending_registrations`, AJAX
`auctionforge_stash/get/clear_reg_address`, nonce `auctionforge_pending_reg`,
7-day expiry) and **applied automatically after the email is verified**
(`afApplyStashedAddress` on the first authenticated `INCOMPLETE_PROFILE` load,
then one guarded `location.reload()` for a clean profile).

- **⚠ Ordering: shipping (`updateShippingAddress`) is applied BEFORE billing
  (`updateDetails`).** `updateDetails` completes the profile and flips the account
  to PENDING, and BidSpirit rejects a pending account's edits — so a shipping
  update sent after billing is silently dropped (billing saves, shipping blank).
- Shipping state uses the `state` param (state code) — NOT `stateCode`, the way
  billing does. Recipient name defaults to the bidder's first + last name.
- Shipping country/state are seeded from billing on uncheck (autofill on the
  async-populated `<select>`s is unreliable), and the stash falls back to the
  billing country/state so a separate shipping is never stashed half-empty.

### Changed — onboarding & per-auction copy

- "Get ready to bid" step checklist after registration; final step is
  "Registration complete".
- Per-auction ("gate 2") reworded to "Participate in this auction" / "validate
  your card on file", de-alarmed (`.af-gate2` card, not a red error), no
  timeframe promise before the request; removed the "your account's approved"
  celebration pop-up. All to avoid the "approved" vs "approval" collision.

### Fixed

- Logout no longer flashes a "no session" alert (`checkError` swallows it for 8s
  post-logout via `sessionStorage['af_logout_ts']`).
- Registration `.errors-group` moved to just above the Register button.
- Friendlier "pending approval" wording on the profile Update gate.

---

## [3.7.4.0] — 2026-08-08

### New — registration bot shield replaces the old name validator

`auctionforge_validate_name_server()` only examined names of 8 or more
characters, so every name in the August 2026 wave ("Awbd Fplafk", "Eczh
Tunnbkdo", "Cslh Flpohlrj") passed untouched. It is replaced by a scored
assessment in `inc/bot-shield.php` combining five signals: a character-trigram
name model, vowel-free words, casing anomalies, email shape, User-Agent and
form-fill timing. No single weak signal blocks; the threshold is reached by one
strong signal or several weak ones.

The name model (`inc/data/name-model.php`, 4 KB) is a bitmap of every
3-character sequence occurring in a corpus of ~18,700 verified fleet bidders
plus an English dictionary. A name is suspicious in proportion to how many of
its sequences appear nowhere in that corpus. Hand-written consonant-cluster and
bigram lists were tried first and abandoned — they rejected Little, Armstrong,
Walsh, Hodgkinson and Szpakowski, because English compound surnames generate
far more legitimate clusters than can be enumerated by hand. Rebuild the model
with `bin/build-name-model.php`; extend `bin/name-supplement.txt` rather than
lowering thresholds when a genuine bidder is blocked.

Measured against fleet ground truth from wpdeploy, with the evaluation set held
out of the training corpus:

| Set | Result |
| --- | --- |
| 598 people who have bid, won or spent (proven human) | 0 blocked |
| 28 accounts from the BlackHawk August wave | 26 blocked (93%) |
| 40 accounts an admin flagged "BOT" | 37 blocked (93%) |
| 66 international control names (Welsh, Polish, Czech, Nordic, Indic, Igbo…) | 0 blocked |
| all 15,450 named people fleet-wide | 0.26% flagged |

Gmail dot-shuffling is scored explicitly: Gmail ignores dots, so
`r.i.n.v.il.lil.a@gmail.com` and `rinvillila@gmail.com` are one inbox. The
canonical address is stored alongside the raw one so repeat attempts on a
single mailbox are visible.

Blocked visitors receive a response shaped exactly like a successful
registration, so an operator cannot probe the thresholds.

### Fixed — the rejection log never existed

`auctionforge_record_proxy_rejection()` wrote to
`{prefix}_auctionforge_rejected_registrations`, but nothing ever created that
table, so it returned early every time and every blocked registration since the
feature shipped was discarded. The table is now created on `admin_init` (and on
first write, for sites that block before an admin ever logs in) and carries the
score, the canonical email and the signals that fired.

### Fixed — registration no longer falls back to an unchecked path

On any proxy error, and when the proxy configuration was missing, `app.js`
called BidSpirit directly — so any request that broke the proxy skipped every
check. Both fallbacks are removed. Because the proxy is now fail-closed and
LiteSpeed serves public pages from a 7-day cache while a nonce lives 24 hours, a
visitor on a cached page would otherwise meet a dead form; a new
`auctionforge_reg_nonce` endpoint mints a fresh nonce and the client retries
once on a 403 before showing a retry message.

### Fixed — client IP was taken from spoofable headers

`auctionforge_get_client_ip()` honoured `CF-Connecting-IP`, `X-Forwarded-For`
and `X-Real-IP` unconditionally. These sites are not behind a proxy, so those
headers were pure attacker input and the 5-per-hour registration rate limit was
bypassed by rotating one. They are now honoured only when the connection
actually arrives from a published Cloudflare range or a reverse proxy on this
host, so the limit holds whether or not the orange cloud is switched on later.

### New — Blocked Signups admin screen

Lots → Blocked Signups lists what was stopped, with counts for the last 24
hours and 7 days and a filter per reason.

---

## [3.7.3.4] — 2026-08-08

### New — profile completion: missing-field checklist, red halos, and a save gate

After confirming their email, a new registrant lands on their profile with the
required address fields still empty. The account form now shows a "please
complete the following required fields" panel at the top and gives every empty
required control a red halo, and **Update refuses to save** while anything
required is missing (the check runs before `users/updateDetails` is called). The
same missing-field list is appended to the site-wide `INCOMPLETE_PROFILE` notice
on the home, catalogue and lot pages, beside the existing Profile link.

Required fields mirror `buildFields()`: First name, Last name, Email, Phone,
Country, Address, City and Zip Code. **State is required only when the user's own
`countryCode` is `US`** — this is driven by the bidder's country selection, not
the site's US/UK catalogue dialect. All logic is centralised in
`afComputeMissingProfile()` in `assets/js/app.js` and shared by the profile
render, the save gate and the notice.

### New — "verification email not received" report button

While an account's email is unconfirmed, the site-wide notice and the
`profile-page-empty-template` now carry a third action next to *Resend email* and
*Change it* — **"Let us know"** (`#reportVerificationEmail`). It posts to a new
nonce-protected AJAX endpoint (`auctionforge_report_verification_email`) that
emails the registrant's name/email/phone to the house's support contact, routed
by `Dialect::get()`: `US` → stephen@bidspirit.com, otherwise → Alec@bidspirit.com.
Rate-limited to three reports per IP per hour. No schema or config-version change.

---

## [3.7.3.2] — 2026-08-06

### Fixed — lot image navigation arrows invisible on dark photos

The previous/next arrows on the single lot page derived their colour from the
theme's `--main-bg-color`, which most themes never set, so they fell back to
black and disappeared over black or dark backgrounds (for example artefacts
shot on black). They are now a white chevron on a translucent dark disc with a
soft shadow, so they stay visible on any image colour. Applies to both the
Slider and Grid gallery layouts.

### Changed — clearer label for the lot gallery layout setting

The Appearance setting previously labelled "Template for lot slider on" is now
"Lot image gallery layout", with a description explaining the Slider (main image
with a thumbnail carousel) and Grid (main image with a thumbnail grid) options.

---

## [3.7.3.1] — 2026-08-04

### Fixed — page 2 was unreachable whenever a category or search filter was active

Selecting a sidebar category with more than one page of lots, then clicking to
the next page, silently returned page 1. The same applied to searching within an
auction. Only the unfiltered and sold-status views paginated correctly.

It was a redirect loop, not a pagination fault. `bp_session_starter()`
(`inc/controllers/BidspiritLots.php:291`) seeds the filter-tracking session keys
to `null` and immediately calls `session_write_close()`. `search_filter()` then
calls `maybe_start_session()` to record the active filter, but that method
returns early on `headers_sent() || session_id()` — and **`session_id()` still
returns the id after a close**, so the session was never reopened. Every write
landed in a detached `$_SESSION` array and the closing `session_write_close()`
was a no-op. The session file on disk held only the seeded nulls:

```
bp_param_category_by|N;bp_param_s|N;paged|i:1;bp_param_per_page|N;
```

So the recorded filter was permanently `null`, never matched the one in the URL,
the "filter changed" branch fired on *every* request, and
`maybe_redirect_to_first_page()` issued a 302 back to page 1 — forever.

The session-based reset is removed rather than repaired. Repairing it would mean
reopening the session on every `pre_get_posts`, reintroducing the lock
contention `session_write_close()` exists to avoid — and the same mechanism was
already abandoned for per-page (see the note at `:844`) because it was
unreliable behind full-page caching. Resetting to page 1 when a filter *changes*
is still correct, so it now happens on the client, which knows a change occurred:

* The category sidebar strips `/page/N/` from the path, not just `?paged`.
  Pagination lives in the path, so clearing the query arg alone left the visitor
  on a page the new, shorter result set might not have.
* The search/sort form had `action=""`, submitting back to the current URL — so
  searching from page 4 requested page 4 of brand-new results, usually empty. It
  now targets page 1.

`PARAM_CATEGORY_BY` and `PARAM_S` are no longer seeded; nothing read them.
`maybe_redirect_to_first_page()` is retained so any session still carrying the
flag mid-deploy drains harmlessly.

Verified on a 48-lot category at 10 per page: 5 pages, all 48 lots, no repeats,
matching the sidebar count exactly; and no change in status or content across
category, phrase, sold, sort, multi-category and per-page combinations.

---

## [3.7.3.0] — 2026-08-04

### Fixed — site statistics were destroying history that sites were told to keep

`AF_Data_Purge` ran a hardcoded 30-day `DELETE` against the four af_stats_*
event streams. It read no setting, so it silently overrode
`af_stats_retention_days` — which is **365 on most sites**. Measured on
rosan-reeves before the fix: **126,229 pageview rows destroyed against 98,073
surviving**, with the oldest surviving row landing exactly 30 days after the
recorded `auctionforge_last_purge_at`. The same alignment held on four other
sites. Those tables are no longer purged at all.

The 30-day ceiling also produced a **guaranteed daily false CRITICAL**. Its
"unsynced at ceiling" alert assumed `synced_at` was being stamped, but no sync
class ever wrote it for these four tables, so it was 100% NULL — every purge
reported "sync has been broken for at least 30 days" to Telegram and wpdeploy.
Only tables with a real sync writer reach that branch now.

### Added — archiving, so all history is retained without slowing the dashboard

Rows older than a configurable threshold (default 30 days, minimum 7) are now
**moved** into parallel `*_archive` tables by a nightly job, never deleted. The
dashboard reads the hot table alone for any range that does not reach the
archive — which is the default view — so its SQL is unchanged and its speed is
unchanged. Ranges that do reach further back transparently union the archive in,
and the UI says so, since those queries legitimately take longer.

* Archive tables are created with `CREATE TABLE ... LIKE`, so there is no second
  schema to keep in sync by hand, and `INSERT..SELECT` between them cannot drift.
  Everything but the primary key and the date index is then dropped — archive
  reads scan wide ranges anyway, so the other indexes were pure storage cost.
  `AF_Stats_Archive::sync_columns()` adds any column the hot table later gains.
* The move is transactional, batched (2,000 rows), time-boxed (20 s) and
  **idempotent** — `INSERT IGNORE` on the copied primary key means a tick that
  dies between insert and delete re-copies harmlessly instead of losing or
  duplicating rows. Verified: 6,710 rows moved, total held at 49,383 across two
  consecutive runs, zero overlap between the tables.
* Read routing keys off **what the archive actually contains**
  (`MAX(date_col)`), not off the configured threshold. Keying off the threshold
  is wrong the moment it is raised: rows archived under the old, shorter setting
  stay archived, and a boundary-based test would call the range "hot-only" and
  silently undercount. Reproduced before the fix — a 30-day query after raising
  the threshold from 7 to 30 returned 42,679 rows instead of 49,389, short by
  6,710.
* `af_stats_visitors` is deliberately never archived: it is a dimension table
  the dashboard joins for visitors of any age.
* Deliberate deletion still exists, but only where explicitly configured.
  `af_stats_retention_days` (default 0 = keep forever) now applies to the
  archive too, and deletes in bounded batches rather than one unbounded
  statement.

`DB_VERSION` 3 → 4; the existing `maybe_create_tables()` hook performs the
migration, and it is additive only.

### Also fixed, found by pre-release review of the above

* **The archive threshold defaults to 90 days, not 30.** At 30 it collided with
  the dashboard's own default "Last 30 days" preset: `get_date_range()` pins
  that view to *midnight* of day 30 while the cron cuts at its own time-of-day,
  so the flagship screen took the union path on every single load — measured
  0.43s → 8.0s. The threshold now clears the longest common preset.
* **Reads no longer depend on the archiving checkbox.** Unticking "Archive Old
  Data" stops the nightly move, as intended, but previously also dropped every
  already-archived row from every report — 13.6% of the data here — while the
  status panel on the same screen still counted them as retained. Routing now
  follows archive contents; the flag governs writing only.
* **Raising the threshold pulls rows back.** It was a one-way door: rows
  archived under a shorter setting stayed archived, so the larger live window
  the operator asked for never materialised. `restore_recent()` mirrors the move.
* **CSV exports and the Searches tab were still reading the hot tables**, so the
  same date picker returned 49,447 on screen and 42,737 in the file. Both
  exports, `handle_searches`, and five `LotAnalytics` readers now route through
  the same helper. Verified: CSV total now equals screen total exactly.
* **Comparison-period queries get their own expression.** Both periods shared one
  spanning `$prev_from`, so every current-period query materialised roughly
  double the rows it needed.
* **The table expression is `%`-escaped for `$wpdb->prepare()`.** Callers embed
  it in their own prepared statements, where a stray `%` would be read as a
  placeholder and corrupt the argument mapping.
* **Per-request memo is cleared after a run.** `archive_max()` caching meant a
  request that fired the archive cron — WP-Cron runs inside ordinary page
  requests — could then render a dashboard routed on pre-run facts. Observed:
  archive drained to zero rows, yet the query still emitted a UNION against it.
* **Three settings checkboxes could never be switched off** (`af_stats_enabled`,
  `af_stats_anonymize_ip`, `af_stats_track_logged_in`). They tested `isset()`,
  but the form always posts the key, so saving anything silently re-enabled all
  three. Pre-existing, but on the form this release modifies.

---

## [3.7.2.9] — 2026-08-04

### Fixed — catalog gallery clipped the bottom third off every photo

The real cause of the cropping reported against 3.7.2.4 and 3.7.2.5, found by
measuring the rendered boxes rather than the image: `.bp-lot` is a **column
flexbox**, and the `.bp-lot-gallery` wrapper sits where
`<a class="bp-lot__wrp-image">` used to be a direct flex item. That `<a>` is
`height: 0` with `padding-top: 78%`, so `flex-shrink` has no content height to
take and its box always survives at full size. The wrapper has real auto height,
so the default `flex-shrink: 1` squashed it to 280×133 around a 280×218 child,
and `overflow: hidden` on the wrapper then clipped everything below 133px. The
photo itself was always painted whole and `contain` was always applied — the
clipping happened one level up. Fixed with `flex: none` on the wrapper, and the
`overflow` dropped so the `<a>` remains the single clipping boundary.

Two related mistakes are also reverted. Slides no longer override `position` or
`inset`: `.bp-lot__image` centres itself with `top: 50%` + `translateY(-50%)`
against the `<a>`'s padding box, and `inset: 0` shifted every non-first slide up
by half its height. The `is-first` special case added in 3.7.2.5 is gone with
it — nothing needs to sit in normal flow, because the box height comes from the
`<a>`'s `padding-top`, not from any child.

Supersedes unreleased 3.7.2.6–3.7.2.8, which chased the same symptom through the
image and the aspect ratio instead of the flex item.

---

## [3.7.2.5] — 2026-08-04

### Fixed — catalog gallery cropped lot photos

The gallery slides introduced in 3.7.2.4 set `background-size: cover`, which
filled the card by cropping the image. That hides exactly the detail a buyer is
paging through the photos to inspect — hallmarks, signatures, damage. Slides now
use `contain`, so the whole photo is always visible.

Also, the first slide is kept in normal document flow rather than absolutely
positioned. Card layouts that size `.bp-lot__image` with a percentage
`padding-bottom` derive their height from that element, and absolutely
positioning every slide could collapse the card.

---

## [3.7.2.4] — 2026-08-04

### Added — browse a lot's photos from the catalog

New Features setting, **"Browse lot images in the catalog"** (off by default).
When enabled, catalog cards for lots with more than one photo gain arrows, dots
and a photo counter, so buyers can page through the images without opening the
lot. Roughly 85% of a typical catalog has multiple photos, and previously all but
the first were invisible until a buyer clicked into the lot.

* Clicking the photo still opens the lot exactly as before — only the arrows and
  dots swallow the click. Keyboard (left/right arrows) and touch swipe supported.
* Images after the first are deferred and fetched on first reveal, so a page of
  lots does not pull every photo up front. Measured cost of the added lookup:
  ~0.03 ms per lot (about 2 ms for a 60-lot page).
* **Content-gated lots are excluded.** Lots flagged as adult content or withheld
  under Section 86a StGB keep their warning tile — making those browsable would
  defeat the gate.
* Lots with a single image, and every site with the setting off, render markup
  byte-identical to 3.7.2.3.
* Controls live in `templates/parts/lot-card-gallery-controls.php` and are
  theme-overridable like the plugin's other templates. Assets
  (`assets/css/lot-card-gallery.css`, `assets/js/lot-card-gallery.js`) are only
  enqueued when the setting is on, are dependency-free (no jQuery) and are
  event-delegated, so catalog paging and filtering need no re-initialisation.

---

## [3.7.2.3] — 2026-08-04

### Fixed — the Engagement Funnel's "Bidders" stage was permanently zero

The Statistics funnel counted bidders from `wp_af_bid_events`, but the only
writer — `AF_Bids_Sync::queue()` — had **no callers anywhere in the plugin**.
The table had never received a row on any site (AUTO_INCREMENT still 1), so the
stage reported "Bidders 0" beneath "N people viewed 5+ lots", implying nobody
who browsed ever bid. On a site with thousands of sold lots that is simply false.

Meanwhile Bidspirit *had* been sending bidder identity all along, into
`wp_bp_firebase_logs` where nothing read it: `LEADING_BID_SURPASSED` carries a
singular `user`, `ITEM_LIVE_SOON` a plural `users` array. Two things hid this —
the payloads are double-JSON-encoded, and `logSave()` filed the email array into
the `firebase_id` column because it only ever looked for the singular key.

* **Live capture.** `item_live_soon()` and `leading_bid_surpassed()` now record
  bidder emails via `AF_Bids_Sync::queue()`. Capture happens *before* the
  push-notification gate, so it works on sites with no mobile-app users — which
  is precisely where the data was being dropped.
* **Provenance.** New `source` column (`bid` | `alert`). Outbid notices are
  confirmed bids; lot alerts are a strong proxy but not proof. The two are never
  conflated, and the wpdeploy bids-wins stream now carries the distinction.
  Added in place on upgrade, option-guarded so it costs nothing after the first
  request.
* **Backfill repaired.** "Backfill bids from legacy tracking" read
  `wp_auctionforge_user_tracking WHERE last_event_type='bid'` — a source nothing
  ever writes, so it could only ever report zero. It now replays bidder identity
  from stored Bidspirit webhooks (batched, keyset-paginated, SQL-prefiltered, so
  a long webhook history cannot exhaust memory or time out) and de-duplicates on
  (email, lot), making it safe to re-run.
* **Retention no longer destroys it.** `wp_af_bid_events` was in the purge map,
  so a backfill of historical sales was deleted by the next daily cron, *and* the
  deleted rows were counted as "unsynced at ceiling" — firing a CRITICAL
  "sync has been broken for 30 days" alert caused purely by pressing the button.
  The table is now exempt: it is low-volume bidder identity, not an event stream.
* **Timezone.** `occurred_at` is written site-local to match
  `af_stats_pageviews.created_at`, which the funnel compares it against; it was
  UTC, putting the stage an offset out. The backfill normalises the MySQL
  server-clock `created` value onto the same clock.

### Changed — the funnel and the weekly report no longer assert false zeros

* An unmeasured stage renders "not tracked" with a hatched bar instead of `0`,
  and the state is judged over the **same window** as the count, so a quiet
  period cannot reintroduce the false zero.
* The stage is labelled from the data: "Bidders" when every event is a confirmed
  bid, "Bidders & lot-watchers" when alert-derived people are included, with a
  tooltip stating it is an upper bound.
* The stage is marked non-nested — it counts people (by email), not visitors (by
  browser), and includes app/live-room bidders with no tracked pageview — so it
  no longer prints a "% of visitors" that divides two different populations.
  Percentages and bar widths are clamped.
* The weekly emailed report omits the bidder row entirely when it was not
  measured, rather than emailing a client "Left a bid: 0".

### Added — full country names in Statistics

Top Countries, the Countries tab (table *and* chart labels) and the real-time
activity feed now show "United Kingdom" rather than "GB". Names come from
ext-intl/ICU where available, falling back to a generated map
(`bin/generate-country-names.php`) on hosts built without it, and to the raw
code for anything unrecognised — so a cell can never render blank.

---

## [3.7.2.2] — 2026-07-31

### Fixed — the bid watcher reintroduced "Start price" on shop lots

`afPatchWatchedLot()` rewrites a row's price block on every 10s tick. Its
final fallback — no sale, no leading bid — is `info.startPrice` with the
"Start price" label, which is exactly the state a shop lot is always in. So
the 3.7.2.1 wishlist fix held only while auto-refresh was off; switching it on
overwrote "Purchase Price: $15" with "Start price: $0" on the next tick.

The watcher now checks `bs_data.shop_auction_ids` against the row's
`data-auction-number` and renders `buyoutPrice` under a "Purchase Price"
label instead. When there is no usable price it returns without touching the
row, so it can never replace a correct rendered price with a wrong one.

---

## [3.7.2.1] — 2026-07-31

### Fixed — shop lots printed "Start price: $0"

A shop lot has no start price: BidSpirit reports `startPrice` 0 because the
item is bought at a fixed price rather than bid on. The JS lot card still
rendered the whole start/estimated-price message block for it — five separate
branches, all reachable — and the profile wishlist fell through to its
"Start price" default. Sold shop items showed it too, alongside "Sold for".

The card now skips that block entirely for shop lots (the sold badge is
unaffected). The wishlist shows the purchase price instead, identified via a
new `bs_data.shop_auction_ids` list — profile views render lots from any
auction and have only `item.auctionId` to go on. `Shop::getShopAuctionIds()`
covers both the legacy `shop_auction` terms and any BidSpirit Shop term.

### Changed — "Auto-refresh bids" is a toggle switch

The control was a bare checkbox whose label inherited the theme's link
styling, so it read as a hyperlink. It is now a slider: the checkbox remains
the real input (visually hidden, still focusable and keyboard-operable) with
the slider as its skin, so every existing `.af-bidwatch-checkbox` binding is
untouched. Link styling is reset explicitly so a child theme cannot restore it.

---

## [3.7.2.0] — 2026-07-31

### Fixed — "catalog not published" notice fired on buy-now lots

`displayBidFormData()` showed the `auction_preview_notice` ("wait for the
catalog to be published, then you can place absentee bids") whenever
`startPrice === 0` and the house has `enablePrebidsUnderStartPrice` false.
**A shop lot always has `startPrice` 0** — it is bought at a fixed price, never
bid on — so the notice fired on every shop lot, and since completing a purchase
calls `loadBid()` again it reappeared over the purchase-confirmation modal.
The `bs_read_notice` cookie hides it for 7 days after a dismissal, which made it
look intermittent. The condition now also requires the lot not to be a shop lot,
read from `#preSale-section[data-is-shop]` — set for both an "Auctions as shop"
auction and a BidSpirit Shop. On pages without that element the attribute is
undefined and the term evaluates true, so nothing else changes.

### New — BidSpirit Shop

BidSpirit's 2026-07-30 API revision added a `shop/` surface: a fixed-price
storefront that lives beside the house's auctions. Its items report
`auctionId: 0` and carry a `shopPrice`; they are bought outright through
`shop/purchaseShopItem` rather than bid on.

New `Settings -> Synchronization -> Enable BidSpirit Shop` (**off by default**,
so the feature is inert until a site opts in) plus an optional shop name.

**Data model.** The shop is represented locally as a single synthetic
`auction_number` term carrying `bs_shop_id` term meta, so shop items become
ordinary `lot` posts and the whole existing stack — lot templates, CDN images,
categories, search, pagination, cache purge, SEO — applies with no changes.
`shopPrice` maps to `buyoutPrice` and `soldPrice` to `soldBid`, which is what
lets the existing buy-now presentation render a shop unmodified. Shop terms are
filtered out of the auction and archive listings via the existing
`auctionforge_query_auction_params[_sync]` filters.

**Sync.** `inc/import/ShopList.php` pages `shop/getShopItems` and stages through
the normal `InsertLotsBS` drain. It runs on the hourly `auctionforge_sync_safety`
hook and on Sync Now, because BidSpirit's webhooks are auction-scoped and never
fire for shop stock. Items withdrawn from the shop are moved to draft.

**Front end.** Auction-scoped API calls are suppressed for the shop's synthetic
auction id (they would otherwise return `BAD_PARAM_VALUE` and surface as an error
modal), the buy action routes to `shop/purchaseShopItem`, and a sold item shows
its purchase price in place of the Buy button.

**Sold items.** A sold shop item keeps its page and its place in the
listing but renders "Sold for" with the price it made, replacing the Buy
button on the item page and the "Purchase Price" label in the listing —
without this it read as still being on offer. `bs_shop_hide_sold`
(**off by default**) drops sold items from the listing instead, via the same
`bp_query` field-filter mechanism `hide_unsold_items` uses. Both are scoped to
BidSpirit Shop terms, so the legacy shop presentation is byte-for-byte
unchanged.

**Unchanged:** the older `shop_auction` ("Auctions as shop") setting, which
presents a real auction as a storefront and sells via `items/buyout`. The two are
independent and can both be active on one site.

No schema or config-version change.

---

## [3.7.1.24] — 2026-07-30

### Fixed — content edits never purged the page cache

`CachePurge::registerAuctionHooks()` registered auction-scoped hooks only:

    save_post_manual_auction
    created_/edited_/delete_<auction_number>
    added_/updated_term_meta   (auction_state transitions)

There was no generic `save_post` hook, so saving an ordinary page, post or
Elementor template purged nothing. On a site fronted by an nginx FastCGI cache
(`fastcgi_cache_valid ... 1d`) the old page kept being served to logged-out
visitors for up to 24 hours, plus whatever a CDN in front of it held —
Cloudflare "Cache Everything" rules typically override the origin's cache
headers entirely, so the edge served it too. Enumerating the callbacks on
`save_post` for an affected site returned only WordPress core's calendar cache
and unrelated SEO/accessibility plugins: nothing that touched a page cache.

The bug presented as INTERMITTENT rather than broken because auction syncs fire
`purgeEverything()` regularly — an edit became visible whenever the next sync
happened to run, and appeared stuck when one did not.

Note that LiteSpeed Cache is not a substitute here. On nginx its page cache
never engages (no `advanced-cache.php` drop-in) even with caching enabled in its
settings, so its own `save_post` purge clears nothing that serves the page.

**Fixed** by registering the content-change hooks alongside the auction ones:

* `save_post` — for every public post type plus `elementor_library` (Elementor
  headers, footers, popups and theme-builder parts are not a public post type
  but render into every page). Filterable via
  `auctionforge_cache_purge_post_types`.
* `elementor/document/after_save` and `elementor/core/files/clear_cache` — Site
  Settings and global kit changes do not necessarily run any single post's
  `save_post`.
* `wp_update_nav_menu` and `customize_save_after` — menus and Customizer
  settings render into every cached page.
* `before_delete_post` / `wp_trash_post` / `untrashed_post` now cover content
  post types too, not just `manual_auction`.

Revisions, autosaves and `auto-draft` posts are ignored. The content hooks are
suppressed while `$_SERVER['AUCTIONFORGE_IMPORT_LOT']` is set, so an update-only
sync keeps its targeted per-lot purge instead of escalating to a zone purge.

### Changed — purges are always queued and coalesced

`purgeAuctionListings()` previously purged immediately outside a sync and queued
during one. It now always routes through the new private `queueFullPurge()`,
which sets the pending flag and registers the `shutdown` flush. One Elementor
save fires both `save_post` and `elementor/document/after_save`, and a bulk edit
fires `save_post` many times; without coalescing each would have been a separate
CDN zone purge. `flush()` now returns early when nothing is pending.

### Changed — purges no longer block the response

`flush()` calls `fastcgi_finish_request()` before purging. Under PHP-FPM the
`shutdown` action still runs on the open connection, and a purge makes a
Cloudflare API call (12s timeout) and then walks the FastCGI cache directory —
so the editor's Update button, and every wp-admin save, previously waited on it.

No schema or config-version change.

---

## [3.7.1.23] — 2026-07-28

### Fixed — lot YouTube videos failed on every iOS device ("Error 153")

`templates/parts/gallery-lot.php`, `gallery-lot-grid.php` and `lot-templates.php`
emitted a bare gallery iframe:

    <iframe src="https://www.youtube.com/embed/<ID>?showinfo=0" frameborder="0" allowfullscreen>

Lazy-load plugins rewrite that server-side. WP Smush (`format.iframe = true`,
`native = false`) turns it into `src="about:blank"` + `data-src="…"` +
`class="lazyload"` + `data-load-mode="0"`, and the bundled lazysizes then loads it
with:

    0 == mode ? e.contentWindow.location.replace(url) : e.src = url

Navigating from *inside* the `about:blank` document is the problem. Blink (desktop
Chrome, Android) inherits the parent page as the referrer, so it works. WebKit sends
no `Referer` at all — and every browser on iOS is WebKit, Chrome and Firefox
included — so YouTube answers with
`PLAYABILITY_ERROR_CODE_EMBEDDER_IDENTITY_MISSING_REFERRER` and renders the grey
"Video player configuration error" card with a "Watch video on YouTube" link.

This was never a regression: lot videos have never played on an iPhone or iPad. The
videos themselves are public and embeddable, and the auction's state is irrelevant.

The iframe now carries:

    class="skip-lazy" loading="lazy"
    referrerpolicy="strict-origin-when-cross-origin"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"

`skip-lazy` is substring-matched by Smush's own exclusion list
(`core/lazy-load/class-lazy-load-transform.php` → `get_default_excluded_keywords()`)
and is honoured by WP Rocket and LiteSpeed Cache too, so the fix is lazy-loader
agnostic rather than Smush-specific. Smush checks `is_element_excluded()` *before*
both its embed-video and native-lazy branches, so `skip-lazy` wins regardless of the
site's Smush settings. Native `loading="lazy"` keeps the deferred-load benefit.

Affects any site running a lazy-loader with iframe support and at least one lot
video. No schema or config-version change.

## [3.7.1.22] — 2026-07-25

### Fixed — `Lot::countLots()` loaded the entire lot catalogue to produce one integer

`inc/model/Lot.php` built its query with `'numberposts' => -1` and then returned
`count(get_posts($args))`. That materialises every matching lot as a full `WP_Post`
object — priming the post-meta and term caches for all of them — purely to arrive at
a row count.

The caller makes it worse: `ajax/LotListAjax.php` returns only **5** lots per request
and calls `countLots()` alongside them just to render a total. So a "give me 5 lots"
AJAX request could load the whole catalogue.

It has been dormant rather than harmless. The query also filters on the `featured`
meta flag (`Lot::VIEW_HOMEPAGE_BS`), which currently matches only 0–11 lots on every
site measured, so the cost never materialised. It would have appeared the moment
anyone bulk-flagged lots as featured on a site holding thousands of them. Measured on
two live sites with the flag filter removed to expose the worst case:

| lots | before | after |
|---|---|---|
| 18,718 | 113.5 MB, 0.78 s | 0.0 MB, 0.01 s |
| 12,850 | 76.5 MB, 0.60 s | 0.0 MB, 0.01 s |

Now uses `WP_Query` with `posts_per_page => 1`, `fields => 'ids'` and
`update_post_meta_cache`/`update_post_term_cache` disabled, returning `found_posts`.
`get_posts()` cannot be used for this: it forces `no_found_rows => true`, which
discards the very total required. This is the pattern `templates/page_auction.php`
already used (`$countLots = $wp_query->found_posts;`), so the two call sites now agree.

The filter arguments are byte-identical — the `BP_QUERY`/`bp_field` meta filter,
`suppress_filters => false` and the Polylang branch are untouched — so the returned
number cannot change. Verified on three live sites: old and new implementations and
the live method all returned the same count (11, 7 and 0 respectively).

## [3.7.1.21] — 2026-07-25

### Fixed — Webhook item updates never purged the page cache

`inc/sync/ItemUpdateWorker.php` stage 5 ("Clearing cache") called only `HummingbirdSupport::clearCacheByUrl()` and `Auction::doClearCacheForAuctionPages()`. Both are **Hummingbird-only**, so on any site without that plugin — which is all of them on the current hosting stack — the entire stage was a no-op: Cloudflare, nginx FastCGI and LiteSpeed were never told about the change. Item updates arriving by BidSpirit webhook therefore left stale cached pages behind.

It also passed the wrong id. `Lot::getId()` returns the **BidSpirit** lot id (it is loaded straight off the API payload), not the WP post id, so `get_permalink()` was resolving a nonexistent post and the log line reported a bogus `post #17471` for what was actually post `13620`. Stage 5 now resolves the real post id through `bp_meta_data` (the authoritative lot↔post join, matched on lot + auction + language and widening if needed) and routes the purge through `CachePurge::purgeLot()`, so every configured cache layer is invalidated. The log line reports the resolved post, or says plainly that it could not be resolved.

### Fixed — A cache dir that cannot be reached now reports itself instead of failing silently

`inc/controllers/NginxCacheSupport.php` skipped the file-deletion purge whenever the configured cache directory was unreachable, and said nothing. Because `isConfigured()` only tests that `af_nginx_cache_dir` is **non-empty** — never that it is reachable — `CachePurge` would call in, the purge would delete nothing, and the caller could not tell success from failure.

This is not hypothetical: aaPanel writes `open_basedir=/www/wwwroot/<site>/:/tmp/` into each site's `.user.ini`, while the shared FastCGI cache lives at `/www/server/fastcgi_cache` — outside it. Every purge silently no-op'd and pages stayed stale until `fastcgi_cache_valid` expired (24h by default). It went unnoticed for days because **`.user.ini` is `PHP_INI_PERDIR` and is read only by the FPM/CGI SAPI — WP-CLI ignores it**, so `wp eval` purge tests passed while the real web and webhook paths failed.

Now every rejection path (outside `open_basedir`, missing, or not writable) logs one clearly-worded line per request via the new `noteCacheDirFault()`, records the fault in the `af_nginx_purge_error` option, and raises a `notice-warning` in wp-admin telling the site owner exactly which directory is unreachable and why. The flag clears itself the moment a purge succeeds. A new public `NginxCacheSupport::purgeHealth()` returns the same state for support tooling without attempting a purge. `purgeByFile()` also checks reachability up front, so a blocked path no longer sprays raw `open_basedir restriction in effect` warnings into the error log.

No schema, option-format or config-version change; existing `af_nginx_*` settings are untouched.

## [3.7.1.20] — 2026-07-24

### Fixed — `auto_commit_disabled` left autocommit off, so sync jobs never committed 'completed'

On sites with the **Auto commit mysql disabled** option ON, `inc/import/InsertLotsBS.php::init()` issued `SET autocommit = 0` before importing lots but never restored `autocommit = 1` after its final `COMMIT`. The database connection was left mid-transaction, so the sync worker's subsequent `SyncQueue::complete()` status UPDATE was never committed — it rolled back when the request ended. The lot **data** persisted (it is committed in per-batch chunks inside the import), but every `catalog_sync` / `item_update` job stayed `processing` until the stuck-job watchdog marked it **failed**, so the Sync Status dashboard showed unbroken failures even though the catalog was mostly current.

Observed in the wild on a site where `catalog_sync` was **0-for-326** and no job of any type had completed in ~4 weeks; the lots were present (560/560) but no job ever reached `completed`. `init()` now runs `SET autocommit = 1` immediately after its final `COMMIT`, so the caller's writes — the job-status update in particular — commit normally. (Setting `autocommit = 1` also implicitly commits any still-open transaction, so this is doubly safe.) Sites with the option **off** (the default) never entered this path and are unaffected. No schema or config-version change.

## [3.7.1.19] — 2026-07-24

### Fixed — Category-filtered listings emitted invalid SQL and returned zero lots

`inc/controllers/BidspiritQuery.php::bp_query_where()` builds the `bpMeta` WHERE clause for AuctionForge listing queries. Many callers pass `bp_index` purely as a **truthy JOIN marker** — `array(1)` (see `inc/shortcodes/LotsByCategory.php`, `inc/model/Lot.php`, `inc/helpers/BS_API.php`) — meaning "join the `bp_meta_data` table so I can filter by `bp_field`/`term_id`", with no `itemIndex` constraint. The reader tried to interpret that positional `array(1)` as an associative `["compare"]`/`["value"]` clause, always resolving `compare` to `""` and `value` to `0`, and emitted:

```
CAST(bpMeta.`itemIndex` AS UNSIGNED)  0
```

— a comparison with **no operator**, which MariaDB rejects as a syntax error. Because this ran inside `posts_where` on the main listing query, the *entire* query failed: category-filtered catalog pages and the `bp_lots_by_category` widget returned **no lots**, and every render logged `WordPress database error You have an error in your SQL syntax … near '0 ) AND ( bpMeta.\`categoryId\` = … )'` (66,000+ entries on the dev site since 2026-03-26).

The `itemIndex` branch now only emits a `CAST(...) {compare} {value}` clause when a comparison operator is actually present; when `bp_index` is just a JOIN marker it emits a `1=1` no-op so the wrapping `AND ( … )` stays valid, leaving the real filtering to `term_id`/`bp_field`. The `between` range branch (used by day-range queries with explicit `>=`/`<=`) and the `bp_id` branch are unchanged. Verified on dev: the previously-failing `bp_index=array(1)` + `categoryId` query now produces valid SQL (`AND ( 1=1 ) AND ( bpMeta.\`categoryId\` = 717 )`) and returns matching lots with no DB error. No schema or config-version change.

*(Latent parallel: the `bp_id` branch dereferences `["compare"]` without an emptiness guard too; not currently triggered in the wild, noted for a future hardening pass.)*

## [3.7.1.18] — 2026-07-24

### Added — Admin control for bidder phone verification (previously CLI-only)

The `require_phone_verification` setting introduced in 3.7.1.7 was only settable via `wp option patch update bidspirit_settings require_phone_verification …` on the server. Auction houses that are not self-hosted (no wp-cli / DB access) therefore had no way to turn it off, so bidders on a BidSpirit account without SMS phone-verification provisioned were permanently blocked at bid time — the bid gate keeps asking for a verification code that BidSpirit never sends.

A new **"Phone Verification"** field on the *Settings → Profile & Users* tab (`Settings::fill_bidspirit_options_require_phone_verification()`, registered in `section_profile`) surfaces the exact three states the front-end already honours as a dropdown:

- **Automatic** (default) — required for every house except UK auction houses (`Dialect::isUk()`); the field shows the currently-resolved state inline.
- **Always required** — every bidder must verify (`on`).
- **Never required** — bidders are never asked (`off`), the fix for un-provisioned houses.

Deliberately a select, not a checkbox: the option is three-state, and the settings sanitiser normalises every registered *checkbox* to `on`/`off` on save — a checkbox would have silently destroyed the `auto` default (and its UK auto-bypass) across the fleet the next time any site saved its settings. The select stores the chosen value verbatim and leaves `auto` intact. No enforcement code changed — `BidspiritViews::bp_footer()` still reads the same option into `window.afRequirePhoneVerification`, and the bid gates in `app.js` are untouched (no JS rebuild). No schema or config-version change.

## [3.7.1.17] — 2026-07-24

### Fixed — Catalog sync `auction-published` push fataled with "Cannot use stdClass as array"

`inc/controllers/AuctionsAPI.php::auctionPublishedPush()` read the localized auction name with `$obj->auctionsInfoByLang[$langCode]` — an array subscript on a value that arrives from the BidSpirit webhook as a **stdClass** (the payload is decoded as objects, exactly as the surrounding sync code already treats `itemsByLanguage`). Under PHP 8 this threw an uncaught `Error: Cannot use object of type stdClass as array`, returning HTTP 500 to `POST /wp-json/bidspirit/v1/sync` so the auction-published push — and the sync request carrying it — failed. It is now read with a null-safe object access (`isset($obj->auctionsInfoByLang->{$langCode})`), falling back to the auction number when the localized info is absent, and degrades quietly whether the field is an object, an array, or missing. No schema or config-version change.

### Fixed — nginx FastCGI cache purge logged open_basedir Warnings and silently failed under restricted sites

`inc/controllers/NginxCacheSupport.php::safeCacheDir()` called `is_dir()` / `is_writable()` directly on the configured cache directory. On aaPanel-hosted sites whose `open_basedir` is restricted to their own docroot + `/tmp` — while `af_nginx_cache_dir` points at the shared `/www/server/fastcgi_cache` — every auction/lot purge emitted `PHP Warning: … open_basedir restriction in effect` and the direct-file-deletion purge silently did nothing. `safeCacheDir()` now checks the target against the active `open_basedir` first (new `dirWithinOpenBasedir()` helper) and returns quietly when it is unreachable, suppressing any residual stat warning. Sites with no restriction (or whose cache dir is inside `open_basedir`) are unchanged. Sites that cannot reach the cache dir directly should use the `ngx_cache_purge` URL method (`AF_NGINX_PURGE_URL`) or add the cache path to `open_basedir`. No schema or config-version change.

---

## [3.7.1.16] — 2026-07-24

### Fixed — Auction pages white-screened (HTTP 500) when an auction had no `auction_days`

`templates/page_auction.php` read the `auction_days` term meta into `$auctionDays` and immediately called `count($auctionDays)` (and iterated it with `foreach`). For any auction whose `auction_days` meta was unset, `getTermOption()` returns `null`, so `count(null)` threw an uncaught `TypeError: count(): Argument #1 ($value) must be of type Countable|array, null given` under PHP 8 — returning a 500 white-screen to visitors and search-engine crawlers hitting that auction page. `$auctionDays` is now normalised to an array (`if (!is_array($auctionDays)) { $auctionDays = []; }`) immediately after it is read, so the `count()`, the `foreach`, and the day-index lookups below are all safe when no schedule is present. Auctions with valid single- or multi-day schedules are unchanged. No schema or config-version change.

---

## [3.7.1.12] — 2026-07-20

### Fixed — Open-invoices banner ignored the "Enable My Invoices tab" setting

The site-wide "⚠ This account has open invoices / Pay Now" banner rendered for logged-in users with unpaid BidSpirit bills even on sites where the **My Invoices** profile tab is disabled — and its "Pay Now" button linked to `#bp-bills`, a tab that does not exist on those sites, so it led nowhere. `checkUnpaidBillsBanner()` had no gate on the setting.

`BidspiritViews::bp_footer()` now emits `window.afBillingTabEnabled`, computed from the **same** `enable_billing_tab` expression that gates the tab markup in `profile-templates.php` (so banner and tab can never disagree), and `checkUnpaidBillsBanner()` returns immediately when it is `false` — no banner, and no `bills/getUnpaidBills` / unpaid-status AJAX traffic on such sites. Sites with the tab enabled are unchanged. No schema or config-version change.

### Fixed — US auction houses resolved to the UK "Catalogue" dialect in Auto mode (regression)

Restores the address-signature detection that was lost in an earlier merge (originally fixed 2026-06-26, silently reverted by a later version sync). US house addresses typically name no country and end in "City, ST 12345", so `Dialect::captureRegionFromAuctions()` took the "ST 12345" tail as an unknown country name and resolved UK. `countryToDialect()` now takes the full address and recognises a US state code + 5-digit ZIP tail (const `US_STATE_CODES`, uppercase-only match), plus a bare trailing ZIP with a `$` currency as a secondary signal. Conservative by design: named countries still resolve UK — Canadian ("ON M5V 3L9") and Australian ("WA 6000", 4-digit postcode) tails cannot match the 5-digit requirement.

### Fixed — LiteSpeed cache purges during sync stacked "Purge url" admin notices

`CachePurge` purged per-lot URLs via the public `litespeed_purge_url` action, which calls `Purge::purge_url()` with `$quite=false` and enqueues a dismissible admin notice **per URL** — a full-auction sync stacked hundreds of "Purge url …" notices on the next wp-admin page and looked like a fault to clients. Purges now call `\LiteSpeed\Purge::cls()->purge_url($u, false, true)` directly (identical purge, no notice), falling back to the action when the class is unavailable. Manual purges elsewhere keep their confirmations.

### Fixed — Sync log wrote one row per skipped auction

`LotList::updateData()` logged "  Skipping auction N (excluded or not matched)" for **every** excluded auction on **every** worker run — the dominant source of `af_sync_log` table growth on multi-house sites (observed in the GBs fleet-wide). It now counts the skips and writes a single "Skipped N/M auctions (excluded or not matched)" summary line per run. The per-auction detail remains available via `Debug::logData()`.

### Housekeeping

Removed stray `.bak` working files (`auctionforge.php.bak-*`, `readme.txt.bak-verbump`, `app.js.prephonebypass-*.bak`, CachePurge/BidspiritViews backups) that had been shipping inside the plugin tree since 3.7.1.4.

## [3.7.1.11] — 2026-07-20

### Added — "Auction is live" popup on single-lot pages

When BidSpirit reports an auction as live, single-lot pages now show the "Auction is live — Enter live auction" popup (linking to the BidSpirit bidding console), exactly as auction catalogue pages already did. Previously a one-lot auction had no live indication anywhere on the site, because its catalogue URL redirects straight to the lot page and only the catalogue code path surfaced the live state. (Entry backfilled — 3.7.1.11 shipped with this note in readme.txt only. 3.7.1.10 was an internal bump with no separate changes.)

## [3.7.1.9] — 2026-07-14

### Fixed — Catalog pagination stayed broken for returning visitors (stale session)

Follow-up to 3.7.1.8. That fix stopped *new* sessions from getting a bad per-page baseline, but a **returning visitor whose session already held the old baseline** (the session cookie lives 24h) kept getting page 2+ reset to page 1 at any non-default items-per-page — only a full "clear site data" (which drops the session cookie) fixed it for them.

The server no longer resets pagination from session state at all. Per-page changes already return to page 1 on the client (the items-per-page dropdown navigates to the page-1 URL), so the session-based reset in `BidspiritLots::search_filter()` and `bidspirit_lot_per_page()` was both redundant and the source of the stale-baseline problem — it has been removed. Pagination is now correct for every visitor (new or returning, any session state) on the next page load, with no cache/cookie clearing required. No schema or config-version change.

## [3.7.1.8] — 2026-07-14

### Fixed — Catalog pagination broke at any non-default "Items per page"

On auction catalog pages, setting **Items per page** to any value other than the site's WordPress default (`posts_per_page`) made page 2 and beyond redirect straight back to page 1 — so it appeared to "show the same lots" — while the value matching the default worked. `BidspiritLots::bp_session_starter()` seeded the per-page change-detection baseline from `get_option('posts_per_page')`; because auction pages are served from full-page cache / behind a CDN that drops the PHP session cookie, that baseline reset to the default on every request, so any non-default size looked "just changed" on every page load and was force-redirected to page 1 by `maybe_redirect_to_first_page()`.

The baseline is now seeded to `null` ("unknown previous value"), matching the existing category/search baselines, so a page reset only fires when a genuinely-known prior per-page value differs (`isset && !== null && !=`). The items-per-page dropdown additionally returns to page 1 client-side, so "change size → page 1" no longer depends on the session. No schema or config-version change.

## [3.7.1.7] — 2026-07-13

### Added — Per-house toggle to bypass phone verification (UK houses auto-bypass)

BidSpirit's `users/phone/sendPhoneVerificationCode` returns a generic `UNKNOWN_ERROR` for UK houses (e.g. rosanreeves, edwards) even for valid `+44` mobiles, because SMS/phone-verification is not provisioned on those BidSpirit accounts. As the requirement is enforced **only plugin-side**, this left UK bidders unable to bid.

A new per-house setting **`require_phone_verification`** (in the `bidspirit_settings` option) makes the client-side bid gate conditional:

- **`auto`** (default) — enforce EXCEPT on UK houses (`Dialect::isUk()`), which are bypassed while their BidSpirit accounts lack SMS provisioning.
- **`off`** — never enforce (per-house override, e.g. `wp option patch update bidspirit_settings require_phone_verification off`).
- **`on`** — always enforce.

`BidspiritViews::bp_footer()` emits `window.afRequirePhoneVerification`; both bid gates (`submitBid`, `submitBidCatalog`) and the profile “Verify phone” CTA honour it. Fails closed (enforces) when the flag is absent. No schema or config-version change.

---

## [3.7.1.2] — 2026-06-30

### Fixed — Background sync worker spawn timing out ("Worker spawn HTTPS failed: cURL error 28")

On busy or memory-pressured hosts the background sync worker frequently failed to start, logging `Worker spawn HTTPS failed: cURL error 28: Operation timed out after 5002 milliseconds with 0 bytes received` (observed ~29×/day on one site). `SyncQueue::spawnWorker()` triggers the worker with a **blocking** loopback HTTP request to the site's own REST endpoint; the timeout was hard-coded to **5 seconds**, so whenever the site's own backend took longer than 5s to respond (DB/FPM contention) the spawn aborted *as a hard error* and raised a sync alert — even though the site was up and serving.

Three changes make the spawn resilient:

- **Configurable, longer timeout.** The 5s hard-coded timeout is now **15s by default**, overridable via the `AUCTIONFORGE_WORKER_SPAWN_TIMEOUT` constant or the `auctionforge_worker_spawn_timeout` filter.
- **Scheme-matched loopback.** Strategy 1 now issues the `127.0.0.1` loopback using the site's own scheme (https when the site is https) instead of always http. Previously an http→https redirect (301) made the loopback look like a failure and forced the slower public-URL fallback; the POST now reaches the worker endpoint directly.
- **Non-blocking best-effort fallback.** If both blocking attempts can't confirm a start within the timeout, the worker is now triggered with a final non-blocking (fire-and-forget) request, logged at **warning** instead of **error**. A merely-slow backend still starts its worker, the hourly `SafetySync` cron re-spawns if it genuinely didn't, and the condition no longer raises a hard sync alert.

No schema, DB, or config-version change. Behaviour is unchanged on healthy hosts (the loopback still returns 2xx immediately); the new paths engage only when the backend is slow. The underlying slow backend was a server-side issue (MariaDB query-cache lock contention + memory pressure) fixed at the infrastructure level; this release hardens the plugin so a transiently slow backend can no longer turn into a failed sync.

---

## [3.7.1.1] — 2026-06-29

### Added — Active uptime monitoring via Kuma keyword health marker

The keepalive mu-plugin now emits an invisible `<!-- af-health:ok -->` marker into every rendered front-end page (hooked on `wp_head` and `wp_footer`, emitted once per request, pure echo — no DB/IO, lands inside cached HTML). Uptime Kuma monitors each site by pulling the homepage and asserting the marker is present, which measures real user-facing availability (DNS + TLS + nginx + PHP + WordPress + DB + render) instead of the old push-heartbeat model that only measured whether WP-Cron was firing — and routinely false-flagged perfectly healthy sites as down (the worst offenders flapped 50–60×/day while serving HTTP 200 the entire time).

Because the marker lives in the mu-plugin (which WordPress can't deactivate and AuctionForge re-asserts on every load), it reflects "WordPress is rendering pages" independently of AuctionForge's own activation state, and it survives failed or in-progress plugin updates. The bundled keepalive template is bumped to **1.3.0**, so the installer rewrites `wp-content/mu-plugins/auctionforge-keepalive.php` on update. The legacy push heartbeat is retained in this release for a safe transition to active monitoring.

---

## [3.7.0.43] — 2026-06-22

### Fixed — Submission "Need help?" contact was hardcoded (one tenant's details on every site)

The vehicle-submission help panel (`templates/js-chunks/submissions.php`) rendered a single hardcoded email and phone, so every AuctionForge site displayed the same contact regardless of which auction house it was — e.g. one tenant's `info@absoluteclearance.co.uk` / `01536 674804` showed on unrelated sites.

The panel now resolves the contact dynamically, in priority order:

- `apply_filters('auctionforge_help_email', '')` / `apply_filters('auctionforge_help_phone', '')` — explicit per-site override (any source, e.g. a theme/global/Elementor setting);
- otherwise the SEO & AEO Optimizer LocalBusiness details stored in the `seo_aeo_options` option (`local_email` / `local_phone`);
- if neither yields a value the corresponding row is omitted, and the whole "Need help?" block is hidden when no contact is available — so no placeholder or wrong-tenant detail is ever shown.

### Internal

- **Modified:** `templates/js-chunks/submissions.php` — replaced the two hardcoded `<p>` contact values with filtered, `esc_html`-escaped values resolved from `seo_aeo_options`, each row guarded by an `if`.
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.43.
- No database schema change (`AUCTIONFORGE_DB_VERSION` unchanged at 25).

---

## [3.7.0.42] — 2026-06-22

### Fixed — Submission templates emitted extra `<h1>`s and placeholder content site-wide

The vehicle-submission view (`templates/js-chunks/submissions.php`) is rendered on `wp_footer` on **every** page, so its `<script type="text/template">` chunks shipped into the source of all pages. Two of those chunks used `<h1>` headings ("Submit your vehicle for auction" and "Detail received successfully"), meaning every page's HTML source contained three `<h1>` elements. While these live inside inert `<script>` templates (so a DOM-rendering crawler like Googlebot still resolves a single true H1), raw-source SEO tooling and some AI crawlers flag the duplicates, and the templates also shipped vendor placeholder text.

- Both template headings demoted from `<h1>` to `<h2>` — a single H1 per page in source.
- Replaced Lorem-ipsum placeholder copy in the intro and progress panels with real submission guidance.
- Replaced the dummy "Need help?" contact details (`support@domain.com` / `+3265464325`) with the live business contact (`info@absoluteclearance.co.uk` / `01536 674804`).

No functional/JS changes — template IDs and structure are unchanged.

---

## [3.7.0.41] — 2026-06-19

### Fixed — Duplicate auction terms (auction shown twice, catalogue split)

A newly published auction could be created **twice** in WordPress as two identical `auction_number` terms, so the auction rendered twice on the listing and its catalogue was split across the two copies (e.g. 145 lots stored as 144 + 1). The lot posts themselves stayed unique — this was a duplicate *auction*, not duplicate *lots*.

Root cause: auction terms are only created in `Auction::save()` via `wp_insert_category`, which inserts whenever the caller's lookup returned no term. That lookup (`findAuctionByIdWithLang()` → `get_terms()` meta_query) can return nothing for a brand-new auction when two syncs run concurrently — for example a `full_sync_*` bulk run (via `BulkSyncWorker` / `AuctionList`) overlapping a catalogue webhook. Both see "no term" and both insert one. The only fallback, `findAuctionByNumber()`, looks up by the auction *number* slug and never matched the real term slug, so it could not catch the miss. This path is **not** covered by the existing `CatalogSyncWorker` per-auction lock, which serialises catalogue syncs to prevent duplicate *lot posts* but does not guard *term* creation.

`Auction::save()` now, before inserting a term:

- acquires a per-auction MySQL advisory lock (`GET_LOCK('<prefix>af_term_<auctionId>', 10)`) so concurrent creators serialise — the loser then finds the just-created term and updates it instead of inserting a second one; and
- performs an authoritative, cache-bypassing lookup by `auction_id` (direct `termmeta` ⋈ `term_taxonomy` query) and switches to update-in-place when a term already exists.

Polylang-aware: under full multilingual support it only reuses a term in the requested language, so legitimate per-language terms are still created.

### Internal

- **Modified:** `inc/model/Auction.php` — `save()` gains a pre-insert dedup guard; new private helpers `findTermIdByAuctionId()`, `acquireTermLock()`, `releaseTermLock()`. `InstanceDB` wraps the global `$wpdb`, so the advisory lock and the `wp_insert_category` insert share a single DB session (the same property that makes the existing `CatalogSyncWorker` lock effective).
- No database schema change (`AUCTIONFORGE_DB_VERSION` unchanged at 25).

---

## [3.7.0.40] — 2026-06-14

### Fixed — Lot "begin to close" countdown now shows minutes (and seconds in the final minute)

The lot-page countdown (`#auctionStartTimer`, the *"Auction will begin to close in …"* line) only ever rendered **whole days and whole hours**, and computed once on page load. With less than an hour to go it therefore read **"0 days and 0 hours"** — e.g. a lot closing at 20:00 looked like a dead/expired timer from 19:00 onwards, even though ~46 minutes remained. The time maths was correct; the widget simply had no sub-hour granularity and never ticked.

It now **updates every second** and shows the largest meaningful units, dropping leading zero units:

- `44 minutes`  •  `3 hours and 20 minutes`  •  `1 day, 2 hours and 5 minutes`
- In the final minute it counts the seconds down (`40 seconds` → `1 second`) so it never sits on a flat "0".
- Once the close time passes (`result < 0`) the timer hides itself, as before.

`getCountdownText()` now wraps the suffix in a `.bp-start-countdown` span so the script can rewrite the whole phrase; if that wrapper is ever absent (e.g. a stale page-cached markup), `getLeftDate()` falls back to the original days+hours behaviour rather than erroring — important because `app.js` also carries the live bidding logic.

### Internal

- **Modified:** `inc/controllers/Settings.php` — `getCountdownText()` suffix wrapped in `.bp-start-countdown`.
- **Modified:** `assets/js/app.js` — `getLeftDate()` rewritten: 1s ticking via `setInterval`, days/hours/minutes/seconds, zero-unit omission, pluralisation, and a wrapper-missing fallback. This is the file actually served (front-end `$jsFile` is hardcoded to `.js`).
- **Modified:** `src/js/app.js` — same `getLeftDate()` change, to keep the gulp source in sync. `app.min.js` is intentionally not regenerated (unused — the unminified `app.js` is enqueued).
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.40. (3.7.0.39 was present in the header with no changelog entry; this release supersedes it.)

---

## [3.7.0.38] — 2026-06-02

### Added — Spelling Dialect "Auto" mode (match the auction house region)

The **Spelling Dialect** setting (Settings → Appearance) gains a third option, **"Auto — match the auction house region"**, which becomes the **new default** for sites that haven't chosen one:

- During the existing auction sync (`getAllAuctions`), `Dialect::captureRegionFromAuctions()` reads the auction house's country from the auction `address` (trailing segment; `currency` as a fallback) and stores the resolved dialect in the `spelling_dialect_auto` option. **No API calls happen on page loads** — it's a fixed, site-wide value refreshed each sync, so it's fully page-cache-safe.
- Mapping: **US → Catalog**; every other country → **Catalogue** (American "Catalog" is US-only; UK/IE/AU/NZ/CA/etc. use "Catalogue").
- The admin field shows what was detected, e.g. *"Detected region: United Kingdom → Catalogue."* **United States** and **United Kingdom** remain manual hard-overrides.

**Default change:** new/unconfigured sites now default to **Auto** (was UK). Until a site's first sync resolves the region, Auto falls back to UK, so behaviour is unchanged until detection kicks in — then it self-corrects (a US house starts showing "Catalog" automatically).

### Internal

- **Modified:** `inc/helpers/Dialect.php` — `auto` mode, `FALLBACK_DIALECT`, `countryToDialect()`, `captureRegionFromAuctions()`; `DEFAULT_DIALECT` → `auto`.
- **Modified:** `inc/import/AuctionList.php` — capture the house region during the auctions sync.
- **Modified:** `inc/controllers/Settings.php` — Auto dropdown option + detected-region note.
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.38.

---

## [3.7.0.37] — 2026-06-02

### Fixed — Per-lot "full price VAT" now drives the VAT statement

The standalone VAT line on lot pages (`vat-and-commission` → `.vat-field`) was keyed off the auction-house VAT rule, so it always read *"VAT: X% on item price and commission"* regardless of a lot's **full-price-VAT** toggle — and toggling a lot off didn't change it. It now branches on the **live per-lot flag** (`itemInfo.item.fullPriceVat`), matching the price-breakdown calc in `getFullPriceInfo()`:

- Full-price VAT **on** → *"VAT: X% on item price and commission"* (VAT applied to hammer + commission)
- **off** (default) → *"VAT: X% on commission only"*

This only changes the **wording** to match the amount `getFullPriceInfo()` already computes per the toggle; the VAT figure itself was already correct. The separate `renderVatField()` "no VAT on commission" path is unchanged.

### Internal

- **Modified:** `templates/js-chunks/vat-and-commission.php` — VAT statement branches on the per-lot `fullPriceVat` instead of `houseCommissionVatRule`.
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.37.

---

## [3.7.0.36] — 2026-06-02

A Site Statistics release: day-of-week analysis, an opt-in weekly email report, lot-focused "top pages", and several dashboard refinements.

### Added — Day-of-week tabs on the hourly charts

The **Hourly Distribution** (Site Statistics → Overview) and **Peak Viewing Times** (Lot Analytics) charts now have a day tab strip — **All days · Mon · Tue … Sun** — each showing that weekday's total. Selecting a day redraws the 24-hour chart consolidated across every matching date in the range, so you can see *which days and times* visitors are active. The lot-analytics data already carried `day_of_week`; it was previously flattened to 24 hours and is now surfaced.

### Added — Scheduled weekly email report (opt-in, off by default)

A new report under **Site Statistics → Settings** emails a polished weekly summary: headline KPI cards (Visitors / Pageviews / Sessions with this-week-vs-last-week change), a **visitor journey** funnel (Visitors → Viewed a lot → Left a bid), **busiest hour for each weekday**, and the week's **most-viewed lots**. Driven by a daily cron that sends once on a configurable weekday. Supports **one or several recipients** (comma/semicolon/space/newline separated, validated and de-duped) and a **"Send test now"** button that sends to whatever is currently in the Recipient(s) box. Built table-based with inline CSS for Gmail/Outlook/Apple Mail compatibility.

### Added — New vs Returning visitors, auction markers, engagement funnel

- **New vs Returning** visitor KPI cards on the Overview (new = authoritative `af_stats_visitors.first_seen` within the period, joined so it can never exceed total visitors).
- **Auction-date markers** drawn on the *Traffic Over Time* chart (dashed lines + a captioned list) so traffic can be read against sale dates.
- **Engagement funnel** panel: Visitors → Viewed a lot → Viewed 5+ lots → Bidders (bidders from synced `af_bid_events`).

### Changed — "Top pages" is now lot-focused ("Top Lots" / "Most viewed lots")

Both the dashboard Overview panel and the email now list the most-viewed **individual lot pages** only (`/lot-…`), excluding home, catalogue listing/pagination and profile pages as low-signal. Email page titles are shown in full (no mid-word truncation) with the site-name suffix stripped; the dedicated **Pages** tab remains the unfiltered drill-down.

### Changed — Lot Analytics "Peak Viewing Times" honours the global date range

Previously hard-coded to the last 30 days; it now respects the dashboard's range picker. The Lot Analytics tab also auto-loads on first click (previously required a manual refresh).

### Changed — Chart.js bundled locally

The Statistics and plugin dashboards loaded Chart.js from a CDN at two different versions (`@4` and `@4.4.1`). Chart.js 4.4.1 is now bundled at `admin/assets/js/chart.umd.min.js` and loaded from the plugin (version-busted), so charts work offline / behind strict CSP and the version is pinned.

### Internal

- **Added:** `inc/stats/class-stats-report.php` — `AF_Stats_Report` (weekly cron, recipient parsing, HTML builder, "send test" AJAX). `admin/assets/js/chart.umd.min.js` — bundled Chart.js 4.4.1.
- **Modified:** `inc/stats/class-stats-ajax.php` — hourly-by-DOW query; `new_visitors`, `auction_events`, `funnel` in the dashboard payload; lot-only top pages; global date range passed to peak times; weekly-email settings saved.
- **Modified:** `inc/model/LotAnalytics.php` — `getPeakViewingTimes()` accepts an explicit `$from`/`$to` range.
- **Modified:** `inc/stats/class-stats-init.php` — require + register `AF_Stats_Report`.
- **Modified:** `admin/view/stats/dashboard.php` — day-of-week tabs, KPI cards, auction-marker plugin, funnel, Top Lots, weekly-email settings UI + test button, local Chart.js.
- **Modified:** `admin/view/dashboard.php` — local Chart.js.
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.36.

---

## [3.7.0.35] — 2026-06-02

### Added — Regional spelling dialect ("Catalog" US / "Catalogue" UK)

A new **Spelling Dialect** dropdown at the top of **Settings → Appearance** lets each site choose how the word "catalog" is presented on the public front end:

- **United States — Catalog** — US spelling (the plugin's original authored wording).
- **United Kingdom — Catalogue** — UK spelling.

When **UK** is selected, every front-end occurrence is rewritten — *"Catalogue will be available soon"*, *"View catalogue"*, *"Catalogue Coming Soon"*, *"Download catalogue"*, *"Back to the catalogue"*, the lot-page **Catalogue** tab, and the "catalogue published" app notification.

**How it works.** Every user-facing catalog string already passes through the `auctionforge` text domain (`__()`), so the swap is implemented as a single `gettext` / `gettext_with_context` filter rather than by editing template files. `Dialect::toUk()` rewrites whole words only, preserving case and pluralisation (`Catalog`→`Catalogue`, `View catalog`→`View catalogue`, `CATALOG`→`CATALOGUE`, `Catalogs`→`Catalogues`), and is idempotent. The filter is **front-end only** (`is_admin()`-guarded, with a `wp_doing_ajax()` exception so front-end AJAX is still covered) and is scoped to the `auctionforge` domain, so it never touches WordPress core, the theme, or other plugins. Code identifiers, option keys (`catalog_layout`, `bids_in_catalog`), CSS classes (`af-catalog-sidebar`, `catalog-section`, `bp-back-to-catalog-button`) and JS variables are all left intact. On US sites the filter is **not registered at all**, so there is zero added overhead.

### Internal

- **Added:** `inc/helpers/Dialect.php` — `Dialect` helper: `get()`/`isUk()` (reads the `spelling_dialect` option, default UK), `word()` for non-translated strings, the case/plural-preserving `toUk()` transform, the `gettext` filter callbacks, and `register()` (no-op on US sites).
- **Modified:** `inc/coreIncludes.php` — require `Dialect.php` and call `Dialect::register()` at plugin load.
- **Modified:** `inc/controllers/Settings.php` — `use` the `Dialect` helper; add the "Regional Spelling" sub-heading and **Spelling Dialect** field at the top of the Appearance section, plus the `fill_bidspirit_options_spelling_dialect()` renderer. The value persists through the existing `bidspirit_settings` option and sanitize callback.
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.35.

**Note for existing sites.** The default dialect is **UK ("Catalogue")**. Any site that upgrades without an explicit `spelling_dialect` value will therefore display "Catalogue" on the front end. Sites that should keep US spelling must set **Settings → Appearance → Spelling Dialect** to **United States — Catalog** (stored as `spelling_dialect = us`). Admin labels are unaffected in either dialect.

---

## [3.7.0.34] — 2026-05-30

### Fixed — Duplicate lot posts created by concurrent catalog syncs for the same auction

When two catalog-sync jobs for the **same auction** ran at the same time, every lot was imported twice and the front-end showed each lot as a pair (the second post taking a `-2` slug). Observed on a live sale: 157 lots became 313 published `lot` posts.

**Root cause.** Two BidSpirit webhooks arriving in the same second (e.g. `CATALOG_PUBLISHED` + `AUCTION_CATALOG_UPDATED`) each enqueue a `catalog_sync`. `SyncQueue::enqueue()` deduplicates with a non-atomic *SELECT-then-INSERT* (no DB unique constraint), so two simultaneous calls both pass the "already pending?" check and both jobs land in the queue. Job *claiming* is atomic per-job, but nothing serialised two **different** jobs for the **same auction**, so two workers ran concurrently. The matcher `Lot::findByBSMeta()` only sees committed `publish`/`draft` posts, so during the overlapping insert windows each worker saw "no existing post" for every lot and inserted a fresh full set. Worse, `findByBSMeta` does `GROUP BY lotId`, so the resulting duplicate pair is collapsed to a single row in every later sync — the importer never detects or repairs it, and the `count($datum) > 1` "Double" guard in `InsertLotsBS::init()` is effectively dead code.

**Fix.** `CatalogSyncWorker::process()` and `processDayEnded()` now take a per-auction MySQL advisory lock (`GET_LOCK('<prefix>af_sync_<auctionId>', 0)`) around the API-fetch + post-insertion work. If another worker already holds the lock for that auction, the duplicate job logs `concurrent-lock busy` and returns cleanly — marked completed, since the in-flight sync already covers the work — instead of racing. The lock is held on the worker's own DB connection and auto-releases if the process dies, so it can never deadlock the queue. This serialises all catalog and day-ended processing per auction no matter how many duplicate jobs reach the queue.

### Internal

- **Modified:** `inc/sync/CatalogSyncWorker.php` — new `acquireAuctionLock()` / `releaseAuctionLock()` helpers; `process()` and `processDayEnded()` acquire the lock up front (skip-on-busy) and release it in a `finally`.
- **Modified:** `auctionforge.php` — plugin header → 3.7.0.34.

**Note for existing sites.** This prevents *new* duplicates; it does not remove ones already created. To find them, look for published `lot` posts where two share one BidSpirit lot id (`bp_meta_data.id`). Keep the most-recently-synced post per lot id and set the rest to `post_status = 'trash'` — the `lot` post type has trash disabled, so do it via SQL, and do **not** use `draft` (which `findByBSMeta` still matches). Then recount the `auction_number` term and clear caches.

---

## [3.7.0.33] — 2026-05-30

### Fixed — `auctionforge_sync_lots` cron never scheduled (Lots push to wpdeploy silently stopped)

`AF_Lots_Sync` uses the custom `bp_ten_minutely` schedule. The sync crons were registered from `AF_Stats_Init::init()`, which runs at **plugin-load time** — *before* `auctionforge.php` adds `bp_ten_minutely` to the `cron_schedules` filter further down the same file. So `wp_schedule_event(time(), 'bp_ten_minutely', 'auctionforge_sync_lots')` ran against an unknown schedule, failed silently, and left the hook **permanently unscheduled**. The other four sync hooks use core's `hourly` and were unaffected — which is why only the **Lots** push to wpdeploy went stale (observed: 3 days with no push while every other stream ran on time). Fixed by deferring the sync `register()` calls to the `init` hook, by which point every custom schedule is registered. The hook self-arms on the next request after upgrade.

### Fixed — Safety Sync admin run reported a false "Request failed (HTTP 200)"

`auctionforge_ajax_run_cron_task` returns its result as JSON, but a stray PHP notice/deprecation emitted by a task (e.g. the Safety Sync BidSpirit delta-check on PHP 8.1) printed into the response body ahead of the JSON, so the browser couldn't parse it and the modal showed a misleading "Request failed (HTTP 200)" even though the task succeeded. The handler now buffers task output, discards it before emitting JSON, and logs any suppressed output via `SyncLogger` (context `admin`) so the offending notice can still be found and fixed at the root.

### Added — "Clear duplicate cron events" button on Sync Status

The one-shot `AF_Cron_Cleanup` migration only runs once per site, so per-hostname duplicate events created by an older build *after* the migration had already marked itself done were never swept. New `AF_Cron_Cleanup::force_sweep()` runs the same sweep on demand, exposed via a **Clear Cron Dupes** button in the Sync Queue toolbar (AJAX `auctionforge_clear_cron_dupes`). It removes legacy-args duplicates of the four per-hostname hooks plus the two orphan hooks, keeping one clean event each.

### Internal

- **Modified:** `inc/stats/class-stats-init.php` — sync `register()` calls wrapped in `add_action('init', …, 20)`.
- **Modified:** `inc/maintenance/class-cron-cleanup.php` — new public `force_sweep()` reusing the existing sweep helpers.
- **Modified:** `auctionforge.php` — `auctionforge_ajax_run_cron_task()` output-buffered against stray output; new `auctionforge_ajax_clear_cron_dupes()` handler + registration; plugin header → 3.7.0.33.
- **Modified:** `admin/view/sync-status.php` — added the "Clear Cron Dupes" button and handler.

Note: with all scheduling now arg-free (3.7.0.28+), the duplicates do not regenerate once swept; a canonical-hostname redirect (www ↔ apex) at the web-server level remains good hygiene but is no longer required to keep the cron table clean.

---

## [3.7.0.32] — 2026-05-30

### Fixed — V3 sync worker never spawned on some hosts (silent import stall)

On some sites (confirmed on a LiteSpeed/cPanel install) the V3 queue worker never actually started, so catalog lots were staged into `import_history` but never converted to WordPress posts — `postId IS NULL`, `syncFinished = 0`, `syncTimes = 0`, and **no errors logged**. Clearing the queue and reprocessing re-stalled immediately.

**Root cause.** `SyncQueue::spawnWorker()` starts the worker by POSTing the REST route `/wp-json/auctionforge/v1/sync-worker`. It tried an HTTP loopback to `http://127.0.0.1/` with the site's `Host:` header first, and the success test was `if (!is_wp_error($result))` — it never inspected the HTTP **status code**. On hosts where `127.0.0.1` + `Host` header resolves to the wrong vhost (or the route is blocked), that request returns **404/403**, which is not a `WP_Error`. The old code therefore treated the 404 as a successful spawn, logged `Spawned … (HTTP 404)`, returned `true`, and **never reached the HTTPS fallback**. The worker never ran; nothing drained the queue or the staging table. Sites whose `127.0.0.1` loopback resolves correctly returned 200 and were unaffected — which is why this was host-specific.

**Fix.** `spawnWorker()` now reads the response code and only treats **2xx** as a successful spawn. On any non-2xx (or a `WP_Error`) from the loopback it logs a warning and falls through to the `rest_url()` HTTPS path, which is likewise status-checked. A new constant short-circuits the doomed attempt on known-bad hosts:

```php
define('AUCTIONFORGE_WORKER_SKIP_LOOPBACK', true); // wp-config.php — skip 127.0.0.1, go straight to HTTPS
```

### Added — Automatic staging recovery on the safety cron

The hourly `auctionforge_sync_safety` cron previously only spawned a worker when the **queue** had pending jobs. Lots that were staged into `import_history` but left unconverted (e.g. after a worker died, or while the spawn bug above was in effect) had no queue job attached, so nothing ever picked them up. The safety cron now also checks `SyncQueue::stagingBacklogCount()` and, when it is non-zero, kicks the staging processor (non-blocking) to convert the backlog into posts — making the pipeline self-healing without manual intervention.

### Added — "Process Staging" button on Sync Status

A new **Process Staging** button in the Sync Queue toolbar (Lots → Sync Status) runs `InsertLotsBS` over the staging table on demand — for when lots are staged but not appearing on the site. It fires non-blocking and switches the Live Console into fast-poll so the operator can watch posts being created in batches of 50.

### Internal

- **Modified:** `inc/sync/SyncQueue.php` — `spawnWorker()` rewritten with HTTP-status-aware fallback + `AUCTIONFORGE_WORKER_SKIP_LOOPBACK` support; `triggerStagingProcessor()` gained a `$blocking` parameter (non-blocking fire-and-forget mode for cron/admin); new `stagingBacklogCount()` helper (counts `import_history` rows with `syncFinished = 0`).
- **Modified:** `auctionforge.php` — `auctionforge_sync_safety_action()` now drains orphaned staging via the non-blocking processor when a backlog exists; new `process_staging` case in the `auctionforge_sync_enqueue` AJAX handler; plugin header bumped to 3.7.0.32.
- **Modified:** `admin/view/sync-status.php` — added the "Process Staging" button and its click handler.

---

## [3.7.0.28] — 2026-05-21

### Fixed — WP-Cron duplicate-event accumulation

A scan across 18 WordPress installs found that **16 sites had duplicate cron events** for the AuctionForge sync hooks — some with up to 20 copies of `bp_auto_close` and 6 copies of `auctionforge_sync_safety`. Root-cause analysis identified three distinct bugs in the plugin's scheduling code, all fixed in this release.

**Bug 1 — `$_SERVER['SERVER_NAME']` baked into scheduling args.** `auctionforge.php:2511-2554` scheduled `bidspirit_import`, `data_import`, `bidspirit_archive_import`, and `auctionforge_sync_safety` with `$attr = array($_SERVER['SERVER_NAME'])`. WP-Cron's `wp_next_scheduled()` guard matches on hook **plus args signature**, so every distinct hostname the site was reached under (canonical, `www.`, IP-direct, internal cron caller hitting `127.0.0.1`, an old domain from before a rename) bypassed the guard and scheduled its own copy. The handlers `unset($host)` — the argument was always vestigial. Removed `$attr` entirely; events now schedule with empty args. Also dropped the `!empty($_SERVER['SERVER_NAME'])` outer guards so wp-cli / OS-cron bootstraps can prime the schedule on a fresh install.

**Bug 2 — `bp_auto_close` per-term scheduling with no cleanup.** `inc/controllers/BidspiritLots.php` scheduled one 10-minutely event per running auction term, keyed on `['term_id' => …, 'slug' => …]` — correct in principle, but nothing ever unscheduled the event when the auction ended or its term was deleted. Long-running sites accumulated dozens of dangling per-term events polling the BidSpirit API for auctions that no longer existed. Added two cleanup paths:
- **`bidspirit_auto_close_action()`** now self-cleans: if the term has been deleted, or if the remote auction state returned by BidSpirit is not `RUNNING` or `ENDING`, the handler writes the remote state to `auction_state` term meta and calls `wp_clear_scheduled_hook('bp_auto_close', $args)` for that term. The meta write is necessary because `check_is_live()` runs on every `wp_loaded` and re-arms `bp_auto_close` for any term still marked `STATE_RUNNING` in local meta — without writing the remote state through, the unschedule would be undone on the next request. The meta write is consistent with what `SafetySync::run()` and `inc/import/AuctionList.php:229` already do on non-RUNNING upstream states.
- **New `pre_delete_term` hook** on the `auction_number` taxonomy unschedules the matching event when an auction term is deleted, so a deleted auction can't leave a poll behind.

**Bug 3 — Orphan hooks from removed plugin code.** `bidspirit_meta_migrate_sync` and `bidspirit_migrate_sync` were scheduled by a previous plugin version whose code has since been removed. They sat in `wp_options.cron` firing into the void on every site (the `MIGRATE_SYNC` constant in `Settings.php:36` is the lone survivor — commented out). No upgrade path ever called `wp_clear_scheduled_hook()` for them.

### Added — One-shot migration: `AF_Cron_Cleanup`

New class at `inc/maintenance/class-cron-cleanup.php`, registered from `class-stats-init.php`, runs once on the first request after upgrading and:

1. Walks `_get_cron_array()` and unschedules every event for the four hooks rewritten in Bug 1 **whose args are non-empty** — clearing per-hostname stragglers. The new scheduling code at the top of `auctionforge.php` has already armed a clean no-args event by the time the migration runs (the scheduling code runs at plugin-load time; the migration runs on `init` priority 20). Preserving empty-args events means the schedule remains continuously populated — no gap between sweep and the next page load.
2. Unschedules **every** event (any args) for the two orphan hooks from Bug 3 — those have no callbacks anywhere in the codebase, so empty-args entries are also dead weight.
3. Marks itself done via the `auctionforge_cron_cleanup_v1` option and never runs again.

Note that `wp_clear_scheduled_hook($hook)` alone only matches events with empty args — events scheduled with non-empty args (which is exactly our duplicate population) are left behind. The migration walks the cron array directly and calls `wp_unschedule_event($timestamp, $hook, $event['args'])` for each match.

Logs a summary line via `SyncLogger` so the per-site outcome is auditable: e.g. `cron-cleanup: Cleared duplicate/orphan cron events: {"bp_auto_close":20,"auctionforge_sync_safety":3,"bidspirit_meta_migrate_sync":2}`.

### Failure modes after this release

| State | Outcome |
|---|---|
| Fresh install (no duplicates) | Migration runs, finds nothing, sets the option, exits in microseconds. |
| Upgrade on a site with N duplicates | Migration clears them on first load post-upgrade; scheduling code re-arms each hook once with empty args. |
| Site reached under multiple hostnames going forward | No new duplicates — args no longer carry the hostname, so the `wp_next_scheduled()` guard catches re-registration regardless of which hostname triggers it. |
| Auction term deleted via wp-admin | `pre_delete_term` clears the matching `bp_auto_close` event in the same request. |
| Auction ends remotely (state != RUNNING/ENDING) | Next handler run sees the state change and self-cleans the event. |
| Auction term deleted while a `bp_auto_close` cron is mid-flight | Handler's `get_term()` returns null, falls through `unschedule_auto_close()` cleanup branch, returns without an API call. |

### Internal

- **Created:** `inc/maintenance/class-cron-cleanup.php` (~80 LOC).
- **Modified:** `auctionforge.php` lines 2511-2573 — removed `SERVER_NAME` args and outer guards from `bidspirit_import` / `data_import` / `bidspirit_archive_import` / `auctionforge_sync_safety` scheduling blocks; handler signatures changed from `($host)` to `()`; `add_action(..., 10, 1)` simplified to `add_action(...)`.
- **Modified:** `inc/controllers/BidspiritLots.php` — added `pre_delete_term` action registration; rewrote `bidspirit_auto_close_action()` with term-existence check and state-based self-clean; added `unschedule_auto_close()` and `cleanup_auto_close_on_term_delete()` helpers; dropped the `SERVER_NAME` guard from the per-term scheduling loop.
- **Modified:** `inc/stats/class-stats-init.php` — requires and registers `AF_Cron_Cleanup` alongside `AF_Data_Purge`.

---

## [3.7.0.21] — 2026-05-05

### Added — Connection Diagnostics on the Updates admin page

A new **Connection Diagnostics** card on **Lots → Updates** that detects and one-click-heals identity mismatches between this site and the deployment server.

**The problem it solves.** When a WordPress install is cloned (template → customer site, dev → prod, backup → restore), the cloned wp_options carry the original site's `auctionforge_update_api_key` over to the new `home_url()`. The new install then makes API calls to wpdeploy *authenticated as the original site*. wpdeploy looks up by api_key, not by URL, so every `/sync-user-tracking`, `/check-updates`, `/report-health`, and most importantly the Kuma push URL, gets recorded against the WRONG `wpdeploy.sites` row. The cloned site's monitor stays silent; the original's monitor shows traffic that isn't from it.

**What the diagnostic does:**

- **Run Identity Check** — calls the new wpdeploy `GET /api.php/whoami` endpoint and compares the returned `caller_site_url` against `home_url()`. Renders a verdict ("matches" / "MISMATCH") plus the full server-side view (site_id, site_name, last_check, plugin version on file, remote IP). The "Re-register & Reset Kuma" button only appears if there's a mismatch.
- **Re-register & Reset Kuma** — calls `/request-access` (which is idempotent on `site_url` — wpdeploy returns the canonical api_key for *this* `home_url()`, regardless of whatever wrong key we were holding), wipes `auctionforge_kuma_push_url` and any leftover heartbeat cron events, then immediately fires `/check-updates` so the api_request interceptor catches the freshly-correct kuma_push_url for *this* site. The keepalive mu-plugin's heartbeat picks it up on the next 60s tick.

### Internal

- **`AuctionForge_Auto_Updater::whoami()`** (new) — thin wrapper over `api_request('whoami', [], 'GET')`. Returns the parsed wpdeploy `/whoami` payload.
- **`AuctionForge_Auto_Updater::reset_connection()`** (new) — three-step heal: re-register → wipe Kuma cached state → re-fetch via `/check-updates`. Returns shape suitable for `wp_send_json_success`.
- **Two new AJAX actions:** `auctionforge_diagnose_connection`, `auctionforge_fix_connection`. Both behind `manage_options` capability + nonce check.
- **`auctionforge_normalize_host()`** helper for case/scheme/www-tolerant URL comparison.

### Server-side companion

Requires the wpdeploy `/api.php/whoami` endpoint added in the same release window. The endpoint is gated behind whatever api_key the caller already presented (a master key returns `type=master` with no site row; a per-site key returns the site row that key resolves to). Reveals nothing the caller doesn't already implicitly authenticate against.

---

## [3.7.0.20] — 2026-05-04

### Changed

- **Kuma heartbeat path is now wpdeploy-independent.** The actual heartbeat firing — wp-cron schedule, page-load piggy-back, outbound HTTP — moved out of AF and into the AuctionForge Keepalive mu-plugin (`wp-content/mu-plugins/auctionforge-keepalive.php`, bumped to v1.2.1). Once a site has been provisioned once and `auctionforge_kuma_push_url` is cached locally, the heartbeat keeps reporting forever even if AF is deactivated, mid-upgrade, deleted from disk, or wpdeploy is offline. The mu-plugin can't be deactivated by WordPress, can't be broken by an AF release, and depends on AF for nothing.
- **AF only writes the `kuma_push_url` option now.** It no longer schedules the cron, no longer fires the heartbeat, and no longer holds any heartbeat-related state. The `api_request()` interceptor still catches `kuma_push_url` from `/check-updates` and `/report-health` responses and persists it; the mu-plugin reads it from there.
- **Migration block.** `init_hooks()` clears any leftover `auctionforge_kuma_heartbeat` wp-cron event from AF ≤ 3.7.0.19 on first plugin load after upgrade. The mu-plugin runs the same clear on its own `plugins_loaded` hook — belt and braces. The new event the mu-plugin schedules is `kuma_heartbeat_tick` on a new `kuma_60s` schedule.
- **`AuctionForge_Auto_Updater::kuma_heartbeat()` no-opped.** Kept as a safety landing pad so any stale `auctionforge_kuma_heartbeat` wp-cron event that fires one last time before migration kicks in doesn't trip a "no callback registered" warning. Real heartbeat work happens in the mu-plugin.

### Added — operator override

- **`AUCTIONFORGE_KUMA_PUSH_URL` constant.** Define it in `wp-config.php` to point a site directly at Kuma without ever involving wpdeploy:

  ```php
  define('AUCTIONFORGE_KUMA_PUSH_URL', 'https://kuma.example.com/api/push/<token>?status=up&msg=&ping=');
  ```

  The constant takes precedence over the `auctionforge_kuma_push_url` option. With it set, the site can be operated fully outside wpdeploy — useful for one-off installs, customer-managed sites, or disaster-recovery scenarios where wpdeploy is unreachable for an extended period.

### Internal — Keepalive mu-plugin v1.2.1

The bundled keepalive template at `inc/keepalive/auctionforge-keepalive.template.php` now also handles Kuma heartbeats. Three layered triggers ensure cadence stays tight regardless of site traffic:

1. Dedicated wp-cron event `kuma_heartbeat_tick` on a new 60-second `kuma_60s` schedule
2. Page-load piggy-back on `init`: any request older than 50s since the last beat fires another, transient-guarded so concurrent hits coalesce. Free on busy sites; rescues low-traffic sites the moment any visitor arrives.
3. Self-heal inside the cron callback: if the event somehow gets cleared, re-arm it before sending the beat.

HTTP is `timeout=2, blocking=false` — fire-and-forget, never slows page loads. External IP introspection (ipify → ifconfig.me → icanhazip fallback chain, 24h transient cache) is duplicated in the mu-plugin so it doesn't depend on AF being loaded for the IP lookup.

The keepalive installer (`class-keepalive-installer.php`) detects the `KEEPALIVE_VERSION: 1.2.1` header bump and idempotently overwrites every site's mu-plugin on the next AF load. No manual reactivation needed.

### Failure modes after this release

| State | Heartbeat? |
|---|---|
| AF active and healthy | ✅ via mu-plugin |
| AF deactivated by an operator | ✅ mu-plugin runs anyway |
| AF deleted from disk | ✅ as long as `auctionforge_kuma_push_url` option exists OR constant is set |
| AF mid-upgrade / partial install | ✅ mu-plugin doesn't depend on AF code paths |
| wpdeploy down (existing site) | ✅ option is cached locally, no wpdeploy round-trip |
| wpdeploy down (brand-new site, never provisioned) | ❌ first-time URL provisioning still needs wpdeploy. One-shot, accepted trade-off. |
| Site has zero traffic AND wp-cron isn't firing | ❌ truly nothing to trigger a beat. Mitigation is OS-level cron / DISABLE_WP_CRON, out of scope here. |

---

## [3.7.0.19] — 2026-05-04

### Changed

- **Reverted Kuma heartbeat cadence: 3 min → 1 min.** `auctionforge_kuma_heartbeat` is back on the `bp_minutely` schedule. With Kuma's 310s silence window, 60s cadence tolerates 4 missed wp-cron ticks before flipping a monitor red — the original "give-it-leeway" semantics. The 3-min cadence introduced in 3.7.0.18 was strictly less chatty but tighter (a single missed tick would trip Kuma), and the trade-off wasn't worth it operationally.
- **Migration block updated.** `init_hooks()` now detects any existing event scheduled on a non-`bp_minutely` cadence (e.g. the `bp_3_minutely` set by 3.7.0.18) and re-schedules onto `bp_minutely`. Sites that took 3.7.0.18 roll back automatically on first plugin load after upgrade.
- **First-fire jitter narrowed back to 0–60s** to match the new cadence.

### Notes

- The `bp_3_minutely` cron schedule registration in `auctionforge.php` is kept (harmless; available for future use). Only the heartbeat scheduling sites have been reverted.
- Apparent latency in Kuma flipping a monitor red after a real outage is governed by Kuma's per-monitor `interval` (silence-detection window, currently 310s on the wpdeploy/Kuma side), not by this cron's cadence. To detect outages faster, lower the Kuma `interval`; to add more tolerance to wp-cron drift, raise it.

---

## [3.7.0.18] — 2026-05-04

### Changed

- **Kuma heartbeat cron cadence: 1 min → 3 min.** `auctionforge_kuma_heartbeat` now runs on a new `bp_3_minutely` schedule (180s) instead of `bp_minutely` (60s). This pairs with Kuma's 310s silence-detection window — a single late wp-cron tick (3 min + drift) still lands inside the threshold, but normal traffic to Kuma drops by ~3×.
- **Random first-fire offset widened from 60s → 180s.** With ~50 sites on a single Kuma instance, spreading first-fires across the full 3-min window prevents synchronised bursts every 3 minutes.
- **Auto-migration of existing scheduled events.** On plugin upgrade `init_hooks()` calls `wp_get_scheduled_event('auctionforge_kuma_heartbeat')` and, if the existing event is on `bp_minutely`, clears it and reschedules onto `bp_3_minutely`. Idempotent — a no-op once migrated.

### Internal

- **New cron schedule registered:** `bp_3_minutely` (`BP_MINUTELY * 3` = 180s). Defined alongside the existing `bp_minutely` / `bp_ten_minutely` / `bp_hourly` / `bp_24_hourly` schedules in `auctionforge.php`.

---

## [3.7.0.17] — 2026-05-04

### Fix: "Install Now" always returned `up_to_date`

The `/sync/version` REST endpoint (called by wpdeploy's Install Now button) was always responding `success: false, reason: up_to_date` regardless of the actual installed version, defeating the button's whole purpose.

#### Root cause

`rest_force_plugin_update()` did:
```
delete_site_transient('update_plugins');
$this->check_pending_updates();
$updates = get_site_transient('update_plugins');
```

`check_pending_updates()` calls the `/pending-updates` endpoint on wpdeploy (which returns the deploy queue), but it does **not** touch the WP `update_plugins` transient. The transient just got deleted on the line above and never gets re-populated, so the `isset($updates->response[…])` check always failed → always returned `up_to_date`.

#### Fix

- Use `wp_update_plugins()` instead — that's what fires the `pre_set_site_transient_update_plugins` filter chain that AF's own `check_for_updates()` callback hooks into to seed the transient. Same pattern is already used correctly in `install_update()` at line 280.
- Belt-and-braces: when the request body carries an explicit target `version` and that version is strictly greater than the on-disk plugin version (per `version_compare`), bypass the transient check and force the install. This handles edge cases where the transient seeding fails (network blip during the wp-cron cycle, hosting-side cache, etc.) so the explicit Install Now command still does what it says.

---

## [3.7.0.16] — 2026-05-04

### Server external IP reported to wpdeploy + included in Kuma heartbeats

Each AF install now reports its server's outbound external IP. Two channels:

1. **Kuma heartbeat `msg`** — the per-minute push payload now reads `<wp_version>|<plugin_version>|<external_ip>` (e.g. `6.9.4|3.7.0.16|152.53.187.82`) so you can spot at a glance which physical box each site lives on. When a host goes dark, multiple monitors flip red simultaneously with the same IP — instant root-cause hint.
2. **`/report-health` payload** — a new `external_ip` field that wpdeploy stores on `sites.external_ip`. Visible in the Sites admin view as a tappable IP chip; clicking it filters the table to all sites sharing that IP.

#### How the IP is detected

`AuctionForge_Auto_Updater::get_external_ip()` calls one of three public echo services in turn — `api.ipify.org`, `ifconfig.me/ip`, `icanhazip.com` — with a 4-second timeout each. The first one that returns a valid IP wins. Result is cached in a transient for 24 hours so we don't hit external services on every report cycle.

#### Wpdeploy fallback for older plugins

`/report-health` now also captures `REMOTE_ADDR` (or `HTTP_CF_CONNECTING_IP` when behind Cloudflare) when the payload doesn't include `external_ip`. So sites still on 3.7.0.15 or older start populating their IP today via the same route, no plugin update needed for that data path.

---

## [3.7.0.15] — 2026-05-04

### Kuma monitors land in an "Unallocated" group by default

New AuctionForge installs are auto-enrolled into Kuma under a new top-level group called **Unallocated** so the operator can review them and drag-drop into the right home (Bidspirit Auction Sites, Stephen's Auctions, Misc Websites, etc.) before classification. Without this every new monitor would appear at the dashboard top level alongside whatever group hierarchy already exists.

#### What changed (wpdeploy-side, no plugin behaviour change)

- `KumaProvisioner` now ensures an "Unallocated" group exists in Kuma (find-or-create via the same Socket.IO `add` path used for monitors, with `type='group'`) before provisioning each new push monitor, and passes `parent=<group_id>` in the `add` payload so the new monitor lands inside it.
- The Node helper accepts arbitrary `type` and `parent` fields on the input payload (previously hardcoded to `type='push'`).
- The group itself gets the same `user_id=1` reassignment so it shows up on the human admin's dashboard.

Existing Clumber monitor (id=71) was migrated into the new group as part of this release.

This is a wpdeploy infrastructure change — the AF plugin code is unchanged from 3.7.0.14. Version bumped only to mark a clean snapshot point.

---

## [3.7.0.14] — 2026-05-04

### Uptime Kuma push heartbeat with automatic enrolment

Each AF install now appears as its own monitor on the central Uptime Kuma at https://kuma.smoothbyteit.dev, beating every minute and flipping red within ~90 seconds when a site goes dark. Auto-enrolled — no manual Kuma UI clicks per site.

#### How it works

1. **First contact**: when the plugin calls `/request-access` or `/report-health`, wpdeploy's new `KumaProvisioner` opens a Socket.IO connection to Kuma as a dedicated `auctionforge-bot` user, calls the official `add` event, and gets back a fresh push token. The resulting URL is shipped to the plugin in the JSON response as `kuma_push_url`.
2. **Plugin-side**: a generic interceptor on `api_request()` persists `kuma_push_url` to the option `auctionforge_kuma_push_url` whenever it appears in any wpdeploy response, and immediately schedules the new `auctionforge_kuma_heartbeat` cron on the existing `bp_minutely` interval.
3. **Heartbeat**: the cron handler sends a single `wp_remote_get` to the push URL each minute with `msg=<wp_version>|<plugin_version>` so Kuma's dashboard shows the running stack at a glance. Kuma's silence-detection window is 90s, so a single missed wp-cron tick won't false-DOWN.
4. **Idempotency**: the Kuma monitor's `description` field stores `wpdeploy_site_id:<id>`. Re-registering the same site short-circuits at the wpdeploy layer (existing `kuma_monitor_id` on `sites`), or — if that's missing — Kuma's existing row is reattached via the description anchor. Re-installing the plugin never duplicates monitors.

#### Why Socket.IO and not direct DB insert

Kuma's monitor scheduler does not poll its own DB for new rows — `startMonitors()` only loads monitors at boot, and new ones are armed via the authenticated Socket.IO `add` event handler (which calls `startMonitor()` and arms the per-monitor `safeBeat` setTimeout). A direct INSERT would create a "phantom" monitor that accepts pushes but never flips DOWN on silence, defeating the whole feature.

#### Notifications + monitor ownership

Provisioner reads existing Kuma notification rows whose channel is Telegram (or marked default-for-new-monitors) and attaches them to every new monitor via the `add` event's `notificationIDList`. After the Socket.IO `add` succeeds, the provisioner direct-writes `user_id=1` to the new monitor row in Kuma's SQLite — Kuma's `add` handler hardcodes `user_id=socket.userID` (the bot at user 2), which causes the frontend's real-time monitor-list updates to skip the admin's session. The follow-up UPDATE stamps ownership to the human admin so monitors render with their full name on the admin dashboard immediately.

#### Failure modes

Every call boundary degrades safely. If Kuma is unreachable when a site registers, registration completes without `kuma_push_url`; the next twicedaily `/report-health` retries. Heartbeat HTTP failures are silent (which is the desired outcome — Kuma's silence-detection is the signal). Kuma reset / monitor row deleted: provisioner's pre-flight check NULLs the wpdeploy anchors and falls through to fresh provisioning on the next call.

#### Operator setup

Already done: the `auctionforge-bot` Kuma user has been created and credentials populated in wpdeploy's `config.php`. To rotate or migrate Kuma later, edit `KUMA_URL` / `KUMA_BOT_USER` / `KUMA_BOT_PASS` in `config.php` — no plugin redeploy needed.

---

## [3.7.0.13] — 2026-05-04

### Wide shortcode: hide Register button for logged-in users

- When a user is logged in to BidSpirit (`body.bp-login`), the **Register** button is now automatically hidden on wide-format auction cards (`bp_upcoming_wide` and `bp_auction_list layout="wide"`).
- The **View Catalog** / **Catalog Coming Soon** button expands to full width when the Register button is hidden.
- Uses a lightweight `MutationObserver` to react to BidSpirit's async session init.
- Applied to both `UpcomingAuctionWide.php` and `AuctionList.php` shortcode CSS blocks.

---

## [3.7.0.12] — 2026-05-03

### Stop-word denylist for BidSpirit category-tag concatenations

BidSpirit's `wp_bp_meta_data.tags` field arrives as PascalCase category names lowercased without separators (`uscollectibles`, `firearmsaccessoriesammunition`, `usdecorativearts`, `printsandmultiples`). They look like keywords but they're really categories, and on sites where the tags field is populated they completely swamp the top-5 chips for power users (e.g. one buyer's profile was led by `uscollectibles 453 views` despite that being a label rather than a real interest signal).

29 known patterns added to `stop-words.php` so the tokenizer drops them at extraction time. New ones surfacing from sites we haven't seen yet should be added here as they appear; the wpdeploy `?tab=keywords` corpus page makes them easy to spot (sort by **most users** with low **lots** count and look for non-word concatenations).

---

## [3.7.0.11] — 2026-05-03

### Keyword extractor — capture model years + hyphenated calibers, drop more auction noise

The People view's top-5 keyword chips were filling up with generic descriptors (`household`, `model`, `original`, `assortment`) instead of the brand/maker terms (`mauser`, `whitworth`, `colt`) that actually distinguish one buyer from another. Live-data review on the new `?tab=keywords` Keyword Corpus page on wpdeploy confirmed the cause: real signal was being lost at the tokenizer stage.

#### Tokenizer changes (`inc/stats/class-keyword-extractor.php`)

Three pre-passes now run before the standard split-on-non-alpha:

1. **Hyphenated / x-separated calibers** — `.45-70`, `.30-06`, `7.62x54r`, `8x57`. Without this they're split into pure-digit fragments and dropped, losing the actual model identifier.
2. **Caliber-with-suffix** — `9mm`, `.22lr`, `.45acp`, `.380acp`. Previously only survived by accident because of the trailing letters; now extracted explicitly.
3. **4-digit model years 1700–2099** — `1873`, `1903`, `1911`, `1971`. Pure-digit tokens were unconditionally dropped; now retained when year-shaped.

Standard pass relaxed to allow 4-digit pure-digit tokens between 1700 and 2099.

#### Stop-word additions (`inc/stats/stop-words.php`)

Conservative — only universally empty auction descriptors:

`original`, `marked`, `accessories`, `decorative`, `household`, `goods`, `made`, `condition`, `assortment`

Deliberately NOT added: `antique`, `vintage`, `rare`, `fine` — those carry buyer-segmentation signal (antiquarian buyers vs. modern-collectibles buyers). Borderline cases (`metal`, `wooden`, `american`, `model`) are left to the wpdeploy-side IDF re-rank.

#### Companion change on wpdeploy (no plugin update needed)

`refresh_people_rollup.php` now multiplies each keyword's score by an IDF factor pulled from `keyword_idf` (refreshed hourly by `refresh_lot_keyword_index.php`). Generic keywords that appear in many lots get demoted automatically; brand/maker words that only appear on a handful get lifted into the top-5 chips.

After deploying this version, reset the `auctionforge_lots_sync_watermark` option on each site and clear the `wp_af_stats_lot_keywords` table to force re-extraction of every lot through the new tokenizer.

---

## [3.7.0.10] — 2026-05-03

### Lots sync — ship resolved CDN image URL, not bare BidSpirit filename

`class-lots-sync.php` was sending `imagesListStr`'s first comma-token verbatim as `image_url` — that's the raw BidSpirit storage value (e.g. `001.jpg`). Wpdeploy received a bare filename, the browser resolved it against the wpdeploy host, and every lot thumbnail 404'd.

The lots-sync now instantiates `LotImage` and pulls the resolved CDN URL (`LABEL_MEDIUM` thumbnail), with fallback to `LABEL_ORIGINAL` and a final defensive check that drops anything that doesn't start with `http(s)://`. Resolution is wrapped in try/catch — a missing class or BidSpirit settings issue leaves `image_url` empty rather than blowing up the sync batch.

After deploying this version on a site, reset its `auctionforge_lots_sync_watermark` option (delete the row) to re-push existing lots through the new resolver. The wpdeploy view skips `<img>` rendering when `image_url` isn't a real URL, so dirty rows degrade silently while the watermark catches up.

---

## [3.7.0.9] — 2026-05-03

### Keepalive sweep — auto-clears WP upgrader debris that produces silent "Could not create directory" failures

WordPress 6.x's atomic-rollback flow leaves `wp-content/upgrade-temp-backup/` lying around when its post-install cleanup misfires (the user-facing symptom is "Update failed: Could not create directory" after an update that has actually already succeeded). Stale `wp-content/upgrade/<plugin>-tmp/` and `.maintenance` lockfiles from crashed upgrades cause the *next* upgrade attempt to fail the same way. None of WP's own scheduled cleanup catches these reliably.

The keepalive mu-plugin (`KEEPALIVE_VERSION` 1.1.0) now sweeps all three classes of debris on every page load, transient-guarded to once per hour. Safety thresholds prevent it touching anything actually in flight: `.maintenance` files less than 5 min old, `wp-content/upgrade/*` entries less than 60 min old, and `upgrade-temp-backup/` less than 30 min old are left alone. Wrapped in try/catch with no throws, so the sweep can never block a request.

The keepalive installer detects the version bump and rewrites `wp-content/mu-plugins/auctionforge-keepalive.php` automatically on the next AF load, no manual reactivation needed.

---

## [3.7.0.8] — 2026-05-03

### Shop auction UI refinements & share button settings

#### Shop (Buy-It-Now) catalog improvements
- **Shop sorting dropdown:** Added a dedicated sorting dropdown for shop catalogs with options: Recently Added, Price: High, and Price: Low. Sorts by `buyoutPrice` metadata. Regular auction catalogs retain the original sorting options.
- **Removed "Buyout price:" label:** On shop lot pages, the redundant "Buyout price:" text before the price is now hidden. Only the price and "Buy Now" button are displayed. Regular auction buyout lots still show the label.
- **Removed increments table & terms links:** On shop lot pages, the "Increments table" and "Terms and conditions" links are hidden since they don't apply to buy-it-now items. The "Make an inquiry" link remains.

#### Buyout confirmation modal overhaul
- **Sales tax logic:** The buyout confirmation modal now follows the same sales tax detection as the regular bid modal. When the auction house has a US state configured, displays "Sales Tax" instead of "VAT", with the correct percentage for same-state buyers and 0% for out-of-state.
- **Hide 0% buyer's premium:** The Buyer's Premium line is hidden when the commission is 0%.
- **Shop-specific text:** For shop buyouts, all disclaimer text is replaced with: *"You will receive an email with your invoice, including shipping, and a secure payment link."*
- **Shop-specific buttons:** "I don't Agree" → "Cancel", "I Agree" → "Buy". The "Terms of sale" button is removed for shop buyouts. Non-shop buyouts retain the original layout.

#### Purchase confirmed screen
- Title changed from "Confirm purchase:" to "Purchase Confirmed".
- Thank you message updated to: *"Thank you for your order. We will confirm your order within 1-2 business days and email you an invoice, including shipping, and a secure payment link."*

#### Share button settings (Appearance tab)
- Added a **Share Buttons** section to the BidSpirit Appearance settings tab with individual checkboxes for each sharing platform: Copy Link, WhatsApp, Facebook, Telegram, X (Twitter), VK, Pinterest, and Email.
- All platforms default to enabled (shown). Unchecking a platform hides that share button on all lot pages (both shop and regular auctions).

---

## [3.7.0.7] — 2026-05-02

### Identity stitch on bidder login — keyword interest tallying now actually fires

#### Why
The keyword extractor was filling `wp_af_stats_lot_keywords` correctly (197 lots, 734 distinct keywords on Clumber), but `wp_af_user_interests` and `wp_af_user_recent_activity` were both empty — zero rows of any dimension. Same on every site running the new sync streams.

Root cause: identity stitching (visitor_hash → email) only fired in the tracker AJAX when `is_user_logged_in()` returned true. On these auction sites the actual bidders authenticate via BidSpirit, never as `wp_users` — so the WP login check was always false, the stitch table stayed empty, and `maybe_record_lot_view($url, $email, …)` got called with an empty email and bailed out before tallying. Lot views by real bidders never landed in the user-side interest profile.

#### What changed (no auth-path intervention)
The fix piggybacks on the *existing* fire-and-forget tracking AJAX that already runs after a successful BidSpirit login (`afLogIP("login", bidspiritUser)`). It does not touch the BidSpirit auth path itself.

- **`inc/ip-logger.php` `auctionforge_ajax_log_ip()`** — after the existing `auctionforge_user_tracking` insert, if the request carries a `visitor_hash` and the email is valid, calls `AF_Stats_Visitor_Email_Map::record($vh, $email, $ip)`. Wrapped in try/catch so any failure here can never break the existing tracking response.
- **`assets/js/app.js` + `src/js/app.js` `afLogIP()`** — reads `localStorage['af_vid']` (defensively, ITP/private-mode safe) and includes it as `visitor_hash` in the POST body.
- **`inc/stats/class-stats-init.php` `maybe_record_lot_view()`** — when `$email` is empty (the common case for BidSpirit-only bidders) but a `visitor_hash` is present, falls back to `AF_Stats_Visitor_Email_Map::lookup($visitor_hash)`. If a stitch exists, the view tallies; if not, it stays anonymous.

Net effect: a bidder logs in (BidSpirit), the post-login tracker AJAX fires as it always has, and now also writes one extra row into `af_stats_visitor_emails`. From that point onward, every page they view on the same browser/device tallies into `af_user_interests` keyed on their email. The BidSpirit transaction itself is untouched.

---

## [3.7.0.6] — 2026-05-02

### Critical fix — `X-API-Key` header collision with sibling SmoothByte plugins

#### What was wrong
On any site running both AuctionForge and another SmoothByte-stack plugin that also points its updater at `wpdeploy.smoothbyteit.dev` (e.g. `seo-aeo-optimizer`), the second plugin's `http_request_args` filter overwrote AuctionForge's `X-API-Key` header on every outbound API call. Wpdeploy then 401'd because the key it received belonged to a different plugin instance — usually a `seo_aeo_update_api_key` that wpdeploy had no record of.

The symptom was a quiet, total auth failure: `report-health`, `check-updates`, every `sync-*` route, all 401. The watchdog flagged streams as "gone" and updates stopped flowing. Affected sites: any install where the AuctionForge key and the sibling plugin's key happen to differ. Sites where both plugins shared a key were silently safe (the overwrite was a no-op).

#### Fix
`AuctionForge_Auto_Updater::add_download_auth()` now refuses to clobber an existing `X-API-Key` and, for download URLs, only stamps when the URL slug matches our own. That keeps targeted calls from `api_request()` intact regardless of filter ordering, and prevents either plugin from writing its key onto the other's download URL.

The same fix has been applied to `seo-aeo-optimizer` so both ends of the collision are defensive.

---

## [3.7.0.5] — 2026-05-02

### Sync Status admin panel — diagnostic + manual triggers in wp-admin

#### Why
Diagnosing sync issues used to require shell access on the site or piecing together state from wpdeploy's app.log. This release surfaces the same diagnostics inside wp-admin so a site admin (or anyone with `manage_options`) can see exactly what's happening and trigger fixes in one click.

Lives **inside the existing Sync Status menu** (`Lots → Sync Status`) — appended below the existing content as a "Sync Diagnostics & Manual Controls" section, no duplicate menu entry.

#### What's on the panel

**Connection summary** — plugin version, `af_stats_db_version`, wpdeploy server URL, last 8 chars of API key, keepalive install state + auto-reactivation count.

**Sync streams table** — for each of the 10 cron hooks (user-tracking, user-profile, user-interests, user-activity, lots, bids+wins, vulnerabilities, report-health, check-updates, fraud-enforcement):
- Configured schedule
- Last fired timestamp + status colour-coded (green=success, blue=idle/empty, red=error)
- Record count from last run
- Next scheduled run
- Current watermark (where applicable)
- "Fire now" button — clears doing_cron, calls `do_action($hook)` synchronously, shows the result

**One-shot utilities**
- `Test wpdeploy connection` — calls `/api.php/info` with the site's API key
- `Clear stuck cron lock` — deletes the `doing_cron` transient (the classic fix when scheduled events stop firing)
- `Reinstall keepalive` — overwrites `mu-plugins/auctionforge-keepalive.php` from the bundled template
- `Backfill bids from legacy tracking` — replays `last_event_type='bid'` rows from `wp_auctionforge_user_tracking` into `wp_af_bid_events` so the bids-wins stream picks them up. Useful for sites that have years of bid history in the legacy table but nothing in the new queue
- `Run all due events now` — clears doing_cron and fires every `auctionforge_*` cron event whose timestamp is past due, in one go

Live response output panel shows the JSON returned from each action so you can see what just happened without leaving the page.

#### Files Changed
- **Created:** `inc/admin/class-sync-status-page.php`
- **Modified:** `inc/stats/class-stats-init.php` (load + init the panel)
- **Modified:** `admin/view/sync-status.php` (append `AF_Sync_Status_Page::render_panel()` call at the bottom)
- **Modified:** `auctionforge.php` (Version 3.7.0.4 → 3.7.0.5)

#### Verification
Navigate to **Lots → Sync Status** in wp-admin on any 3.7.0.5 site. Scroll past the existing content to the new "Sync Diagnostics & Manual Controls" section. Click "Test wpdeploy connection" — you should see `{"success": true, "auth_type": "site"}`. Click "Fire now" on any stream — the row's last-fired timestamp should refresh after 1–2 seconds.

---

## [3.7.0.4] — 2026-05-02

### Self-healing: failed plugin updates can no longer take a site offline

#### Why
WordPress's plugin updater isn't transactional — when an update fails partway (corrupt unzip, fatal during the new version's `plugins_loaded`, disk pressure, theme conflict, etc.) WP often deactivates the plugin and leaves the site without it. Once AuctionForge is dropped from `active_plugins`, no AF code runs again — including its sync handlers — until someone notices and reactivates manually. On the AuctionForge site fleet, this had quietly happened to ~5 sites we found; almost certainly more on production sites we don't have filesystem access to.

This release ships an autonomous watchdog that lives outside the AuctionForge plugin entirely so it survives even when AF gets knocked out.

#### What changed

**1. Self-installing keepalive (mu-plugin)**
- `inc/keepalive/auctionforge-keepalive.template.php` — a 50-line WordPress must-use plugin. Lives in `wp-content/mu-plugins/`, which WordPress can't deactivate. On every `plugins_loaded` (priority 1, before any regular plugin), it checks: is `auctionforge/auctionforge.php` on disk but missing from `active_plugins`? If yes AND the file has a valid `Plugin Name:` header (smoke check — don't reactivate a corrupt zip), it re-adds AF and `include`s the file in the same request.
- `inc/keepalive/class-keepalive-installer.php` — runs on every successful AF load. Copies the template into `wp-content/mu-plugins/auctionforge-keepalive.php` if missing or version-bumped. Idempotent: compares `KEEPALIVE_VERSION` headers, only writes on change. Refuses to overwrite a foreign mu-plugin (defensive against name collisions). Failure is non-fatal — install errors are recorded in `wp_options` for wpdeploy visibility but never throw.

**Net effect.** Any site that successfully loads AF 3.7.0.4 or later will have the keepalive permanently installed in mu-plugins. From that point forward, even if a future update hard-fails, the keepalive recovers AF on the next pageview without any manual intervention or filesystem access. Crucially: this works on sites we don't own — the keepalive ships inside the AF zip, gets installed during the first successful update, and lives in mu-plugins thereafter.

**2. Forensic markers**
The keepalive writes three options on each recovery:
- `auctionforge_auto_reactivated_at` — most recent recovery timestamp
- `auctionforge_auto_reactivated_count` — total recoveries
- `auctionforge_auto_reactivated_failed_at` — set when the keepalive saw AF on disk but with no Plugin Name header (genuinely corrupt — needs operator attention)

These get picked up by the AF sync streams and surfaced on the wpdeploy dashboard so you can see which sites have been quietly recovering themselves and whether a particular release is failing more than it should.

**3. Companion: post-update-silence alerts on wpdeploy (server side)**
Wpdeploy's `report-health` route now compares incoming `plugin_version` against the row and writes `last_version_changed_at` + `previous_plugin_version` on change. The `SyncWatchdog` cron then alerts if a site has reported a version change but not sent any heartbeat for 2+ hours since — likely a failed install. Emits `sync_incidents` rows with `kind=post_update_failure` so the existing Telegram fan-out picks them up. Each version-change event alerts at most once.

#### Files Changed
- **Created:** `inc/keepalive/auctionforge-keepalive.template.php`, `inc/keepalive/class-keepalive-installer.php`
- **Modified:** `inc/stats/class-stats-init.php` (call `AF_Keepalive_Installer::maybe_install()` on every plugin load)
- **Modified:** `auctionforge.php` (Version 3.7.0.3 → 3.7.0.4)

Companion server-side changes (already deployed on wpdeploy.smoothbyteit.dev — not in this zip):
- `sites` table: `last_version_changed_at TIMESTAMP NULL`, `previous_plugin_version VARCHAR(20) NULL`
- `api.php` /report-health route detects version changes
- `SyncWatchdog::maybe_alert_post_update_failure()` emits the new alert kind

#### Verification
1. Drop a 3.7.0.4 install on any site. Confirm `wp-content/mu-plugins/auctionforge-keepalive.php` exists post-load.
2. Manually deactivate AF from the database (`DELETE FROM wp_options ...active_plugins...`). Hit the homepage. AF reappears in `active_plugins`; `auctionforge_auto_reactivated_count` increments.
3. Delete `mu-plugins/auctionforge-keepalive.php`. Trigger AF load (any pageview). The file reappears.
4. Bump KEEPALIVE_VERSION in the source template, ship a new release, observe the installed mu-plugin update on next AF load.

#### Operator notes
- Sites currently in the broken state (AF deactivated, no keepalive yet) are NOT auto-rescued by this release — they need a one-time manual reactivation in wp-admin OR a fresh AF install via wp-admin's Upload Plugin. After that, the keepalive is in place and future failures self-heal.
- The post-update-failure alert won't fire for upgrades that happened before this release (no `last_version_changed_at` was recorded). It activates from the first version change wpdeploy sees from each site after this update lands.

---

## [3.7.0.3] — 2026-05-02

### Fix: profile re-sync on login / profile-edit / register (no more empty names)

#### Why
The hourly profile sync uses a `user_registered` watermark — once a user has been pushed once, the watermark advances past them and they're never re-pulled. That meant:

1. Existing users whose first_name/last_name became populated AFTER their first sync stayed empty on wpdeploy forever.
2. BidSpirit-driven sites that don't write WP's standard `first_name`/`last_name` fields shipped empty rows even though the canonical name was visible in `display_name`.

The People view was showing `—` for the name on rows that absolutely had a name available — just not where the sync handler was looking.

#### Fixes (`inc/sync/class-profile-sync.php` + `inc/stats/class-stats-init.php`)
- **`AF_Profile_Sync::push_for_user_id($user_id, $blocking=false)`** — new public method that builds a single user's profile row, normalises it, and POSTs to wpdeploy bypassing the watermark. Non-blocking by default (`wp_remote_post` with `blocking=false, timeout=2s`) so login latency is unaffected.
- **`build_user_row($user_id)`** extracted as a reusable helper. Both the hourly batch and the on-demand push call it. Centralises role-filtering + display_name fallback + usermeta probing.
- **`display_name` fallback.** When `wp_usermeta.first_name` / `last_name` are both empty (typical on BidSpirit-only sites), `build_user_row()` parses `display_name` ("John Smith" → first=John, last=Smith) so we don't ship a row with a blank name when the data is sitting on the user record itself.
- **Three new action hooks** registered in `class-stats-init.php::init()`:
  - `wp_login` → `push_for_user_id($user->ID)` — every login refreshes the row
  - `profile_update` → same — profile edits in wp-admin refresh
  - `user_register` → same — new registrations get pushed instantly (instead of waiting up to 60 min for the next cron tick)

#### Files Changed
- **Modified:** `inc/sync/class-profile-sync.php` (refactor + new method + display_name fallback)
- **Modified:** `inc/stats/class-stats-init.php` (three new action hooks)
- **Modified:** `auctionforge.php` (Version 3.7.0.2 → 3.7.0.3)

#### Verification
1. On a 3.7.0.3 site, find a user whose `wpdeploy.user_profiles.first_name` is empty:
   `SELECT site_id, email, first_name FROM user_profiles WHERE email='something@example.com'`
2. Log in as that user (or have them log in).
3. Within ~2 seconds, re-query — `first_name` should now be populated, either from the standard meta key OR parsed from `display_name`.
4. The non-blocking POST means the user's login experience is unchanged — no spinner, no extra latency.

#### Operator note
Existing wpdeploy rows with empty `first_name`/`last_name` will refresh automatically as users log in or edit their profile. To force a one-shot re-walk of every user on a site (regardless of watermark), reset the watermark there:
`DELETE FROM wp_options WHERE option_name='auctionforge_profile_sync_watermark';`
Next hourly cron will re-pull every non-admin user with the new fallback applied.

---

## [3.7.0.2] — 2026-05-02

### Fix: sync-lots datetime mismatch + idle syncs now heartbeat

#### Why
Two issues surfaced in the wpdeploy Sync Health dashboard:

1. **Every site's `sync-lots` was stuck on `api_error`.** The plugin sends `last_modified` straight from `wp_bp_meta_data.lastUpdate`, which is a **Unix epoch string** (`"1756919754"`). wpdeploy's `lots.last_modified` column is a `DATETIME`, so MariaDB rejects the insert with `SQLSTATE[22007]: Incorrect datetime value`. The handler records `api_error` and the watermark never advances, so every cron tick re-fails on the same batch.

2. **Sites with nothing to sync looked dead.** When a sync handler had an empty batch (e.g. no non-admin users yet) it was returning early without POSTing anything. wpdeploy's `update_sync_health()` only fires on a successful POST, so `sites.sync_health_json` stayed NULL and the dashboard showed grey dots — indistinguishable from a broken cron. WP-Cron was actually firing fine; the watchdog just couldn't tell.

#### Fixes
- **`inc/sync/class-lots-sync.php`** — new `epoch_to_datetime()` helper converts the BidSpirit epoch field to UTC `Y-m-d H:i:s` before shipping. Idempotent: already-formatted datetimes pass through untouched, invalid values become `NULL`.
- **`inc/sync/class-sync-base.php::run()`** — always POST, even when the batch is empty. Empty payload is `{records: []}` and wpdeploy's existing handlers handle it gracefully (loop is skipped, `update_sync_health()` still fires with `synced=0`). This makes the dashboard reflect **cron liveness**, not just **data flow**.

#### Files Changed
- **Modified:** `inc/sync/class-lots-sync.php`
- **Modified:** `inc/sync/class-sync-base.php`
- **Modified:** `auctionforge.php` (Version 3.7.0.1 → 3.7.0.2)

#### Verification
1. On Clumber: `do_action('auctionforge_sync_lots')` — confirm the previous `auctionforge_last_sync-lots_status = api_error` flips to `success(N)` and that `wpdeploy.lots` now contains rows.
2. On a site with no non-admin users: `do_action('auctionforge_sync_user_profile')` — confirm `auctionforge_last_sync-user-profile_status = idle(0)` AND that wpdeploy's Sync Health page now shows a green dot for that cell.

---

## [3.7.0.1] — 2026-05-02

### Fix: marketing data leaks WordPress admins into the people list

#### Why
After the 3.7.0.0 rollout the central `people` view on wpdeploy was showing site administrators alongside real auction-house users — the `sync-user-profile` handler read straight from `wp_users` with no role filter, so any account with administrator/editor/author/contributor/shop_manager capabilities was getting shipped as if it were a buyer. Marketing audiences must contain buyers, not staff.

#### Fixes (`inc/sync/class-profile-sync.php`)
- The `read_batch()` SQL now LEFT JOINs `{$wpdb->usermeta}` on the prefix-aware capabilities key (`{$wpdb->prefix}capabilities`) and filters out any user whose serialized capabilities meta_value contains `administrator`, `editor`, `author`, `contributor`, `shop_manager`, or `super_admin`. Users with `IS NULL` (no roles registered) are still allowed through — those are typically subscribers/buyers on this fleet.
- Watermark on `auctionforge_profile_sync_watermark` is unchanged; the next run after upgrade picks up the same batch with the new filter applied. Operationally the wpdeploy team also wiped the existing admin rows from `user_profiles`, `user_interests`, `user_recent_activity`, `bids_wins`, and `people` to clear the leak server-side — total of 35 rows scrubbed.

#### Files Changed
- **Modified:** `inc/sync/class-profile-sync.php`
- **Modified:** `auctionforge.php` (Version 3.7.0.0 → 3.7.0.1)

#### Verification on Clumber dev
1. Find a logged-in admin: `SELECT u.user_email FROM wp_users u JOIN wp_usermeta um ON um.user_id=u.ID AND um.meta_key='wp_capabilities' WHERE um.meta_value LIKE '%administrator%';`
2. Reset the watermark: `DELETE FROM wp_options WHERE option_name='auctionforge_profile_sync_watermark';`
3. Trigger sync: `do_action('auctionforge_sync_user_profile');`
4. On wpdeploy: confirm those admin emails do NOT appear in `user_profiles` (`SELECT * FROM user_profiles WHERE email='admin@…';` returns zero rows).

#### Privacy / scope
- No API contract changes. The wpdeploy `/api.php/sync-user-profile` route accepts the same payload shape; this is purely a source-side filter narrowing what gets sent.

---

## [3.7.0.0] — 2026-05-02

### Cross-site user data lake — full Phase 2 + Phase 3 build

#### Why
AuctionForge sites were exposing per-lot engagement (views, favourites, bids, wins) keyed only on the anonymous `af_vid` localStorage hash, with no central aggregation. Marketing — even the "collect data now, send later" phase — needs a per-person view across the whole AF network: name + phone + postcode + country + IP + device fingerprint + the keywords they engage with + the bids they place + the lots they win, all rolled up across every site they've used. This release wires every piece needed for that, and keeps it sustainable: per-site aggregates rather than raw event firehose, watermarked syncs with retry, 14/30-day site-side retention, watchdog + Telegram alerts for dead streams.

See `/root/.claude/plans/i-want-to-add-synthetic-goose.md` for the full architecture.

#### New tables (idempotent dbDelta on the existing `plugins_loaded` migration path)
- **`wp_af_stats_lot_keywords`** — per-lot keyword cache. Title + tags + author tokenized (lowercase → drop stop-words → drop ≤2-char → drop pure-digit), one row per (post_id, keyword). Populated on `save_post_lot`.
- **`wp_af_user_interests`** — running tally per `(user_email, dimension, dimension_value)` where dimension ∈ `keyword | category | author | price_band`. UPSERT-keyed so increments are atomic. Bounded by users × distinct interests (typically a few dozen rows per user).
- **`wp_af_user_recent_activity`** — capped per-user activity log (last 50 events per email). Seeds the drill-down timeline on the wpdeploy person view.
- **`wp_af_bid_events`** — bid + win events (auto-created by the bids sync handler). Drained and shipped to wpdeploy hourly.
- **`synced_at` columns** added to `wp_af_stats_pageviews`, `wp_af_stats_sessions`, `wp_af_stats_searches`, `wp_af_stats_outbound`. Powers the durable sync-and-purge lifecycle.
- `af_stats_db_version` bumped 2 → **3**. The existing `AF_Stats_Init::maybe_create_tables()` picks up the new tables on the next admin page load after auto-update.

#### New helper classes
- **`AF_Keyword_Extractor`** (`inc/stats/class-keyword-extractor.php`) — pure tokenizer + cache writer + read-through helper. Hooks `save_post_lot` to refresh on lot changes. Stop-words tunable via `apply_filters('af_keyword_stop_words', ...)`.
- **`AF_User_Interests`** (`inc/stats/class-user-interests.php`) — `record_view / record_favorite / record_bid / record_win`. All idempotent UPSERTs. Calls `AF_Stats_Tracker::should_skip()` first so admins/bots never pollute interest profiles.
- **`AF_Sync_Base`** (`inc/sync/class-sync-base.php`) — abstract durability backbone. Reads `synced_at IS NULL` batches, POSTs, marks rows synced only on a confirmed 200, retries on next cron run if the POST fails. Idempotent UNIQUE keys on the wpdeploy side mean retries never duplicate.
- **`AF_Profile_Sync` / `AF_Interests_Sync` / `AF_Activity_Sync` / `AF_Lots_Sync` / `AF_Bids_Sync`** — five concrete sync streams.
- **`AF_Data_Purge`** (`inc/maintenance/class-data-purge.php`) — daily cron: 14-day floor on synced rows, 30-day hard cap with CRITICAL alert if any unsynced row hits the cap (which would mean wpdeploy has been unreachable for a month).

#### New cron schedules (auto-registered on plugins_loaded if `auctionforge_update_server_url` + `_api_key` are configured)
| Hook | Schedule | Endpoint |
|---|---|---|
| `auctionforge_sync_user_profile` | hourly | `POST /api.php/sync-user-profile` |
| `auctionforge_sync_user_interests` | hourly | `POST /api.php/sync-user-interests` |
| `auctionforge_sync_user_activity` | hourly | `POST /api.php/sync-user-activity` |
| `auctionforge_sync_lots` | every 10 min | `POST /api.php/sync-lots` |
| `auctionforge_sync_bids_wins` | hourly | `POST /api.php/sync-bids-wins` |
| `auctionforge_purge_synced_data` | daily | (purge cron — local) |

All sync handlers are watermarked or `synced_at`-gated; cron mid-run failure is harmless because the next run picks up the same rows.

#### Hooks fired
- `af_stats_pageview_recorded($url, $email, $visitor_hash)` — fires inside `AF_Stats_Ajax::handle_track()` after a logged-in pageview is recorded. Default listener resolves URL → lot post → `AF_User_Interests::record_view()`.
- `af_stats_lot_favorited($email, $post_id)` — fires from the existing `lot_analytics_record_favorite` AJAX handler when a watchlist add happens. Default listener calls `record_favorite()`.

#### Server-side companion (wpdeploy)
This release is paired with wpdeploy changes (handled out-of-band; not in the plugin zip):
- **5 new API routes** matching the sync streams above. All return `synced` + watermark metadata so the plugin can advance / mark rows synced.
- **`update_sync_health()` helper** in wpdeploy — every successful sync POST writes `(stream, last_seen_at, last_synced_count, state)` into `sites.sync_health_json`.
- **`SyncWatchdog`** — 5-minute cron on wpdeploy that scans every (site × stream) cell and runs an `ok → overdue → gone → recovering → ok` state machine. Emits alerts via the existing `sync_incidents` Telegram fan-out.
- **People section** in the wpdeploy admin — `?tab=people` (cross-site rollup with top keywords / lifetime spend), `?tab=person&email=...` (drill-down with timeline + bids), `?tab=people-export` (Google Customer Match + Meta Custom Audience CSV with sha256 hashing per spec).
- **`?tab=sync-health`** — site × stream traffic-light grid showing every sync's state.

#### Files Changed
- **Created:** `inc/stats/class-keyword-extractor.php`, `inc/stats/class-user-interests.php`, `inc/stats/stop-words.php`
- **Created:** `inc/sync/class-sync-base.php`, `inc/sync/class-profile-sync.php`, `inc/sync/class-interests-sync.php`, `inc/sync/class-activity-sync.php`, `inc/sync/class-lots-sync.php`, `inc/sync/class-bids-sync.php`
- **Created:** `inc/maintenance/class-data-purge.php`
- **Modified:** `inc/stats/class-stats-database.php` (DB_VERSION = 3, new dbDelta calls, synced_at column adds)
- **Modified:** `inc/stats/class-stats-init.php` (require new classes, register sync crons, wire `af_stats_pageview_recorded` and `af_stats_lot_favorited` listeners)
- **Modified:** `inc/stats/class-stats-ajax.php` (fire `af_stats_pageview_recorded` after pageview record; fire `af_stats_lot_favorited` on watchlist add)
- **Modified:** `auctionforge.php` (Version: 3.6.4.0 → 3.7.0.0)

#### Verification on Clumber dev
1. After auto-update + first wp-admin page load: `wp_af_stats_lot_keywords`, `wp_af_user_interests`, `wp_af_user_recent_activity` all exist; `af_stats_db_version = 3`.
2. Save a test lot titled `"Pair of Victorian silver candlesticks by Mappin & Webb"` → `wp_af_stats_lot_keywords` has rows for `victorian, silver, candlesticks, mappin, webb` (and **not** `pair, of, by`).
3. Log in as throwaway test user, view 3 of those lots → `wp_af_user_interests` shows `(test@…, keyword, 'victorian', view_count=3)`.
4. Wait one hourly cron, confirm `wpdeploy.user_interests` mirror via the wpdeploy admin → `?tab=people` → search for the test email.
5. Confirm `wpdeploy.sites.sync_health_json` has `user-interests.state = 'ok'` for the Clumber site.
6. Pause the cron, wait 2h, watch the watchdog flip the cell to `overdue` and emit a Telegram alert.

#### Privacy / scope
- Admins and bots are filtered out at write time via `AF_Stats_Tracker::should_skip()`.
- Email is normalized lowercase + trim before storage; phone normalized to E.164 (UK-aware) on the sync side.
- No outbound activation yet — wpdeploy stores everything but doesn't send anything to anyone. Marketing send mechanics (consent flag, suppression list, sender) explicitly deferred.

---

## [3.6.4.0] — 2026-05-02

### Identity stitching: visitor_hash → user_email

#### Why
The `af_stats_*` tables (`pageviews`, `sessions`, `searches`, `outbound`) and the lot-engagement tables (`af_lot_analytics`, `af_lot_shares`) currently store activity keyed only on the anonymous `af_vid` localStorage hash — there is no link from any browsing event to the user's email even when they're logged in. Without that link, the central wpdeploy server can never aggregate behavioural data on a per-person basis, which is the prerequisite for the cross-site user-data lake being built on wpdeploy (see `/root/.claude/plans/i-want-to-add-synthetic-goose.md` Phase 1).

This release adds the bridge: every authenticated tracker hit records a `(visitor_hash, user_email)` pair into a new table. From here on, anonymous browsing can be resolved to an email via JOIN for any session the user was logged in.

#### Changes
- **New table `wp_af_stats_visitor_emails`** — `(visitor_hash, user_email, ip_address, first_seen, last_seen)` with `UNIQUE(visitor_hash, user_email)` + index on `user_email`. Created via `dbDelta` inside the existing `AF_Stats_Database::create_tables()` so it ships through the standard `plugins_loaded` migration path — no manual reactivation needed after auto-update.
- **`af_stats_db_version` bumped 1 → 2.** `AF_Stats_Init::maybe_create_tables()` now compares against the new `AF_Stats_Database::DB_VERSION` constant rather than a hard-coded `< 1`, so future schema bumps (Phase 2 onwards) just require constant edits + new dbDelta calls.
- **Identity record on every authenticated tracker hit.** `AF_Stats_Ajax::handle_track()` now calls `AF_Stats_Visitor_Email_Map::record($visitor_hash, $email, $ip)` whenever `is_user_logged_in()` is true. Trust boundary: the email is read server-side from `wp_get_current_user()` — clients cannot assert who they are.
- **Defensive table-exists guard.** The new helper short-circuits silently if the migration hasn't run yet on a given site (race window on the very first request after auto-update); the next request finds the table and proceeds. No fatals, no missing rows.
- **Email normalised at write time** — lowercase + trim + `is_email()` validation before storage. Marketing audience match-rates downstream depend on consistent casing.

#### Files Changed
- **Created:** `inc/stats/class-visitor-email-map.php`
- **Modified:** `inc/stats/class-stats-database.php` (DB_VERSION constant; new table; drop_tables list)
- **Modified:** `inc/stats/class-stats-init.php` (require new helper; migration check uses constant)
- **Modified:** `inc/stats/class-stats-ajax.php` (record pair in `handle_track`)
- **Modified:** `auctionforge.php` (Version header bump)

#### Verification on Clumber dev
1. After auto-update, load any `wp-admin/*` page once → `wp_af_stats_visitor_emails` exists, `af_stats_db_version = 2`.
2. Log in as a test user, browse 5 lots → exactly one row in `wp_af_stats_visitor_emails` for that `(visitor_hash, email)` pair, with `last_seen` updating on each hit.
3. `SELECT email FROM wp_af_stats_visitor_emails WHERE visitor_hash IN (SELECT visitor_hash FROM wp_af_stats_pageviews WHERE created_at > NOW() - INTERVAL 1 HOUR)` returns the test email — proving the JOIN works.

#### Privacy / scope
- This change writes a `(visitor_hash, email)` pair to a single new table on the AF site. Nothing is sent to wpdeploy yet — the sync stream lands in Phase 2.
- Anonymous (logged-out) browsing is unaffected: no row is created until the user authenticates.
- Bots are still skipped via `AF_Stats_Tracker::is_bot()` before any tracking writes.

---

## [3.6.3.1] — 2026-05-01

### Fix: New lots never processed + Live console improvements

#### Why
When BidSpirit adds new lots to an auction (e.g. items 683-692), the sync pipeline correctly staged all 709 lots into `import_history` but the InsertLotsBS processing loop exited after the first batch of 500 unchanged lots, never reaching the genuinely new lots at the tail of the queue. This caused new lots to remain permanently stuck in staging — synced but never created as WordPress posts.

Separately, the admin Live Console was replaying the entire log history from the beginning on every page load, and showed long silent gaps during API calls with no indication of what the worker was doing.

#### Fixes
- **InsertLotsBS loop break condition** — The stage 5 insert loop in `CatalogSyncWorker::process()` and `processDayEnded()` previously broke when `updated lots === 0`, even if the batch had cleared hundreds of unchanged ("unupdated") lots from the queue. New lots with higher IDs were never reached. Now breaks only when both `updated lots` and `unupdated lots` are 0, meaning the staging queue is truly empty.
- **Live Console starts from current position** — `consoleSinceId` was hardcoded to `0`, causing the console to page through the entire `af_sync_log` table (50 rows at a time) before reaching real-time entries. Now initializes to `SyncLogger::getLatestId()` so only entries created after page load appear.
- **`queueIsActive` declaration order** — Variable was declared after functions that referenced it, causing the console to always start at the slow 3s polling rate even when a worker was active. Moved declaration before first use.
- **Progress logging during API calls** — `LotList::auctionProcessing()` made 2-3 BidSpirit API calls with zero `SyncLogger` entries, creating long silent gaps in the console. Added log entries for API fetch start, response received (with duration), data staged, and API errors.
- **Per-auction progress in bulk runs** — `LotList::run()` now logs `Processing auction X/Y: {id}` for each auction in the bulk loop, and logs skipped auctions.
- **`processDayEnded` insert loop logging** — Added batch progress logging and timeout guard matching the `process()` method pattern. Previously this loop was completely silent.

#### Files Changed
- **Modified:** `inc/sync/CatalogSyncWorker.php`, `inc/import/LotList.php`, `admin/view/sync-status.php`

---

## [3.6.3.0] — 2026-05-01

### Public Auctions API — Token Authentication

#### Why
The `/wp-json/auctionforge/v1/public/auctions` endpoint added in v3.6.2.9 was publicly accessible. Since auction data should only be served to authorized aggregator sites, the endpoint now requires a valid auth token.

#### Changes
- **Token verification** — The `PublicAuctions` REST endpoint now validates an `X-AF-Token` header (or `?token=` query parameter) against the site's existing **Webhook auth token** from AuctionForge settings.
- Returns `401 Unauthorized` for missing/invalid tokens and `403 Forbidden` if no auth token is configured on the site.
- Uses `hash_equals()` for timing-safe comparison.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/api/PublicAuctions.php`

---

## [3.6.2.9] — 2026-05-01

### Public Auctions REST API Endpoint

#### Features
- **New REST endpoint** `GET /wp-json/auctionforge/v1/public/auctions` — Exposes auction list data (title, date, image, state, catalog link) for consumption by the AuctionForge Hub plugin on parent/main sites.
- **Parameters:** `type` (upcoming or past, default: upcoming), `limit` (max results, default: 50).
- Handles multi-day auctions with correct part numbers and catalog links.
- Returns auction metadata including `auction_id`, `source_site`, and `day_index` for deduplication.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/coreIncludes.php`
- **Created:** `inc/api/PublicAuctions.php`

---

## [3.6.2.8] — 2026-04-30

### Suppress alerts for transient retry conditions

#### Why
The "BidSpirit API returned 0 lots — catalog may not be ready yet" message is logged when the BidSpirit webhook fires before the auction catalog is fully published. The safety cron retries the job automatically and it usually succeeds on the next attempt — there's no operator action required, so a Telegram alert is just noise.

#### Fix
- New private helper `SyncLogger::isSuppressedFromAlerts($message, $context)` matches the message against a substring allowlist of known-transient errors. When matched, the row is still inserted into `af_sync_log` (so the in-app sync console still shows the retry chain) but the `auctionforge_sync_error_logged` fan-out action does NOT fire — wpdeploy and Telegram stay quiet.
- Default suppression substrings: `"BidSpirit API returned 0 lots"`, `"catalog may not be ready yet"`.
- Extendable via the new `auctionforge_sync_alert_suppress_patterns` filter — themes/MU plugins can add their own substrings.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/sync/SyncLogger.php`

---

## [3.6.2.7] — 2026-04-30

### Typo fix
- Settings → Connections tab: the placeholder/help text under the **Server** field showed `https://my-accaunt.bidspirit.com/` (extra "a"). Now reads `https://my-account.bidspirit.com/`.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/controllers/Settings.php`

---

## [3.6.2.6] — 2026-04-30

### Fix stale `plugin_version` in reports

#### Why
Sites were reporting their old version to wpdeploy long after they'd been upgraded — e.g. Lonsdale Auctioneers ran v3.6.1.1 but kept telling wpdeploy it was on v2.9.6. Root cause: the constant `AUCTIONFORGE_VERSION` is read from the plugin file's `Version:` header *once at plugin load*, then frozen into `$this->current_version` on the auto-updater instance. After a plugin upgrade replaces the file on disk, existing PHP-FPM workers keep using the cached constant value until they're recycled, so every report from those workers carries the old version.

#### Fix
- New helper `AuctionForge_Auto_Updater::get_live_version()` reads the version directly from the plugin file's header on each call (via `get_file_data()` — uncached).
- `report_health`, `push_sync_error_immediate`, and `retry_sync_errors` now send `get_live_version()` instead of the cached constant.
- The pre-existing iteration through active plugins inside `report_health` already used `get_plugin_data()` per plugin, so secondary plugin entries were already accurate; only the top-level `plugin_version` field was stale.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/class-auto-updater.php`

---

## [3.6.2.5] — 2026-04-30

### Real diagnostics for plugin self-updates

#### Why
The wpdeploy `update_queue` table had a 46% historical failure rate, but every single failure row stored the literal string `"Installation failed"` — collapsing every failure mode into one opaque error with no diagnostic value. Root cause: the `install_update()` method in both clients (AF auto-updater and wpd-client) treated `Plugin_Upgrader::upgrade()` as a boolean, throwing away `WP_Error` objects and skin-emitted error messages.

#### Fixes (`inc/class-auto-updater.php` `install_update()`)
- **Refreshes the `update_plugins` transient before invoking the upgrader.** The upgrader reads from this transient to know what version to download; if WP hasn't checked recently, the upgrader silently returns false (looking like a failure even though there's a real update queued at wpdeploy).
- **Distinguishes `WP_Error`, `false`/`null`, and success.** `WP_Error` was previously treated as truthy → reported as "completed" even on failure. Now extracts the error code and message.
- **Reads `Automatic_Upgrader_Skin::get_errors()`** to surface the real upgrader-emitted error messages.
- **Reads `$upgrader->result`** which holds the final `WP_Error` for some failure paths.
- **Captures PHP errors/warnings emitted during the upgrade** (file-move issues, etc.) via a temporary `set_error_handler`.
- **Detects "no update in transient"** as a distinct failure cause and labels it explicitly so we know it's a transient-cache problem, not an actual install failure.
- Truncates the combined diagnostic to 1000 chars before sending.

The same fix is applied to `wpd-client.php` (the WPD client plugin shipped from `client-plugin/wpd-client.php` on the wpdeploy server).

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/class-auto-updater.php`

---

## [3.6.2.4] — 2026-04-30

### Bulk-sync worker dispatch fix

#### Fixes
- The HTTP-loopback worker (`auctionforge_rest_sync_worker` in `auctionforge.php`) had its own switch statement for job-type dispatch, distinct from the one in `crontasks/syncWorker.php`. The new `full_sync_upcoming` and `full_sync_past` job types were only added to the CLI worker, so the loopback path produced "Unknown job type" errors. Both switches now route those types to `BulkSyncWorker::process`.
- `inc/coreIncludes.php` now `require_once`s `BulkSyncWorker.php` so the class is available wherever sync code runs.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/coreIncludes.php`

---

## [3.6.2.3] — 2026-04-30

### Fixes for end-to-end Telegram round-trip

#### Fixes
- **`verify_api_key` reads the option fresh on every call** (`hash_equals` against `get_option('auctionforge_update_api_key')`) instead of relying on the cached `$this->api_key` from constructor time. Different FPM workers can hold different cached values when the option drifts (e.g. via auto-heal collisions), which previously produced intermittent 401s on REST endpoints.
- **New REST endpoint `POST /wp-json/auctionforge/v1/sync/version`** — wraps the existing `trigger-install` flow with a JSON response (always HTTP 200 with `success` flag). The original endpoint returned `WP_Error('no_update', …, status: 404)` when the plugin was already on the latest version, which nginx's `error_page 404` directive then clobbered with a static HTML 404 page on hosts using aaPanel/BTWAF. Keeping the URL under `/sync/*` matches the pattern that's already proven to pass through host WAFs.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/class-auto-updater.php`

---

## [3.6.2.2] — 2026-04-29

### Catalog-sync kind disambiguation

#### Fix
- `catalog_sync` jobs target a single auction that may be either active or archived. Until now their alerts were always classified as `kind=upcoming`. The classifier now resolves the auction's `auction_state` term meta and routes:
  - `ARCHIVED` / `ENDED` / `POST_AUCTION` → `kind=past` → **Kill + Full Past** buttons
  - anything else (or unresolvable) → `kind=upcoming` → **Kill + Full Upcoming** buttons

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/class-auto-updater.php`

---

## [3.6.2.1] — 2026-04-29

### Telegram Sync Monitoring follow-ups

#### Features
- **Context-aware alert buttons.** Each Telegram alert now shows the action set most relevant to the failing sync: upcoming-sync errors get **Kill + Full Upcoming**, past-sync errors get **Kill + Full Past**, everything else gets **Kill** only. Button choice is driven by a new `kind` field classified at log-write time from the queue's `job_type`.
- **Plugin version in alerts + Update button.** Each push now includes the live `plugin_version`. wpdeploy compares it to the latest `is_stable=1` row in its `versions` table and only renders an **Update plugin** button when the site is behind. Tapping Update calls the existing `POST /wp-json/auctionforge/v1/trigger-install` endpoint to force the upgrade.

#### Architecture
- New private helper `AuctionForge_Auto_Updater::classify_error_kind($jobId)` looks up the job's `job_type` and maps it to `'upcoming'` / `'past'` / `'other'`. Used by both the immediate push and the 1-min retry cron.
- Outbound payloads now include `plugin_version` at the top level; wpdeploy refreshes `sites.plugin_version` on every `/sync-errors` POST.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/class-auto-updater.php`

---

## [3.6.2.0] — 2026-04-29

### Telegram Sync Monitoring & Remote Control

#### Features
- **Instant Telegram alerts on sync errors.** Any `level='error'` row written to `af_sync_log` is pushed (fire-and-forget, non-blocking) to wpdeploy, which fans it out to a configured Telegram group with inline-keyboard action buttons (Kill / Full Upcoming / Full Past / Acknowledge).
- **Remote command surface.** Any group admin can tap a button to:
  - **Kill sync** — flips DB flags via `SyncQueue::cancelAll()`, then SIGTERMs every active worker; a 5-second watchdog escalates to SIGKILL for any survivor.
  - **Full sync upcoming** — full re-import of every active auction and its lots (queued, observable, killable). Replaces the `re-sync?do=importBidSpiritUpcoming` flow with a queued equivalent.
  - **Full sync past** — same, for archived auctions.
- **Safety-net retry cron** (`auctionforge_retry_sync_errors`, every minute) re-pushes recent error rows. wpdeploy dedupes via `UNIQUE(site_id, af_log_id)` so duplicates are silent.

#### Architecture
- **`SyncLogger::log()`** now fires `do_action('auctionforge_sync_error_logged', …)` on `level='error'` so any handler can react. The auto-updater listener does a 2 s non-blocking POST to `/api.php/sync-errors`.
- **New REST endpoint** `POST /wp-json/auctionforge/v1/sync/cancel-all` (X-API-Key auth) — runs `SyncQueue::cancelAll()` + SIGTERM + watchdog. Logs every action through `SyncLogger`.
- **`POST /sync/start` extended** with a `mode` parameter (`upcoming` default, `past` for archived). Bulk re-sync is now a queued job, not a synchronous block.
- **New job types** `full_sync_upcoming` and `full_sync_past` on `SyncQueue`, dispatched to a new `BulkSyncWorker` that wraps the existing `AuctionList::init()` + `LotList::init($upcoming)` pipeline.
- **`syncWorker.php`** installs a SIGTERM handler that flips the in-flight job to `failed` with a "Killed by admin (SIGTERM, cleanup OK)" marker and closes the DB cleanly before exiting. Watchdog escalates to SIGKILL after 5 s grace.
- **`LotList::auctionProcessing()`** now heartbeats the active bulk-sync job between auctions, preventing the safety cron from auto-failing long bulk runs as stale (the 600 s threshold is unchanged).

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/sync/SyncLogger.php`, `inc/sync/SyncQueue.php`, `inc/class-auto-updater.php`, `crontasks/syncWorker.php`, `inc/import/LotList.php`
- **Created:** `inc/sync/BulkSyncWorker.php`

---

## [3.6.1.6] — 2026-04-29

### High-Value Lot Modes for `[bp_lot_list]`

#### Features
- **`high_value_past` mode** — Displays past auction lots ordered by their catalog estimate (`estimatedPriceInt`), highest first. Useful for "Top Lots" or "Highlights" sections.
- **`high_value_sold` mode** — Displays past sold lots ordered by their actual sold price (`soldBid`), highest first. Only includes genuinely sold lots (`soldBid > 0`) and reuses the same false-positive guard as `random_past` to skip lots where `soldBid` was carried over from `startPrice` without real bidding activity.
- **Admin documentation updated** — The Shortcodes reference page in WP Admin now lists both new modes in the `mode` attribute description and includes example shortcode usage.

#### Architecture
- New constants `MODE_HIGH_VALUE_PAST` and `MODE_HIGH_VALUE_SOLD` on `LotsWidget`.
- New method `LotsWidget::getLotListHighValue($mode, $limit)` builds the query, applies the past-auction tax filter, sets `bp_orderby` to the relevant meta field with `DESC` order, and (for the sold variant) filters out unsold-but-stale lots in PHP.
- New filter hook `bp_lot_list_high_value_args_filter` for downstream customization of the WP_Query args.

#### Files Changed
- **Modified:** `inc/widgets/LotsWidget.php`, `inc/controllers/ShortcodesPage.php`

---

## [3.6.0.18] — 2026-04-25

### Brevo SMS Notifications Integration

#### Features
- **Transactional SMS via Brevo API** — The plugin now sends text message alerts through the Brevo transactional SMS service, running alongside existing Firebase push notifications.
- **Four Notification Types:**
  - **New Auction Published** — Broadcast SMS to all opted-in subscribers when an auction is published.
  - **Auction Starting Soon** — Targeted SMS to users registered for an auction when it is about to start.
  - **Favorited Item Live Soon** — Targeted SMS to users who favorited a lot when it is approaching in the auction.
  - **Outbid Notice** — SMS sent when a user's leading bid is surpassed, with a 5-minute per-item cooldown to prevent spam.
- **SMS Subscriber Database** — New `af_sms_subscribers` table stores user phone numbers, country codes, and per-notification-type opt-in preferences (GDPR-ready explicit consent).
- **Admin Settings Tab** — New "SMS Notifications" tab in Settings with:
  - Brevo API Key input
  - SMS Sender Name (11 char max)
  - Master enable/disable toggle
  - Per-notification-type on/off switches
  - Test SMS widget with phone input and send button
  - Live subscriber count display
- **Profile SMS Preferences** — Users can opt into SMS notifications directly from their profile page with phone number entry and granular notification type selection.

#### Architecture
- **BrevoSMS Service** (`inc/services/BrevoSMS.php`) — cURL-based API wrapper with broadcast, targeted, and single-send methods, message builders with 160-char limit awareness, rate limiting, and comprehensive error logging.
- **SMSSubscriber Model** (`inc/model/SMSSubscriber.php`) — CRUD operations, email-to-phone resolution, opt-in filtering, and upsert logic.
- **Webhook Integration** — SMS dispatch hooks added to `AuctionsAPI.php` methods: `leading_bid_surpassed()`, `item_live_soon()`, `auction_starts_soon()`, and `auctionPublishedPush()`.

#### AJAX Handlers
- `auctionforge_sms_save` — Save SMS preferences from profile page
- `auctionforge_sms_load` — Load SMS preferences for profile page
- `auctionforge_sms_test` — Admin-only test SMS endpoint

#### Database
- New table: `af_sms_subscribers` — Phone numbers, country codes, per-notification opt-ins, timestamps

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/coreIncludes.php`, `inc/controllers/AuctionsAPI.php`, `inc/controllers/Settings.php`, `admin/view/settings.php`, `templates/parts/profile-templates.php`
- **Created:** `inc/services/BrevoSMS.php`, `inc/model/SMSSubscriber.php`

---

## [3.6.0.7] — 2026-04-14

### Hotfix

#### Fixes
- **Namespace resolution** — Fixed fatal error in `wp_footer` commission label hook caused by unqualified `SettingsHelper` and `Settings` class references. Now uses fully qualified namespaces.

#### Files Changed
- **Modified:** `auctionforge.php`

---

## [3.6.0.6] — 2026-04-14

### Commission Label Override Fix

#### Fixes
- **Theme template override compatibility** — Moved the commission label JS override from the `single-lot.php` template into `auctionforge.php` via `wp_footer` hook (priority 99). Sites with theme-level template overrides (e.g. `themes/*/auctionforge/single-lot.php`) were not picking up the inline script. The `wp_footer` approach works universally on all single lot pages.

#### Files Changed
- **Modified:** `auctionforge.php`, `templates/single-lot.php`

---

## [3.6.0.5] — 2026-04-14

### Commission Label Override

#### Features
- **Commission Label Setting** — New dropdown in Settings → Appearance allowing admins to choose between "Buyer's Premium" (default) and "Auction House Commission" as the label displayed on lot pages. This overrides the Bidspirit JS client's default label using a MutationObserver that catches asynchronously injected text.

#### Files Changed
- **Modified:** `inc/controllers/Settings.php`, `templates/single-lot.php`

---

## [3.6.0.4] — 2026-04-12

### Auction Approval Modal Redesign

#### Changes
- **Removed BidSpirit Terms of Use** — The generic BidSpirit "Terms of Use" section that appeared at the top of the non-credit-card approval modal has been removed. The modal now shows only the auction house's own legal agreements managed within the plugin.
- **Renamed Modal Title** — Changed "Terms of conditions" to "Terms & Conditions".
- **Larger Scroll Area** — The Terms & Conditions scroll box is now 350px tall (up from 200px) for easier reading.
- **Proportional Section Heights** — Refund & Return Policy and Chargeback Waiver sections use a shorter 260px scroll box to visually distinguish them from the primary Terms & Conditions.

#### Files Changed
- **Modified:** `templates/js-chunks/approval-info.php`, `assets/js/app.js`

---

## [3.6.0.2] — 2026-04-10

### Webhook Token Generator

#### Features
- **Token Generator Widget** — Added a 16-character random token generator directly below the "Webhook auth token" field on the Settings page. Includes a "Generate Token" button and a clipboard copy icon with visual feedback.

#### Files Changed
- **Modified:** `inc/controllers/Settings.php`

---

## [3.6.0.1] — 2026-04-10

### Patch

#### Changes
- Version bump.

---

## [3.6.0.0] — 2026-04-10

### Legal Agreements for Auction Approval

#### Features
- **Terms & Conditions** — A mandatory, scrollable Terms & Conditions document is now displayed during the auction approval process. Bidders must scroll to the bottom and check an agreement box before their approval request is submitted. Always enabled.
- **Refund & Return Policy** — An optional, scrollable Refund & Return Policy can be enabled in Settings. When enabled, bidders must read and accept it during auction approval.
- **Chargeback Waiver** — An optional, scrollable Chargeback Waiver can be enabled in Settings. When enabled, bidders must read and accept it during auction approval.
- **Admin Management** — New "Legal Agreements" page under the Bidspirit admin menu with tabbed TinyMCE editors for each agreement type. Includes automatic version numbering (incremented on content change) and full version history.
- **Consent Logging** — Every agreement acceptance is logged to a dedicated database table (`af_user_agreement_log`) with the agreement type, version, user email, IP address, user agent, auction ID, and timestamp.
- **User Tracking Integration** — Agreement events (`terms_agreement`, `refund_agreement`, `chargeback_waiver`) are also recorded in the existing IP tracking system alongside registration, login, and bid events.
- **Placeholder Content** — Default sample content is seeded on plugin activation for all three agreement types.

#### Settings Added
- **Enable Refund & Return Policy** — Toggle to require bidders to accept the refund policy during auction approval.
- **Enable Chargeback Waiver** — Toggle to require bidders to accept the chargeback waiver during auction approval.

#### Database
- New table: `af_legal_agreements` — Current active agreement content per type
- New table: `af_legal_agreement_versions` — Version history archive
- New table: `af_user_agreement_log` — User consent records

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/controllers/Settings.php`, `inc/controllers/BidspiritViews.php`, `inc/ip-logger.php`, `templates/js-chunks/approval-info.php`, `assets/js/app.js`
- **Created:** `inc/legal/class-legal-agreements.php`, `admin/view/legal-agreements.php`

---

## [3.5.0.6] — 2026-04-08

### Bid Event Tracking

#### Features
- **Bid Tracking** — All successful bids (regular, catalog, pre-sale buyout, post-sale purchase) now fire `afLogIP("bid", bidspiritUser)` to capture the bidder's full identity in the user tracking table.
- **Whitelist Update** — Added `bid` to the allowed `event_type` values in both the local plugin and the central WPDeploy API.

---

## [3.5.0.5] — 2026-04-08

### Self-Healing Database Schema

#### Features
- **Auto-Migration** — The plugin now auto-detects and adds any missing tracking table columns on version upgrade, ensuring sites with older schemas seamlessly migrate without manual SQL.

---

## [3.5.0.4] — 2026-04-08

### API Key Auto-Healing

#### Features
- **Seamless Self-Registration** — The WordPress plugin now autonomously regenerates and saves a fresh API Key whenever WPDeploy rejects a payload with an `api_key_collision` error. This guarantees zero-downtime security handovers when developers clone instances to proxy/staging servers.

---

## [3.5.0.3] — 2026-04-08

### API Key Collision Defense

#### Features
- **Network-Level Quarantine** — Central API dynamically extracts and strictly validates the incoming payload domain against the registered API Key domain, forcefully blocking mismatched traffic (HTTP 403).
- **Local Critical Alert Banner** — Added a persistent crimson warning banner in the WordPress admin panel that intercepts `api_key_collision` errors, directly instructing developers to update cloned keys.

---

## [3.5.0.2] — 2026-04-08

### Modal Trigger Hotfix

#### Fixes
- Restructured internal UI rendering priority to gracefully deploy the inline Modal triggers inside custom IP log view without colliding with cached `z-index` stacks.

---

## [3.5.0.1] — 2026-04-08

### Hotfix

#### Fixes
- **Timed Auction Badge Visibility** — Fixed a strict type-matching bug in the main `auction_list.php` core file that incorrectly hid the "Timed Auction" badge overlay from valid timed/online auctions on the main upcoming/archive views. The badge now displays properly across all manual and Bidspirit-synced objects when toggled on.

#### Files Changed
- **Modified:** `templates/main/auction_list.php`

---

## [3.2.9.1] — 2026-03-30

### Timed Auction Display Enhancements

#### Features
- **Timed Auction Countdown Text Toggle** — Added a WP-Admin setting "Change text to 'Auction will close in'" that conditionally changes the countdown label for timed/online auctions on single lot pages.
- **Shortcode Text Toggle Support** — Extended the new countdown text logic to the `bp_upcoming_wide` shortcodes so "Live bidding starts in:" successfully transitions to "Auction will close in:" for timed auctions.

#### Files Changed
- **Modified:** `inc/controllers/Settings.php`, `templates/single-lot.php`, `templates/shortcodes/upcoming-auction-wide.php`, `templates/shortcodes/upcoming-auction-wide-manual.php`

---

## [3.2.9.0] — 2026-03-29

### Billing & Invoices Polish

#### Fixes
- **Profile Invoice ID Tracking** — Prevented internal auction codes (e.g. `002` or `ai4`) from breaking into the clean hyphenated titles inside the `profile-templates.php` view.
- **Dynamic Payment Statuses** — Replaced Bidspirit's default/cached `NOT PAID` HTML payloads by wiring the `dompdf` generator to directly respect the current `$b.status` string from the API (resolving instances where a paid invoice still printed "NOT PAID" atop the document).
- **Payment Instructions Conditional** — Automatically hide the "Payment Instructions" block from the PDF footer when an invoice is fully paid.
- **Improved Nomenclature** — Changed "Invoice No:" to "Bidder No:" on document output to prevent confusion with static invoice ledger IDs.

---

## [3.2.6.0] — 2026-03-28

### Multi-Day Auction Lot Filtering Fix

#### Fixes
- **Day 2+ showing lots from Day 1 in multi-day auctions** — The `itemIndex` column in `bp_meta_data` is `varchar(55)`, but the day-range filter in `BidspiritQuery::bp_query_where()` was comparing it as a string. With string comparison, lot numbers like `'11'`, `'101'`–`'145'` incorrectly fell between `'1001'` and `'1450'`, causing Day 1 lots to leak into Day 2 results. Fixed by using `CAST(bpMeta.itemIndex AS UNSIGNED)` for numeric comparison and `intval()` on the PHP-side boundary values. Letter lots (e.g. `14A`, `1025A`) are handled correctly — `CAST` strips the suffix, keeping them grouped with their base lot number.

#### Files Changed
- **Modified:** `inc/controllers/BidspiritQuery.php`

---

## [3.2.5.9] — 2026-03-27

### Catalog Page Fix

#### Fixes
- **Duplicate preview hours on catalog pages** — The `displayHours` field was rendered twice: once via the `bp_after_description_auction` hook in `BidspiritViews.php` and once inline in the auction template. Removed the hook-based rendering to eliminate the duplication.

#### Files Changed
- **Modified:** `inc/controllers/BidspiritViews.php`, `templates/main/auction.php`

---

## [3.2.5] — 2026-03-19

### Sync Reliability & Stuck Lot Prevention

#### Fixes
- **CATALOG_PUBLISHED webhook now triggers catalog sync** — Previously only sent push notifications without syncing lots. When the earlier AUCTION_CATALOG_UPDATED fired before the catalog was ready on BidSpirit, lots were never imported. CATALOG_PUBLISHED now calls `auctionCatalogUpdated()` to trigger a full catalog sync when the catalog is actually available.
- **0-lot API results now fail instead of silently succeeding** — When BidSpirit API returns 0 lots (catalog not yet ready), the sync job now fails with an explicit error message, allowing the next webhook to create a fresh retry job.
- **Expanded CDN allowlist in pre_http_request filter** — The HTTP filter during bulk post insertion now allows all `*.bidspirit.com` subdomains and BidSpirit image CDNs (`fastly.net`, `cloudfront.net`). Previously only `bidspirit.com` and `www.bidspirit.com` were allowed, blocking image downloads during sync and causing worker hangs.
- **InsertLotsBS timeout guard** — Stage 5 (InsertLotsBS processing loop) now has a 180-second timeout and logs progress every 2 batches. Prevents indefinite hangs that previously required manual force-kill.
- **fixResult() no longer marks lots as done when postId=0** — When `lotProcessing()` fails to create a WP post, the staging row now keeps `syncFinished=0` so the lot is retried on the next sync pass, instead of being permanently marked as complete with no actual post.
- **lotProcessing() logs error on missing auction term** — Previously returned 0 silently when `Auction::findAuctionById()` failed. Now logs an explicit error that feeds into `fixResult()` to keep the lot retryable.
- **Hourly orphan detection in safety cron** — New step in `syncSafety.php` detects lot posts that exist in `bp_meta_data` but are missing their auction taxonomy assignment (caused by workers killed mid-batch) and auto-repairs them with `wp_set_object_terms()`.

#### Files Changed
- **Modified:** `inc/controllers/AuctionsAPI.php`, `inc/sync/CatalogSyncWorker.php`, `inc/abs/InsertLotsAbstract.php`, `inc/import/InsertLotsBS.php`, `crontasks/syncSafety.php`

---

## [3.2.4] — 2026-03-18

### Sync Worker Hang Fix

#### Fixes
- **Sync workers hanging at InsertLotsBS stage** — Third-party plugins (Smush Pro, SmartCrawl SEO, WPMU DEV Updates) hook into `wp_insert_post()` and make blocking outbound HTTPS calls for every lot processed. With 489+ lots per auction, this caused workers to hang indefinitely on socket reads to WPMU DEV servers (`32.193.94.25`, `32.193.93.106`). Added `WP_IMPORTING` constant at the REST worker entry point and a `pre_http_request` filter in `CatalogSyncWorker` that blocks all non-essential external HTTP during the batch post-insertion phase. Loopback (`127.0.0.1`, `localhost`) and BidSpirit API calls remain allowed.
- **Stuck staging rows after force-kill** — When workers were force-killed mid-sync, staging rows in `import_history` were left with `syncstart=1` and never unblocked. Subsequent sync runs could not see these rows (filtered out by `Lot::getRawLots()`), causing the InsertLotsBS loop to exit immediately with 0 posts processed. Combined with job deduplication (`SyncQueue::enqueue()` skips if a `processing` job exists), this created a deadlock where no new sync could proceed.

#### Files Changed
- **Modified:** `auctionforge.php`, `inc/sync/CatalogSyncWorker.php`

---

## [3.2.2] — 2026-03-17

### Full Sync Auction Discovery Fix

#### Fixes
- **Full Sync finds no auctions on fresh sites** — The v3 `full_sync` action only queried the WordPress database for existing auction terms, but on a freshly deployed site the database has zero auctions. Added `AuctionList::init()` discovery step before querying the DB, so Full Sync now fetches all auctions from the BidSpirit API and creates taxonomy terms first, then enqueues catalog sync jobs for each.

#### Files Changed
- **Modified:** `auctionforge.php`

---

## [3.2.0] — 2026-03-14

### Webhook Pipeline Fixes, Archive Sync & Admin UI Overhaul

#### New Features
- **Archive Sync** — New "Archive Sync" button on Sync Status admin page. Enqueues catalog sync jobs for all ARCHIVED/ENDED auctions. Past auctions now appear in the auction dropdown with an optgroup separator and state label.
- **Sync Tasks v3** — Replaced old v2 task cards (importData, importBidSpirit, lotBidSpiritOld) with v3 queue-based tasks. "Sync Active Auctions" and "Sync Archived Auctions" now enqueue jobs via the queue system. Maintenance tasks (Clean Draft/Trash Lots, Clean Orphaned Meta) remain as direct-execution tasks.
- **How It Works guide rewritten** — Complete rewrite of the admin guide for v3 architecture. Documents webhook-first flow, all 5 job types, queue system mechanics, cron setup (external + WP-Cron), configuration constants, and troubleshooting.

#### Fixes
- **Dual-write removed** — Removed legacy handlers from `auctionCatalogUpdated()`, `auctionDayEnded()`, and `itemsUpdated()` webhook methods. The old handlers were racing with the v3 worker, creating orphaned staging rows in `import_history` that were never processed. All three methods now exclusively use the v3 enqueue → spawn path.
- **ITEMS_UPDATED webhook** — Fixed `itemsUpdated()` failing to enqueue jobs. The `ITEMS_UPDATED` payload has `auctionId` nested in `itemsByLanguage.{lang}[0].item.auctionId`, not at the top level. Added extraction logic to resolve it.
- **ItemUpdateWorker incomplete** — `ItemUpdateWorker::process()` was only calling `Lot::bulkSave()` (staging table) without running `InsertLotsBS` to process staged data into WP posts and `bp_meta_data`. Added the InsertLotsBS processing step with autocommit restore. Item updates now flow: stage → InsertLotsBS → WP post + meta → cache clear.
- **Worker spawn reliability** — Changed `SyncQueue::spawnWorker()` from non-blocking HTTPS to blocking HTTP loopback (`http://127.0.0.1` with explicit `Host` header, 2s timeout). Fixes nginx 403/404 errors when HTTPS loopback hits the default vhost instead of the site's vhost.
- **API token constant** — Plugin expects `AUCTIONFORGE_API_TOKEN` in `wp-config.php` (renamed from `UCO_API_TOKEN` during the plugin rename). Documented in readme.

#### Database
- DB version: 22 → 23

#### Files Changed
- **Modified:** `inc/controllers/AuctionsAPI.php`, `inc/sync/ItemUpdateWorker.php`, `inc/sync/SyncQueue.php`, `auctionforge.php`, `admin/view/sync-status.php`, `readme.txt`

---

## [3.0.0] — 2025-03-14

### New Feature: Webhook-First Sync Architecture (Sync v3)

**Overview:** Complete redesign of the BidSpirit data synchronisation pipeline. Replaces the old two-phase polling system (importBidSpirit every hour + importData every minute) with a webhook-driven job queue that processes data in near real-time.

#### Architecture
- **Webhook-driven sync** — BidSpirit webhooks now enqueue jobs into a dedicated queue table (`af_sync_queue`) instead of relying solely on polling cron tasks. Data flows: webhook → queue → background worker → WordPress posts.
- **Dual-write mode** — During transition, webhooks enqueue jobs AND still run the legacy handlers. Once verified, legacy handlers can be removed cleanly.
- **Job deduplication** — Prevents duplicate work by checking for existing pending/processing jobs with the same auction_id + job_type.

#### New Components
- **Job Queue System** (`inc/sync/SyncQueue.php`) — Priority-based FIFO queue with atomic job claiming, status tracking (pending/processing/completed/failed/skipped), worker PID tracking, and automatic stuck job recovery.
- **Sync Logger** (`inc/sync/SyncLogger.php`) — Real-time log table for admin console polling with level filtering (info/success/warning/error) and incremental cursor-based retrieval.
- **Catalog Sync Worker** (`inc/sync/CatalogSyncWorker.php`) — Processes catalog_sync, full_sync, and day_ended jobs by reusing existing `LotList::singleRun()` logic.
- **Item Update Worker** (`inc/sync/ItemUpdateWorker.php`) — Processes individual item_update jobs with the same logic as the webhook `itemsUpdated` handler.
- **Background Worker** (`crontasks/syncWorker.php`) — Async CLI process spawned by webhooks via `exec()` or by the safety cron. Claims and processes queued jobs with a configurable timeout (default 240s).
- **Safety Net** (`crontasks/syncSafety.php`) — Hourly cron that resets stuck jobs (>10min), spawns workers for pending jobs, runs a 24-hour health check (auto-enqueues full sync if no webhooks received), and purges old data.

#### Admin UI
- **Queue Status Dashboard** — New card on Sync Status page showing pending/processing/completed/failed/skipped counts, recent jobs table with real-time refresh, worker status badge, and action buttons:
  - **Full Sync** — Enqueues catalog sync for all active auctions
  - **Sync Selected** — Enqueue a single auction from dropdown
  - **Spawn Worker** — Manually trigger a background worker process
- **Live Console** — Dark-themed terminal console on the Sync Status page. Polls `af_sync_log` every 1.5s showing timestamped, color-coded log entries from webhooks, workers, queue operations, and the safety cron. Supports pause, clear, and auto-scroll.

#### Cron Changes
- **Removed** `importData` (ran every minute — ~1,440 PHP executions/day)
- **Removed** `importBidSpirit` (ran hourly — ~24 PHP executions/day)
- **Added** `syncSafety` (runs hourly at :10 — 24 PHP executions/day)
- **Net effect:** ~62 PHP executions/hour → 1 PHP execution/hour
- **WP-Cron safety hook** (`auctionforge_sync_safety`) — Always-active hourly event, independent of external cron
- **setup.sh v3** — Updated task definitions, new `migrate` command for interactive v2→v3 transition

#### Database
- New table: `af_sync_queue` — Job queue with status, priority, source, attempts, worker PID, timing columns
- New table: `af_sync_log` — Real-time log entries with context, level, auction_id, job_id
- DB version: 20 → 22

#### Constants Added
| Constant | Default | Description |
|---|---|---|
| `AUCTIONFORGE_SYNC_WORKER_TIMEOUT` | 240 | Worker process timeout in seconds |
| `AUCTIONFORGE_SYNC_STALE_JOB` | 600 | Seconds before a processing job is considered stuck |
| `AUCTIONFORGE_SYNC_HEALTH_CHECK` | 86400 | Seconds without webhook before triggering full sync |
| `AUCTIONFORGE_SYNC_LOG_RETENTION` | 7 | Days to keep log entries |
| `AUCTIONFORGE_SYNC_QUEUE_RETENTION` | 30 | Days to keep completed queue jobs |
| `AUCTIONFORGE_CURL_TIMEOUT` | 30 | BidSpirit API cURL timeout in seconds |
| `AUCTIONFORGE_PHP_BINARY` | /www/server/php/83/bin/php | PHP CLI binary path for spawning workers |

#### AJAX Handlers Added
- `auctionforge_sync_log_poll` — Incremental log polling for live console
- `auctionforge_sync_queue_status` — Queue counts + recent jobs for dashboard
- `auctionforge_sync_enqueue` — Admin-triggered sync actions (full_sync, sync_auction, spawn_worker)

### Fixes
- **cURL timeout** — BidSpiritRequest now enforces a 30-second timeout (was unlimited/0), preventing hung connections from blocking the sync pipeline.

### Files Changed
- **Modified:** `auctionforge.php`, `inc/coreIncludes.php`, `inc/controllers/AuctionsAPI.php`, `inc/BidSpiritRequest.php`, `admin/view/sync-status.php`, `crontasks/setup.sh`
- **Created:** `inc/sync/SyncQueue.php`, `inc/sync/SyncLogger.php`, `inc/sync/CatalogSyncWorker.php`, `inc/sync/ItemUpdateWorker.php`, `crontasks/syncWorker.php`, `crontasks/syncSafety.php`

---

## [2.7.3.0]

### Fixes
- Various stability improvements and bug fixes.

## [2.7.1.2]

### Fixes
- Added missing `vatAndCommission()` function in `loadBid()` — the sales tax display on single lot pages was never being rendered because the code to call `loadTemplate` for `vat-and-commission-template` was missing from `app.js`.

## [2.7.1.1]

### Fixes
- 2 decimal places now only appear in the bid modal (Your max bid, Total Price) via `showDecimals` flag. Start price, increments list, and other prices show whole numbers as before.

## [2.7.1.0]

### Fixes
- Fixed Buyer's Premium value not showing on single lot pages.

## [2.7.0.9]

### Fixes
- Fixed fatal error during plugin install/upgrade: `class-stats-database.php` require failure.

## [2.7.0.0]

### New Features: Advanced Lot Analytics
- Conversion Tracking, Peak Viewing Times, Pre-Sale vs Live Comparison, Category Performance, Share Analytics, Search → View Funnel, Click-Through Tracking, CSV Export, Hot Lot Badge.
- New DB table: `af_lot_shares`. New columns on `af_lot_analytics`. DB version bumped to 20.

## [2.6.0.0]

### New Feature: Lot Analytics
- Comprehensive lot view and favorite tracking system with admin dashboard.

## [2.5.0.0]

### New Features
- **Site Statistics System** — Full website analytics built into the plugin admin.
- **Recently Viewed Items** — Profile page tab showing last 20 viewed lots per user.
