Compare commits

...

100 Commits

Author SHA1 Message Date
giles
268f033396 Fix circular fragment fetching (shared submodule update)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:20:56 +00:00
giles
26e146e3e3 Sync shared: fragment failures now raise by default
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-24 18:04:32 +00:00
giles
1c6dda0cb7 trigger rebuild
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-02-24 18:01:56 +00:00
giles
b809b12994 trigger rebuild
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
2026-02-24 17:48:22 +00:00
giles
afadb3aa0b Remove cross-domain template copies, use shared macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
- Browse search: use macros/search.html for orders search UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:33:12 +00:00
giles
24e26f1f18 Add cross-domain template copy: browse desktop search filter for orders search
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:17:37 +00:00
giles
1de0af4fed Sync shared submodule (bound DB connection pool)
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-24 17:08:15 +00:00
giles
1154d82791 Own cart domain templates (Phase 6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
Cart, order, orders templates moved from shared to cart/templates/.
Cross-domain copies: auth header/index, product cart macro.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:55:50 +00:00
giles
17117096c9 Sync shared submodule (Phase 5 widget cleanup)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:59:10 +00:00
giles
a9b982c8c7 Sync shared submodule: Phase 4 container widget → fragment changes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:33:48 +00:00
giles
c23c131024 Restore menu_items fallback for nav, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
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:50 +00:00
giles
e0046403d8 Fetch nav-tree fragment from blog, drop local menu_items query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
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
641178855d Update shared submodule (product_slug rename in templates)
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 10:30:13 +00:00
giles
a72598f75e Fix cart-mini fragment using internal URL for logo image
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
site().logo resolves against g.host which is cart:8000 on internal
fragment requests. Use blog_url() for the public blog asset URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:37:44 +00:00
giles
0b2e59bc2c Add cart-mini and account-nav-item fragment handlers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Phase 2 of fragment composition: cart exposes cart-mini fragment
(icon + item count badge) and account-nav-item fragment (orders
link) via /internal/fragments/ endpoint. Updates shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:11:50 +00:00
giles
ac458ba96c Add fragment blueprint + sync shared: micro-frontend infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:49 +00:00
giles
39a9dc34fb Sync shared: instant logout detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:36 +00:00
giles
a1d6b635e5 Sync shared submodule: external delivery handler
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 00:41:18 +00:00
giles
f229dac2db Sync shared: add artdag_url() helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:49 +00:00
giles
533590f733 Sync shared: per-domain delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-02-23 21:54:16 +00:00
giles
d4a31a3877 Update shared: backfill only current posts
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 21:36:50 +00:00
giles
c4e0667ee8 Update shared: rewrite object URLs for per-app AP delivery
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 21:06:06 +00:00
giles
36374793ec Update shared: fix activity ID domain mismatch in AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:13 +00:00
giles
a739c0d0b1 Update shared submodule: exempt AP paths from auth redirect
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-23 20:29:08 +00:00
giles
89989cdd24 Update shared submodule: AP delivery fixes + sentinel
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-23 19:31:31 +00:00
giles
37d8754a7f Update shared submodule: per-app AP actors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:24 +00:00
giles
dca45a3fbb Update shared submodule (blog.home → blog.index template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-02-23 16:55:33 +00:00
giles
3fbed403fc Retrigger CI (Docker Hub image now cached)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-02-23 16:45:46 +00:00
giles
0417e8abff 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:13 +00:00
giles
c8248575ab Update shared submodule (NOTIFY/LISTEN event processor)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1s
2026-02-23 16:05:18 +00:00
giles
5d930e96f6 Update shared submodule (add device_id migration)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-02-23 15:26:51 +00:00
giles
42acfde023 Update shared: blog_did = account_did, one device identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:12:26 +00:00
giles
77634c513b Update shared: device-id SSO with account_did + Redis login signal
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-23 15:01:51 +00:00
giles
a59bc859cf Sync shared submodule
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:41:33 +00:00
giles
85d18fd418 Update shared: add aiohttp dependency
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 13:05:49 +00:00
giles
e0f4679805 Update shared: device cookie auth state 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-23 12:57:16 +00:00
giles
2d1ed47d2c Update shared: grant-based session revocation
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 12:30:23 +00:00
giles
43ae80ec02 Iframe-based SSO logout (tolerates dead apps)
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 12:21:46 +00:00
giles
28d173d9fd Update shared: remove sso_hint, add sso-clear logout chain
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:17:42 +00:00
giles
4e295e3fbd 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:04 +00:00
giles
66d1727b0f Update shared submodule: account is now OAuth server
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-23 12:01:33 +00:00
giles
938bee5197 Add /auth/clear to reset stale cookies
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:45:27 +00:00
giles
41e13513e1 Logout through federation sso-logout
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-23 11:32:12 +00:00
giles
8526c54840 Silent SSO via sso_hint cookie
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:24:54 +00:00
giles
c226ab99d3 Fix logout redirect to blog home
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-23 11:15:31 +00:00
giles
14de629a29 Fix logout to use local /auth/logout/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:43 +00:00
giles
a53c50feee Sign-in → account, clear old shared cookie
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-23 10:57:11 +00:00
giles
2f55f3762b Trigger rebuild: per-app cookies + OAuth SSO
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:45:20 +00:00
giles
ae52edb928 Fix OAuth authorize URL prefix
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-23 10:25:58 +00:00
giles
c5e2578534 Update shared submodule: OAuth SSO + account app support
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 09:59:07 +00:00
giles
5877d7e382 Update shared submodule (fix root top-bar account link)
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 09:07:52 +00:00
giles
ed6363295a Update shared submodule (account URLs → federation)
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-23 09:01:17 +00:00
giles
2212f3d069 Update shared: auth routes to federation
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-23 08:42:36 +00:00
giles
e5e01dad27 Rename coop config keys to blog/market, update shared submodule
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-23 08:33:26 +00:00
giles
eee50809e8 Update COOP_DIR to /root/rose-ash in CI workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
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:18 +00:00
giles
32ae7fc6cc Update shared submodule — add list_marketplaces
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-22 23:35:16 +00:00
giles
fbd1ce214d Update shared: AP_DOMAIN default to federation.rose-ash.com
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:52 +00:00
giles
ba51b36f87 Update shared: origin_app isolation for EventProcessor
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-22 20:59:44 +00:00
giles
c84e02e623 Update shared: fix AP re-publish versioned object IDs
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 20:04:19 +00:00
giles
b16c97e070 Update shared submodule — restore deleted templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:29:47 +00:00
giles
cb518b9cd0 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:37 +00:00
giles
13798358fc Update shared submodule (remove dead cart template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:05:32 +00:00
giles
89cc2af958 Fix cart sign-in button in app-level template override
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
The cart app has its own _cart.html that overrides the shared one.
The shared copy was fixed but this one still had hx-get on the
cross-origin login link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:03:29 +00:00
giles
8abb0c65d6 Update shared submodule (cart_sid in login URL)
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:46:44 +00:00
giles
c0f9162e07 Update shared submodule (cart sign-in fix)
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-22 17:38:13 +00:00
giles
739ad0451e Fix cart clearing on unpaid checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Cart was soft-deleted when the SumUp checkout was created, before the
user paid. If they navigated back, their cart was empty.

Move clear_cart_for_order into check_sumup_status so it only runs
when the order status transitions to "paid".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:27:33 +00:00
giles
d93e624b45 Switch to unified AP activity bus
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
emit_event → emit_activity for order.created and order.paid events.
Update shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:20:12 +00:00
giles
dfb8651a39 Tech debt cleanup: update README, fix comments, sync shared submodule
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-22 15:35:52 +00:00
giles
62236233b4 Update shared: add fediverse social tables and protocols
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:16:01 +00:00
giles
822c62411f Update shared: fix duplicate AP posts + stable object IDs
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 10:18:24 +00:00
giles
592c48f1cd Update shared: fix AP Delete Tombstone id mismatch
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-22 09:25:59 +00:00
giles
e05de6e1fe Update shared: widget Phase 2 nav templates
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-22 09:14:29 +00:00
giles
5eeb14a457 Update shared: fix AP object id domain for Mastodon
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:53:20 +00:00
giles
fae68ebafa Update shared: inline federation publish + AP delivery fixes
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-22 08:28:11 +00:00
giles
0e66c37a69 Update shared submodule: inline federation publication
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:56:00 +00:00
giles
0d47a094a6 Use SqlFederationService instead of stub
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
StubFederationService silently no-ops federation events when
cart's event processor wins the race to pick them up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:05:14 +00:00
giles
a6cfd02832 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:24 +00:00
giles
41d198630e Update shared submodule
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-21 22:53:39 +00:00
giles
881ea09e71 Update shared submodule
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-21 22:47:07 +00:00
giles
ca9ee83ae4 Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:33:50 +00:00
giles
e5de05dd79 Add startup reconciliation for stale pending orders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
On boot, check SumUp API for any orders stuck in 'pending' that have
a checkout ID. This handles missed webhooks (service down, CSRF
rejection, etc). Runs once via before_serving hook, limited to 50
orders older than 2 minutes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:22:59 +00:00
giles
9df23276a1 Update shared submodule: fix adopt_entries login bug
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-21 21:20:51 +00:00
giles
058f0a1d8a Add csrf_exempt to SumUp webhook endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
SumUp POSTs to /checkout/webhook/ externally with no session,
causing CSRF rejection. Mark endpoint as exempt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:13:00 +00:00
giles
e86c4a0cc8 Fix Decimal+float TypeError in cart total calculation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Product prices are float from DB but calendar/ticket totals are Decimal,
causing TypeError when summed in cart_context. Wrap prices in Decimal().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:11:54 +00:00
giles
e41d22746e Update shared submodule with federation handlers and anchoring
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-21 16:00:10 +00:00
giles
a6f7bfdc7b Wire federation service stub and update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
- 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:21 +00:00
giles
28c10a3411 Fix TypeError: Decimal + float in cart_total computation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
ticket_total() returned float while total() returns Decimal, causing
a crash when summing cart totals. Standardise all total functions to
return Decimal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:36:22 +00:00
giles
fd89426ed9 Group cart tickets by event with +/- quantity buttons
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
- Cart page groups tickets by entry+type instead of listing individually
- Each group shows event name, type, date, qty with +/- buttons, line total
- New POST /cart/ticket-quantity/ route for adjusting from cart page
- Summary includes ticket quantities in Items count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:53:17 +00:00
giles
dc249ea9ce Decoupling audit: remove cart API, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
- Delete bp/cart/api.py (dead internal API endpoint)
- Remove registration from bp/__init__.py and app.py
- Update shared submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:15:28 +00:00
giles
cd41b6c8ef Add ticket-to-cart integration
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Tickets now appear as cart line items, are included in checkout totals,
confirmed on payment, and displayed on the checkout return page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:01:30 +00:00
giles
b3efed2f60 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:13 +00:00
giles
d61b9573d3 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:38 +00:00
giles
32c9c98d74 Update shared submodule: tickets & bookings account pages
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-19 16:07:31 +00:00
giles
0cdc289d00 Update shared submodule: fix category selector highlighting
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-19 15:29:08 +00:00
giles
d2d8156461 Update shared submodule: select_colours Jinja global
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-19 15:17:25 +00:00
giles
52d973ff01 Update shared submodule: fix menu item highlighting
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-19 13:57:11 +00:00
giles
f299805c22 Group cart overview by market, show market name on cards
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
A page can have multiple markets — the overview now groups cart items
by market_place_id instead of page. Each card shows the market name
as heading with the page title below it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:41:28 +00:00
giles
17ab7f09c7 Add delete endpoint with confirm modal, keep items at quantity 0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
- POST /delete/<product_id>/ removes the cart item entirely
- POST /quantity/ now clamps at 0 instead of deleting
- cart_delete_url Jinja global registered for template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:43:35 +00:00
giles
8c72664e1f Update shared submodule pointer to latest template fix
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 09:32:40 +00:00
giles
6140c727c6 Add /quantity/ endpoint so cart +/- buttons work same-origin
Adds POST /quantity/<product_id>/ to set cart item quantity (or remove at 0),
and registers cart_quantity_url Jinja global so the shared template uses it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:31:11 +00:00
49 changed files with 1758 additions and 252 deletions

View File

@@ -8,7 +8,7 @@ env:
REGISTRY: registry.rose-ash.com:5000
IMAGE: cart
REPO_DIR: /root/rose-ash/cart
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 @@ Shopping cart, checkout, and order management service for the Rose Ash cooperati
## 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,15 +20,12 @@ 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, SumUp config
models/ # Cart-domain models
order.py # Order, OrderItem
page_config.py # PageConfig (per-page SumUp credentials)
models/ # Cart-domain models (Order, OrderItem, PageConfig)
bp/
cart/ # Cart blueprint
global_routes.py # Add to cart, checkout, webhooks, return page
page_routes.py # Page-scoped cart and checkout
overview_routes.py # Cart overview / summary page
api.py # Internal API (/internal/cart/summary)
services/ # Business logic
checkout.py # Order creation, SumUp integration
check_sumup_status.py # Payment status polling
@@ -39,31 +37,19 @@ bp/
clear_cart_for_order.py # Soft-delete cart after checkout
order/ # Single order detail view
orders/ # Order listing view
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 cart + calendar + market
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
## Dependencies
## Cross-Domain Communication
**Cross-app model imports:**
- `market.models.market.Product, CartItem`cart services, checkout, API
- `market.models.market_place.MarketPlace` — checkout page-config resolution, API page filtering
- `events.models.calendars.CalendarEntry, Calendar` — checkout, API summary, calendar cart services
- `blog.models.ghost_content.Post``app.py` context processor, API page-slug lookup
- `services.calendar.*` — claim/confirm entries for orders, adopt on login
- `services.market.*`marketplace queries for page-scoped carts
- `services.blog.*` — post lookup for page context
- `shared.services.navigation` — site navigation tree
**Glue services:**
- `glue.services.order_lifecycle.claim_entries_for_order` — checkout marks entries as "ordered"
- `glue.services.order_lifecycle.confirm_entries_for_order` — payment confirmation marks entries "provisional"
- `glue.services.order_lifecycle.get_entries_for_order` — checkout return page loads entries
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
## Domain Events
**Internal APIs:**
- Exposes `GET /internal/cart/summary` — cart count + total for current session/user
**Domain events:**
- `checkout.py` emits `order.created` via `shared.events.emit_event`
- `check_sumup_status.py` emits `order.paid` via `shared.events.emit_event`
@@ -72,11 +58,11 @@ glue/ # Submodule → git.rose-ash.com/coop/glue.git
```
1. User clicks "Checkout"
2. create_order_from_cart() creates Order + OrderItems
3. glue: claim_entries_for_order() marks CalendarEntries as "ordered"
3. services.calendar.claim_entries_for_order() marks entries as "ordered"
4. emit: order.created event
5. SumUp hosted checkout created, user redirected
6. SumUp webhook / return page triggers check_sumup_status()
7. If PAID: glue: confirm_entries_for_order(), emit: order.paid
7. If PAID: services.calendar.confirm_entries_for_order(), emit: order.paid
```
## Running
@@ -86,12 +72,5 @@ export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
hypercorn app:app --reload --bind 0.0.0.0:8002
```
## Docker
```bash
docker build -t cart .
docker run -p 8002:8000 --env-file .env cart
hypercorn app:app --bind 0.0.0.0:8002
```

93
app.py
View File

@@ -1,9 +1,10 @@
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 decimal import Decimal
from pathlib import Path
from quart import g, abort
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
@@ -13,19 +14,23 @@ from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_cart_api,
register_orders,
register_fragments,
)
from bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
)
from bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
async def _load_cart():
@@ -40,42 +45,54 @@ async def cart_context() -> dict:
- cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html)
- menu_items: direct DB query via glue layer
- nav_tree_html: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context()
ctx["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
params={"app_name": "cart", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or []
all_cal = await get_calendar_cart_entries(g.s)
all_tickets = await get_ticket_cart_entries(g.s)
# Global counts for cart-mini (always global)
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
ctx["cart_count"] = cart_qty + len(all_cal)
ctx["cart_total"] = (total(all_cart) or 0) + (calendar_total(all_cal) or 0)
ctx["cart_count"] = cart_qty + len(all_cal) + len(all_tickets)
ctx["cart_total"] = (total(all_cart) or Decimal(0)) + (calendar_total(all_cal) or Decimal(0)) + (ticket_total(all_tickets) or Decimal(0))
# Page-scoped data when viewing a page cart
page_post = getattr(g, "page_post", None)
if page_post:
page_cart = await get_cart_for_page(g.s, page_post.id)
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
page_tickets = await get_tickets_for_page(g.s, page_post.id)
ctx["cart"] = page_cart
ctx["calendar_cart_entries"] = page_cal
ctx["ticket_cart_entries"] = page_tickets
ctx["page_post"] = page_post
ctx["page_config"] = getattr(g, "page_config", None)
else:
ctx["cart"] = all_cart
ctx["calendar_cart_entries"] = all_cal
ctx["ticket_cart_entries"] = all_tickets
ctx["ticket_groups"] = group_tickets(ctx.get("ticket_cart_entries", []))
ctx["total"] = total
ctx["calendar_total"] = calendar_total
ctx["menu_items"] = await get_navigation_tree(g.s)
ctx["ticket_total"] = ticket_total
return ctx
@@ -99,6 +116,11 @@ def create_app() -> "Quart":
app.jinja_loader,
])
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
# --- Page slug hydration (follows events/market app pattern) ---
@app.url_value_preprocessor
@@ -134,9 +156,6 @@ def create_app() -> "Quart":
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
# Internal API (server-to-server, CSRF-exempt)
app.register_blueprint(register_cart_api())
# Orders blueprint
app.register_blueprint(register_orders(url_prefix="/orders"))
@@ -158,6 +177,58 @@ def create_app() -> "Quart":
url_prefix="/<page_slug>",
)
# --- Reconcile stale pending orders on startup ---
@app.before_serving
async def _reconcile_pending_orders():
"""Check SumUp status for orders stuck in 'pending' with a checkout ID.
Handles the case where SumUp webhooks fired while the service was down
or were rejected (e.g. CSRF). Runs once on boot.
"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.db.session import get_session
from shared.models.order import Order
from bp.cart.services.check_sumup_status import check_sumup_status
log = logging.getLogger("cart.reconcile")
try:
async with get_session() as sess:
async with sess.begin():
# Orders that are pending, have a SumUp checkout, and are
# older than 2 minutes (avoid racing with in-flight checkouts)
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
result = await sess.execute(
select(Order)
.where(
Order.status == "pending",
Order.sumup_checkout_id.isnot(None),
Order.created_at < cutoff,
)
.options(selectinload(Order.page_config))
.limit(50)
)
stale_orders = result.scalars().all()
if not stale_orders:
return
log.info("Reconciling %d stale pending orders", len(stale_orders))
for order in stale_orders:
try:
await check_sumup_status(sess, order)
log.info(
"Order %d reconciled: %s",
order.id, order.status,
)
except Exception:
log.exception("Failed to reconcile order %d", order.id)
except Exception:
log.exception("Order reconciliation failed")
return app

View File

@@ -1,6 +1,6 @@
from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .cart.api import register as register_cart_api
from .order.routes import register as register_order
from .orders.routes import register as register_orders
from .fragments import register_fragments

View File

@@ -1,111 +0,0 @@
"""
Internal JSON API for the cart app.
These endpoints are called by other apps (coop, market) over HTTP.
They are CSRF-exempt because they are server-to-server calls.
"""
from __future__ import annotations
from quart import Blueprint, g, request, jsonify
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.browser.app.csrf import csrf_exempt
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services
def register() -> Blueprint:
bp = Blueprint("cart_api", __name__, url_prefix="/internal/cart")
@bp.get("/summary")
@csrf_exempt
async def summary():
"""
Return a lightweight cart summary (count + total) for the
current session/user. Called by coop and market apps to
populate the cart-mini widget without importing cart services.
Optional query param: ?page_slug=<slug>
When provided, returns only items scoped to that page.
"""
ident = current_cart_identity()
# Resolve optional page filter
page_slug = request.args.get("page_slug")
page_post_id = None
if page_slug:
post = await services.blog.get_post_by_slug(g.s, page_slug)
if post and post.is_page:
page_post_id = post.id
# --- product cart ---
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
if ident["user_id"] is not None:
cart_q = cart_q.where(CartItem.user_id == ident["user_id"])
else:
cart_q = cart_q.where(CartItem.session_id == ident["session_id"])
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
cart_q = cart_q.options(selectinload(CartItem.product)).order_by(CartItem.created_at.desc())
result = await g.s.execute(cart_q)
cart_items = result.scalars().all()
cart_count = sum(ci.quantity for ci in cart_items)
cart_total = sum(
(ci.product.special_price or ci.product.regular_price or 0) * ci.quantity
for ci in cart_items
if ci.product and (ci.product.special_price or ci.product.regular_price)
)
# --- calendar entries via service ---
if page_post_id is not None:
cal_entries = await services.calendar.entries_for_page(
g.s, page_post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
else:
cal_entries = await services.calendar.pending_entries(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
calendar_count = len(cal_entries)
calendar_total = sum((e.cost or 0) for e in cal_entries if e.cost is not None)
items = [
{
"slug": ci.product.slug if ci.product else None,
"title": ci.product.title if ci.product else None,
"image": ci.product.image if ci.product else None,
"quantity": ci.quantity,
"price": float(ci.product.special_price or ci.product.regular_price or 0)
if ci.product
else 0,
}
for ci in cart_items
]
return jsonify(
{
"count": cart_count,
"total": float(cart_total),
"calendar_count": calendar_count,
"calendar_total": float(calendar_total),
"items": items,
}
)
return bp

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select
from shared.models.market import CartItem
from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.services.registry import services
@@ -12,9 +13,10 @@ from .services import (
current_cart_identity,
get_cart,
total,
clear_cart_for_order,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
check_sumup_status,
)
from .services.checkout import (
@@ -28,6 +30,7 @@ from .services.checkout import (
get_order_with_details,
)
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.browser.app.csrf import csrf_exempt
def register(url_prefix: str) -> Blueprint:
@@ -53,24 +56,96 @@ def register(url_prefix: str) -> Blueprint:
return redirect(url_for("cart_overview.overview"))
@bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int):
ident = current_cart_identity()
form = await request.form
count = int(form.get("count", 0))
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
existing = await g.s.scalar(select(CartItem).where(*filters))
if existing:
existing.quantity = max(count, 0)
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/ticket-quantity/")
async def update_ticket_quantity():
"""Adjust reserved ticket count (+/- pattern, like products)."""
ident = current_cart_identity()
form = await request.form
entry_id = int(form.get("entry_id", 0))
count = max(int(form.get("count", 0)), 0)
tt_raw = (form.get("ticket_type_id") or "").strip()
ticket_type_id = int(tt_raw) if tt_raw else None
await services.calendar.adjust_ticket_quantity(
g.s, entry_id, count,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/delete/<int:product_id>/")
async def delete_item(product_id: int):
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
existing = await g.s.scalar(select(CartItem).where(*filters))
if existing:
await g.s.delete(existing)
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/checkout/")
async def checkout():
"""Legacy global checkout (for orphan items without page scope)."""
cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s)
tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries:
if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.overview"))
product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0
cart_total = product_total + calendar_amount
ticket_amount = ticket_total(tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("cart_overview.overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries)
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e:
html = await render_template(
"_types/cart/checkout_error.html",
@@ -88,6 +163,7 @@ def register(url_prefix: str) -> Blueprint:
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
)
if page_config:
@@ -95,7 +171,7 @@ def register(url_prefix: str) -> Blueprint:
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id)
description = build_sumup_description(cart, order.id, ticket_count=len(tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
@@ -107,8 +183,6 @@ def register(url_prefix: str) -> Blueprint:
description=description,
page_config=page_config,
)
await clear_cart_for_order(g.s, order)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
@@ -129,6 +203,7 @@ def register(url_prefix: str) -> Blueprint:
return redirect(hosted_url)
@csrf_exempt
@bp.post("/checkout/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int):
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
@@ -204,6 +279,7 @@ def register(url_prefix: str) -> Blueprint:
status = (order.status or "pending").lower()
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id)
await g.s.flush()
html = await render_template(
@@ -211,6 +287,7 @@ def register(url_prefix: str) -> Blueprint:
order=order,
status=status,
calendar_entries=calendar_entries,
order_tickets=order_tickets,
)
return await make_response(html)

View File

@@ -9,10 +9,11 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
from shared.config import config
from .services import (
total,
clear_cart_for_order,
calendar_total,
ticket_total,
)
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
from .services.ticket_groups import group_tickets
from .services.checkout import (
create_order_from_cart,
build_sumup_description,
@@ -30,14 +31,20 @@ def register(url_prefix: str) -> Blueprint:
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
tpl_ctx = dict(
page_post=post,
page_config=getattr(g, "page_config", None),
cart=cart,
calendar_cart_entries=cal_entries,
ticket_cart_entries=page_tickets,
ticket_groups=ticket_groups,
total=total,
calendar_total=calendar_total,
ticket_total=ticket_total,
)
if not is_htmx_request():
@@ -53,13 +60,15 @@ def register(url_prefix: str) -> Blueprint:
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries:
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
cart_total = product_total + calendar_amount
ticket_amount = ticket_total(page_tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.page_view"))
@@ -74,6 +83,7 @@ def register(url_prefix: str) -> Blueprint:
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
page_post_id=post.id,
)
@@ -84,7 +94,7 @@ def register(url_prefix: str) -> Blueprint:
# Build SumUp checkout details — webhook/return use global routes
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id)
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
@@ -96,8 +106,6 @@ def register(url_prefix: str) -> Blueprint:
description=description,
page_config=page_config,
)
await clear_cart_for_order(g.s, order, page_post_id=post.id)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")

View File

@@ -2,11 +2,12 @@ from .get_cart import get_cart
from .identity import current_cart_identity
from .total import total
from .clear_cart_for_order import clear_cart_for_order
from .calendar_cart import get_calendar_cart_entries, calendar_total
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
from .check_sumup_status import check_sumup_status
from .page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_tickets_for_page,
get_cart_grouped_by_page,
)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from decimal import Decimal
from shared.services.registry import services
from .identity import current_cart_identity
@@ -17,12 +19,27 @@ async def get_calendar_cart_entries(session):
)
def calendar_total(entries) -> float:
def calendar_total(entries) -> Decimal:
"""
Total cost of pending calendar entries.
"""
return sum(
(e.cost or 0)
(Decimal(str(e.cost)) if e.cost else Decimal(0))
for e in entries
if e.cost is not None
)
async def get_ticket_cart_entries(session):
"""Return all reserved tickets (as TicketDTOs) for the current identity."""
ident = current_cart_identity()
return await services.calendar.pending_tickets(
session,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
def ticket_total(tickets) -> Decimal:
"""Total cost of reserved tickets."""
return sum((Decimal(str(t.price)) if t.price else Decimal(0) for t in tickets), Decimal(0))

View File

@@ -1,6 +1,7 @@
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_event
from shared.events import emit_activity
from shared.services.registry import services
from .clear_cart_for_order import clear_cart_for_order
async def check_sumup_status(session, order):
@@ -16,10 +17,24 @@ async def check_sumup_status(session, order):
await services.calendar.confirm_entries_for_order(
session, order.id, order.user_id, order.session_id
)
await emit_event(session, "order.paid", "order", order.id, {
await services.calendar.confirm_tickets_for_order(session, order.id)
# Clear cart only after payment is confirmed
page_post_id = page_config.container_id if page_config else None
await clear_cart_for_order(session, order, page_post_id=page_post_id)
await emit_activity(
session,
activity_type="rose:OrderPaid",
actor_uri="internal:cart",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": order.user_id,
})
},
source_type="order",
source_id=order.id,
)
elif sumup_status == "FAILED":
order.status = "failed"
else:

View File

@@ -13,7 +13,7 @@ from shared.models.page_config import PageConfig
from shared.models.market_place import MarketPlace
from shared.config import config
from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_event
from shared.events import emit_activity
from shared.services.registry import services
@@ -65,6 +65,7 @@ async def resolve_page_config(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntryDTO],
tickets=None,
) -> Optional["PageConfig"]:
"""Determine the PageConfig for this order.
@@ -85,6 +86,11 @@ async def resolve_page_config(
if entry.calendar_container_id:
post_ids.add(entry.calendar_container_id)
# From tickets via calendar_container_id
for tk in (tickets or []):
if tk.calendar_container_id:
post_ids.add(tk.calendar_container_id)
if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages")
@@ -110,16 +116,17 @@ async def create_order_from_cart(
product_total: float,
calendar_total: float,
*,
ticket_total: float = 0,
page_post_id: int | None = None,
) -> Order:
"""
Create an Order and OrderItems from the current cart + calendar entries.
Create an Order and OrderItems from the current cart + calendar entries + tickets.
When *page_post_id* is given, only calendar entries whose calendar
When *page_post_id* is given, only calendar entries/tickets whose calendar
belongs to that page are marked as "ordered". Otherwise all pending
entries are updated (legacy behaviour).
"""
cart_total = product_total + calendar_total
cart_total = product_total + calendar_total + ticket_total
# Determine currency from first product
first_product = cart[0].product if cart else None
@@ -154,29 +161,46 @@ async def create_order_from_cart(
session, order.id, user_id, session_id, page_post_id
)
await emit_event(session, "order.created", "order", order.id, {
# Claim reserved tickets for this order
await services.calendar.claim_tickets_for_order(
session, order.id, user_id, session_id, page_post_id
)
await emit_activity(
session,
activity_type="Create",
actor_uri="internal:cart",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
})
},
source_type="order",
source_id=order.id,
)
return order
def build_sumup_description(cart: list[CartItem], order_id: int) -> str:
def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str:
"""Build a human-readable description for SumUp checkout."""
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
item_count = sum(ci.quantity for ci in cart)
parts = []
if titles:
if len(titles) <= 3:
summary = ", ".join(titles)
parts.append(", ".join(titles))
else:
summary = ", ".join(titles[:3]) + f" + {len(titles) - 3} more"
else:
summary = "order items"
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
if ticket_count:
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}"
summary = ", ".join(parts) if parts else "order items"
total_count = item_count + ticket_count
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
def build_sumup_reference(order_id: int, page_config=None) -> str:

View File

@@ -57,15 +57,26 @@ async def get_calendar_entries_for_page(session, post_id: int):
)
async def get_tickets_for_page(session, post_id: int):
"""Return reserved tickets (DTOs) scoped to a specific page."""
ident = current_cart_identity()
return await services.calendar.tickets_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
async def get_cart_grouped_by_page(session) -> list[dict]:
"""
Load all cart items + calendar entries for the current identity,
grouped by owning page (post_id).
grouped by market_place (one card per market).
Returns a list of dicts:
{
"post": Post | None,
"page_config": PageConfig | None,
"market_place": MarketPlace | None,
"cart_items": [...],
"calendar_entries": [...],
"product_count": int,
@@ -75,41 +86,83 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
"total": float,
}
Calendar entries (no market concept) attach to a page-level group.
Items without a market_place go in an orphan bucket (post=None).
"""
from .get_cart import get_cart
from .calendar_cart import get_calendar_cart_entries
from .calendar_cart import get_calendar_cart_entries, get_ticket_cart_entries
from .total import total as calc_product_total
from .calendar_cart import calendar_total as calc_calendar_total
from .calendar_cart import calendar_total as calc_calendar_total, ticket_total as calc_ticket_total
cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session)
all_tickets = await get_ticket_cart_entries(session)
# Group by container_id (all current data has container_type="page")
groups: dict[int | None, dict] = defaultdict(lambda: {
"post_id": None,
# Group cart items by market_place_id
market_groups: dict[int | None, dict] = {}
for ci in cart_items:
mp_id = ci.market_place_id if ci.market_place else None
if mp_id not in market_groups:
market_groups[mp_id] = {
"market_place": ci.market_place,
"post_id": ci.market_place.container_id if ci.market_place else None,
"cart_items": [],
"calendar_entries": [],
})
"tickets": [],
}
market_groups[mp_id]["cart_items"].append(ci)
for ci in cart_items:
if ci.market_place and ci.market_place.container_id:
pid = ci.market_place.container_id
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["cart_items"].append(ci)
# Attach calendar entries to an existing market group for the same page,
# or create a page-level group if no market group exists for that page.
page_to_market: dict[int | None, int | None] = {}
for mp_id, grp in market_groups.items():
pid = grp["post_id"]
if pid is not None and pid not in page_to_market:
page_to_market[pid] = mp_id
for ce in cal_entries:
if ce.calendar_container_id:
pid = ce.calendar_container_id
pid = ce.calendar_container_id or None
if pid in page_to_market:
market_groups[page_to_market[pid]]["calendar_entries"].append(ce)
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["calendar_entries"].append(ce)
# Create a page-level group for calendar-only entries
key = ("cal", pid)
if key not in market_groups:
market_groups[key] = {
"market_place": None,
"post_id": pid,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
if pid is not None:
page_to_market[pid] = key
market_groups[key]["calendar_entries"].append(ce)
# Attach tickets to page groups (via calendar_container_id)
for tk in all_tickets:
pid = tk.calendar_container_id or None
if pid in page_to_market:
market_groups[page_to_market[pid]]["tickets"].append(tk)
else:
key = ("tk", pid)
if key not in market_groups:
market_groups[key] = {
"market_place": None,
"post_id": pid,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
if pid is not None:
page_to_market[pid] = key
market_groups[key]["tickets"].append(tk)
# Batch-load Post DTOs and PageConfig objects
post_ids = [pid for pid in groups if pid is not None]
post_ids = list({
grp["post_id"] for grp in market_groups.values()
if grp["post_id"] is not None
})
posts_by_id: dict[int, object] = {}
configs_by_post: dict[int, PageConfig] = {}
@@ -126,25 +179,34 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
for pc in pc_result.scalars().all():
configs_by_post[pc.container_id] = pc
# Build result list (pages first, orphan last)
# Build result list (markets with pages first, orphan last)
result = []
for pid in sorted(groups, key=lambda x: (x is None, x)):
grp = groups[pid]
for _key, grp in sorted(
market_groups.items(),
key=lambda kv: (kv[1]["post_id"] is None, kv[1]["post_id"] or 0),
):
items = grp["cart_items"]
entries = grp["calendar_entries"]
tks = grp["tickets"]
prod_total = calc_product_total(items) or 0
cal_total = calc_calendar_total(entries) or 0
tk_total = calc_ticket_total(tks) or 0
pid = grp["post_id"]
result.append({
"post": posts_by_id.get(pid) if pid else None,
"page_config": configs_by_post.get(pid) if pid else None,
"market_place": grp["market_place"],
"cart_items": items,
"calendar_entries": entries,
"tickets": tks,
"product_count": sum(ci.quantity for ci in items),
"product_total": prod_total,
"calendar_count": len(entries),
"calendar_total": cal_total,
"total": prod_total + cal_total,
"ticket_count": len(tks),
"ticket_total": tk_total,
"total": prod_total + cal_total + tk_total,
})
return result

View File

@@ -0,0 +1,43 @@
"""Group individual TicketDTOs by (entry_id, ticket_type_id) for cart display."""
from __future__ import annotations
from collections import OrderedDict
def group_tickets(tickets) -> list[dict]:
"""
Group a flat list of TicketDTOs into aggregate rows.
Returns list of dicts:
{
"entry_id": int,
"entry_name": str,
"entry_start_at": datetime,
"entry_end_at": datetime | None,
"ticket_type_id": int | None,
"ticket_type_name": str | None,
"price": Decimal | None,
"quantity": int,
"line_total": float,
}
"""
groups: OrderedDict[tuple, dict] = OrderedDict()
for tk in tickets:
key = (tk.entry_id, getattr(tk, "ticket_type_id", None))
if key not in groups:
groups[key] = {
"entry_id": tk.entry_id,
"entry_name": tk.entry_name,
"entry_start_at": tk.entry_start_at,
"entry_end_at": tk.entry_end_at,
"ticket_type_id": getattr(tk, "ticket_type_id", None),
"ticket_type_name": tk.ticket_type_name,
"price": tk.price,
"quantity": 0,
"line_total": 0,
}
groups[key]["quantity"] += 1
groups[key]["line_total"] += float(tk.price or 0)
return list(groups.values())

View File

@@ -1,6 +1,12 @@
from decimal import Decimal
def total(cart):
return sum(
(item.product.special_price or item.product.regular_price) * item.quantity
(
Decimal(str(item.product.special_price or item.product.regular_price))
* item.quantity
)
for item in cart
if (item.product.special_price or item.product.regular_price) is not None
)

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

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

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

@@ -0,0 +1,70 @@
"""Cart app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Fragments:
cart-mini Cart icon with badge (or logo when empty)
account-nav-item "orders" link for account dashboard
"""
from __future__ import annotations
from quart import Blueprint, Response, request, render_template, g
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# ---------------------------------------------------------------
# Fragment handlers
# ---------------------------------------------------------------
async def _cart_mini():
from shared.services.registry import services
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id,
)
count = summary.count + summary.calendar_count + summary.ticket_count
return await render_template("fragments/cart_mini.html", cart_count=count)
async def _account_nav_item():
from shared.infrastructure.urls import cart_url
href = cart_url("/orders/")
return (
'<div class="relative nav-group">'
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
'orders</a></div>'
)
_handlers = {
"cart-mini": _cart_mini,
"account-nav-item": _account_nav_item,
}
# ---------------------------------------------------------------
# Routing
# ---------------------------------------------------------------
@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")
return bp

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

@@ -23,3 +23,6 @@ def register_domain_services() -> None:
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

2
shared

Submodule shared updated: b3a0e9922a...9ab4b7b3fe

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(account_url('/'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% macro show_cart(oob=False) %}
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
{# Empty cart #}
{% if not cart and not calendar_cart_entries %}
{% if not cart and not calendar_cart_entries and not ticket_cart_entries %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
@@ -60,8 +60,106 @@
</ul>
</div>
{% endif %}
{% if ticket_groups is defined and ticket_groups %}
<div class="mt-6 border-t border-stone-200 pt-4">
<h2 class="text-base font-semibold mb-2">
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
Event tickets
</h2>
<div class="space-y-3">
{% for tg in ticket_groups %}
<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">
<div class="flex-1 min-w-0">
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
<div class="min-w-0">
<h3 class="text-sm sm:text-base font-semibold text-stone-900">
{{ tg.entry_name }}
</h3>
{% if tg.ticket_type_name %}
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
{{ tg.ticket_type_name }}
</p>
{% endif %}
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
{{ tg.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
{% if tg.entry_end_at %}
{{ tg.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
{% endif %}
</p>
</div>
<div class="text-left sm:text-right">
<p class="text-sm sm:text-base font-semibold text-stone-900">
£{{ "%.2f"|format(tg.price or 0) }}
</p>
</div>
</div>
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
<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>
{% set qty_url = url_for('cart_global.update_ticket_quantity') %}
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
{% if tg.ticket_type_id %}
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
{% endif %}
<input type="hidden" name="count" value="{{ [tg.quantity - 1, 0] | max }}">
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
-
</button>
</form>
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
{{ tg.quantity }}
</span>
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
{% if tg.ticket_type_id %}
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
{% endif %}
<input type="hidden" name="count" value="{{ tg.quantity + 1 }}">
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
+
</button>
</form>
</div>
<div class="flex items-center justify-between sm:justify-end gap-3">
<p class="text-sm sm:text-base font-semibold text-stone-900">
Line total:
£{{ "%.2f"|format(tg.line_total) }}
</p>
</div>
</div>
</div>
</article>
{% endfor %}
</div>
</div>
{% endif %}
</section>
{{summary(cart, total, calendar_total, calendar_cart_entries,)}}
{{summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries)}}
</div>
@@ -70,7 +168,7 @@
{% endmacro %}
{% macro summary(cart, total, calendar_total, calendar_cart_entries, oob=False) %}
{% macro summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries, oob=False) %}
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
<div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
@@ -81,13 +179,15 @@
<div class="flex items-center justify-between">
<dt class="text-stone-600">Items</dt>
<dd class="text-stone-900">
{{ cart | sum(attribute="quantity") }}
{% set product_qty = cart | sum(attribute="quantity") %}
{% set ticket_qty = ticket_cart_entries | length if ticket_cart_entries else 0 %}
{{ product_qty + ticket_qty }}
</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-stone-600">Subtotal</dt>
<dd class="text-stone-900">
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries ) }}
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) }}
</dd>
</div>
</dl>
@@ -117,23 +217,13 @@
</form>
{% else %}
{% set href=login_url(request.url) %}
<div
class="w-full flex"
>
<div class="w-full flex">
<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 local_href == request.path else 'false' }}"
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
data-close-details
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
>
<i class="fa-solid fa-key"></i>
<span>sign in or register to checkout</span>
</a>
</div>
@@ -154,10 +244,11 @@
{% endmacro %}
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries) %}
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) %}
{% set product_total = total(cart) or 0 %}
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
{% set grand = product_total + cal_total %}
{% set tk_total = ticket_total(ticket_cart_entries) or 0 %}
{% set grand = product_total + cal_total + tk_total %}
{% if cart and cart[0].product.regular_price_currency %}
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}

View File

@@ -0,0 +1,4 @@
<div class="max-w-full px-3 py-3 space-y-3">
{% from '_types/cart/_cart.html' import show_cart with context %}
{{ show_cart() }}
</div>

View File

@@ -0,0 +1,45 @@
{% macro mini(oob=False, count=None) %}
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} >
{# cart_count is set by the context processor in all apps.
Cart app computes it from g.cart + calendar_cart_entries;
other apps get it from the cart internal API.
count param allows explicit override when macro is imported without context. #}
{% if count is not none %}
{% set _count = count %}
{% elif cart_count is defined and cart_count is not none %}
{% set _count = cart_count %}
{% elif cart is defined and cart is not none %}
{% set _count = (cart | sum(attribute="quantity")) + ((calendar_cart_entries | length) if calendar_cart_entries else 0) %}
{% else %}
{% set _count = 0 %}
{% endif %}
{% if _count == 0 %}
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
<a
href="{{ blog_url('/') }}"
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
>
<img
src="{{ site().logo }}"
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
>
</a>
</div>
{% else %}
<a
href="{{ cart_url('/') }}"
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
>
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
>
{{ _count }}
</span>
</a>
{% endif %}
</div>
{% endmacro %}

View File

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

View File

@@ -0,0 +1,28 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{% 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('root-header-child', 'cart-header-child', '_types/cart/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/cart/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/cart/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends '_types/root/index.html' %}
{% block filter %}
<header class="mb-6 sm:mb-8">
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
Checkout error
</h1>
<p class="text-xs sm:text-sm text-stone-600">
We tried to start your payment with SumUp but hit a problem.
</p>
</header>
{% endblock %}
{% block content %}
<div class="max-w-full px-3 py-3 space-y-4">
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
<p class="font-medium">Something went wrong.</p>
<p>
{{ error or "Unexpected error while creating the hosted checkout session." }}
</p>
{% if order %}
<p class="text-xs text-rose-800/80">
Order ID: <span class="font-mono">#{{ order.id }}</span>
</p>
{% endif %}
</div>
<div>
<a
href="{{ cart_url('/') }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>
Back to cart
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends '_types/root/index.html' %}
{% block filter %}
<header class="mb-1 sm:mb-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div class="space-y-1">
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
{% if order.status == 'paid' %}
Payment received
{% elif order.status == 'failed' %}
Payment failed
{% elif order.status == 'missing' %}
Order not found
{% else %}
Payment status: {{ order.status|default('pending')|capitalize }}
{% endif %}
</h1>
<p class="text-xs sm:text-sm text-stone-600">
{% if order.status == 'paid' %}
Thanks for your order.
{% elif order.status == 'failed' %}
Something went wrong while processing your payment. You can try again below.
{% elif order.status == 'missing' %}
We couldn't find that order it may have expired or never been created.
{% else %}
Were still waiting for a final confirmation from SumUp.
{% endif %}
</p>
</div>
</header>
{% endblock %}
{% block aside %}
{# no aside content for now #}
{% endblock %}
{% block content %}
<div class="max-w-full px-1 py-1">
{% if order %}
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2">
{% include '_types/order/_summary.html' %}
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-rose-300 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-800">
We couldnt find that order. If you reached this page from an old link, please start a new order.
</div>
{% endif %}
{% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %}
{% include '_types/order/_ticket_items.html' %}
{% if order.status == 'failed' and order %}
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
<p class="font-medium">Your payment was not completed.</p>
<p>
You can go back to your cart and try checkout again. If the problem persists,
please contact us and mention order <span class="font-mono">#{{ order.id }}</span>.
</p>
</div>
{% elif order.status == 'paid' %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 sm:p-6 text-sm text-emerald-900 space-y-2">
<p class="font-medium">All done!</p>
<p>Well start processing your order shortly.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='cart-row', oob=oob) %}
{% call links.link(cart_url('/'), hx_select_search ) %}
<i class="fa fa-shopping-cart"></i>
<h2 class="text-xl font-bold">cart</h2>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/cart/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,22 @@
{% extends '_types/root/_index.html' %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('cart-header-child', '_types/cart/header/_header.html') %}
{% block cart_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/cart/_nav.html' %}
{% endblock %}
{% block aside %}
{% endblock %}
{% block content %}
{% include '_types/cart/_main_panel.html' %}
{% endblock %}

View File

@@ -13,7 +13,7 @@
{# Check if there are any items at all across all groups #}
{% set ns = namespace(has_items=false) %}
{% for grp in page_groups %}
{% if grp.cart_items or grp.calendar_entries %}
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
{% set ns.has_items = true %}
{% endif %}
{% endfor %}
@@ -30,10 +30,10 @@
{% else %}
<div class="space-y-4">
{% for grp in page_groups %}
{% if grp.cart_items or grp.calendar_entries %}
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
{% if grp.post %}
{# Page cart card #}
{# Market / page cart card #}
<a
href="{{ cart_url('/' + grp.post.slug + '/') }}"
class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
@@ -53,8 +53,15 @@
<div class="flex-1 min-w-0">
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
{% if grp.market_place %}
{{ grp.market_place.name }}
{% else %}
{{ grp.post.title }}
{% endif %}
</h3>
{% if grp.market_place %}
<p class="text-xs text-stone-500 truncate">{{ grp.post.title }}</p>
{% endif %}
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
{% if grp.product_count > 0 %}
@@ -69,6 +76,12 @@
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
</span>
{% endif %}
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">
<i class="fa fa-ticket" aria-hidden="true"></i>
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
</span>
{% endif %}
</div>
</div>
@@ -108,6 +121,12 @@
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
</span>
{% endif %}
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100">
<i class="fa fa-ticket" aria-hidden="true"></i>
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
</span>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,43 @@
{# --- NEW: calendar bookings in this order --- #}
{% if order and calendar_entries %}
<section class="mt-6 space-y-3">
<h2 class="text-base sm:text-lg font-semibold">
Calendar bookings in this order
</h2>
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
{% for entry in calendar_entries %}
<li class="px-4 py-3 flex items-start justify-between text-sm">
<div>
<div class="font-medium flex items-center gap-2">
{{ entry.name }}
{# Small status pill #}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if entry.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif entry.state == 'provisional' %}
bg-amber-100 text-amber-800
{% elif entry.state == 'ordered' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ entry.state|capitalize }}
</span>
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%-d %b %Y, %H:%M') }}
{% if entry.end_at %}
{{ entry.end_at.strftime('%-d %b %Y, %H:%M') }}
{% endif %}
</div>
</div>
<div class="ml-4 font-medium">
£{{ "%.2f"|format(entry.cost or 0) }}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -0,0 +1,51 @@
{# Items list #}
{% if order and order.items %}
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">
<h2 class="text-sm sm:text-base font-semibold mb-3">
Items
</h2>
<ul class="divide-y divide-stone-100 text-xs sm:text-sm">
{% for item in order.items %}
<li>
<a class="w-full py-2 flex gap-3" href="{{ market_product_url(item.product.slug) }}">
{# Thumbnail #}
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">
{% if item.product and item.product.image %}
<img
src="{{ item.product.image }}"
alt="{{ item.product_title or item.product.title or 'Product image' }}"
class="w-full h-full object-contain object-center"
loading="lazy"
decoding="async"
>
{% else %}
<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">
No image
</div>
{% endif %}
</div>
{# Text + pricing #}
<div class="flex-1 flex justify-between gap-3">
<div>
<p class="font-medium">
{{ item.product_title or (item.product and item.product.title) or 'Unknown product' }}
</p>
<p class="text-[11px] text-stone-500">
Product ID: {{ item.product_id }}
</p>
</div>
<div class="text-right whitespace-nowrap">
<p>Qty: {{ item.quantity }}</p>
<p>
{{ item.currency or order.currency or 'GBP' }}
{{ '%.2f'|format(item.unit_price or 0) }}
</p>
</div>
</div>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -0,0 +1,7 @@
<div class="max-w-full px-3 py-3 space-y-4">
{# Order summary card #}
{% include '_types/order/_summary.html' %}
{% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %}
</div>

View File

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

View File

@@ -0,0 +1,30 @@
{% 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 %}
{# 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('orders-header-child', 'order-header-child', '_types/order/header/_header.html')}}
{% from '_types/order/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/order/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/order/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,52 @@
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800">
<p>
<span class="font-medium">Order ID:</span>
<span class="font-mono">#{{ order.id }}</span>
</p>
<p>
<span class="font-medium">Created:</span>
{% if order.created_at %}
{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}
{% else %}
{% endif %}
</p>
<p>
<span class="font-medium">Description:</span>
{{ order.description or '' }}
</p>
<p>
<span class="font-medium">Status:</span>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium
{% if order.status == 'paid' %}
bg-emerald-50 text-emerald-700 border border-emerald-200
{% elif order.status == 'failed' %}
bg-rose-50 text-rose-700 border border-rose-200
{% else %}
bg-stone-50 text-stone-700 border border-stone-200
{% endif %}
">
{{ order.status or 'pending' }}
</span>
</p>
<p>
<span class="font-medium">Currency:</span>
{{ order.currency or 'GBP' }}
</p>
<p>
<span class="font-medium">Total:</span>
{% if order.total_amount %}
{{ order.currency or 'GBP' }} {{ '%.2f'|format(order.total_amount) }}
{% else %}
{% endif %}
</p>
</div>

View File

@@ -0,0 +1,49 @@
{# --- Tickets in this order --- #}
{% if order and order_tickets %}
<section class="mt-6 space-y-3">
<h2 class="text-base sm:text-lg font-semibold">
Event tickets in this order
</h2>
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
{% for tk in order_tickets %}
<li class="px-4 py-3 flex items-start justify-between text-sm">
<div>
<div class="font-medium flex items-center gap-2">
{{ tk.entry_name }}
{# Small status pill #}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if tk.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif tk.state == 'reserved' %}
bg-amber-100 text-amber-800
{% elif tk.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ tk.state|replace('_', ' ')|capitalize }}
</span>
</div>
{% if tk.ticket_type_name %}
<div class="text-xs text-stone-500">{{ tk.ticket_type_name }}</div>
{% endif %}
<div class="text-xs text-stone-500">
{{ tk.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
{% if tk.entry_end_at %}
{{ tk.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
{% endif %}
</div>
<div class="text-xs text-stone-400 font-mono mt-0.5">
{{ tk.code }}
</div>
</div>
<div class="ml-4 font-medium">
£{{ "%.2f"|format(tk.price or 0) }}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -0,0 +1,17 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='order-row', oob=oob) %}
{% call links.link(url_for('orders.order.order_detail', order_id=order.id), hx_select_search ) %}
<i class="fa fa-gbp" aria-hidden="true"></i>
<div>
Order
</div>
<div>
{{ order.id }}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/order/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,68 @@
{% extends '_types/orders/index.html' %}
{% block orders_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('order-header-child', '_types/order/header/_header.html') %}
{% block order_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/order/_nav.html' %}
{% endblock %}
{% block filter %}
<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div class="space-y-1">
<p class="text-xs sm:text-sm text-stone-600">
Placed {% if order.created_at %}{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}{% else %}—{% endif %} &middot; Status: {{ order.status or 'pending' }}
</p>
</div>
<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">
<a
href="{{ url_for('orders.list_orders')|host }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa-solid fa-list mr-2" aria-hidden="true"></i>
All orders
</a>
{# Re-check status button #}
<form
method="post"
action="{{ url_for('orders.order.order_recheck', order_id=order.id)|host }}"
class="inline"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
Re-check status
</button>
</form>
{% if order.status != 'paid' %}
<a
href="{{ url_for('orders.order.order_pay', order_id=order.id)|host }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
>
<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>
Open payment page
</a>
{% endif %}
</div>
</header>
{% endblock %}
{% block content %}
{% include '_types/order/_main_panel.html' %}
{% endblock %}
{% block aside %}
{% endblock %}

View File

@@ -0,0 +1,26 @@
<div class="max-w-full px-3 py-3 space-y-3">
{% if not orders %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">
No orders yet.
</div>
{% else %}
<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">
<table class="min-w-full text-xs sm:text-sm">
<thead class="bg-stone-50 border-b border-stone-200 text-stone-600">
<tr>
<th class="px-3 py-2 text-left font-medium">Order</th>
<th class="px-3 py-2 text-left font-medium">Created</th>
<th class="px-3 py-2 text-left font-medium">Description</th>
<th class="px-3 py-2 text-left font-medium">Total</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{# rows + infinite-scroll sentinel #}
{% include "_types/orders/_rows.html" %}
</tbody>
</table>
</div>
{% endif %}
</div>

View File

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

View File

@@ -0,0 +1,38 @@
{% 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 %}
{# 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('auth-header-child', 'orders-header-child', '_types/orders/header/_header.html')}}
{% from '_types/auth/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block aside %}
{% from 'macros/search.html' import search_desktop %}
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% endblock %}
{% block filter %}
{% include '_types/orders/_summary.html' %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/orders/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/orders/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,164 @@
{# suma_browser/templates/_types/order/_orders_rows.html #}
{# --- existing rows, but split into desktop/tablet vs mobile --- #}
{% for order in orders %}
{# Desktop / tablet table row #}
<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">
<td class="px-3 py-2 align-top">
<span class="font-mono text-[11px] sm:text-xs">#{{ order.id }}</span>
</td>
<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">
{% if order.created_at %}
{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}
{% else %}
{% endif %}
</td>
<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">
{{ order.description or '' }}
</td>
<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">
{{ order.currency or 'GBP' }}
{{ '%.2f'|format(order.total_amount or 0) }}
</td>
<td class="px-3 py-2 align-top">
{# status pill, roughly matching existing styling #}
<span
class="
inline-flex items-center rounded-full border px-2 py-0.5
text-[11px] sm:text-xs
{% if (order.status or '').lower() == 'paid' %}
border-emerald-300 bg-emerald-50 text-emerald-700
{% elif (order.status or '').lower() in ['failed', 'cancelled'] %}
border-rose-300 bg-rose-50 text-rose-700
{% else %}
border-stone-300 bg-stone-50 text-stone-700
{% endif %}
"
>
{{ order.status or 'pending' }}
</span>
</td>
<td class="px-3 py-0.5 align-top text-right">
<a
href="{{ url_for('orders.order.order_detail', order_id=order.id)|host }}"
class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
View
</a>
</td>
</tr>
{# Mobile card row #}
<tr class="sm:hidden border-t border-stone-100">
<td colspan="5" class="px-3 py-3">
<div class="flex flex-col gap-2 text-xs">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-[11px] text-stone-700">
#{{ order.id }}
</span>
<span
class="
inline-flex items-center rounded-full border px-2 py-0.5
text-[11px]
{% if (order.status or '').lower() == 'paid' %}
border-emerald-300 bg-emerald-50 text-emerald-700
{% elif (order.status or '').lower() in ['failed', 'cancelled'] %}
border-rose-300 bg-rose-50 text-rose-700
{% else %}
border-stone-300 bg-stone-50 text-stone-700
{% endif %}
"
>
{{ order.status or 'pending' }}
</span>
</div>
<div class="text-[11px] text-stone-500 break-words">
{{ order.created_at or '' }}
</div>
<div class="flex items-center justify-between gap-2">
<div class="font-medium text-stone-800">
{{ order.currency or 'GBP' }}
{{ '%.2f'|format(order.total_amount or 0) }}
</div>
<a
href="{{ url_for('orders.order.order_detail', order_id=order.id)|host }}"
class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0"
>
View
</a>
</div>
</div>
</td>
</tr>
{% endfor %}
{# --- sentinel / end-of-results --- #}
{% if page < total_pages|int %}
<tr
id="orders-sentinel-{{ page }}"
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 sentinel:retry
remove .hidden from .js-loading in me
add .hidden to .js-neterr in me
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
trigger htmx:consume on me
call htmx.trigger(me, 'intersect')
end
def backoff()
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
set myMs to Number(me.dataset.retryMs)
if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end
js setTimeout(() => htmx.trigger(me, 'sentinel:retry'), myMs)
end
on htmx:beforeRequest
set me.style.pointerEvents to 'none'
set me.style.opacity to '0'
end
on htmx:afterSwap
set me.dataset.retryMs to 1000
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
<td colspan="5" class="px-3 py-4">
{# Mobile sentinel content #}
<div class="block md:hidden h-[60vh] js-mobile-sentinel">
{% include "sentinel/mobile_content.html" %}
</div>
{# Desktop sentinel content #}
<div class="hidden md:block h-[30vh] js-desktop-sentinel">
{% include "sentinel/desktop_content.html" %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">
End of results
</td>
</tr>
{% endif %}

View File

@@ -0,0 +1,11 @@
<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div class="space-y-1">
<p class="text-xs sm:text-sm text-stone-600">
Recent orders placed via the checkout.
</p>
</div>
<div class="md:hidden">
{% from 'macros/search.html' import search_mobile %}
{{ search_mobile(current_local_href, search, search_count, hx_select) }}
</div>
</header>

View File

@@ -0,0 +1,14 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='orders-row', oob=oob) %}
{% call links.link(url_for('orders.list_orders'), hx_select_search, ) %}
<i class="fa fa-gbp" aria-hidden="true"></i>
<div>
Orders
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/orders/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,29 @@
{% extends '_types/auth/index.html' %}
{% block auth_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('orders-header-child', '_types/orders/header/_header.html') %}
{% block orders_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/orders/_nav.html' %}
{% endblock %}
{% block aside %}
{% from 'macros/search.html' import search_desktop %}
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% endblock %}
{% block filter %}
{% include '_types/orders/_summary.html' %}
{% endblock %}
{% block content %}
{% include '_types/orders/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,250 @@
{% macro add(slug, cart, oob='false') %}
{% set quantity = cart
| selectattr('product.slug', 'equalto', slug)
| sum(attribute='quantity') %}
<div id="cart-{{ slug }}" {% if oob=='true' %} hx-swap-oob="{{oob}}" {% endif %}>
{% if not quantity %}
<form
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
class="rounded flex items-center"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
type="hidden"
name="count"
value="1"
>
<button
type="submit"
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"
>
<span class="relative inline-flex items-center justify-center">
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
<!-- black + overlaid in the center -->
</span>
</button>
</form>
{% else %}
<div class="rounded flex items-center gap-2">
<!-- minus -->
<form
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
type="hidden"
name="count"
value="{{ quantity - 1 }}"
>
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
-
</button>
</form>
<!-- basket with quantity badge -->
<a
class="relative inline-flex items-center justify-center text-emerald-700"
href="{{ cart_url('/') }}"
>
<span class="relative inline-flex items-center justify-center">
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<span
class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold"
>
{{ quantity }}
</span>
</span>
</span>
</a>
<!-- plus -->
<form
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
type="hidden"
name="count"
value="{{ quantity + 1 }}"
>
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
+
</button>
</form>
</div>
{% endif %}
</div>
{% endmacro %}
{% macro cart_item(oob=False) %}
{% set p = item.product %}
{% set unit_price = p.special_price or p.regular_price %}
<article
id="cart-item-{{p.slug}}"
{% if oob %}
hx-swap-oob="{{oob}}"
{% endif %}
class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
>
<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">
{% if p.image %}
<img
src="{{ p.image }}"
alt="{{ p.title }}"
class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100"
loading="lazy"
>
{% else %}
<div
class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"
>
No image
</div>'market', 'product', p.slug
{% endif %}
</div>
{# Details #}
<div class="flex-1 min-w-0">
<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', product_slug=p.slug) %}
<a
href="{{ href }}"
hx_get="{{href}}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="hover:text-emerald-700"
>
{{ p.title }}
</a>
</h2>
{% if p.brand %}
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
{{ p.brand }}
</p>
{% endif %}
{% if item.is_deleted %}
<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>
This item is no longer available or price has changed
</p>
{% endif %}
</div>
{# Unit price #}
<div class="text-left sm:text-right">
{% if unit_price %}
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
<p class="text-sm sm:text-base font-semibold text-stone-900">
{{ symbol }}{{ "%.2f"|format(unit_price) }}
</p>
{% if p.special_price and p.special_price != p.regular_price %}
<p class="text-xs text-stone-400 line-through">
{{ symbol }}{{ "%.2f"|format(p.regular_price) }}
</p>
{% endif %}
{% else %}
<p class="text-xs text-stone-500">No price</p>
{% endif %}
</div>
</div>
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
<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', product_slug=p.slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
type="hidden"
name="count"
value="{{ item.quantity - 1 }}"
>
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
-
</button>
</form>
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
{{ item.quantity }}
</span>
<form
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
method="post"
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
hx-target="#cart-mini"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input
type="hidden"
name="count"
value="{{ item.quantity + 1 }}"
>
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
+
</button>
</form>
</div>
<div class="flex items-center justify-between sm:justify-end gap-3">
{% if unit_price %}
{% set line_total = unit_price * item.quantity %}
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
<p class="text-sm sm:text-base font-semibold text-stone-900">
Line total:
{{ symbol }}{{ "%.2f"|format(line_total) }}
</p>
{% endif %}
</div>
</div>
</div>
</article>
{% endmacro %}

View File

@@ -0,0 +1,27 @@
<div id="cart-mini">
{% if cart_count == 0 %}
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
<a
href="{{ blog_url('/') }}"
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
>
<img
src="{{ blog_url('/static/img/logo.jpg') }}"
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
>
</a>
</div>
{% else %}
<a
href="{{ cart_url('/') }}"
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
>
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
>
{{ cart_count }}
</span>
</a>
{% endif %}
</div>