Compare commits

145 Commits

Author SHA1 Message Date
giles
90bb061c08 Fix circular fragment fetching (shared submodule update)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:20:52 +00:00
giles
923eea339e Sync shared: fragment failures now raise by default
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:04:29 +00:00
giles
2a90a21c64 trigger rebuild
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
2026-02-24 18:01:55 +00:00
giles
85ffe34fc9 Remove cross-domain template copies, use shared macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
- Blog hamburger: removed (inlined in shared layout.html)
- Cart mini: use macros/cart_icon.html for add-to-cart OOB
- Post header: use blog_url() instead of url_for('blog.post.post_detail')

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:33:11 +00:00
giles
3db0ca23c7 Add cross-domain template copy: blog hamburger filter for shared layout.html
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:17:36 +00:00
giles
7949718383 Sync shared submodule (bound DB connection pool)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:08:14 +00:00
giles
1ff4f64d5d Own market domain templates (Phase 6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s
Browse, market, product templates + filters macro moved from shared
to market/templates/. Cross-domain copies: post header, cart mini.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:55:49 +00:00
giles
4d2a14cdcb Sync shared submodule (Phase 5 widget cleanup)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:59:10 +00:00
giles
395d40c7f7 Phase 4: add container-nav fragment handler for market links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Market app serves marketplace links as a fragment at
/internal/fragments/container-nav for consumption by blog and events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:33:31 +00:00
giles
07ed2980fa Render cart-mini directly in add-to-cart HTMX response
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
The handler already has g.cart with the updated items — no need to
make an HTTP round-trip to the cart app's fragment endpoint. Renders
the _mini.html macro directly with the count from g.cart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:57:12 +00:00
giles
f34d35b9c4 Fix cart icon vanishing on add-to-cart when fragment fetch fails
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
When the cart-mini fragment fetch returns empty, the HTMX outerHTML
swap was replacing #cart-mini with nothing. Now falls back to the
local _mini.html macro with count from g.cart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 12:52:19 +00:00
giles
49a4780efe Restore menu_items fallback for nav, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Keep get_navigation_tree() as fallback when nav-tree fragment fetch
fails. Update shared submodule with fixed app slug URLs in nav.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:57:49 +00:00
giles
d1a6690cc3 Fetch nav-tree fragment from blog, drop local menu_items query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Navigation is now rendered by blog as an HTML fragment. This app
fetches it with its own app_name and path for correct highlighting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:39:39 +00:00
giles
93f830ff13 Commit transaction before fetching cart-mini fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
The cart-mini fragment is fetched via HTTP from the cart app, which
uses its own DB connection. Without committing first, the cart app
sees stale data (no new item). Commit the transaction, start a new
one so after_request can still commit cleanly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:55:40 +00:00
giles
06e700820e Replace shared _added.html with market-owned version using cart-mini fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
The shared _added.html imported _types/cart/_cart.html which only
exists in the cart app. Market now has its own _added.html that:
- Fetches cart-mini fragment for the OOB mini cart update
- Uses market's own _types/product/_cart.html macros
- Drops cart summary/show_cart (not visible on product pages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:49:09 +00:00
giles
d92d4840ed Rename product blueprint URL param slug → product_slug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
The app-level url_value_preprocessor pops "slug" and "page_slug"
into g.post_slug, with page_slug overwriting the product slug.
Renaming the blueprint param to product_slug eliminates the
collision entirely. Views now use g.product_slug (set by blueprint
preprocessor) and have clean signatures with no **_kw hacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:26:20 +00:00
giles
291c829c7f Let resolve_product run fully for POST, just skip redirects
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Instead of skipping resolve_product entirely for POST (leaving
g.item_data unset and breaking templates), run the full resolution
but suppress canonical-slug redirects. This sets g.item_data for
the context processor so d.slug and other template vars work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:22:04 +00:00
giles
e0b640f15b Pass product data to cart _added.html template
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
resolve_product skips for POST so g.item_data (and thus d) is not
set. Pass d={slug} directly in the render_template call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:21:16 +00:00
giles
25228881aa Fix product slug: extract from URL path instead of g.post_slug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
App-level preprocessor pops slug then page_slug into g.post_slug,
overwriting the product slug with the page slug ("market"). Extract
the product slug directly from the request path after "/product/".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:12:00 +00:00
giles
97bc694ff0 Skip resolve_product redirects for POST requests (cart, like)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m4s
resolve_product's canonical slug redirect causes a 302 loop on POST
/cart/ because it redirects to the GET product detail page. POST
endpoints only need g.product_slug to look up the product — skip
the full resolution/redirect logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:06:05 +00:00
giles
d014203776 Fix url_for endpoint name in product slug redirect
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Use fully qualified "market.browse.product.product_detail" instead
of "product.product_detail" which doesn't exist in the URL map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:02:04 +00:00
giles
f53d2841e9 Fix product slug resolution: fall back to g.post_slug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
App-level url_value_preprocessor pops slug into g.post_slug before
the blueprint preprocessor runs, leaving values empty. Fall back to
g.post_slug so resolve_product and cart route get the slug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:57:42 +00:00
giles
3c9ff1210a Fix cart route missing slug param (popped by app-level preprocessor)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
App-level url_value_preprocessor pops slug from values, so the
product blueprint's cart() never receives it. Use g.product_slug
(set by resolve_product before_request) instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:51:50 +00:00
giles
4a37f281d4 Update shared submodule (fragment auth skip for internal paths)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:11:56 +00:00
giles
e49668b301 Add fragment blueprint + sync shared: micro-frontend infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:47 +00:00
giles
005c04e5f9 Sync shared: instant logout detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:35 +00:00
giles
e479730f3f Sync shared submodule: external delivery handler
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:41:17 +00:00
giles
0954dc0505 Sync shared: add artdag_url() helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:48 +00:00
giles
84f13153a6 Sync shared: per-domain delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
2026-02-23 21:54:16 +00:00
giles
09ca461df6 Update shared: backfill only current posts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:36:50 +00:00
giles
0e2f0b818e Update shared: rewrite object URLs for per-app AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:06 +00:00
giles
c147900072 Update shared: fix activity ID domain mismatch in AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:13 +00:00
giles
fc8bbc927b Update shared submodule: exempt AP paths from auth redirect
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:29:08 +00:00
giles
8f44f99232 Update shared submodule: AP delivery fixes + sentinel
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:31:30 +00:00
giles
05d7ccd422 Update shared submodule: per-app AP actors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:23 +00:00
giles
74fc2f4fb9 Update shared submodule (blog.home → blog.index template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-02-23 16:55:31 +00:00
giles
f0743a5949 Retrigger CI (Docker Hub image now cached)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
2026-02-23 16:45:45 +00:00
giles
b91abd1a34 Update shared submodule (at-least-once + delivery log)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1s
2026-02-23 16:21:12 +00:00
giles
6e1a7cfc5b Update shared submodule (NOTIFY/LISTEN event processor)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
2026-02-23 16:05:17 +00:00
giles
973d639f0b Update shared submodule (add device_id migration)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
2026-02-23 15:26:50 +00:00
giles
2fd05faccb Update shared: blog_did = account_did, one device identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:12:26 +00:00
giles
1919258dcd Update shared: device-id SSO with account_did + Redis login signal
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:01:50 +00:00
giles
b027bf5bdf Sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:41:31 +00:00
giles
9eab90a3ae Update shared: add aiohttp dependency
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:05:48 +00:00
giles
691cb9c2ab Update shared: device cookie auth state detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:57:15 +00:00
giles
6fa2b73072 Update shared: grant-based session revocation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:21 +00:00
giles
0fd1e5be99 Iframe-based SSO logout (tolerates dead apps)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:21:43 +00:00
giles
2205e23e56 Update shared: remove sso_hint, add sso-clear logout chain
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:17:37 +00:00
giles
d4aa3ea4d2 Update shared: SSO revocation clears local session on logout
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:16:03 +00:00
giles
2ea879db44 Update shared submodule: account is now OAuth server
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:01:33 +00:00
giles
9d8e21001b Add /auth/clear to reset stale cookies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:45:26 +00:00
giles
26bc7c885a Logout through federation sso-logout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:32:09 +00:00
giles
bddc3cb122 Silent SSO via sso_hint cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:24:53 +00:00
giles
72a30b90f6 Fix logout redirect to blog home
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:15:30 +00:00
giles
7261645d1e Fix logout to use local /auth/logout/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:42 +00:00
giles
edaa028b67 Sign-in → account, clear old shared cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:57:09 +00:00
giles
4be16b92cd Trigger rebuild: per-app cookies + OAuth SSO
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:45:18 +00:00
giles
fd1575ed04 Fix OAuth authorize URL prefix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:25:57 +00:00
giles
5e26b5ec63 Update shared submodule: OAuth SSO + account app support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:59:07 +00:00
giles
715db8e493 Update shared submodule (fix root top-bar account link)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:07:51 +00:00
giles
9b4a63ff1e Update shared submodule (account URLs → federation)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:01:16 +00:00
giles
a8e587ebb3 Update shared: auth routes to federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:42:36 +00:00
giles
139eb3ac1f Rename coop_title to market_title, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:33:20 +00:00
giles
dcb93269fc Update COOP_DIR to /root/rose-ash in CI workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Infra files (.env, docker-compose.yml, _config) moved from ~/coop to ~/rose-ash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:44:11 +00:00
giles
3c87832fdf Add global + page-scoped market listings with infinite scroll
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
- New all_markets blueprint at / with paginated grid and HTMX infinite scroll
- New page_markets blueprint at /<slug>/ for page-scoped market listing
- list_marketplaces service method (via shared submodule update)
- Updated slug preprocessor to handle both /<slug>/ and /<page_slug>/<market_slug>/
- Removed inline markets_listing() route (replaced by all_markets blueprint)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:34:58 +00:00
giles
555ac6a152 Update shared: AP_DOMAIN default to federation.rose-ash.com
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:51 +00:00
giles
800d4c1822 Update shared: origin_app isolation for EventProcessor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:59:41 +00:00
giles
0fcfed4546 Update shared: fix AP re-publish versioned object IDs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:04:22 +00:00
giles
2cc646b5c6 Update shared submodule — restore deleted templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:29:50 +00:00
giles
9ef6f47bf1 Update shared submodule (remove dead code)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:11:39 +00:00
giles
504ada5d9b Update shared submodule (remove dead cart template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:05:34 +00:00
giles
a5ad2af550 Update shared submodule (cart_sid in login URL)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:46:47 +00:00
giles
43b98dd45a Update shared submodule (cart sign-in fix)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:38:16 +00:00
giles
20daef8808 Update shared submodule to unified AP activity bus
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:20:16 +00:00
giles
930ffae854 Tech debt cleanup: update README, fix comments, sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:36:03 +00:00
giles
460b909392 Update shared: add fediverse social tables and protocols
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:16:04 +00:00
giles
dd3bf455ef Update shared: fix duplicate AP posts + stable object IDs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:18:27 +00:00
giles
8db76c7099 Update shared: fix AP Delete Tombstone id mismatch
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:26:02 +00:00
giles
ab93ca2b84 Update shared: widget Phase 2 nav templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:14:29 +00:00
giles
599ba37d61 Update shared: fix AP object id domain for Mastodon
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:53:22 +00:00
giles
1d891a5cbf Update shared: inline federation publish + AP delivery fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:28:13 +00:00
giles
4671bc616e Inline federation publication for products
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
Replace emit_event("product.listed") with direct try_publish().
Update shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:55:57 +00:00
giles
c05e6e5baa Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:27:28 +00:00
giles
8bbf70eafd Wire real SqlFederationService instead of stub
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:19:26 +00:00
giles
bf0996e013 Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:54:11 +00:00
giles
0811b52869 Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:47:09 +00:00
giles
81526d5a9f Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:33:52 +00:00
giles
57a7ee3358 Update shared submodule: fix adopt_entries login bug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:20:52 +00:00
giles
a80547c7fa Update shared submodule + emit product events for federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
- Emit product.listed events when products are created
- Updated shared with federation handlers, delivery, anchoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 16:00:04 +00:00
giles
3bddee0d94 Wire federation service stub and update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
- Register StubFederationService in services/__init__.py
- Add federation to CI sibling list
- Add federation URL to app-config.yaml
- Update shared submodule with federation models/contracts/services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:11:24 +00:00
giles
ade59dcbb4 Update shared submodule: ticket +/- quantity support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:53:36 +00:00
giles
e6fa255941 Update shared submodule: decoupling audit cleanup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:15:40 +00:00
giles
a57ea63b92 Update shared submodule: ticket-to-cart integration
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:33:10 +00:00
giles
e42a91982f Update shared submodule to latest widget registry
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:04:15 +00:00
giles
806efadb93 Update shared submodule: widget registry
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:08:52 +00:00
giles
93aa61494a Update shared submodule: tickets & bookings account pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:07:33 +00:00
giles
05cba16cef Fix category highlighting and revert current_local_href breakage
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
- Update shared submodule: category selector uses slug comparison
  instead of current_local_href for active state
- Keep current_local_href commented out in category_context() to
  avoid overriding the base template value used by brand filters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:29:06 +00:00
giles
74d6071ad4 Update shared submodule: select_colours Jinja global
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:17:26 +00:00
giles
1aa3659bb8 Uncomment current_local_href in category_context for subcategory highlighting
The category selector compares current_local_href against sub.local_href
to determine the active subcategory. This was commented out, so no
subcategory was ever highlighted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:17:12 +00:00
giles
aab9cf3f6b Update shared submodule: fix menu item highlighting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:57:12 +00:00
giles
6984f3f3db Update shared submodule: delete button + quantity clamp in cart_item
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:43:42 +00:00
giles
a50c5e4d46 Update shared submodule: cart_quantity_url template support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:32:38 +00:00
giles
ca5b952ffc Update shared submodule: MarketService write methods
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:57:33 +00:00
giles
d7162f5543 Fix inconsistent cart count: include calendar entries in market app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Market context processor was only counting product CartItems for cart_count,
while blog/cart/events apps include calendar entries too. Use cart service
for consistent counts across all apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:17:04 +00:00
giles
0a3997b82a Update shared submodule: DTO template compatibility fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:05:46 +00:00
giles
50cad49576 Update shared submodule: revert extend_existing workaround
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:51:08 +00:00
giles
fe255fc53c Fix cart template: use direct CartItem queries in market context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Market owns CartItem/Product — query directly with selectinload
so templates can access item.product.slug and other ORM attributes.
The cart service DTOs are for cross-domain consumers (blog, events).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:50:06 +00:00
giles
ad445e2fd2 Fix NameError: import services registry in create_app scope
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
The services singleton was used in before_request closures but the
import was removed when refactoring to domain_services_fn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:45:37 +00:00
giles
b04dbbba67 Remove glue submodule: models moved to shared/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
The glue layer's models (MenuNode, ContainerRelation), services
(navigation, relationships), and event handlers have been absorbed
into shared/. The glue submodule caused duplicate SQLAlchemy table
registration for 'menu_nodes'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:41:20 +00:00
giles
3fea2e6fdb Update shared submodule: fix duplicate table error for MenuNode
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:34:59 +00:00
giles
acf352ee3b Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Replace direct Post query and CartItem imports with typed service calls.
Market registers all 4 services via domain_services_fn with has() guards.

Key changes:
- app.py: use domain_services_fn, Post query → services.blog,
  CartItem → services.cart, MarketPlace+Post join → separate queries,
  glue navigation → shared navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:30:22 +00:00
giles
33befd4c3d Update shared submodule: fix ticket_types lazy-load
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:02:22 +00:00
giles
8dfb95ccab Update shared submodule: fix cart-mini home link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:49:54 +00:00
giles
7ccdc1fa83 Decouple market: use shared.models for all cross-app imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
- Replace blog.models import with shared.models equivalent
- Convert market/models/market.py and market_place.py to re-export stubs
- Update shared + glue submodule pointers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:58:11 +00:00
giles
31f9aa3fac Remove 47 identical market template overrides of shared templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:03:31 +00:00
giles
ec1880b658 Update shared submodule: fix orders link htmx interception
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:30:58 +00:00
giles
72042e793b Update shared submodule: use coop_url for auth links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:18:43 +00:00
giles
b3125c5db4 Update shared submodule: fix market nav link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:57:11 +00:00
giles
7edc0a53a1 Update shared submodule: add page_config to get_checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:38:20 +00:00
giles
867bfa234f Update shared submodule: market_product_url for correct product URLs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:19:56 +00:00
giles
bc1a7783df Update shared submodule: add page_config to SumUp checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:52:19 +00:00
giles
0015839845 Update shared submodule: fix doubled URLs in |host filter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:46:07 +00:00
giles
63778d9f20 Fix cart quantities not showing on product list after page refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
The context processor loaded cart items but only passed aggregates
(cart_count, cart_total) — not the cart list itself. Templates need
the full list to display per-product quantities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:40:48 +00:00
giles
28938e38b5 Fix cart count: query DB directly instead of cross-app API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Two bugs fixed:

1. First htmx add didn't update mini cart count because the context
   processor's API call couldn't see the uncommitted transaction.
   Fix: pass cart_count/cart_total explicitly from the route handler.

2. Page refresh always showed cart count 0 because the internal API
   call to the cart service failed to resolve cart identity correctly.
   Fix: replace the API call with a direct DB query using the same
   shared database and session, matching how the cart app itself works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:39:43 +00:00
giles
faa72eec5d Update shared submodule: cross-app checkout URL fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:22:30 +00:00
giles
8c2358022a Fix undefined calendar_total/calendar_cart_entries in product cart template
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
The _added.html template's summary macro expects calendar_total and
calendar_cart_entries from the cart/events domain. The market app has
no calendar entries, so pass a no-op function and empty list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:15:07 +00:00
giles
44f475857b Fix AttributeError on g.cart in product cart route
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
g.cart was never populated in the market app — the cart loader
before_request hook was only registered in the cart microservice.
Replace the dead filter-building code with an actual query that
loads cart items inline, scoped to the current user/session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:11:49 +00:00
giles
039386b6e7 README: replace vague cross-app section with actual code dependencies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
List specific model imports, glue services, and internal APIs
that market code actually references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:47:50 +00:00
giles
dc94cfa29e Update shared + glue submodule pointers (README additions)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:36:26 +00:00
giles
a29612ffa4 Rewrite README for post-decoupling architecture
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Fix microservice count (4 not 3), document submodules, models,
scraper, all blueprints including bp/cart/, and cross-app integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:28:56 +00:00
giles
99dd473afb Phase 5: Update shared + glue submodule pointers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
shared: migration to drop cross-domain FK constraints
glue: order lifecycle services, cart adoption, login/order handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:11:58 +00:00
giles
7820257577 Update shared submodule to include glue layer + MenuItem fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:03:32 +00:00
giles
f81d6803d4 Add glue layer: replace /internal/menu-items API with direct DB query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
- Context processor: get_navigation_tree() replaces api_get("coop", "/internal/menu-items")
- Add glue submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:37:47 +00:00
giles
ba10f5547a ci: clean all sibling dirs before copying to fix stale table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Previous runs left self-copies (e.g. market/market/) that caused
'Table already defined' errors. Split into two loops: first rm -rf
all sibling dirs, then copy only non-self siblings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:31:49 +00:00
giles
7025333951 CI: skip copying own models to avoid duplicate SQLAlchemy table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s
Each app has its own models/ at the root (imported as bare `models.X`).
The CI copy was also creating {app}/models/ (imported as `{app}.models.X`),
causing SQLAlchemy to see the same table defined twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:14:50 +00:00
giles
737c82ae7f Update shared submodule: import all model packages at startup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:01:41 +00:00
giles
cccdba65a8 CI: use git archive for sibling models (atomic, race-safe)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
The cp approach failed when sibling repos were mid-update from
their own CI runs. git archive reads directly from git objects,
and git fetch ensures origin/decoupling is available even if the
sibling working tree is on a different branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:10:59 +00:00
giles
7b1f8a0f5e CI: copy sibling app models into build context for cross-domain imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m42s
Phases 1-3 split models by domain ownership, but cross-app imports
still exist (e.g. cart imports market.models.CartItem). In Docker
each app only has its own code. The CI step now copies sibling app
model packages into the build context before docker build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:01:51 +00:00
giles
000185c2cc Update shared submodule: merge diverged alembic heads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:27:30 +00:00
giles
4f90f65a8f Update shared submodule (adds missing alembic.ini)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:20:16 +00:00
giles
3b24cf45e5 Add PYTHONPATH=/app so Hypercorn spawn workers find app module
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:01:33 +00:00
giles
a6ed49d0c1 Update shared submodule: rename logging → log_config
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s
Fixes stdlib logging shadow that caused circular import in Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:55:51 +00:00
giles
62000256aa Replace shared_lib submodule with shared for decoupling deploy
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
- Swap shared_lib submodule → shared (tracking decoupling branch)
- Dockerfile: shared_lib/ → shared/, remove bp symlink hack
- CI: trigger on decoupling branch, use dynamic ref_name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:29:25 +00:00
giles
079293eb2c fix: update market template to use container refs, add config
- Template uses m.page.slug instead of m.post.slug
- Add app-config.yaml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:50:48 +00:00
giles
478636f799 feat: decouple market from shared_lib, add app-owned models
Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Market-owned models in market/models/ (market, market_place)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- MarketPlace uses container_type/container_id instead of post_id FK

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:46:32 +00:00
95 changed files with 1013 additions and 267 deletions

View File

@@ -2,13 +2,13 @@ name: Build and Deploy
on: on:
push: push:
branches: [main] branches: [main, decoupling]
env: env:
REGISTRY: registry.rose-ash.com:5000 REGISTRY: registry.rose-ash.com:5000
IMAGE: market IMAGE: market
REPO_DIR: /root/rose-ash/market REPO_DIR: /root/rose-ash/market
COOP_DIR: /root/coop COOP_DIR: /root/rose-ash
jobs: jobs:
build-and-deploy: build-and-deploy:
@@ -36,9 +36,23 @@ jobs:
run: | run: |
ssh "root@$DEPLOY_HOST" " ssh "root@$DEPLOY_HOST" "
cd ${{ env.REPO_DIR }} cd ${{ env.REPO_DIR }}
git fetch origin main git fetch origin ${{ github.ref_name }}
git reset --hard origin/main git reset --hard origin/${{ github.ref_name }}
git submodule update --init --recursive git submodule update --init --recursive
# Clean ALL sibling dirs (including stale self-copies from previous runs)
for sibling in blog market cart events federation; do
rm -rf \$sibling
done
# Copy non-self sibling models for cross-domain imports
for sibling in blog market cart events federation; do
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
repo=/root/rose-ash/\$sibling
if [ -d \$repo/.git ]; then
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
mkdir -p \$sibling
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
fi
done
" "
- name: Build and push image - name: Build and push image

5
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "shared_lib"] [submodule "shared"]
path = shared_lib path = shared
url = https://git.rose-ash.com/coop/shared.git url = https://git.rose-ash.com/coop/shared.git
branch = decoupling

View File

@@ -5,6 +5,7 @@ FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \ APP_PORT=8000 \
APP_MODULE=app:app APP_MODULE=app:app
@@ -17,14 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \ postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY shared_lib/requirements.txt ./requirements.txt COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
COPY . . COPY . .
# Link app blueprints into the shared library's namespace
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -1,67 +1,56 @@
# Market App # Market App
Product browsing and marketplace application for the Rose Ash cooperative. Product browsing and marketplace service for the Rose Ash cooperative. Displays products scraped from Suma Wholesale.
## Overview
The Market app is one of three microservices split from the original coop monolith:
- **coop** (:8000) - Blog, calendar, auth, settings
- **market** (:8001) - Product browsing, categories, product detail
- **cart** (:8002) - Shopping cart, orders, payments
## Architecture ## Architecture
- **Framework:** Quart (async Flask) One of five Quart microservices sharing a single PostgreSQL database:
- **Database:** PostgreSQL 16 with SQLAlchemy 2.0 (async)
- **Cache:** Redis (tag-based page cache)
- **Frontend:** HTMX + Jinja2 + Tailwind CSS
- **Data:** Products scraped from Suma Wholesale
## Blueprints | App | Port | Domain |
|-----|------|--------|
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
| **market** | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
- `bp/market/` - Market root (navigation, category listing) ## Structure
- `bp/browse/` - Product browsing with filters and infinite scroll
- `bp/product/` - Product detail pages
- `bp/api/` - Product sync API (used by scraper)
## Development ```
app.py # Application factory (create_base_app + blueprints)
path_setup.py # Adds project root + app dir to sys.path
config/app-config.yaml # App URLs, feature flags
models/ # Market-domain models (+ re-export stubs)
bp/ # Blueprints
market/ # Market root, navigation, category listing
browse/ # Product browsing with filters and infinite scroll
product/ # Product detail pages
cart/ # Page-scoped cart views
api/ # Product sync API (used by scraper)
scrape/ # Suma Wholesale scraper
services/ # register_domain_services() — wires market + cart
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
# Install dependencies ## Cross-Domain Communication
pip install -r requirements.txt
# Set environment variables - `services.cart.*` — cart summary via CartService protocol
export $(grep -v '^#' .env | xargs) - `services.federation.*` — AP publishing via FederationService protocol
- `shared.services.navigation` — site navigation tree
# Run migrations
alembic upgrade head
# Scrape products
bash scrape.sh
# Run the dev server
hypercorn app:app --reload --bind 0.0.0.0:8001
## Scraping ## Scraping
# Full scrape (max 50 pages, 200k products, 8 concurrent) ```bash
bash scrape.sh bash scrape.sh # Full Suma Wholesale catalogue
bash scrape-test.sh # Limited test scrape
```
# Test scraping ## Running
bash scrape-test.sh
## Docker ```bash
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
docker build -t market . hypercorn app:app --bind 0.0.0.0:8001
docker run -p 8001:8000 --env-file .env market ```
## Environment Variables
DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=your-secret-key
SUMA_USER=your-suma-username
SUMA_PASSWORD=your-suma-password
APP_URL_COOP=http://localhost:8000
APP_URL_MARKET=http://localhost:8001
APP_URL_CART=http://localhost:8002

0
__init__.py Normal file
View File

146
app.py
View File

@@ -1,52 +1,80 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path from pathlib import Path
from quart import g, abort, render_template, make_response from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select from sqlalchemy import select
from shared.factory import create_base_app from shared.infrastructure.factory import create_base_app
from shared.cart_loader import load_cart from shared.config import config
from config import config
from suma_browser.app.bp import register_market_bp from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments
async def market_context() -> dict: async def market_context() -> dict:
""" """
Market app context processor. Market app context processor.
- menu_items: fetched from coop internal API - nav_tree_html: fetched from blog as fragment
- cart_count/cart_total: fetched from cart internal API - cart_count/cart_total: via cart service (includes calendar entries)
- cart: direct ORM query (templates need .product relationship)
""" """
from shared.context import base_context from shared.infrastructure.context import base_context
from shared.internal_api import get as api_get, dictobj from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragment
from shared.models.market import CartItem
from sqlalchemy.orm import selectinload
ctx = await base_context() ctx = await base_context()
# Menu items from coop API (wrapped for attribute access in templates) ctx["nav_tree_html"] = await fetch_fragment(
menu_data = await api_get("coop", "/internal/menu-items") "blog", "nav-tree",
ctx["menu_items"] = dictobj(menu_data) if menu_data else [] params={"app_name": "market", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data from cart API ident = current_cart_identity()
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
if cart_data: # cart_count/cart_total via service (consistent with blog/events apps)
ctx["cart_count"] = cart_data.get("count", 0) summary = await services.cart.cart_summary(
ctx["cart_total"] = cart_data.get("total", 0) g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count
ctx["cart_total"] = float(summary.total + summary.calendar_total)
# ORM cart items for product templates (need .product relationship)
filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
elif ident["session_id"] is not None:
filters.append(CartItem.session_id == ident["session_id"])
else: else:
ctx["cart_count"] = 0 ctx["cart"] = []
ctx["cart_total"] = 0 return ctx
result = await g.s.execute(
select(CartItem).where(*filters).options(selectinload(CartItem.product))
)
ctx["cart"] = list(result.scalars().all())
return ctx return ctx
def create_app() -> "Quart": def create_app() -> "Quart":
from models.market_place import MarketPlace from models.market_place import MarketPlace
from models.ghost_content import Post from shared.services.registry import services
from services import register_domain_services
app = create_base_app("market", context_fn=market_context, before_request_fns=[load_cart]) app = create_base_app(
"market",
context_fn=market_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates # App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates") app_templates = str(Path(__file__).resolve().parent / "templates")
@@ -55,19 +83,37 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# All markets: / — global view across all pages
app.register_blueprint(
register_all_markets(),
url_prefix="/",
)
# Page markets: /<slug>/ — markets for a single page
app.register_blueprint(
register_page_markets(),
url_prefix="/<slug>",
)
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/ # Market blueprint nested under post slug: /<page_slug>/<market_slug>/
app.register_blueprint( app.register_blueprint(
register_market_bp( register_market_bp(
url_prefix="/", url_prefix="/",
title=config()["coop_title"], title=config()["market_title"],
), ),
url_prefix="/<page_slug>/<market_slug>", url_prefix="/<page_slug>/<market_slug>",
) )
# --- Auto-inject page_slug and market_slug into url_for() calls --- app.register_blueprint(register_fragments())
# --- Auto-inject slugs into url_for() calls ---
@app.url_value_preprocessor @app.url_value_preprocessor
def pull_slugs(endpoint, values): def pull_slugs(endpoint, values):
if values: if values:
# page_markets blueprint uses "slug"
if "slug" in values:
g.post_slug = values.pop("slug")
# market blueprint uses "page_slug" / "market_slug"
if "page_slug" in values: if "page_slug" in values:
g.post_slug = values.pop("page_slug") g.post_slug = values.pop("page_slug")
if "market_slug" in values: if "market_slug" in values:
@@ -75,26 +121,26 @@ def create_app() -> "Quart":
@app.url_defaults @app.url_defaults
def inject_slugs(endpoint, values): def inject_slugs(endpoint, values):
for attr, param in [("post_slug", "page_slug"), ("market_slug", "market_slug")]: slug = g.get("post_slug")
val = g.get(attr) if slug:
if val and param not in values: for param in ("slug", "page_slug"):
if app.url_map.is_endpoint_expecting(endpoint, param): if param not in values and app.url_map.is_endpoint_expecting(endpoint, param):
values[param] = val values[param] = slug
market_slug = g.get("market_slug")
if market_slug and "market_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "market_slug"):
values["market_slug"] = market_slug
# --- Load post and market data --- # --- Load post and market data ---
@app.before_request @app.before_request
async def hydrate_market(): async def hydrate_market():
post_slug = getattr(g, "post_slug", None) post_slug = getattr(g, "post_slug", None)
market_slug = getattr(g, "market_slug", None) market_slug = getattr(g, "market_slug", None)
if not post_slug or not market_slug: if not post_slug:
return return
# Load post by slug # Load post by slug via blog service
post = ( post = await services.blog.get_post_by_slug(g.s, post_slug)
await g.s.execute(
select(Post).where(Post.slug == post_slug)
)
).scalar_one_or_none()
if not post: if not post:
abort(404) abort(404)
@@ -111,12 +157,16 @@ def create_app() -> "Quart":
}, },
} }
# Load market scoped to post # Only load market when market_slug is present (/<page_slug>/<market_slug>/)
if not market_slug:
return
market = ( market = (
await g.s.execute( await g.s.execute(
select(MarketPlace).where( select(MarketPlace).where(
MarketPlace.slug == market_slug, MarketPlace.slug == market_slug,
MarketPlace.post_id == post.id, MarketPlace.container_type == "page",
MarketPlace.container_id == post.id,
MarketPlace.deleted_at.is_(None), MarketPlace.deleted_at.is_(None),
) )
) )
@@ -132,26 +182,6 @@ def create_app() -> "Quart":
return {} return {}
return {**post_data} return {**post_data}
# --- Root route: market listing ---
@app.get("/")
async def markets_listing():
from sqlalchemy.orm import selectinload
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.deleted_at.is_(None))
.options(selectinload(MarketPlace.post))
.order_by(MarketPlace.name)
)
).scalars().all()
html = await render_template(
"_types/market/markets_listing.html",
markets=markets,
)
return await make_response(html)
return app return app

View File

@@ -1,2 +1,5 @@
from .market.routes import register as register_market_bp from .market.routes import register as register_market_bp
from .product.routes import register as register_product from .product.routes import register as register_product
from .all_markets.routes import register as register_all_markets
from .page_markets.routes import register as register_page_markets
from .fragments import register_fragments

View File

74
bp/all_markets/routes.py Normal file
View File

@@ -0,0 +1,74 @@
"""
All-markets blueprint — shows markets across ALL pages.
Mounted at / (root of market app). No slug context.
Routes:
GET / — full page with first page of markets
GET /all-markets — HTMX fragment for infinite scroll
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.services.registry import services
def register() -> Blueprint:
bp = Blueprint("all_markets", __name__)
async def _load_markets(page, per_page=20):
"""Load all markets + page info for container badges."""
markets, has_more = await services.market.list_marketplaces(
g.s, page=page, per_page=per_page,
)
# Batch-load page info for container_ids
page_info = {}
if markets:
post_ids = list({
m.container_id for m in markets
if m.container_type == "page"
})
if post_ids:
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
for p in posts:
page_info[p.id] = {"title": p.title, "slug": p.slug}
return markets, has_more, page_info
@bp.get("/")
async def index():
page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page)
ctx = dict(
markets=markets,
has_more=has_more,
page_info=page_info,
page=page,
)
if is_htmx_request():
html = await render_template("_types/all_markets/_main_panel.html", **ctx)
else:
html = await render_template("_types/all_markets/index.html", **ctx)
return await make_response(html, 200)
@bp.get("/all-markets")
async def markets_fragment():
page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page)
html = await render_template(
"_types/all_markets/_cards.html",
markets=markets,
has_more=has_more,
page_info=page_info,
page=page,
)
return await make_response(html, 200)
return bp

View File

@@ -27,8 +27,8 @@ from models.market import (
ProductAllergen, ProductAllergen,
) )
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from suma_browser.app.csrf import csrf_exempt from shared.browser.app.csrf import csrf_exempt
products_api = Blueprint("products_api", __name__, url_prefix="/api/products") products_api = Blueprint("products_api", __name__, url_prefix="/api/products")
@@ -291,6 +291,22 @@ async def _create_product_from_payload(session: AsyncSession, payload: Dict[str,
#await session.flush() # get p.id #await session.flush() # get p.id
_replace_children(p, payload) _replace_children(p, payload)
await session.flush() await session.flush()
# Publish to federation inline
from shared.services.federation_publish import try_publish
await try_publish(
session,
user_id=getattr(p, "user_id", None),
activity_type="Create",
object_type="Object",
object_data={
"name": p.title or "",
"summary": getattr(p, "description", "") or "",
},
source_type="Product",
source_id=p.id,
)
return p return p
# ---- API -------------------------------------------------------------------- # ---- API --------------------------------------------------------------------

View File

@@ -10,7 +10,7 @@ from quart import (
make_response, make_response,
current_app, current_app,
) )
from config import config from shared.config import config
from .services.nav import category_context, get_nav from .services.nav import category_context, get_nav
from .services.blacklist.category import is_category_blocked from .services.blacklist.category import is_category_blocked
@@ -21,8 +21,8 @@ from .services import (
_current_url_without_page, _current_url_without_page,
) )
from suma_browser.app.redis_cacher import cache_page from shared.browser.app.redis_cacher import cache_page
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():
browse_bp = Blueprint("browse", __name__) browse_bp = Blueprint("browse", __name__)

View File

@@ -1,7 +1,7 @@
# suma_browser/category_blacklist.py # suma_browser/category_blacklist.py
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from config import config from shared.config import config
def _norm(s: str) -> str: def _norm(s: str) -> str:
return (s or "").strip().lower().strip("/") return (s or "").strip().lower().strip("/")

View File

@@ -1,6 +1,6 @@
from typing import Set, Optional from typing import Set, Optional
from ..slugs import canonical_html_slug from ..slugs import canonical_html_slug
from config import config from shared.config import config
_blocked: Set[str] = set() _blocked: Set[str] = set()
_mtime: Optional[float] = None _mtime: Optional[float] = None

View File

@@ -1,5 +1,5 @@
import re import re
from config import config from shared.config import config
def _norm_title_key(t: str) -> str: def _norm_title_key(t: str) -> str:
t = (t or "").strip().lower() t = (t or "").strip().lower()

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os, json import os, json
from typing import List, Optional from typing import List, Optional
from config import config from shared.config import config
from .blacklist.product import is_product_blocked from .blacklist.product import is_product_blocked

View File

@@ -4,7 +4,7 @@ from typing import Dict, List, Optional
from sqlalchemy import select, and_ from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from config import config # if unused elsewhere, you can remove this import from shared.config import config # if unused elsewhere, you can remove this import
# ORM models # ORM models
from models.market import ( from models.market import (

View File

@@ -5,7 +5,7 @@ import re
from typing import Dict, List, Tuple, Optional from typing import Dict, List, Tuple, Optional
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
from config import config from shared.config import config
from . import db_backend as cb from . import db_backend as cb
from .blacklist.category import is_category_blocked # Reverse map: slug -> label from .blacklist.category import is_category_blocked # Reverse map: slug -> label

View File

@@ -6,11 +6,11 @@ from quart import (
g, g,
request, request,
) )
from config import config from shared.config import config
from .products import products, products_nocounts from .products import products, products_nocounts
from .blacklist.product_details import is_blacklisted_heading from .blacklist.product_details import is_blacklisted_heading
from utils import host_url from shared.utils import host_url
from sqlalchemy import select from sqlalchemy import select
@@ -163,7 +163,7 @@ def _massage_product(d):
# Re-export from canonical shared location # Re-export from canonical shared location
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
async def _is_liked(user_id: int | None, slug: str) -> bool: async def _is_liked(user_id: int | None, slug: str) -> bool:
""" """

View File

@@ -1,6 +1,6 @@
import re import re
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from config import config from shared.config import config
def product_slug_from_href(href: str) -> str: def product_slug_from_href(href: str) -> str:
p = urlparse(href) p = urlparse(href)

View File

@@ -1,4 +1,4 @@
# Re-export from canonical shared location # Re-export from canonical shared location
from shared.cart_identity import CartIdentity, current_cart_identity from shared.infrastructure.cart_identity import CartIdentity, current_cart_identity
__all__ = ["CartIdentity", "current_cart_identity"] __all__ = ["CartIdentity", "current_cart_identity"]

1
bp/fragments/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

54
bp/fragments/routes.py Normal file
View File

@@ -0,0 +1,54 @@
"""Market app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
# --- container-nav fragment: market links --------------------------------
async def _container_nav_handler():
container_type = request.args.get("container_type", "page")
container_id = int(request.args.get("container_id", 0))
post_slug = request.args.get("post_slug", "")
markets = await services.market.marketplaces_for_container(
g.s, container_type, container_id,
)
if not markets:
return ""
return await render_template(
"fragments/container_nav_markets.html",
markets=markets, post_slug=post_slug,
)
_handlers["container-nav"] = _container_nav_handler
bp._fragment_handlers = _handlers
return bp

View File

@@ -5,7 +5,7 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
def register(): def register():
@@ -15,7 +15,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(): async def admin():
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -2,10 +2,10 @@ from quart import request
from typing import Iterable, Optional, Union from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import ( from shared.browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs, KEEP, _norm, make_filter_set, build_qs,
) )
from suma_browser.app.filters.query_types import MarketQuery from shared.browser.app.filters.query_types import MarketQuery
def decode() -> MarketQuery: def decode() -> MarketQuery:

View File

@@ -27,7 +27,7 @@ def register(url_prefix, title):
post_data = getattr(g, "post_data", None) or {} post_data = getattr(g, "post_data", None) or {}
return { return {
**post_data, **post_data,
"coop_title": market.name if market else title, "market_title": market.name if market else title,
"categories": (await get_nav(g.s, market_id=market_id))["cats"], "categories": (await get_nav(g.s, market_id=market_id))["cats"],
"qs": makeqs_factory()(), "qs": makeqs_factory()(),
"market": market, "market": market,

View File

65
bp/page_markets/routes.py Normal file
View File

@@ -0,0 +1,65 @@
"""
Page-markets blueprint — shows markets for a single page.
Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post.
Routes:
GET /<slug>/ — full page scoped to this page
GET /<slug>/page-markets — HTMX fragment for infinite scroll
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.services.registry import services
def register() -> Blueprint:
bp = Blueprint("page_markets", __name__)
async def _load_markets(post_id, page, per_page=20):
"""Load markets for this page's container."""
markets, has_more = await services.market.list_marketplaces(
g.s, "page", post_id, page=page, per_page=per_page,
)
return markets, has_more
@bp.get("/")
async def index():
post = g.post_data["post"]
page = int(request.args.get("page", 1))
markets, has_more = await _load_markets(post["id"], page)
ctx = dict(
markets=markets,
has_more=has_more,
page_info={},
page=page,
)
if is_htmx_request():
html = await render_template("_types/page_markets/_main_panel.html", **ctx)
else:
html = await render_template("_types/page_markets/index.html", **ctx)
return await make_response(html, 200)
@bp.get("/page-markets")
async def markets_fragment():
post = g.post_data["post"]
page = int(request.args.get("page", 1))
markets, has_more = await _load_markets(post["id"], page)
html = await render_template(
"_types/page_markets/_cards.html",
markets=markets,
has_more=has_more,
page_info={},
page=page,
)
return await make_response(html, 200)
return bp

View File

@@ -15,27 +15,33 @@ from ..browse.services.slugs import canonical_html_slug
from ..browse.services.blacklist.product import is_product_blocked from ..browse.services.blacklist.product import is_product_blocked
from ..browse.services import db_backend as cb from ..browse.services import db_backend as cb
from ..browse.services import _massage_product from ..browse.services import _massage_product
from utils import host_url from shared.utils import host_url
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from ..cart.services import total from ..cart.services import total
from .services.product_operations import toggle_product_like, massage_full_product from .services.product_operations import toggle_product_like, massage_full_product
def register(): def register():
bp = Blueprint("product", __name__, url_prefix="/product/<slug>") bp = Blueprint("product", __name__, url_prefix="/product/<product_slug>")
@bp.url_value_preprocessor @bp.url_value_preprocessor
def pull_blog(endpoint, values): def pull_product_slug(endpoint, values):
g.product_slug = values.get("slug") # product_slug is distinct from the app-level "slug"/"page_slug" params,
# so it won't be popped by the app-level preprocessor in app.py.
g.product_slug = values.pop("product_slug", None)
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# BEFORE REQUEST: Slug or numeric ID resolver # BEFORE REQUEST: Slug or numeric ID resolver
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@bp.before_request @bp.before_request
async def resolve_product(): async def resolve_product():
from quart import request as req
raw_slug = g.product_slug = getattr(g, "product_slug", None) raw_slug = g.product_slug = getattr(g, "product_slug", None)
if raw_slug is None: if raw_slug is None:
return return
is_post = req.method == "POST"
# 1. If slug is INT → load product by ID # 1. If slug is INT → load product by ID
if raw_slug.isdigit(): if raw_slug.isdigit():
product_id = int(raw_slug) product_id = int(raw_slug)
@@ -53,20 +59,24 @@ def register():
g.item_data = {"d": d, "slug": product["slug"], "liked": False} g.item_data = {"d": d, "slug": product["slug"], "liked": False}
return return
# Not deleted → redirect to canonical slug # Not deleted → redirect to canonical slug (GET only)
if not is_post:
canon = canonical_html_slug(product["slug"]) canon = canonical_html_slug(product["slug"])
return redirect( return redirect(
host_url(url_for("market.browse.product.product_detail", slug=canon)) host_url(url_for("market.browse.product.product_detail", product_slug=canon))
) )
g.item_data = {"d": product, "slug": product["slug"], "liked": False}
return
# 2. Normal slug-based behaviour # 2. Normal slug-based behaviour
if is_product_blocked(raw_slug): if is_product_blocked(raw_slug):
abort(404) abort(404)
canon = canonical_html_slug(raw_slug) canon = canonical_html_slug(raw_slug)
if canon != raw_slug: if canon != raw_slug and not is_post:
return redirect( return redirect(
host_url(url_for("product.product_detail", slug=canon)) host_url(url_for("market.browse.product.product_detail", product_slug=canon))
) )
# hydrate full product # hydrate full product
@@ -75,7 +85,7 @@ def register():
) )
if not d: if not d:
abort(404) abort(404)
g.item_data = {"d": d, "slug": canon, "liked": d["is_liked"]} g.item_data = {"d": d, "slug": canon, "liked": d.get("is_liked", False)}
@bp.context_processor @bp.context_processor
def context(): def context():
@@ -93,8 +103,8 @@ def register():
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@bp.get("/") @bp.get("/")
@cache_page(tag="browse") @cache_page(tag="browse")
async def product_detail(slug: str): async def product_detail():
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():
@@ -108,9 +118,8 @@ def register():
@bp.post("/like/toggle/") @bp.post("/like/toggle/")
@clear_cache(tag="browse", tag_scope="user") @clear_cache(tag="browse", tag_scope="user")
async def like_toggle(slug): async def like_toggle():
# Use slug from URL parameter (set by url_prefix="/product/<slug>") product_slug = g.product_slug
product_slug = slug
if not g.user: if not g.user:
html = await render_template( html = await render_template(
@@ -139,8 +148,8 @@ def register():
@bp.get("/admin/") @bp.get("/admin/")
async def admin(slug: str): async def admin():
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
if not is_htmx_request(): if not is_htmx_request():
# Normal browser request: full page with layout # Normal browser request: full page with layout
@@ -152,14 +161,15 @@ def register():
return await make_response(html) return await make_response(html)
from suma_browser.app.bp.cart.services.identity import current_cart_identity from bp.cart.services.identity import current_cart_identity
#from suma_browser.app.bp.cart.routes import view_cart #from bp.cart.routes import view_cart
from models.market import CartItem from models.market import CartItem
from quart import request, url_for from quart import request, url_for
@bp.post("/cart/") @bp.post("/cart/")
@clear_cache(tag="browse", tag_scope="user") @clear_cache(tag="browse", tag_scope="user")
async def cart(slug: str): async def cart():
slug = g.product_slug
# make sure product exists (we *allow* deleted_at != None later if you want) # make sure product exists (we *allow* deleted_at != None later if you want)
product_id = await g.s.scalar( product_id = await g.s.scalar(
select(Product.id).where( select(Product.id).where(
@@ -192,11 +202,23 @@ def register():
ident = current_cart_identity() ident = current_cart_identity()
filters = [CartItem.deleted_at.is_(None), CartItem.product_id == product_id] # Load cart items for current user/session
from sqlalchemy.orm import selectinload
cart_filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None: if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"]) cart_filters.append(CartItem.user_id == ident["user_id"])
else: else:
filters.append(CartItem.session_id == ident["session_id"]) cart_filters.append(CartItem.session_id == ident["session_id"])
cart_result = await g.s.execute(
select(CartItem)
.where(*cart_filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
g.cart = list(cart_result.scalars().all())
ci = next( ci = next(
(item for item in g.cart if item.product_id == product_id), (item for item in g.cart if item.product_id == product_id),
@@ -230,19 +252,16 @@ def register():
# no explicit commit; your session middleware should handle it # no explicit commit; your session middleware should handle it
# htmx support (optional) # htmx response: OOB-swap mini cart + product buttons
if request.headers.get("HX-Request") == "true": if request.headers.get("HX-Request") == "true":
# You can return a small fragment or mini-cart here
return await render_template( return await render_template(
"_types/product/_added.html", "_types/product/_added.html",
cart=g.cart, cart=g.cart,
item=ci, item=ci,
total = total
) )
# normal POST: go to cart page # normal POST: go to cart page
from shared.urls import cart_url from shared.infrastructure.urls import cart_url
return redirect(cart_url("/")) return redirect(cart_url("/"))

View File

@@ -13,7 +13,7 @@ def massage_full_product(product: Product) -> dict:
Convert a Product ORM model to a dictionary with all fields. Convert a Product ORM model to a dictionary with all fields.
Used for rendering product detail pages. Used for rendering product detail pages.
""" """
from suma_browser.app.bp.browse.services import _massage_product from bp.browse.services import _massage_product
gallery = [] gallery = []
if product.image: if product.image:

84
config/app-config.yaml Normal file
View File

@@ -0,0 +1,84 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
federation: "http://localhost:8004"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

8
models/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from .market import (
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)
from .market_place import MarketPlace

7
models/market.py Normal file
View File

@@ -0,0 +1,7 @@
from shared.models.market import ( # noqa: F401
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)

1
models/market_place.py Normal file
View File

@@ -0,0 +1 @@
from shared.models.market_place import MarketPlace # noqa: F401

View File

@@ -1,7 +1,9 @@
import sys import sys
import os import os
# Add the shared library submodule to the Python path _app_dir = os.path.dirname(os.path.abspath(__file__))
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib") _project_root = os.path.dirname(_app_dir)
if _shared not in sys.path:
sys.path.insert(0, _shared) for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

View File

@@ -7,9 +7,9 @@ from typing import Dict, Set
from ..http_client import configure_cookies from ..http_client import configure_cookies
from ..get_auth import login from ..get_auth import login
from config import config from shared.config import config
from utils import log from shared.utils import log
# DB: persistence helpers # DB: persistence helpers

View File

@@ -3,7 +3,7 @@ from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
from ._anchor_text import _anchor_text from ._anchor_text import _anchor_text
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href from bp.browse.services.slugs import product_slug_from_href
from .APP_ROOT_PLACEHOLDER import APP_ROOT_PLACEHOLDER from .APP_ROOT_PLACEHOLDER import APP_ROOT_PLACEHOLDER
def _rewrite_links_fragment( def _rewrite_links_fragment(

View File

@@ -1,6 +1,6 @@
from urllib.parse import urljoin from urllib.parse import urljoin
from config import config from shared.config import config
from utils import log from shared.utils import log
from ...listings import scrape_products from ...listings import scrape_products
async def capture_category( async def capture_category(

View File

@@ -1,7 +1,7 @@
from typing import Dict, Set from typing import Dict, Set
from .capture_category import capture_category from .capture_category import capture_category
from .capture_sub import capture_sub from .capture_sub import capture_sub
from config import config from shared.config import config
async def capture_product_slugs( async def capture_product_slugs(

View File

@@ -1,7 +1,7 @@
from urllib.parse import urljoin from urllib.parse import urljoin
from urllib.parse import urljoin from urllib.parse import urljoin
from config import config from shared.config import config
from utils import log from shared.utils import log
from ...listings import scrape_products from ...listings import scrape_products
async def capture_sub( async def capture_sub(

View File

@@ -6,12 +6,12 @@ import httpx
from ...html_utils import to_fragment from ...html_utils import to_fragment
from suma_browser.app.bp.browse.services.slugs import suma_href_from_html_slug from bp.browse.services.slugs import suma_href_from_html_slug
from config import config from shared.config import config
from utils import log from shared.utils import log
# DB: persistence helpers # DB: persistence helpers
from ...product.product_detail import scrape_product_detail from ...product.product_detail import scrape_product_detail

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
from typing import Dict, List, Set from typing import Dict, List, Set
from config import config from shared.config import config
from utils import log from shared.utils import log
from .fetch_and_upsert_product import fetch_and_upsert_product from .fetch_and_upsert_product import fetch_and_upsert_product

View File

@@ -1,7 +1,7 @@
from typing import Dict from typing import Dict
from urllib.parse import urljoin from urllib.parse import urljoin
from config import config from shared.config import config
def rewrite_nav(nav: Dict[str, Dict], nav_redirects:Dict[str, str]): def rewrite_nav(nav: Dict[str, Dict], nav_redirects:Dict[str, str]):
if nav_redirects: if nav_redirects:

View File

@@ -2,7 +2,7 @@ from typing import Optional, Dict, Any, List
from urllib.parse import urljoin from urllib.parse import urljoin
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from config import config from shared.config import config
class LoginFailed(Exception): class LoginFailed(Exception):
def __init__(self, message: str, *, debug: Dict[str, Any]): def __init__(self, message: str, *, debug: Dict[str, Any]):

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from urllib.parse import urljoin from urllib.parse import urljoin
from config import config from shared.config import config

View File

@@ -7,7 +7,7 @@ import secrets
from typing import Optional, Dict from typing import Optional, Dict
import httpx import httpx
from config import config from shared.config import config
_CLIENT: httpx.AsyncClient | None = None _CLIENT: httpx.AsyncClient | None = None

View File

@@ -7,8 +7,8 @@ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
from .http_client import fetch from .http_client import fetch
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href from bp.browse.services.slugs import product_slug_from_href
from suma_browser.app.bp.browse.services.state import ( from bp.browse.services.state import (
KNOWN_PRODUCT_SLUGS, KNOWN_PRODUCT_SLUGS,
_listing_page_cache, _listing_page_cache,
_listing_page_ttl, _listing_page_ttl,
@@ -16,8 +16,8 @@ from suma_browser.app.bp.browse.services.state import (
_listing_variant_ttl, _listing_variant_ttl,
now, now,
) )
from utils import normalize_text, soup_of from shared.utils import normalize_text, soup_of
from config import config from shared.config import config
def parse_total_pages_from_text(text: str) -> Optional[int]: def parse_total_pages_from_text(text: str) -> Optional[int]:

View File

@@ -5,7 +5,7 @@ from typing import Dict, List, Tuple, Optional
from urllib.parse import urlparse, urljoin from urllib.parse import urlparse, urljoin
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from config import config from shared.config import config
from .http_client import fetch # only fetch; define soup_of locally from .http_client import fetch # only fetch; define soup_of locally
#from .. import cache_backend as cb #from .. import cache_backend as cb
#from ..blacklist.category import is_category_blocked # Reverse map: slug -> label #from ..blacklist.category import is_category_blocked # Reverse map: slug -> label

View File

@@ -17,7 +17,7 @@ from models.market import (
Listing, Listing,
ListingItem, ListingItem,
) )
from db.session import get_session from shared.db.session import get_session
# --- Models are unchanged, see original code --- # --- Models are unchanged, see original code ---

View File

@@ -5,7 +5,7 @@ from typing import Dict
from models.market import ( from models.market import (
ProductLog, ProductLog,
) )
from db.session import get_session from shared.db.session import get_session
async def log_product_result(ok: bool, payload: Dict) -> None: async def log_product_result(ok: bool, payload: Dict) -> None:

View File

@@ -7,7 +7,7 @@ from models.market import (
LinkError, LinkError,
LinkExternal, LinkExternal,
) )
from db.session import get_session from shared.db.session import get_session
# --- Models are unchanged, see original code --- # --- Models are unchanged, see original code ---

View File

@@ -9,7 +9,7 @@ from models.market import (
NavTop, NavTop,
NavSub, NavSub,
) )
from db.session import get_session from shared.db.session import get_session

View File

@@ -8,7 +8,7 @@ from sqlalchemy import (
from models.market import ( from models.market import (
SubcategoryRedirect, SubcategoryRedirect,
) )
from db.session import get_session from shared.db.session import get_session
# --- Models are unchanged, see original code --- # --- Models are unchanged, see original code ---

View File

@@ -17,7 +17,7 @@ from models.market import (
ProductNutrition, ProductNutrition,
ProductAllergen ProductAllergen
) )
from db.session import get_session from shared.db.session import get_session
from ._get import _get from ._get import _get
from .log_product_result import _log_product_result from .log_product_result import _log_product_result

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Dict, List, Union from typing import Dict, List, Union
from urllib.parse import urlparse from urllib.parse import urlparse
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ..registry import extractor from ..registry import extractor
@extractor @extractor

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List from typing import Dict, List
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ...html_utils import absolutize_fragment from ...html_utils import absolutize_fragment
from ..registry import extractor from ..registry import extractor
from ..helpers.desc import ( from ..helpers.desc import (

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, Union from typing import Dict, Union
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ..registry import extractor from ..registry import extractor
from ..helpers.price import parse_price, parse_case_size from ..helpers.price import parse_price, parse_case_size

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List from typing import Dict, List
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ..registry import extractor from ..registry import extractor
@extractor @extractor

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import re import re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ..registry import extractor from ..registry import extractor
from ..helpers.desc import ( from ..helpers.desc import (
split_description_container, find_description_container, split_description_container, find_description_container,

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict from typing import Dict
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ..registry import extractor from ..registry import extractor
@extractor @extractor

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict from typing import Dict
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from utils import normalize_text from shared.utils import normalize_text
from ..registry import extractor from ..registry import extractor
@extractor @extractor

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from bs4 import BeautifulSoup, NavigableString, Tag from bs4 import BeautifulSoup, NavigableString, Tag
from utils import normalize_text from shared.utils import normalize_text
from ...html_utils import absolutize_fragment from ...html_utils import absolutize_fragment
from .text import clean_title, is_blacklisted_heading from .text import clean_title, is_blacklisted_heading
from config import config from shared.config import config
def split_description_container(desc_el: Tag) -> Tuple[str, List[Dict]]: def split_description_container(desc_el: Tag) -> Tuple[str, List[Dict]]:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from config import config from shared.config import config
def first_from_srcset(val: str) -> Optional[str]: def first_from_srcset(val: str) -> Optional[str]:
if not val: if not val:

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import re import re
from utils import normalize_text from shared.utils import normalize_text
from config import config from shared.config import config
def clean_title(t: str) -> str: def clean_title(t: str) -> str:
t = normalize_text(t) t = normalize_text(t)

View File

@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, Tuple, Union from typing import Dict, Tuple, Union
from utils import soup_of from shared.utils import soup_of
from ..http_client import fetch from ..http_client import fetch
from ..html_utils import absolutize_fragment from ..html_utils import absolutize_fragment
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href from bp.browse.services.slugs import product_slug_from_href
from .registry import REGISTRY, merge_missing from .registry import REGISTRY, merge_missing
from . import extractors as _auto_register # noqa: F401 (import-time side effects) from . import extractors as _auto_register # noqa: F401 (import-time side effects)

29
services/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
"""Market app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the market app.
Market owns: Product, CartItem, MarketPlace, NavTop, NavSub,
Listing, ProductImage.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.market = SqlMarketService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("cart"):
services.cart = SqlCartService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

1
shared Submodule

Submodule shared added at 9ab4b7b3fe

Submodule shared_lib deleted from 356d916e26

View File

@@ -0,0 +1,33 @@
{# Card for a single market in the global listing #}
{% set pi = page_info.get(market.container_id, {}) %}
{% set page_slug = pi.get('slug', '') %}
{% set page_title = pi.get('title') %}
{% if page_slug %}
{% set market_href = market_url('/' ~ page_slug ~ '/' ~ market.slug ~ '/') %}
{% else %}
{% set market_href = '' %}
{% endif %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
<div>
{% if market_href %}
<a href="{{ market_href }}" class="hover:text-emerald-700">
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
</a>
{% else %}
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
{% endif %}
{% if market.description %}
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
{% endif %}
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-3">
{% if page_title %}
<a href="{{ market_url('/' ~ page_slug ~ '/') }}"
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
{{ page_title }}
</a>
{% endif %}
</div>
</article>

View File

@@ -0,0 +1,18 @@
{% for market in markets %}
{% include "_types/all_markets/_card.html" %}
{% endfor %}
{% if has_more %}
{# Infinite scroll sentinel #}
{% set next_url = url_for('all_markets.markets_fragment', page=page + 1)|host %}
<div
id="sentinel-{{ page }}"
class="h-4 opacity-0 pointer-events-none"
hx-get="{{ next_url }}"
hx-trigger="intersect once delay:250ms"
hx-swap="outerHTML"
role="status"
aria-hidden="true"
>
<div class="text-center text-xs text-stone-400">loading...</div>
</div>
{% endif %}

View File

@@ -0,0 +1,12 @@
{# Markets grid #}
{% if markets %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/all_markets/_cards.html" %}
</div>
{% else %}
<div class="px-3 py-12 text-center text-stone-400">
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg">No markets available</p>
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -0,0 +1,7 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block content %}
{% include '_types/all_markets/_main_panel.html' %}
{% endblock %}

View File

@@ -2,6 +2,6 @@
{% if g.rights.admin %} {% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %} {% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item( {{admin_nav_item(
url_for('market.browse.product.admin', slug=slug) url_for('market.browse.product.admin', product_slug=slug)
)}} )}}
{% endif %} {% endif %}

View File

@@ -2,7 +2,7 @@
{% import '_types/product/prices.html' as prices %} {% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %} {% set prices_ns = namespace() %}
{{ prices.set_prices(p, prices_ns) }} {{ prices.set_prices(p, prices_ns) }}
{% set item_href = url_for('market.browse.product.product_detail', slug=p.slug)|host %} {% set item_href = url_for('market.browse.product.product_detail', product_slug=p.slug)|host %}
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative"> <div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
{# ❤️ like button overlay - OUTSIDE the link #} {# ❤️ like button overlay - OUTSIDE the link #}
{% if g.user %} {% if g.user %}

View File

@@ -2,7 +2,7 @@
<nav aria-label="Categories" <nav aria-label="Categories"
class="rounded-xl border bg-white shadow-sm min-h-0"> class="rounded-xl border bg-white shadow-sm min-h-0">
<ul class="divide-y"> <ul class="divide-y">
{% set top_active = (current_local_href == top_local_href) %} {% set top_active = (sub_slug is not defined or sub_slug is none or sub_slug == '') %}
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %} {% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
<li> <li>
<a <a
@@ -19,7 +19,7 @@
</li> </li>
{% for sub in subs_local %} {% for sub in subs_local %}
{% set active = (current_local_href == sub.local_href) %} {% set active = (sub.slug == sub_slug) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %} {% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
<li> <li>
<a <a

View File

@@ -1,6 +1,6 @@
<button <button
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]" class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', slug=slug)|host }}" hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
hx-target="this" hx-target="this"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-push-url="false" hx-push-url="false"

View File

@@ -2,7 +2,7 @@
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center"> class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
<div> <div>
<i class="fa fa-shop"></i> <i class="fa fa-shop"></i>
{{ coop_title }} {{ market_title }}
</div> </div>
<div class="flex flex-col md:flex-row md:gap-2 text-xs"> <div class="flex flex-col md:flex-row md:gap-2 text-xs">
<div> <div>

View File

@@ -7,7 +7,7 @@
{% if markets %} {% if markets %}
<div class="grid gap-4"> <div class="grid gap-4">
{% for m in markets %} {% for m in markets %}
<a href="/{{ m.post.slug }}/{{ m.slug }}/" <a href="/{{ m.page.slug }}/{{ m.slug }}/"
class="block p-6 bg-white border border-stone-200 rounded-lg hover:border-stone-400 transition-colors"> class="block p-6 bg-white border border-stone-200 rounded-lg hover:border-stone-400 transition-colors">
<h2 class="text-lg font-semibold">{{ m.name }}</h2> <h2 class="text-lg font-semibold">{{ m.name }}</h2>
{% if m.description %} {% if m.description %}

View File

@@ -0,0 +1,13 @@
{# Card for a single market in a page-scoped listing #}
{% set market_href = market_url('/' ~ post.slug ~ '/' ~ market.slug ~ '/') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
<div>
<a href="{{ market_href }}" class="hover:text-emerald-700">
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
</a>
{% if market.description %}
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
{% endif %}
</div>
</article>

View File

@@ -0,0 +1,18 @@
{% for market in markets %}
{% include "_types/page_markets/_card.html" %}
{% endfor %}
{% if has_more %}
{# Infinite scroll sentinel #}
{% set next_url = url_for('page_markets.markets_fragment', page=page + 1)|host %}
<div
id="sentinel-{{ page }}"
class="h-4 opacity-0 pointer-events-none"
hx-get="{{ next_url }}"
hx-trigger="intersect once delay:250ms"
hx-swap="outerHTML"
role="status"
aria-hidden="true"
>
<div class="text-center text-xs text-stone-400">loading...</div>
</div>
{% endif %}

View File

@@ -0,0 +1,12 @@
{# Markets grid for a single page #}
{% if markets %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/page_markets/_cards.html" %}
</div>
{% else %}
<div class="px-3 py-12 text-center text-stone-400">
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg">No markets for this page</p>
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -0,0 +1,15 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% block post_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/page_markets/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% import 'macros/links.html' as links %}
{# Widget-driven container nav — entries, calendars, markets #}
{% if container_nav_widgets %}
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
id="entries-calendars-nav-wrapper">
{% include '_types/post/admin/_nav_entries.html' %}
</div>
{% endif %}
{# Admin link #}
{% if post and has_access('blog.post.admin.admin') %}
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
<i class="fa fa-cog" aria-hidden="true"></i>
{% endcall %}
{% endif %}

View File

@@ -0,0 +1,50 @@
{# Left scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll left"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
<i class="fa fa-chevron-left"></i>
</button>
{# Widget-driven nav items container #}
<div id="associated-items-container"
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
style="scroll-behavior: smooth;"
_="on load or scroll
-- Show arrows if content overflows (desktop only)
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
remove .hidden from .entries-nav-arrow
add .flex to .entries-nav-arrow
else
add .hidden to .entries-nav-arrow
remove .flex from .entries-nav-arrow
end">
<div class="flex flex-col sm:flex-row gap-1">
{% for wdata in container_nav_widgets %}
{% with ctx=wdata.ctx %}
{% include wdata.widget.template with context %}
{% endwith %}
{% endfor %}
</div>
</div>
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
{# Right scroll arrow - desktop only #}
<button
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
aria-label="Scroll right"
_="on click
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
<i class="fa fa-chevron-right"></i>
</button>

View File

@@ -0,0 +1,28 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post-row', oob=oob) %}
{% call links.link(blog_url('/' + post.slug + '/'), hx_select_search ) %}
{% if post.feature_image %}
<img
src="{{ post.feature_image }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% endif %}
<span>
{{ post.title | truncate(160, True, '…') }}
</span>
{% endcall %}
{% call links.desktop_nav() %}
{% if page_cart_count is defined and page_cart_count > 0 %}
<a
href="{{ cart_url('/' + post.slug + '/') }}"
class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
>
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
<span>{{ page_cart_count }}</span>
</a>
{% endif %}
{% include '_types/post/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -1,25 +1,17 @@
{% set oob='true' %} {# HTMX response after add-to-cart: OOB-swap the mini cart + product buttons #}
{% import '_types/product/_cart.html' as _cart %} {% import '_types/product/_cart.html' as _cart %}
{% from '_types/cart/_mini.html' import mini with context %}
{{mini()}}
{{ _cart.add(d.slug, cart, oob='true')}} {# 1. Update mini cart directly — handler already has the cart data #}
{% from 'macros/cart_icon.html' import cart_icon %}
{{ cart_icon(count=cart | sum(attribute="quantity")) }}
{# 2. Update add/remove buttons on the product #}
{{ _cart.add(d.slug, cart, oob='true') }}
{# 3. Update cart item row if visible #}
{% from '_types/product/_cart.html' import cart_item with context %} {% from '_types/product/_cart.html' import cart_item with context %}
{% if item and item.quantity > 0 %}
{% if cart | sum(attribute="quantity") > 0 %} {{ cart_item(oob='true') }}
{% if item.quantity > 0 %} {% elif item %}
{{ cart_item(oob='true')}} {{ cart_item(oob='delete') }}
{% else %}
{{ cart_item(oob='delete')}}
{% endif %}
{% from '_types/cart/_cart.html' import summary %}
{{ summary(cart, total,calendar_total, calendar_cart_entries, oob='true')}}
{% else %}
{% set cart=[] %}
{% from '_types/cart/_cart.html' import show_cart with context %}
{{ show_cart( oob='true') }}
{% endif %} {% endif %}

View File

@@ -7,9 +7,9 @@
{% if not quantity %} {% if not quantity %}
<form <form
action="{{ url_for('market.browse.product.cart', slug=slug) }}" action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post" method="post"
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}" hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini" hx-target="#cart-mini"
hx-swap="outerHTML" hx-swap="outerHTML"
class="rounded flex items-center" class="rounded flex items-center"
@@ -38,9 +38,9 @@
<div class="rounded flex items-center gap-2"> <div class="rounded flex items-center gap-2">
<!-- minus --> <!-- minus -->
<form <form
action="{{ url_for('market.browse.product.cart', slug=slug) }}" action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post" method="post"
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}" hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini" hx-target="#cart-mini"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
@@ -80,9 +80,9 @@
<!-- plus --> <!-- plus -->
<form <form
action="{{ url_for('market.browse.product.cart', slug=slug) }}" action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post" method="post"
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}" hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini" hx-target="#cart-mini"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
@@ -139,7 +139,7 @@
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"> <div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
<div class="min-w-0"> <div class="min-w-0">
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900"> <h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
{% set href=url_for('market.browse.product.product_detail', slug=p.slug) %} {% set href=url_for('market.browse.product.product_detail', product_slug=p.slug) %}
<a <a
href="{{ href }}" href="{{ href }}"
hx_get="{{href}}" hx_get="{{href}}"
@@ -189,9 +189,9 @@
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700"> <div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span> <span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
<form <form
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}" action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
method="post" method="post"
hx-post="{{ url_for('market.browse.product.cart', slug=p.slug) }}" hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
hx-target="#cart-mini" hx-target="#cart-mini"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
@@ -212,9 +212,9 @@
{{ item.quantity }} {{ item.quantity }}
</span> </span>
<form <form
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}" action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
method="post" method="post"
hx-post="{{ url_for('market.browse.product.cart', slug=p.slug) }}" hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
hx-target="#cart-mini" hx-target="#cart-mini"
hx-swap="outerHTML" hx-swap="outerHTML"
> >

View File

@@ -30,7 +30,7 @@
{% block filter %} {% block filter %}
{% call layout.details() %} {% call layout.details() %}
{% call layout.summary('coop-child-header') %} {% call layout.summary('blog-child-header') %}
{% endcall %} {% endcall %}
{% call layout.menu('blog-child-menu') %} {% call layout.menu('blog-child-menu') %}
{% endcall %} {% endcall %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='product-admin-row', oob=oob) %} {% call links.menu_row(id='product-admin-row', oob=oob) %}
{% call links.link(url_for('market.browse.product.admin', slug=d.slug), hx_select_search ) %} {% call links.link(url_for('market.browse.product.admin', product_slug=d.slug), hx_select_search ) %}
admin!! admin!!
{% endcall %} {% endcall %}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}

View File

@@ -15,7 +15,7 @@
{% block ___app_title %} {% block ___app_title %}
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% call links.menu_row() %} {% call links.menu_row() %}
{% call links.link(url_for('market.browse.product.admin', slug=slug), hx_select_search) %} {% call links.link(url_for('market.browse.product.admin', product_slug=slug), hx_select_search) %}
{{ links.admin() }} {{ links.admin() }}
{% endcall %} {% endcall %}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='product-row', oob=oob) %} {% call links.menu_row(id='product-row', oob=oob) %}
{% call links.link(url_for('market.browse.product.product_detail', slug=d.slug), hx_select_search ) %} {% call links.link(url_for('market.browse.product.product_detail', product_slug=d.slug), hx_select_search ) %}
{% include '_types/product/_title.html' %} {% include '_types/product/_title.html' %}
{% endcall %} {% endcall %}
{% include '_types/product/_prices.html' %} {% include '_types/product/_prices.html' %}

View File

@@ -30,8 +30,8 @@
{% block filter %} {% block filter %}
{% call layout.details() %} {% call layout.details() %}
{% call layout.summary('coop-child-header') %} {% call layout.summary('blog-child-header') %}
{% block coop_child_summary %} {% block blog_child_summary %}
{% endblock %} {% endblock %}
{% endcall %} {% endcall %}
{% call layout.menu('blog-child-menu') %} {% call layout.menu('blog-child-menu') %}

View File

@@ -0,0 +1,7 @@
<aside
id="aside"
hx-swap-oob="outerHTML"
class="hidden"
>
</aside>

View File

@@ -0,0 +1,5 @@
<div
id="filter"
hx-swap-oob="outerHTML"
>
</div>

View File

@@ -0,0 +1,9 @@
{# Market links nav — served as fragment from market app #}
{% for m in markets %}
<a
href="{{ market_url('/' + post_slug + '/' + m.slug + '/') }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>{{m.name}}</div>
</a>
{% endfor %}

View File

@@ -0,0 +1,117 @@
{#
Unified filter macros for browse/shop pages
Consolidates duplicate mobile/desktop filter components
#}
{% macro filter_item(href, is_on, title, icon_html, count=none, variant='desktop') %}
{#
Generic filter item (works for labels, stickers, etc.)
variant: 'desktop' or 'mobile'
#}
{% set base_class = "flex flex-col items-center justify-center" %}
{% if variant == 'mobile' %}
{% set item_class = base_class ~ " p-1 rounded hover:bg-stone-50" %}
{% set count_class = "text-[10px] text-stone-500 mt-1 leading-none tabular-nums" if count != 0 else "text-md text-red-500 font-bold mt-1 leading-none tabular-nums" %}
{% else %}
{% set item_class = base_class ~ " py-2 w-full h-full" %}
{% set count_class = "text-xs text-stone-500 leading-none justify-self-end tabular-nums" if count != 0 else "text-md text-red-500 font-bold leading-none justify-self-end tabular-nums" %}
{% endif %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ title }}"
aria-label="{{ title }}"
class="{{ item_class }}"
>
{{ icon_html | safe }}
{% if count is not none %}
<span class="{{ count_class }}">{{ count }}</span>
{% endif %}
</a>
{% endmacro %}
{% macro labels_list(labels, selected_labels, current_local_href, variant='desktop') %}
{#
Unified labels filter list
variant: 'desktop' or 'mobile'
#}
{% import 'macros/stickers.html' as stick %}
{% if variant == 'mobile' %}
<nav aria-label="labels" class="px-4 pb-3">
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
{% else %}
<ul id="labels-details-desktop" class="flex justify-center p-0 m-0 gap-2" >
{% endif %}
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on else {"add_label": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="{{ 'list-none shrink-0' if variant == 'mobile' else '' }}">
{{ filter_item(
href, is_on, s.name,
stick.sticker(asset_url('nav-labels/' ~ s.name ~ '.svg'), s.name, is_on),
s.count, variant
) }}
</li>
{% endfor %}
</ul>
{% if variant == 'mobile' %}
</nav>
{% endif %}
{% endmacro %}
{% macro stickers_list(stickers, selected_stickers, current_local_href, variant='desktop') %}
{#
Unified stickers filter list
variant: 'desktop' or 'mobile'
#}
{% import 'macros/stickers.html' as stick %}
{% if variant == 'mobile' %}
<nav aria-label="stickers" class="px-4 pb-3">
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
{% else %}
<ul id="stickers-details-desktop"
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
>
{% endif %}
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
{% set display_name = s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar' %}
<li class="{{ 'list-none shrink-0' if variant == 'mobile' else '' }}">
{% set icon_html %}
<span class="{{ 'text-sm' if variant == 'mobile' else 'text-[11px]' }}">{{ display_name }}</span>
{{ stick.sticker(asset_url('stickers/' ~ s.name ~ '.svg'), s.name, is_on) }}
{% endset %}
{{ filter_item(href, is_on, s.name, icon_html, s.count, variant) }}
</li>
{% endfor %}
</ul>
{% if variant == 'mobile' %}
</nav>
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
{% endif %}
{% endmacro %}