From 4e203c4001f64934f1cb4f24f4a8feed216146dd Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 15 May 2026 11:40:46 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20product=20thumbnail=20hover/click=20swa?= =?UTF-8?q?p=20=E2=80=94=20capability-driven=20enhancement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /static/js/product-thumbnail-swap.js (loaded with `defer` only when thumbnail1 is present in product.extensions): hovering a thumbnail swaps the .product-main preview image to that thumb's source; clicking sticks the swap and preventDefault()s the anchor so the customer stays on the product page. Mouseleave on the .product-images container restores the cover image unless a click has pinned it. The no-JS path is unchanged — each thumbnail stays wrapped in an so clicking still opens the full image in a new tab when the script isn't loaded. The script self-bails on non-img main elements (watch-mode video / audio art) and on .audio-cover so album-art swap doesn't fire on audio products. CSS: .product-thumbnail gains a brief border-color transition + a :hover navy outline so the affordance reads without changing markup. Test: asset-served check for the new JS file. The template wiring is a single guarded line; if it regresses, the visual smoke test on the product page catches it. --- make_post_sell/static/css/common.css | 7 ++ .../static/js/product-thumbnail-swap.js | 65 +++++++++++++++++++ make_post_sell/templates/product.j2 | 6 ++ make_post_sell/tests/test_functional.py | 12 ++++ 4 files changed, 90 insertions(+) create mode 100644 make_post_sell/static/js/product-thumbnail-swap.js diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 249a54e..ad50582 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -635,6 +635,13 @@ img.product-thumbnail { max-height:58px; width: auto; height: auto; + transition: border-color var(--duration-fast, 100ms) var(--ease-standard, ease); +} +/* Lit-up affordance when the JS hover-swap enhancement is active — + product-thumbnail-swap.js binds hover/click handlers; the visual cue + ships in CSS so it works without changing the markup. */ +.product-images img.product-thumbnail:hover { + border-color: var(--color-navy, #5871ad); } .product-main { diff --git a/make_post_sell/static/js/product-thumbnail-swap.js b/make_post_sell/static/js/product-thumbnail-swap.js new file mode 100644 index 0000000..5b6ff56 --- /dev/null +++ b/make_post_sell/static/js/product-thumbnail-swap.js @@ -0,0 +1,65 @@ +/* Product thumbnail swap — capability-driven enhancement. + * + * No JS: each thumbnail under .product-images is wrapped in an + * so a click opens the full + * image in a new tab — that path is unchanged. + * + * With JS: hovering a thumbnail swaps the main image preview to that + * thumbnail's source; clicking sticks the swap and suppresses the + * default navigation (so the customer stays on the product page and + * keeps cycling through views, Amazon-style). + * + * Bails silently when: + * - there's no .product-images container + * - the main element isn't an (watch-mode video / audio art) + * - .product-main is the audio-cover (swapping album art on hover + * would be confusing) + * - there are no .product-thumbnail children + */ +(function () { + "use strict"; + + var container = document.querySelector(".product-images"); + if (!container) return; + + var mainEl = container.querySelector("img.product-main"); + if (!mainEl) return; + if (mainEl.classList.contains("audio-cover")) return; + + var thumbs = container.querySelectorAll("a > img.product-thumbnail"); + if (thumbs.length === 0) return; + + // Remember the original src so we never lose the cover image; on + // mouseleave of the whole strip we restore it (matches expected + // "hover to preview, leave to see the cover again" behavior). + var originalSrc = mainEl.getAttribute("src"); + var sticky = false; // a click pins the swap; mouseleave won't restore. + + function swapTo(url) { + if (url && mainEl.getAttribute("src") !== url) { + mainEl.setAttribute("src", url); + } + } + + Array.prototype.forEach.call(thumbs, function (img) { + var anchor = img.parentElement; + + img.addEventListener("mouseenter", function () { + swapTo(img.src); + }); + + if (anchor && anchor.tagName === "A") { + anchor.addEventListener("click", function (e) { + e.preventDefault(); + sticky = true; + swapTo(img.src); + }); + } + }); + + container.addEventListener("mouseleave", function () { + if (!sticky && originalSrc) { + swapTo(originalSrc); + } + }); +})(); diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index bd7cc62..4e096a6 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -132,6 +132,12 @@ {% endif %} {% endfor %} + {% if "thumbnail1" in product.extensions %} + {# Capability-driven enhancement: hover/click a thumbnail to swap + the main preview. The no-JS path keeps the original + open-in-new-tab behavior. #} + + {% endif %}
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 48138a9..8311f15 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -7307,6 +7307,18 @@ class TestOfferCheckout(_AuthenticatedBase): self.assertIn("auction-page", body) self.assertIn("auction-countdown", body) + def test_product_thumbnail_swap_js_served(self): + """Capability-driven hover/click swap of the product cover image + against the thumbnail strip — the JS itself is asset-tested + here; the template wires it only when thumbnail1 is present.""" + res = self.testapp.get( + "/static/js/product-thumbnail-swap.js", status=200, + ) + body = res.body.decode() + self.assertIn(".product-images", body) + self.assertIn(".product-thumbnail", body) + self.assertIn("mouseenter", body) + def test_auction_page_loads_auction_js(self): """MPS-20: auction.j2 references /static/js/auction.js.""" from ..models.auction import (