Compare commits

...

8 Commits

Author SHA1 Message Date
giles
8ce8fc5380 fix: remove existing bp dir before symlinking in Dockerfile
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
shared_lib now has a bp/ directory that conflicts with the symlink.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 01:08:04 +00:00
giles
b6f8141f20 Revert "fix: remove is_page filter from cart page hydration"
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 18s
This reverts commit d0da418f12.
2026-02-11 00:55:06 +00:00
giles
d0da418f12 fix: remove is_page filter from cart page hydration
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 18s
Posts used as containers aren't necessarily pages. Market and events
apps don't filter on is_page either.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 00:54:12 +00:00
giles
d5cb2131b7 chore: move repo to ~/rose-ash/ and add configurable CI paths
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 14s
REPO_DIR points to /root/rose-ash/cart, COOP_DIR to /root/coop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:05:43 +00:00
giles
742d84e999 chore: update shared_lib submodule to Phase 4
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 21:46:59 +00:00
giles
cb2fcd9d32 feat: per-page carts with overview, page-scoped checkout, and split blueprints (Phase 4)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 21s
Splits the monolithic cart blueprint into three: cart_overview (GET /),
page_cart (/<page_slug>/), and cart_global (webhook, return, add).

- New page_cart.py service: get_cart_for_page(), get_calendar_entries_for_page(), get_cart_grouped_by_page()
- clear_cart_for_order() and create_order_from_cart() accept page_post_id for scoping
- Cart app hydrates page_slug via url_value_preprocessor/url_defaults/hydrate_page
- Context processor provides page-scoped cart data when g.page_post exists
- Internal API /internal/cart/summary accepts ?page_slug= for page-scoped counts
- Overview template shows page cards with item counts and totals
- Page cart template reuses show_cart() macro with page-specific header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 21:45:30 +00:00
giles
7bdb736ef5 chore: update shared_lib submodule to Phase 3
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:54:16 +00:00
giles
c8d927bf72 feat: per-page SumUp credentials in checkout flow (Phase 3)
- Add resolve_page_config() to determine PageConfig from cart/calendar context
- Set page_config_id on Order during checkout
- Pass page_config to SumUp create_checkout and build_sumup_reference
- check_sumup_status uses order.page_config for per-page credential resolution
- Fix: use session.flush() instead of g.s.flush() in check_sumup_status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:49:45 +00:00
27 changed files with 1043 additions and 69 deletions

View File

@@ -7,7 +7,8 @@ on:
env: env:
REGISTRY: registry.rose-ash.com:5000 REGISTRY: registry.rose-ash.com:5000
IMAGE: cart IMAGE: cart
REPO_DIR: /root/cart REPO_DIR: /root/rose-ash/cart
COOP_DIR: /root/coop
jobs: jobs:
build-and-deploy: build-and-deploy:
@@ -58,7 +59,7 @@ jobs:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: | run: |
ssh "root@$DEPLOY_HOST" " ssh "root@$DEPLOY_HOST" "
cd /root/coop cd ${{ env.COOP_DIR }}
source .env source .env
docker stack deploy -c docker-compose.yml coop docker stack deploy -c docker-compose.yml coop
echo 'Waiting for services to update...' echo 'Waiting for services to update...'

View File

@@ -23,7 +23,7 @@ RUN pip install -r requirements.txt
COPY . . COPY . .
# Link app blueprints into the shared library's namespace # Link app blueprints into the shared library's namespace
RUN ln -s /app/bp /app/shared_lib/suma_browser/app/bp RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh

124
app.py
View File

@@ -1,17 +1,31 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path import path_setup # noqa: F401 # adds shared_lib to sys.path
from quart import g from pathlib import Path
from quart import g, abort
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app 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 import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_cart_api,
register_orders,
)
from suma_browser.app.bp.cart.services import ( from suma_browser.app.bp.cart.services import (
get_cart, get_cart,
total, total,
get_calendar_cart_entries, get_calendar_cart_entries,
calendar_total, calendar_total,
) )
from suma_browser.app.bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
)
async def _load_cart(): async def _load_cart():
@@ -27,6 +41,9 @@ async def cart_context() -> dict:
(cart app owns this data) (cart app owns this data)
- cart_count: derived from cart + calendar entries (for _mini.html) - cart_count: derived from cart + calendar entries (for _mini.html)
- menu_items: fetched from coop internal API - menu_items: fetched from coop internal API
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
""" """
from shared.context import base_context from shared.context import base_context
from shared.internal_api import get as api_get, dictobj from shared.internal_api import get as api_get, dictobj
@@ -34,19 +51,30 @@ async def cart_context() -> dict:
ctx = await base_context() ctx = await base_context()
# Cart app owns cart data — use g.cart from _load_cart # Cart app owns cart data — use g.cart from _load_cart
cart = getattr(g, "cart", None) or [] all_cart = getattr(g, "cart", None) or []
cal_entries = await get_calendar_cart_entries(g.s) all_cal = await get_calendar_cart_entries(g.s)
# Global counts for cart-mini (always global)
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
ctx["cart_count"] = cart_qty + len(all_cal)
ctx["cart_total"] = (total(all_cart) or 0) + (calendar_total(all_cal) or 0)
# Page-scoped data when viewing a page cart
page_post = getattr(g, "page_post", None)
if page_post:
page_cart = await get_cart_for_page(g.s, page_post.id)
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
ctx["cart"] = page_cart
ctx["calendar_cart_entries"] = page_cal
ctx["page_post"] = page_post
ctx["page_config"] = getattr(g, "page_config", None)
else:
ctx["cart"] = all_cart
ctx["calendar_cart_entries"] = all_cal
ctx["cart"] = cart
ctx["calendar_cart_entries"] = cal_entries
ctx["total"] = total ctx["total"] = total
ctx["calendar_total"] = calendar_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 items from coop API (wrapped for attribute access in templates)
menu_data = await api_get("coop", "/internal/menu-items") menu_data = await api_get("coop", "/internal/menu-items")
ctx["menu_items"] = dictobj(menu_data) if menu_data else [] ctx["menu_items"] = dictobj(menu_data) if menu_data else []
@@ -55,26 +83,82 @@ async def cart_context() -> dict:
def create_app() -> "Quart": def create_app() -> "Quart":
from models.ghost_content import Post
from models.page_config import PageConfig
app = create_base_app( app = create_base_app(
"cart", "cart",
context_fn=cart_context, context_fn=cart_context,
before_request_fns=[_load_cart], before_request_fns=[_load_cart],
) )
# Cart blueprint at root (was /cart in monolith) # App-specific templates override shared templates
app.register_blueprint( app_templates = str(Path(__file__).resolve().parent / "templates")
register_cart_bp(url_prefix="/"), app.jinja_loader = ChoiceLoader([
url_prefix="/", FileSystemLoader(app_templates),
) app.jinja_loader,
])
# Orders blueprint # --- Page slug hydration (follows events/market app pattern) ---
app.register_blueprint(
register_orders(url_prefix="/orders"), @app.url_value_preprocessor
) def pull_page_slug(endpoint, values):
if values and "page_slug" in values:
g.page_slug = values.pop("page_slug")
@app.url_defaults
def inject_page_slug(endpoint, values):
slug = g.get("page_slug")
if slug and "page_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "page_slug"):
values["page_slug"] = slug
@app.before_request
async def hydrate_page():
slug = getattr(g, "page_slug", None)
if not slug:
return
post = (
await g.s.execute(
select(Post).where(Post.slug == slug, Post.is_page == True) # noqa: E712
)
).scalar_one_or_none()
if not post:
abort(404)
g.page_post = post
g.page_config = (
await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post.id)
)
).scalar_one_or_none()
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
# Internal API (server-to-server, CSRF-exempt) # Internal API (server-to-server, CSRF-exempt)
app.register_blueprint(register_cart_api()) app.register_blueprint(register_cart_api())
# Orders blueprint
app.register_blueprint(register_orders(url_prefix="/orders"))
# Global routes (webhook, return, add — specific paths under /)
app.register_blueprint(
register_cart_global(url_prefix="/"),
url_prefix="/",
)
# Cart overview at GET /
app.register_blueprint(
register_cart_overview(url_prefix="/"),
url_prefix="/",
)
# Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint(
register_page_cart(url_prefix="/"),
url_prefix="/<page_slug>",
)
return app return app

View File

@@ -1,4 +1,6 @@
from .cart.routes import register as register_cart_bp from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global
from .cart.api import register as register_cart_api from .cart.api import register as register_cart_api
from .order.routes import register as register_order from .order.routes import register as register_order
from .orders.routes import register as register_orders from .orders.routes import register as register_orders

View File

@@ -11,7 +11,9 @@ from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.market import CartItem from models.market import CartItem
from models.calendars import CalendarEntry from models.market_place import MarketPlace
from models.calendars import CalendarEntry, Calendar
from models.ghost_content import Post
from suma_browser.app.csrf import csrf_exempt from suma_browser.app.csrf import csrf_exempt
from shared.cart_identity import current_cart_identity from shared.cart_identity import current_cart_identity
@@ -26,22 +28,41 @@ def register() -> Blueprint:
Return a lightweight cart summary (count + total) for the Return a lightweight cart summary (count + total) for the
current session/user. Called by coop and market apps to current session/user. Called by coop and market apps to
populate the cart-mini widget without importing cart services. populate the cart-mini widget without importing cart services.
Optional query param: ?page_slug=<slug>
When provided, returns only items scoped to that page.
""" """
ident = current_cart_identity() ident = current_cart_identity()
# --- product cart --- # Resolve optional page filter
cart_filters = [CartItem.deleted_at.is_(None)] page_slug = request.args.get("page_slug")
if ident["user_id"] is not None: page_post_id = None
cart_filters.append(CartItem.user_id == ident["user_id"]) if page_slug:
else: post = (
cart_filters.append(CartItem.session_id == ident["session_id"]) await g.s.execute(
select(Post).where(Post.slug == page_slug, Post.is_page == True) # noqa: E712
)
).scalar_one_or_none()
if post:
page_post_id = post.id
result = await g.s.execute( # --- product cart ---
select(CartItem) cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
.where(*cart_filters) if ident["user_id"] is not None:
.options(selectinload(CartItem.product)) cart_q = cart_q.where(CartItem.user_id == ident["user_id"])
.order_by(CartItem.created_at.desc()) else:
) cart_q = cart_q.where(CartItem.session_id == ident["session_id"])
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.post_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
cart_q = cart_q.options(selectinload(CartItem.product)).order_by(CartItem.created_at.desc())
result = await g.s.execute(cart_q)
cart_items = result.scalars().all() cart_items = result.scalars().all()
cart_count = sum(ci.quantity for ci in cart_items) cart_count = sum(ci.quantity for ci in cart_items)
@@ -52,18 +73,23 @@ def register() -> Blueprint:
) )
# --- calendar entries --- # --- calendar entries ---
cal_filters = [ cal_q = select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None), CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending", 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)
) )
if ident["user_id"] is not None:
cal_q = cal_q.where(CalendarEntry.user_id == ident["user_id"])
else:
cal_q = cal_q.where(CalendarEntry.session_id == ident["session_id"])
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.post_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
cal_q = cal_q.where(CalendarEntry.calendar_id.in_(cal_ids))
cal_result = await g.s.execute(cal_q)
cal_entries = cal_result.scalars().all() cal_entries = cal_result.scalars().all()
calendar_count = len(cal_entries) calendar_count = len(cal_entries)

201
bp/cart/global_routes.py Normal file
View File

@@ -0,0 +1,201 @@
# bp/cart/global_routes.py — Global cart routes (webhook, return, add)
from __future__ import annotations
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
from sqlalchemy import select
from models.order import Order
from suma_browser.app.utils.htmx import is_htmx_request
from .services import (
current_cart_identity,
get_cart,
total,
clear_cart_for_order,
get_calendar_cart_entries,
calendar_total,
check_sumup_status,
)
from .services.checkout import (
find_or_create_cart_item,
create_order_from_cart,
resolve_page_config,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
validate_webhook_secret,
get_order_with_details,
)
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("cart_global", __name__, url_prefix=url_prefix)
@bp.post("/add/<int:product_id>/")
async def add_to_cart(product_id: int):
ident = current_cart_identity()
cart_item = await find_or_create_cart_item(
g.s,
product_id,
ident["user_id"],
ident["session_id"],
)
if not cart_item:
return await make_response("Product not found", 404)
if request.headers.get("HX-Request") == "true":
# Redirect to overview for HTMX
return redirect(url_for("cart_overview.overview"))
return redirect(url_for("cart_overview.overview"))
@bp.post("/checkout/")
async def checkout():
"""Legacy global checkout (for orphan items without page scope)."""
cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s)
if not cart and not calendar_entries:
return redirect(url_for("cart_overview.overview"))
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_overview.overview"))
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries)
except ValueError as e:
html = await render_template(
"_types/cart/checkout_error.html",
order=None,
error=str(e),
)
return await make_response(html, 400)
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,
)
if page_config:
order.page_config_id = page_config.id
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id)
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
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."""
if not validate_webhook_secret(request.args.get("token")):
return "", 204
try:
payload = await request.get_json()
except Exception:
payload = None
if not isinstance(payload, dict):
return "", 204
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
return "", 204
checkout_id = payload.get("id")
if not checkout_id:
return "", 204
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return "", 204
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
return "", 204
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return "", 204
@bp.get("/checkout/return/<int:order_id>/")
async def checkout_return(order_id: int):
"""Handle the browser returning from SumUp after payment."""
order = await get_order_with_details(g.s, order_id)
if not order:
html = await render_template(
"_types/cart/checkout_return.html",
order=None,
status="missing",
calendar_entries=[],
)
return await make_response(html)
status = (order.status or "pending").lower()
if order.sumup_checkout_id:
try:
await check_sumup_status(g.s, order)
except Exception:
status = status or "pending"
calendar_entries = order.calendar_entries or []
await g.s.flush()
html = await render_template(
"_types/cart/checkout_return.html",
order=order,
status=status,
calendar_entries=calendar_entries,
)
return await make_response(html)
return bp

View File

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

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

@@ -0,0 +1,123 @@
# bp/cart/page_routes.py — Per-page cart (view + checkout)
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for
from suma_browser.app.utils.htmx import is_htmx_request
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
from config import config
from .services import (
total,
clear_cart_for_order,
calendar_total,
check_sumup_status,
)
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page
from .services.checkout import (
create_order_from_cart,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
get_order_with_details,
)
from .services import current_cart_identity
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("page_cart", __name__, url_prefix=url_prefix)
@bp.get("/")
async def page_view():
post = g.page_post
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
tpl_ctx = dict(
page_post=post,
page_config=getattr(g, "page_config", None),
cart=cart,
calendar_cart_entries=cal_entries,
total=total,
calendar_total=calendar_total,
)
if not is_htmx_request():
html = await render_template("_types/cart/page/index.html", **tpl_ctx)
else:
html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx)
return await make_response(html)
@bp.post("/checkout/")
async def page_checkout():
post = g.page_post
page_config = getattr(g, "page_config", None)
cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
if not cart and not cal_entries:
return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0
cart_total = product_total + calendar_amount
if cart_total <= 0:
return redirect(url_for("page_cart.page_view"))
# Create order scoped to this page
ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
cal_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
page_post_id=post.id,
)
# Set page_config on order
if page_config:
order.page_config_id = page_config.id
# Build SumUp checkout details — webhook/return use global routes
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id)
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
await clear_cart_for_order(g.s, order, page_post_id=post.id)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
error="No hosted checkout URL returned from SumUp.",
)
return await make_response(html, 500)
return redirect(hosted_url)
return bp

View File

@@ -21,6 +21,7 @@ from .services import (
from .services.checkout import ( from .services.checkout import (
find_or_create_cart_item, find_or_create_cart_item,
create_order_from_cart, create_order_from_cart,
resolve_page_config,
build_sumup_description, build_sumup_description,
build_sumup_reference, build_sumup_reference,
build_webhook_url, build_webhook_url,
@@ -102,6 +103,17 @@ def register(url_prefix: str) -> Blueprint:
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("cart.view_cart")) return redirect(url_for("cart.view_cart"))
# Resolve per-page credentials
try:
page_config = await resolve_page_config(g.s, cart, calendar_entries)
except ValueError as e:
html = await render_template(
"_types/cart/checkout_error.html",
order=None,
error=str(e),
)
return await make_response(html, 400)
# Create order from cart # Create order from cart
ident = current_cart_identity() ident = current_cart_identity()
order = await create_order_from_cart( order = await create_order_from_cart(
@@ -114,9 +126,13 @@ def register(url_prefix: str) -> Blueprint:
calendar_amount, calendar_amount,
) )
# Set page_config on order if resolved
if page_config:
order.page_config_id = page_config.id
# Build SumUp checkout details # Build SumUp checkout details
redirect_url = url_for("cart.checkout_return", order_id=order.id, _external=True) redirect_url = url_for("cart.checkout_return", order_id=order.id, _external=True)
order.sumup_reference = build_sumup_reference(order.id) order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id) description = build_sumup_description(cart, order.id)
webhook_base_url = url_for("cart.checkout_webhook", order_id=order.id, _external=True) webhook_base_url = url_for("cart.checkout_webhook", order_id=order.id, _external=True)
@@ -127,6 +143,7 @@ def register(url_prefix: str) -> Blueprint:
redirect_url=redirect_url, redirect_url=redirect_url,
webhook_url=webhook_url, webhook_url=webhook_url,
description=description, description=description,
page_config=page_config,
) )
await clear_cart_for_order(g.s, order) await clear_cart_for_order(g.s, order)

View File

@@ -5,4 +5,9 @@ from .clear_cart_for_order import clear_cart_for_order
from .adopt_session_cart_for_user import adopt_session_cart_for_user from .adopt_session_cart_for_user import adopt_session_cart_for_user
from .calendar_cart import get_calendar_cart_entries, calendar_total from .calendar_cart import get_calendar_cart_entries, calendar_total
from .check_sumup_status import check_sumup_status from .check_sumup_status import check_sumup_status
from .page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_cart_grouped_by_page,
)

View File

@@ -1,10 +1,12 @@
from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout
from sqlalchemy import update from sqlalchemy import update
from models.calendars import CalendarEntry # NEW from models.calendars import CalendarEntry
async def check_sumup_status(session, order): async def check_sumup_status(session, order):
checkout_data = await sumup_get_checkout(order.sumup_checkout_id) # Use order's page_config for per-page SumUp credentials
page_config = getattr(order, "page_config", None)
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
order.sumup_status = checkout_data.get("status") or order.sumup_status order.sumup_status = checkout_data.get("status") or order.sumup_status
sumup_status = (order.sumup_status or "").upper() sumup_status = (order.sumup_status or "").upper()
@@ -26,10 +28,9 @@ async def check_sumup_status(session, order):
.where(*filters) .where(*filters)
.values(state="provisional") .values(state="provisional")
) )
# also clear cart for this user/session if it wasn't already
elif sumup_status == "FAILED": elif sumup_status == "FAILED":
order.status = "failed" order.status = "failed"
else: else:
order.status = sumup_status.lower() or order.status order.status = sumup_status.lower() or order.status
await g.s.flush() await session.flush()

View File

@@ -9,7 +9,9 @@ from sqlalchemy.orm import selectinload
from models.market import Product, CartItem from models.market import Product, CartItem
from models.order import Order, OrderItem from models.order import Order, OrderItem
from models.calendars import CalendarEntry from models.calendars import CalendarEntry, Calendar
from models.page_config import PageConfig
from models.market_place import MarketPlace
from config import config from config import config
@@ -57,6 +59,44 @@ async def find_or_create_cart_item(
return cart_item return cart_item
async def resolve_page_config(
session: AsyncSession,
cart: list[CartItem],
calendar_entries: list[CalendarEntry],
) -> Optional["PageConfig"]:
"""Determine the PageConfig for this order.
Returns PageConfig or None (use global credentials).
Raises ValueError if items span multiple pages.
"""
post_ids: set[int] = set()
# From cart items via market_place
for ci in cart:
if ci.market_place_id:
mp = await session.get(MarketPlace, ci.market_place_id)
if mp:
post_ids.add(mp.post_id)
# From calendar entries via calendar
for entry in calendar_entries:
cal = await session.get(Calendar, entry.calendar_id)
if cal and cal.post_id:
post_ids.add(cal.post_id)
if len(post_ids) > 1:
raise ValueError("Cannot checkout items from multiple pages")
if not post_ids:
return None # global credentials
post_id = post_ids.pop()
pc = (await session.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
)).scalar_one_or_none()
return pc
async def create_order_from_cart( async def create_order_from_cart(
session: AsyncSession, session: AsyncSession,
cart: list[CartItem], cart: list[CartItem],
@@ -65,10 +105,15 @@ async def create_order_from_cart(
session_id: Optional[str], session_id: Optional[str],
product_total: float, product_total: float,
calendar_total: float, calendar_total: float,
*,
page_post_id: int | None = None,
) -> Order: ) -> Order:
""" """
Create an Order and OrderItems from the current cart + calendar entries. Create an Order and OrderItems from the current cart + calendar entries.
Returns the created Order.
When *page_post_id* is given, only calendar entries whose calendar
belongs to that page are marked as "ordered". Otherwise all pending
entries are updated (legacy behaviour).
""" """
cart_total = product_total + calendar_total cart_total = product_total + calendar_total
@@ -111,6 +156,13 @@ async def create_order_from_cart(
elif order.session_id is not None: elif order.session_id is not None:
calendar_filters.append(CalendarEntry.session_id == order.session_id) calendar_filters.append(CalendarEntry.session_id == order.session_id)
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.post_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
calendar_filters.append(CalendarEntry.calendar_id.in_(cal_ids))
await session.execute( await session.execute(
update(CalendarEntry) update(CalendarEntry)
.where(*calendar_filters) .where(*calendar_filters)
@@ -139,10 +191,13 @@ def build_sumup_description(cart: list[CartItem], order_id: int) -> str:
return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}" return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}"
def build_sumup_reference(order_id: int) -> str: def build_sumup_reference(order_id: int, page_config=None) -> str:
"""Build a SumUp reference with configured prefix.""" """Build a SumUp reference with configured prefix."""
sumup_cfg = config().get("sumup", {}) or {} if page_config and page_config.sumup_checkout_prefix:
prefix = sumup_cfg.get("checkout_reference_prefix", "") prefix = page_config.sumup_checkout_prefix
else:
sumup_cfg = config().get("sumup", {}) or {}
prefix = sumup_cfg.get("checkout_reference_prefix", "")
return f"{prefix}{order_id}" return f"{prefix}{order_id}"

View File

@@ -1,14 +1,16 @@
from sqlalchemy import update, func from sqlalchemy import update, func, select
from models.market import CartItem from models.market import CartItem
from models.market_place import MarketPlace
from models.order import Order 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: async def clear_cart_for_order(session, order: Order, *, page_post_id: int | None = None) -> None:
""" """
Soft-delete all CartItem rows belonging to this order's user_id/session_id. Soft-delete CartItem rows belonging to this order's user_id/session_id.
Called when an order is marked as paid.
When *page_post_id* is given, only items whose market_place belongs to
that page are cleared. Otherwise all items are cleared (legacy behaviour).
""" """
filters = [CartItem.deleted_at.is_(None)] filters = [CartItem.deleted_at.is_(None)]
if order.user_id is not None: if order.user_id is not None:
@@ -20,6 +22,13 @@ async def clear_cart_for_order(session, order: Order) -> None:
# no user_id/session_id on order nothing to clear # no user_id/session_id on order nothing to clear
return return
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.post_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
filters.append(CartItem.market_place_id.in_(mp_ids))
await session.execute( await session.execute(
update(CartItem) update(CartItem)
.where(*filters) .where(*filters)

View File

@@ -18,7 +18,8 @@ async def get_cart(session):
.where(*filters) .where(*filters)
.order_by(CartItem.created_at.desc()) .order_by(CartItem.created_at.desc())
.options( .options(
selectinload(CartItem.product), # <-- important bit selectinload(CartItem.product),
selectinload(CartItem.market_place),
) )
) )
return result.scalars().all() return result.scalars().all()

View File

@@ -0,0 +1,164 @@
"""
Page-scoped cart queries.
Groups cart items and calendar entries by their owning page (Post),
determined via CartItem.market_place.post_id and CalendarEntry.calendar.post_id.
"""
from __future__ import annotations
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.market import CartItem
from models.market_place import MarketPlace
from models.calendars import CalendarEntry, Calendar
from models.ghost_content import Post
from models.page_config import PageConfig
from .identity import current_cart_identity
async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
"""Return cart items scoped to a specific page (via MarketPlace.post_id)."""
ident = current_cart_identity()
filters = [
CartItem.deleted_at.is_(None),
MarketPlace.post_id == post_id,
MarketPlace.deleted_at.is_(None),
]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
result = await session.execute(
select(CartItem)
.join(MarketPlace, CartItem.market_place_id == MarketPlace.id)
.where(*filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
return result.scalars().all()
async def get_calendar_entries_for_page(session, post_id: int) -> list[CalendarEntry]:
"""Return pending calendar entries scoped to a specific page (via Calendar.post_id)."""
ident = current_cart_identity()
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
Calendar.post_id == post_id,
Calendar.deleted_at.is_(None),
]
if ident["user_id"] is not None:
filters.append(CalendarEntry.user_id == ident["user_id"])
else:
filters.append(CalendarEntry.session_id == ident["session_id"])
result = await session.execute(
select(CalendarEntry)
.join(Calendar, CalendarEntry.calendar_id == Calendar.id)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(selectinload(CalendarEntry.calendar))
)
return result.scalars().all()
async def get_cart_grouped_by_page(session) -> list[dict]:
"""
Load all cart items + calendar entries for the current identity,
grouped by owning page (post_id).
Returns a list of dicts:
{
"post": Post | None,
"page_config": PageConfig | None,
"cart_items": [...],
"calendar_entries": [...],
"product_count": int,
"product_total": float,
"calendar_count": int,
"calendar_total": float,
"total": float,
}
Items without a market_place go in an orphan bucket (post=None).
"""
from .get_cart import get_cart
from .calendar_cart import get_calendar_cart_entries
from .total import total as calc_product_total
from .calendar_cart import calendar_total as calc_calendar_total
cart_items = await get_cart(session)
cal_entries = await get_calendar_cart_entries(session)
# Group by post_id
groups: dict[int | None, dict] = defaultdict(lambda: {
"post_id": None,
"cart_items": [],
"calendar_entries": [],
})
for ci in cart_items:
if ci.market_place and ci.market_place.post_id:
pid = ci.market_place.post_id
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["cart_items"].append(ci)
for ce in cal_entries:
if ce.calendar and ce.calendar.post_id:
pid = ce.calendar.post_id
else:
pid = None
groups[pid]["post_id"] = pid
groups[pid]["calendar_entries"].append(ce)
# Batch-load Post and PageConfig objects
post_ids = [pid for pid in groups if pid is not None]
posts_by_id: dict[int, Post] = {}
configs_by_post: dict[int, PageConfig] = {}
if post_ids:
post_result = await session.execute(
select(Post).where(Post.id.in_(post_ids))
)
for p in post_result.scalars().all():
posts_by_id[p.id] = p
pc_result = await session.execute(
select(PageConfig).where(PageConfig.post_id.in_(post_ids))
)
for pc in pc_result.scalars().all():
configs_by_post[pc.post_id] = pc
# Build result list (pages first, orphan last)
result = []
for pid in sorted(groups, key=lambda x: (x is None, x)):
grp = groups[pid]
items = grp["cart_items"]
entries = grp["calendar_entries"]
prod_total = calc_product_total(items) or 0
cal_total = calc_calendar_total(entries) or 0
result.append({
"post": posts_by_id.get(pid) if pid else None,
"page_config": configs_by_post.get(pid) if pid else None,
"cart_items": items,
"calendar_entries": entries,
"product_count": sum(ci.quantity for ci in items),
"product_total": prod_total,
"calendar_count": len(entries),
"calendar_total": cal_total,
"total": prod_total + cal_total,
})
return result

View File

@@ -72,12 +72,12 @@ def register() -> Blueprint:
return redirect(order.sumup_hosted_url) return redirect(order.sumup_hosted_url)
# Otherwise, create a fresh checkout for this order # Otherwise, create a fresh checkout for this order
redirect_url = url_for("cart.checkout_return", order_id=order.id, _external=True) redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
sumup_cfg = config().get("sumup", {}) or {} sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret") webhook_secret = sumup_cfg.get("webhook_secret")
webhook_url = url_for("cart.checkout_webhook", order_id=order.id, _external=True) webhook_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
if webhook_secret: if webhook_secret:
from urllib.parse import urlencode from urllib.parse import urlencode

View File

@@ -103,7 +103,7 @@
{% if g.user %} {% if g.user %}
<form <form
method="post" method="post"
action="{{ url_for('cart.checkout')|host }}" action="{{ url_for('page_cart.page_checkout')|host if page_post is defined and page_post else url_for('cart_global.checkout')|host }}"
class="w-full" class="w-full"
> >
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">

View File

@@ -27,7 +27,7 @@
<div> <div>
<a <a
href="{{ url_for('cart.view_cart')|host }}" href="{{ cart_url('/') }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" 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> <i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
{% extends 'oob_elements.html' %}
{# OOB elements for cart overview HTMX navigation #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'cart-header-child', '_types/cart/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/cart/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/cart/overview/_main_panel.html" %}
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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