Compare commits

..

99 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
79 changed files with 2504 additions and 124 deletions

View File

@@ -8,7 +8,7 @@ env:
REGISTRY: registry.rose-ash.com:5000
IMAGE: market
REPO_DIR: /root/rose-ash/market
COOP_DIR: /root/coop
COOP_DIR: /root/rose-ash
jobs:
build-and-deploy:
@@ -40,11 +40,11 @@ jobs:
git reset --hard origin/${{ github.ref_name }}
git submodule update --init --recursive
# Clean ALL sibling dirs (including stale self-copies from previous runs)
for sibling in blog market cart events; do
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; do
for sibling in blog market cart events federation; do
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
repo=/root/rose-ash/\$sibling
if [ -d \$repo/.git ]; then

View File

@@ -4,7 +4,7 @@ Product browsing and marketplace service for the Rose Ash cooperative. Displays
## Architecture
One of four Quart microservices sharing a single PostgreSQL database:
One of five Quart microservices sharing a single PostgreSQL database:
| App | Port | Domain |
|-----|------|--------|
@@ -12,6 +12,7 @@ One of four Quart microservices sharing a single PostgreSQL database:
| **market** | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
## Structure
@@ -19,9 +20,7 @@ One of four Quart microservices sharing a single PostgreSQL database:
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
market.py # Product, Category, CartItem
market_place.py # MarketPlace (page-scoped marketplace)
models/ # Market-domain models (+ re-export stubs)
bp/ # Blueprints
market/ # Market root, navigation, category listing
browse/ # Product browsing with filters and infinite scroll
@@ -29,39 +28,21 @@ bp/ # Blueprints
cart/ # Page-scoped cart views
api/ # Product sync API (used by scraper)
scrape/ # Suma Wholesale scraper
get_auth.py # Authentication
listings.py # Product listing pages
nav.py # Category navigation
product/ # Individual product scraping
build_snapshot/ # Build product snapshots
persist_snapshot/ # Save snapshots to DB
persist_api/ # Save via API
templates/ # Jinja2 templates
entrypoint.sh # Docker entrypoint
Dockerfile
shared/ # Submodule → git.rose-ash.com/coop/shared.git
glue/ # Submodule → git.rose-ash.com/coop/glue.git
services/ # register_domain_services() — wires market + cart
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
## Dependencies
## Cross-Domain Communication
**Cross-app model imports:**
- `blog.models.ghost_content.Post``app.py` hydrates page data for marketplace views
**Glue services:**
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
**Internal APIs:**
- Calls `GET /internal/cart/summary` — context processor for cart widget
- `services.cart.*` — cart summary via CartService protocol
- `services.federation.*` — AP publishing via FederationService protocol
- `shared.services.navigation` — site navigation tree
## Scraping
```bash
# Full scrape (Suma Wholesale catalogue)
bash scrape.sh
# Test scraping (limited)
bash scrape-test.sh
bash scrape.sh # Full Suma Wholesale catalogue
bash scrape-test.sh # Limited test scrape
```
## Running
@@ -70,15 +51,6 @@ bash scrape-test.sh
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
export SUMA_USER=your-suma-username
export SUMA_PASSWORD=your-suma-password
hypercorn app:app --reload --bind 0.0.0.0:8001
```
## Docker
```bash
docker build -t market .
docker run -p 8001:8000 --env-file .env market
hypercorn app:app --bind 0.0.0.0:8001
```

84
app.py
View File

@@ -1,23 +1,23 @@
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 quart import g, abort, render_template, make_response
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from bp import register_market_bp
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments
async def market_context() -> dict:
"""
Market app context processor.
- menu_items: direct DB query
- nav_tree_html: fetched from blog as fragment
- cart_count/cart_total: via cart service (includes calendar entries)
- cart: direct ORM query (templates need .product relationship)
"""
@@ -25,11 +25,17 @@ async def market_context() -> dict:
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["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
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)
ident = current_cart_identity()
@@ -77,19 +83,37 @@ def create_app() -> "Quart":
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>/
app.register_blueprint(
register_market_bp(
url_prefix="/",
title=config()["coop_title"],
title=config()["market_title"],
),
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
def pull_slugs(endpoint, 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:
g.post_slug = values.pop("page_slug")
if "market_slug" in values:
@@ -97,18 +121,22 @@ def create_app() -> "Quart":
@app.url_defaults
def inject_slugs(endpoint, values):
for attr, param in [("post_slug", "page_slug"), ("market_slug", "market_slug")]:
val = g.get(attr)
if val and param not in values:
if app.url_map.is_endpoint_expecting(endpoint, param):
values[param] = val
slug = g.get("post_slug")
if slug:
for param in ("slug", "page_slug"):
if param not in values and app.url_map.is_endpoint_expecting(endpoint, param):
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 ---
@app.before_request
async def hydrate_market():
post_slug = getattr(g, "post_slug", None)
market_slug = getattr(g, "market_slug", None)
if not post_slug or not market_slug:
if not post_slug:
return
# Load post by slug via blog service
@@ -129,7 +157,10 @@ def create_app() -> "Quart":
},
}
# Load market scoped to post (container pattern)
# Only load market when market_slug is present (/<page_slug>/<market_slug>/)
if not market_slug:
return
market = (
await g.s.execute(
select(MarketPlace).where(
@@ -151,33 +182,6 @@ def create_app() -> "Quart":
return {}
return {**post_data}
# --- Root route: market listing ---
@app.get("/")
async def markets_listing():
result = await g.s.execute(
select(MarketPlace)
.where(MarketPlace.deleted_at.is_(None), MarketPlace.container_type == "page")
.order_by(MarketPlace.name)
)
all_markets = result.scalars().all()
# Resolve page posts via blog service
post_ids = list({m.container_id for m in all_markets})
posts_by_id = {
p.id: p
for p in await services.blog.get_posts_by_ids(g.s, post_ids)
}
markets = []
for market in all_markets:
market.page = posts_by_id.get(market.container_id)
markets.append(market)
html = await render_template(
"_types/market/markets_listing.html",
markets=markets,
)
return await make_response(html)
return app

View File

@@ -1,2 +1,5 @@
from .market.routes import register as register_market_bp
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

@@ -291,6 +291,22 @@ async def _create_product_from_payload(session: AsyncSession, payload: Dict[str,
#await session.flush() # get p.id
_replace_children(p, payload)
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
# ---- API --------------------------------------------------------------------

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

@@ -27,7 +27,7 @@ def register(url_prefix, title):
post_data = getattr(g, "post_data", None) or {}
return {
**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"],
"qs": makeqs_factory()(),
"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

@@ -22,20 +22,26 @@ from .services.product_operations import toggle_product_like, massage_full_produ
def register():
bp = Blueprint("product", __name__, url_prefix="/product/<slug>")
bp = Blueprint("product", __name__, url_prefix="/product/<product_slug>")
@bp.url_value_preprocessor
def pull_blog(endpoint, values):
g.product_slug = values.get("slug")
def pull_product_slug(endpoint, values):
# 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
# ─────────────────────────────────────────────────────────────
@bp.before_request
async def resolve_product():
from quart import request as req
raw_slug = g.product_slug = getattr(g, "product_slug", None)
if raw_slug is None:
return
is_post = req.method == "POST"
# 1. If slug is INT → load product by ID
if raw_slug.isdigit():
product_id = int(raw_slug)
@@ -53,20 +59,24 @@ def register():
g.item_data = {"d": d, "slug": product["slug"], "liked": False}
return
# Not deleted → redirect to canonical slug
canon = canonical_html_slug(product["slug"])
return redirect(
host_url(url_for("market.browse.product.product_detail", slug=canon))
)
# Not deleted → redirect to canonical slug (GET only)
if not is_post:
canon = canonical_html_slug(product["slug"])
return redirect(
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
if is_product_blocked(raw_slug):
abort(404)
canon = canonical_html_slug(raw_slug)
if canon != raw_slug:
if canon != raw_slug and not is_post:
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
@@ -75,7 +85,7 @@ def register():
)
if not d:
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
def context():
@@ -93,7 +103,7 @@ def register():
# ─────────────────────────────────────────────────────────────
@bp.get("/")
@cache_page(tag="browse")
async def product_detail(slug: str):
async def product_detail():
from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type
@@ -108,9 +118,8 @@ def register():
@bp.post("/like/toggle/")
@clear_cache(tag="browse", tag_scope="user")
async def like_toggle(slug):
# Use slug from URL parameter (set by url_prefix="/product/<slug>")
product_slug = slug
async def like_toggle():
product_slug = g.product_slug
if not g.user:
html = await render_template(
@@ -139,7 +148,7 @@ def register():
@bp.get("/admin/")
async def admin(slug: str):
async def admin():
from shared.browser.app.utils.htmx import is_htmx_request
if not is_htmx_request():
@@ -159,7 +168,8 @@ def register():
@bp.post("/cart/")
@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)
product_id = await g.s.scalar(
select(Product.id).where(
@@ -242,19 +252,12 @@ def register():
# 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":
# You can return a small fragment or mini-cart here
return await render_template(
"_types/product/_added.html",
cart=g.cart,
item=ci,
total=total,
cart_count=sum(i.quantity for i in g.cart),
cart_total=total(g.cart),
calendar_total=lambda entries: 0,
calendar_cart_entries=[],
)
# normal POST: go to cart page

View File

@@ -3,16 +3,17 @@ base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
coop_root: /market
coop_title: Market
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
coop: "http://localhost:8000"
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:

View File

@@ -24,3 +24,6 @@ def register_domain_services() -> None:
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()

2
shared

Submodule shared updated: 3febef074b...9ab4b7b3fe

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

@@ -0,0 +1,7 @@
{% import "macros/links.html" as links %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for('market.browse.product.admin', product_slug=slug)
)}}
{% endif %}

View File

@@ -0,0 +1,5 @@
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">
{% include "_types/browse/_product_cards.html" %}
</div>
<div class="pb-8"></div>

View File

@@ -0,0 +1,104 @@
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(p, prices_ns) }}
{% 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">
{# ❤️ like button overlay - OUTSIDE the link #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">
{% set slug = p.slug %}
{% set liked = p.is_liked or False %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class=""
>
{# Make this relative so we can absolutely position children #}
<div class="w-full aspect-square bg-stone-100 relative">
{% if p.image %}
<figure class="inline-block w-full h-full">
<div class="relative w-full h-full">
<img
src="{{ p.image }}"
alt="no image"
class="absolute inset-0 w-full h-full object-contain object-top"
loading="lazy" decoding="async" fetchpriority="low"
/>
{% for l in p.labels %}
<img
src="{{ asset_url('labels/' + l + '.svg') }}"
alt=""
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
/>
{% endfor %}
</div>
<figcaption class="
mt-2 text-sm text-center
{{ 'bg-yellow-200' if p.brand in selected_brands else '' }}
text-stone-600
">
{{ p.brand }}
</figcaption>
</figure>
{% else %}
<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">
<div class="text-stone-400 text-xs">No image</div>
<ul class="flex flex-row gap-1">
{% for l in p.labels %}
<li>{{ l }}</li>
{% endfor %}
</ul>
<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.brand }}
</div>
</div>
{% endif %}
</div>
{# <div>{{ prices.rrp(prices_ns) }}</div> #}
{{ prices.card_price(p)}}
{% import '_types/product/_cart.html' as _cart %}
</a>
<div class="flex justify-center">
{{ _cart.add(p.slug, cart)}}
</div>
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div class="flex flex-row justify-center gap-2 p-2">
{% for s in p.stickers %}
{{ stick.sticker(
asset_url('stickers/' + s + '.svg'),
s,
True,
size=24,
found=s in selected_stickers
) }}
{% endfor %}
</div>
<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.title | highlight(search) }}
</div>
</a>
</div>

View File

@@ -0,0 +1,107 @@
{% for p in products %}
{% include "_types/browse/_product_card.html" %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -0,0 +1,40 @@
{# Categories #}
<nav aria-label="Categories"
class="rounded-xl border bg-white shadow-sm min-h-0">
<ul class="divide-y">
{% 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 %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if top_active else 'false' }}"
class="block px-4 py-3 text-[15px] transition {{select_colours}}">
<div class="prose prose-stone max-w-none">All products</div>
</a>
</li>
{% for sub in subs_local %}
{% set active = (sub.slug == sub_slug) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if active else 'false' }}"
class="block px-4 py-3 text-[15px] border-l-4 transition {{select_colours}}"
>
<div class="prose prose-stone max-w-none">{{ (sub.html_label or sub.name) | safe }}</div>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,40 @@
{# Brand filter (desktop, single-select) #}
{# Brands #}
<nav aria-label="Brands"
class="rounded-xl border bg-white shadow-sm">
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="divide-y">
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
{% if is_selected %}
{% set brand_href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set brand_href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{% endif %}
<li>
<a
href="{{ brand_href }}"
hx-get="{{ brand_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML" hx-push-url="true" hx-on:htmx:afterSwap="this.closest('details')?.removeAttribute('open')"
class="flex items-center gap-2 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="{% if b.count==0 %}text-lg text-red-500{% else %}text-sm{% endif %} {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,44 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="labels-details-desktop"
class="flex justify-center p-0 m-0 gap-2"
>
{% 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>
<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="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,38 @@
{% import 'macros/stickers.html' as stick %}
{% set qs = {"liked": None if liked else True, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<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 liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-300 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>

View File

@@ -0,0 +1,44 @@
{% macro search(current_local_href,search, search_count, hx_select) -%}
<!-- Search (1/3 width → 4/12 columns) -->
<!-- nb this does NOT oob itself!! -->
<div
id="search-desktop-wrapper"
class="flex flex-row gap-2 items-center"
>
<input
id="search-desktop"
type="text"
name="search"
aria-label="search"
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-desktop"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
{{zap_filter}}
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,34 @@
{% import 'macros/stickers.html' as stick %}
{% set sort_val = sort|default('az', true) %}
<ul
id="sort-details-desktop"
class="flex w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-0 [&>li]:list-none [&>li]:flex-1"
>
{% for key,label,icon in sort_options %}
{% set is_on = (sort_val == key) %}
{% set qs = {"sort": None, "page": None}|qs if is_on
else {"sort": key, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<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' }}"
class="flex flex-col items-center justify-center w-full h-full py-2 m-0"
>
{{ stick.sticker(asset_url(icon), label, is_on) }}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,46 @@
{% import 'macros/stickers.html' as stick %}
<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"
>
{% 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%}
<li>
<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="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<span class="text-[11px]">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -0,0 +1,37 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
<div class="mb-4">
<div class="text-2xl uppercase tracking-wide text-black-500">{{ category_label }}</div>
</div>
{% include "_types/browse/desktop/_filter/sort.html" %}
<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">
{% include "_types/browse/desktop/_filter/like.html" %}
{% if labels %}
{% include "_types/browse/desktop/_filter/labels.html" %}
{% endif %}
</nav>
{% if stickers %}
{% include "_types/browse/desktop/_filter/stickers.html" %}
{% endif %}
{% if subs_local and top_local_href %}
{% include "_types/browse/desktop/_category_selector.html" %}
{% endif %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include "_types/browse/desktop/_filter/brand.html" %}
</div>

View File

@@ -0,0 +1,13 @@
{% extends '_types/market/index.html' %}
{% block filter %}
{% include "_types/browse/mobile/_filter/summary.html" %}
{% endblock %}
{% block aside %}
{% include "_types/browse/desktop/menu.html" %}
{% endblock %}
{% block content %}
{% include "_types/browse/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,20 @@
<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]"
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
hx-target="this"
hx-swap="outerHTML"
hx-push-url="false"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-swap-settle="0ms"
{% if liked %}
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
{% else %}
aria-label="Like this {{ item_type if item_type else 'product' }}"
{% endif %}
>
{% if liked %}
<i aria-hidden="true" class="fa-solid fa-heart"></i>
{% else %}
<i aria-hidden="true" class="fa-regular fa-heart"></i>
{% endif %}
</button>

View File

@@ -0,0 +1,40 @@
<nav aria-label="Brands" class="px-4 pb-3" >
{% if brands|length %}
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="space-y-1 pr-1" >
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
<li>
{{current_local_href}}
<a
{% if is_selected %}
{% set href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{%endif%}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="flex items-center gap-2 my-3 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="text-xs {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</nav>

View File

@@ -0,0 +1,30 @@
{% include "_types/browse/mobile/_filter/sort_ul.html" %}
{% if search or selected_labels|length or selected_stickers|length or selected_brands|length %}
{% set href = (current_local_href ~ {"clear_filters": True}|qs)|host %}
<div class = "flex flex-row justify-center">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
title="clear filters"
aria-label="clear filters"
class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">
<span class="mt-1 leading-none tabular-nums"
>
clear filters
</span>
</a>
</div>
{% endif %}
<div class="flex flex-row gap-2 justify-center items center">
{% include "_types/browse/mobile/_filter/like.html" %}
{% include "_types/browse/mobile/_filter/labels.html" %}
</div>
{% include "_types/browse/mobile/_filter/stickers.html" %}
{% include "_types/browse/mobile/_filter/brand_ul.html" %}

View File

@@ -0,0 +1,47 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="labels" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% 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">
<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="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -0,0 +1,40 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="like" class="px-4 pb-3">
{% set qs = {"liked": None if liked else True, "page": None}|qs%}
{% set href = (current_local_href ~ qs)|host %}
<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 liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-500 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>
</nav>

View File

@@ -0,0 +1,40 @@
{% macro search(current_local_href, search, search_count, hx_select) -%}
<div
id="search-mobile-wrapper"
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
>
<input
id="search-mobile"
type="text"
name="search"
aria-label="search"
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-mobile"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -0,0 +1,33 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="sort" 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">
{% for key,label,icon in sort_options %}
<li class="list-none">
<div class="flex flex-col items-center justify-center w-full">
<a
{% if sort == key %}
{% set href= (current_local_href, {"sort": None, "page": None}|qs )|host %}
{% else %}
{% set href= (current_local_href ~ {"sort": key, "page": None}|qs )|host %}
{% endif %}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{ stick.sticker(asset_url(icon), label, sort==key) }}
</a>
</div>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -0,0 +1,50 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="stickers" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% 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 %}
<li class="list-none shrink-0">
<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="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
<span class="text-sm">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on) }}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -0,0 +1,120 @@
{% import 'macros/stickers.html' as stick %}
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
<div
class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2"
role="list">
<div class="flex flex-row items-start gap-2">
{% if sort %}
<ul class="relative inline-flex items-center justify-center gap-2">
<!-- sticker icon -->
{% for k,l,i in sort_options %}
{% if k == sort %}
{% set key = k %}
{% set label = l %}
{% set icon = i %}
<li role="listitem">
{{ stick.sticker(asset_url(icon), label, True)}}
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% if liked %}
<div class="flex flex-col items-center gap-1 pb-1">
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% if liked_count is not none %}
<div class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums"
>
{{ liked_count }}
</div>
{% endif %}
</div>
{% endif %}
{% if selected_labels and selected_labels|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_labels %}
{% for s in labels %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<div class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</div>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{% if selected_stickers and selected_stickers|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_stickers %}
{% for s in stickers %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
<!-- sticker icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
</div>
{% if selected_brands and selected_brands|length %}
<ul class_="w-full grid grid-cols-12 items-center gap-3 px-4 py-3">
{% for b in selected_brands %}
<li role="listitem" class="flex flex-row items-center gap-2">
{% set ns = namespace(count=0) %}
{% for brand in brands %}
{% if brand.name == b %}
{% set ns.count = brand.count %}
{% endif %}
{% endfor %}
{% if ns.count %}
<div class="text-md">{{ b }}</div>
<div class="text-md">{{ ns.count }}</div>
{% else %}
<div class="text-md text-red-500">{{ b }}</div>
<div class="text-xl text-red-500">0</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endcall %}
<div id="filter-details-mobile" style="display:contents">
{% include "_types/browse/mobile/_filter/index.html" %}
</div>
{% endcall %}

View File

@@ -0,0 +1,7 @@
{% import "macros/links.html" as links %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for('market.admin.admin')
)}}
{% endif %}

View File

@@ -0,0 +1,23 @@
{# Main panel fragment for HTMX navigation - market landing page #}
<article class="relative w-full">
{% if post.custom_excerpt %}
<div class="w-full text-center italic text-3xl p-2">
{{post.custom_excerpt|safe}}
</div>
{% endif %}
{% if post.feature_image %}
<div class="mb-3 flex justify-center">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full md:w-3/4 object-cover"
>
</div>
{% endif %}
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
<div class="pb-8"></div>

View File

@@ -0,0 +1,17 @@
<div
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
<div>
<i class="fa fa-shop"></i>
{{ market_title }}
</div>
<div class="flex flex-col md:flex-row md:gap-2 text-xs">
<div>
{{top_slug or ''}}
</div>
{% if sub_slug %}
<div>
{{sub_slug}}
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1 @@
market admin

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,29 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('market-header-child', 'market-admin-header-child', '_types/market/admin/header/_header.html')}}
{% from '_types/market/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/market/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/market/admin/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='market-admin-row', oob=oob) %}
{% call links.link(url_for('market.admin.admin'), hx_select_search) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/market/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,19 @@
{% extends '_types/market/index.html' %}
{% block market_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('market-admin-header-child', '_types/market/admin/header/_header.html') %}
{% block market_admin_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/market/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/market/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,38 @@
<!-- Desktop nav -->
<nav class="hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center">
{% set all_href = (url_for('market.browse.browse_all') ~ qs)|host %}
{% set all_active = (category_label == 'All Products') %}
<div class="relative nav-group">
<a
href="{{ all_href }}"
hx-get="{{ all_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if all_active else 'false' }}"
class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {{select_colours}}">
All
</a>
</div>
{% for cat, data in categories.items() %}
{% set cat_href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host%}
{% set cat_active = (cat == category_label) %}
<div class="relative nav-group">
<a
href="{{ cat_href }}"
hx-get="{{ cat_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if cat_active else 'false' }}"
class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {{select_colours}}"
>
{{ cat }}
</a>
</div>
{% endfor %}
{% include '_types/market/_admin.html' %}
</nav>

View File

@@ -0,0 +1,11 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='market-row', oob=oob) %}
{% call links.link(url_for('market.browse.home'), hx_select_search ) %}
{% include '_types/market/_title.html' %}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/market/desktop/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,110 @@
{% from 'macros/glyphs.html' import opener %}
<div class="px-4 py-2">
<div class="divide-y">
{% set all_href = (url_for('market.browse.browse_all') ~ qs)|host %}
{% set all_active = (category_label == 'All Products') %}
<a role="option"
href="{{ all_href }}"
hx-get="{{ all_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if all_active else 'false' }}"
class="block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {{select_colours}}">
<div class="prose prose-stone max-w-none">
All
</div>
</a>
{% for cat, data in categories.items() %}
<details
class="group/cat py-1"
{% if top_slug == (data.slug | lower) %}open{% endif %}
>
<summary class="flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {% if top_slug==(data.slug | lower) %} bg-stone-900 text-white hover:bg-stone-900 {% endif %}">
{% set href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if top_slug==(data.slug | lower) else 'false' }}"
class="font-medium {{ select_colours }} flex flex-row gap-2"
>
<div>{{ cat }}</div>
<div aria-label="{{ data.count }} products">{{ data.count }}</div>
</a>
{{ opener('cat')}}
</summary>
<div class="pb-3 pl-2">
{% if data.subs %}
<!-- Viewport -->
<div
data-peek-viewport
data-peek-size-px="18"
data-peek-edge="bottom"
data-peek-mask="true"
class="m-2 bg-stone-100">
<!-- Inner list (no negative margin by default) -->
<div data-peek-inner class="grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" aria-label="Subcategories">
{% for sub in data.subs %}
{% set href = (url_for('market.browse.browse_sub', top_slug=data.slug, sub_slug=sub.slug) ~qs)|host%}
{% if top_slug==(data.slug | lower) and sub_slug == sub.slug %}
<a
class="snap-start px-2 py-3 rounded {{select_colours}} flex flex-row gap-2"
aria-selected="{{ 'true' if top_slug==(data.slug | lower) and sub_slug == sub.slug else 'false' }}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div>{{ sub.html_label or sub.name }}</div>
<div aria-label="{{ sub.count }} products">{{ sub.count }}</div>
</a>
{% endif %}
{% endfor %}
{% for sub in data.subs %}
{% if not (top_slug==(data.slug | lower) and sub_slug == sub.slug) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=data.slug, sub_slug=sub.slug) ~ qs)|host%}
<a
class="snap-start px-2 py-3 rounded {{select_colours}} flex flex-row gap-2"
aria-selected="{{ 'true' if top_slug==(data.slug | lower) and sub_slug == sub.slug else 'false' }}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div>{{ sub.name }}</div>
<div aria-label="{{ sub.count }} products">{{ sub.count }}</div>
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
{% set href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host%}
<a class="px-2 py-1 rounded hover:bg-stone-100 block"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>View all</a>
{% endif %}
</div>
</details>
{% endfor %}
{% include '_types/market/_admin.html' %}
</div>
</div>

View File

@@ -0,0 +1,6 @@
{% extends 'mobile/menu.html' %}
{% block menu %}
{% block mobile_menu %}
{% endblock %}
{% include '_types/market/mobile/_nav_panel.html' %}
{% endblock %}

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

@@ -0,0 +1,17 @@
{# HTMX response after add-to-cart: OOB-swap the mini cart + product buttons #}
{% import '_types/product/_cart.html' as _cart %}
{# 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 %}
{% if item and item.quantity > 0 %}
{{ cart_item(oob='true') }}
{% elif item %}
{{ cart_item(oob='delete') }}
{% endif %}

View File

@@ -7,9 +7,9 @@
{% if not quantity %}
<form
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
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-swap="outerHTML"
class="rounded flex items-center"
@@ -38,9 +38,9 @@
<div class="rounded flex items-center gap-2">
<!-- minus -->
<form
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
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-swap="outerHTML"
>
@@ -80,9 +80,9 @@
<!-- plus -->
<form
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
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-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="min-w-0">
<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
href="{{ href }}"
hx_get="{{href}}"
@@ -189,9 +189,9 @@
<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>
<form
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
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-swap="outerHTML"
>
@@ -212,9 +212,9 @@
{{ item.quantity }}
</span>
<form
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
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-swap="outerHTML"
>

View File

@@ -0,0 +1,131 @@
{# Main panel fragment for HTMX navigation - product detail content #}
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
{# Product detail grid from content block #}
<div class="mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" data-gallery-root>
<div class="md:col-span-2">
{% if d.images and d.images|length > 0 %}
<div class="relative rounded-xl overflow-hidden bg-stone-100">
{# --- like button overlay in top-right --- #}
{% if g.user %}
<div class="absolute top-3 right-5 z-10 text-6xl md:text-xl">
{% set slug = d.slug %}
{% set liked = liked_by_current_user %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
<figure class="inline-block">
<div class="relative w-full aspect-square">
<img
data-main-img
src="{{ d.images[0] }}"
alt="{{ d.title }}"
class="w-full h-full object-contain object-top"
loading="eager" decoding="async"
/>
{% for l in d.labels %}
<img
src="{{ asset_url('labels/' + l + '.svg') }}"
alt=""
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
/>
{% endfor %}
</div>
<figcaption class="mt-2 text-sm text-stone-600 text-center">
{{ d.brand }}
</figcaption>
</figure>
{% if d.images|length > 1 %}
<button type="button" data-prev
class="absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg focus:outline-none focus:ring-2 focus:ring-stone-300 text-3xl md:text-4xl"
title="Previous"></button>
<button type="button" data-next
class="absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg focus:outline-none focus:ring-2 focus:ring-stone-300 text-3xl md:text-4xl"
title="Next"></button>
{% endif %}
</div>
<div class="flex flex-row justify-center">
<div class="mt-3 flex gap-2 overflow-x-auto no-scrollbar">
{% for u in d.images %}
<button type="button" data-thumb
class="shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"
title="Image {{ loop.index }}">
<img src="{{ u }}" class="h-16 w-16 object-contain" alt="thumb {{ loop.index }}" loading="lazy" decoding="async">
</button>
<span data-image-src="{{ u }}" class="hidden"></span>
{% endfor %}
</div>
</div>
{% else %}
<div class="relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400">
{# Even if no image, still render the like button in the corner for consistency #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10">
{% set slug = d.slug %}
{% set liked = liked_by_current_user %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
No image
</div>
{% endif %}
<div class="p-2 flex flex-row justify-center gap-2">
{% for s in d.stickers %}
{{ stick.sticker(asset_url('stickers/' + s + '.svg'), s, True, size=40) }}
{% endfor %}
</div>
</div>
<div class="md:col-span-3">
{# Optional extras shown quietly #}
<div class="mt-2 space-y-1 text-sm text-stone-600">
{% if d.price_per_unit or d.price_per_unit_raw %}
<div>Unit price: {{ prices.price_str(d.price_per_unit, d.price_per_unit_raw, d.price_per_unit_currency) }}</div>
{% endif %}
{% if d.case_size_raw %}
<div>Case size: {{ d.case_size_raw }}</div>
{% endif %}
</div>
{% if d.description_short or d.description_html %}
<div class="mt-4 text-stone-800 space-y-3">
{% if d.description_short %}
<p class="leading-relaxed text-lg">{{ d.description_short }}</p>
{% endif %}
{% if d.description_html %}
<div class="max-w-none text-sm leading-relaxed">
{{ d.description_html | safe }}
</div>
{% endif %}
</div>
{% endif %}
{% if d.sections and d.sections|length %}
<div class="mt-8 space-y-3">
{% for sec in d.sections %}
<details class="group rounded-xl border bg-white shadow-sm open:shadow p-0">
<summary class="cursor-pointer select-none px-4 py-3 flex items-center justify-between">
<span class="font-medium">{{ sec.title }}</span>
<span class="ml-2 text-xl transition-transform group-open:rotate-180"></span>
</summary>
<div class="px-4 pb-4 max-w-none text-sm leading-relaxed">
{{ sec.html | safe }}
</div>
</details>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="pb-8"></div>

View File

@@ -0,0 +1,106 @@
{# --- social/meta_product.html --- #}
{# Context expected:
site, d (Product), request
#}
{# Visibility → robots: index unless soft-deleted #}
{% set robots_here = 'noindex,nofollow' if d.deleted_at else 'index,follow' %}
{# Compute canonical #}
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
{% set _product_path = request.path if request else ('/products/' ~ (d.slug or '')) %}
{% set canonical = _site_url ~ _product_path if _site_url else (request.url if request else None) %}
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
{% set robots_override = robots_here %}
{% include 'social/meta_base.html' %}
{# ---- Titles / descriptions ---- #}
{% set base_product_title = d.title or base_title %}
{% set og_title = base_product_title %}
{% set tw_title = base_product_title %}
{# Description: prefer short, then HTML stripped #}
{% set desc_source = d.description_short
or (d.description_html|striptags if d.description_html else '') %}
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
{# ---- Image priority: product image, then first gallery image, then site default ---- #}
{% set image_url = d.image
or ((d.images|first).url if d.images and (d.images|first).url else None)
or (site().default_image if site and site().default_image else None) %}
{# ---- Price / offer helpers ---- #}
{% set price = d.special_price or d.regular_price or d.rrp %}
{% set price_currency = d.special_price_currency or d.regular_price_currency or d.rrp_currency %}
{# ---- Basic meta ---- #}
<title>{{ base_product_title }}</title>
<meta name="description" content="{{ description }}">
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{# ---- Open Graph ---- #}
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
<meta property="og:type" content="product">
<meta property="og:title" content="{{ og_title }}">
<meta property="og:description" content="{{ description }}">
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
{# Optional product OG price tags #}
{% if price and price_currency %}
<meta property="product:price:amount" content="{{ '%.2f'|format(price) }}">
<meta property="product:price:currency" content="{{ price_currency }}">
{% endif %}
{% if d.brand %}
<meta property="product:brand" content="{{ d.brand }}">
{% endif %}
{% if d.sku %}
<meta property="product:retailer_item_id" content="{{ d.sku }}">
{% endif %}
{# ---- Twitter ---- #}
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
<meta name="twitter:title" content="{{ tw_title }}">
<meta name="twitter:description" content="{{ description }}">
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
{# ---- JSON-LD Product ---- #}
{% set jsonld = {
"@context": "https://schema.org",
"@type": "Product",
"name": d.title,
"image": image_url,
"description": description,
"sku": d.sku,
"brand": d.brand,
"url": canonical
} %}
{# Brand as proper object if present #}
{% if d.brand %}
{% set jsonld = jsonld | combine({
"brand": {
"@type": "Brand",
"name": d.brand
}
}) %}
{% endif %}
{# Offers if price available #}
{% if price and price_currency %}
{% set jsonld = jsonld | combine({
"offers": {
"@type": "Offer",
"price": price,
"priceCurrency": price_currency,
"url": canonical,
"availability": "https://schema.org/InStock"
}
}) %}
{% endif %}
<script type="application/ld+json">
{{ jsonld | tojson }}
</script>

View File

@@ -0,0 +1,49 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - product extends browse so use similar structure #}
{% import 'macros/layout.html' as layout %}
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/market/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('market-header-child', 'product-header-child', '_types/product/header/_header.html')}}
{% endblock %}
{% block mobile_menu %}
{% include '_types/market/mobile/_nav_panel.html' %}
{% include '_types/browse/_admin.html' %}
{% endblock %}
{% block filter %}
{% call layout.details() %}
{% call layout.summary('blog-child-header') %}
{% endcall %}
{% call layout.menu('blog-child-menu') %}
{% endcall %}
{% endcall %}
{% call layout.details() %}
{% call layout.summary('product-child-header') %}
{% endcall %}
{% call layout.menu('item-child-menu') %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/product/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% import '_types/product/_cart.html' as _cart %}
{# ---- Price block ---- #}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
<div class="flex flex-row items-center justify-between md:gap-2 md:px-2">
{{ _cart.add(d.slug, cart)}}
{% if prices_ns.sp_val %}
<div class="text-md font-bold text-emerald-700">
Special price
</div>
<div class="text-xl font-semibold text-emerald-700">
{{ prices.price_str(prices_ns.sp_val, prices_ns.sp_raw, prices_ns.sp_cur) }}
</div>
{% if prices_ns.sp_val and prices_ns.rp_val %}
<div class="text-base text-md line-through text-stone-500">
{{ prices.price_str(prices_ns.rp_val, prices_ns.rp_raw, prices_ns.rp_cur) }}
</div>
{% endif %}
{% elif prices_ns.rp_val %}
<div class="hidden md:block text-xl font-bold">
Our price
</div>
<div class="text-xl font-semibold">
{{ prices.price_str(prices_ns.rp_val, prices_ns.rp_raw, prices_ns.rp_cur) }}
</div>
{% endif %}
{{ prices.rrp(prices_ns) }}
</div>

View File

@@ -0,0 +1,2 @@
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>{{ d.title }}</div>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,40 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('product-header-child', 'product-admin-header-child', '_types/product/admin/header/_header.html')}}
{% from '_types/product/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% from '_types/root/_n/macros.html' import header with context %}
{% call header(id='product-header-child', oob=True) %}
{% call header() %}
{% from '_types/product/admin/header/_header.html' import header_row with context %}
{{header_row()}}
<div id="product-admin-header-header-child">
</div>
{% endcall %}
{% endcall %}
{% block mobile_menu %}
{% include '_types/product/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/product/_main_panel.html' %}
{% endblock %}

View File

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

View File

@@ -0,0 +1,39 @@
{% extends '_types/product/index.html' %}
{% import 'macros/layout.html' as layout %}
{% block product_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('market-header-child', '_types/product/admin/header/_header.html') %}
{% block product_admin_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block ___app_title %}
{% import 'macros/links.html' as links %}
{% call links.menu_row() %}
{% call links.link(url_for('market.browse.product.admin', product_slug=slug), hx_select_search) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/product/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/product/admin/_nav.html' %}
{% endblock %}
{% block aside %}
{% endblock %}
{% block content %}
{% include '_types/product/_main_panel.html' %}
{% endblock %}

View File

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

View File

@@ -0,0 +1,61 @@
{% extends '_types/browse/index.html' %}
{% block meta %}
{% include '_types/product/_meta.html' %}
{% endblock %}
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
{% block market_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('market-header-child', '_types/product/header/_header.html') %}
{% block product_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/browse/_admin.html' %}
{% endblock %}
{% block filter %}
{% call layout.details() %}
{% call layout.summary('blog-child-header') %}
{% block blog_child_summary %}
{% endblock %}
{% endcall %}
{% call layout.menu('blog-child-menu') %}
{% block post_child_menu %}
{% endblock %}
{% endcall %}
{% endcall %}
{% call layout.details() %}
{% call layout.summary('product-child-header') %}
{% block item_child_summary %}
{% endblock %}
{% endcall %}
{% call layout.menu('item-child-menu') %}
{% block item_child_menu %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block aside %}
{% endblock %}
{% block content %}
{% include '_types/product/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,66 @@
{# ---- Price formatting helpers ---- #}
{% set _sym = {'GBP':'£','EUR':'€','USD':'$'} %}
{% macro price_str(val, raw, cur) -%}
{%- if raw -%}
{{ raw }}
{%- elif val is number -%}
{{ (_sym.get(cur) or '') ~ ('%.2f'|format(val)) }}
{%- else -%}
{{ val or '' }}
{%- endif -%}
{%- endmacro %}
{% macro set_prices(item, ns) -%}
{% set ns.sp_val = item.special_price or (item.oe_list_price and item.oe_list_price.special) %}
{% set ns.sp_raw = item.special_price_raw or (item.oe_list_price and item.oe_list_price.special_raw) %}
{% set ns.sp_cur = item.special_price_currency or (item.oe_list_price and item.oe_list_price.special_currency) %}
{% set ns.rp_val = item.regular_price or item.rrp or (item.oe_list_price and item.oe_list_price.rrp) %}
{% set ns.rp_raw = item.regular_price_raw or item.rrp_raw or (item.oe_list_price and item.oe_list_price.rrp_raw) %}
{% set ns.rp_cur = item.regular_price_currency or item.rrp_currency or (item.oe_list_price and item.oe_list_price.rrp_currency) %}
{% set ns.case_size_count = (item.case_size_count or 1) %}
{% set ns.rrp = item.rrp_raw[0] ~ "%.2f"|format(item.rrp * (ns.case_size_count)) %}
{% set ns.rrp_raw = item.rrp_raw %}
{%- endmacro %}
{% macro rrp(ns) -%}
{% if ns.rrp %}
<div class="text-base md:text-lgtext-stone-400">
<span>rrp:</span>
<span>
{{ ns.rrp }}
</span>
</div>
{% endif %}
{%- endmacro %}
{% macro card_price(item) %}
{# price block unchanged #}
{% set _sym = {'GBP':'£','EUR':'€','USD':'$'} %}
{% set sp_val = item.special_price or (item.oe_list_price and item.oe_list_price.special) %}
{% set sp_raw = item.special_price_raw or (item.oe_list_price and item.oe_list_price.special_raw) %}
{% set sp_cur = item.special_price_currency or (item.oe_list_price and item.oe_list_price.special_currency) %}
{% set rp_val = item.regular_price or item.rrp or (item.oe_list_price and item.oe_list_price.rrp) %}
{% set rp_raw = item.regular_price_raw or item.rrp_raw or (item.oe_list_price and item.oe_list_price.rrp_raw) %}
{% set rp_cur = item.regular_price_currency or item.rrp_currency or (item.oe_list_price and item.oe_list_price.rrp_currency) %}
{% set sp_str = sp_raw if sp_raw else ( (_sym.get(sp_cur, '') ~ ('%.2f'|format(sp_val))) if sp_val is number else (sp_val or '')) %}
{% set rp_str = rp_raw if rp_raw else ( (_sym.get(rp_cur, '') ~ ('%.2f'|format(rp_val))) if rp_val is number else (rp_val or '')) %}
<div class="mt-1 flex items-baseline gap-2 justify-center">
{% if sp_val %}
<div class="text-lg font-semibold text-emerald-700">{{ sp_str }}</div>
{% if rp_val %}
<div class="text-sm line-through text-stone-500">{{ rp_str }}</div>
{% endif %}
{% elif rp_val %}
<div class="mt-1 text-lg font-semibold">{{ rp_str }}</div>
{% endif %}
</div>
{% endmacro %}

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 %}