feat: Google-SERP-style list rows on tag detail (Phase 1 redesign)
Phase 1 of fox's "drop the desktop-card framing, build SERP-style
list view that works at every resolution" redesign. Scope: tag detail
page only (/s/{shop_id}/tag/{slug}). If this shape lands well it
extends to filtered shop home + search + "See all" routes next.
templates/shop_tag.j2 — replaces .serp card grid with .serp-list
rows. Each row: thumbnail | (title + price + excerpt). Article
semantics (<article class="serp-list-row"> + <h3>) so the page
reads correctly to crawlers + screen readers.
models/product.py — new Product.excerpt(max_chars=180) method.
Strips common markdown markers (headings, emphasis, links, images,
blockquotes, code, list bullets) and backtracks to the nearest
sentence terminator or word boundary so the snippet doesn't end
mid-word. ~Google-snippet feel (default 180 chars).
static/css/common.css — mobile-first SERP-list layout. Default
grid-template-columns is 80px minmax(0, 1fr) — small thumbnail on
the left, text on the right, comfortable on phones. Container
queries (container-type: inline-size on .serp-list) bump the
thumbnail to 120px at 600px container width and 160px at 900px,
so the layout uses as much horizontal real estate as it gets
without ever wrapping. Excerpt has max-width: 70ch so the reading
band stays comfortable on wide monitors instead of stretching to
one-line-per-row.
Still TODO (next phases):
- left sidebar with categories on wide viewports
- apply this same SERP-list to filtered shop home + search
- convert lanes to either list rows or keep Netflix-style cards
based on fox's call after seeing this phase live
This commit is contained in:
parent
095d91bc68
commit
56649db952
3 changed files with 143 additions and 10 deletions
|
|
@ -295,6 +295,41 @@ class Product(RBase, Base):
|
|||
self.description = description
|
||||
self.description_html = markdown_to_html(description, self.shop)
|
||||
|
||||
def excerpt(self, max_chars=180):
|
||||
"""Plain-text snippet of the description for SERP-style rows
|
||||
(Google-snippet feel: ~160-180 chars, ends at a sentence/space
|
||||
boundary, no markdown chars, no HTML tags).
|
||||
|
||||
Built off the raw markdown rather than description_html so we
|
||||
don't have to import bleach or html.parser here — markdown's
|
||||
symbol set is small and easy to strip cheaply.
|
||||
"""
|
||||
if not self.description:
|
||||
return ""
|
||||
# Strip the common markdown markers: # heading, * / _ emphasis,
|
||||
# ` code, [text](url) → text, > blockquote, - bullet,
|
||||
# newlines → space. Order matters: handle links before stars.
|
||||
import re
|
||||
text = self.description
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images
|
||||
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links
|
||||
text = re.sub(r"[#>*_`~|]+", "", text) # md symbols
|
||||
text = re.sub(r"^[\s-]+", "", text, flags=re.M) # leading -/space
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
cut = text[:max_chars]
|
||||
# Backtrack to last sentence terminator or space so we don't cut
|
||||
# mid-word. Prefer .!? then space, then hard cut.
|
||||
for stop in (". ", "! ", "? "):
|
||||
idx = cut.rfind(stop)
|
||||
if idx >= max_chars * 0.5:
|
||||
return cut[: idx + 1].strip()
|
||||
idx = cut.rfind(" ")
|
||||
if idx >= max_chars * 0.5:
|
||||
return cut[:idx].rstrip() + "…"
|
||||
return cut.rstrip() + "…"
|
||||
|
||||
def set_price(self, price):
|
||||
self.updated_timestamp = now_timestamp()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1463,6 +1463,97 @@ form.serp-sort-form select {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
* SERP-list layout — Google-style horizontal rows (thumb | text-block).
|
||||
*
|
||||
* Mobile-first: each row is grid-template-columns 80px 1fr (small
|
||||
* thumbnail on left, title + price + excerpt on right). Container
|
||||
* queries scale the thumbnail to 120px at 600px container width and
|
||||
* 160px at 900px — uses as much horizontal real estate as the layout
|
||||
* gives us without ever wrapping awkwardly. Excerpt gets max-width:
|
||||
* 70ch so on huge monitors it stays in the comfortable reading band
|
||||
* instead of stretching into one line per row.
|
||||
* ===================================================================*/
|
||||
section.serp-list {
|
||||
display: grid;
|
||||
gap: var(--space-4, 16px);
|
||||
container-type: inline-size;
|
||||
margin: 0 0 var(--space-6, 24px) 0;
|
||||
}
|
||||
article.serp-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px minmax(0, 1fr);
|
||||
gap: var(--space-3, 12px);
|
||||
align-items: start;
|
||||
padding: var(--space-2, 8px);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
transition: background-color 200ms ease;
|
||||
}
|
||||
article.serp-list-row:hover {
|
||||
background-color: var(--surface-dim, #f9f9fa);
|
||||
}
|
||||
a.serp-list-thumb-link {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
img.serp-list-thumb {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
min-width: 0;
|
||||
}
|
||||
.serp-list-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--space-1, 4px);
|
||||
}
|
||||
h3.serp-list-title {
|
||||
margin: 0;
|
||||
font-size: var(--type-title-size, 1.125rem);
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
h3.serp-list-title a { text-decoration: none; }
|
||||
h3.serp-list-title a:hover { text-decoration: underline; }
|
||||
p.serp-list-price {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-green, #4d7a1f);
|
||||
}
|
||||
p.serp-list-price a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
p.serp-list-excerpt {
|
||||
margin: 0;
|
||||
color: var(--text-color, #333);
|
||||
font-size: var(--type-body-sm-size, 0.875rem);
|
||||
line-height: 1.5;
|
||||
max-width: 70ch;
|
||||
}
|
||||
[data-theme="dark"] article.serp-list-row:hover {
|
||||
background-color: var(--dark-button-bg, #2d3748);
|
||||
}
|
||||
|
||||
/* Container queries — bump the thumbnail column up as the section
|
||||
grows. The parent .serp-list is the query container. */
|
||||
@container (min-width: 600px) {
|
||||
article.serp-list-row {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: var(--space-4, 16px);
|
||||
}
|
||||
h3.serp-list-title {
|
||||
font-size: var(--type-title-size, 1.25rem);
|
||||
}
|
||||
}
|
||||
@container (min-width: 900px) {
|
||||
article.serp-list-row {
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sectioned-lanes layout — one lane per top tag. Netflix-style
|
||||
horizontal row with scroll-snap. */
|
||||
section.tag-lane {
|
||||
|
|
|
|||
|
|
@ -34,21 +34,28 @@
|
|||
<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>
|
||||
<section class="serp-list" data-tag-grid>
|
||||
{% for product in products %}
|
||||
{% if product.is_ready %}
|
||||
<div class="serp-item">
|
||||
<article class="serp-list-row">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-thumbnail" />
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow" class="serp-list-thumb-link">
|
||||
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="serp-list-thumb" loading="lazy" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<b><a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a></b>
|
||||
{% if product.is_sellable %}
|
||||
<br>
|
||||
<a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="serp-list-body">
|
||||
<h3 class="serp-list-title">
|
||||
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color">{{ product.title }}</a>
|
||||
</h3>
|
||||
{% if product.is_sellable %}
|
||||
<p class="serp-list-price"><a href="{{ product.absolute_url(request) }}" rel="nofollow">${{ '{:,.2f}'.format(product.price) }}</a></p>
|
||||
{% endif %}
|
||||
{% set snippet = product.excerpt(200) %}
|
||||
{% if snippet %}
|
||||
<p class="serp-list-excerpt">{{ snippet }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue