feat: initialize cart app with blueprints, templates, and CI
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Extract cart, order, and orders blueprints with their service layer,
templates, Dockerfile (APP_MODULE=app:app, IMAGE=cart), entrypoint,
and Gitea CI workflow from the coop monolith.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-09 23:17:41 +00:00
commit 967303093d
46 changed files with 2472 additions and 0 deletions

63
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,63 @@
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: registry.rose-ash.com:5000
IMAGE: cart
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
apt-get update && apt-get install -y --no-install-recommends openssh-client
- name: Set up SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Pull latest code on server
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/cart
git fetch origin main
git reset --hard origin/main
"
- name: Build and push image
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/cart
docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} .
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
"
- name: Deploy stack
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
ssh "root@$DEPLOY_HOST" "
cd /root/cart
source .env
docker stack deploy -c docker-compose.yml cart
echo 'Waiting for services to update...'
sleep 10
docker stack services cart
"

8
.gitignore vendored Normal file
View File

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

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# ---------- Python application ----------
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
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 requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .
# ---------- Runtime setup ----------
COPY 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"]

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# Cart App
Shopping cart, checkout, and order management service for the Rose Ash cooperative marketplace.
## Overview
This is the **cart** microservice, split from the Rose Ash monolith. It handles:
- **Shopping cart** - Add/remove products, view cart, cart summary API
- **Checkout** - SumUp payment integration with hosted checkout
- **Orders** - Order listing, detail view, payment status tracking
- **Calendar bookings** - Calendar entry cart items and checkout integration
## Architecture
- **Framework:** Quart (async Flask)
- **Database:** PostgreSQL 16 via SQLAlchemy 2.0 (async)
- **Payments:** SumUp Hosted Checkout
- **Frontend:** HTMX + Jinja2 templates + Tailwind CSS
## Directory Structure
```
app.py # Quart application factory
bp/
cart/ # Cart blueprint (add, view, checkout, webhooks)
routes.py
api.py # Internal API (server-to-server, CSRF-exempt)
login_helper.py # Cart merge on login
services/ # Business logic layer
order/ # Single order detail blueprint
routes.py
filters/qs.py # Query string helpers
orders/ # Order listing blueprint
routes.py
filters/qs.py
templates/
_types/cart/ # Cart templates
_types/order/ # Single order templates
_types/orders/ # Order listing templates
entrypoint.sh # Docker entrypoint (migrations + server start)
Dockerfile # Container build
.gitea/workflows/ci.yml # CI/CD pipeline
```
## Running
```bash
# Set environment variables
export APP_MODULE=app:app
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
# Run the server
hypercorn app:app --reload --bind 0.0.0.0:8002
```
## Cross-App Communication
The cart app exposes internal API endpoints at `/internal/cart/` for other services:
- `GET /internal/cart/summary` - Cart count and total for the current session/user
- `POST /internal/cart/adopt` - Adopt anonymous cart items after user login
## Docker
```bash
docker build -t cart:latest .
docker run -p 8002:8000 --env-file .env cart:latest
```

80
app.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from quart import g
from shared.factory import create_base_app
from suma_browser.app.bp import register_cart_bp, register_orders, register_cart_api
from suma_browser.app.bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
)
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)
- menu_items: fetched from coop internal API
"""
from shared.context import base_context
from shared.internal_api import get as api_get, dictobj
ctx = await base_context()
# Cart app owns cart data — use g.cart from _load_cart
cart = getattr(g, "cart", None) or []
cal_entries = await get_calendar_cart_entries(g.s)
ctx["cart"] = cart
ctx["calendar_cart_entries"] = cal_entries
ctx["total"] = total
ctx["calendar_total"] = calendar_total
# Also set cart_count so _mini.html works the same way
cart_qty = sum(ci.quantity for ci in cart) if cart else 0
ctx["cart_count"] = cart_qty + len(cal_entries)
ctx["cart_total"] = (total(cart) or 0) + (calendar_total(cal_entries) or 0)
# Menu items from coop API (wrapped for attribute access in templates)
menu_data = await api_get("coop", "/internal/menu-items")
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
return ctx
def create_app() -> "Quart":
app = create_base_app(
"cart",
context_fn=cart_context,
before_request_fns=[_load_cart],
)
# Cart blueprint at root (was /cart in monolith)
app.register_blueprint(
register_cart_bp(url_prefix="/"),
url_prefix="/",
)
# Orders blueprint
app.register_blueprint(
register_orders(url_prefix="/orders"),
)
# Internal API (server-to-server, CSRF-exempt)
app.register_blueprint(register_cart_api())
return app
app = create_app()

148
bp/cart/api.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Internal JSON API for the cart app.
These endpoints are called by other apps (coop, market) over HTTP.
They are CSRF-exempt because they are server-to-server calls.
"""
from __future__ import annotations
from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload
from models.market import CartItem
from models.calendars import CalendarEntry
from suma_browser.app.csrf import csrf_exempt
from shared.cart_identity import current_cart_identity
def register() -> Blueprint:
bp = Blueprint("cart_api", __name__, url_prefix="/internal/cart")
@bp.get("/summary")
@csrf_exempt
async def summary():
"""
Return a lightweight cart summary (count + total) for the
current session/user. Called by coop and market apps to
populate the cart-mini widget without importing cart services.
"""
ident = current_cart_identity()
# --- product cart ---
cart_filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
cart_filters.append(CartItem.user_id == ident["user_id"])
else:
cart_filters.append(CartItem.session_id == ident["session_id"])
result = await g.s.execute(
select(CartItem)
.where(*cart_filters)
.options(selectinload(CartItem.product))
.order_by(CartItem.created_at.desc())
)
cart_items = result.scalars().all()
cart_count = sum(ci.quantity for ci in cart_items)
cart_total = sum(
(ci.product.special_price or ci.product.regular_price or 0) * ci.quantity
for ci in cart_items
if ci.product and (ci.product.special_price or ci.product.regular_price)
)
# --- calendar entries ---
cal_filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if ident["user_id"] is not None:
cal_filters.append(CalendarEntry.user_id == ident["user_id"])
else:
cal_filters.append(CalendarEntry.session_id == ident["session_id"])
cal_result = await g.s.execute(
select(CalendarEntry).where(*cal_filters)
)
cal_entries = cal_result.scalars().all()
calendar_count = len(cal_entries)
calendar_total = sum((e.cost or 0) for e in cal_entries if e.cost is not None)
items = [
{
"slug": ci.product.slug if ci.product else None,
"title": ci.product.title if ci.product else None,
"image": ci.product.image if ci.product else None,
"quantity": ci.quantity,
"price": float(ci.product.special_price or ci.product.regular_price or 0)
if ci.product
else 0,
}
for ci in cart_items
]
return jsonify(
{
"count": cart_count,
"total": float(cart_total),
"calendar_count": calendar_count,
"calendar_total": float(calendar_total),
"items": items,
}
)
@bp.post("/adopt")
@csrf_exempt
async def adopt():
"""
Adopt anonymous cart items + calendar entries for a user.
Called by the coop app after successful login.
Body: {"user_id": int, "session_id": str}
"""
data = await request.get_json() or {}
user_id = data.get("user_id")
session_id = data.get("session_id")
if not user_id or not session_id:
return jsonify({"ok": False, "error": "user_id and session_id required"}), 400
# --- adopt cart items ---
anon_result = await g.s.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.user_id.is_(None),
CartItem.session_id == session_id,
)
)
anon_items = anon_result.scalars().all()
if anon_items:
# Soft-delete existing user cart
await g.s.execute(
update(CartItem)
.where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id)
.values(deleted_at=func.now())
)
for ci in anon_items:
ci.user_id = user_id
# --- adopt calendar entries ---
await g.s.execute(
update(CalendarEntry)
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
.values(deleted_at=func.now())
)
cal_result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
return jsonify({"ok": True})
return bp

57
bp/cart/login_helper.py Normal file
View File

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

236
bp/cart/routes.py Normal file
View File

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

View File

@@ -0,0 +1,8 @@
from .get_cart import get_cart
from .identity import current_cart_identity
from .total import total
from .clear_cart_for_order import clear_cart_for_order
from .adopt_session_cart_for_user import adopt_session_cart_for_user
from .calendar_cart import get_calendar_cart_entries, calendar_total
from .check_sumup_status import check_sumup_status

View File

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

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from .identity import current_cart_identity
async def get_calendar_cart_entries(session):
"""
Return all *pending* calendar entries for the current cart identity
(user or anonymous session).
"""
ident = current_cart_identity()
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if ident["user_id"] is not None:
filters.append(CalendarEntry.user_id == ident["user_id"])
else:
filters.append(CalendarEntry.session_id == ident["session_id"])
result = await session.execute(
select(CalendarEntry)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(
selectinload(CalendarEntry.calendar),
)
)
return result.scalars().all()
def calendar_total(entries) -> float:
"""
Total cost of pending calendar entries.
"""
return sum(
(e.cost or 0)
for e in entries
if e.cost is not None
)

View File

@@ -0,0 +1,35 @@
from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout
from sqlalchemy import update
from models.calendars import CalendarEntry # NEW
async def check_sumup_status(session, order):
checkout_data = await sumup_get_checkout(order.sumup_checkout_id)
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"
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "ordered",
CalendarEntry.order_id==order.id,
]
if order.user_id is not None:
filters.append(CalendarEntry.user_id == order.user_id)
elif order.session_id is not None:
filters.append(CalendarEntry.session_id == order.session_id)
await session.execute(
update(CalendarEntry)
.where(*filters)
.values(state="provisional")
)
# also clear cart for this user/session if it wasn't already
elif sumup_status == "FAILED":
order.status = "failed"
else:
order.status = sumup_status.lower() or order.status
await g.s.flush()

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from typing import Optional
from urllib.parse import urlencode
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.market import Product, CartItem
from models.order import Order, OrderItem
from models.calendars import CalendarEntry
from config import config
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 create_order_from_cart(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntry],
user_id: Optional[int],
session_id: Optional[str],
product_total: float,
calendar_total: float,
) -> Order:
"""
Create an Order and OrderItems from the current cart + calendar entries.
Returns the created Order.
"""
cart_total = product_total + calendar_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)
# Update calendar entries to reference this order
calendar_filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if order.user_id is not None:
calendar_filters.append(CalendarEntry.user_id == order.user_id)
elif order.session_id is not None:
calendar_filters.append(CalendarEntry.session_id == order.session_id)
await session.execute(
update(CalendarEntry)
.where(*calendar_filters)
.values(
state="ordered",
order_id=order.id,
)
)
return order
def build_sumup_description(cart: list[CartItem], order_id: int) -> 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)
if titles:
if len(titles) <= 3:
summary = ", ".join(titles)
else:
summary = ", ".join(titles[:3]) + f" + {len(titles) - 3} more"
else:
summary = "order items"
return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}"
def build_sumup_reference(order_id: int) -> str:
"""Build a SumUp reference with configured prefix."""
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),
selectinload(Order.calendar_entries),
)
.where(Order.id == order_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,27 @@
from sqlalchemy import update, func
from models.market import CartItem
from models.order import Order
# ...
# helper function near the top of the file (outside register())
async def clear_cart_for_order(session, order: Order) -> None:
"""
Soft-delete all CartItem rows belonging to this order's user_id/session_id.
Called when an order is marked as paid.
"""
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
await session.execute(
update(CartItem)
.where(*filters)
.values(deleted_at=func.now())
)

View File

@@ -0,0 +1,24 @@
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from 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), # <-- important bit
)
)
return result.scalars().all()

View File

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

View File

@@ -0,0 +1,7 @@
def total(cart):
return sum(
(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
)

74
bp/order/filters/qs.py Normal file
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 suma_browser.app.filters.qs_base import KEEP, build_qs
from suma_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
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 models.market import Product
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from suma_browser.app.bp.cart.services import check_sumup_status
from suma_browser.app.utils.htmx import is_htmx_request
from .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.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.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

77
bp/orders/filters/qs.py Normal file
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 suma_browser.app.filters.qs_base import KEEP, build_qs
from suma_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

139
bp/orders/routes.py Normal file
View File

@@ -0,0 +1,139 @@
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 models.market import Product
from models.order import Order, OrderItem
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from suma_browser.app.bp.cart.services import check_sumup_status
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.bp import register_order
from .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
@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

31
entrypoint.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/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
# Run DB migrations (uses alembic.ini/env.py to resolve the DB URL)
echo "Running Alembic migrations..."
alembic upgrade head
# 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
# APP_MODULE can be overridden per-service (e.g. apps.market.app:app)
echo "Starting Hypercorn (${APP_MODULE:-suma_browser.app.app:app})..."
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-suma_browser.app.app:app}" --bind 0.0.0.0:${PORT:-8000}

View File

@@ -0,0 +1,169 @@
{% 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 %}
<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 %}
</section>
{{summary(cart, total, calendar_total, calendar_cart_entries,)}}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro summary(cart, total, calendar_total, calendar_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">
{{ cart | sum(attribute="quantity") }}
</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 ) }}
</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('cart.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 }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if local_href == request.path else 'false' }}"
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
data-close-details
>
<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) %}
{% set product_total = total(cart) or 0 %}
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
{% set grand = product_total + cal_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,42 @@
{% macro mini(oob=False) %}
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} >
{# cart_count is set by the context processor in all apps.
Cart app computes it from g.cart + calendar_cart_entries;
other apps get it from the cart internal API. #}
{% if cart_count is defined and cart_count is not none %}
{% set _count = cart_count %}
{% elif cart is defined and cart is not none %}
{% set _count = (cart | sum(attribute="quantity")) + ((calendar_cart_entries | length) if calendar_cart_entries else 0) %}
{% else %}
{% set _count = 0 %}
{% endif %}
{% if _count == 0 %}
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
<a
href="{{ {'clear_filters': True}|qs|host }}"
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
>
<img
src="{{ site().logo }}"
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
>
</a>
</div>
{% else %}
<a
href="{{ cart_url('/') }}"
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
>
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
<span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
>
{{ _count }}
</span>
</a>
{% endif %}
</div>
{% endmacro %}

View File

@@ -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="{{ url_for('cart.view_cart')|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 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' %}
{% 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(url_for('cart.view_cart'), 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,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_url('/product/' + item.product.slug + '/') }}">
{# Thumbnail #}
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">
{% if item.product and item.product.image %}
<img
src="{{ item.product.image }}"
alt="{{ item.product_title or item.product.title or 'Product image' }}"
class="w-full h-full object-contain object-center"
loading="lazy"
decoding="async"
>
{% else %}
<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">
No image
</div>
{% endif %}
</div>
{# Text + pricing #}
<div class="flex-1 flex justify-between gap-3">
<div>
<p class="font-medium">
{{ item.product_title or (item.product and item.product.title) or 'Unknown product' }}
</p>
<p class="text-[11px] text-stone-500">
Product ID: {{ item.product_id }}
</p>
</div>
<div class="text-right whitespace-nowrap">
<p>Qty: {{ item.quantity }}</p>
<p>
{{ item.currency or order.currency or 'GBP' }}
{{ '%.2f'|format(item.unit_price or 0) }}
</p>
</div>
</div>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -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,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 %}
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% endblock %}
{% block filter %}
{% include '_types/orders/_summary.html' %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/orders/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/orders/_main_panel.html" %}
{% endblock %}

View File

@@ -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">
{% import '_types/browse/mobile/_filter/search.html' as s %}
{{ s.search(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 %}
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
{% endblock %}
{% block filter %}
{% include '_types/orders/_summary.html' %}
{% endblock %}
{% block content %}
{% include '_types/orders/_main_panel.html' %}
{% endblock %}