Compare commits

...

44 Commits

Author SHA1 Message Date
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
57 changed files with 421 additions and 1454 deletions

View File

@@ -2,7 +2,7 @@ name: Build and Deploy
on:
push:
branches: [main]
branches: [main, decoupling]
env:
REGISTRY: registry.rose-ash.com:5000
@@ -36,9 +36,23 @@ jobs:
run: |
ssh "root@$DEPLOY_HOST" "
cd ${{ env.REPO_DIR }}
git fetch origin main
git reset --hard origin/main
git fetch origin ${{ github.ref_name }}
git reset --hard origin/${{ github.ref_name }}
git submodule update --init --recursive
# Clean ALL sibling dirs (including stale self-copies from previous runs)
for sibling in blog market cart events; do
rm -rf \$sibling
done
# Copy non-self sibling models for cross-domain imports
for sibling in blog market cart events; 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

5
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "shared_lib"]
path = shared_lib
[submodule "shared"]
path = shared
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 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
@@ -17,14 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
&& 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
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 ----------
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

118
README.md
View File

@@ -1,71 +1,97 @@
# Cart App
Shopping cart, checkout, and order management service for the Rose Ash cooperative marketplace.
## 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
Shopping cart, checkout, and order management service for the Rose Ash cooperative.
## Architecture
- **Framework:** Quart (async Flask)
- **Database:** PostgreSQL 16 via SQLAlchemy 2.0 (async)
- **Payments:** SumUp Hosted Checkout
- **Frontend:** HTMX + Jinja2 templates + Tailwind CSS
One of four Quart microservices sharing a single PostgreSQL database:
## 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 |
## 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.py # Order, OrderItem
page_config.py # PageConfig (per-page SumUp credentials)
bp/
cart/ # Cart blueprint (add, view, checkout, webhooks)
routes.py
api.py # Internal API (server-to-server, CSRF-exempt)
login_helper.py # Cart merge on login
services/ # Business logic layer
order/ # Single order detail blueprint
routes.py
filters/qs.py # Query string helpers
orders/ # Order listing blueprint
routes.py
filters/qs.py
templates/
_types/cart/ # Cart templates
_types/order/ # Single order templates
_types/orders/ # Order listing templates
entrypoint.sh # Docker entrypoint (migrations + server start)
Dockerfile # Container build
.gitea/workflows/ci.yml # CI/CD pipeline
cart/ # Cart blueprint
global_routes.py # Add to cart, checkout, webhooks, return page
page_routes.py # Page-scoped cart and checkout
overview_routes.py # Cart overview / summary page
api.py # Internal API (/internal/cart/summary)
services/ # Business logic
checkout.py # Order creation, SumUp integration
check_sumup_status.py # Payment status polling
calendar_cart.py # Calendar entry cart queries
page_cart.py # Page-scoped cart queries
get_cart.py # Cart item queries
identity.py # Cart identity (user_id / session_id)
total.py # Price calculations
clear_cart_for_order.py # Soft-delete cart after checkout
order/ # Single order detail view
orders/ # Order listing view
templates/ # Jinja2 templates
entrypoint.sh # Docker entrypoint
Dockerfile
shared/ # Submodule → git.rose-ash.com/coop/shared.git
glue/ # Submodule → git.rose-ash.com/coop/glue.git
```
## Dependencies
**Cross-app model imports:**
- `market.models.market.Product, CartItem` — cart services, checkout, API
- `market.models.market_place.MarketPlace` — checkout page-config resolution, API page filtering
- `events.models.calendars.CalendarEntry, Calendar` — checkout, API summary, calendar cart services
- `blog.models.ghost_content.Post``app.py` context processor, API page-slug lookup
**Glue services:**
- `glue.services.order_lifecycle.claim_entries_for_order` — checkout marks entries as "ordered"
- `glue.services.order_lifecycle.confirm_entries_for_order` — payment confirmation marks entries "provisional"
- `glue.services.order_lifecycle.get_entries_for_order` — checkout return page loads entries
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
**Internal APIs:**
- Exposes `GET /internal/cart/summary` — cart count + total for current session/user
**Domain events:**
- `checkout.py` emits `order.created` via `shared.events.emit_event`
- `check_sumup_status.py` emits `order.paid` via `shared.events.emit_event`
## Checkout Flow
```
1. User clicks "Checkout"
2. create_order_from_cart() creates Order + OrderItems
3. glue: claim_entries_for_order() marks CalendarEntries as "ordered"
4. emit: order.created event
5. SumUp hosted checkout created, user redirected
6. SumUp webhook / return page triggers check_sumup_status()
7. If PAID: glue: confirm_entries_for_order(), emit: order.paid
```
## Running
```bash
# Set environment variables
export APP_MODULE=app:app
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
# Run the server
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
docker build -t cart .
docker run -p 8002:8000 --env-file .env cart
```

0
__init__.py Normal file
View File

39
app.py
View File

@@ -7,22 +7,22 @@ from quart import g, abort
from jinja2 import FileSystemLoader, ChoiceLoader
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_page_cart,
register_cart_global,
register_cart_api,
register_orders,
)
from suma_browser.app.bp.cart.services import (
from bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
)
from suma_browser.app.bp.cart.services.page_cart import (
from bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
)
@@ -40,13 +40,13 @@ async def cart_context() -> dict:
- cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html)
- menu_items: fetched from coop internal API
- menu_items: direct DB query via glue layer
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
"""
from shared.context import base_context
from shared.internal_api import get as api_get, dictobj
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
ctx = await base_context()
@@ -75,21 +75,21 @@ async def cart_context() -> dict:
ctx["total"] = total
ctx["calendar_total"] = calendar_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 []
ctx["menu_items"] = await get_navigation_tree(g.s)
return ctx
def create_app() -> "Quart":
from models.ghost_content import Post
from models.page_config import PageConfig
from shared.models.page_config import PageConfig
from shared.services.registry import services
from services import register_domain_services
app = create_base_app(
"cart",
context_fn=cart_context,
before_request_fns=[_load_cart],
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
@@ -99,6 +99,8 @@ def create_app() -> "Quart":
app.jinja_loader,
])
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
# --- Page slug hydration (follows events/market app pattern) ---
@app.url_value_preprocessor
@@ -118,17 +120,16 @@ def create_app() -> "Quart":
slug = getattr(g, "page_slug", None)
if not slug:
return
post = (
await g.s.execute(
select(Post).where(Post.slug == slug, Post.is_page == True) # noqa: E712
)
).scalar_one_or_none()
if not post:
post = await services.blog.get_post_by_slug(g.s, slug)
if not post or not post.is_page:
abort(404)
g.page_post = post
g.page_config = (
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()

View File

@@ -7,15 +7,14 @@ 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 import select
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
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.browser.app.csrf import csrf_exempt
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services
def register() -> Blueprint:
@@ -38,12 +37,8 @@ def register() -> Blueprint:
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:
post = await services.blog.get_post_by_slug(g.s, page_slug)
if post and post.is_page:
page_post_id = post.id
# --- product cart ---
@@ -55,7 +50,8 @@ def register() -> Blueprint:
if page_post_id is not None:
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),
).scalar_subquery()
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
@@ -72,25 +68,19 @@ def register() -> Blueprint:
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"])
# --- calendar entries via service ---
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()
cal_entries = await services.calendar.entries_for_page(
g.s, page_post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
else:
cal_entries = await services.calendar.pending_entries(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
calendar_count = len(cal_entries)
calendar_total = sum((e.cost or 0) for e in cal_entries if e.cost is not None)
@@ -118,57 +108,4 @@ def register() -> Blueprint:
}
)
@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,8 +5,10 @@ from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select
from models.order import Order
from suma_browser.app.utils.htmx import is_htmx_request
from shared.models.market import CartItem
from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.services.registry import services
from .services import (
current_cart_identity,
get_cart,
@@ -26,8 +28,7 @@ from .services.checkout import (
validate_webhook_secret,
get_order_with_details,
)
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
def register(url_prefix: str) -> Blueprint:
@@ -53,6 +54,34 @@ def register(url_prefix: str) -> Blueprint:
return redirect(url_for("cart_overview.overview"))
@bp.post("/quantity/<int:product_id>/")
async def update_quantity(product_id: int):
ident = current_cart_identity()
form = await request.form
count = int(form.get("count", 0))
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
existing = await g.s.scalar(select(CartItem).where(*filters))
if existing:
if count <= 0:
await g.s.delete(existing)
else:
existing.quantity = count
await g.s.flush()
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
return resp
@bp.post("/checkout/")
async def checkout():
"""Legacy global checkout (for orphan items without page scope)."""
@@ -179,15 +208,31 @@ def register(url_prefix: str) -> Blueprint:
)
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:
try:
await check_sumup_status(g.s, order)
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)
await g.s.flush()
html = await render_template(

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

View File

@@ -4,14 +4,13 @@ from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from .services import (
total,
clear_cart_for_order,
calendar_total,
check_sumup_status,
)
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page
from .services.checkout import (
@@ -19,7 +18,6 @@ from .services.checkout import (
build_sumup_description,
build_sumup_reference,
build_webhook_url,
get_order_with_details,
)
from .services import current_cart_identity

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,7 +2,6 @@ from .get_cart import get_cart
from .identity import current_cart_identity
from .total import total
from .clear_cart_for_order import clear_cart_for_order
from .adopt_session_cart_for_user import adopt_session_cart_for_user
from .calendar_cart import get_calendar_cart_entries, calendar_total
from .check_sumup_status import check_sumup_status
from .page_cart import (

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,38 +1,20 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from shared.services.registry import services
from .identity import current_cart_identity
async def get_calendar_cart_entries(session):
"""
Return all *pending* calendar entries for the current cart identity
(user or anonymous session).
Return all *pending* calendar entries (as CalendarEntryDTOs) for the
current cart identity (user or anonymous session).
"""
ident = current_cart_identity()
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
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 await services.calendar.pending_entries(
session,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
return result.scalars().all()
def calendar_total(entries) -> float:

View File

@@ -1,6 +1,6 @@
from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout
from sqlalchemy import update
from models.calendars import CalendarEntry
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_event
from shared.services.registry import services
async def check_sumup_status(session, order):
@@ -13,21 +13,13 @@ async def check_sumup_status(session, order):
if sumup_status == "PAID":
if order.status != "paid":
order.status = "paid"
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "ordered",
CalendarEntry.order_id==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(
update(CalendarEntry)
.where(*filters)
.values(state="provisional")
await services.calendar.confirm_entries_for_order(
session, order.id, order.user_id, order.session_id
)
await emit_event(session, "order.paid", "order", order.id, {
"order_id": order.id,
"user_id": order.user_id,
})
elif sumup_status == "FAILED":
order.status = "failed"
else:

View File

@@ -3,16 +3,18 @@ from __future__ import annotations
from typing import Optional
from urllib.parse import urlencode
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.market import Product, CartItem
from models.order import Order, OrderItem
from models.calendars import CalendarEntry, Calendar
from models.page_config import PageConfig
from models.market_place import MarketPlace
from config import config
from shared.models.market import Product, CartItem
from shared.models.order import Order, OrderItem
from shared.models.page_config import PageConfig
from shared.models.market_place import MarketPlace
from shared.config import config
from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_event
from shared.services.registry import services
async def find_or_create_cart_item(
@@ -62,7 +64,7 @@ async def find_or_create_cart_item(
async def resolve_page_config(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntry],
calendar_entries: list[CalendarEntryDTO],
) -> Optional["PageConfig"]:
"""Determine the PageConfig for this order.
@@ -76,13 +78,12 @@ async def resolve_page_config(
if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id)
if mp:
post_ids.add(mp.post_id)
post_ids.add(mp.container_id)
# From calendar entries via calendar
for entry in calendar_entries:
cal = await session.get(Calendar, entry.calendar_id)
if cal and cal.post_id:
post_ids.add(cal.post_id)
if entry.calendar_container_id:
post_ids.add(entry.calendar_container_id)
if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages")
@@ -92,7 +93,10 @@ async def resolve_page_config(
post_id = post_ids.pop()
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()
return pc
@@ -100,7 +104,7 @@ async def resolve_page_config(
async def create_order_from_cart(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntry],
calendar_entries: list[CalendarEntryDTO],
user_id: Optional[int],
session_id: Optional[str],
product_total: float,
@@ -145,33 +149,17 @@ async def create_order_from_cart(
)
session.add(oi)
# Update calendar entries to reference this order
calendar_filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if order.user_id is not None:
calendar_filters.append(CalendarEntry.user_id == order.user_id)
elif order.session_id is not None:
calendar_filters.append(CalendarEntry.session_id == order.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()
calendar_filters.append(CalendarEntry.calendar_id.in_(cal_ids))
await session.execute(
update(CalendarEntry)
.where(*calendar_filters)
.values(
state="ordered",
order_id=order.id,
)
# Mark pending calendar entries as "ordered" via calendar service
await services.calendar.claim_entries_for_order(
session, order.id, user_id, session_id, page_post_id
)
await emit_event(session, "order.created", "order", order.id, {
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
})
return order
@@ -230,7 +218,6 @@ async def get_order_with_details(session: AsyncSession, order_id: int) -> Option
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
selectinload(Order.calendar_entries),
)
.where(Order.id == order_id)
)

View File

@@ -1,8 +1,8 @@
from sqlalchemy import update, func, select
from models.market import CartItem
from models.market_place import MarketPlace
from models.order import Order
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.order import Order
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:
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),
).scalar_subquery()
filters.append(CartItem.market_place_id.in_(mp_ids))

View File

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

View File

@@ -1,4 +1,4 @@
# 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"]

View File

@@ -2,7 +2,8 @@
Page-scoped cart queries.
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
@@ -11,21 +12,21 @@ from collections import defaultdict
from sqlalchemy import select
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 models.page_config import PageConfig
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.page_config import PageConfig
from shared.services.registry import services
from .identity import current_cart_identity
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()
filters = [
CartItem.deleted_at.is_(None),
MarketPlace.post_id == post_id,
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
]
if ident["user_id"] is not None:
@@ -46,29 +47,14 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
return result.scalars().all()
async def get_calendar_entries_for_page(session, post_id: int) -> list[CalendarEntry]:
"""Return pending calendar entries scoped to a specific page (via Calendar.post_id)."""
async def get_calendar_entries_for_page(session, post_id: int):
"""Return pending calendar entries (DTOs) scoped to a specific page."""
ident = current_cart_identity()
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
Calendar.post_id == post_id,
Calendar.deleted_at.is_(None),
]
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)
.join(Calendar, CalendarEntry.calendar_id == Calendar.id)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(selectinload(CalendarEntry.calendar))
return await services.calendar.entries_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
return result.scalars().all()
async def get_cart_grouped_by_page(session) -> list[dict]:
@@ -99,7 +85,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session)
# Group by post_id
# Group by container_id (all current data has container_type="page")
groups: dict[int | None, dict] = defaultdict(lambda: {
"post_id": None,
"cart_items": [],
@@ -107,38 +93,38 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
})
for ci in cart_items:
if ci.market_place and ci.market_place.post_id:
pid = ci.market_place.post_id
if ci.market_place and ci.market_place.container_id:
pid = ci.market_place.container_id
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["cart_items"].append(ci)
for ce in cal_entries:
if ce.calendar and ce.calendar.post_id:
pid = ce.calendar.post_id
if ce.calendar_container_id:
pid = ce.calendar_container_id
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["calendar_entries"].append(ce)
# Batch-load Post and PageConfig objects
# Batch-load Post DTOs and PageConfig objects
post_ids = [pid for pid in groups if pid is not None]
posts_by_id: dict[int, Post] = {}
posts_by_id: dict[int, object] = {}
configs_by_post: dict[int, PageConfig] = {}
if post_ids:
post_result = await session.execute(
select(Post).where(Post.id.in_(post_ids))
)
for p in post_result.scalars().all():
for p in await services.blog.get_posts_by_ids(session, post_ids):
posts_by_id[p.id] = p
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():
configs_by_post[pc.post_id] = pc
configs_by_post[pc.container_id] = pc
# Build result list (pages first, orphan last)
result = []

View File

@@ -3,8 +3,8 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import KEEP, build_qs
from suma_browser.app.filters.query_types import OrderQuery
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import 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 models.market import Product
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.models.market import Product
from shared.models.order import Order, OrderItem
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from shared.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 suma_browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from .filters.qs import makeqs_factory, decode

View File

@@ -3,8 +3,8 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import KEEP, build_qs
from suma_browser.app.filters.query_types import OrderQuery
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import 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 models.market import Product
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.models.market import Product
from shared.models.order import Order, OrderItem
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from shared.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 suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.bp import register_order
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from bp.cart.services import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from bp import register_order
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
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
def route():
# this is the crucial bit for the |qs filter

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

@@ -0,0 +1,83 @@
# 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
coop_root: /market
coop_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
coop: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
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 os
# Add the shared library submodule to the Python path
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
if _shared not in sys.path:
sys.path.insert(0, _shared)
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

25
services/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""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()

1
shared Submodule

Submodule shared added at 7b55d78214

Submodule shared_lib deleted from 0c9b8d6aa2

View File

@@ -43,7 +43,7 @@
<li class="flex items-start justify-between text-sm">
<div>
<div class="font-medium">
{{ entry.name or entry.calendar.name }}
{{ entry.name or entry.calendar_name }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('auth-header-child', 'orders-header-child', '_types/orders/header/_header.html')}}
{% from '_types/auth/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block aside %}
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% endblock %}
{% block filter %}
{% include '_types/orders/_summary.html' %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/orders/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/orders/_main_panel.html" %}
{% endblock %}

View File

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

View File

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

View File

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

View File

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