feat: initialize cart app with blueprints, templates, and CI
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
63
.gitea/workflows/ci.yml
Normal file
63
.gitea/workflows/ci.yml
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal 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
71
README.md
Normal 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
80
app.py
Normal 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
148
bp/cart/api.py
Normal 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
57
bp/cart/login_helper.py
Normal 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
236
bp/cart/routes.py
Normal 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
|
||||||
8
bp/cart/services/__init__.py
Normal file
8
bp/cart/services/__init__.py
Normal 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
|
||||||
|
|
||||||
46
bp/cart/services/adopt_session_cart_for_user.py
Normal file
46
bp/cart/services/adopt_session_cart_for_user.py
Normal 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
|
||||||
46
bp/cart/services/calendar_cart.py
Normal file
46
bp/cart/services/calendar_cart.py
Normal 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
|
||||||
|
)
|
||||||
35
bp/cart/services/check_sumup_status.py
Normal file
35
bp/cart/services/check_sumup_status.py
Normal 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()
|
||||||
182
bp/cart/services/checkout.py
Normal file
182
bp/cart/services/checkout.py
Normal 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()
|
||||||
27
bp/cart/services/clear_cart_for_order.py
Normal file
27
bp/cart/services/clear_cart_for_order.py
Normal 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())
|
||||||
|
)
|
||||||
24
bp/cart/services/get_cart.py
Normal file
24
bp/cart/services/get_cart.py
Normal 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()
|
||||||
4
bp/cart/services/identity.py
Normal file
4
bp/cart/services/identity.py
Normal 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"]
|
||||||
7
bp/cart/services/total.py
Normal file
7
bp/cart/services/total.py
Normal 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
74
bp/order/filters/qs.py
Normal 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
137
bp/order/routes.py
Normal 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
77
bp/orders/filters/qs.py
Normal 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
139
bp/orders/routes.py
Normal 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
31
entrypoint.sh
Normal 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}
|
||||||
169
templates/_types/cart/_cart.html
Normal file
169
templates/_types/cart/_cart.html
Normal 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 %}
|
||||||
4
templates/_types/cart/_main_panel.html
Normal file
4
templates/_types/cart/_main_panel.html
Normal 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>
|
||||||
42
templates/_types/cart/_mini.html
Normal file
42
templates/_types/cart/_mini.html
Normal 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 %}
|
||||||
2
templates/_types/cart/_nav.html
Normal file
2
templates/_types/cart/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
28
templates/_types/cart/_oob_elements.html
Normal file
28
templates/_types/cart/_oob_elements.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
38
templates/_types/cart/checkout_error.html
Normal file
38
templates/_types/cart/checkout_error.html
Normal 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 %}
|
||||||
68
templates/_types/cart/checkout_return.html
Normal file
68
templates/_types/cart/checkout_return.html
Normal 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 %}
|
||||||
|
We’re 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 couldn’t 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>We’ll start processing your order shortly.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
templates/_types/cart/header/_header.html
Normal file
12
templates/_types/cart/header/_header.html
Normal 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 %}
|
||||||
22
templates/_types/cart/index.html
Normal file
22
templates/_types/cart/index.html
Normal 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 %}
|
||||||
43
templates/_types/order/_calendar_items.html
Normal file
43
templates/_types/order/_calendar_items.html
Normal 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 %}
|
||||||
51
templates/_types/order/_items.html
Normal file
51
templates/_types/order/_items.html
Normal 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 %}
|
||||||
7
templates/_types/order/_main_panel.html
Normal file
7
templates/_types/order/_main_panel.html
Normal 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>
|
||||||
2
templates/_types/order/_nav.html
Normal file
2
templates/_types/order/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
30
templates/_types/order/_oob_elements.html
Normal file
30
templates/_types/order/_oob_elements.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
52
templates/_types/order/_summary.html
Normal file
52
templates/_types/order/_summary.html
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
17
templates/_types/order/header/_header.html
Normal file
17
templates/_types/order/header/_header.html
Normal 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 %}
|
||||||
68
templates/_types/order/index.html
Normal file
68
templates/_types/order/index.html
Normal 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 %} · 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 %}
|
||||||
26
templates/_types/orders/_main_panel.html
Normal file
26
templates/_types/orders/_main_panel.html
Normal 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>
|
||||||
2
templates/_types/orders/_nav.html
Normal file
2
templates/_types/orders/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||||
|
{{ placeholder_nav() }}
|
||||||
38
templates/_types/orders/_oob_elements.html
Normal file
38
templates/_types/orders/_oob_elements.html
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
164
templates/_types/orders/_rows.html
Normal file
164
templates/_types/orders/_rows.html
Normal 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 %}
|
||||||
11
templates/_types/orders/_summary.html
Normal file
11
templates/_types/orders/_summary.html
Normal 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>
|
||||||
14
templates/_types/orders/header/_header.html
Normal file
14
templates/_types/orders/header/_header.html
Normal 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 %}
|
||||||
29
templates/_types/orders/index.html
Normal file
29
templates/_types/orders/index.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user