Compare commits

142 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
giles
8498807597 Remove Calendar model import from checkout, use DTO fields
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
resolve_page_config() now reads entry.calendar_container_id from the
CalendarEntryDTO instead of fetching the Calendar ORM model. Fixes
stale CalendarEntry type hints to CalendarEntryDTO. Updates shared
submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 06:07:12 +00:00
giles
72062930f0 Update shared submodule pointer to latest DTO fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:07:29 +00:00
giles
edad7b299d Fix DTO compatibility in cart templates and page_cart service
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m58s
- page_cart.py: use ce.calendar_container_id instead of ce.calendar.container_id
- _cart.html: use entry.calendar_name instead of entry.calendar.name
- Update shared submodule with DTO field additions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:05:04 +00:00
giles
cf04a3a9fd Update shared submodule: revert extend_existing workaround
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:52:05 +00:00
giles
e5c686643c Fix NameError: import services registry in create_app scope
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
The services singleton was used in before_request closures but the
import was removed when refactoring to domain_services_fn.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:41:15 +00:00
giles
377396283d Update shared submodule: fix duplicate table error for MenuNode
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:34:44 +00:00
giles
049b35479b Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Replace direct Post, Calendar, CalendarEntry model queries and glue
lifecycle imports with typed service calls. Cart registers all 4
services via domain_services_fn with has() guards.

Key changes:
- app.py: use domain_services_fn, Post query → services.blog
- api.py: Calendar/CalendarEntry → services.calendar
- checkout: glue order_lifecycle → services.calendar.claim/confirm
- calendar_cart: CalendarEntry → services.calendar.pending_entries()
- page_cart: Post/Calendar queries → services.blog/calendar
- global_routes: glue imports → service calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:30:17 +00:00
giles
b7f09d638d Update shared submodule: fix ticket_types lazy-load
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:02:21 +00:00
giles
52105def89 Update shared submodule: fix cart-mini home link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:49:53 +00:00
giles
8527ddb84b Decouple cart: use shared.models for all cross-app imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
- Replace all imports from blog.models, market.models, events.models
  and bare models.* with shared.models equivalents
- Convert cart/models/order.py and page_config.py to re-export stubs
- Update shared + glue submodule pointers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:58:10 +00:00
giles
d6d82664d6 Remove 23 identical cart template overrides of shared templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:03:21 +00:00
giles
56230eff0a Update shared submodule: fix orders link htmx interception
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:30:56 +00:00
giles
7f25e6b63f Update shared submodule: use coop_url for auth links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:18:41 +00:00
giles
91f05d41ca Add oob context processor to orders blueprint for full-page rendering
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 38s
The orders/index.html template extends auth/index.html which needs
the oob dict for template inheritance. Without it, direct navigation
to /orders/ fails with "'oob' is undefined".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:06:40 +00:00
giles
bd14e2564a Update shared submodule: fix market nav link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:57:10 +00:00
giles
1256755a3a Fix checkout return: resolve product URLs and read status after SumUp check
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Resolve page_slug and market_slug from the order's page_config so that
product links on the checkout return page include the correct prefix.
Also move the status read after check_sumup_status so the template
reflects the actual payment result.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:38:11 +00:00
giles
298b5cd0a7 Fix product URLs: use market_product_url with page/market prefix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:19:53 +00:00
giles
e341df5836 Update shared submodule: add page_config to SumUp checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:52:16 +00:00
giles
d81d116be8 Update shared submodule: fix doubled URLs in |host filter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:46:08 +00:00
giles
de219aa870 Update shared submodule pointer (README addition)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:16:46 +00:00
giles
93ffffac16 Update glue submodule pointer (README addition)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:49:55 +00:00
giles
fb70c4c76d README: replace vague cross-app section with actual code dependencies
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
List specific model imports, glue services, internal APIs, and
domain events that cart code actually references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:47:55 +00:00
giles
2af4dd2073 Remove dead code: routes_old.py and unused imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
- routes_old.py: 253 lines, completely unreferenced (replaced by
  global_routes, page_routes, overview_routes)
- page_routes.py: remove unused check_sumup_status, get_order_with_details
- global_routes.py: remove unused is_htmx_request, config imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:46:52 +00:00
giles
6aa2919f34 Remove dead adopt_session_cart_for_user.py (replaced by glue/services/cart_adoption.py)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:39:21 +00:00
giles
61686fd70c Remove dead login_helper.py (replaced by glue/services/cart_adoption.py)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:38:18 +00:00
giles
4f9f482c6c Rewrite README for post-decoupling architecture
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Remove stale /adopt endpoint reference, document submodules, all
services, glue integration, checkout flow, and domain events.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:11:58 +00:00
giles
d407957928 Phase 5: Replace cross-domain writes with glue services, emit events
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
- checkout.py: use claim_entries_for_order(), emit order.created
- check_sumup_status.py: use confirm_entries_for_order(), emit order.paid
- global_routes.py: use get_entries_for_order() instead of relationship
- order.py: remove calendar_entries relationship
- api.py: remove /adopt endpoint (replaced by event-driven adoption)

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:01:58 +00:00
giles
908f92464e Update shared submodule: merge diverged alembic heads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:27:34 +00:00
giles
c4fbfa4c53 Update shared submodule (adds missing alembic.ini)
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-11 14:20:20 +00:00
giles
3adf268ffe Add PYTHONPATH=/app so Hypercorn spawn workers find app module
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-11 14:01:42 +00:00
giles
e97eea816f Update shared submodule: rename logging → log_config
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
Fixes stdlib logging shadow that caused circular import in Docker.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:29:28 +00:00
giles
5d0653bf2e feat: decouple cart from shared_lib, add app-owned models
Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Cart-owned models in cart/models/ (order, page_config)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- PageConfig uses container_type/container_id instead of post_id FK

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:46:34 +00:00
51 changed files with 1312 additions and 870 deletions

View File

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

5
.gitmodules vendored
View File

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

View File

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

109
README.md
View File

@@ -1,71 +1,76 @@
# Cart App # Cart App
Shopping cart, checkout, and order management service for the Rose Ash cooperative marketplace. Shopping cart, checkout, and order management service for the Rose Ash cooperative.
## Overview
This is the **cart** microservice, split from the Rose Ash monolith. It handles:
- **Shopping cart** - Add/remove products, view cart, cart summary API
- **Checkout** - SumUp payment integration with hosted checkout
- **Orders** - Order listing, detail view, payment status tracking
- **Calendar bookings** - Calendar entry cart items and checkout integration
## Architecture ## Architecture
- **Framework:** Quart (async Flask) One of five Quart microservices sharing a single PostgreSQL database:
- **Database:** PostgreSQL 16 via SQLAlchemy 2.0 (async)
- **Payments:** SumUp Hosted Checkout
- **Frontend:** HTMX + Jinja2 templates + Tailwind CSS
## Directory Structure | App | Port | Domain |
|-----|------|--------|
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
| market | 8001 | Product browsing, Suma scraping |
| **cart** | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
## Structure
``` ```
app.py # Quart application factory 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, OrderItem, PageConfig)
bp/ bp/
cart/ # Cart blueprint (add, view, checkout, webhooks) cart/ # Cart blueprint
routes.py global_routes.py # Add to cart, checkout, webhooks, return page
api.py # Internal API (server-to-server, CSRF-exempt) page_routes.py # Page-scoped cart and checkout
login_helper.py # Cart merge on login overview_routes.py # Cart overview / summary page
services/ # Business logic layer services/ # Business logic
order/ # Single order detail blueprint checkout.py # Order creation, SumUp integration
routes.py check_sumup_status.py # Payment status polling
filters/qs.py # Query string helpers calendar_cart.py # Calendar entry cart queries
orders/ # Order listing blueprint page_cart.py # Page-scoped cart queries
routes.py get_cart.py # Cart item queries
filters/qs.py identity.py # Cart identity (user_id / session_id)
templates/ total.py # Price calculations
_types/cart/ # Cart templates clear_cart_for_order.py # Soft-delete cart after checkout
_types/order/ # Single order templates order/ # Single order detail view
_types/orders/ # Order listing templates orders/ # Order listing view
entrypoint.sh # Docker entrypoint (migrations + server start) services/ # register_domain_services() — wires cart + calendar + market
Dockerfile # Container build shared/ # Submodule -> git.rose-ash.com/coop/shared.git
.gitea/workflows/ci.yml # CI/CD pipeline ```
## Cross-Domain Communication
- `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
## Domain Events
- `checkout.py` emits `order.created` via `shared.events.emit_event`
- `check_sumup_status.py` emits `order.paid` via `shared.events.emit_event`
## Checkout Flow
```
1. User clicks "Checkout"
2. create_order_from_cart() creates Order + OrderItems
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: services.calendar.confirm_entries_for_order(), emit: order.paid
``` ```
## Running ## Running
```bash ```bash
# Set environment variables
export APP_MODULE=app:app
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0 export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key export SECRET_KEY=your-secret-key
# Run the server hypercorn app:app --bind 0.0.0.0:8002
hypercorn app:app --reload --bind 0.0.0.0:8002
```
## Cross-App Communication
The cart app exposes internal API endpoints at `/internal/cart/` for other services:
- `GET /internal/cart/summary` - Cart count and total for the current session/user
- `POST /internal/cart/adopt` - Adopt anonymous cart items after user login
## Docker
```bash
docker build -t cart:latest .
docker run -p 8002:8000 --env-file .env cart:latest
``` ```

0
__init__.py Normal file
View File

126
app.py
View File

@@ -1,31 +1,36 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
from decimal import Decimal
from pathlib import Path from pathlib import Path
from quart import g, abort from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select from sqlalchemy import select
from shared.factory import create_base_app from shared.infrastructure.factory import create_base_app
from suma_browser.app.bp import ( from bp import (
register_cart_overview, register_cart_overview,
register_page_cart, register_page_cart,
register_cart_global, register_cart_global,
register_cart_api,
register_orders, register_orders,
register_fragments,
) )
from suma_browser.app.bp.cart.services import ( from bp.cart.services import (
get_cart, get_cart,
total, total,
get_calendar_cart_entries, get_calendar_cart_entries,
calendar_total, calendar_total,
get_ticket_cart_entries,
ticket_total,
) )
from suma_browser.app.bp.cart.services.page_cart import ( from bp.cart.services.page_cart import (
get_cart_for_page, get_cart_for_page,
get_calendar_entries_for_page, get_calendar_entries_for_page,
get_tickets_for_page,
) )
from bp.cart.services.ticket_groups import group_tickets
async def _load_cart(): async def _load_cart():
@@ -40,56 +45,68 @@ async def cart_context() -> dict:
- cart / calendar_cart_entries / total / calendar_total: direct DB - cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data) (cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html) - cart_count: derived from cart + calendar entries (for _mini.html)
- menu_items: fetched from coop internal API - nav_tree_html: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped. When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini. Global cart_count / cart_total stay global for cart-mini.
""" """
from shared.context import base_context from shared.infrastructure.context import base_context
from shared.internal_api import get as api_get, dictobj from shared.services.navigation import get_navigation_tree
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context() 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 # Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or [] all_cart = getattr(g, "cart", None) or []
all_cal = await get_calendar_cart_entries(g.s) 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) # Global counts for cart-mini (always global)
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0 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_count"] = cart_qty + len(all_cal) + len(all_tickets)
ctx["cart_total"] = (total(all_cart) or 0) + (calendar_total(all_cal) or 0) 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-scoped data when viewing a page cart
page_post = getattr(g, "page_post", None) page_post = getattr(g, "page_post", None)
if page_post: if page_post:
page_cart = await get_cart_for_page(g.s, page_post.id) 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_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["cart"] = page_cart
ctx["calendar_cart_entries"] = page_cal ctx["calendar_cart_entries"] = page_cal
ctx["ticket_cart_entries"] = page_tickets
ctx["page_post"] = page_post ctx["page_post"] = page_post
ctx["page_config"] = getattr(g, "page_config", None) ctx["page_config"] = getattr(g, "page_config", None)
else: else:
ctx["cart"] = all_cart ctx["cart"] = all_cart
ctx["calendar_cart_entries"] = all_cal 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["total"] = total
ctx["calendar_total"] = calendar_total ctx["calendar_total"] = calendar_total
ctx["ticket_total"] = ticket_total
# Menu items from coop API (wrapped for attribute access in templates)
menu_data = await api_get("coop", "/internal/menu-items")
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
return ctx return ctx
def create_app() -> "Quart": def create_app() -> "Quart":
from models.ghost_content import Post from shared.models.page_config import PageConfig
from models.page_config import PageConfig from shared.services.registry import services
from services import register_domain_services
app = create_base_app( app = create_base_app(
"cart", "cart",
context_fn=cart_context, context_fn=cart_context,
before_request_fns=[_load_cart], before_request_fns=[_load_cart],
domain_services_fn=register_domain_services,
) )
# App-specific templates override shared templates # App-specific templates override shared templates
@@ -99,6 +116,11 @@ def create_app() -> "Quart":
app.jinja_loader, 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) --- # --- Page slug hydration (follows events/market app pattern) ---
@app.url_value_preprocessor @app.url_value_preprocessor
@@ -118,26 +140,22 @@ def create_app() -> "Quart":
slug = getattr(g, "page_slug", None) slug = getattr(g, "page_slug", None)
if not slug: if not slug:
return return
post = ( post = await services.blog.get_post_by_slug(g.s, slug)
await g.s.execute( if not post or not post.is_page:
select(Post).where(Post.slug == slug, Post.is_page == True) # noqa: E712
)
).scalar_one_or_none()
if not post:
abort(404) abort(404)
g.page_post = post g.page_post = post
g.page_config = ( g.page_config = (
await g.s.execute( await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post.id) select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post.id,
)
) )
).scalar_one_or_none() ).scalar_one_or_none()
# --- Blueprint registration --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
# Internal API (server-to-server, CSRF-exempt)
app.register_blueprint(register_cart_api())
# Orders blueprint # Orders blueprint
app.register_blueprint(register_orders(url_prefix="/orders")) app.register_blueprint(register_orders(url_prefix="/orders"))
@@ -159,6 +177,58 @@ def create_app() -> "Quart":
url_prefix="/<page_slug>", 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 return app

View File

@@ -1,6 +1,6 @@
from .cart.overview_routes import register as register_cart_overview from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global 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 .order.routes import register as register_order
from .orders.routes import register as register_orders from .orders.routes import register as register_orders
from .fragments import register_fragments

View File

@@ -1,174 +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, update, func
from sqlalchemy.orm import selectinload
from models.market import CartItem
from models.market_place import MarketPlace
from models.calendars import CalendarEntry, Calendar
from models.ghost_content import Post
from suma_browser.app.csrf import csrf_exempt
from shared.cart_identity import current_cart_identity
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 g.s.execute(
select(Post).where(Post.slug == page_slug, Post.is_page == True) # noqa: E712
)
).scalar_one_or_none()
if post:
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.post_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 ---
cal_q = select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
)
if ident["user_id"] is not None:
cal_q = cal_q.where(CalendarEntry.user_id == ident["user_id"])
else:
cal_q = cal_q.where(CalendarEntry.session_id == ident["session_id"])
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.post_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
cal_q = cal_q.where(CalendarEntry.calendar_id.in_(cal_ids))
cal_result = await g.s.execute(cal_q)
cal_entries = cal_result.scalars().all()
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,
}
)
@bp.post("/adopt")
@csrf_exempt
async def adopt():
"""
Adopt anonymous cart items + calendar entries for a user.
Called by the coop app after successful login.
Body: {"user_id": int, "session_id": str}
"""
data = await request.get_json() or {}
user_id = data.get("user_id")
session_id = data.get("session_id")
if not user_id or not session_id:
return jsonify({"ok": False, "error": "user_id and session_id required"}), 400
# --- adopt cart items ---
anon_result = await g.s.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.user_id.is_(None),
CartItem.session_id == session_id,
)
)
anon_items = anon_result.scalars().all()
if anon_items:
# Soft-delete existing user cart
await g.s.execute(
update(CartItem)
.where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id)
.values(deleted_at=func.now())
)
for ci in anon_items:
ci.user_id = user_id
# --- adopt calendar entries ---
await g.s.execute(
update(CalendarEntry)
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
.values(deleted_at=func.now())
)
cal_result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
return jsonify({"ok": True})
return bp

View File

@@ -5,15 +5,18 @@ from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select from sqlalchemy import select
from models.order import Order from shared.models.market import CartItem
from suma_browser.app.utils.htmx import is_htmx_request from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.services.registry import services
from .services import ( from .services import (
current_cart_identity, current_cart_identity,
get_cart, get_cart,
total, total,
clear_cart_for_order,
get_calendar_cart_entries, get_calendar_cart_entries,
calendar_total, calendar_total,
get_ticket_cart_entries,
ticket_total,
check_sumup_status, check_sumup_status,
) )
from .services.checkout import ( from .services.checkout import (
@@ -26,8 +29,8 @@ from .services.checkout import (
validate_webhook_secret, validate_webhook_secret,
get_order_with_details, get_order_with_details,
) )
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config from shared.browser.app.csrf import csrf_exempt
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
@@ -53,24 +56,96 @@ def register(url_prefix: str) -> Blueprint:
return redirect(url_for("cart_overview.overview")) 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/") @bp.post("/checkout/")
async def checkout(): async def checkout():
"""Legacy global checkout (for orphan items without page scope).""" """Legacy global checkout (for orphan items without page scope)."""
cart = await get_cart(g.s) cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(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")) return redirect(url_for("cart_overview.overview"))
product_total = total(cart) or 0 product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) 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: if cart_total <= 0:
return redirect(url_for("cart_overview.overview")) return redirect(url_for("cart_overview.overview"))
try: 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: except ValueError as e:
html = await render_template( html = await render_template(
"_types/cart/checkout_error.html", "_types/cart/checkout_error.html",
@@ -88,6 +163,7 @@ def register(url_prefix: str) -> Blueprint:
ident.get("session_id"), ident.get("session_id"),
product_total, product_total,
calendar_amount, calendar_amount,
ticket_total=ticket_amount,
) )
if page_config: 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) 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) 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_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url) webhook_url = build_webhook_url(webhook_base_url)
@@ -107,8 +183,6 @@ def register(url_prefix: str) -> Blueprint:
description=description, description=description,
page_config=page_config, page_config=page_config,
) )
await clear_cart_for_order(g.s, order)
order.sumup_checkout_id = checkout_data.get("id") order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status") order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description") order.description = checkout_data.get("description")
@@ -129,6 +203,7 @@ def register(url_prefix: str) -> Blueprint:
return redirect(hosted_url) return redirect(hosted_url)
@csrf_exempt
@bp.post("/checkout/webhook/<int:order_id>/") @bp.post("/checkout/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int): async def checkout_webhook(order_id: int):
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events.""" """Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
@@ -179,15 +254,32 @@ def register(url_prefix: str) -> Blueprint:
) )
return await make_response(html) return await make_response(html)
status = (order.status or "pending").lower() # Resolve page/market slugs so product links render correctly
if order.page_config:
post = await services.blog.get_post_by_id(g.s, order.page_config.container_id)
if post:
g.page_slug = post.slug
result = await g.s.execute(
select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post.id,
MarketPlace.deleted_at.is_(None),
).limit(1)
)
mp = result.scalar_one_or_none()
if mp:
g.market_slug = mp.slug
if order.sumup_checkout_id: if order.sumup_checkout_id:
try: try:
await check_sumup_status(g.s, order) await check_sumup_status(g.s, order)
except Exception: except Exception:
status = status or "pending" pass
calendar_entries = order.calendar_entries or [] 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() await g.s.flush()
html = await render_template( html = await render_template(
@@ -195,6 +287,7 @@ def register(url_prefix: str) -> Blueprint:
order=order, order=order,
status=status, status=status,
calendar_entries=calendar_entries, calendar_entries=calendar_entries,
order_tickets=order_tickets,
) )
return await make_response(html) return await make_response(html)

View File

@@ -1,57 +0,0 @@
# app/cart_merge.py
from __future__ import annotations
from quart import g, session as qsession
from sqlalchemy import select
from typing import Optional
from models.market import CartItem
async def merge_anonymous_cart_into_user(user_id: int) -> None:
"""
When a user logs in, move any anonymous cart (session_id) items onto their user_id.
"""
sid: Optional[str] = qsession.get("cart_sid")
if not sid:
return
# get all anon cart items for this session
anon_items = (
await g.s.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.session_id == sid,
)
)
).scalars().all()
if not anon_items:
return
# Existing user items keyed by product_id for quick merge
user_items_by_product = {
ci.product_id: ci
for ci in (
await g.s.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.user_id == user_id,
)
)
).scalars().all()
}
for anon in anon_items:
existing = user_items_by_product.get(anon.product_id)
if existing:
# merge quantities then soft-delete the anon row
existing.quantity += anon.quantity
anon.deleted_at = func.now()
else:
# reassign anonymous cart row to this user
anon.user_id = user_id
anon.session_id = None
# clear the anonymous session id now that it's "claimed"
qsession.pop("cart_sid", None)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from quart import Blueprint, render_template, make_response from quart import Blueprint, render_template, make_response
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from .services import get_cart_grouped_by_page from .services import get_cart_grouped_by_page

View File

@@ -4,22 +4,21 @@ from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for from quart import Blueprint, g, render_template, redirect, make_response, url_for
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config from shared.config import config
from .services import ( from .services import (
total, total,
clear_cart_for_order,
calendar_total, calendar_total,
check_sumup_status, 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 ( from .services.checkout import (
create_order_from_cart, create_order_from_cart,
build_sumup_description, build_sumup_description,
build_sumup_reference, build_sumup_reference,
build_webhook_url, build_webhook_url,
get_order_with_details,
) )
from .services import current_cart_identity from .services import current_cart_identity
@@ -32,14 +31,20 @@ def register(url_prefix: str) -> Blueprint:
post = g.page_post post = g.page_post
cart = await get_cart_for_page(g.s, post.id) cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_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( tpl_ctx = dict(
page_post=post, page_post=post,
page_config=getattr(g, "page_config", None), page_config=getattr(g, "page_config", None),
cart=cart, cart=cart,
calendar_cart_entries=cal_entries, calendar_cart_entries=cal_entries,
ticket_cart_entries=page_tickets,
ticket_groups=ticket_groups,
total=total, total=total,
calendar_total=calendar_total, calendar_total=calendar_total,
ticket_total=ticket_total,
) )
if not is_htmx_request(): if not is_htmx_request():
@@ -55,13 +60,15 @@ def register(url_prefix: str) -> Blueprint:
cart = await get_cart_for_page(g.s, post.id) cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_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")) return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0 product_total = total(cart) or 0
calendar_amount = calendar_total(cal_entries) 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: if cart_total <= 0:
return redirect(url_for("page_cart.page_view")) return redirect(url_for("page_cart.page_view"))
@@ -76,6 +83,7 @@ def register(url_prefix: str) -> Blueprint:
ident.get("session_id"), ident.get("session_id"),
product_total, product_total,
calendar_amount, calendar_amount,
ticket_total=ticket_amount,
page_post_id=post.id, page_post_id=post.id,
) )
@@ -86,7 +94,7 @@ def register(url_prefix: str) -> Blueprint:
# Build SumUp checkout details — webhook/return use global routes # Build SumUp checkout details — webhook/return use global routes
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True) 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) 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_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url) webhook_url = build_webhook_url(webhook_base_url)
@@ -98,8 +106,6 @@ def register(url_prefix: str) -> Blueprint:
description=description, description=description,
page_config=page_config, 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_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status") order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description") order.description = checkout_data.get("description")

View File

@@ -1,253 +0,0 @@
# app/bp/cart/routes.py
from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select, update
from sqlalchemy.orm import selectinload
from models.market import Product, CartItem
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from .services import (
current_cart_identity,
get_cart,
total,
clear_cart_for_order,
get_calendar_cart_entries, # NEW
calendar_total, # NEW
check_sumup_status
)
from .services.checkout import (
find_or_create_cart_item,
create_order_from_cart,
resolve_page_config,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
validate_webhook_secret,
get_order_with_details,
)
from config import config
from models.calendars import CalendarEntry # NEW
from suma_browser.app.utils.htmx import is_htmx_request
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart", __name__, url_prefix=url_prefix)
# NOTE: load_cart moved to shared/cart_loader.py
# and registered in shared/factory.py as an app-level before_request
#@bp.context_processor
#async def inject_root():
# return {
# "total": total,
# "calendar_total": calendar_total, # NEW helper
#
# }
@bp.get("/")
async def view_cart():
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template(
"_types/cart/index.html",
)
else:
html = await render_template(
"_types/cart/_oob_elements.html",
)
return await make_response(html)
@bp.post("/add/<int:product_id>/")
async def add_to_cart(product_id: int):
ident = current_cart_identity()
cart_item = await find_or_create_cart_item(
g.s,
product_id,
ident["user_id"],
ident["session_id"],
)
if not cart_item:
return await make_response("Product not found", 404)
# htmx support (optional)
if request.headers.get("HX-Request") == "true":
return await view_cart()
# normal POST: go to cart page
return redirect(url_for("cart.view_cart"))
@bp.post("/checkout/")
async def checkout():
"""Create an Order from the current cart and redirect to SumUp Hosted Checkout."""
# Build cart
cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s)
if not cart and not calendar_entries:
return redirect(url_for("cart.view_cart"))
product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0
cart_total = product_total + calendar_amount
if cart_total <= 0:
return redirect(url_for("cart.view_cart"))
# Resolve per-page credentials
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries)
except ValueError as e:
html = await render_template(
"_types/cart/checkout_error.html",
order=None,
error=str(e),
)
return await make_response(html, 400)
# Create order from cart
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
calendar_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
)
# Set page_config on order if resolved
if page_config:
order.page_config_id = page_config.id
# Build SumUp checkout details
redirect_url = url_for("cart.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)
webhook_base_url = url_for("cart.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
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")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
error="No hosted checkout URL returned from SumUp.",
)
return await make_response(html, 500)
return redirect(hosted_url)
@bp.post("/checkout/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int):
"""
Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events.
Security:
- Optional shared secret in ?token=... (checked against config sumup.webhook_secret)
- We *always* verify the event by calling SumUp's API.
"""
# Optional shared secret check
if not validate_webhook_secret(request.args.get("token")):
return "", 204
try:
payload = await request.get_json()
except Exception:
payload = None
if not isinstance(payload, dict):
return "", 204
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
return "", 204
checkout_id = payload.get("id")
if not checkout_id:
return "", 204
# Look up our order
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return "", 204
# Make sure the checkout id matches the one we stored
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
return "", 204
# Verify with SumUp
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return "", 204
@bp.get("/checkout/return/<int:order_id>/")
async def checkout_return(order_id: int):
"""Handle the browser returning from SumUp after payment."""
order = await get_order_with_details(g.s, order_id)
if not order:
html = await render_template(
"_types/cart/checkout_return.html",
order=None,
status="missing",
calendar_entries=[],
)
return await make_response(html)
status = (order.status or "pending").lower()
# Optionally refresh status from SumUp
if order.sumup_checkout_id:
try:
await check_sumup_status(g.s, order)
except Exception:
status = status or "pending"
calendar_entries = order.calendar_entries or []
await g.s.flush()
html = await render_template(
"_types/cart/checkout_return.html",
order=order,
status=status,
calendar_entries=calendar_entries,
)
return await make_response(html)
return bp

View File

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

View File

@@ -1,46 +0,0 @@
from sqlalchemy import select, update, func
from models.market import CartItem
async def adopt_session_cart_for_user(session, user_id: int, session_id: str | None) -> None:
"""
When a user logs in or registers:
- If there are cart items for this anonymous session, take them over.
- Replace any existing cart items for this user with the anonymous cart.
"""
if not session_id:
return
# 1) Find anonymous cart items for this session
result = await session.execute(
select(CartItem)
.where(
CartItem.deleted_at.is_(None),
CartItem.user_id.is_(None),
CartItem.session_id == session_id,
)
)
anon_items = result.scalars().all()
if not anon_items:
# nothing to adopt
return
# 2) Soft-delete any existing cart for this user
await session.execute(
update(CartItem)
.where(
CartItem.deleted_at.is_(None),
CartItem.user_id == user_id,
)
.values(deleted_at=func.now())
)
# 3) Reassign anonymous cart items to the user
for ci in anon_items:
ci.user_id = user_id
# optional: you can keep the session_id as well, but user_id will take precedence
# ci.session_id = session_id
# No explicit commit here; caller's transaction will handle it

View File

@@ -1,46 +1,45 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select from decimal import Decimal
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry from shared.services.registry import services
from .identity import current_cart_identity from .identity import current_cart_identity
async def get_calendar_cart_entries(session): async def get_calendar_cart_entries(session):
""" """
Return all *pending* calendar entries for the current cart identity Return all *pending* calendar entries (as CalendarEntryDTOs) for the
(user or anonymous session). current cart identity (user or anonymous session).
""" """
ident = current_cart_identity() ident = current_cart_identity()
return await services.calendar.pending_entries(
filters = [ session,
CalendarEntry.deleted_at.is_(None), user_id=ident["user_id"],
CalendarEntry.state == "pending", session_id=ident["session_id"],
]
if ident["user_id"] is not None:
filters.append(CalendarEntry.user_id == ident["user_id"])
else:
filters.append(CalendarEntry.session_id == ident["session_id"])
result = await session.execute(
select(CalendarEntry)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(
selectinload(CalendarEntry.calendar),
)
) )
return result.scalars().all()
def calendar_total(entries) -> float: def calendar_total(entries) -> Decimal:
""" """
Total cost of pending calendar entries. Total cost of pending calendar entries.
""" """
return sum( return sum(
(e.cost or 0) (Decimal(str(e.cost)) if e.cost else Decimal(0))
for e in entries for e in entries
if e.cost is not None 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 suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from sqlalchemy import update from shared.events import emit_activity
from models.calendars import CalendarEntry from shared.services.registry import services
from .clear_cart_for_order import clear_cart_for_order
async def check_sumup_status(session, order): async def check_sumup_status(session, order):
@@ -13,20 +14,26 @@ async def check_sumup_status(session, order):
if sumup_status == "PAID": if sumup_status == "PAID":
if order.status != "paid": if order.status != "paid":
order.status = "paid" order.status = "paid"
filters = [ await services.calendar.confirm_entries_for_order(
CalendarEntry.deleted_at.is_(None), session, order.id, order.user_id, order.session_id
CalendarEntry.state == "ordered", )
CalendarEntry.order_id==order.id, await services.calendar.confirm_tickets_for_order(session, order.id)
]
if order.user_id is not None:
filters.append(CalendarEntry.user_id == order.user_id)
elif order.session_id is not None:
filters.append(CalendarEntry.session_id == order.session_id)
await session.execute( # Clear cart only after payment is confirmed
update(CalendarEntry) page_post_id = page_config.container_id if page_config else None
.where(*filters) await clear_cart_for_order(session, order, page_post_id=page_post_id)
.values(state="provisional")
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": elif sumup_status == "FAILED":
order.status = "failed" order.status = "failed"

View File

@@ -3,16 +3,18 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from urllib.parse import urlencode from urllib.parse import urlencode
from sqlalchemy import select, update from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.market import Product, CartItem from shared.models.market import Product, CartItem
from models.order import Order, OrderItem from shared.models.order import Order, OrderItem
from models.calendars import CalendarEntry, Calendar from shared.models.page_config import PageConfig
from models.page_config import PageConfig from shared.models.market_place import MarketPlace
from models.market_place import MarketPlace from shared.config import config
from config import config from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_activity
from shared.services.registry import services
async def find_or_create_cart_item( async def find_or_create_cart_item(
@@ -62,7 +64,8 @@ async def find_or_create_cart_item(
async def resolve_page_config( async def resolve_page_config(
session: AsyncSession, session: AsyncSession,
cart: list[CartItem], cart: list[CartItem],
calendar_entries: list[CalendarEntry], calendar_entries: list[CalendarEntryDTO],
tickets=None,
) -> Optional["PageConfig"]: ) -> Optional["PageConfig"]:
"""Determine the PageConfig for this order. """Determine the PageConfig for this order.
@@ -76,13 +79,17 @@ async def resolve_page_config(
if ci.market_place_id: if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id) mp = await session.get(MarketPlace, ci.market_place_id)
if mp: if mp:
post_ids.add(mp.post_id) post_ids.add(mp.container_id)
# From calendar entries via calendar # From calendar entries via calendar
for entry in calendar_entries: for entry in calendar_entries:
cal = await session.get(Calendar, entry.calendar_id) if entry.calendar_container_id:
if cal and cal.post_id: post_ids.add(entry.calendar_container_id)
post_ids.add(cal.post_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: if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages") raise ValueError("Cannot checkout items from multiple pages")
@@ -92,7 +99,10 @@ async def resolve_page_config(
post_id = post_ids.pop() post_id = post_ids.pop()
pc = (await session.execute( pc = (await session.execute(
select(PageConfig).where(PageConfig.post_id == post_id) select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post_id,
)
)).scalar_one_or_none() )).scalar_one_or_none()
return pc return pc
@@ -100,22 +110,23 @@ async def resolve_page_config(
async def create_order_from_cart( async def create_order_from_cart(
session: AsyncSession, session: AsyncSession,
cart: list[CartItem], cart: list[CartItem],
calendar_entries: list[CalendarEntry], calendar_entries: list[CalendarEntryDTO],
user_id: Optional[int], user_id: Optional[int],
session_id: Optional[str], session_id: Optional[str],
product_total: float, product_total: float,
calendar_total: float, calendar_total: float,
*, *,
ticket_total: float = 0,
page_post_id: int | None = None, page_post_id: int | None = None,
) -> Order: ) -> 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 belongs to that page are marked as "ordered". Otherwise all pending
entries are updated (legacy behaviour). entries are updated (legacy behaviour).
""" """
cart_total = product_total + calendar_total cart_total = product_total + calendar_total + ticket_total
# Determine currency from first product # Determine currency from first product
first_product = cart[0].product if cart else None first_product = cart[0].product if cart else None
@@ -145,50 +156,51 @@ async def create_order_from_cart(
) )
session.add(oi) session.add(oi)
# Update calendar entries to reference this order # Mark pending calendar entries as "ordered" via calendar service
calendar_filters = [ await services.calendar.claim_entries_for_order(
CalendarEntry.deleted_at.is_(None), session, order.id, user_id, session_id, page_post_id
CalendarEntry.state == "pending", )
]
if order.user_id is not None: # Claim reserved tickets for this order
calendar_filters.append(CalendarEntry.user_id == order.user_id) await services.calendar.claim_tickets_for_order(
elif order.session_id is not None: session, order.id, user_id, session_id, page_post_id
calendar_filters.append(CalendarEntry.session_id == order.session_id) )
if page_post_id is not None: await emit_activity(
cal_ids = select(Calendar.id).where( session,
Calendar.post_id == page_post_id, activity_type="Create",
Calendar.deleted_at.is_(None), actor_uri="internal:cart",
).scalar_subquery() object_type="rose:Order",
calendar_filters.append(CalendarEntry.calendar_id.in_(cal_ids)) object_data={
"order_id": order.id,
await session.execute( "user_id": user_id,
update(CalendarEntry) "session_id": session_id,
.where(*calendar_filters) },
.values( source_type="order",
state="ordered", source_id=order.id,
order_id=order.id,
)
) )
return order 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.""" """Build a human-readable description for SumUp checkout."""
titles = [ci.product.title for ci in cart if ci.product and ci.product.title] titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
item_count = sum(ci.quantity for ci in cart) item_count = sum(ci.quantity for ci in cart)
parts = []
if titles: if titles:
if len(titles) <= 3: if len(titles) <= 3:
summary = ", ".join(titles) parts.append(", ".join(titles))
else: else:
summary = ", ".join(titles[:3]) + f" + {len(titles) - 3} more" parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
else: if ticket_count:
summary = "order items" 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: def build_sumup_reference(order_id: int, page_config=None) -> str:
@@ -230,7 +242,6 @@ async def get_order_with_details(session: AsyncSession, order_id: int) -> Option
select(Order) select(Order)
.options( .options(
selectinload(Order.items).selectinload(OrderItem.product), selectinload(Order.items).selectinload(OrderItem.product),
selectinload(Order.calendar_entries),
) )
.where(Order.id == order_id) .where(Order.id == order_id)
) )

View File

@@ -1,8 +1,8 @@
from sqlalchemy import update, func, select from sqlalchemy import update, func, select
from models.market import CartItem from shared.models.market import CartItem
from models.market_place import MarketPlace from shared.models.market_place import MarketPlace
from models.order import Order from shared.models.order import Order
async def clear_cart_for_order(session, order: Order, *, page_post_id: int | None = None) -> None: async def clear_cart_for_order(session, order: Order, *, page_post_id: int | None = None) -> None:
@@ -24,7 +24,8 @@ async def clear_cart_for_order(session, order: Order, *, page_post_id: int | Non
if page_post_id is not None: if page_post_id is not None:
mp_ids = select(MarketPlace.id).where( mp_ids = select(MarketPlace.id).where(
MarketPlace.post_id == page_post_id, MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None), MarketPlace.deleted_at.is_(None),
).scalar_subquery() ).scalar_subquery()
filters.append(CartItem.market_place_id.in_(mp_ids)) filters.append(CartItem.market_place_id.in_(mp_ids))

View File

@@ -1,7 +1,7 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.market import CartItem from shared.models.market import CartItem
from .identity import current_cart_identity from .identity import current_cart_identity
async def get_cart(session): async def get_cart(session):

View File

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

View File

@@ -2,7 +2,8 @@
Page-scoped cart queries. Page-scoped cart queries.
Groups cart items and calendar entries by their owning page (Post), Groups cart items and calendar entries by their owning page (Post),
determined via CartItem.market_place.post_id and CalendarEntry.calendar.post_id. determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id
(where container_type == "page").
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,21 +12,21 @@ from collections import defaultdict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.market import CartItem from shared.models.market import CartItem
from models.market_place import MarketPlace from shared.models.market_place import MarketPlace
from models.calendars import CalendarEntry, Calendar from shared.models.page_config import PageConfig
from models.ghost_content import Post from shared.services.registry import services
from models.page_config import PageConfig
from .identity import current_cart_identity from .identity import current_cart_identity
async def get_cart_for_page(session, post_id: int) -> list[CartItem]: async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
"""Return cart items scoped to a specific page (via MarketPlace.post_id).""" """Return cart items scoped to a specific page (via MarketPlace.container_id)."""
ident = current_cart_identity() ident = current_cart_identity()
filters = [ filters = [
CartItem.deleted_at.is_(None), CartItem.deleted_at.is_(None),
MarketPlace.post_id == post_id, MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None), MarketPlace.deleted_at.is_(None),
] ]
if ident["user_id"] is not None: if ident["user_id"] is not None:
@@ -46,40 +47,36 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
return result.scalars().all() return result.scalars().all()
async def get_calendar_entries_for_page(session, post_id: int) -> list[CalendarEntry]: async def get_calendar_entries_for_page(session, post_id: int):
"""Return pending calendar entries scoped to a specific page (via Calendar.post_id).""" """Return pending calendar entries (DTOs) scoped to a specific page."""
ident = current_cart_identity() ident = current_cart_identity()
return await services.calendar.entries_for_page(
filters = [ session, post_id,
CalendarEntry.deleted_at.is_(None), user_id=ident["user_id"],
CalendarEntry.state == "pending", session_id=ident["session_id"],
Calendar.post_id == post_id, )
Calendar.deleted_at.is_(None),
]
if ident["user_id"] is not None: async def get_tickets_for_page(session, post_id: int):
filters.append(CalendarEntry.user_id == ident["user_id"]) """Return reserved tickets (DTOs) scoped to a specific page."""
else: ident = current_cart_identity()
filters.append(CalendarEntry.session_id == ident["session_id"]) return await services.calendar.tickets_for_page(
session, post_id,
result = await session.execute( user_id=ident["user_id"],
select(CalendarEntry) session_id=ident["session_id"],
.join(Calendar, CalendarEntry.calendar_id == Calendar.id)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(selectinload(CalendarEntry.calendar))
) )
return result.scalars().all()
async def get_cart_grouped_by_page(session) -> list[dict]: async def get_cart_grouped_by_page(session) -> list[dict]:
""" """
Load all cart items + calendar entries for the current identity, 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: Returns a list of dicts:
{ {
"post": Post | None, "post": Post | None,
"page_config": PageConfig | None, "page_config": PageConfig | None,
"market_place": MarketPlace | None,
"cart_items": [...], "cart_items": [...],
"calendar_entries": [...], "calendar_entries": [...],
"product_count": int, "product_count": int,
@@ -89,76 +86,127 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
"total": float, "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). Items without a market_place go in an orphan bucket (post=None).
""" """
from .get_cart import get_cart 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 .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) cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session) cal_entries = await get_calendar_cart_entries(session)
all_tickets = await get_ticket_cart_entries(session)
# Group by post_id # Group cart items by market_place_id
groups: dict[int | None, dict] = defaultdict(lambda: { market_groups: dict[int | None, dict] = {}
"post_id": None,
"cart_items": [],
"calendar_entries": [],
})
for ci in cart_items: for ci in cart_items:
if ci.market_place and ci.market_place.post_id: mp_id = ci.market_place_id if ci.market_place else None
pid = ci.market_place.post_id if mp_id not in market_groups:
else: market_groups[mp_id] = {
pid = None "market_place": ci.market_place,
groups[pid]["post_id"] = pid "post_id": ci.market_place.container_id if ci.market_place else None,
groups[pid]["cart_items"].append(ci) "cart_items": [],
"calendar_entries": [],
"tickets": [],
}
market_groups[mp_id]["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: for ce in cal_entries:
if ce.calendar and ce.calendar.post_id: pid = ce.calendar_container_id or None
pid = ce.calendar.post_id if pid in page_to_market:
market_groups[page_to_market[pid]]["calendar_entries"].append(ce)
else: else:
pid = None # Create a page-level group for calendar-only entries
groups[pid]["post_id"] = pid key = ("cal", pid)
groups[pid]["calendar_entries"].append(ce) 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)
# Batch-load Post and PageConfig objects # Attach tickets to page groups (via calendar_container_id)
post_ids = [pid for pid in groups if pid is not None] for tk in all_tickets:
posts_by_id: dict[int, Post] = {} 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 = 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] = {} configs_by_post: dict[int, PageConfig] = {}
if post_ids: if post_ids:
post_result = await session.execute( for p in await services.blog.get_posts_by_ids(session, post_ids):
select(Post).where(Post.id.in_(post_ids))
)
for p in post_result.scalars().all():
posts_by_id[p.id] = p posts_by_id[p.id] = p
pc_result = await session.execute( pc_result = await session.execute(
select(PageConfig).where(PageConfig.post_id.in_(post_ids)) select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id.in_(post_ids),
)
) )
for pc in pc_result.scalars().all(): for pc in pc_result.scalars().all():
configs_by_post[pc.post_id] = pc configs_by_post[pc.container_id] = pc
# Build result list (pages first, orphan last) # Build result list (markets with pages first, orphan last)
result = [] result = []
for pid in sorted(groups, key=lambda x: (x is None, x)): for _key, grp in sorted(
grp = groups[pid] market_groups.items(),
key=lambda kv: (kv[1]["post_id"] is None, kv[1]["post_id"] or 0),
):
items = grp["cart_items"] items = grp["cart_items"]
entries = grp["calendar_entries"] entries = grp["calendar_entries"]
tks = grp["tickets"]
prod_total = calc_product_total(items) or 0 prod_total = calc_product_total(items) or 0
cal_total = calc_calendar_total(entries) or 0 cal_total = calc_calendar_total(entries) or 0
tk_total = calc_ticket_total(tks) or 0
pid = grp["post_id"]
result.append({ result.append({
"post": posts_by_id.get(pid) if pid else None, "post": posts_by_id.get(pid) if pid else None,
"page_config": configs_by_post.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, "cart_items": items,
"calendar_entries": entries, "calendar_entries": entries,
"tickets": tks,
"product_count": sum(ci.quantity for ci in items), "product_count": sum(ci.quantity for ci in items),
"product_total": prod_total, "product_total": prod_total,
"calendar_count": len(entries), "calendar_count": len(entries),
"calendar_total": cal_total, "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 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): def total(cart):
return sum( 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 for item in cart
if (item.product.special_price or item.product.regular_price) is not None 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,8 +3,8 @@ from quart import request
from typing import Iterable, Optional, Union from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import KEEP, build_qs from shared.browser.app.filters.qs_base import KEEP, build_qs
from suma_browser.app.filters.query_types import OrderQuery from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery: def decode() -> OrderQuery:

View File

@@ -5,14 +5,14 @@ from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.market import Product from shared.models.market import Product
from models.order import Order, OrderItem from shared.models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config from shared.config import config
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from suma_browser.app.bp.cart.services import check_sumup_status from bp.cart.services import check_sumup_status
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode

View File

@@ -3,8 +3,8 @@ from quart import request
from typing import Iterable, Optional, Union from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import KEEP, build_qs from shared.browser.app.filters.qs_base import KEEP, build_qs
from suma_browser.app.filters.query_types import OrderQuery from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery: def decode() -> OrderQuery:

View File

@@ -5,15 +5,15 @@ from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.market import Product from shared.models.market import Product
from models.order import Order, OrderItem from shared.models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config from shared.config import config
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from suma_browser.app.bp.cart.services import check_sumup_status from bp.cart.services import check_sumup_status
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from suma_browser.app.bp import register_order from bp import register_order
from .filters.qs import makeqs_factory, decode from .filters.qs import makeqs_factory, decode
@@ -25,6 +25,18 @@ def register(url_prefix: str) -> Blueprint:
) )
ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference
oob = {
"extends": "_types/root/_index.html",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html",
}
@bp.context_processor
def inject_oob():
return {"oob": oob}
@bp.before_request @bp.before_request
def route(): def route():
# this is the crucial bit for the |qs filter # this is the crucial bit for the |qs filter

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

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

2
models/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .order import Order, OrderItem
from .page_config import PageConfig

1
models/order.py Normal file
View File

@@ -0,0 +1 @@
from shared.models.order import Order, OrderItem # noqa: F401

1
models/page_config.py Normal file
View File

@@ -0,0 +1 @@
from shared.models.page_config import PageConfig # noqa: F401

View File

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

28
services/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""Cart app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the cart app.
Cart owns: Order, OrderItem.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.cart = SqlCartService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
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()

1
shared Submodule

Submodule shared added at 9ab4b7b3fe

Submodule shared_lib deleted from 0c9b8d6aa2

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) %} {% macro show_cart(oob=False) %}
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}> <div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
{# Empty cart #} {# 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="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"> <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> <i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
@@ -43,7 +43,7 @@
<li class="flex items-start justify-between text-sm"> <li class="flex items-start justify-between text-sm">
<div> <div>
<div class="font-medium"> <div class="font-medium">
{{ entry.name or entry.calendar.name }} {{ entry.name or entry.calendar_name }}
</div> </div>
<div class="text-xs text-stone-500"> <div class="text-xs text-stone-500">
{{ entry.start_at }} {{ entry.start_at }}
@@ -60,8 +60,106 @@
</ul> </ul>
</div> </div>
{% endif %} {% 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> </section>
{{summary(cart, total, calendar_total, calendar_cart_entries,)}} {{summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries)}}
</div> </div>
@@ -70,7 +168,7 @@
{% endmacro %} {% 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 %}> <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"> <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"> <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"> <div class="flex items-center justify-between">
<dt class="text-stone-600">Items</dt> <dt class="text-stone-600">Items</dt>
<dd class="text-stone-900"> <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> </dd>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<dt class="text-stone-600">Subtotal</dt> <dt class="text-stone-600">Subtotal</dt>
<dd class="text-stone-900"> <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> </dd>
</div> </div>
</dl> </dl>
@@ -117,23 +217,13 @@
</form> </form>
{% else %} {% else %}
{% set href=login_url(request.url) %} {% set href=login_url(request.url) %}
<div <div class="w-full flex">
class="w-full flex"
>
<a <a
href="{{ href }}" href="{{ href }}"
hx-get="{{ href }}" 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"
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
> >
<i class="fa-solid fa-key"></i> <i class="fa-solid fa-key"></i>
<span>sign in or register to checkout</span> <span>sign in or register to checkout</span>
</a> </a>
</div> </div>
@@ -154,10 +244,11 @@
{% endmacro %} {% 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 product_total = total(cart) or 0 %}
{% set cal_total = calendar_total(calendar_cart_entries) 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 %} {% 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 %} {% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}

View File

@@ -1,9 +1,12 @@
{% macro mini(oob=False) %} {% macro mini(oob=False, count=None) %}
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} > <div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} >
{# cart_count is set by the context processor in all apps. {# cart_count is set by the context processor in all apps.
Cart app computes it from g.cart + calendar_cart_entries; Cart app computes it from g.cart + calendar_cart_entries;
other apps get it from the cart internal API. #} other apps get it from the cart internal API.
{% if cart_count is defined and cart_count is not none %} 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 %} {% set _count = cart_count %}
{% elif cart is defined and cart is not none %} {% 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) %} {% set _count = (cart | sum(attribute="quantity")) + ((calendar_cart_entries | length) if calendar_cart_entries else 0) %}
@@ -14,7 +17,7 @@
{% if _count == 0 %} {% if _count == 0 %}
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"> <div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
<a <a
href="{{ {'clear_filters': True}|qs|host }}" href="{{ blog_url('/') }}"
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1" class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
> >
<img <img

View File

@@ -47,8 +47,8 @@
{% endif %} {% endif %}
{% include '_types/order/_items.html' %} {% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %} {% include '_types/order/_calendar_items.html' %}
{% include '_types/order/_ticket_items.html' %}
{% if order.status == 'failed' and order %} {% 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"> <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 class="font-medium">Your payment was not completed.</p>

View File

@@ -13,7 +13,7 @@
{# Check if there are any items at all across all groups #} {# Check if there are any items at all across all groups #}
{% set ns = namespace(has_items=false) %} {% set ns = namespace(has_items=false) %}
{% for grp in page_groups %} {% 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 %} {% set ns.has_items = true %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -30,10 +30,10 @@
{% else %} {% else %}
<div class="space-y-4"> <div class="space-y-4">
{% for grp in page_groups %} {% 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 %} {% if grp.post %}
{# Page cart card #} {# Market / page cart card #}
<a <a
href="{{ cart_url('/' + grp.post.slug + '/') }}" 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" 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"> <div class="flex-1 min-w-0">
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate"> <h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
{{ grp.post.title }} {% if grp.market_place %}
{{ grp.market_place.name }}
{% else %}
{{ grp.post.title }}
{% endif %}
</h3> </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"> <div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
{% if grp.product_count > 0 %} {% if grp.product_count > 0 %}
@@ -69,6 +76,12 @@
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }} {{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
</span> </span>
{% endif %} {% 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>
</div> </div>
@@ -108,6 +121,12 @@
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }} {{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
</span> </span>
{% endif %} {% 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>
</div> </div>

View File

@@ -7,7 +7,7 @@
<ul class="divide-y divide-stone-100 text-xs sm:text-sm"> <ul class="divide-y divide-stone-100 text-xs sm:text-sm">
{% for item in order.items %} {% for item in order.items %}
<li> <li>
<a class="w-full py-2 flex gap-3" href="{{ market_url('/product/' + item.product.slug + '/') }}"> <a class="w-full py-2 flex gap-3" href="{{ market_product_url(item.product.slug) }}">
{# Thumbnail #} {# Thumbnail #}
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden"> <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 %} {% if item.product and item.product.image %}

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

@@ -18,8 +18,8 @@
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
{% import '_types/browse/desktop/_filter/search.html' as s %} {% from 'macros/search.html' import search_desktop %}
{{ s.search(current_local_href, search, search_count, hx_select) }} {{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% endblock %} {% endblock %}
{% block filter %} {% block filter %}

View File

@@ -5,7 +5,7 @@
</p> </p>
</div> </div>
<div class="md:hidden"> <div class="md:hidden">
{% import '_types/browse/mobile/_filter/search.html' as s %} {% from 'macros/search.html' import search_mobile %}
{{ s.search(current_local_href, search, search_count, hx_select) }} {{ search_mobile(current_local_href, search, search_count, hx_select) }}
</div> </div>
</header> </header>

View File

@@ -15,8 +15,8 @@
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
{% import '_types/browse/desktop/_filter/search.html' as s %} {% from 'macros/search.html' import search_desktop %}
{{ s.search(current_local_href, search, search_count, hx_select) }} {{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% endblock %} {% 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>