feat: sort dropdown on tag detail + filtered shop home (MPS-24)
Six sort options on category/tag pages, controlled by ?sort=: newest Newest first (default — created_timestamp desc) oldest Oldest first (created_timestamp asc) price_asc Price low → high (price_in_cents asc) price_desc Price high → low (price_in_cents desc) title Alphabetical A→Z (lowercased title) popular Most popular (28d) (PageSession views with visible_ms ≥ 7s) Rendered as a <select> right-aligned above the grid. Capability-driven: plain GET form works without JS (button appears via <noscript>), JS-on auto-submits on change. Both shop_tag.j2 and home.j2 (filtered branch only — lanes keep their editorial order) get the dropdown. views/shop.py — new SORT_OPTIONS list + _sort_key_from_request + _sort_products + _popular_view_counts helpers. Sort runs in Python on the already-filtered list, so it's O(N log N) on the tagged subset not the whole shop catalog. The 'popular' option fires one extra PageSession aggregate query scoped to the filtered product_ids; ~tens of ms on indexed (shop_id, product_id, created_timestamp). Doesn't address fox's bigger ask: Netflix-style truly-responsive SERP layout with left nav + description-excerpt rows that works at every resolution. That's a real redesign — proposing it next.
This commit is contained in:
parent
18a8566023
commit
095d91bc68
4 changed files with 156 additions and 1 deletions
|
|
@ -1430,6 +1430,39 @@ a.tag-chip-active:hover {
|
|||
background: var(--dark-button-bg-hover, #3a4658);
|
||||
}
|
||||
|
||||
/* Sort dropdown — shown on tag detail and filtered shop home above
|
||||
the grid. Right-aligned on a single row at desktop, full width on
|
||||
mobile (label above select). Grid only — no flexbox. */
|
||||
form.serp-sort-form {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 8px);
|
||||
margin: 0 0 var(--space-3, 12px) 0;
|
||||
padding: 0 var(--space-1, 4px);
|
||||
}
|
||||
form.serp-sort-form .serp-sort-label {
|
||||
color: var(--text-muted, #666);
|
||||
font-size: var(--type-body-sm-size, 0.875rem);
|
||||
}
|
||||
form.serp-sort-form select {
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--border-color, #dee2e6);
|
||||
background: var(--surface-color, #fff);
|
||||
color: var(--text-color, #222);
|
||||
font-size: var(--type-body-sm-size, 0.875rem);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
/* On narrow screens, label sits above the select instead of
|
||||
crowding the row. Still right-aligned. */
|
||||
form.serp-sort-form {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sectioned-lanes layout — one lane per top tag. Netflix-style
|
||||
horizontal row with scroll-snap. */
|
||||
section.tag-lane {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,22 @@
|
|||
when no tag filter is active. #}
|
||||
{% set grid_products = filtered_products if filtered_products is defined and filtered_products is not none else products %}
|
||||
{% if grid_products %}
|
||||
{% if active_tag and sort_options is defined %}
|
||||
{# Sort dropdown — only shown on filtered views (when a tag is
|
||||
active). Lane home doesn't show this; lanes have a fixed editorial
|
||||
order. Capability-driven: plain GET form works without JS,
|
||||
auto-submits on change with JS. The ?tag= param is preserved. #}
|
||||
<form method="get" action="" class="serp-sort-form">
|
||||
<input type="hidden" name="tag" value="{{ active_tag.slug }}" />
|
||||
<label for="serp-sort" class="serp-sort-label">Sort by</label>
|
||||
<select id="serp-sort" name="sort" onchange="this.form.submit()">
|
||||
{% for key, label in sort_options %}
|
||||
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<noscript><button type="submit" class="mps-button mps-button-small">Apply</button></noscript>
|
||||
</form>
|
||||
{% endif %}
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
|
||||
{% for product in grid_products %}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@
|
|||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{# Sort dropdown — capability-driven: works as a plain GET form
|
||||
without JS, auto-submits on change with JS. #}
|
||||
<form method="get" action="" class="serp-sort-form">
|
||||
<label for="serp-sort" class="serp-sort-label">Sort by</label>
|
||||
<select id="serp-sort" name="sort" onchange="this.form.submit()">
|
||||
{% for key, label in sort_options %}
|
||||
<option value="{{ key }}"{% if key == sort_key %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<noscript><button type="submit" class="mps-button mps-button-small">Apply</button></noscript>
|
||||
</form>
|
||||
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
|
||||
{% for product in products %}
|
||||
{% if product.is_ready %}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,86 @@ def get_shop_from_matchdict(request, prefetched_shop=None):
|
|||
return get_shop_by_id(request.dbsession, request.matchdict["shop_id"])
|
||||
|
||||
|
||||
# MPS-24: sort options surfaced as a <select> dropdown on tag detail
|
||||
# pages and any filtered grid view. Keep the keys short — they appear
|
||||
# in URL query strings (?sort=newest). Order in this list controls the
|
||||
# order in the dropdown UI.
|
||||
SORT_OPTIONS = [
|
||||
("newest", "Newest first"),
|
||||
("oldest", "Oldest first"),
|
||||
("price_asc", "Price: low to high"),
|
||||
("price_desc", "Price: high to low"),
|
||||
("title", "Alphabetical (A→Z)"),
|
||||
("popular", "Most popular (28d)"),
|
||||
]
|
||||
SORT_KEYS = {k for k, _ in SORT_OPTIONS}
|
||||
DEFAULT_SORT = "newest"
|
||||
|
||||
|
||||
def _sort_key_from_request(request):
|
||||
"""Read ?sort= from the request, falling back to default if absent
|
||||
or unknown. Lower-cased + stripped to be forgiving."""
|
||||
raw = (request.params.get("sort") or "").strip().lower()
|
||||
return raw if raw in SORT_KEYS else DEFAULT_SORT
|
||||
|
||||
|
||||
def _popular_view_counts(dbsession, shop_id, product_ids, days=28):
|
||||
"""Return {product_id: view_count} over the last `days`. Used by the
|
||||
'popular' sort option. Filters by visible_ms >= 7000 to match the
|
||||
same 'real view' definition used in analytics, and excludes owner
|
||||
traffic. One query, returns a dict for cheap in-Python sort."""
|
||||
if not product_ids:
|
||||
return {}
|
||||
from sqlalchemy import func, and_, or_
|
||||
from ..models.page_session import PageSession
|
||||
from ..models.meta import now_timestamp
|
||||
cutoff_ms = now_timestamp() - days * 24 * 60 * 60 * 1000
|
||||
rows = (
|
||||
dbsession.query(
|
||||
PageSession.product_id,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.filter(
|
||||
PageSession.shop_id == shop_id,
|
||||
PageSession.product_id.in_(product_ids),
|
||||
PageSession.visible_ms >= 7000,
|
||||
PageSession.created_timestamp > cutoff_ms,
|
||||
or_(PageSession.is_owner == None, PageSession.is_owner == False), # noqa: E711,E712
|
||||
)
|
||||
.group_by(PageSession.product_id)
|
||||
.all()
|
||||
)
|
||||
return {r.product_id: r.cnt for r in rows}
|
||||
|
||||
|
||||
def _sort_products(products, sort_key, dbsession=None, shop_id=None):
|
||||
"""Sort an in-Python list of Product objects by sort_key. Stable —
|
||||
products without the sort attribute keep their original order
|
||||
relative to each other. Falls back to created_timestamp desc for
|
||||
any unknown key."""
|
||||
if not products:
|
||||
return products
|
||||
if sort_key == "oldest":
|
||||
return sorted(products, key=lambda p: p.created_timestamp or 0)
|
||||
if sort_key == "price_asc":
|
||||
return sorted(products, key=lambda p: p.price_in_cents or 0)
|
||||
if sort_key == "price_desc":
|
||||
return sorted(products, key=lambda p: p.price_in_cents or 0, reverse=True)
|
||||
if sort_key == "title":
|
||||
return sorted(products, key=lambda p: (p.title or "").lower())
|
||||
if sort_key == "popular":
|
||||
if dbsession is None or shop_id is None:
|
||||
return products
|
||||
counts = _popular_view_counts(
|
||||
dbsession, shop_id, [p.id for p in products]
|
||||
)
|
||||
# Sort by view count desc; products with zero views keep their
|
||||
# incoming order at the end via a stable secondary key.
|
||||
return sorted(products, key=lambda p: -counts.get(p.id, 0))
|
||||
# newest (default) and anything unknown
|
||||
return sorted(products, key=lambda p: -(p.created_timestamp or 0))
|
||||
|
||||
|
||||
def _build_home_layout_context(request, shop, products):
|
||||
"""
|
||||
MPS-24: derive home-layout context (chips/lanes/featured) for a shop home
|
||||
|
|
@ -237,11 +317,14 @@ def _build_home_layout_context(request, shop, products):
|
|||
else:
|
||||
products_list = list(products)
|
||||
|
||||
sort_key = _sort_key_from_request(request)
|
||||
ctx = {
|
||||
"home_chips": [],
|
||||
"home_lanes": [],
|
||||
"active_tag": None,
|
||||
"filtered_products": products_list,
|
||||
"sort_key": sort_key,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
}
|
||||
if shop is None:
|
||||
return ctx
|
||||
|
|
@ -271,9 +354,13 @@ def _build_home_layout_context(request, shop, products):
|
|||
.all()
|
||||
}
|
||||
if products_list is not None:
|
||||
ctx["filtered_products"] = [
|
||||
filtered = [
|
||||
p for p in products_list if p.id in tagged_ids
|
||||
]
|
||||
ctx["filtered_products"] = _sort_products(
|
||||
filtered, sort_key,
|
||||
dbsession=request.dbsession, shop_id=shop.id,
|
||||
)
|
||||
|
||||
# Sectioned lanes only when layout == 2 and the shopper hasn't already
|
||||
# filtered to one tag (filtering trumps lanes — single grid in that case).
|
||||
|
|
@ -1974,6 +2061,11 @@ def shop_tag_detail(request):
|
|||
.all()
|
||||
}
|
||||
filtered = [p for p in products if p.id in tagged_ids]
|
||||
sort_key = _sort_key_from_request(request)
|
||||
filtered = _sort_products(
|
||||
filtered, sort_key,
|
||||
dbsession=request.dbsession, shop_id=shop.id,
|
||||
)
|
||||
ctx = {
|
||||
"products": filtered,
|
||||
"active_tag": tag,
|
||||
|
|
@ -1984,6 +2076,8 @@ def shop_tag_detail(request):
|
|||
),
|
||||
"home_lanes": [],
|
||||
"filtered_products": filtered,
|
||||
"sort_key": sort_key,
|
||||
"sort_options": SORT_OPTIONS,
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue