diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index c9413e4..78dfe7f 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -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: diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 84a2c80..d9e5111 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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 { diff --git a/make_post_sell/templates/shop_tag.j2 b/make_post_sell/templates/shop_tag.j2 index 61195d4..8efb4c6 100644 --- a/make_post_sell/templates/shop_tag.j2 +++ b/make_post_sell/templates/shop_tag.j2 @@ -34,21 +34,28 @@ -
+
{% for product in products %} {% if product.is_ready %} -
+
+
+

+ {{ product.title }} +

+ {% if product.is_sellable %} +

${{ '{:,.2f}'.format(product.price) }}

+ {% endif %} + {% set snippet = product.excerpt(200) %} + {% if snippet %} +

{{ snippet }}

+ {% endif %} +
+ {% endif %} {% endfor %}