diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 2d84980..330a856 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -319,6 +319,35 @@ 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.4 — SPA bulk tagger + Netflix-style lanes (shipped 2026-05-15) + +Two improvements that compound for the operator workflow: + +- **SPA progressive enhancement on `/s/{shop_id}/tags`**. Each form + (create / delete / attach / detach / apply_suggestion / + dismiss_suggestion) still POSTs and 302-redirects without JS, but + with JS, `static/js/tag_bulk.js` intercepts the submit, sends + `X-Requested-With: XMLHttpRequest`, and the server returns JSON + describing what changed. JS mutates the DOM in place — no full + reload while the operator iterates on suggestions, applies a + cluster, deletes a tag they don't like, repeats. Flash messages + render as toasts via the new `.tag-flash` region. Falls back to + full submit if `fetch()` errors. +- **Netflix-style horizontal-scroll lanes**. `.tag-lane-grid` is now + a horizontal-scrolling row of fixed-width tiles + (`grid-auto-flow: column; grid-auto-columns: minmax(160px, 200px); + overflow-x: auto; scroll-snap-type: x mandatory`). Each lane is + visually bounded as a category, tiles snap on swipe, mobile-friendly. + Tiles drop the `.serp` class (the old auto-fit grid layout was + fighting the new horizontal flow) but keep `.serp-item` for hover + styles. Thumbnails: `width: auto; max-width: 100%; max-height: + 200px` per CLAUDE.md media-sizing rule. +- **Companion `serp-thumbnail` fix** (commit `e284f88`): `img.serp-thumbnail` + gained `width: auto; max-width: 100%; height: auto`. On + printableprompts the 1080×1080 natural thumbnails were forcing + grid cells wider than the column template, collapsing + `auto-fit, minmax(160px, 1fr)` to a one-column-per-viewport layout. + ### Phase 2.3 — multi-bigram supersession + apostrophe labels + top_n 100 (shipped 2026-05-15) Phase 2.2 surfaced real categories but left residue: `Color` (119), diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 13f4e15..0eed861 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -1320,6 +1320,12 @@ div.serp-item { padding: var(--space-1, 4px); grid-column: span 1; grid-row: span 1; + /* Grid items default to min-width: auto — a wide img inside would + push the cell past minmax(160px, 1fr) and collapse the auto-fit + grid to one full-viewport column. Force min-width: 0 so the + child img's max-width: 100% actually clamps. Same gotcha as the + lane tiles below. */ + min-width: 0; /* The new CSS animations are off the hook. */ transition: background-color 800ms ease; @@ -1424,7 +1430,8 @@ a.tag-chip-active:hover { background: var(--dark-button-bg-hover, #3a4658); } -/* Sectioned-lanes layout — one lane per top tag. */ +/* Sectioned-lanes layout — one lane per top tag. Netflix-style + horizontal row with scroll-snap. */ section.tag-lane { margin: 0 0 var(--space-6, 24px) 0; } @@ -1450,6 +1457,61 @@ a.tag-lane-more:hover { text-decoration: underline; } +div.tag-lane-grid { + /* Horizontal scrolling row of fixed-width tiles. grid-auto-flow: + column lays children in a single row left-to-right; overflow-x + auto provides the scroll. scroll-snap-type x mandatory makes + swipe snap to tile edges. Grid-only per CLAUDE.md (no flexbox). */ + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(160px, 200px); + gap: var(--space-2, 8px); + overflow-x: auto; + overflow-y: hidden; + padding: var(--space-1, 4px) 0 var(--space-2, 8px) 0; + scroll-snap-type: x mandatory; + scrollbar-width: thin; +} + +div.tag-lane-tile { + /* Tiles sit inside the horizontal scroll row. The default + .serp-item rules (hover, transition, border-radius) still + apply since we kept that class on the tile element. */ + scroll-snap-align: start; + /* Make the whole tile occupy its grid track. */ + width: 100%; + /* CRITICAL: grid items default to min-width: auto, which lets a + large img inside push the grid track past grid-auto-columns' + max (200px). Forcing min-width: 0 makes the track respect the + cap — without this, a 1500×1500 marketing thumbnail (e.g. + printableprompts "Color With Kindness") explodes its tile + across the full viewport. Same fix on min-width: 0 for the img + belt-and-suspenders so max-width: 100% can actually clamp. */ + min-width: 0; +} +div.tag-lane-tile img.serp-thumbnail { + /* Per CLAUDE.md CSS Media Sizing: width auto + max-width 100% + + max-height. Never combine width: 100% with max-height — + portrait thumbs leave a little whitespace, which is acceptable. + min-width: 0 lets max-width: 100% actually clamp (see the tile + rule above for the grid-item min-content gotcha). */ + width: auto; + max-width: 100%; + max-height: 200px; + min-width: 0; +} + +@media (max-width: 800px) { + /* Mobile: narrower tiles + breathing room on the swipe edge */ + div.tag-lane-grid { + grid-auto-columns: minmax(140px, 160px); + padding-right: var(--space-4, 16px); + } + div.tag-lane-tile img.serp-thumbnail { + max-height: 160px; + } +} + /* Tag editor bulk list */ ul.tag-list { list-style: none; diff --git a/make_post_sell/static/js/tag_bulk.js b/make_post_sell/static/js/tag_bulk.js new file mode 100644 index 0000000..74976ff --- /dev/null +++ b/make_post_sell/static/js/tag_bulk.js @@ -0,0 +1,274 @@ +/* MPS-24 Phase 2.4: Single-page progressive enhancement for /s/{id}/tags. + * + * Capability-driven per CLAUDE.md: every form on the bulk tagger is a + * real
that 302-redirects without JS. With JS, we + * intercept the submit, POST via fetch with X-Requested-With, and + * mutate the DOM in place. Server returns JSON describing what changed. + * + * No-JS users get the existing full-page-reload flow. + * + * Forms we handle (data-tag-form=""): + * create — append new tag to the All Tags list + * delete — remove tag row from the All Tags list + * attach / detach — toggle the per-product Apply / Applied form + * apply_suggestion — remove suggestion row + add tag to All Tags + * dismiss_suggestion — remove suggestion row + */ +(function () { + "use strict"; + + function init() { + const forms = document.querySelectorAll("form[data-tag-form]"); + if (forms.length === 0) { + return; + } + forms.forEach(attach); + + // 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); + } + + function attachAll(root) { + (root || document).querySelectorAll("form[data-tag-form]").forEach(attach); + } + + function attach(form) { + if (form.__tagBulkBound) return; + form.__tagBulkBound = true; + } + + function maybeIntercept(ev) { + const form = ev.target; + if (!(form instanceof HTMLFormElement)) return; + if (!form.hasAttribute("data-tag-form")) return; + // Confirm dialog is on the delete button via inline onclick; if it + // returned false the submit never fires, so we don't need to repeat + // the check here. + ev.preventDefault(); + submitForm(form); + } + + async function submitForm(form) { + const action = form.getAttribute("data-tag-form"); + const fd = new FormData(form); + // Disable submit button so a rapid double-click doesn't queue + // duplicate POSTs. + const submit = form.querySelector("button[type=submit]"); + if (submit) submit.disabled = true; + try { + const res = await fetch(form.action, { + method: "POST", + body: fd, + headers: { "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + }); + if (!res.ok) throw new Error("server " + res.status); + const data = await res.json(); + flashAll(data.messages); + dispatch(action, form, data); + } catch (err) { + // Fall back to a normal form submit so the user isn't stranded. + console.warn("tag_bulk: AJAX failed, falling back to full submit:", err); + form.submit(); + } finally { + if (submit) submit.disabled = false; + } + } + + function dispatch(action, form, data) { + switch (action) { + case "create": return onCreate(form, data); + case "delete": return onDelete(form, data); + case "attach": return onToggle(form, data, true); + case "detach": return onToggle(form, data, false); + case "apply_suggestion": return onApplySuggestion(form, data); + case "dismiss_suggestion": return onDismissSuggestion(form, data); + } + } + + /* ----- handlers --------------------------------------------------- */ + + function onCreate(form, data) { + if (!data.tag) return; // server flagged an error; flash already shown + appendTagRow(data.tag); + incTagCount(1); + const name = form.querySelector("input[name=name]"); + if (name) name.value = ""; + } + + function onDelete(form) { + const slug = form.querySelector("input[name=tag_slug]"); + if (!slug) return; + const row = document.querySelector( + "[data-tag-row][data-tag-slug=\"" + cssEscape(slug.value) + "\"]" + ); + if (row) row.remove(); + incTagCount(-1); + // Remove the tag from the chip strip on the product detail page if open? + // Out of scope — /s/{id}/tags is its own page. + } + + function onToggle(form, data, attached) { + // Server may report attached=null if the tag/product weren't found + if (data.attached === null || data.attached === undefined) return; + const productId = form.querySelector("input[name=product_id]").value; + const tagSlug = form.querySelector("input[name=tag_slug]").value; + const row = document.querySelector( + "[data-product-row][data-product-id=\"" + cssEscape(productId) + "\"]" + ); + if (!row) return; + swapToggleForm(row, tagSlug, productId, data.attached); + } + + function onApplySuggestion(form, data) { + if (data.status === "error" || !data.tag) return; + // Remove the candidate row + const label = form.querySelector("input[name=label]").value; + removeSuggestRow(label); + // Append the new tag (with product_count from server) to All Tags + appendTagRow(data.tag); + incTagCount(1); + } + + function onDismissSuggestion(form, data) { + const label = form.querySelector("input[name=label]").value; + removeSuggestRow(label); + } + + /* ----- DOM helpers ----------------------------------------------- */ + + function appendTagRow(tag) { + const list = document.querySelector("[data-tag-list]"); + if (!list) return; + if (list.hasAttribute("hidden")) list.removeAttribute("hidden"); + const empty = document.querySelector("[data-tag-empty]"); + if (empty) empty.remove(); + + // Don't duplicate if the operator re-applied the same tag + if ( + document.querySelector( + "[data-tag-row][data-tag-slug=\"" + cssEscape(tag.slug) + "\"]" + ) + ) { + return; + } + + const shopBase = window.location.pathname.replace(/\/tags\/?$/, ""); + const li = document.createElement("li"); + li.className = "tag-list-item"; + li.setAttribute("data-tag-row", ""); + li.setAttribute("data-tag-slug", tag.slug); + li.innerHTML = + '' + escapeHtml(tag.name) + '' + + '' + tag.product_count + + ' product' + (tag.product_count === 1 ? '' : 's') + '' + + 'view →' + + '' + + '' + + '' + + '' + + ''; + list.appendChild(li); + attachAll(li); + } + + function removeSuggestRow(label) { + const row = document.querySelector( + "[data-suggest-row][data-suggest-label=\"" + cssEscape(label) + "\"]" + ); + if (row) row.remove(); + + // Update the heading count + const countEl = document.querySelector("[data-suggest-count]"); + const list = document.querySelector("[data-suggest-list]"); + if (countEl && list) { + countEl.textContent = list.querySelectorAll("[data-suggest-row]").length; + } + } + + function swapToggleForm(row, tagSlug, productId, attached) { + // Replace the existing toggle form with the opposite-state version. + const existing = row.querySelector("form.tag-product-toggle"); + if (!existing) return; + 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"; + const form = document.createElement("form"); + form.method = "POST"; + form.action = window.location.pathname; + form.className = "tag-product-toggle"; + form.setAttribute("data-tag-form", nextAction); + form.innerHTML = + '' + + '' + + '' + + ''; + existing.replaceWith(form); + attachAll(form); + } + + function incTagCount(delta) { + const countEl = document.querySelector("[data-tag-count]"); + if (!countEl) return; + const current = parseInt(countEl.textContent, 10) || 0; + countEl.textContent = Math.max(0, current + delta); + } + + /* ----- flash / toast --------------------------------------------- */ + + function flashAll(messages) { + if (!messages || messages.length === 0) return; + const region = document.querySelector("[data-tag-flash]"); + if (!region) return; + messages.forEach((entry) => { + // Pyramid flash entries can be ("msg", "level") tuples or plain + // strings; both come through as either ["msg","level"] or "msg" + // in the JSON serialisation. + let text = entry; + let level = "info"; + if (Array.isArray(entry) && entry.length >= 1) { + text = entry[0]; + level = entry[1] || "info"; + } + const toast = document.createElement("div"); + toast.className = "tag-flash-toast tag-flash-" + level; + toast.textContent = text; + region.appendChild(toast); + // Auto-dismiss after 5s + setTimeout(() => { + toast.style.opacity = "0"; + setTimeout(() => toast.remove(), 400); + }, 5000); + }); + } + + /* ----- tiny helpers ---------------------------------------------- */ + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function escapeJs(s) { + return String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + } + + function cssEscape(s) { + if (window.CSS && window.CSS.escape) return window.CSS.escape(s); + return String(s).replace(/(["\\])/g, "\\$1"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/make_post_sell/templates/home.j2 b/make_post_sell/templates/home.j2 index e32f05c..c5ea714 100644 --- a/make_post_sell/templates/home.j2 +++ b/make_post_sell/templates/home.j2 @@ -103,10 +103,10 @@

{{ lane.tag.name }}

See all → -
+
{% for product in lane.products %} {% if product.is_ready %} -
+
{% if "thumbnail1" in product.extensions %} diff --git a/make_post_sell/templates/shop.j2 b/make_post_sell/templates/shop.j2 index 8ba6663..8ac0203 100644 --- a/make_post_sell/templates/shop.j2 +++ b/make_post_sell/templates/shop.j2 @@ -33,10 +33,10 @@

{{ lane.tag.name }}

See all → -
+
{% for product in lane.products %} {% if product.is_ready %} -
+
{% if "thumbnail1" in product.extensions %} diff --git a/make_post_sell/templates/shop_tags.j2 b/make_post_sell/templates/shop_tags.j2 index a0a9aeb..a82a1f9 100644 --- a/make_post_sell/templates/shop_tags.j2 +++ b/make_post_sell/templates/shop_tags.j2 @@ -10,9 +10,14 @@

+{# MPS-24 Phase 2.4: SPA flash region — JS injects toast messages here + instead of triggering a full page reload. No-JS clients see Pyramid's + own flash via base.j2 after the 302 redirect. #} +
+

Create a tag

-
+
-
-

All tags ({{ tags|length }})

+
+

All tags ({{ tags|length }})

{% if not tags %} -

No tags yet. Create one above, or open a product and add a tag inline.

- {% else %} -
{% if focus_tag %} @@ -121,16 +125,16 @@

Check a product to apply this tag; uncheck to remove it. Saves on click.

    {% for product in all_products %} -
  • +
  • {% if focus_tag in product.tags|list %} - + {% else %} -
    + @@ -144,4 +148,6 @@
{% endif %} + + {%- endblock -%} diff --git a/make_post_sell/templates/styleguide.j2 b/make_post_sell/templates/styleguide.j2 index f64be1b..6e001c2 100644 --- a/make_post_sell/templates/styleguide.j2 +++ b/make_post_sell/templates/styleguide.j2 @@ -872,30 +872,44 @@ Click filters the grid in place via static/js/tag_filter.js (no reload).
-
Sectioned lanes (MPS-24, layout 2)
+
Sectioned lanes — Netflix-style horizontal row (MPS-24, layout 2)
-
+

Math

See all →
-
.tag-lane — section wrapping a single category .tag-lane-header — grid 1fr auto, title on left, "See all" on right -.tag-lane-grid — .serp grid with capped product count +.tag-lane-grid — horizontal-scroll row (grid-auto-flow: column) +.tag-lane-tile — fixed-width tile, scroll-snap-align: start One lane per top tag (capped by shop.home_layout_tag_limit). -Cap products per lane via shop.home_layout_per_lane_limit.
+Cap products per lane via shop.home_layout_per_lane_limit. +Mobile: tiles narrow to 140-160px, swipe-friendly.
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index a961ba4..f405087 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -8678,3 +8678,137 @@ class TestHomeLayoutAndTags(_AuthenticatedBase): p = get_product_by_id(self.dbsession, pid) self.assertIsNotNone(p) self.assertEqual(list(p.tags), []) + + # --- MPS-24 Phase 2.4: SPA progressive enhancement ---------------- + + def test_ajax_create_returns_json(self): + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params={**self.shop1_params, "name": "ajax-create-shop"}, + ) + res = self.testapp.post( + f"/s/{shop.id}/tags", + {"action": "create", "name": "Math"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/json") + data = res.json + self.assertEqual(data["status"], "ok") + self.assertIn("tag", data) + self.assertEqual(data["tag"]["slug"], "math") + self.assertEqual(data["tag"]["product_count"], 0) + self.assertTrue(any("Math" in str(m) for m in data["messages"])) + + def test_ajax_delete_returns_json(self): + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params={**self.shop1_params, "name": "ajax-delete-shop"}, + ) + # Create then delete via AJAX + self.testapp.post( + f"/s/{shop.id}/tags", + {"action": "create", "name": "Holiday"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + res = self.testapp.post( + f"/s/{shop.id}/tags", + {"action": "delete", "tag_slug": "holiday"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + self.assertEqual(res.status_int, 200) + data = res.json + self.assertEqual(data["status"], "ok") + self.assertEqual(data["deleted_slug"], "holiday") + + def test_ajax_attach_detach_returns_json(self): + shop, products = self._make_shop_with_products( + "ajax-attach-shop", [("Alpha", "Body.")] + ) + # Create tag + self.testapp.post( + f"/s/{shop.id}/tags", + {"action": "create", "name": "Tag1"}, + ) + product_id = str(products[0].id) + + # Attach via AJAX + res = self.testapp.post( + f"/s/{shop.id}/tags", + { + "action": "attach", + "tag_slug": "tag1", + "product_id": product_id, + }, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + self.assertEqual(res.status_int, 200) + data = res.json + self.assertTrue(data["attached"]) + self.assertEqual(data["tag_slug"], "tag1") + self.assertEqual(data["product_id"], product_id) + + # Detach via AJAX + res = self.testapp.post( + f"/s/{shop.id}/tags", + { + "action": "detach", + "tag_slug": "tag1", + "product_id": product_id, + }, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + self.assertEqual(res.status_int, 200) + data = res.json + self.assertFalse(data["attached"]) + + def test_ajax_apply_suggestion_returns_json(self): + shop, products = self._make_shop_with_products( + "ajax-apply-shop", + [("Alpha", "Body."), ("Beta", "Body.")], + ) + product_ids = ",".join(str(p.id) for p in products) + res = self.testapp.post( + f"/s/{shop.id}/tags", + { + "action": "apply_suggestion", + "label": "Seasonal", + "product_ids": product_ids, + }, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + self.assertEqual(res.status_int, 200) + data = res.json + self.assertEqual(data["status"], "ok") + self.assertEqual(data["applied_count"], 2) + self.assertEqual(data["tag"]["slug"], "seasonal") + self.assertEqual(data["tag"]["product_count"], 2) + + def test_ajax_dismiss_suggestion_returns_json(self): + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params={**self.shop1_params, "name": "ajax-dismiss-shop"}, + ) + res = self.testapp.post( + f"/s/{shop.id}/tags", + {"action": "dismiss_suggestion", "label": "Foo Bar"}, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + self.assertEqual(res.status_int, 200) + data = res.json + self.assertEqual(data["status"], "ok") + self.assertIn("foo", data["dismissed_tokens"]) + self.assertIn("bar", data["dismissed_tokens"]) + + def test_non_ajax_still_redirects(self): + """Without X-Requested-With, every action 302-redirects (no-JS flow).""" + shop = self._create_shop_helper( + user_creds=self.user1_creds, + shop_params={**self.shop1_params, "name": "no-js-shop"}, + ) + res = self.testapp.post( + f"/s/{shop.id}/tags", + {"action": "create", "name": "Math"}, + # NO X-Requested-With + ) + self.assertEqual(res.status_int, 302) diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index 5f42b00..a632ac1 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -1974,10 +1974,34 @@ def shop_tag_detail(request): return ctx +def _is_ajax(request): + return request.headers.get("X-Requested-With") == "XMLHttpRequest" + + +def _tag_ajax_response(request, data=None): + """MPS-24 Phase 2.4: JSON response for the SPA-enhanced tag editor. + + Pops Pyramid flash so the JS can render toasts; piggybacks any + per-action payload (tag, suggestion, etc.) under `data`. + """ + messages = list(request.session.pop_flash()) + payload = {"status": "ok", "messages": messages} + if data: + payload.update(data) + return Response(json=payload, content_type="application/json") + + @view_config(route_name="shop_tags", renderer="shop_tags.j2") @shop_editor_required() def shop_tags(request): - """Bulk tag editor for shop operators (MPS-24).""" + """Bulk tag editor for shop operators (MPS-24). + + Capability-driven: every POST is a real form that 302-redirects + back to the editor for no-JS clients. With JS, + `static/js/tag_bulk.js` intercepts submits and POSTs with + `X-Requested-With: XMLHttpRequest`; the view detects the header + and returns JSON for in-place DOM updates. + """ shop = get_shop_from_matchdict(request) if shop is None: raise HTTPNotFound() @@ -1989,10 +2013,24 @@ def shop_tags(request): tag = get_or_create_tag(request.dbsession, shop, name) if tag is None: request.session.flash(("That tag name is invalid.", "error")) + if _is_ajax(request): + return _tag_ajax_response(request, {"status": "error"}) else: request.session.flash( (f"Tag '{tag.name}' is ready to use.", "success") ) + if _is_ajax(request): + return _tag_ajax_response( + request, + { + "tag": { + "id": tag.uuid_str, + "name": tag.name, + "slug": tag.slug, + "product_count": 0, + } + }, + ) return HTTPFound(f"/s/{shop.id}/tags") if action == "delete": @@ -2002,6 +2040,8 @@ def shop_tags(request): name = tag.name request.dbsession.delete(tag) request.session.flash((f"Tag '{name}' deleted.", "success")) + if _is_ajax(request): + return _tag_ajax_response(request, {"deleted_slug": tag_slug}) return HTTPFound(f"/s/{shop.id}/tags") if action in ("attach", "detach"): @@ -2015,6 +2055,7 @@ def shop_tags(request): if product_id else None ) + attached = None if ( tag is not None and product is not None @@ -2028,6 +2069,7 @@ def shop_tags(request): "success", ) ) + attached = True elif action == "detach" and tag in product.tags: product.tags.remove(tag) request.session.flash( @@ -2036,6 +2078,16 @@ def shop_tags(request): "success", ) ) + attached = False + if _is_ajax(request): + return _tag_ajax_response( + request, + { + "tag_slug": tag_slug, + "product_id": product_id, + "attached": attached, + }, + ) return HTTPFound( f"/s/{shop.id}/tags?focus={tag_slug}" ) @@ -2045,12 +2097,14 @@ def shop_tags(request): if action == "dismiss_suggestion": import json as _json label = (request.params.get("label") or "").strip() + dismissed_tokens = [] if label: existing = list(shop.tag_stopwords) tokens = [t.strip().lower() for t in label.split() if t.strip()] for tok in tokens: if tok and tok not in existing: existing.append(tok) + dismissed_tokens.append(tok) shop.tag_stopwords_json = _json.dumps(existing) request.session.flash( ( @@ -2058,6 +2112,11 @@ def shop_tags(request): "success", ) ) + if _is_ajax(request): + return _tag_ajax_response( + request, + {"label": label, "dismissed_tokens": dismissed_tokens}, + ) return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1") # MPS-24 Phase 2: one-click apply a suggested cluster — create the @@ -2072,12 +2131,16 @@ def shop_tags(request): request.session.flash( ("Could not apply suggestion (empty label or no products).", "error") ) + if _is_ajax(request): + return _tag_ajax_response(request, {"status": "error"}) return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1") tag = get_or_create_tag(request.dbsession, shop, label) if tag is None: request.session.flash( ("Could not create that tag (invalid name).", "error") ) + if _is_ajax(request): + return _tag_ajax_response(request, {"status": "error"}) return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1") applied = 0 for product_id in product_ids: @@ -2093,6 +2156,20 @@ def shop_tags(request): "success", ) ) + if _is_ajax(request): + return _tag_ajax_response( + request, + { + "label": label, + "tag": { + "id": tag.uuid_str, + "name": tag.name, + "slug": tag.slug, + "product_count": applied, + }, + "applied_count": applied, + }, + ) return HTTPFound(f"/s/{shop.id}/tags?show_suggestions=1") # GET: render bulk tagger.