feat: MPS-24 — shop home page categorization (tags + chips + sectioned lanes)
Operator feedback on shop.printableprompts.com flagged our flat default
home page as the reason for considering a move to Shopify. This adds an
opt-in home_layout selector with the navigation primitives shoppers expect
from a modern catalog — fewer clicks to a relevant product.
Phase 1 shipped (default unchanged for every existing shop):
- New Tag + ProductTag models, shop-scoped, many-per-product, flat (no tree)
- Shop.home_layout (0=flat / 1=chips / 2=lanes) plus tag/lane caps, optional
featured strip, and per-shop tag stopwords for the Phase 2 auto-tagger
- home-layout-settings form section in shop_settings.j2
- Bulk tag editor at /s/{shop_id}/tags with apply/remove per product
- Public tag detail page at /s/{shop_id}/tag/{slug} (works without JS)
- Comma-separated tag input on the product edit form
- home.j2 / shop.j2 branch on layout — chip strip for layout 1, sectioned
lanes for layout 2, flat unchanged for layout 0
- /search results page also receives the chip strip so shoppers can narrow
keyword results by tag
- static/js/tag_filter.js progressively enhances chip clicks into in-place
grid filtering via data-tag-slugs — zero navigation cost, capability-driven
fallback to ?tag= URL nav with no JS
- New chip / lane CSS in common.css — tokens only, Grid only (no flexbox)
- Live tag-chip + tag-lane examples in /styleguide under #cards
- Idempotent Alembic migration creates 2 tables + 5 shop columns with
server_default + _table_exists / _column_exists guards
- 24 new tests across unit + functional layers (1049 total passing)
- New "Ticket Scoping — One Feature, One Ticket" rule in CLAUDE.md;
Phase 2 (deterministic auto-tag from titles) and Phase 3 (uncloseai-
backed ML categorization behind a kill switch) stay under this ticket
This commit is contained in:
parent
3e4e663498
commit
c03ca53fb4
26 changed files with 2139 additions and 14 deletions
62
CLAUDE.md
62
CLAUDE.md
|
|
@ -335,6 +335,19 @@ After completing a feature or significant change, always perform these chores be
|
|||
|
||||
**CRITICAL**: Do not include Claude Code attribution in commit messages. Attributing human work to Claude is inappropriate and misrepresents our actual authorship of our code. All code changes should be attributed to our human developer who reviewed, approved, and committed our work.
|
||||
|
||||
## Ticket Scoping — One Feature, One Ticket
|
||||
|
||||
**Prefer one ticket that fully scopes a feature** over splitting it into `MPS-N`, `MPS-N+1`, `MPS-N+2` phase tickets. Phases inside a single ticket are fine — they let us land work incrementally — but they live in **one document** with **one ticket number**.
|
||||
|
||||
Spawn a new ticket only when:
|
||||
- A "next phase" is a genuinely separate feature with different goals or stakeholders
|
||||
- A "next phase" is gated on something external (a vendor decision, another team's work)
|
||||
- A "next phase" has uncertain priority and may never ship
|
||||
|
||||
**Why**: splitting one feature across `MPS-24` / `MPS-25` / `MPS-26` fragments commit history, multiplies status pages, and forces a future reader to re-stitch three documents to understand one change. One ticket, with `### Phase 1` / `### Phase 2` headings inside it, is our default. Multiple tickets is our exception, not our pattern.
|
||||
|
||||
When in doubt: write it as one ticket. If it grows past ~400 lines or the phases truly drift in goal, split it then — not pre-emptively.
|
||||
|
||||
## Capability-Driven Presentation
|
||||
|
||||
Follow Russell Ballestrini's capability-driven presentation practice
|
||||
|
|
@ -423,6 +436,55 @@ Depth 20 covers any legitimate nesting while keeping N well below our exponentia
|
|||
Bleach version: 6.3.0 (html5lib 1.1 vendored inside bleach).
|
||||
Every webapp calling `bleach.clean(user_html)` is exposed — this is our correct fix.
|
||||
|
||||
## Shop Home Layout + Tags (MPS-24)
|
||||
|
||||
Optional categorization to cut shopper clicks-to-purchase on shops with a
|
||||
catalog too large for a flat grid (printableprompts.com has 481 K-1
|
||||
printables — `Math`, `Seasonal`, `Literacy`, `Novel Studies` are obvious
|
||||
groups but pre-MPS-24 the home page didn't surface them).
|
||||
|
||||
Models: `Tag` (`mps_tag`, shop-scoped) + `ProductTag` association
|
||||
(`mps_product_tag`). Tags are flat (no tree), many-per-product, unique
|
||||
per `(shop_id, slug)`. `Product.tags` is an `association_proxy` collection.
|
||||
|
||||
Shop columns:
|
||||
- `home_layout` (Integer, 0=flat / 1=chips / 2=lanes, default 0)
|
||||
- `home_layout_tag_limit` (Integer, default 8) — caps chips + lanes
|
||||
- `home_layout_per_lane_limit` (Integer, default 10) — products per lane
|
||||
- `featured_product_ids_json` (UnicodeText) — optional curated strip
|
||||
- `tag_stopwords_json` (UnicodeText) — auto-tagger exclusions (Phase 2)
|
||||
|
||||
Form section: `home-layout-settings` (`views/shop.py`,
|
||||
`templates/shop_settings.j2`). Adds the 4 layout columns + featured +
|
||||
stopwords inputs.
|
||||
|
||||
Routes (registered before `shop_slug` catch-all):
|
||||
- `/s/{shop_id}/tags` — operator bulk tagger (`@shop_editor_required`)
|
||||
- `/s/{shop_id}/tag/{slug}` — public tag detail page (works without JS)
|
||||
|
||||
Tag input on product edit: comma-separated `tags` field on
|
||||
`product_edit.j2`; server slugifies, dedupes, upserts.
|
||||
|
||||
Home rendering: `views/shop.py:_build_home_layout_context()` builds
|
||||
`home_chips` (list of `Tag`), `home_lanes` (list of
|
||||
`{"tag": Tag, "products": [...]}`), `active_tag` (Tag or None),
|
||||
`filtered_products` (list). `home.j2` + `shop.j2` branch on
|
||||
`shop.home_layout` to render flat / chips / lanes. `search` view also
|
||||
receives the chip strip so `/search?keywords=X&tag=Y` works.
|
||||
|
||||
Capability-driven: every chip is a real `<a href="?tag=<slug>">` — server
|
||||
filters on no-JS. With JS, `static/js/tag_filter.js` intercepts clicks
|
||||
and filters the grid in place via `data-tag-slugs` attribute on
|
||||
`.serp-item`, zero network cost, fewer clicks to purchase.
|
||||
|
||||
Phase 2 (this ticket, follow-on commit): sectioned-lane layout (`==2`)
|
||||
+ deterministic auto-tagger script (`scripts/backfill_tags.py`) that
|
||||
clusters by shared title keywords minus stopwords.
|
||||
|
||||
Phase 3 (this ticket, gated): ML categorization via uncloseai endpoint
|
||||
behind `app.features.ml_categorization.enabled` kill switch (mirror MPS-22).
|
||||
Suggest-then-approve; never auto-commit.
|
||||
|
||||
## Auction & Make-an-Offer (MPS-20 + MPS-21)
|
||||
|
||||
Both ride on `Product.pricing_mode` (Integer, 0=fixed, 1=auction,
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ mps_page_session (raw rows)
|
|||
| Offer expiration (hours) | `shop.offer_expiration_hours` | `offer-settings` | 48 |
|
||||
| Offer pay window post-accept (hours) | `shop.offer_acceptance_payment_hours` | `offer-settings` | 24 |
|
||||
| In-app notifications | (always on, no toggle — every txn email also drops a row) | n/a | On |
|
||||
| Home page layout (MPS-24) | `shop.home_layout` (0=flat / 1=chips / 2=lanes) | `home-layout-settings` | 0 (flat) |
|
||||
| Tag chip / lane caps (MPS-24) | `shop.home_layout_tag_limit` / `shop.home_layout_per_lane_limit` | `home-layout-settings` | 8 / 10 |
|
||||
| Featured products strip (MPS-24) | `shop.featured_product_ids_json` | `home-layout-settings` | empty |
|
||||
| Product tags (MPS-24) | `Tag` + `ProductTag` association | product edit + `/s/{id}/tags` bulk editor | — |
|
||||
|
||||
## Ticket Index
|
||||
|
||||
|
|
@ -240,6 +244,7 @@ mps_page_session (raw rows)
|
|||
| [MPS-21](tickets/mps-21.md) | Make-an-Offer Mode | Complete |
|
||||
| [MPS-22](tickets/mps-22.md) | Kill-Switch Feature Flags — Karaoke + Torrent Off by Default | Complete |
|
||||
| [MPS-23](tickets/mps-23.md) | Consolidated Transactional Sender Identity + Shop Contact Email | Open |
|
||||
| [MPS-24](tickets/mps-24.md) | Shop home page overhaul + product categorization (tags + chips + lanes) | In progress (Phase 1 landed) |
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ All components are documented with live examples at `/styleguide`. The styleguid
|
|||
| Wells | `#wells` | Content wells and containers |
|
||||
| Alerts | `#alerts` | Success, info, warning, danger alerts |
|
||||
| Status | `#status` | Status indicators |
|
||||
| Product Cards | `#cards` | Product grid cards, profile card (`.profile-card-header` / `.profile-avatar` / `.profile-handle` / `.profile-email-reveal`), action button grid (`.action-columns` / `.action-button-grid`) |
|
||||
| Product Cards | `#cards` | Product grid cards, profile card (`.profile-card-header` / `.profile-avatar` / `.profile-handle` / `.profile-email-reveal`), action button grid (`.action-columns` / `.action-button-grid`), MPS-24 tag chips (`#tag-chips`), MPS-24 sectioned lanes (`#tag-lanes`) |
|
||||
| Cart | `#cart` | Cart and checkout components |
|
||||
| Gift Cards | `#gift-cards` | Gift card purchase, balance check, management |
|
||||
| Comments | `#comments` | Comment form and list |
|
||||
|
|
@ -266,6 +266,23 @@ All components are documented with live examples at `/styleguide`. The styleguid
|
|||
| `.billing-page` / `.billing-paypal-card` / `.billing-actions` | `billing.j2` | `/billing` redesigned with Grid + tokens (no inline styles) |
|
||||
| `[data-pay-deadline]` | offer + auction + cart | Convention attribute the shared `static/js/pay-countdown.js` (and inline tickers in `offer.js` / `auction.js`) rewrite once per second with a prose human delta ("in 23 hours, 14 minutes, 8 seconds") matching the server-rendered `ago.human()` fallback |
|
||||
|
||||
### MPS-24 components — tags, chips, lanes
|
||||
|
||||
| Class | Where | Description |
|
||||
|---|---|---|
|
||||
| `.tag-chip-strip` | `home.j2`, `shop.j2`, `shop_tag.j2` | Horizontally scrolling row of chips at the top of the shop home; `grid-auto-flow: column` + `overflow-x: auto` so chips swipe on mobile |
|
||||
| `.tag-chip` | every chip strip + bulk tagger | Pill-shaped link, tokens-only colors, hover brightens border + bg |
|
||||
| `.tag-chip-active` | current filter chip | Accent-fill chip; click on home filters the grid in place via `static/js/tag_filter.js` |
|
||||
| `.tag-lane` | `home.j2`, `shop.j2` (layout 2) | One `<section>` per top tag; capped product count via `shop.home_layout_per_lane_limit` |
|
||||
| `.tag-lane-header` | inside `.tag-lane` | Grid 1fr / auto — lane title left, "See all →" right |
|
||||
| `.tag-lane-grid` | inside `.tag-lane` | Reuses `.serp` grid pattern; horizontal lane is the *outer* layout, items inside still use product grid |
|
||||
| `.tag-list` / `.tag-list-item` | `shop_tags.j2` (bulk tagger) | Operator tag overview: tag pill + product count + view / delete row |
|
||||
| `.tag-product-list` / `.tag-product-row` | `shop_tags.j2` (bulk tagger) | "Apply / Applied" toggle per product per tag |
|
||||
| `.tag-detail-header` | `shop_tag.j2` | Tag detail page header (tag name + back link) |
|
||||
| `[data-tag-strip]` | chip strip | JS hook for `tag_filter.js` |
|
||||
| `[data-tag-grid]` | flat `.serp` | JS hook — items inside carry `data-tag-slugs` for in-place filter |
|
||||
| `[data-tag-slugs]` | `.serp-item` | Space-separated tag slugs the item carries; consumed by `tag_filter.js` |
|
||||
|
||||
## CSS Conventions
|
||||
|
||||
### Layout
|
||||
|
|
|
|||
344
docs/tickets/mps-24.md
Normal file
344
docs/tickets/mps-24.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# MPS-24: Shop home page overhaul + product categorization
|
||||
|
||||
## Status
|
||||
|
||||
**PHASE 1 SHIPPED (2026-05-15).** Tag model + chip strip + sectioned-lanes
|
||||
layout + bulk tagger live behind an opt-in `home_layout` selector (default
|
||||
`0` = flat = unchanged). Phase 2 (auto-tag from titles) and Phase 3
|
||||
(ML-assisted via uncloseai) follow under this same ticket per
|
||||
CLAUDE.md "One Feature, One Ticket".
|
||||
|
||||
**Background:** operator feedback on `shop.printableprompts.com` flagged our
|
||||
default home page as the reason for considering a move to Shopify. We
|
||||
needed an opt-in home-page layout overhaul and a way to surface natural
|
||||
product categories so shoppers can browse a 481-item shop without
|
||||
scrolling a flat list.
|
||||
|
||||
## Problem
|
||||
|
||||
`shop.printableprompts.com` is a digital-printables shop with **481 products**,
|
||||
all K-1 classroom materials. Crawled 2026-05-15 from
|
||||
`https://shop.printableprompts.com/sitemap.xml`:
|
||||
|
||||
- 481 product pages, 2 shop pages, no tag/category pages (none exist).
|
||||
- Natural groupings are obvious from titles alone: Math (`Addition to 10`,
|
||||
`Counting to 100`), Seasonal/Holiday (`Valentine's Day`, `St. Patrick's Day`,
|
||||
`Christmas`), Literacy (`Little Red Hen`, `Frog and Toad`), Science (`Life
|
||||
Cycle of a Butterfly`, `Solar Eclipse`), Novel Studies (`Stone Fox`,
|
||||
`Chocolate Touch`), Procedural Writing (`How to Build a Snowman`), Thematic
|
||||
Units (`Thanksgiving Writing`).
|
||||
- Our home today renders a flat chronological grid with no way to filter,
|
||||
group, or jump to a topic.
|
||||
|
||||
### Why an operator would reach for Shopify
|
||||
|
||||
Shopify shops get **collections** (operator-defined groups), **automated
|
||||
collections** (rule-based — "all products with tag X"), a sectioned home page
|
||||
template, collection lanes on home, a faceted product index, and tag-based
|
||||
search. None of that exists in MPS.
|
||||
|
||||
### Current MPS home page
|
||||
|
||||
- Route: `home` / `shop` / `shop_slug` → `views/shop.py:212` (`home`) +
|
||||
`views/shop.py:226` (`shop`).
|
||||
- Template: `templates/home.j2` (shared by site root + merchant shop).
|
||||
- Data: `get_products_from_a_shop(shop, visibility=1)` at
|
||||
`models/product.py:730` — single query, ordered by `updated_timestamp DESC`,
|
||||
no grouping, no filtering, no pagination.
|
||||
- Visible features: optional sales-stats banner, flat `.serp` grid, optional
|
||||
subscription CTA. That's it.
|
||||
|
||||
### What we already have (do not rebuild)
|
||||
|
||||
| Capability | Where | Notes |
|
||||
|---|---|---|
|
||||
| Visibility (public/private/unlisted) | `Product.visibility` (`product.py:130`) | Already filters home grid |
|
||||
| Digital vs physical | `Product.is_physical` (`product.py:157`) | Binary, not a category |
|
||||
| Sellable vs content | `Product.is_sellable` (`product.py:154`) | Blog post vs product |
|
||||
| Pricing modes | `Product.pricing_mode` (`product.py:137`) | Fixed/auction/offer combos |
|
||||
| Grid lanes (masonry) | `Shop.grid_lanes_enabled` (`shop.py:71`) | Layout polish only |
|
||||
| Watch mode SPA | `Shop.watch_mode_enabled` (`shop.py:150`) | Sticky media SPA |
|
||||
| Discovery ring (circular order) | `Shop.json_discovery_ring` (`shop.py:216`) | For watch mode SPA, not home |
|
||||
| Full-text title search | `views/shop.py:275` → `get_products_by_keywords` (`product.py:740`) | Title `ilike`, no tags, no description |
|
||||
| Sandbox mode (creative filters) | `Shop.sandbox_mode` (`shop.py:166`) | Image filters — not categorization |
|
||||
|
||||
### What we do NOT have
|
||||
|
||||
- No `Tag` model. No `Collection` model. No `Category` table. No tag-style
|
||||
fields on `Product`. No tag-aware search.
|
||||
- No LLM or embeddings infrastructure inside MPS (karaoke is audio ML routed
|
||||
to unsandbox; `lib/sentiment.py` is a rule-based comment scorer).
|
||||
- No featured-item or hero columns on `Shop`.
|
||||
- No browse routes beyond `/search?keywords=`. No `/tag/X`, `/collection/X`,
|
||||
`/category/X`.
|
||||
|
||||
## Goals — fewest clicks to a purchase
|
||||
|
||||
Our checkout flows are done; what's missing is *navigation into* our catalog.
|
||||
Every design decision below optimises for: **shopper lands → finds a relevant
|
||||
product → opens it → buys**. Each extra click, extra page, or extra scroll
|
||||
between "land" and "open" is friction we cut.
|
||||
|
||||
1. Opt-in. Default `home_layout = 0` (flat) keeps every existing shop pixel-identical.
|
||||
2. A flipped-on shop with no tagging effort still produces a usable, grouped
|
||||
home page within minutes of opt-in — backfill must work without operator
|
||||
hand-labeling 481 products.
|
||||
3. Shopper sees the grouping **on land**, not behind a click. Categories live
|
||||
above the fold; chip click filters in place (no page reload, no extra page).
|
||||
4. Operator can correct mistakes — auto-grouping is never final state.
|
||||
5. No new external dependencies on first ship. ML-assisted tagging stays
|
||||
pluggable, off-by-default, last phase.
|
||||
|
||||
## Two orthogonal design dimensions
|
||||
|
||||
We have **two independent decisions** that combine to form the overhaul.
|
||||
Treating them as one decision is what makes the design feel huge — splitting
|
||||
them lets us ship Phase 1 in a week.
|
||||
|
||||
### Dimension A — How products get grouped (categorization mechanism)
|
||||
|
||||
| Option | Approach | Operator effort | Quality on day 1 | Infra cost | Reversible? |
|
||||
|---|---|---|---|---|---|
|
||||
| **A1** Manual tags | Operator types comma-separated tags per product (or via bulk admin) | High (481 products × a few seconds) | Perfect — operator picks | Tiny — `Tag` + `product_tag` table | Trivial |
|
||||
| **A2** Manual collections | Operator creates named collections, assigns products | Medium-High | Perfect | Small — `Collection` + `collection_product` table | Trivial |
|
||||
| **A3** Auto-tag from title keywords | Deterministic rules: tokenize title, strip stopwords, group by shared stems, emit top-N tags | Zero on backfill, low on new uploads (operator confirms suggested tag) | Decent — works very well for printableprompts because titles are descriptive | Tiny — pure Python, no external deps | Trivial |
|
||||
| **A4** Auto-tag via embeddings + clustering | Embed each product title+description, cluster via K-means or HDBSCAN, label clusters by centroid keyword | Zero | Better than A3 on shops with cryptic titles | Moderate — embedding lib + model file (~100MB) or external API | Trivial (re-cluster) |
|
||||
| **A5** Auto-tag via LLM | One-shot call per product: "pick 1–3 categories from this taxonomy" | Zero | Best | High — needs LLM API, retry/timeout/cost handling, kill-switch | Trivial (re-run) |
|
||||
| **A6** Hybrid (A3 or A5 → operator approve) | Auto-suggest tags on product edit form; operator one-click accepts | Zero baseline + low correction | Best — operator owns final state | Same as picked auto-method | Trivial |
|
||||
|
||||
**Reads from CLAUDE.md** — "MPS uses one warm sending identity / one source of
|
||||
truth / one place per fact" — argues we should pick one *storage* for groupings
|
||||
and let mechanisms write into it. That storage is a `Tag` table. A1/A3/A5 all
|
||||
write tags. A2 (collections) is a *different* primitive that we may want
|
||||
on top of tags (a curated subset).
|
||||
|
||||
### Dimension B — How groups render on home (layout)
|
||||
|
||||
| Option | Layout | Shopper benefit | Implementation |
|
||||
|---|---|---|---|
|
||||
| **B1** Sectioned home (Shopify-style lanes) | One horizontal lane per category, products scroll horizontally within each lane; lanes stacked vertically | Browse by topic at-a-glance, see ≤10 per category | New template; loop tags → query per tag (capped) |
|
||||
| **B2** Filter chips above flat grid | Existing grid stays; chip row at top (`Math · Seasonal · Literacy · ...`); clicking a chip filters the grid in place (no reload — JS optional) | Lightest visual change; preserves chronological signal | Existing template + chip strip + JS `data-tag` filter |
|
||||
| **B3** Featured + flat grid | Operator picks ≤6 "featured" products shown as large cards; rest of catalog underneath in current flat grid | No categorization needed; operator merchandises | New `featured_product_ids` JSON column; small template addition |
|
||||
| **B4** Sidebar nav | Left rail with category list; main pane shows filtered grid | Familiar pattern; bad on mobile (we have no sidebar pattern today) | Bigger template lift; mobile collapse |
|
||||
| **B5** Tag cloud + grid | Cloud at top sized by tag popularity, grid below | Discovery-flavoured; less directed than chips | Similar to B2 but visual variant |
|
||||
| **B6** Search-first | Big search bar hero, popular searches/tags chips under it, grid below | Best for shops with a known-item search pattern | Promote existing `/search` UI; needs popular-search data we already log in `ShopSearchRequest` |
|
||||
|
||||
A and B compose. E.g. **A6 + B1** = "auto-suggest tags with operator approval,
|
||||
rendered as sectioned lanes." **A1 + B2** = "manual tags, filter chips." Both
|
||||
ship.
|
||||
|
||||
## Phased implementation (all in this ticket)
|
||||
|
||||
Per CLAUDE.md "Ticket Scoping" — one feature, one ticket. Phases below land
|
||||
incrementally but live under one MPS-24 thread.
|
||||
|
||||
### Phase 1 — Foundation: tags + filter chips on flat grid (A1 + B2)
|
||||
|
||||
Smallest ship that solves the printableprompts feedback. Cuts shopper clicks
|
||||
from "scroll 481 items" to "click chip, scan ~50, click product."
|
||||
|
||||
- New `Tag` model (`id`, `shop_id`, `name`, `slug`, `created_timestamp`).
|
||||
- New `product_tag` association (composite PK `product_id` + `tag_id`).
|
||||
- New `Product.tags` relationship (collection, not lazy=dynamic — small N per
|
||||
product).
|
||||
- New `Shop.home_layout` `Integer` column, default `0`:
|
||||
- `0` = flat (current behavior, unchanged)
|
||||
- `1` = filter chips on flat grid
|
||||
- `2` = sectioned lanes (Phase 2)
|
||||
- New form section `home-layout-settings` in `views/shop.py` + `shop_settings.j2`.
|
||||
- Tag editor: comma-separated input on product edit form (`product_edit.j2`)
|
||||
— splits, slugifies, upserts `Tag` rows scoped to shop.
|
||||
- Bulk tag editor: small admin page at `/s/{shop_id}/tags` listing tags +
|
||||
product counts, click a tag → list of products with checkboxes to
|
||||
add/remove. (Avoids forcing operator into product-by-product.)
|
||||
- Home template: if `home_layout == 1`, render chip strip from
|
||||
`shop.tags_by_popularity()` (top N, capped); chip click adds
|
||||
`?tag=<slug>` to URL; server filters grid; JS enhancement does it in-place
|
||||
(zero navigation cost when JS is available).
|
||||
- Tag detail route: `/s/{shop_id}/tag/{slug}` for crawlers + no-JS users
|
||||
(capability-driven presentation per CLAUDE.md).
|
||||
- Filter chips also added to `/search` results so shopper can refine by tag
|
||||
after a keyword query (`/search?keywords=X&tag=Y`).
|
||||
|
||||
### Phase 2 — Sectioned lanes + auto-tag from titles (A3 + B1)
|
||||
|
||||
Even fewer clicks: shopper sees *all* categories on land, no chip click needed.
|
||||
Lanes are scoped to top-N tags by popularity.
|
||||
|
||||
- `home_layout == 2` renders one lane per top-N tags, each lane shows up to
|
||||
~10 products of that tag, with a "see all" link to the tag detail page.
|
||||
- Lanes scroll horizontally on touch; stack as single-column below 800px
|
||||
(matches our existing mobile reorder pattern).
|
||||
- Deterministic title-keyword auto-tagger as a standalone script
|
||||
(`scripts/backfill_tags.py`) and a "Suggest tags from titles" button on
|
||||
the bulk tag editor. Rules:
|
||||
- Tokenize all titles in shop.
|
||||
- Drop stopwords + common shop-vocabulary words (per-shop configurable
|
||||
list — printableprompts will drop `write`, `the`, `room`, `activity`
|
||||
because they appear in nearly every title and group nothing).
|
||||
- Stem (simple suffix strip — no new dep, no Porter ports).
|
||||
- Cluster: products sharing >= 2 non-stopword stems form a candidate group.
|
||||
- Label the candidate group by its most frequent shared stem.
|
||||
- **Emit candidate tags to operator for approval — never auto-commit**
|
||||
(A6 hybrid: suggest, don't impose).
|
||||
- This phase has no ML, no external deps. It's grep-flavoured clustering.
|
||||
|
||||
### Phase 3 — ML-assisted categorization via uncloseai (A5)
|
||||
|
||||
Optional, off-by-default kill-switch (mirrors MPS-22 pattern):
|
||||
`app.features.ml_categorization.enabled` default `False`.
|
||||
|
||||
- Per-product call to `uncloseai.com` OpenAI-compatible endpoint we already
|
||||
operate — "pick 1–3 from this taxonomy (provided)."
|
||||
- Cheaper than vendor LLMs because we run the endpoint ourselves.
|
||||
- Same approve-don't-commit UX as Phase 2 — operator owns final state.
|
||||
- Backfill script `scripts/ml_tag_suggest.py` runs over a shop's catalog,
|
||||
writes suggestions to a new `tag_suggestion` table (not `product_tag`),
|
||||
surfaces them in the bulk tagger for one-click accept.
|
||||
- We do **not** ship embedding-clustering (A4) — A5 is cheaper to operate
|
||||
given our existing uncloseai infrastructure, and the operator-approval UX
|
||||
is identical so we don't need both.
|
||||
|
||||
Phase 3 lands behind the kill-switch even when shipped. Operator opt-in
|
||||
required.
|
||||
|
||||
## Shop setting toggle (the operator-facing surface)
|
||||
|
||||
New form section `home-layout-settings`, added to the existing 19 sections in
|
||||
`views/shop.py`. UI lives in `shop_settings.j2` alongside `ribbon-settings`.
|
||||
|
||||
### Columns added to `Shop`
|
||||
|
||||
| Column | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `home_layout` | `Integer` | `0` | 0=flat, 1=filter_chips, 2=sectioned_lanes |
|
||||
| `home_layout_tag_limit` | `Integer` | `8` | Max chips / lanes to show on home |
|
||||
| `home_layout_per_lane_limit` | `Integer` | `10` | Max products per lane (B1) |
|
||||
| `featured_product_ids_json` | `UnicodeText` | `""` | JSON list of UUIDs for optional B3 hero strip; nullable, opt-in |
|
||||
|
||||
All `server_default` per CLAUDE.md SQLite migration rule.
|
||||
|
||||
### Form UI (operator's view)
|
||||
|
||||
A single select for `home_layout` with previewable explanations:
|
||||
|
||||
- **Flat grid (default)** — every product, newest first. Same as today.
|
||||
- **Filter chips on flat grid** — flat grid with a clickable category strip
|
||||
on top. Categories come from product tags.
|
||||
- **Sectioned by category** — separate lanes per category, like a magazine
|
||||
rack. Best for shops with 50+ products in 4+ categories.
|
||||
|
||||
Plus three numeric fields (tag limit, per-lane limit, featured strip on/off).
|
||||
Plus a "Featured products" picker (Phase 1 ships the column + form, the rich
|
||||
picker is Phase 2).
|
||||
|
||||
## Decisions (resolved at draft time — flag in review if fox disagrees)
|
||||
|
||||
1. **Many tags per product**, not single category. Matches printableprompts —
|
||||
a product can be both `math` and `valentines`.
|
||||
2. **Tags scoped per shop**, not platform-wide. Avoids collision between
|
||||
unrelated shops (a music shop's `blues` ≠ a gardening shop's `blues`).
|
||||
3. **Tag bulk editor reachable from `/actions/view`** as a new
|
||||
`.mps-button` in `action-button-grid`.
|
||||
4. **Mobile**: chip strip horizontally scrolls; sectioned lanes stack as
|
||||
single-column below 800px (existing mobile reorder pattern in CLAUDE.md).
|
||||
5. **Watch mode** uses `discovery_ring` once a shopper enters it — sectioned
|
||||
home is entry-page only, no SPA JSON shape change.
|
||||
6. **`/search?keywords=X&tag=Y`** — tag filter on search results in Phase 1.
|
||||
7. **Stopwords**: per-shop `tag_stopwords_json` override on top of a
|
||||
platform-wide default list.
|
||||
8. **Phase 2 + 3 are suggest-then-approve only** — never auto-commit tags.
|
||||
9. **Naming**: `Tag` not `Category` — tags are many-per-product and flat;
|
||||
categories would imply a tree we are not building.
|
||||
|
||||
## Implementation (Phase 1 — shipped 2026-05-15)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `models/tag.py` (new) | `Tag` model: id, shop_id, name, slug, created_timestamp; unique `(shop_id, slug)`; helpers `get_or_create_tag`, `tags_by_popularity` |
|
||||
| `models/product_tag.py` (new) | `ProductTag` many-to-many association with `(product_id, tag_id)` unique constraint |
|
||||
| `models/product.py` | Add `tags` association_proxy |
|
||||
| `models/shop.py` | Add `home_layout`, `home_layout_tag_limit`, `home_layout_per_lane_limit`, `featured_product_ids_json`, `tag_stopwords_json` columns + `is_home_flat`/`is_home_chips`/`is_home_lanes`/`home_layout_label`/`featured_product_ids`/`tag_stopwords` helpers + `tags` relationship |
|
||||
| `models/meta.py` | Register `Tag` / `ProductTag` in `CLASS_TO_TABLE` |
|
||||
| `models/__init__.py` | Import `tag` + `product_tag` modules |
|
||||
| `scripts/alembic/versions/882d68db47fa_mps_24_*.py` (new) | Idempotent migration: creates `mps_tag` + `mps_product_tag` + 5 `mps_shop` columns; guards via `_table_exists` / `_column_exists` (CLAUDE.md pattern) |
|
||||
| `routes.py` | Add `shop_tags` + `shop_tag_detail` before `shop_slug` catch-all |
|
||||
| `views/shop.py` | `_build_home_layout_context()` helper; `home-layout-settings` form_section handler; `shop_tag_detail` + `shop_tags` (bulk tagger) views; tag filter param on `home` / `shop` / `search` views |
|
||||
| `views/product.py` | Tag handling on product edit POST — comma-separated slugify + diff |
|
||||
| `templates/home.j2` | Branch on `shop.home_layout` for chip strip / sectioned lanes / flat grid |
|
||||
| `templates/shop.j2` | Same branching (used by `/s/{id}/{slug}`) |
|
||||
| `templates/shop_settings.j2` | New `home-layout-settings` section |
|
||||
| `templates/product_edit.j2` | Comma-separated tag input |
|
||||
| `templates/shop_tag.j2` (new) | Tag detail page (works without JS) |
|
||||
| `templates/shop_tags.j2` (new) | Bulk tagger UI: list tags + apply/remove per product |
|
||||
| `templates/actions_view.j2` | Add Tags shortcut to operator action grid |
|
||||
| `templates/styleguide.j2` | Live tag-chip + tag-lane examples under `#cards` |
|
||||
| `static/css/common.css` | `.tag-chip-strip` / `.tag-chip` / `.tag-chip-active` / `.tag-lane` / `.tag-list` / `.tag-product-list` styles — tokens only, Grid only |
|
||||
| `static/js/tag_filter.js` (new) | Progressive enhancement: in-place chip filter via `data-tag-slugs`; falls back to server `?tag=` |
|
||||
| `tests/test_models.py` | `TestShopHomeLayout` + `TestTagModel` — 14 unit tests |
|
||||
| `tests/test_functional.py` | `TestHomeLayoutAndTags` — 10 functional tests (settings save, tag editor, attach/detach, chip filter, tag detail) |
|
||||
| `docs/architecture.md` | Add MPS-24 to feature matrix + ticket index |
|
||||
| `docs/design-system.md` | Document MPS-24 chip + lane component classes |
|
||||
| `CLAUDE.md` | New "Shop Home Layout + Tags" section + "Ticket Scoping — One Feature, One Ticket" rule |
|
||||
| `~/git/www.makepostsell.com/index.html` + `pricing.html` | "Categorized Home Page" feature card + pricing list entry |
|
||||
|
||||
## Tests (Phase 1)
|
||||
|
||||
### Unit (`test_models.py`)
|
||||
|
||||
- `Tag` create/slugify/uniqueness-per-shop
|
||||
- `Product.tags` collection add/remove
|
||||
- `Shop.home_layout` defaults to `0`; integer round-trip 0/1/2
|
||||
- `Shop.tags_by_popularity()` returns shop-scoped tag list ordered by count
|
||||
- Featured product ids JSON parse + roundtrip
|
||||
|
||||
### Integration (`test_integration.py`)
|
||||
|
||||
- Operator saves tags on product edit → `product_tag` row written; comma split
|
||||
handles whitespace, dedupes, slugifies
|
||||
- Bulk tagger add/remove flow
|
||||
- `home-layout-settings` form_section save persists all four columns
|
||||
|
||||
### Functional (`test_functional.py`)
|
||||
|
||||
- Shop home with `home_layout=0` renders `.serp` flat grid, no chip strip
|
||||
- Shop home with `home_layout=1` renders chip strip + filterable grid
|
||||
- `?tag=<slug>` filters the grid server-side
|
||||
- Tag detail page renders products with that tag only
|
||||
- Bulk tagger page loads, POSTs persist
|
||||
- Mobile chip strip horizontally scrolls (CSS check — render at `<800px`
|
||||
viewport via testbench)
|
||||
|
||||
## Verification
|
||||
|
||||
1. `source vars.sh && make test` — all pass
|
||||
2. Local: `make serve`, create a shop with 10 fake products, opt into
|
||||
`home_layout=1`, tag products `math` / `seasonal`, verify chip filter works
|
||||
3. Local: opt out (`home_layout=0`), verify identical to current behavior
|
||||
4. Push → CI green → Salt highstate → verify on my.makepostsell.com
|
||||
5. Send shop link to printableprompts operator for feedback; if positive,
|
||||
plan Phase 2 (auto-tagger) as MPS-25
|
||||
|
||||
## Out of scope (genuinely separate tickets later)
|
||||
|
||||
- Tag-aware search **ranking** (Phase 1 adds a tag *filter* to `/search`;
|
||||
tuning rank weights for tag matches vs title matches is its own ticket
|
||||
once we have shopper data).
|
||||
- Faceted filtering (price range + tag + type combined) — wait for shopper
|
||||
signal after Phase 1.
|
||||
- Cross-shop tag discovery (browse all shops by tag) — privacy question,
|
||||
defer.
|
||||
- Tag-based RSS / sitemap segmentation — defer until tags exist for a few
|
||||
weeks and the segmentation use case is concrete.
|
||||
- Tag tree / nested categories — explicitly not in scope, see decision #9.
|
||||
|
||||
## References
|
||||
|
||||
- `shop.printableprompts.com` crawled 2026-05-15 via sitemap (481 products,
|
||||
obvious natural categories surfaced from titles)
|
||||
- `views/shop.py:212` (`home`), `:226` (`shop`), `:275` (`search`)
|
||||
- `models/product.py:730` (`get_products_from_a_shop`), `:740`
|
||||
(`get_products_by_keywords`)
|
||||
- `templates/home.j2` (current flat grid)
|
||||
- CLAUDE.md "Feature Kill Switches" pattern (MPS-22) — model for shop toggle
|
||||
- CLAUDE.md "Capability-Driven Presentation" — tag detail page works without JS
|
||||
|
|
@ -44,6 +44,9 @@ from .cart_auction import *
|
|||
from .cart_offer import *
|
||||
from .notification import *
|
||||
|
||||
from .tag import *
|
||||
from .product_tag import *
|
||||
|
||||
# run configure_mappers after defining all of the models
|
||||
# to ensure all relationships can be setup.
|
||||
configure_mappers()
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ CLASS_TO_TABLE = {
|
|||
"MpsCartAuction": "mps_cart_auction",
|
||||
"MpsCartOffer": "mps_cart_offer",
|
||||
"MpsNotification": "mps_notification",
|
||||
"Tag": "mps_tag",
|
||||
"ProductTag": "mps_product_tag",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from .meta import (
|
|||
from sqlalchemy.ext.associationproxy import association_proxy
|
||||
|
||||
from .user_product import UserProduct
|
||||
from .product_tag import ProductTag
|
||||
from .price import Price
|
||||
|
||||
from make_post_sell.lib.render import markdown_to_html
|
||||
|
|
@ -184,6 +185,11 @@ class Product(RBase, Base):
|
|||
"product_users", "user", creator=lambda u: UserProduct(user=u)
|
||||
)
|
||||
|
||||
# MPS-24: many to many product->tag via ProductTag association.
|
||||
tags = association_proxy(
|
||||
"product_tags", "tag", creator=lambda t: ProductTag(tag=t)
|
||||
)
|
||||
|
||||
file_keys = [
|
||||
"product",
|
||||
"preview",
|
||||
|
|
|
|||
41
make_post_sell/models/product_tag.py
Normal file
41
make_post_sell/models/product_tag.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import Column, BigInteger, UniqueConstraint
|
||||
|
||||
from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
|
||||
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
|
||||
|
||||
class ProductTag(RBase, Base):
|
||||
"""
|
||||
Many-to-many: Products to Tags (MPS-24).
|
||||
A row signifies that a product carries a tag.
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
product_id = Column(UUIDType, foreign_key("Product", "id"), nullable=False)
|
||||
tag_id = Column(UUIDType, foreign_key("Tag", "id"), nullable=False)
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
product = relationship(
|
||||
argument="Product",
|
||||
backref=backref("product_tags", cascade="all, delete-orphan"),
|
||||
)
|
||||
|
||||
tag = relationship(
|
||||
argument="Tag",
|
||||
backref=backref("tag_products", cascade="all, delete-orphan"),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"product_id", "tag_id", name="uq_mps_product_tag_product_id_tag_id"
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, product=None, tag=None):
|
||||
self.id = uuid.uuid1()
|
||||
self.product = product
|
||||
self.tag = tag
|
||||
self.created_timestamp = now_timestamp()
|
||||
|
|
@ -215,6 +215,32 @@ class Shop(RBase, Base):
|
|||
# Precomputed discovery ring: circular ordering of all public products
|
||||
json_discovery_ring = Column(UnicodeText, nullable=True)
|
||||
|
||||
# MPS-24: home page layout selector.
|
||||
# 0 = flat grid (default, unchanged from pre-MPS-24)
|
||||
# 1 = filter chips above flat grid (click chip → filters in place)
|
||||
# 2 = sectioned lanes (one lane per popular tag)
|
||||
home_layout = Column(
|
||||
BigInteger, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
# Cap chips/lanes shown on home so we never query the long tail.
|
||||
home_layout_tag_limit = Column(
|
||||
BigInteger, nullable=False, default=8, server_default="8"
|
||||
)
|
||||
# Cap products per lane in sectioned layout.
|
||||
home_layout_per_lane_limit = Column(
|
||||
BigInteger, nullable=False, default=10, server_default="10"
|
||||
)
|
||||
# Optional operator-curated featured strip — JSON list of product UUIDs.
|
||||
featured_product_ids_json = Column(
|
||||
UnicodeText, nullable=False, default="", server_default=""
|
||||
)
|
||||
# MPS-24 Phase 2: per-shop stopwords for the title-keyword auto-tagger.
|
||||
# JSON list of lowercase words to exclude from clustering (e.g. for
|
||||
# printableprompts: ["write", "the", "room", "activity"]).
|
||||
tag_stopwords_json = Column(
|
||||
UnicodeText, nullable=False, default="", server_default=""
|
||||
)
|
||||
|
||||
# many to many uses association_proxy.
|
||||
users = association_proxy("shop_users", "user", creator=lambda u: UserShop(user=u))
|
||||
|
||||
|
|
@ -266,6 +292,11 @@ class Shop(RBase, Base):
|
|||
argument="MpsApiKey", lazy="dynamic", back_populates="shop"
|
||||
)
|
||||
|
||||
# MPS-24: tags scoped to this shop.
|
||||
tags = relationship(
|
||||
argument="Tag", lazy="dynamic", back_populates="shop"
|
||||
)
|
||||
|
||||
def __init__(self, name, phone_number, billing_address, description):
|
||||
self.id = uuid.uuid1()
|
||||
self.name = name
|
||||
|
|
@ -300,6 +331,54 @@ class Shop(RBase, Base):
|
|||
|
||||
# --- Environment properties (MPS-14) ---
|
||||
|
||||
# --- Home layout properties (MPS-24) ---
|
||||
|
||||
@property
|
||||
def is_home_flat(self):
|
||||
return (self.home_layout or 0) == 0
|
||||
|
||||
@property
|
||||
def is_home_chips(self):
|
||||
return (self.home_layout or 0) == 1
|
||||
|
||||
@property
|
||||
def is_home_lanes(self):
|
||||
return (self.home_layout or 0) == 2
|
||||
|
||||
@property
|
||||
def home_layout_label(self):
|
||||
return {0: "Flat grid", 1: "Filter chips", 2: "Sectioned lanes"}.get(
|
||||
self.home_layout or 0, "Flat grid"
|
||||
)
|
||||
|
||||
@property
|
||||
def featured_product_ids(self):
|
||||
"""Parse the featured_product_ids_json column into a list of UUID strs."""
|
||||
raw = (self.featured_product_ids_json or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [str(x) for x in data if x]
|
||||
|
||||
@property
|
||||
def tag_stopwords(self):
|
||||
"""Parse tag_stopwords_json into a list of lowercase tokens."""
|
||||
raw = (self.tag_stopwords_json or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [str(x).lower() for x in data if x]
|
||||
|
||||
@property
|
||||
def is_production(self):
|
||||
return self.environment == 0
|
||||
|
|
|
|||
108
make_post_sell/models/tag.py
Normal file
108
make_post_sell/models/tag.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import Column, BigInteger, Unicode, UniqueConstraint, func
|
||||
|
||||
from slugify import slugify
|
||||
|
||||
from .meta import (
|
||||
Base,
|
||||
RBase,
|
||||
UUIDType,
|
||||
foreign_key,
|
||||
get_object_by_id,
|
||||
now_timestamp,
|
||||
)
|
||||
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
||||
class Tag(RBase, Base):
|
||||
"""
|
||||
A shop-scoped tag, used to group products on a shop's home page
|
||||
(MPS-24). Tags are flat (no tree), many-per-product, and uniquely
|
||||
slugged within a shop.
|
||||
"""
|
||||
|
||||
id = Column(UUIDType, primary_key=True, index=True)
|
||||
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
|
||||
name = Column(Unicode(64), nullable=False)
|
||||
slug = Column(Unicode(80), nullable=False)
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
shop = relationship(
|
||||
argument="Shop", uselist=False, lazy="joined", back_populates="tags"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("shop_id", "slug", name="uq_mps_tag_shop_id_slug"),
|
||||
)
|
||||
|
||||
def __init__(self, shop=None, name=None):
|
||||
self.id = uuid.uuid1()
|
||||
self.shop = shop
|
||||
self.name = (name or "").strip()[:64]
|
||||
self.slug = slugify(self.name)[:80]
|
||||
self.created_timestamp = now_timestamp()
|
||||
|
||||
@property
|
||||
def product_count(self):
|
||||
"""Count of products carrying this tag (computed on demand)."""
|
||||
from .product_tag import ProductTag
|
||||
return (
|
||||
self.dbsession.query(func.count(ProductTag.id))
|
||||
.filter(ProductTag.tag_id == self.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def get_tag_by_id(dbsession, _id):
|
||||
return get_object_by_id(dbsession, _id, Tag)
|
||||
|
||||
|
||||
def get_tag_by_shop_and_slug(dbsession, shop, slug):
|
||||
"""Find a Tag by its shop scope + slug, or return None."""
|
||||
if not shop or not slug:
|
||||
return None
|
||||
return (
|
||||
dbsession.query(Tag)
|
||||
.filter(Tag.shop_id == shop.id, Tag.slug == slug)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_tag(dbsession, shop, name):
|
||||
"""
|
||||
Upsert a tag for a shop by name. Returns None if the slug would be empty
|
||||
(e.g. whitespace-only or unprintable input).
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
slug = slugify(name)[:80]
|
||||
if not slug:
|
||||
return None
|
||||
tag = get_tag_by_shop_and_slug(dbsession, shop, slug)
|
||||
if tag is None:
|
||||
tag = Tag(shop=shop, name=name)
|
||||
dbsession.add(tag)
|
||||
dbsession.flush()
|
||||
return tag
|
||||
|
||||
|
||||
def tags_by_popularity(dbsession, shop, limit=None):
|
||||
"""
|
||||
Return tags scoped to a shop, ordered by product count desc then name asc.
|
||||
Limited to `limit` rows if provided.
|
||||
"""
|
||||
from .product_tag import ProductTag
|
||||
q = (
|
||||
dbsession.query(Tag, func.count(ProductTag.id).label("n"))
|
||||
.outerjoin(ProductTag, ProductTag.tag_id == Tag.id)
|
||||
.filter(Tag.shop_id == shop.id)
|
||||
.group_by(Tag.id)
|
||||
.order_by(func.count(ProductTag.id).desc(), Tag.name.asc())
|
||||
)
|
||||
if limit:
|
||||
q = q.limit(limit)
|
||||
return [tag for tag, _n in q.all()]
|
||||
|
|
@ -165,6 +165,11 @@ def includeme(config):
|
|||
|
||||
config.add_route("discovery_ring_json", "/s/{shop_id}/ring.json")
|
||||
config.add_route("discovery_ring_health", "/s/{shop_id}/ring/health.json")
|
||||
|
||||
# MPS-24: tag routes — must register before shop_slug catch-all.
|
||||
config.add_route("shop_tags", "/s/{shop_id}/tags")
|
||||
config.add_route("shop_tag_detail", "/s/{shop_id}/tag/{slug}")
|
||||
|
||||
config.add_route("shop_slug", "/s/{shop_id}/{slug:.*}")
|
||||
|
||||
# content routes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
"""mps_24 tag model + shop home layout columns
|
||||
|
||||
Revision ID: 882d68db47fa
|
||||
Revises: 7d6af811b6a1
|
||||
Create Date: 2026-05-15 08:23:25.929834
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from make_post_sell.models.meta import UUIDType
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "882d68db47fa"
|
||||
down_revision = "7d6af811b6a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(name):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"),
|
||||
{"name": name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
# MPS-24: shop-scoped tag model.
|
||||
if not _table_exists("mps_tag"):
|
||||
op.create_table(
|
||||
"mps_tag",
|
||||
sa.Column("id", UUIDType, primary_key=True),
|
||||
sa.Column(
|
||||
"shop_id",
|
||||
UUIDType,
|
||||
sa.ForeignKey("mps_shop.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Unicode(64), nullable=False),
|
||||
sa.Column("slug", sa.Unicode(80), nullable=False),
|
||||
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"shop_id", "slug", name="uq_mps_tag_shop_id_slug"
|
||||
),
|
||||
)
|
||||
op.create_index("ix_mps_tag_id", "mps_tag", ["id"])
|
||||
op.create_index("ix_mps_tag_shop_id", "mps_tag", ["shop_id"])
|
||||
|
||||
# MPS-24: product->tag many-to-many association.
|
||||
if not _table_exists("mps_product_tag"):
|
||||
op.create_table(
|
||||
"mps_product_tag",
|
||||
sa.Column("id", UUIDType, primary_key=True),
|
||||
sa.Column(
|
||||
"product_id",
|
||||
UUIDType,
|
||||
sa.ForeignKey("mps_product.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"tag_id",
|
||||
UUIDType,
|
||||
sa.ForeignKey("mps_tag.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"product_id",
|
||||
"tag_id",
|
||||
name="uq_mps_product_tag_product_id_tag_id",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_mps_product_tag_id", "mps_product_tag", ["id"])
|
||||
op.create_index(
|
||||
"ix_mps_product_tag_product_id", "mps_product_tag", ["product_id"]
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mps_product_tag_tag_id", "mps_product_tag", ["tag_id"]
|
||||
)
|
||||
|
||||
# MPS-24: shop home layout columns. server_default required for SQLite
|
||||
# when adding NOT NULL columns to a populated table.
|
||||
if not _column_exists("mps_shop", "home_layout"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"home_layout",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
if not _column_exists("mps_shop", "home_layout_tag_limit"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"home_layout_tag_limit",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="8",
|
||||
),
|
||||
)
|
||||
if not _column_exists("mps_shop", "home_layout_per_lane_limit"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"home_layout_per_lane_limit",
|
||||
sa.BigInteger(),
|
||||
nullable=False,
|
||||
server_default="10",
|
||||
),
|
||||
)
|
||||
if not _column_exists("mps_shop", "featured_product_ids_json"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"featured_product_ids_json",
|
||||
sa.UnicodeText(),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
if not _column_exists("mps_shop", "tag_stopwords_json"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column(
|
||||
"tag_stopwords_json",
|
||||
sa.UnicodeText(),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# SQLite drop_column requires batch operations; we keep downgrade simple
|
||||
# since MPS-24 columns are non-destructive (additive).
|
||||
if _column_exists("mps_shop", "tag_stopwords_json"):
|
||||
op.drop_column("mps_shop", "tag_stopwords_json")
|
||||
if _column_exists("mps_shop", "featured_product_ids_json"):
|
||||
op.drop_column("mps_shop", "featured_product_ids_json")
|
||||
if _column_exists("mps_shop", "home_layout_per_lane_limit"):
|
||||
op.drop_column("mps_shop", "home_layout_per_lane_limit")
|
||||
if _column_exists("mps_shop", "home_layout_tag_limit"):
|
||||
op.drop_column("mps_shop", "home_layout_tag_limit")
|
||||
if _column_exists("mps_shop", "home_layout"):
|
||||
op.drop_column("mps_shop", "home_layout")
|
||||
if _table_exists("mps_product_tag"):
|
||||
op.drop_table("mps_product_tag")
|
||||
if _table_exists("mps_tag"):
|
||||
op.drop_table("mps_tag")
|
||||
|
|
@ -1321,6 +1321,147 @@ img.serp-thumbnail {
|
|||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
/* ---------- MPS-24: tag chips, chip strip, sectioned lanes ---------- */
|
||||
|
||||
/* Chip strip sits above the grid; auto-flow column so chips line up, with
|
||||
horizontal overflow on narrow viewports so mobile can swipe through them. */
|
||||
nav.tag-chip-strip {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px) var(--space-1, 4px);
|
||||
margin: 0 0 var(--space-3, 12px) 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
a.tag-chip {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: var(--space-1, 4px) var(--space-3, 12px);
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #111);
|
||||
font-size: var(--type-body-sm-size, 0.875rem);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
a.tag-chip:hover {
|
||||
background: var(--color-surface-2, #f3f4f6);
|
||||
border-color: var(--color-border-strong, #9ca3af);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.tag-chip-active {
|
||||
background: var(--color-accent, #5871AD);
|
||||
border-color: var(--color-accent, #5871AD);
|
||||
color: var(--color-on-accent, #fff);
|
||||
}
|
||||
|
||||
a.tag-chip-active:hover {
|
||||
background: var(--color-accent-strong, #4860a0);
|
||||
color: var(--color-on-accent, #fff);
|
||||
}
|
||||
|
||||
[data-theme="dark"] a.tag-chip {
|
||||
background: var(--dark-button-bg, #2d3748);
|
||||
border-color: var(--dark-border, #475569);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] a.tag-chip:hover {
|
||||
background: var(--dark-button-bg-hover, #3a4658);
|
||||
}
|
||||
|
||||
/* Sectioned-lanes layout — one lane per top tag. */
|
||||
section.tag-lane {
|
||||
margin: 0 0 var(--space-6, 24px) 0;
|
||||
}
|
||||
|
||||
header.tag-lane-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
margin: 0 0 var(--space-2, 8px) 0;
|
||||
padding: 0 var(--space-1, 4px);
|
||||
}
|
||||
|
||||
h2.tag-lane-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a.tag-lane-more {
|
||||
font-size: var(--type-body-sm-size, 0.875rem);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.tag-lane-more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Tag editor bulk list */
|
||||
ul.tag-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-3, 12px) 0;
|
||||
display: grid;
|
||||
gap: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
li.tag-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 12px);
|
||||
padding: var(--space-2, 8px);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
span.tag-list-count {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-size: var(--type-body-sm-size, 0.875rem);
|
||||
}
|
||||
|
||||
ul.tag-product-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--space-3, 12px) 0;
|
||||
display: grid;
|
||||
gap: var(--space-1, 4px);
|
||||
}
|
||||
|
||||
li.tag-product-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-3, 12px);
|
||||
padding: var(--space-1, 4px) 0;
|
||||
}
|
||||
|
||||
form.tag-product-toggle,
|
||||
form.tag-list-delete {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tag detail page header */
|
||||
section.tag-detail-header {
|
||||
margin: var(--space-3, 12px) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
/* Mobile: chips swipe horizontally; lanes already stack via single-col grid */
|
||||
nav.tag-chip-strip {
|
||||
padding-right: var(--space-4, 16px); /* breathing room at the swipe edge */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
div.edit-page {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
99
make_post_sell/static/js/tag_filter.js
Normal file
99
make_post_sell/static/js/tag_filter.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/* MPS-24: progressive enhancement for tag chip filtering.
|
||||
*
|
||||
* Capability-driven presentation per CLAUDE.md: every chip is a real link
|
||||
* that the server already handles via `?tag=<slug>`. When JS is available,
|
||||
* we intercept the click and filter the grid in place — zero network cost,
|
||||
* zero page navigation, fewer clicks to a purchase.
|
||||
*
|
||||
* No-op when:
|
||||
* - the chip strip is missing (no chips configured)
|
||||
* - the grid is missing (lanes layout uses lanes, not in-place filter)
|
||||
* - the strip points at /tag/<slug> URLs (tag detail page, full navigation
|
||||
* is the right behaviour because the grid is already pre-filtered)
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
const strip = document.querySelector("[data-tag-strip]");
|
||||
const grid = document.querySelector("[data-tag-grid]");
|
||||
if (!strip || !grid) {
|
||||
return;
|
||||
}
|
||||
const chips = strip.querySelectorAll("a.tag-chip");
|
||||
if (chips.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If chips link to /tag/<slug> (tag detail page), keep full navigation —
|
||||
// the server already filters; in-place filter would be wrong context.
|
||||
const firstChip = chips[0];
|
||||
const href = firstChip.getAttribute("href") || "";
|
||||
if (href.indexOf("/tag/") !== -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = grid.querySelectorAll(".serp-item");
|
||||
|
||||
function applyFilter(slug) {
|
||||
let visible = 0;
|
||||
items.forEach(function (item) {
|
||||
const slugs = (item.getAttribute("data-tag-slugs") || "").trim().split(/\s+/);
|
||||
const match = !slug || slugs.indexOf(slug) !== -1;
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) visible += 1;
|
||||
});
|
||||
return visible;
|
||||
}
|
||||
|
||||
function setActive(target) {
|
||||
chips.forEach(function (c) {
|
||||
c.classList.remove("tag-chip-active");
|
||||
});
|
||||
if (target) {
|
||||
target.classList.add("tag-chip-active");
|
||||
}
|
||||
}
|
||||
|
||||
function syncFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slug = (params.get("tag") || "").trim().toLowerCase();
|
||||
applyFilter(slug);
|
||||
const target = Array.prototype.find.call(chips, function (c) {
|
||||
return (c.getAttribute("data-tag-slug") || "").toLowerCase() === slug;
|
||||
});
|
||||
setActive(target || (slug === "" ? chips[0] : null));
|
||||
}
|
||||
|
||||
chips.forEach(function (chip) {
|
||||
chip.addEventListener("click", function (ev) {
|
||||
// Honour modifier-key navigation (open-in-new-tab etc.)
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
const slug = (chip.getAttribute("data-tag-slug") || "").toLowerCase();
|
||||
applyFilter(slug);
|
||||
setActive(chip);
|
||||
const url = new URL(window.location.href);
|
||||
if (slug) {
|
||||
url.searchParams.set("tag", slug);
|
||||
} else {
|
||||
url.searchParams.delete("tag");
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
});
|
||||
});
|
||||
|
||||
// Back/forward navigation: re-sync state with the URL.
|
||||
window.addEventListener("popstate", syncFromUrl);
|
||||
|
||||
syncFromUrl();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
<a href="/s/{{ request.shop.id }}/products" class="mps-button product-edit-button">View Products</a>
|
||||
|
||||
{% if request.user in request.shop.owners %}
|
||||
<a href="/s/{{ request.shop.id }}/tags" class="mps-button product-edit-button">🗂️ Tags</a>
|
||||
<a href="/s/{{ request.shop.id }}/coupons" class="mps-button product-edit-button">View Coupons</a>
|
||||
{% if request.shop.gift_card_enabled %}
|
||||
<a href="/s/{{ request.shop.id }}/gift-cards/manage" class="mps-button product-edit-button">🎁 Gift Cards</a>
|
||||
|
|
|
|||
|
|
@ -79,12 +79,62 @@
|
|||
|
||||
{% else %}
|
||||
|
||||
{% if products %}
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}">
|
||||
{% for product in products %}
|
||||
{# MPS-24: tag chip strip — shown on layout 1 (chips) and 2 (lanes). #}
|
||||
{% if home_chips %}
|
||||
<nav class="tag-chip-strip" data-tag-strip aria-label="Browse by category">
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
class="tag-chip{% if not active_tag %} tag-chip-active{% endif %}"
|
||||
data-tag-slug=""
|
||||
rel="nofollow">All</a>
|
||||
{% for chip in home_chips %}
|
||||
<a href="?tag={{ chip.slug }}"
|
||||
class="tag-chip{% if active_tag and active_tag.id == chip.id %} tag-chip-active{% endif %}"
|
||||
data-tag-slug="{{ chip.slug }}"
|
||||
rel="nofollow">{{ chip.name }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{# Sectioned lanes (layout == 2 and no active tag filter). #}
|
||||
{% if home_lanes %}
|
||||
{% for lane in home_lanes %}
|
||||
<section class="tag-lane" data-tag-lane="{{ lane.tag.slug }}">
|
||||
<header class="tag-lane-header">
|
||||
<h2 class="type-title tag-lane-title">{{ lane.tag.name }}</h2>
|
||||
<a href="?tag={{ lane.tag.slug }}" class="tag-lane-more shop-theme-link-color" rel="nofollow">See all →</a>
|
||||
</header>
|
||||
<div class="serp tag-lane-grid">
|
||||
{% for product in lane.products %}
|
||||
{% if product.is_ready %}
|
||||
<div class="serp-item">
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
||||
{# Default flat / filtered grid. `filtered_products` falls back to `products`
|
||||
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 %}
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
|
||||
{% for product in grid_products %}
|
||||
|
||||
{% if product.is_ready %}
|
||||
<div class="serp-item">
|
||||
<div class="serp-item"
|
||||
data-tag-slugs="{% for t in product.tags %}{{ t.slug }} {% endfor %}">
|
||||
{% 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" />
|
||||
|
|
@ -102,15 +152,24 @@
|
|||
|
||||
{% endfor %}
|
||||
|
||||
</section>
|
||||
{% elif active_tag %}
|
||||
<section class="one-column well">
|
||||
<p>No products tagged <b>{{ active_tag.name }}</b> yet.</p>
|
||||
<p><a href="{{ request.shop.absolute_url(request) }}" class="shop-theme-link-color">← Back to all products</a></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endif %} {# end home_lanes else #}
|
||||
|
||||
{% if request.shop and request.shop.subscriptions_enabled %}
|
||||
<section class="one-column subscribe-cta">
|
||||
<a href="/subscribe" class="shop-theme-link-color">Stay in the Loop — get email updates</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/js/tag_filter.js" defer></script>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -495,6 +495,20 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
{# MPS-24: tags input — comma-separated, slugified server-side. #}
|
||||
<label for="tags_input">Tags (comma-separated)</label>
|
||||
<input type="text" name="tags" id="tags_input"
|
||||
maxlength="512"
|
||||
placeholder="e.g. math, seasonal, valentines"
|
||||
value="{% for t in request.product.tags %}{{ t.name }}{% if not loop.last %}, {% endif %}{% endfor %}" />
|
||||
<small class="note-text">
|
||||
Tags group this product on your shop's home page. Manage all tags at
|
||||
<a href="/s/{{ request.shop.id }}/tags" class="shop-theme-link-color">your tag editor</a>.
|
||||
</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% if request.torrent_enabled and torrent_enabled %}
|
||||
<label>Torrent Distribution</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,59 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}">
|
||||
{% if products %}
|
||||
{% for product in products %}
|
||||
{# MPS-24: tag chip strip — shown on layout 1 (chips) and 2 (lanes). #}
|
||||
{% if home_chips %}
|
||||
<nav class="tag-chip-strip" data-tag-strip aria-label="Browse by category">
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
class="tag-chip{% if not active_tag %} tag-chip-active{% endif %}"
|
||||
data-tag-slug=""
|
||||
rel="nofollow">All</a>
|
||||
{% for chip in home_chips %}
|
||||
<a href="?tag={{ chip.slug }}"
|
||||
class="tag-chip{% if active_tag and active_tag.id == chip.id %} tag-chip-active{% endif %}"
|
||||
data-tag-slug="{{ chip.slug }}"
|
||||
rel="nofollow">{{ chip.name }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% if home_lanes %}
|
||||
{% for lane in home_lanes %}
|
||||
<section class="tag-lane" data-tag-lane="{{ lane.tag.slug }}">
|
||||
<header class="tag-lane-header">
|
||||
<h2 class="type-title tag-lane-title">{{ lane.tag.name }}</h2>
|
||||
<a href="?tag={{ lane.tag.slug }}" class="tag-lane-more shop-theme-link-color" rel="nofollow">See all →</a>
|
||||
</header>
|
||||
<div class="serp tag-lane-grid">
|
||||
{% for product in lane.products %}
|
||||
{% if product.is_ready %}
|
||||
<div class="serp-item">
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
||||
{% set grid_products = filtered_products if filtered_products is defined and filtered_products is not none else products %}
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
|
||||
{% if grid_products %}
|
||||
{% for product in grid_products %}
|
||||
|
||||
{% if product.is_ready %}
|
||||
<div class="serp-item">
|
||||
<div class="serp-item"
|
||||
data-tag-slugs="{% for t in product.tags %}{{ t.slug }} {% endfor %}">
|
||||
{% 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" />
|
||||
|
|
@ -35,4 +82,15 @@
|
|||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if (not grid_products or grid_products|length == 0) and active_tag %}
|
||||
<section class="one-column well">
|
||||
<p>No products tagged <b>{{ active_tag.name }}</b> yet.</p>
|
||||
<p><a href="{{ request.shop.absolute_url(request) }}" class="shop-theme-link-color">← Back to all products</a></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endif %} {# end home_lanes else #}
|
||||
|
||||
<script src="/static/js/tag_filter.js" defer></script>
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
|
|||
|
|
@ -845,6 +845,84 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
{# MPS-24: Home page layout settings — categorize products for fewer clicks. #}
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="home-layout-settings" />
|
||||
|
||||
<h3>Home Page Layout & Tags 🗂️</h3>
|
||||
<p class="type-body-sm">
|
||||
Group your products by category so shoppers find what they want in
|
||||
fewer clicks. Categories come from tags. Manage tags on
|
||||
<a href="/s/{{ request.shop.id }}/tags" class="shop-theme-link-color">your tag editor</a>.
|
||||
</p>
|
||||
|
||||
<label>Home page layout</label>
|
||||
<br />
|
||||
<input type="radio" name="home_layout" id="home_layout_0" value="0"
|
||||
{% if (request.shop.home_layout or 0) == 0 %}checked{% endif %} />
|
||||
<label for="home_layout_0" class="inline-label">Flat grid (default) — every product, newest first.</label>
|
||||
<br />
|
||||
<input type="radio" name="home_layout" id="home_layout_1" value="1"
|
||||
{% if request.shop.home_layout == 1 %}checked{% endif %} />
|
||||
<label for="home_layout_1" class="inline-label">Filter chips on flat grid — chip strip on top, click to filter in place.</label>
|
||||
<br />
|
||||
<input type="radio" name="home_layout" id="home_layout_2" value="2"
|
||||
{% if request.shop.home_layout == 2 %}checked{% endif %} />
|
||||
<label for="home_layout_2" class="inline-label">Sectioned lanes — one lane per top tag.</label>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="home_layout_tag_limit">Max categories shown on home</label>
|
||||
<input type="number" name="home_layout_tag_limit"
|
||||
id="home_layout_tag_limit"
|
||||
min="1" max="40"
|
||||
value="{{ request.shop.home_layout_tag_limit or 8 }}" />
|
||||
<br />
|
||||
<small class="note-text">Caps both chips (layout 1) and lanes (layout 2).</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="home_layout_per_lane_limit">Max products per lane (layout 2 only)</label>
|
||||
<input type="number" name="home_layout_per_lane_limit"
|
||||
id="home_layout_per_lane_limit"
|
||||
min="1" max="40"
|
||||
value="{{ request.shop.home_layout_per_lane_limit or 10 }}" />
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="featured_product_ids">Featured product IDs (comma-separated, optional)</label>
|
||||
<textarea name="featured_product_ids" id="featured_product_ids"
|
||||
class="mps-shop-ribbon-text"
|
||||
placeholder="Leave blank to skip. Type a single dash (-) to clear."
|
||||
>{{ request.shop.featured_product_ids|join(', ') }}</textarea>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="tag_stopwords">Tag stopwords (auto-tagger, Phase 2)</label>
|
||||
<textarea name="tag_stopwords" id="tag_stopwords"
|
||||
class="mps-shop-ribbon-text"
|
||||
placeholder="e.g. the, write, room, activity. Dash to clear."
|
||||
>{{ request.shop.tag_stopwords|join(', ') }}</textarea>
|
||||
<br />
|
||||
<small class="note-text">Words to exclude from automated keyword clustering when we suggest tags from product titles.</small>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<button type="submit" name="submit" class="mps-button mps-button-primary">Save Home Layout</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
|
|
|
|||
50
make_post_sell/templates/shop_tag.j2
Normal file
50
make_post_sell/templates/shop_tag.j2
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
<section class="one-column tag-detail-header">
|
||||
<h1 class="type-headline-3">{{ active_tag.name }}</h1>
|
||||
<p class="type-body-sm"><a href="{{ request.shop.absolute_url(request) }}" class="shop-theme-link-color">← All products</a></p>
|
||||
</section>
|
||||
|
||||
{% if home_chips %}
|
||||
<nav class="tag-chip-strip" data-tag-strip aria-label="Browse by category">
|
||||
<a href="{{ request.shop.absolute_url(request) }}"
|
||||
class="tag-chip"
|
||||
data-tag-slug=""
|
||||
rel="nofollow">All</a>
|
||||
{% for chip in home_chips %}
|
||||
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ chip.slug }}"
|
||||
class="tag-chip{% if active_tag.id == chip.id %} tag-chip-active{% endif %}"
|
||||
data-tag-slug="{{ chip.slug }}"
|
||||
rel="nofollow">{{ chip.name }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<section class="serp{% if request.shop.grid_lanes_enabled %} grid-lanes-enabled{% endif %}" data-tag-grid>
|
||||
{% for product in products %}
|
||||
{% if product.is_ready %}
|
||||
<div class="serp-item">
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if not products %}
|
||||
<section class="one-column well">
|
||||
<p>No products tagged <b>{{ active_tag.name }}</b> yet.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
81
make_post_sell/templates/shop_tags.j2
Normal file
81
make_post_sell/templates/shop_tags.j2
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{% block content -%}
|
||||
|
||||
<section class="one-column">
|
||||
<h1 class="type-headline-3">Tags · {{ request.shop.name }}</h1>
|
||||
<p class="type-body-sm">
|
||||
Group your products by topic so shoppers can browse your shop in fewer
|
||||
clicks. Tags are flat (no tree) and many-per-product.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="one-column well">
|
||||
<h2 class="type-title">Create a tag</h2>
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-create-form">
|
||||
<input type="hidden" name="action" value="create" />
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" maxlength="64" placeholder="e.g. Math, Seasonal, Holiday" required />
|
||||
</label>
|
||||
<button type="submit" class="mps-button mps-button-primary">Add tag</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="one-column">
|
||||
<h2 class="type-title">All tags ({{ tags|length }})</h2>
|
||||
{% if not tags %}
|
||||
<p>No tags yet. Create one above, or open a product and add a tag inline.</p>
|
||||
{% else %}
|
||||
<ul class="tag-list">
|
||||
{% for tag in tags %}
|
||||
<li class="tag-list-item">
|
||||
<a href="?focus={{ tag.slug }}"
|
||||
class="tag-chip{% if focus_tag and focus_tag.id == tag.id %} tag-chip-active{% endif %}"
|
||||
rel="nofollow">{{ tag.name }}</a>
|
||||
<span class="tag-list-count">{{ tag.product_count }} product{% if tag.product_count != 1 %}s{% endif %}</span>
|
||||
<a href="{{ request.shop.absolute_url(request) }}/tag/{{ tag.slug }}"
|
||||
class="shop-theme-link-color tag-list-view"
|
||||
rel="nofollow">view →</a>
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-list-delete">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<input type="hidden" name="tag_slug" value="{{ tag.slug }}" />
|
||||
<button type="submit" class="mps-button mps-button-small mps-button-red"
|
||||
onclick="return confirm('Delete tag {{ tag.name }}?');">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if focus_tag %}
|
||||
<section class="one-column well">
|
||||
<h2 class="type-title">Products in «{{ focus_tag.name }}»</h2>
|
||||
<p class="type-body-sm">Check a product to apply this tag; uncheck to remove it. Saves on click.</p>
|
||||
<ul class="tag-product-list">
|
||||
{% for product in all_products %}
|
||||
<li class="tag-product-row">
|
||||
{% if focus_tag in product.tags|list %}
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-product-toggle">
|
||||
<input type="hidden" name="action" value="detach" />
|
||||
<input type="hidden" name="tag_slug" value="{{ focus_tag.slug }}" />
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}" />
|
||||
<button type="submit" class="mps-button mps-button-small mps-button-green">✓ Applied</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-product-toggle">
|
||||
<input type="hidden" name="action" value="attach" />
|
||||
<input type="hidden" name="tag_slug" value="{{ focus_tag.slug }}" />
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}" />
|
||||
<button type="submit" class="mps-button mps-button-small">Apply</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ product.absolute_url(request) }}" class="shop-theme-link-color" rel="nofollow">{{ product.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{%- endblock -%}
|
||||
|
|
@ -829,6 +829,53 @@ Dark mode overrides via --notice-*-bg and --notice-*-border tokens.</div>
|
|||
.serp-item — hover: brightness change, transition 700ms
|
||||
.serp-thumbnail — border-radius: 4px, width: 100%</div>
|
||||
|
||||
<!-- ======== MPS-24: TAG CHIPS ======== -->
|
||||
<div class="sg-subsection" id="tag-chips">
|
||||
<div class="sg-label">Tag chips (MPS-24)</div>
|
||||
<div class="sg-demo">
|
||||
<nav class="tag-chip-strip" style="max-width: 500px;">
|
||||
<a href="#" class="tag-chip tag-chip-active" rel="nofollow">All</a>
|
||||
<a href="#" class="tag-chip" rel="nofollow">Math</a>
|
||||
<a href="#" class="tag-chip" rel="nofollow">Seasonal</a>
|
||||
<a href="#" class="tag-chip" rel="nofollow">Literacy</a>
|
||||
<a href="#" class="tag-chip" rel="nofollow">Science</a>
|
||||
<a href="#" class="tag-chip" rel="nofollow">Novel Studies</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sg-code">.tag-chip-strip — grid-auto-flow: column; overflow-x: auto
|
||||
.tag-chip — pill, border, hover brightens
|
||||
.tag-chip-active — accent fill + on-accent text
|
||||
Shop home page renders one chip per top tag (shop.home_layout == 1 or 2).
|
||||
Click filters the grid in place via static/js/tag_filter.js (no reload).</div>
|
||||
</div>
|
||||
|
||||
<div class="sg-subsection" id="tag-lanes">
|
||||
<div class="sg-label">Sectioned lanes (MPS-24, layout 2)</div>
|
||||
<div class="sg-demo">
|
||||
<section class="tag-lane" style="max-width: 500px;">
|
||||
<header class="tag-lane-header">
|
||||
<h2 class="type-title tag-lane-title">Math</h2>
|
||||
<a href="#" class="tag-lane-more">See all →</a>
|
||||
</header>
|
||||
<div class="serp tag-lane-grid">
|
||||
<div class="serp-item">
|
||||
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 80px;"></div>
|
||||
<b><a href="#">Addition to 10</a></b><br/><a href="#">$3</a>
|
||||
</div>
|
||||
<div class="serp-item">
|
||||
<div style="background: var(--input-border, #ddd); border-radius: 4px; height: 80px;"></div>
|
||||
<b><a href="#">Counting to 100</a></b><br/><a href="#">$4</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="sg-code">.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
|
||||
One lane per top tag (capped by shop.home_layout_tag_limit).
|
||||
Cap products per lane via shop.home_layout_per_lane_limit.</div>
|
||||
</div>
|
||||
|
||||
<div class="sg-subsection">
|
||||
<div class="sg-label">Profile card (profile.j2)</div>
|
||||
<section class="profile-page" style="max-width: 480px;">
|
||||
|
|
|
|||
|
|
@ -8013,3 +8013,251 @@ class TestAuctionCheckoutQuantity(_AuthenticatedBase):
|
|||
self.assertEqual(match[0].cart.get(product_id, 0), 4)
|
||||
# Total still uses winning bid amount, not list price × 4.
|
||||
self.assertEqual(match[0].total_price_in_cents, 2200)
|
||||
|
||||
|
||||
class TestHomeLayoutAndTags(_AuthenticatedBase):
|
||||
"""MPS-24: home page layout + product tags."""
|
||||
|
||||
def _make_shop_with_product(self, shop_name="mps24-shop"):
|
||||
shop = self._create_shop_helper(
|
||||
user_creds=self.user1_creds,
|
||||
shop_params={**self.shop1_params, "name": shop_name},
|
||||
)
|
||||
self.testapp.post(
|
||||
f"/p/new?shop_id={shop.id}", self.product1_params
|
||||
)
|
||||
from ..models.product import get_all_products
|
||||
products = get_all_products(self.dbsession).all()
|
||||
self.assertTrue(len(products) >= 1)
|
||||
return shop, products[-1]
|
||||
|
||||
def test_default_home_layout_is_flat_no_chip_strip(self):
|
||||
"""Existing shops opt-in to chips/lanes — default is unchanged."""
|
||||
shop, _product = self._make_shop_with_product("flat-shop")
|
||||
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}")
|
||||
self.assertNotIn('data-tag-strip', res.body.decode())
|
||||
|
||||
def test_home_layout_settings_save_chips_layout(self):
|
||||
shop, _product = self._make_shop_with_product("chip-shop")
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "home-layout-settings",
|
||||
"home_layout": "1",
|
||||
"home_layout_tag_limit": "5",
|
||||
"home_layout_per_lane_limit": "10",
|
||||
"submit": "Save Home Layout",
|
||||
},
|
||||
)
|
||||
# follow redirect through flash
|
||||
if res.status_int == 302:
|
||||
res = res.follow()
|
||||
self.assertIn("Filter chips", res.body.decode())
|
||||
self.dbsession.expire(shop)
|
||||
self.assertEqual(shop.home_layout, 1)
|
||||
self.assertEqual(shop.home_layout_tag_limit, 5)
|
||||
|
||||
def test_home_layout_settings_save_lanes_layout(self):
|
||||
shop, _product = self._make_shop_with_product("lane-shop")
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "home-layout-settings",
|
||||
"home_layout": "2",
|
||||
"home_layout_tag_limit": "6",
|
||||
"home_layout_per_lane_limit": "7",
|
||||
"submit": "Save Home Layout",
|
||||
},
|
||||
)
|
||||
if res.status_int == 302:
|
||||
res = res.follow()
|
||||
self.assertIn("Sectioned lanes", res.body.decode())
|
||||
self.dbsession.expire(shop)
|
||||
self.assertEqual(shop.home_layout, 2)
|
||||
self.assertEqual(shop.home_layout_per_lane_limit, 7)
|
||||
|
||||
def test_home_layout_clamps_out_of_range_values(self):
|
||||
shop, _product = self._make_shop_with_product("clamp-shop")
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "home-layout-settings",
|
||||
"home_layout": "99", # invalid → clamp to 2 (hi bound)
|
||||
"home_layout_tag_limit": "9999",
|
||||
"home_layout_per_lane_limit": "0",
|
||||
"submit": "Save Home Layout",
|
||||
},
|
||||
)
|
||||
self.dbsession.expire(shop)
|
||||
# home_layout clamps to [0, 2]
|
||||
self.assertEqual(shop.home_layout, 2)
|
||||
# tag_limit clamps to [1, 40]
|
||||
self.assertEqual(shop.home_layout_tag_limit, 40)
|
||||
# per_lane_limit clamps to [1, 40]
|
||||
self.assertEqual(shop.home_layout_per_lane_limit, 1)
|
||||
|
||||
def test_tag_editor_create_and_delete(self):
|
||||
shop, _product = self._make_shop_with_product("tag-editor-shop")
|
||||
# GET the tag editor page
|
||||
res = self.testapp.get(f"/s/{shop.id}/tags")
|
||||
self.assertIn("Create a tag", res.body.decode())
|
||||
|
||||
# Create a tag
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{"action": "create", "name": "Math"},
|
||||
)
|
||||
if res.status_int == 302:
|
||||
res = res.follow()
|
||||
self.assertIn("ready to use", res.body.decode())
|
||||
|
||||
from ..models.tag import get_tag_by_shop_and_slug
|
||||
self.dbsession.expire_all()
|
||||
tag = get_tag_by_shop_and_slug(self.dbsession, shop, "math")
|
||||
self.assertIsNotNone(tag)
|
||||
|
||||
# Delete the tag
|
||||
res = self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{"action": "delete", "tag_slug": "math"},
|
||||
)
|
||||
if res.status_int == 302:
|
||||
res = res.follow()
|
||||
self.assertIn("deleted", res.body.decode())
|
||||
self.dbsession.expire_all()
|
||||
self.assertIsNone(
|
||||
get_tag_by_shop_and_slug(self.dbsession, shop, "math")
|
||||
)
|
||||
|
||||
def test_product_edit_writes_tags(self):
|
||||
shop, product = self._make_shop_with_product("prod-tag-shop")
|
||||
product_id = str(product.id)
|
||||
# POST tags via product edit form
|
||||
res = self.testapp.post(
|
||||
f"/p/{product_id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": product.description,
|
||||
"price": str(product.price),
|
||||
"visibility": str(product.visibility),
|
||||
"tags": "Math, Seasonal, Valentine's Day",
|
||||
},
|
||||
)
|
||||
if res.status_int == 302:
|
||||
res = res.follow()
|
||||
self.assertIn("updated the product's tags", res.body.decode())
|
||||
|
||||
# Verify tag rows exist + product carries them
|
||||
from ..models.tag import tags_by_popularity
|
||||
self.dbsession.expire_all()
|
||||
tags = tags_by_popularity(self.dbsession, shop)
|
||||
slugs = {t.slug for t in tags}
|
||||
self.assertIn("math", slugs)
|
||||
self.assertIn("seasonal", slugs)
|
||||
|
||||
def test_tag_attach_and_detach_via_bulk_tagger(self):
|
||||
shop, product = self._make_shop_with_product("attach-shop")
|
||||
product_id = str(product.id)
|
||||
# Create a tag
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{"action": "create", "name": "Holiday"},
|
||||
)
|
||||
# Attach
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{
|
||||
"action": "attach",
|
||||
"tag_slug": "holiday",
|
||||
"product_id": product_id,
|
||||
},
|
||||
)
|
||||
self.dbsession.expire_all()
|
||||
from ..models.tag import get_tag_by_shop_and_slug
|
||||
from ..models.product import get_product_by_id
|
||||
tag = get_tag_by_shop_and_slug(self.dbsession, shop, "holiday")
|
||||
product = get_product_by_id(self.dbsession, product_id)
|
||||
self.assertIn(tag, list(product.tags))
|
||||
|
||||
# Detach
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/tags",
|
||||
{
|
||||
"action": "detach",
|
||||
"tag_slug": "holiday",
|
||||
"product_id": product_id,
|
||||
},
|
||||
)
|
||||
self.dbsession.expire_all()
|
||||
product = get_product_by_id(self.dbsession, product_id)
|
||||
self.assertNotIn(tag, list(product.tags))
|
||||
|
||||
def test_chip_filter_via_query_string(self):
|
||||
"""?tag=<slug> renders chip strip; filter resolves server-side."""
|
||||
shop, product = self._make_shop_with_product("filter-shop")
|
||||
product_id = str(product.id)
|
||||
# Enable chip layout
|
||||
self.testapp.post(
|
||||
f"/s/{shop.id}/settings",
|
||||
{
|
||||
"form_section": "home-layout-settings",
|
||||
"home_layout": "1",
|
||||
"submit": "Save Home Layout",
|
||||
},
|
||||
)
|
||||
# Tag the product (file isn't uploaded so the product isn't
|
||||
# "is_ready"; we're testing chip wiring, not grid rendering).
|
||||
self.testapp.post(
|
||||
f"/p/{product_id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": product.description,
|
||||
"price": str(product.price),
|
||||
"visibility": "1",
|
||||
"tags": "Math",
|
||||
},
|
||||
)
|
||||
# Shop home renders the chip strip when tags exist + layout=1
|
||||
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}")
|
||||
body = res.body.decode()
|
||||
self.assertIn("data-tag-strip", body)
|
||||
self.assertIn("Math", body)
|
||||
|
||||
# Filter to an existing tag — page renders, chip shows active state
|
||||
res = self.testapp.get(f"/s/{shop.id}/{shop.slug}?tag=math")
|
||||
body = res.body.decode()
|
||||
self.assertIn("tag-chip-active", body)
|
||||
self.assertIn('data-tag-slug="math"', body)
|
||||
|
||||
# Filter to a non-existent slug — view degrades gracefully
|
||||
res = self.testapp.get(
|
||||
f"/s/{shop.id}/{shop.slug}?tag=does-not-exist"
|
||||
)
|
||||
self.assertEqual(res.status_int, 200)
|
||||
|
||||
def test_tag_detail_page_renders(self):
|
||||
shop, product = self._make_shop_with_product("tag-detail-shop")
|
||||
product_id = str(product.id)
|
||||
self.testapp.post(
|
||||
f"/p/{product_id}/edit",
|
||||
{
|
||||
"title": product.title,
|
||||
"description": product.description,
|
||||
"price": str(product.price),
|
||||
"visibility": "1",
|
||||
"tags": "Seasonal",
|
||||
},
|
||||
)
|
||||
res = self.testapp.get(f"/s/{shop.id}/tag/seasonal")
|
||||
self.assertEqual(res.status_int, 200)
|
||||
# Tag detail page renders even when products aren't is_ready —
|
||||
# confirm the tag header is present.
|
||||
body = res.body.decode()
|
||||
self.assertIn("Seasonal", body)
|
||||
|
||||
def test_tag_detail_404_when_tag_missing(self):
|
||||
shop, _product = self._make_shop_with_product("missing-tag-shop")
|
||||
res = self.testapp.get(
|
||||
f"/s/{shop.id}/tag/does-not-exist", expect_errors=True
|
||||
)
|
||||
self.assertEqual(res.status_int, 404)
|
||||
|
|
|
|||
|
|
@ -4954,3 +4954,113 @@ class TestEmailNotificationContent(unittest.TestCase):
|
|||
self.assertIn("accepted", args[2].lower())
|
||||
self.assertIn("120.00", args[3])
|
||||
self.assertIn("/o/off-789", args[4])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MPS-24: Shop home layout + tag model unit tests
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestShopHomeLayout(unittest.TestCase):
|
||||
"""MPS-24: home_layout int + helper properties (no DB)."""
|
||||
|
||||
def _make_shop(self):
|
||||
return Shop("layout-test", "555-0000", "123 Test St", "test shop")
|
||||
|
||||
def test_default_layout_is_flat(self):
|
||||
shop = self._make_shop()
|
||||
# Default before DB load is None; helper props treat that as 0.
|
||||
self.assertTrue(shop.is_home_flat)
|
||||
self.assertFalse(shop.is_home_chips)
|
||||
self.assertFalse(shop.is_home_lanes)
|
||||
self.assertEqual(shop.home_layout_label, "Flat grid")
|
||||
|
||||
def test_chips_layout(self):
|
||||
shop = self._make_shop()
|
||||
shop.home_layout = 1
|
||||
self.assertFalse(shop.is_home_flat)
|
||||
self.assertTrue(shop.is_home_chips)
|
||||
self.assertFalse(shop.is_home_lanes)
|
||||
self.assertEqual(shop.home_layout_label, "Filter chips")
|
||||
|
||||
def test_lanes_layout(self):
|
||||
shop = self._make_shop()
|
||||
shop.home_layout = 2
|
||||
self.assertFalse(shop.is_home_flat)
|
||||
self.assertFalse(shop.is_home_chips)
|
||||
self.assertTrue(shop.is_home_lanes)
|
||||
self.assertEqual(shop.home_layout_label, "Sectioned lanes")
|
||||
|
||||
def test_unknown_layout_falls_back_to_flat_label(self):
|
||||
shop = self._make_shop()
|
||||
shop.home_layout = 99
|
||||
self.assertEqual(shop.home_layout_label, "Flat grid")
|
||||
|
||||
def test_featured_product_ids_blank(self):
|
||||
shop = self._make_shop()
|
||||
shop.featured_product_ids_json = ""
|
||||
self.assertEqual(shop.featured_product_ids, [])
|
||||
|
||||
def test_featured_product_ids_parses_json_list(self):
|
||||
shop = self._make_shop()
|
||||
shop.featured_product_ids_json = '["abc123", "def456"]'
|
||||
self.assertEqual(shop.featured_product_ids, ["abc123", "def456"])
|
||||
|
||||
def test_featured_product_ids_rejects_non_list(self):
|
||||
shop = self._make_shop()
|
||||
shop.featured_product_ids_json = '{"oops": true}'
|
||||
self.assertEqual(shop.featured_product_ids, [])
|
||||
|
||||
def test_featured_product_ids_rejects_invalid_json(self):
|
||||
shop = self._make_shop()
|
||||
shop.featured_product_ids_json = "not json"
|
||||
self.assertEqual(shop.featured_product_ids, [])
|
||||
|
||||
def test_tag_stopwords_lowercases(self):
|
||||
shop = self._make_shop()
|
||||
shop.tag_stopwords_json = '["The", "WRITE", "room"]'
|
||||
self.assertEqual(shop.tag_stopwords, ["the", "write", "room"])
|
||||
|
||||
def test_tag_stopwords_blank(self):
|
||||
shop = self._make_shop()
|
||||
shop.tag_stopwords_json = ""
|
||||
self.assertEqual(shop.tag_stopwords, [])
|
||||
|
||||
|
||||
class TestTagModel(unittest.TestCase):
|
||||
"""MPS-24: Tag model construction + slugification (no DB)."""
|
||||
|
||||
def _make_shop(self):
|
||||
return Shop("tag-test", "555-0000", "123 Test St", "test shop")
|
||||
|
||||
def test_tag_construction_slugifies_name(self):
|
||||
from ..models.tag import Tag
|
||||
shop = self._make_shop()
|
||||
tag = Tag(shop=shop, name="Math Activities")
|
||||
self.assertEqual(tag.name, "Math Activities")
|
||||
self.assertEqual(tag.slug, "math-activities")
|
||||
self.assertEqual(tag.shop, shop)
|
||||
self.assertIsNotNone(tag.created_timestamp)
|
||||
|
||||
def test_tag_slug_normalises_capitalisation(self):
|
||||
from ..models.tag import Tag
|
||||
shop = self._make_shop()
|
||||
tag = Tag(shop=shop, name="VALENTINE'S DAY")
|
||||
# slugify lowercases and strips punctuation
|
||||
self.assertIn("valentine", tag.slug)
|
||||
self.assertNotIn("'", tag.slug)
|
||||
|
||||
def test_tag_name_truncated_to_64_chars(self):
|
||||
from ..models.tag import Tag
|
||||
shop = self._make_shop()
|
||||
long_name = "a" * 200
|
||||
tag = Tag(shop=shop, name=long_name)
|
||||
self.assertEqual(len(tag.name), 64)
|
||||
|
||||
def test_tag_strips_whitespace(self):
|
||||
from ..models.tag import Tag
|
||||
shop = self._make_shop()
|
||||
tag = Tag(shop=shop, name=" Math ")
|
||||
self.assertEqual(tag.name, "Math")
|
||||
self.assertEqual(tag.slug, "math")
|
||||
|
||||
|
|
|
|||
|
|
@ -311,6 +311,36 @@ def product_edit(request):
|
|||
request.dbsession.add(product.set_price(price))
|
||||
request.session.flash(("You updated the product's price.", "success"))
|
||||
|
||||
# MPS-24: tag editing. Only act when the `tags` input is present in the
|
||||
# submission, so other forms on the page (uploads, inventory) don't
|
||||
# silently strip product tags.
|
||||
if "tags" in request.params:
|
||||
from ..models.tag import get_or_create_tag
|
||||
raw = (request.params.get("tags") or "").strip()
|
||||
names = [t.strip() for t in raw.split(",") if t.strip()]
|
||||
desired_tags = []
|
||||
seen_slugs = set()
|
||||
for name in names:
|
||||
tag = get_or_create_tag(request.dbsession, product.shop, name)
|
||||
if tag is None or tag.slug in seen_slugs:
|
||||
continue
|
||||
seen_slugs.add(tag.slug)
|
||||
desired_tags.append(tag)
|
||||
current = list(product.tags)
|
||||
current_slugs = {t.slug for t in current}
|
||||
desired_slugs = {t.slug for t in desired_tags}
|
||||
if current_slugs != desired_slugs:
|
||||
for tag in current:
|
||||
if tag.slug not in desired_slugs:
|
||||
product.tags.remove(tag)
|
||||
for tag in desired_tags:
|
||||
if tag.slug not in current_slugs:
|
||||
product.tags.append(tag)
|
||||
product_modified = True
|
||||
request.session.flash(
|
||||
("You updated the product's tags.", "success")
|
||||
)
|
||||
|
||||
# MPS-20 + MPS-21: pricing_mode + allow_offers (per-product overrides).
|
||||
# Gated on the pricing_mode field being present — it only appears on
|
||||
# the product-title-and-description edit form, so other forms on the
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ from ..models.shop_search_request import (
|
|||
ShopSearchRequest,
|
||||
)
|
||||
|
||||
from ..models.tag import (
|
||||
Tag,
|
||||
get_or_create_tag,
|
||||
get_tag_by_shop_and_slug,
|
||||
tags_by_popularity,
|
||||
)
|
||||
from ..models.product_tag import ProductTag
|
||||
|
||||
from ..lib.mail import send_invite_email
|
||||
|
||||
from ..lib.phone_numbers import is_phone_number_valid
|
||||
|
|
@ -209,6 +217,87 @@ def get_shop_from_matchdict(request, prefetched_shop=None):
|
|||
return get_shop_by_id(request.dbsession, request.matchdict["shop_id"])
|
||||
|
||||
|
||||
def _build_home_layout_context(request, shop, products):
|
||||
"""
|
||||
MPS-24: derive home-layout context (chips/lanes/featured) for a shop home
|
||||
or search results page. Returns a dict that always includes:
|
||||
|
||||
- home_chips: list of Tag rows for chip strip (empty when layout == 0)
|
||||
- home_lanes: list of {"tag": Tag, "products": [Product, ...]} for
|
||||
sectioned layout (empty unless layout == 2)
|
||||
- active_tag: Tag if user filtered via ?tag=<slug>, else None
|
||||
- filtered_products: products filtered by active_tag if set, else products
|
||||
"""
|
||||
# Coerce query/iterable to a list so templates can call |length and
|
||||
# python list-comprehensions work uniformly downstream.
|
||||
if products is None:
|
||||
products_list = None
|
||||
elif isinstance(products, list):
|
||||
products_list = products
|
||||
else:
|
||||
products_list = list(products)
|
||||
|
||||
ctx = {
|
||||
"home_chips": [],
|
||||
"home_lanes": [],
|
||||
"active_tag": None,
|
||||
"filtered_products": products_list,
|
||||
}
|
||||
if shop is None:
|
||||
return ctx
|
||||
|
||||
layout = shop.home_layout or 0
|
||||
tag_limit = int(shop.home_layout_tag_limit or 8)
|
||||
per_lane = int(shop.home_layout_per_lane_limit or 10)
|
||||
|
||||
# Chip strip rendered only when layout == 1 or 2 (lanes shows chips too,
|
||||
# so a shopper can drill into a single tag from sectioned home).
|
||||
if layout in (1, 2):
|
||||
ctx["home_chips"] = tags_by_popularity(
|
||||
request.dbsession, shop, limit=tag_limit
|
||||
)
|
||||
|
||||
# Active tag from ?tag= param — applied to filtered_products regardless
|
||||
# of layout so /search?tag=X and /s/{id}?tag=X both filter in place.
|
||||
tag_slug = (request.params.get("tag") or "").strip().lower()
|
||||
if tag_slug:
|
||||
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
|
||||
if tag is not None:
|
||||
ctx["active_tag"] = tag
|
||||
tagged_ids = {
|
||||
row.product_id
|
||||
for row in request.dbsession.query(ProductTag.product_id)
|
||||
.filter(ProductTag.tag_id == tag.id)
|
||||
.all()
|
||||
}
|
||||
if products_list is not None:
|
||||
ctx["filtered_products"] = [
|
||||
p for p in products_list if p.id in tagged_ids
|
||||
]
|
||||
|
||||
# Sectioned lanes only when layout == 2 and the shopper hasn't already
|
||||
# filtered to one tag (filtering trumps lanes — single grid in that case).
|
||||
if layout == 2 and ctx["active_tag"] is None:
|
||||
lanes = []
|
||||
for tag in ctx["home_chips"]:
|
||||
tagged_ids = [
|
||||
row.product_id
|
||||
for row in request.dbsession.query(ProductTag.product_id)
|
||||
.filter(ProductTag.tag_id == tag.id)
|
||||
.all()
|
||||
]
|
||||
if not tagged_ids:
|
||||
continue
|
||||
tag_products = [
|
||||
p for p in (products_list or []) if p.id in set(tagged_ids)
|
||||
][:per_lane]
|
||||
if tag_products:
|
||||
lanes.append({"tag": tag, "products": tag_products})
|
||||
ctx["home_lanes"] = lanes
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
@view_config(route_name="home", renderer="home.j2")
|
||||
def home(request):
|
||||
if request.is_saas_domain and request.user and request.user.authenticated:
|
||||
|
|
@ -218,9 +307,9 @@ def home(request):
|
|||
products = None
|
||||
if request.shop:
|
||||
products = get_products_from_a_shop(request.shop)
|
||||
return {
|
||||
"products": products,
|
||||
}
|
||||
ctx = {"products": products}
|
||||
ctx.update(_build_home_layout_context(request, request.shop, products))
|
||||
return ctx
|
||||
|
||||
|
||||
@view_config(route_name="shop", renderer="shop.j2")
|
||||
|
|
@ -230,7 +319,9 @@ def shop(request):
|
|||
if "slug" not in request.matchdict:
|
||||
return HTTPFound(f"/s/{shop.id}/{shop.slug}")
|
||||
|
||||
ctx = {"products": get_products_from_a_shop(shop)}
|
||||
products = get_products_from_a_shop(shop)
|
||||
ctx = {"products": products}
|
||||
ctx.update(_build_home_layout_context(request, shop, products))
|
||||
|
||||
if shop.public_sales_stats:
|
||||
from ..models.invoice import Invoice
|
||||
|
|
@ -300,10 +391,12 @@ def search(request):
|
|||
# redirect to only match, the title slugified node uri.
|
||||
return HTTPFound(products[0].absolute_url(request))
|
||||
|
||||
return {
|
||||
ctx = {
|
||||
"products": products,
|
||||
"keywords": keywords,
|
||||
}
|
||||
ctx.update(_build_home_layout_context(request, request.shop, products))
|
||||
return ctx
|
||||
|
||||
|
||||
@view_config(route_name="shop_new", renderer="shop_new.j2")
|
||||
|
|
@ -1423,6 +1516,72 @@ def shop_settings(request):
|
|||
else:
|
||||
shop.offer_min_in_cents = None
|
||||
|
||||
# MPS-24: home layout settings.
|
||||
if form_section == "home-layout-settings":
|
||||
def _int_param(name, default, lo=0, hi=10**9):
|
||||
raw = (request.params.get(name) or "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
val = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(lo, min(hi, val))
|
||||
|
||||
new_layout = _int_param("home_layout", shop.home_layout or 0, 0, 2)
|
||||
if (shop.home_layout or 0) != new_layout:
|
||||
shop.home_layout = new_layout
|
||||
label = {0: "Flat grid", 1: "Filter chips", 2: "Sectioned lanes"}[
|
||||
new_layout
|
||||
]
|
||||
request.session.flash(
|
||||
(f"Home page layout set to {label}.", "success")
|
||||
)
|
||||
|
||||
shop.home_layout_tag_limit = _int_param(
|
||||
"home_layout_tag_limit",
|
||||
shop.home_layout_tag_limit or 8,
|
||||
1,
|
||||
40,
|
||||
)
|
||||
shop.home_layout_per_lane_limit = _int_param(
|
||||
"home_layout_per_lane_limit",
|
||||
shop.home_layout_per_lane_limit or 10,
|
||||
1,
|
||||
40,
|
||||
)
|
||||
|
||||
# Featured product ids — comma-separated UUIDs. Skip silently
|
||||
# if input is empty; clear if operator submits an explicit "-".
|
||||
featured_raw = (
|
||||
request.params.get("featured_product_ids") or ""
|
||||
).strip()
|
||||
if featured_raw == "-":
|
||||
shop.featured_product_ids_json = ""
|
||||
elif featured_raw:
|
||||
ids = [
|
||||
s.strip()
|
||||
for s in featured_raw.replace("\n", ",").split(",")
|
||||
if s.strip()
|
||||
]
|
||||
import json as _json
|
||||
shop.featured_product_ids_json = _json.dumps(ids)
|
||||
|
||||
# Per-shop tag stopwords (Phase 2 auto-tagger input).
|
||||
stopwords_raw = (
|
||||
request.params.get("tag_stopwords") or ""
|
||||
).strip()
|
||||
if stopwords_raw == "-":
|
||||
shop.tag_stopwords_json = ""
|
||||
elif stopwords_raw:
|
||||
words = [
|
||||
w.strip().lower()
|
||||
for w in stopwords_raw.replace("\n", ",").split(",")
|
||||
if w.strip()
|
||||
]
|
||||
import json as _json
|
||||
shop.tag_stopwords_json = _json.dumps(words)
|
||||
|
||||
# If we processed any form submission, respond accordingly
|
||||
if form_section:
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
|
|
@ -1778,3 +1937,121 @@ def checksum_backfill_status(request):
|
|||
"done": done_count,
|
||||
"remaining": total - done_count,
|
||||
}
|
||||
|
||||
|
||||
# --- MPS-24: tag views ---------------------------------------------------
|
||||
|
||||
|
||||
@view_config(route_name="shop_tag_detail", renderer="shop_tag.j2")
|
||||
def shop_tag_detail(request):
|
||||
"""Public page listing every public product carrying one tag in a shop."""
|
||||
shop = get_shop_from_matchdict(request)
|
||||
if shop is None:
|
||||
raise HTTPNotFound()
|
||||
tag_slug = request.matchdict.get("slug", "").strip().lower()
|
||||
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
|
||||
if tag is None:
|
||||
raise HTTPNotFound()
|
||||
products = get_products_from_a_shop(shop)
|
||||
tagged_ids = {
|
||||
row.product_id
|
||||
for row in request.dbsession.query(ProductTag.product_id)
|
||||
.filter(ProductTag.tag_id == tag.id)
|
||||
.all()
|
||||
}
|
||||
filtered = [p for p in products if p.id in tagged_ids]
|
||||
ctx = {
|
||||
"products": filtered,
|
||||
"active_tag": tag,
|
||||
"home_chips": tags_by_popularity(
|
||||
request.dbsession,
|
||||
shop,
|
||||
limit=int(shop.home_layout_tag_limit or 8),
|
||||
),
|
||||
"home_lanes": [],
|
||||
"filtered_products": filtered,
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
@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)."""
|
||||
shop = get_shop_from_matchdict(request)
|
||||
if shop is None:
|
||||
raise HTTPNotFound()
|
||||
|
||||
action = (request.params.get("action") or "").strip()
|
||||
|
||||
if action == "create":
|
||||
name = (request.params.get("name") or "").strip()
|
||||
tag = get_or_create_tag(request.dbsession, shop, name)
|
||||
if tag is None:
|
||||
request.session.flash(("That tag name is invalid.", "error"))
|
||||
else:
|
||||
request.session.flash(
|
||||
(f"Tag '{tag.name}' is ready to use.", "success")
|
||||
)
|
||||
return HTTPFound(f"/s/{shop.id}/tags")
|
||||
|
||||
if action == "delete":
|
||||
tag_slug = (request.params.get("tag_slug") or "").strip().lower()
|
||||
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
|
||||
if tag is not None:
|
||||
name = tag.name
|
||||
request.dbsession.delete(tag)
|
||||
request.session.flash((f"Tag '{name}' deleted.", "success"))
|
||||
return HTTPFound(f"/s/{shop.id}/tags")
|
||||
|
||||
if action in ("attach", "detach"):
|
||||
from ..models.product import get_product_by_id
|
||||
|
||||
tag_slug = (request.params.get("tag_slug") or "").strip().lower()
|
||||
tag = get_tag_by_shop_and_slug(request.dbsession, shop, tag_slug)
|
||||
product_id = (request.params.get("product_id") or "").strip()
|
||||
product = (
|
||||
get_product_by_id(request.dbsession, product_id)
|
||||
if product_id
|
||||
else None
|
||||
)
|
||||
if (
|
||||
tag is not None
|
||||
and product is not None
|
||||
and product.shop_id == shop.id
|
||||
):
|
||||
if action == "attach" and tag not in product.tags:
|
||||
product.tags.append(tag)
|
||||
request.session.flash(
|
||||
(
|
||||
f"Tag '{tag.name}' applied to '{product.title}'.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
elif action == "detach" and tag in product.tags:
|
||||
product.tags.remove(tag)
|
||||
request.session.flash(
|
||||
(
|
||||
f"Tag '{tag.name}' removed from '{product.title}'.",
|
||||
"success",
|
||||
)
|
||||
)
|
||||
return HTTPFound(
|
||||
f"/s/{shop.id}/tags?focus={tag_slug}"
|
||||
)
|
||||
|
||||
# GET: render bulk tagger.
|
||||
all_tags = tags_by_popularity(request.dbsession, shop)
|
||||
focus_slug = (request.params.get("focus") or "").strip().lower()
|
||||
focus_tag = (
|
||||
get_tag_by_shop_and_slug(request.dbsession, shop, focus_slug)
|
||||
if focus_slug
|
||||
else None
|
||||
)
|
||||
all_products = get_all_products_from_a_shop(shop)
|
||||
return {
|
||||
"tags": all_tags,
|
||||
"focus_tag": focus_tag,
|
||||
"all_products": all_products,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue