Monorepo: consolidate 7 repos into one

Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

8
cart/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.env
node_modules/
*.egg-info/
dist/
build/
.venv/

50
cart/Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
# ---------- Python application ----------
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
WORKDIR /app
# Install system deps + psql client
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code (replaces submodule)
COPY shared/ ./shared/
# App code
COPY cart/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
# ---------- Runtime setup ----------
COPY cart/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

76
cart/README.md Normal file
View File

@@ -0,0 +1,76 @@
# Cart App
Shopping cart, checkout, and order management service for the Rose Ash cooperative.
## Architecture
One of five Quart microservices sharing a single PostgreSQL database:
| 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 # 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/
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
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
services/ # register_domain_services() — wires cart + calendar + market
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
```
## 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
```bash
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
hypercorn app:app --bind 0.0.0.0:8002
```

0
cart/__init__.py Normal file
View File

235
cart/app.py Normal file
View File

@@ -0,0 +1,235 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from decimal import Decimal
from pathlib import Path
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_orders,
register_fragments,
)
from bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
)
from bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
async def _load_cart():
"""Load the full cart for the cart app (before each request)."""
g.cart = await get_cart(g.s)
async def cart_context() -> dict:
"""
Cart app context processor.
- cart / calendar_cart_entries / total / calendar_total: direct DB
(cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html)
- nav_tree_html: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context()
ctx["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
params={"app_name": "cart", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or []
all_cal = await get_calendar_cart_entries(g.s)
all_tickets = await get_ticket_cart_entries(g.s)
# Global counts for cart-mini (always global)
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
ctx["cart_count"] = cart_qty + len(all_cal) + len(all_tickets)
ctx["cart_total"] = (total(all_cart) or Decimal(0)) + (calendar_total(all_cal) or Decimal(0)) + (ticket_total(all_tickets) or Decimal(0))
# Page-scoped data when viewing a page cart
page_post = getattr(g, "page_post", None)
if page_post:
page_cart = await get_cart_for_page(g.s, page_post.id)
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
page_tickets = await get_tickets_for_page(g.s, page_post.id)
ctx["cart"] = page_cart
ctx["calendar_cart_entries"] = page_cal
ctx["ticket_cart_entries"] = page_tickets
ctx["page_post"] = page_post
ctx["page_config"] = getattr(g, "page_config", None)
else:
ctx["cart"] = all_cart
ctx["calendar_cart_entries"] = all_cal
ctx["ticket_cart_entries"] = all_tickets
ctx["ticket_groups"] = group_tickets(ctx.get("ticket_cart_entries", []))
ctx["total"] = total
ctx["calendar_total"] = calendar_total
ctx["ticket_total"] = ticket_total
return ctx
def create_app() -> "Quart":
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
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
# --- Page slug hydration (follows events/market app pattern) ---
@app.url_value_preprocessor
def pull_page_slug(endpoint, values):
if values and "page_slug" in values:
g.page_slug = values.pop("page_slug")
@app.url_defaults
def inject_page_slug(endpoint, values):
slug = g.get("page_slug")
if slug and "page_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "page_slug"):
values["page_slug"] = slug
@app.before_request
async def hydrate_page():
slug = getattr(g, "page_slug", None)
if not slug:
return
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.container_type == "page",
PageConfig.container_id == post.id,
)
)
).scalar_one_or_none()
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
# Orders blueprint
app.register_blueprint(register_orders(url_prefix="/orders"))
# Global routes (webhook, return, add — specific paths under /)
app.register_blueprint(
register_cart_global(url_prefix="/"),
url_prefix="/",
)
# Cart overview at GET /
app.register_blueprint(
register_cart_overview(url_prefix="/"),
url_prefix="/",
)
# Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint(
register_page_cart(url_prefix="/"),
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
app = create_app()

6
cart/bp/__init__.py Normal file
View File

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

View File

@@ -0,0 +1,294 @@
# bp/cart/global_routes.py — Global cart routes (webhook, return, add)
from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select
from shared.models.market import CartItem
from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.services.registry import services
from .services import (
current_cart_identity,
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
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 shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.browser.app.csrf import csrf_exempt
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_global", __name__, url_prefix=url_prefix)
@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)
if request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX
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/")
async def checkout():
"""Legacy global checkout (for orphan items without page scope)."""
cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s)
tickets = await get_ticket_cart_entries(g.s)
if not cart and not calendar_entries and not tickets:
return redirect(url_for("cart_overview.overview"))
product_total = total(cart) or 0
calendar_amount = calendar_total(calendar_entries) or 0
ticket_amount = ticket_total(tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("cart_overview.overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
except ValueError as e:
html = await render_template(
"_types/cart/checkout_error.html",
order=None,
error=str(e),
)
return await make_response(html, 400)
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,
ticket_total=ticket_amount,
)
if page_config:
order.page_config_id = page_config.id
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
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)
@csrf_exempt
@bp.post("/checkout/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int):
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
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
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return "", 204
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
return "", 204
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)
# 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:
pass
status = (order.status or "pending").lower()
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id)
await g.s.flush()
html = await render_template(
"_types/cart/checkout_return.html",
order=order,
status=status,
calendar_entries=calendar_entries,
order_tickets=order_tickets,
)
return await make_response(html)
return bp

View File

@@ -0,0 +1,31 @@
# bp/cart/overview_routes.py — Cart overview (list of page carts)
from __future__ import annotations
from quart import Blueprint, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from .services import get_cart_grouped_by_page
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_overview", __name__, url_prefix=url_prefix)
@bp.get("/")
async def overview():
from quart import g
page_groups = await get_cart_grouped_by_page(g.s)
if not is_htmx_request():
html = await render_template(
"_types/cart/overview/index.html",
page_groups=page_groups,
)
else:
html = await render_template(
"_types/cart/overview/_oob_elements.html",
page_groups=page_groups,
)
return await make_response(html)
return bp

129
cart/bp/cart/page_routes.py Normal file
View File

@@ -0,0 +1,129 @@
# bp/cart/page_routes.py — Per-page cart (view + checkout)
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for
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,
calendar_total,
ticket_total,
)
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
from .services.ticket_groups import group_tickets
from .services.checkout import (
create_order_from_cart,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
)
from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.get("/")
async def page_view():
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
ticket_groups = group_tickets(page_tickets)
tpl_ctx = dict(
page_post=post,
page_config=getattr(g, "page_config", None),
cart=cart,
calendar_cart_entries=cal_entries,
ticket_cart_entries=page_tickets,
ticket_groups=ticket_groups,
total=total,
calendar_total=calendar_total,
ticket_total=ticket_total,
)
if not is_htmx_request():
html = await render_template("_types/cart/page/index.html", **tpl_ctx)
else:
html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx)
return await make_response(html)
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
page_config = getattr(g, "page_config", None)
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
page_tickets = await get_tickets_for_page(g.s, post.id)
if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
ticket_amount = ticket_total(page_tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount
if cart_total <= 0:
return redirect(url_for("page_cart.page_view"))
# Create order scoped to this page
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
cal_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
page_post_id=post.id,
)
# Set page_config on order
if page_config:
order.page_config_id = page_config.id
# Build SumUp checkout details — webhook/return use global routes
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
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)
return bp

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_activity
from shared.services.registry import services
from .clear_cart_for_order import clear_cart_for_order
async def check_sumup_status(session, order):
# Use order's page_config for per-page SumUp credentials
page_config = getattr(order, "page_config", None)
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
order.sumup_status = checkout_data.get("status") or order.sumup_status
sumup_status = (order.sumup_status or "").upper()
if sumup_status == "PAID":
if order.status != "paid":
order.status = "paid"
await services.calendar.confirm_entries_for_order(
session, order.id, order.user_id, order.session_id
)
await services.calendar.confirm_tickets_for_order(session, order.id)
# Clear cart only after payment is confirmed
page_post_id = page_config.container_id if page_config else None
await clear_cart_for_order(session, order, page_post_id=page_post_id)
await emit_activity(
session,
activity_type="rose:OrderPaid",
actor_uri="internal:cart",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": order.user_id,
},
source_type="order",
source_id=order.id,
)
elif sumup_status == "FAILED":
order.status = "failed"
else:
order.status = sumup_status.lower() or order.status
await session.flush()

View File

@@ -0,0 +1,248 @@
from __future__ import annotations
from typing import Optional
from urllib.parse import urlencode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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_activity
from shared.services.registry import services
async def find_or_create_cart_item(
session: AsyncSession,
product_id: int,
user_id: Optional[int],
session_id: Optional[str],
) -> Optional[CartItem]:
"""
Find an existing cart item for this product/identity, or create a new one.
Returns None if the product doesn't exist.
Increments quantity if item already exists.
"""
# Make sure product exists
product = await session.scalar(
select(Product).where(Product.id == product_id)
)
if not product:
return None
# Look for existing cart item
filters = [
CartItem.deleted_at.is_(None),
CartItem.product_id == product_id,
]
if user_id is not None:
filters.append(CartItem.user_id == user_id)
else:
filters.append(CartItem.session_id == session_id)
existing = await session.scalar(select(CartItem).where(*filters))
if existing:
existing.quantity += 1
return existing
else:
cart_item = CartItem(
user_id=user_id,
session_id=session_id,
product_id=product.id,
quantity=1,
)
session.add(cart_item)
return cart_item
async def resolve_page_config(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntryDTO],
tickets=None,
) -> Optional["PageConfig"]:
"""Determine the PageConfig for this order.
Returns PageConfig or None (use global credentials).
Raises ValueError if items span multiple pages.
"""
post_ids: set[int] = set()
# From cart items via market_place
for ci in cart:
if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id)
if mp:
post_ids.add(mp.container_id)
# From calendar entries via calendar
for entry in calendar_entries:
if entry.calendar_container_id:
post_ids.add(entry.calendar_container_id)
# From tickets via calendar_container_id
for tk in (tickets or []):
if tk.calendar_container_id:
post_ids.add(tk.calendar_container_id)
if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages")
if not post_ids:
return None # global credentials
post_id = post_ids.pop()
pc = (await session.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post_id,
)
)).scalar_one_or_none()
return pc
async def create_order_from_cart(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntryDTO],
user_id: Optional[int],
session_id: Optional[str],
product_total: float,
calendar_total: float,
*,
ticket_total: float = 0,
page_post_id: int | None = None,
) -> Order:
"""
Create an Order and OrderItems from the current cart + calendar entries + tickets.
When *page_post_id* is given, only calendar entries/tickets whose calendar
belongs to that page are marked as "ordered". Otherwise all pending
entries are updated (legacy behaviour).
"""
cart_total = product_total + calendar_total + ticket_total
# Determine currency from first product
first_product = cart[0].product if cart else None
currency = (first_product.regular_price_currency if first_product else None) or "GBP"
# Create order
order = Order(
user_id=user_id,
session_id=session_id,
status="pending",
currency=currency,
total_amount=cart_total,
)
session.add(order)
await session.flush()
# Create order items from cart
for ci in cart:
price = ci.product.special_price or ci.product.regular_price or 0
oi = OrderItem(
order=order,
product_id=ci.product.id,
product_title=ci.product.title,
quantity=ci.quantity,
unit_price=price,
currency=currency,
)
session.add(oi)
# 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
)
# Claim reserved tickets for this order
await services.calendar.claim_tickets_for_order(
session, order.id, user_id, session_id, page_post_id
)
await emit_activity(
session,
activity_type="Create",
actor_uri="internal:cart",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
},
source_type="order",
source_id=order.id,
)
return order
def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str:
"""Build a human-readable description for SumUp checkout."""
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
item_count = sum(ci.quantity for ci in cart)
parts = []
if titles:
if len(titles) <= 3:
parts.append(", ".join(titles))
else:
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
if ticket_count:
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
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:
"""Build a SumUp reference with configured prefix."""
if page_config and page_config.sumup_checkout_prefix:
prefix = page_config.sumup_checkout_prefix
else:
sumup_cfg = config().get("sumup", {}) or {}
prefix = sumup_cfg.get("checkout_reference_prefix", "")
return f"{prefix}{order_id}"
def build_webhook_url(base_url: str) -> str:
"""Add webhook secret token to URL if configured."""
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if webhook_secret:
sep = "&" if "?" in base_url else "?"
return f"{base_url}{sep}{urlencode({'token': webhook_secret})}"
return base_url
def validate_webhook_secret(token: Optional[str]) -> bool:
"""Validate webhook token against configured secret."""
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if not webhook_secret:
return True # No secret configured, allow all
return token is not None and token == webhook_secret
async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]:
"""Fetch an order with items and calendar entries eagerly loaded."""
result = await session.execute(
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product),
)
.where(Order.id == order_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,37 @@
from sqlalchemy import update, func, select
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:
"""
Soft-delete CartItem rows belonging to this order's user_id/session_id.
When *page_post_id* is given, only items whose market_place belongs to
that page are cleared. Otherwise all items are cleared (legacy behaviour).
"""
filters = [CartItem.deleted_at.is_(None)]
if order.user_id is not None:
filters.append(CartItem.user_id == order.user_id)
if order.session_id is not None:
filters.append(CartItem.session_id == order.session_id)
if len(filters) == 1:
# no user_id/session_id on order nothing to clear
return
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
filters.append(CartItem.market_place_id.in_(mp_ids))
await session.execute(
update(CartItem)
.where(*filters)
.values(deleted_at=func.now())
)

View File

@@ -0,0 +1,25 @@
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from .identity import current_cart_identity
async def get_cart(session):
ident = current_cart_identity()
filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()

View File

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

View File

@@ -0,0 +1,212 @@
"""
Page-scoped cart queries.
Groups cart items and calendar entries by their owning page (Post),
determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id
(where container_type == "page").
"""
from __future__ import annotations
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.orm import selectinload
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.container_id)."""
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
MarketPlace.container_type == "page",
MarketPlace.container_id == post_id,
MarketPlace.deleted_at.is_(None),
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.join(MarketPlace, CartItem.market_place_id == MarketPlace.id)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()
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()
return await services.calendar.entries_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
async def get_tickets_for_page(session, post_id: int):
"""Return reserved tickets (DTOs) scoped to a specific page."""
ident = current_cart_identity()
return await services.calendar.tickets_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
async def get_cart_grouped_by_page(session) -> list[dict]:
"""
Load all cart items + calendar entries for the current identity,
grouped by market_place (one card per market).
Returns a list of dicts:
{
"post": Post | None,
"page_config": PageConfig | None,
"market_place": MarketPlace | None,
"cart_items": [...],
"calendar_entries": [...],
"product_count": int,
"product_total": float,
"calendar_count": int,
"calendar_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).
"""
from .get_cart import get_cart
from .calendar_cart import get_calendar_cart_entries, get_ticket_cart_entries
from .total import total as calc_product_total
from .calendar_cart import calendar_total as calc_calendar_total, ticket_total as calc_ticket_total
cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session)
all_tickets = await get_ticket_cart_entries(session)
# Group cart items by market_place_id
market_groups: dict[int | None, dict] = {}
for ci in cart_items:
mp_id = ci.market_place_id if ci.market_place else None
if mp_id not in market_groups:
market_groups[mp_id] = {
"market_place": ci.market_place,
"post_id": ci.market_place.container_id if ci.market_place else None,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
market_groups[mp_id]["cart_items"].append(ci)
# 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:
pid = ce.calendar_container_id or None
if pid in page_to_market:
market_groups[page_to_market[pid]]["calendar_entries"].append(ce)
else:
# Create a page-level group for calendar-only entries
key = ("cal", pid)
if key not in market_groups:
market_groups[key] = {
"market_place": None,
"post_id": pid,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
if pid is not None:
page_to_market[pid] = key
market_groups[key]["calendar_entries"].append(ce)
# Attach tickets to page groups (via calendar_container_id)
for tk in all_tickets:
pid = tk.calendar_container_id or None
if pid in page_to_market:
market_groups[page_to_market[pid]]["tickets"].append(tk)
else:
key = ("tk", pid)
if key not in market_groups:
market_groups[key] = {
"market_place": None,
"post_id": pid,
"cart_items": [],
"calendar_entries": [],
"tickets": [],
}
if pid is not None:
page_to_market[pid] = key
market_groups[key]["tickets"].append(tk)
# Batch-load Post DTOs and PageConfig objects
post_ids = 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] = {}
if post_ids:
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.container_type == "page",
PageConfig.container_id.in_(post_ids),
)
)
for pc in pc_result.scalars().all():
configs_by_post[pc.container_id] = pc
# Build result list (markets with pages first, orphan last)
result = []
for _key, grp in sorted(
market_groups.items(),
key=lambda kv: (kv[1]["post_id"] is None, kv[1]["post_id"] or 0),
):
items = grp["cart_items"]
entries = grp["calendar_entries"]
tks = grp["tickets"]
prod_total = calc_product_total(items) or 0
cal_total = calc_calendar_total(entries) or 0
tk_total = calc_ticket_total(tks) or 0
pid = grp["post_id"]
result.append({
"post": posts_by_id.get(pid) if pid else None,
"page_config": configs_by_post.get(pid) if pid else None,
"market_place": grp["market_place"],
"cart_items": items,
"calendar_entries": entries,
"tickets": tks,
"product_count": sum(ci.quantity for ci in items),
"product_total": prod_total,
"calendar_count": len(entries),
"calendar_total": cal_total,
"ticket_count": len(tks),
"ticket_total": tk_total,
"total": prod_total + cal_total + tk_total,
})
return result

View File

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

View File

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

View File

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

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

@@ -0,0 +1,74 @@
# suma_browser/app/bp/order/filters/qs.py
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:
"""
Decode current query string into an OrderQuery(page, search).
"""
try:
page = int(request.args.get("page", 1) or 1)
except ValueError:
page = 1
search = request.args.get("search") or None
return OrderQuery(page, search)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Behaviour:
- If filters change and you don't explicitly pass page,
the page is reset to 1 (same pattern as browse/blog).
- You can clear search with search=None.
"""
q = decode()
base_search = q.search or None
base_page = int(q.page or 1)
def makeqs(
*,
clear_filters: bool = False,
search: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
) -> str:
filters_changed = False
# --- search logic ---
if search is KEEP and not clear_filters:
final_search = base_search
else:
filters_changed = True
final_search = (search or None)
# --- page logic ---
if page is None:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# --- build params ---
params: list[tuple[str, str]] = []
if final_search:
params.append(("search", final_search))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

137
cart/bp/order/routes.py Normal file
View File

@@ -0,0 +1,137 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
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.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
def register() -> Blueprint:
bp = Blueprint("order", __name__, url_prefix='/<int:order_id>')
ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference
@bp.before_request
def route():
# this is the crucial bit for the |qs filter
g.makeqs_factory = makeqs_factory
@bp.get("/")
async def order_detail(order_id: int):
"""
Show a single order + items.
"""
result = await g.s.execute(
select(Order)
.options(
selectinload(Order.items).selectinload(OrderItem.product)
)
.where(Order.id == order_id)
)
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/order/index.html", order=order,)
else:
# HTMX navigation (page 1): main panel + OOB elements
html = await render_template("_types/order/_oob_elements.html", order=order,)
return await make_response(html)
@bp.get("/pay/")
async def order_pay(order_id: int):
"""
Re-open the SumUp payment page for this order.
If already paid, just go back to the order detail.
If not, (re)create a SumUp checkout and redirect.
"""
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if order.status == "paid":
# Already paid; nothing to pay
return redirect(url_for("orders.order.order_detail", order_id=order.id))
# Prefer to reuse existing hosted URL if we have one
if order.sumup_hosted_url:
return redirect(order.sumup_hosted_url)
# Otherwise, create a fresh checkout for this order
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
webhook_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
if webhook_secret:
from urllib.parse import urlencode
sep = "&" if "?" in webhook_url else "?"
webhook_url = f"{webhook_url}{sep}{urlencode({'token': webhook_secret})}"
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
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 when trying to reopen payment.",
)
return await make_response(html, 500)
return redirect(hosted_url)
@bp.post("/recheck/")
async def order_recheck(order_id: int):
"""
Manually re-check this order's status with SumUp.
Useful if the webhook hasn't fired or the user didn't return correctly.
"""
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
# If we don't have a checkout ID yet, nothing to query
if not order.sumup_checkout_id:
return redirect(url_for("orders.order.order_detail", order_id=order.id))
try:
await check_sumup_status(g.s, order)
except Exception:
# In a real app, log the error; here we just fall back to previous status
pass
return redirect(url_for("orders.order.order_detail", order_id=order.id))
return bp

View File

@@ -0,0 +1,77 @@
# suma_browser/app/bp/orders/filters/qs.py
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:
"""
Decode current query string into an OrderQuery(page, search).
"""
try:
page = int(request.args.get("page", 1) or 1)
except ValueError:
page = 1
search = request.args.get("search") or None
return OrderQuery(page, search)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Behaviour:
- If filters change and you don't explicitly pass page,
the page is reset to 1 (same pattern as browse/blog).
- You can clear search with search=None.
"""
q = decode()
base_search = q.search or None
base_page = int(q.page or 1)
def makeqs(
*,
clear_filters: bool = False,
search: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
) -> str:
filters_changed = False
# --- search logic ---
if search is KEEP and not clear_filters:
final_search = base_search
else:
filters_changed = True
if search is KEEP:
final_search = None
else:
final_search = (search or None)
# --- page logic ---
if page is None:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# --- build params ---
params: list[tuple[str, str]] = []
if final_search:
params.append(("search", final_search))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

151
cart/bp/orders/routes.py Normal file
View File

@@ -0,0 +1,151 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
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.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
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("orders", __name__, url_prefix=url_prefix)
bp.register_blueprint(
register_order(),
)
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
g.makeqs_factory = makeqs_factory
@bp.get("/")
async def list_orders():
# --- decode filters from query string (page + search) ---
q = decode()
page, search = q.page, q.search
# sanity clamp page
if page < 1:
page = 1
# --- build where clause for search ---
where_clause = None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1)
.select_from(OrderItem)
.join(Product, Product.id == OrderItem.product_id)
.where(
OrderItem.order_id == Order.id,
or_(
OrderItem.product_title.ilike(term),
Product.title.ilike(term),
Product.description_short.ilike(term),
Product.description_html.ilike(term),
Product.slug.ilike(term),
Product.brand.ilike(term),
),
)
)
)
# allow exact ID match or partial (string) match
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
# --- total count & total pages (respecting search) ---
count_stmt = select(func.count()).select_from(Order)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
# clamp page if beyond range (just in case)
if page > total_pages:
page = total_pages
# --- paginated orders (respecting search) ---
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
context = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count, # For search display
}
# Determine which template to use based on request type and pagination
if not is_htmx_request():
# Normal browser request: full page with layout
html = await render_template("_types/orders/index.html", **context)
elif page > 1:
# HTMX pagination: just table rows + sentinel
html = await render_template("_types/orders/_rows.html", **context)
else:
# HTMX navigation (page 1): main panel + OOB elements
html = await render_template("_types/orders/_oob_elements.html", **context)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp)
return bp

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

29
cart/entrypoint.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# Optional: wait for Postgres to be reachable
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
for i in {1..60}; do
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
sleep 1
done
fi
# NOTE: Cart app does NOT run Alembic migrations.
# Migrations are managed by the blog app which owns the shared database schema.
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
echo "Flushing Redis cache..."
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushall()
print('Redis cache cleared.')
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000}

2
cart/models/__init__.py Normal file
View File

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

1
cart/models/order.py Normal file
View File

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

View File

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

9
cart/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_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)

28
cart/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()

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

@@ -0,0 +1,260 @@
{% macro show_cart(oob=False) %}
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
{# Empty cart #}
{% if not cart and not calendar_cart_entries and not ticket_cart_entries %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
</div>
<p class="text-base sm:text-lg font-medium text-stone-800">
Your cart is empty
</p>
{#
<p class="mt-1 text-xs sm:text-sm text-stone-600">
Add some items from the shop to see them here.
</p>
<div class="mt-4">
<a
href="{{ market_url('/') }}"
class="inline-flex items-center px-4 py-2 text-sm font-semibold rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
>
Browse products
</a>
</div> #}
</div>
{% else %}
<div _class="grid gap-y-6 lg:gap-8 lg:grid-cols-[minmax(0,2fr),minmax(0,1fr)]">
{# Items list #}
<section class="space-y-3 sm:space-y-4">
{% for item in cart %}
{% from '_types/product/_cart.html' import cart_item with context %}
{{ cart_item()}}
{% endfor %}
{% if calendar_cart_entries %}
<div class="mt-6 border-t border-stone-200 pt-4">
<h2 class="text-base font-semibold mb-2">
Calendar bookings
</h2>
<ul class="space-y-2">
{% for entry in calendar_cart_entries %}
<li class="flex items-start justify-between text-sm">
<div>
<div class="font-medium">
{{ entry.name or entry.calendar_name }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at }}
{% if entry.end_at %}
{{ entry.end_at }}
{% endif %}
</div>
</div>
<div class="ml-4 font-medium">
£{{ "%.2f"|format(entry.cost or 0) }}
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if ticket_groups is defined and ticket_groups %}
<div class="mt-6 border-t border-stone-200 pt-4">
<h2 class="text-base font-semibold mb-2">
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
Event tickets
</h2>
<div class="space-y-3">
{% for tg in ticket_groups %}
<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">
<div class="flex-1 min-w-0">
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
<div class="min-w-0">
<h3 class="text-sm sm:text-base font-semibold text-stone-900">
{{ tg.entry_name }}
</h3>
{% if tg.ticket_type_name %}
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
{{ tg.ticket_type_name }}
</p>
{% endif %}
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
{{ tg.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
{% if tg.entry_end_at %}
{{ tg.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
{% endif %}
</p>
</div>
<div class="text-left sm:text-right">
<p class="text-sm sm:text-base font-semibold text-stone-900">
£{{ "%.2f"|format(tg.price or 0) }}
</p>
</div>
</div>
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
{% set qty_url = url_for('cart_global.update_ticket_quantity') %}
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
{% if tg.ticket_type_id %}
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
{% endif %}
<input type="hidden" name="count" value="{{ [tg.quantity - 1, 0] | max }}">
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
-
</button>
</form>
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
{{ tg.quantity }}
</span>
<form
action="{{ qty_url }}"
method="post"
hx-post="{{ qty_url }}"
hx-swap="none"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
{% if tg.ticket_type_id %}
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
{% endif %}
<input type="hidden" name="count" value="{{ tg.quantity + 1 }}">
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
+
</button>
</form>
</div>
<div class="flex items-center justify-between sm:justify-end gap-3">
<p class="text-sm sm:text-base font-semibold text-stone-900">
Line total:
£{{ "%.2f"|format(tg.line_total) }}
</p>
</div>
</div>
</div>
</article>
{% endfor %}
</div>
</div>
{% endif %}
</section>
{{summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries)}}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries, oob=False) %}
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
<div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
Order summary
</h2>
<dl class="space-y-2 text-xs sm:text-sm">
<div class="flex items-center justify-between">
<dt class="text-stone-600">Items</dt>
<dd class="text-stone-900">
{% set product_qty = cart | sum(attribute="quantity") %}
{% set ticket_qty = ticket_cart_entries | length if ticket_cart_entries else 0 %}
{{ product_qty + ticket_qty }}
</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-stone-600">Subtotal</dt>
<dd class="text-stone-900">
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) }}
</dd>
</div>
</dl>
<div class="flex flex-col items-center w-full">
<h1 class="text-5xl mt-2">
This is a test - it will not take actual money
</h1>
<div>
use dummy card number: 5555 5555 5555 4444
</div>
</div>
<div class="mt-4 sm:mt-5">
{% if g.user %}
<form
method="post"
action="{{ url_for('page_cart.page_checkout')|host if page_post is defined and page_post else url_for('cart_global.checkout')|host }}"
class="w-full"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="w-full inline-flex items-center justify-center px-4 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-solid fa-credit-card mr-2" aria-hidden="true"></i>
Checkout as {{g.user.email}}
</button>
</form>
{% else %}
{% set href=login_url(request.url) %}
<div class="w-full flex">
<a
href="{{ 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"
>
<i class="fa-solid fa-key"></i>
<span>sign in or register to checkout</span>
</a>
</div>
{% endif %}
</div>
</div>
</aside>
{% endmacro %}
{% macro cart_total(cart, total) %}
{% set cart_total = total(cart) %}
{% if cart_total %}
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
{{ symbol }}{{ "%.2f"|format(cart_total) }}
{% else %}
{% endif %}
{% endmacro %}
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) %}
{% set product_total = total(cart) or 0 %}
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
{% set tk_total = ticket_total(ticket_cart_entries) or 0 %}
{% set grand = product_total + cal_total + tk_total %}
{% if cart and cart[0].product.regular_price_currency %}
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
{% else %}
{% set symbol = "£" %}
{% endif %}
{{ symbol }}{{ "%.2f"|format(grand) }}
{% endmacro %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,147 @@
<div class="max-w-full px-3 py-3 space-y-3">
{% if not page_groups or (page_groups | length == 0) %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
</div>
<p class="text-base sm:text-lg font-medium text-stone-800">
Your cart is empty
</p>
</div>
{% else %}
{# Check if there are any items at all across all groups #}
{% set ns = namespace(has_items=false) %}
{% for grp in page_groups %}
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
{% set ns.has_items = true %}
{% endif %}
{% endfor %}
{% if not ns.has_items %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
</div>
<p class="text-base sm:text-lg font-medium text-stone-800">
Your cart is empty
</p>
</div>
{% else %}
<div class="space-y-4">
{% for grp in page_groups %}
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
{% if grp.post %}
{# Market / page cart card #}
<a
href="{{ cart_url('/' + grp.post.slug + '/') }}"
class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
>
<div class="flex items-start gap-4">
{% if grp.post.feature_image %}
<img
src="{{ grp.post.feature_image }}"
alt="{{ grp.post.title }}"
class="h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0"
>
{% else %}
<div class="h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0">
<i class="fa fa-store text-stone-400 text-xl" aria-hidden="true"></i>
</div>
{% endif %}
<div class="flex-1 min-w-0">
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
{% if grp.market_place %}
{{ grp.market_place.name }}
{% else %}
{{ grp.post.title }}
{% endif %}
</h3>
{% if grp.market_place %}
<p class="text-xs text-stone-500 truncate">{{ grp.post.title }}</p>
{% endif %}
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
{% if grp.product_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">
<i class="fa fa-box-open" aria-hidden="true"></i>
{{ grp.product_count }} item{{ 's' if grp.product_count != 1 }}
</span>
{% endif %}
{% if grp.calendar_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">
<i class="fa fa-calendar" aria-hidden="true"></i>
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
</span>
{% endif %}
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">
<i class="fa fa-ticket" aria-hidden="true"></i>
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
</span>
{% endif %}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-lg font-bold text-stone-900">
&pound;{{ "%.2f"|format(grp.total) }}
</div>
<div class="mt-1 text-xs text-emerald-700 font-medium">
View cart &rarr;
</div>
</div>
</div>
</a>
{% else %}
{# Orphan bucket (items without a page) #}
<div class="rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5">
<div class="flex items-start gap-4">
<div class="h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0">
<i class="fa fa-shopping-cart text-amber-500 text-xl" aria-hidden="true"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base sm:text-lg font-semibold text-stone-900">
Other items
</h3>
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
{% if grp.product_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100">
<i class="fa fa-box-open" aria-hidden="true"></i>
{{ grp.product_count }} item{{ 's' if grp.product_count != 1 }}
</span>
{% endif %}
{% if grp.calendar_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100">
<i class="fa fa-calendar" aria-hidden="true"></i>
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
</span>
{% endif %}
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100">
<i class="fa fa-ticket" aria-hidden="true"></i>
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
</span>
{% endif %}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-lg font-bold text-stone-900">
&pound;{{ "%.2f"|format(grp.total) }}
</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>

View File

@@ -0,0 +1,24 @@
{% extends 'oob_elements.html' %}
{# OOB elements for cart overview HTMX navigation #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('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/overview/_main_panel.html" %}
{% endblock %}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
{% extends 'oob_elements.html' %}
{# OOB elements for page cart HTMX navigation #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'cart-header-child', '_types/cart/header/_header.html')}}
{% from '_types/cart/page/header/_header.html' import page_header_row with context %}
{{ page_header_row(oob=True) }}
{% 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/page/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% import 'macros/links.html' as links %}
{% macro page_header_row(oob=False) %}
{% call links.menu_row(id='page-cart-row', oob=oob) %}
{% call links.link(cart_url('/' + page_post.slug + '/'), hx_select_search) %}
{% if page_post.feature_image %}
<img
src="{{ page_post.feature_image }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% endif %}
<span>
{{ page_post.title | truncate(160, True, '...') }}
</span>
{% endcall %}
{% call links.desktop_nav() %}
<a
href="{{ cart_url('/') }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa fa-arrow-left text-xs" aria-hidden="true"></i>
All carts
</a>
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,24 @@
{% 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 %}
{% from '_types/cart/page/header/_header.html' import page_header_row with context %}
{{ page_header_row() }}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/cart/_nav.html' %}
{% endblock %}
{% block aside %}
{% endblock %}
{% block content %}
{% include '_types/cart/page/_main_panel.html' %}
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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