""" Orders service s-expression page components. Each function renders a complete page section (full page, OOB, or pagination) using shared s-expression components. Called from route handlers in place of ``render_template()``. """ from __future__ import annotations from typing import Any from shared.sexp.jinja_bridge import sexp, register_components from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) from shared.infrastructure.urls import market_product_url, cart_url # --------------------------------------------------------------------------- # Service-specific component definitions # --------------------------------------------------------------------------- def load_orders_components() -> None: """Register orders-specific s-expression components (placeholder for future).""" pass # --------------------------------------------------------------------------- # Header helpers (shared auth + orders-specific) # --------------------------------------------------------------------------- def _auth_nav_html(ctx: dict) -> str: """Auth section desktop nav items.""" html = sexp( '(~nav-link :href h :label "newsletters" :select-colours sc)', h=call_url(ctx, "account_url", "/newsletters/"), sc=ctx.get("select_colours", ""), ) account_nav_html = ctx.get("account_nav_html", "") if account_nav_html: html += account_nav_html return html def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" return sexp( '(~menu-row :id "auth-row" :level 1 :colour "sky"' ' :link-href lh :link-label "account" :icon "fa-solid fa-user"' ' :nav-html nh :child-id "auth-header-child" :oob oob)', lh=call_url(ctx, "account_url", "/"), nh=_auth_nav_html(ctx), oob=oob, ) def _orders_header_html(ctx: dict, list_url: str) -> str: """Build the orders section header row.""" return sexp( '(~menu-row :id "orders-row" :level 2 :colour "sky"' ' :link-href lh :link-label "Orders" :icon "fa fa-gbp"' ' :child-id "orders-header-child")', lh=list_url, ) # --------------------------------------------------------------------------- # Orders list rendering # --------------------------------------------------------------------------- def _status_pill_cls(status: str) -> str: """Return Tailwind classes for order status pill.""" sl = status.lower() if sl == "paid": return "border-emerald-300 bg-emerald-50 text-emerald-700" if sl in ("failed", "cancelled"): return "border-rose-300 bg-rose-50 text-rose-700" return "border-stone-300 bg-stone-50 text-stone-700" def _order_row_html(order: Any, detail_url: str) -> str: """Render a single order as desktop table row + mobile card.""" status = order.status or "pending" pill = _status_pill_cls(status) created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" desktop = sexp( '(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" (raw! oid)))' ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created))' ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc))' ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total))' ' (td :class "px-3 py-2 align-top" (span :class pill (raw! status)))' ' (td :class "px-3 py-0.5 align-top text-right"' ' (a :href url :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")))', oid=f"#{order.id}", created=created, desc=order.description or "", total=total, pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}", status=status, url=detail_url, ) mobile = sexp( '(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" (raw! oid))' ' (span :class pill (raw! status)))' ' (div :class "text-[11px] text-stone-500 break-words" (raw! created))' ' (div :class "flex items-center justify-between gap-2"' ' (div :class "font-medium text-stone-800" (raw! total))' ' (a :href url :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")))))', oid=f"#{order.id}", created=created, total=total, pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}", status=status, url=detail_url, ) return desktop + mobile def _orders_rows_html(orders: list, page: int, total_pages: int, url_for_fn: Any, qs_fn: Any) -> str: """Render order rows + infinite scroll sentinel.""" from shared.utils import route_prefix pfx = route_prefix() parts = [ _order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id)) for o in orders ] if page < total_pages: next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) parts.append(sexp( '(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)', u=next_url, p=page, **{"total-pages": total_pages}, )) else: parts.append(sexp( '(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))', )) return "".join(parts) def _orders_main_panel_html(orders: list, rows_html: str) -> str: """Main panel with table or empty state.""" if not orders: return sexp( '(div :class "max-w-full px-3 py-3 space-y-3"' ' (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."))', ) return sexp( '(div :class "max-w-full px-3 py-3 space-y-3"' ' (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 :class "px-3 py-2 text-left font-medium" "Created")' ' (th :class "px-3 py-2 text-left font-medium" "Description")' ' (th :class "px-3 py-2 text-left font-medium" "Total")' ' (th :class "px-3 py-2 text-left font-medium" "Status")' ' (th :class "px-3 py-2 text-left font-medium" "")))' ' (tbody (raw! rows)))))', rows=rows_html, ) def _orders_summary_html(ctx: dict) -> str: """Filter section for orders list.""" return sexp( '(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."))' ' (div :class "md:hidden" (raw! sm)))', sm=search_mobile_html(ctx), ) # --------------------------------------------------------------------------- # Public API: orders list # --------------------------------------------------------------------------- async def render_orders_page(ctx: dict, orders: list, page: int, total_pages: int, search: str | None, search_count: int, url_for_fn: Any, qs_fn: Any) -> str: """Full page: orders list.""" from shared.utils import route_prefix ctx["search"] = search ctx["search_count"] = search_count list_url = route_prefix() + url_for_fn("orders.list_orders") rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) main = _orders_main_panel_html(orders, rows) hdr = root_header_html(ctx) hdr += sexp( '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a) (raw! o))', a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url), ) return full_page(ctx, header_rows_html=hdr, filter_html=_orders_summary_html(ctx), aside_html=search_desktop_html(ctx), content_html=main) async def render_orders_rows(ctx: dict, orders: list, page: int, total_pages: int, url_for_fn: Any, qs_fn: Any) -> str: """Pagination: just the table rows.""" return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) async def render_orders_oob(ctx: dict, orders: list, page: int, total_pages: int, search: str | None, search_count: int, url_for_fn: Any, qs_fn: Any) -> str: """OOB response for HTMX navigation to orders list.""" from shared.utils import route_prefix ctx["search"] = search ctx["search_count"] = search_count list_url = route_prefix() + url_for_fn("orders.list_orders") rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) main = _orders_main_panel_html(orders, rows) oobs = ( _auth_header_html(ctx, oob=True) + sexp( '(div :id "auth-header-child" :hx-swap-oob "outerHTML"' ' :class "flex flex-col w-full items-center" (raw! o))', o=_orders_header_html(ctx, list_url), ) + root_header_html(ctx, oob=True) ) return oob_page(ctx, oobs_html=oobs, filter_html=_orders_summary_html(ctx), aside_html=search_desktop_html(ctx), content_html=main) # --------------------------------------------------------------------------- # Single order detail # --------------------------------------------------------------------------- def _order_items_html(order: Any) -> str: """Render order items list.""" if not order or not order.items: return "" items = [] for item in order.items: prod_url = market_product_url(item.product_slug) if item.product_image: img = sexp( '(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")', src=item.product_image, alt=item.product_title or "Product image", ) else: img = sexp('(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")') items.append(sexp( '(li (a :class "w-full py-2 flex gap-3" :href href' ' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))' ' (div :class "flex-1 flex justify-between gap-3"' ' (div' ' (p :class "font-medium" (raw! title))' ' (p :class "text-[11px] text-stone-500" "Product ID: " (raw! pid)))' ' (div :class "text-right whitespace-nowrap"' ' (p "Qty: " (raw! qty))' ' (p (raw! price))))))', href=prod_url, img=img, title=item.product_title or "Unknown product", pid=str(item.product_id), qty=str(item.quantity), price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", )) return sexp( '(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")' ' (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items)))', items="".join(items), ) def _calendar_items_html(calendar_entries: list | None) -> str: """Render calendar bookings for an order.""" if not calendar_entries: return "" items = [] for e in calendar_entries: st = e.state or "" pill = ( "bg-emerald-100 text-emerald-800" if st == "confirmed" else "bg-amber-100 text-amber-800" if st == "provisional" else "bg-blue-100 text-blue-800" if st == "ordered" else "bg-stone-100 text-stone-700" ) ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" if e.end_at: ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" items.append(sexp( '(li :class "px-4 py-3 flex items-start justify-between text-sm"' ' (div' ' (div :class "font-medium flex items-center gap-2"' ' (raw! name)' ' (span :class pill (raw! state)))' ' (div :class "text-xs text-stone-500" (raw! ds)))' ' (div :class "ml-4 font-medium" (raw! cost)))', name=e.name, pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", state=st.capitalize(), ds=ds, cost=f"\u00a3{e.cost or 0:.2f}", )) return sexp( '(section :class "mt-6 space-y-3"' ' (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")' ' (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items)))', items="".join(items), ) def _order_main_html(order: Any, calendar_entries: list | None) -> str: """Main panel for single order detail.""" summary = sexp( '(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)', oid=order.id, ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, d=order.description, s=order.status, c=order.currency, ta=f"{order.total_amount:.2f}" if order.total_amount else None, ) return sexp( '(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary) (raw! items) (raw! cal))', summary=summary, items=_order_items_html(order), cal=_calendar_items_html(calendar_entries), ) def _order_filter_html(order: Any, list_url: str, recheck_url: str, pay_url: str, csrf_token: str) -> str: """Filter section for single order detail.""" created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" status = order.status or "pending" pay_html = "" if status != "paid": pay_html = sexp( '(a :href url :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") "Open payment page")', url=pay_url, ) return sexp( '(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 " (raw! created) " \u00b7 Status: " (raw! status)))' ' (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"' ' (a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' ' (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")' ' (form :method "post" :action recheck-url :class "inline"' ' (input :type "hidden" :name "csrf_token" :value csrf)' ' (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") "Re-check status"))' ' (raw! pay)))', created=created, status=status, **{"list-url": list_url, "recheck-url": recheck_url}, csrf=csrf_token, pay=pay_html, ) async def render_order_page(ctx: dict, order: Any, calendar_entries: list | None, url_for_fn: Any) -> str: """Full page: single order detail.""" from shared.utils import route_prefix from shared.browser.app.csrf import generate_csrf_token pfx = route_prefix() detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id) list_url = pfx + url_for_fn("orders.list_orders") recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) main = _order_main_html(order, calendar_entries) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) # Header stack: root -> auth -> orders -> order hdr = root_header_html(ctx) order_row = sexp( '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp")', lh=detail_url, ) hdr += sexp( '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)' ' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))', a=_auth_header_html(ctx), b=_orders_header_html(ctx, list_url), c=order_row, ) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) async def render_order_oob(ctx: dict, order: Any, calendar_entries: list | None, url_for_fn: Any) -> str: """OOB response for single order detail.""" from shared.utils import route_prefix from shared.browser.app.csrf import generate_csrf_token pfx = route_prefix() detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id) list_url = pfx + url_for_fn("orders.list_orders") recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) main = _order_main_html(order, calendar_entries) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) order_row_oob = sexp( '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp" :oob true)', lh=detail_url, ) oobs = ( sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob) + root_header_html(ctx, oob=True) ) return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main) # --------------------------------------------------------------------------- # Public API: Checkout error # --------------------------------------------------------------------------- def _checkout_error_filter_html() -> str: return sexp( '(header :class "mb-6 sm:mb-8"' ' (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")' ' (p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem."))', ) def _checkout_error_content_html(error: str | None, order: Any | None) -> str: err_msg = error or "Unexpected error while creating the hosted checkout session." order_html = "" if order: order_html = sexp( '(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid)))', oid=f"#{order.id}", ) back_url = cart_url("/") return sexp( '(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 (raw! msg))' ' (raw! order-html))' ' (div' ' (a :href back-url' ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' ' (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))', msg=err_msg, **{"order-html": order_html, "back-url": back_url}, ) async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: """Full page: checkout error.""" hdr = root_header_html(ctx) hdr += sexp( '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', c=_auth_header_html(ctx), ) filt = _checkout_error_filter_html() content = _checkout_error_content_html(error, order) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content)