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