fix: MPS-24 Phase 2.8 — bulk tagger AJAX tag-focus + real drag-to-reorder

Operator (printableprompts.com, 481 products) reported the bulk tagger
'still refreshing the whole screen' and 'dragging tags doesn't work'
after 2.7. Two real defects the 2.7 static audit missed:

1. Tag-focus was a full-page navigation: clicking a tag chip is
   <a href=?focus=slug>, and the view loaded+rendered ALL products on
   EVERY GET. On a 481-product catalog every tag click reloaded a
   multi-MB page. The forms were AJAX; the dominant workflow was not.
2. Drag-to-reorder never existed: shop_tags.j2 shipped draggable=true +
   a handle + help text, but tag_bulk.js had ZERO drag handlers.

Fix:
- shop.py:shop_tags — all_products loads only when focus_tag or
  show_suggestions (bare GET is light). New AJAX branch: is_ajax +
  ?focus=slug -> JSON {focus, products:[{id,title,url,attached}]}.
- shop_tags.j2 — stable [data-focus-section] (always in DOM, hidden
  until focused); ?focus= chips carry data-tag-focus-link. No-JS
  unchanged (real navigation, server renders the section).
- tag_bulk.js — wireFocusLinks() intercepts chip clicks, fetchFocus()
  + renderFocus() swap the list in place, active-chip + history
  pushState/popstate, real-navigation fallback. wireDragAndDrop()
  HTML5 DnD -> persistOrder() POSTs action=set_order&tag_slugs=…
  (view already supported it) + re-syncs up/down disabled states.
  .tag-list-dragging CSS added.
- Tests: TestProductTagsSpa +4 (ajax focus json, unknown-slug null,
  set_order persists positions, bare GET no catalog). 1128 passed.

Docs: mps-24.md Phase 2.8, architecture.md, design-system.md, CLAUDE.md.
Deferred: AJAX 'Suggest categories' link (occasional click, not hot path).
This commit is contained in:
russell@unturf.com 2026-05-16 08:31:03 -04:00
parent f591620424
commit 155f7ff66f
No known key found for this signature in database
9 changed files with 439 additions and 10 deletions

View file

@ -474,7 +474,16 @@ Form section: `home-layout-settings` (`views/shop.py`,
stopwords inputs.
Routes (registered before `shop_slug` catch-all):
- `/s/{shop_id}/tags` — operator bulk tagger (`@shop_editor_required`)
- `/s/{shop_id}/tags` — operator bulk tagger (`@shop_editor_required`).
**Phase 2.8**: tag-focus is AJAX — `tag_bulk.js` intercepts a
`[data-tag-focus-link]` chip click and fetches `?focus=<slug>` with
`X-Requested-With`; the view returns JSON
`{focus, products:[{id,title,url,attached}]}` and the JS swaps the
`[data-focus-section]` list in place (no full reload — critical on
large catalogs; the view only loads `all_products` when
`focus_tag or show_suggestions`). Real HTML5 drag-to-reorder on the
tag rows POSTs `action=set_order&tag_slugs=…`. No-JS unchanged: the
`?focus=` link is a real navigation, server still renders the section.
- `/s/{shop_id}/tag/{slug}` — public tag detail page (works without JS)
Tag input on product edit: comma-separated `tags` field on

View file

@ -222,6 +222,7 @@ mps_page_session (raw rows)
| Facet nav on shop home (MPS-24 Phase 2.6b) | `_facet_nav.j2` macros + `home.j2` / `shop.j2` wrappers | Desktop sidebar + mobile `<details>` accordion when `shop.home_layout >= 1` | Opt-in via home_layout |
| Mobile SERP rows under each lane (MPS-24 Phase 2.6b) | `home.j2` / `shop.j2` `.tag-lane-rows` markup + CSS visibility swap | Horizontal tiles ≥800px; vertical SERP rows with 6-sentence excerpts <800px | On for layout 2 |
| Per-product SPA tag chips (MPS-24 Phase 2.7) | `product_tags` view (`/p/{id}/tags`) + `static/js/product_tags.js` + `views/__init__.py:is_ajax()` | Chip add/remove on product edit, AJAX persist, no full page reload; no-JS keeps the comma `tags` field on the main form | Always on (JS-enhanced; no-JS fallback) |
| Bulk tagger AJAX focus + DnD (MPS-24 Phase 2.8) | `shop.py:shop_tags` AJAX `?focus=` branch + `tag_bulk.js` `wireFocusLinks`/`wireDragAndDrop` | Tag chip click swaps product list in place (no reload); HTML5 drag-to-reorder POSTs `set_order`; bare GET no longer loads the whole catalog | Always on (JS-enhanced; no-JS = real navigation) |
## Ticket Index
@ -251,7 +252,7 @@ mps_page_session (raw rows)
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Complete |
| [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete |
| [MPS-23](tickets/mps-23.md) | Consolidated Transactional Sender Identity + Shop Contact Email | Open |
| [MPS-24](tickets/mps-24.md) | Shop home page overhaul + product categorization (tags + chips + lanes + auto-suggest + per-product SPA tag chips) | In progress (Phases 1 + 2 + 2.6 + 2.7 landed) |
| [MPS-24](tickets/mps-24.md) | Shop home page overhaul + product categorization (tags + chips + lanes + auto-suggest + per-product SPA tag chips + bulk-tagger AJAX/DnD) | In progress (Phases 1 + 2 + 2.6 + 2.7 + 2.8 landed) |
## Related Docs

View file

@ -289,6 +289,9 @@ All components are documented with live examples at `/styleguide`. The styleguid
| `.tag-chip-add` | `product_edit.j2` | Add-a-tag row, grid `1fr auto`, stacks to one column under 600px |
| `.tag-chip-flash` | `product_edit.j2` | Toast region reusing `.tag-flash-toast` / `.tag-flash-{success,error,info}` |
| `[data-product-tags]` / `[data-product-tags-url]` | `product_edit.j2` | JS hooks for `product_tags.js`: container + the `/p/{id}/tags` endpoint URL |
| `[data-focus-section]` / `[data-focus-heading]` / `[data-focus-list]` | `shop_tags.j2` (Phase 2.8) | Stable bulk-tagger focus container — always in the DOM, `hidden` until a tag is focused; `tag_bulk.js` swaps the product list in place instead of a full reload |
| `[data-tag-focus-link]` | `shop_tags.j2` (Phase 2.8) | Tag chip in the All-tags list; `tag_bulk.js` intercepts the click and fetches `?focus=<slug>` as JSON |
| `.tag-list-dragging` / `.tag-list-drop-target` | `shop_tags.j2` (Phase 2.8) | Dragged row (dimmed) + active drop position during HTML5 drag-to-reorder; `tag_bulk.js` POSTs `set_order` on drop |
## CSS Conventions

View file

@ -319,6 +319,50 @@ picker is Phase 2).
| `tests/test_models.py` | `TestTagSuggestPureFunctions` — 11 unit tests over tokenize / stem / cluster |
| `tests/test_functional.py` | `test_suggest_clusters_renders_candidates`, `test_apply_suggestion_creates_tag_and_attaches_products`, `test_dismiss_suggestion_adds_to_stopwords`, `test_apply_suggestion_rejects_empty_input` |
### Phase 2.8 — bulk tagger: AJAX tag-focus + real drag-to-reorder + page-weight fix (shipped 2026-05-16)
Operator (printableprompts.com, **481 products**) reported the bulk
tagger "still refreshing the whole screen" and "dragging tags doesn't
work" after 2.7. Two real defects the 2.7 static audit missed:
1. **Tag-focus was a full-page navigation.** Clicking a tag chip is
`<a href="?focus=slug">` — a real reload. On a 481-product catalog
the view loaded + rendered *every* product on *every* GET, so each
tag click reloaded a multi-MB page. The forms were AJAX; the
dominant workflow action (focus a tag → assign products) was not.
2. **Drag-to-reorder never existed.** `shop_tags.j2` shipped
`draggable="true"`, a ≡ handle, and "drag rows when JS is enabled"
help text, but `tag_bulk.js` had **zero** drag handlers — only the
↑/↓ buttons worked. The affordance lied.
Fix:
- **View** (`shop.py:shop_tags`): `all_products` now loads only when
`focus_tag or show_suggestions` (bare GET is light). New AJAX branch:
`is_ajax + ?focus=<slug>` → JSON `{focus:{name,slug},
products:[{id,title,url,attached}]}`.
- **Template** (`shop_tags.j2`): focus section is now a stable
`[data-focus-section]` (always in DOM, `hidden` until focused);
`?focus=` chips carry `data-tag-focus-link` + `data-tag-slug`.
No-JS unchanged: the link is a real navigation, server still renders
the section.
- **JS** (`tag_bulk.js`): `wireFocusLinks()` intercepts chip clicks →
`fetchFocus()``renderFocus()` swaps the product list in place,
updates the active chip, `history.pushState` (back/forward via
`popstate`), graceful real-navigation fallback. `wireDragAndDrop()`
implements HTML5 DnD on the tag rows → `persistOrder()` POSTs
`action=set_order&tag_slugs=…` (view already supported it) and
re-syncs ↑/↓ disabled states. `.tag-list-dragging` CSS added.
Tests (`test_functional.py::TestProductTagsSpa`):
`test_ajax_focus_returns_product_list_json`,
`test_ajax_focus_unknown_slug_returns_null_focus`,
`test_ajax_set_order_persists_tag_positions`,
`test_bulk_tagger_bare_get_renders_without_products`.
Deferred (occasional click, not the hot path): AJAX-ifying the
"Suggest categories" link — still a full navigation by design.
### Phase 2.7 — per-product SPA tag chips on product edit (shipped 2026-05-16)
Operator report: adding/removing a tag on the product edit page

View file

@ -1787,6 +1787,11 @@ li.tag-list-item.tag-list-drop-target {
/* Active drop position during drag-and-drop reordering. */
background: color-mix(in srgb, var(--color-green, #a3c765) 14%, var(--surface-base, #fff) 86%);
}
li.tag-list-item.tag-list-dragging {
/* The row being dragged (MPS-24 Phase 2.8 — tag_bulk.js DnD). */
opacity: 0.55;
cursor: grabbing;
}
span.tag-list-handle {
cursor: grab;

View file

@ -29,6 +29,13 @@
// Event delegation isn't enough because we re-render rows; we
// re-attach to any new forms after each mutation via attachAll().
document.addEventListener("submit", maybeIntercept, true);
// MPS-24 Phase 2.8: the heavy full-page reload on this page was the
// tag-focus navigation (a plain <a href="?focus=">), not the forms.
// On a 481-product catalog every tag click reloaded the whole bulk
// tagger. Intercept it + wire real drag-to-reorder.
wireFocusLinks();
wireDragAndDrop();
}
function attachAll(root) {
@ -254,6 +261,221 @@
countEl.textContent = Math.max(0, current + delta);
}
/* ----- AJAX tag focus (no full page reload) ----------------------- */
function wireFocusLinks() {
// Delegated so chips re-rendered after create/delete still work.
document.addEventListener("click", function (ev) {
const link = ev.target.closest("a[data-tag-focus-link]");
if (!link) return;
// Respect new-tab / middle-click / modified clicks.
if (
ev.button !== 0 ||
ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey
) {
return;
}
ev.preventDefault();
fetchFocus(link.getAttribute("data-tag-slug"));
});
// Back/forward should restore (or clear) the focused tag.
window.addEventListener("popstate", function () {
const m = window.location.search.match(/[?&]focus=([^&]+)/);
if (m) {
fetchFocus(decodeURIComponent(m[1]), true);
} else {
renderFocus(null, { focus: null });
}
});
}
async function fetchFocus(slug, skipPush) {
if (!slug) return;
const url =
window.location.pathname + "?focus=" + encodeURIComponent(slug);
const section = document.querySelector("[data-focus-section]");
if (section) section.setAttribute("aria-busy", "true");
try {
const res = await fetch(url, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
});
if (!res.ok) throw new Error("server " + res.status);
const ct = res.headers.get("content-type") || "";
if (ct.indexOf("application/json") === -1) {
throw new Error("non-JSON response");
}
const data = await res.json();
flashAll(data.messages);
renderFocus(slug, data);
if (!skipPush && window.history && window.history.pushState) {
window.history.pushState({ focus: slug }, "", url);
}
} catch (err) {
// Graceful fallback — a real navigation still works (no-JS path).
console.warn("tag_bulk: focus AJAX failed, navigating:", err);
window.location.href = url;
} finally {
if (section) section.removeAttribute("aria-busy");
}
}
function renderFocus(slug, data) {
const section = document.querySelector("[data-focus-section]");
const heading = document.querySelector("[data-focus-heading]");
const list = document.querySelector("[data-focus-list]");
if (!section || !list) return;
document
.querySelectorAll("a[data-tag-focus-link]")
.forEach(function (a) {
a.classList.toggle(
"tag-chip-active",
a.getAttribute("data-tag-slug") === slug
);
});
if (!data || !data.focus) {
section.setAttribute("hidden", "");
list.innerHTML = "";
return;
}
if (heading) {
heading.innerHTML =
"Products in &laquo;" + escapeHtml(data.focus.name) + "&raquo;";
}
list.innerHTML = (data.products || [])
.map(function (p) {
return buildProductRowHTML(p, data.focus.slug);
})
.join("");
section.removeAttribute("hidden");
attachAll(list);
section.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Mirrors the Jinja .tag-product-row markup + swapToggleForm()'s
// button markup so the existing onToggle()/swapToggleForm() path
// keeps working on AJAX-rendered rows.
function buildProductRowHTML(p, tagSlug) {
const attached = !!p.attached;
const nextAction = attached ? "detach" : "attach";
const buttonClass = attached
? "mps-button mps-button-small mps-button-green"
: "mps-button mps-button-small";
const buttonText = attached ? "✓ Applied" : "Apply";
return (
'<li class="tag-product-row" data-product-row data-product-id="' +
escapeHtml(p.id) + '">' +
'<form method="POST" action="' + window.location.pathname +
'" class="tag-product-toggle" data-tag-form="' + nextAction + '">' +
'<input type="hidden" name="action" value="' + nextAction + '" />' +
'<input type="hidden" name="tag_slug" value="' +
escapeHtml(tagSlug) + '" />' +
'<input type="hidden" name="product_id" value="' +
escapeHtml(p.id) + '" />' +
'<button type="submit" class="' + buttonClass + '">' +
buttonText + "</button>" +
"</form>" +
'<a href="' + escapeHtml(p.url) +
'" class="shop-theme-link-color" rel="nofollow">' +
escapeHtml(p.title) + "</a>" +
"</li>"
);
}
/* ----- drag-to-reorder (the ≡ handle / draggable rows) ------------ */
function wireDragAndDrop() {
const list = document.querySelector("[data-tag-list]");
if (!list) return;
let dragRow = null;
list.addEventListener("dragstart", function (ev) {
const row = ev.target.closest("[data-tag-row]");
if (!row) return;
dragRow = row;
row.classList.add("tag-list-dragging");
ev.dataTransfer.effectAllowed = "move";
// Firefox won't start a drag unless data is set.
try {
ev.dataTransfer.setData(
"text/plain", row.getAttribute("data-tag-slug") || ""
);
} catch (e) {}
});
list.addEventListener("dragover", function (ev) {
if (!dragRow) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
const over = ev.target.closest("[data-tag-row]");
if (!over || over === dragRow) return;
const rect = over.getBoundingClientRect();
const after = ev.clientY - rect.top > rect.height / 2;
clearDropTargets(list);
over.classList.add("tag-list-drop-target");
if (after) {
if (over.nextElementSibling !== dragRow) {
list.insertBefore(dragRow, over.nextElementSibling);
}
} else if (over !== dragRow) {
list.insertBefore(dragRow, over);
}
});
list.addEventListener("drop", function (ev) {
if (dragRow) ev.preventDefault();
});
list.addEventListener("dragend", function () {
if (!dragRow) return;
dragRow.classList.remove("tag-list-dragging");
clearDropTargets(list);
dragRow = null;
persistOrder(list);
});
}
function clearDropTargets(list) {
list.querySelectorAll(".tag-list-drop-target").forEach(function (el) {
el.classList.remove("tag-list-drop-target");
});
}
function persistOrder(list) {
const slugs = [];
list.querySelectorAll("[data-tag-row]").forEach(function (row) {
const s = row.getAttribute("data-tag-slug");
if (s) slugs.push(s);
});
if (!slugs.length) return;
const fd = new FormData();
fd.append("action", "set_order");
fd.append("tag_slugs", slugs.join(","));
fetch(window.location.pathname, {
method: "POST",
body: fd,
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin",
})
.then(function (res) {
if (!res.ok) throw new Error("server " + res.status);
return res.json();
})
.then(function (data) {
flashAll(data.messages);
syncReorderButtonStates(list);
})
.catch(function (err) {
console.warn("tag_bulk: set_order failed, reloading:", err);
flashAll([["Could not save the new order — reloading.", "error"]]);
window.location.reload();
});
}
/* ----- flash / toast --------------------------------------------- */
function flashAll(messages) {

View file

@ -105,6 +105,7 @@
<span class="tag-list-handle js-only" data-tag-handle aria-hidden="true" title="Drag to reorder">&#8801;</span>
<a href="?focus={{ tag.slug }}"
class="tag-chip{% if focus_tag and focus_tag.id == tag.id %} tag-chip-active{% endif %}"
data-tag-focus-link data-tag-slug="{{ tag.slug }}"
rel="nofollow">{{ tag.name }}</a>
<span class="tag-list-count">{{ tag.product_count }} product{% if tag.product_count != 1 %}s{% endif %}</span>
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ tag.slug }}"
@ -137,11 +138,18 @@
</ul>
</section>
{% if focus_tag %}
<section class="one-column well">
<h2 class="type-title">Products in &laquo;{{ focus_tag.name }}&raquo;</h2>
{# MPS-24 Phase 2.8: stable focus container — always in the DOM so
tag_bulk.js can swap the product list in place when a tag chip is
clicked, instead of a full-page reload of the whole bulk tagger
(catastrophic on a 481-product catalog). No-JS still works: the
?focus= link is a real navigation and this block server-renders. #}
<section class="one-column well" data-focus-section{% if not focus_tag %} hidden{% endif %}>
<h2 class="type-title" data-focus-heading>
{%- if focus_tag %}Products in &laquo;{{ focus_tag.name }}&raquo;{% endif -%}
</h2>
<p class="type-body-sm">Check a product to apply this tag; uncheck to remove it. Saves on click.</p>
<ul class="tag-product-list">
<ul class="tag-product-list" data-focus-list>
{% if focus_tag %}
{% for product in all_products %}
<li class="tag-product-row" data-product-row data-product-id="{{ product.id }}">
{% if focus_tag in product.tags|list %}
@ -162,9 +170,9 @@
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color" rel="nofollow">{{ product.title }}</a>
</li>
{% endfor %}
{% endif %}
</ul>
</section>
{% endif %}
<script src="/static/js/tag_bulk.js" defer></script>

View file

@ -6892,6 +6892,106 @@ class TestProductTagsSpa(_AuthenticatedBase):
self.assertEqual(res.json["status"], "ok")
self.assertEqual(res.json["tag"]["slug"], "geometry")
# ── Phase 2.8: AJAX tag-focus + drag set_order + page weight ──────
def test_ajax_focus_returns_product_list_json(self):
"""Clicking a tag chip must NOT reload — the view answers
?focus=<slug> AJAX with the product list as JSON so tag_bulk.js
swaps it in place. This is the fix for 'refreshing the whole
screen' on the 481-product shop."""
shop_id, product_id = self._make_product()
# Create a tag and attach it to the product (proven AJAX paths).
self.testapp.post(
f"/s/{shop_id}/tags",
{"action": "create", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"}, status=200,
)
self.testapp.post(
f"/p/{product_id}/tags",
{"action": "add", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"}, status=200,
)
res = self.testapp.get(
f"/s/{shop_id}/tags?focus=math",
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertIn("application/json", res.content_type)
data = res.json
self.assertEqual(data["status"], "ok")
self.assertEqual(data["focus"]["slug"], "math")
self.assertTrue(len(data["products"]) >= 1)
mine = [p for p in data["products"] if p["id"] == str(product_id)]
self.assertEqual(len(mine), 1)
self.assertTrue(mine[0]["attached"])
self.assertIn("title", mine[0])
self.assertIn("url", mine[0])
def test_ajax_focus_unknown_slug_returns_null_focus(self):
shop_id, _ = self._make_product()
res = self.testapp.get(
f"/s/{shop_id}/tags?focus=nope-not-real",
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertIn("application/json", res.content_type)
self.assertIsNone(res.json["focus"])
self.assertEqual(res.json["products"], [])
def test_ajax_set_order_persists_tag_positions(self):
"""Drag-to-reorder POSTs action=set_order&tag_slugs=a,b,c —
must return JSON and persist Tag.position."""
from ..models.tag import get_tag_by_shop_and_slug
shop_id, _ = self._make_product()
for name in ("Alpha", "Bravo", "Charlie"):
self.testapp.post(
f"/s/{shop_id}/tags",
{"action": "create", "name": name},
headers={"X-Requested-With": "XMLHttpRequest"}, status=200,
)
res = self.testapp.post(
f"/s/{shop_id}/tags",
{"action": "set_order", "tag_slugs": "charlie,alpha,bravo"},
headers={"X-Requested-With": "XMLHttpRequest"},
status=200,
)
self.assertIn("application/json", res.content_type)
self.assertEqual(res.json["status"], "ok")
self.assertEqual(
res.json["ordered_slugs"], ["charlie", "alpha", "bravo"]
)
from ..models.shop import get_shop_by_id
self.dbsession.expire_all()
shop = get_shop_by_id(self.dbsession, shop_id)
pos = {
s: get_tag_by_shop_and_slug(self.dbsession, shop, s).position
for s in ("charlie", "alpha", "bravo")
}
self.assertEqual(pos["charlie"], 0)
self.assertEqual(pos["alpha"], 1)
self.assertEqual(pos["bravo"], 2)
def test_bulk_tagger_bare_get_renders_without_products(self):
"""Phase 2.8 perf: a bare GET (no focus / no suggestions) must
not load+render the whole catalog. Page renders, focus section
is present but hidden."""
shop_id, _ = self._make_product()
# A tag so the All-tags list (and its focus link) renders.
self.testapp.post(
f"/s/{shop_id}/tags",
{"action": "create", "name": "Math"},
headers={"X-Requested-With": "XMLHttpRequest"}, status=200,
)
res = self.testapp.get(f"/s/{shop_id}/tags", status=200)
body = res.body.decode()
self.assertIn("data-focus-section", body)
self.assertIn("data-tag-focus-link", body)
# The focus section ships hidden until a tag is focused.
import re
m = re.search(r'data-focus-section[^>]*>', body)
self.assertIsNotNone(m)
self.assertIn("hidden", m.group(0))
class TestAuctionCheckout(_AuthenticatedBase):
"""MPS-20: /a/{id}/checkout creates a cart linked to the auction so

View file

@ -2427,15 +2427,52 @@ def shop_tags(request):
if focus_slug
else None
)
all_products = get_all_products_from_a_shop(shop)
show_suggestions = (request.params.get("show_suggestions") or "") == "1"
# MPS-24 Phase 2.8: only load the (potentially huge) product catalog
# when a surface actually needs it. The bare tag list never renders
# products; on a 481-product shop, loading + rendering them on every
# GET is exactly the "refreshing the whole screen" the operator hit.
need_products = focus_tag is not None or show_suggestions
all_products = (
get_all_products_from_a_shop(shop) if need_products else []
)
# MPS-24 Phase 2.8: AJAX tag focus. tag_bulk.js intercepts a tag
# chip click and fetches ?focus=<slug> with X-Requested-With; we
# return just the product list as JSON so the JS swaps it in place
# instead of a full-page reload of the entire bulk tagger.
if _is_ajax(request) and focus_slug:
if focus_tag is None:
return _tag_ajax_response(
request, {"focus": None, "products": []}
)
products_payload = [
{
"id": p.uuid_str,
"title": p.title,
"url": p.absolute_url(request),
"attached": focus_tag in p.tags,
}
for p in all_products
]
return _tag_ajax_response(
request,
{
"focus": {
"name": focus_tag.name,
"slug": focus_tag.slug,
},
"products": products_payload,
},
)
# MPS-24 Phase 2: compute candidate clusters on demand. We always
# compute (cheap O(N × tokens) over the shop catalog), but the
# template only renders the well when the operator clicks the
# button (?show_suggestions=1). Power-user knobs `max_share` and
# `top_n` accept URL overrides so the operator can tune without
# redeploying.
show_suggestions = (request.params.get("show_suggestions") or "") == "1"
# redeploying. (`show_suggestions` already resolved above.)
suggestions = []
shop_vocab_filtered = 0
if show_suggestions: