Compare commits
100 Commits
8498807597
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268f033396 | ||
|
|
26e146e3e3 | ||
|
|
1c6dda0cb7 | ||
|
|
b809b12994 | ||
|
|
afadb3aa0b | ||
|
|
24e26f1f18 | ||
|
|
1de0af4fed | ||
|
|
1154d82791 | ||
|
|
17117096c9 | ||
|
|
a9b982c8c7 | ||
|
|
c23c131024 | ||
|
|
e0046403d8 | ||
|
|
641178855d | ||
|
|
a72598f75e | ||
|
|
0b2e59bc2c | ||
|
|
ac458ba96c | ||
|
|
39a9dc34fb | ||
|
|
a1d6b635e5 | ||
|
|
f229dac2db | ||
|
|
533590f733 | ||
|
|
d4a31a3877 | ||
|
|
c4e0667ee8 | ||
|
|
36374793ec | ||
|
|
a739c0d0b1 | ||
|
|
89989cdd24 | ||
|
|
37d8754a7f | ||
|
|
dca45a3fbb | ||
|
|
3fbed403fc | ||
|
|
0417e8abff | ||
|
|
c8248575ab | ||
|
|
5d930e96f6 | ||
|
|
42acfde023 | ||
|
|
77634c513b | ||
|
|
a59bc859cf | ||
|
|
85d18fd418 | ||
|
|
e0f4679805 | ||
|
|
2d1ed47d2c | ||
|
|
43ae80ec02 | ||
|
|
28d173d9fd | ||
|
|
4e295e3fbd | ||
|
|
66d1727b0f | ||
|
|
938bee5197 | ||
|
|
41e13513e1 | ||
|
|
8526c54840 | ||
|
|
c226ab99d3 | ||
|
|
14de629a29 | ||
|
|
a53c50feee | ||
|
|
2f55f3762b | ||
|
|
ae52edb928 | ||
|
|
c5e2578534 | ||
|
|
5877d7e382 | ||
|
|
ed6363295a | ||
|
|
2212f3d069 | ||
|
|
e5e01dad27 | ||
|
|
eee50809e8 | ||
|
|
32ae7fc6cc | ||
|
|
fbd1ce214d | ||
|
|
ba51b36f87 | ||
|
|
c84e02e623 | ||
|
|
b16c97e070 | ||
|
|
cb518b9cd0 | ||
|
|
13798358fc | ||
|
|
89cc2af958 | ||
|
|
8abb0c65d6 | ||
|
|
c0f9162e07 | ||
|
|
739ad0451e | ||
|
|
d93e624b45 | ||
|
|
dfb8651a39 | ||
|
|
62236233b4 | ||
|
|
822c62411f | ||
|
|
592c48f1cd | ||
|
|
e05de6e1fe | ||
|
|
5eeb14a457 | ||
|
|
fae68ebafa | ||
|
|
0e66c37a69 | ||
|
|
0d47a094a6 | ||
|
|
a6cfd02832 | ||
|
|
41d198630e | ||
|
|
881ea09e71 | ||
|
|
ca9ee83ae4 | ||
|
|
e5de05dd79 | ||
|
|
9df23276a1 | ||
|
|
058f0a1d8a | ||
|
|
e86c4a0cc8 | ||
|
|
e41d22746e | ||
|
|
a6f7bfdc7b | ||
|
|
28c10a3411 | ||
|
|
fd89426ed9 | ||
|
|
dc249ea9ce | ||
|
|
cd41b6c8ef | ||
|
|
b3efed2f60 | ||
|
|
d61b9573d3 | ||
|
|
32c9c98d74 | ||
|
|
0cdc289d00 | ||
|
|
d2d8156461 | ||
|
|
52d973ff01 | ||
|
|
f299805c22 | ||
|
|
17ab7f09c7 | ||
|
|
8c72664e1f | ||
|
|
6140c727c6 |
@@ -8,7 +8,7 @@ env:
|
|||||||
REGISTRY: registry.rose-ash.com:5000
|
REGISTRY: registry.rose-ash.com:5000
|
||||||
IMAGE: cart
|
IMAGE: cart
|
||||||
REPO_DIR: /root/rose-ash/cart
|
REPO_DIR: /root/rose-ash/cart
|
||||||
COOP_DIR: /root/coop
|
COOP_DIR: /root/rose-ash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -40,11 +40,11 @@ jobs:
|
|||||||
git reset --hard origin/${{ github.ref_name }}
|
git reset --hard origin/${{ github.ref_name }}
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
# Clean ALL sibling dirs (including stale self-copies from previous runs)
|
# Clean ALL sibling dirs (including stale self-copies from previous runs)
|
||||||
for sibling in blog market cart events; do
|
for sibling in blog market cart events federation; do
|
||||||
rm -rf \$sibling
|
rm -rf \$sibling
|
||||||
done
|
done
|
||||||
# Copy non-self sibling models for cross-domain imports
|
# Copy non-self sibling models for cross-domain imports
|
||||||
for sibling in blog market cart events; do
|
for sibling in blog market cart events federation; do
|
||||||
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
|
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
|
||||||
repo=/root/rose-ash/\$sibling
|
repo=/root/rose-ash/\$sibling
|
||||||
if [ -d \$repo/.git ]; then
|
if [ -d \$repo/.git ]; then
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -4,7 +4,7 @@ Shopping cart, checkout, and order management service for the Rose Ash cooperati
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
One of four Quart microservices sharing a single PostgreSQL database:
|
One of five Quart microservices sharing a single PostgreSQL database:
|
||||||
|
|
||||||
| App | Port | Domain |
|
| App | Port | Domain |
|
||||||
|-----|------|--------|
|
|-----|------|--------|
|
||||||
@@ -12,6 +12,7 @@ One of four Quart microservices sharing a single PostgreSQL database:
|
|||||||
| market | 8001 | Product browsing, Suma scraping |
|
| market | 8001 | Product browsing, Suma scraping |
|
||||||
| **cart** | 8002 | Shopping cart, checkout, orders |
|
| **cart** | 8002 | Shopping cart, checkout, orders |
|
||||||
| events | 8003 | Calendars, bookings, tickets |
|
| events | 8003 | Calendars, bookings, tickets |
|
||||||
|
| federation | 8004 | ActivityPub, fediverse social |
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -19,15 +20,12 @@ One of four Quart microservices sharing a single PostgreSQL database:
|
|||||||
app.py # Application factory (create_base_app + blueprints)
|
app.py # Application factory (create_base_app + blueprints)
|
||||||
path_setup.py # Adds project root + app dir to sys.path
|
path_setup.py # Adds project root + app dir to sys.path
|
||||||
config/app-config.yaml # App URLs, SumUp config
|
config/app-config.yaml # App URLs, SumUp config
|
||||||
models/ # Cart-domain models
|
models/ # Cart-domain models (Order, OrderItem, PageConfig)
|
||||||
order.py # Order, OrderItem
|
|
||||||
page_config.py # PageConfig (per-page SumUp credentials)
|
|
||||||
bp/
|
bp/
|
||||||
cart/ # Cart blueprint
|
cart/ # Cart blueprint
|
||||||
global_routes.py # Add to cart, checkout, webhooks, return page
|
global_routes.py # Add to cart, checkout, webhooks, return page
|
||||||
page_routes.py # Page-scoped cart and checkout
|
page_routes.py # Page-scoped cart and checkout
|
||||||
overview_routes.py # Cart overview / summary page
|
overview_routes.py # Cart overview / summary page
|
||||||
api.py # Internal API (/internal/cart/summary)
|
|
||||||
services/ # Business logic
|
services/ # Business logic
|
||||||
checkout.py # Order creation, SumUp integration
|
checkout.py # Order creation, SumUp integration
|
||||||
check_sumup_status.py # Payment status polling
|
check_sumup_status.py # Payment status polling
|
||||||
@@ -39,31 +37,19 @@ bp/
|
|||||||
clear_cart_for_order.py # Soft-delete cart after checkout
|
clear_cart_for_order.py # Soft-delete cart after checkout
|
||||||
order/ # Single order detail view
|
order/ # Single order detail view
|
||||||
orders/ # Order listing view
|
orders/ # Order listing view
|
||||||
templates/ # Jinja2 templates
|
services/ # register_domain_services() — wires cart + calendar + market
|
||||||
entrypoint.sh # Docker entrypoint
|
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||||
Dockerfile
|
|
||||||
shared/ # Submodule → git.rose-ash.com/coop/shared.git
|
|
||||||
glue/ # Submodule → git.rose-ash.com/coop/glue.git
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Cross-Domain Communication
|
||||||
|
|
||||||
**Cross-app model imports:**
|
- `services.calendar.*` — claim/confirm entries for orders, adopt on login
|
||||||
- `market.models.market.Product, CartItem` — cart services, checkout, API
|
- `services.market.*` — marketplace queries for page-scoped carts
|
||||||
- `market.models.market_place.MarketPlace` — checkout page-config resolution, API page filtering
|
- `services.blog.*` — post lookup for page context
|
||||||
- `events.models.calendars.CalendarEntry, Calendar` — checkout, API summary, calendar cart services
|
- `shared.services.navigation` — site navigation tree
|
||||||
- `blog.models.ghost_content.Post` — `app.py` context processor, API page-slug lookup
|
|
||||||
|
|
||||||
**Glue services:**
|
## Domain Events
|
||||||
- `glue.services.order_lifecycle.claim_entries_for_order` — checkout marks entries as "ordered"
|
|
||||||
- `glue.services.order_lifecycle.confirm_entries_for_order` — payment confirmation marks entries "provisional"
|
|
||||||
- `glue.services.order_lifecycle.get_entries_for_order` — checkout return page loads entries
|
|
||||||
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
|
|
||||||
|
|
||||||
**Internal APIs:**
|
|
||||||
- Exposes `GET /internal/cart/summary` — cart count + total for current session/user
|
|
||||||
|
|
||||||
**Domain events:**
|
|
||||||
- `checkout.py` emits `order.created` via `shared.events.emit_event`
|
- `checkout.py` emits `order.created` via `shared.events.emit_event`
|
||||||
- `check_sumup_status.py` emits `order.paid` via `shared.events.emit_event`
|
- `check_sumup_status.py` emits `order.paid` via `shared.events.emit_event`
|
||||||
|
|
||||||
@@ -72,11 +58,11 @@ glue/ # Submodule → git.rose-ash.com/coop/glue.git
|
|||||||
```
|
```
|
||||||
1. User clicks "Checkout"
|
1. User clicks "Checkout"
|
||||||
2. create_order_from_cart() creates Order + OrderItems
|
2. create_order_from_cart() creates Order + OrderItems
|
||||||
3. glue: claim_entries_for_order() marks CalendarEntries as "ordered"
|
3. services.calendar.claim_entries_for_order() marks entries as "ordered"
|
||||||
4. emit: order.created event
|
4. emit: order.created event
|
||||||
5. SumUp hosted checkout created, user redirected
|
5. SumUp hosted checkout created, user redirected
|
||||||
6. SumUp webhook / return page triggers check_sumup_status()
|
6. SumUp webhook / return page triggers check_sumup_status()
|
||||||
7. If PAID: glue: confirm_entries_for_order(), emit: order.paid
|
7. If PAID: services.calendar.confirm_entries_for_order(), emit: order.paid
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
@@ -86,12 +72,5 @@ export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
|||||||
export REDIS_URL=redis://localhost:6379/0
|
export REDIS_URL=redis://localhost:6379/0
|
||||||
export SECRET_KEY=your-secret-key
|
export SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
hypercorn app:app --reload --bind 0.0.0.0:8002
|
hypercorn app:app --bind 0.0.0.0:8002
|
||||||
```
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t cart .
|
|
||||||
docker run -p 8002:8000 --env-file .env cart
|
|
||||||
```
|
```
|
||||||
|
|||||||
93
app.py
93
app.py
@@ -1,9 +1,10 @@
|
|||||||
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/ to sys.path
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, abort
|
from quart import g, abort, request
|
||||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -13,19 +14,23 @@ from bp import (
|
|||||||
register_cart_overview,
|
register_cart_overview,
|
||||||
register_page_cart,
|
register_page_cart,
|
||||||
register_cart_global,
|
register_cart_global,
|
||||||
register_cart_api,
|
|
||||||
register_orders,
|
register_orders,
|
||||||
|
register_fragments,
|
||||||
)
|
)
|
||||||
from bp.cart.services import (
|
from bp.cart.services import (
|
||||||
get_cart,
|
get_cart,
|
||||||
total,
|
total,
|
||||||
get_calendar_cart_entries,
|
get_calendar_cart_entries,
|
||||||
calendar_total,
|
calendar_total,
|
||||||
|
get_ticket_cart_entries,
|
||||||
|
ticket_total,
|
||||||
)
|
)
|
||||||
from bp.cart.services.page_cart import (
|
from bp.cart.services.page_cart import (
|
||||||
get_cart_for_page,
|
get_cart_for_page,
|
||||||
get_calendar_entries_for_page,
|
get_calendar_entries_for_page,
|
||||||
|
get_tickets_for_page,
|
||||||
)
|
)
|
||||||
|
from bp.cart.services.ticket_groups import group_tickets
|
||||||
|
|
||||||
|
|
||||||
async def _load_cart():
|
async def _load_cart():
|
||||||
@@ -40,42 +45,54 @@ async def cart_context() -> dict:
|
|||||||
- cart / calendar_cart_entries / total / calendar_total: direct DB
|
- cart / calendar_cart_entries / total / calendar_total: direct DB
|
||||||
(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: direct DB query via glue layer
|
- nav_tree_html: fetched from blog as fragment
|
||||||
|
|
||||||
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
|
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
|
||||||
Global cart_count / cart_total stay global for cart-mini.
|
Global cart_count / cart_total stay global for cart-mini.
|
||||||
"""
|
"""
|
||||||
from shared.infrastructure.context import base_context
|
from shared.infrastructure.context import base_context
|
||||||
from shared.services.navigation import get_navigation_tree
|
from shared.services.navigation import get_navigation_tree
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
|
ctx["nav_tree_html"] = await fetch_fragment(
|
||||||
|
"blog", "nav-tree",
|
||||||
|
params={"app_name": "cart", "path": request.path},
|
||||||
|
)
|
||||||
|
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||||
|
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||||
|
|
||||||
# Cart app owns cart data — use g.cart from _load_cart
|
# Cart app owns cart data — use g.cart from _load_cart
|
||||||
all_cart = getattr(g, "cart", None) or []
|
all_cart = getattr(g, "cart", None) or []
|
||||||
all_cal = await get_calendar_cart_entries(g.s)
|
all_cal = await get_calendar_cart_entries(g.s)
|
||||||
|
all_tickets = await get_ticket_cart_entries(g.s)
|
||||||
|
|
||||||
# Global counts for cart-mini (always global)
|
# Global counts for cart-mini (always global)
|
||||||
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
|
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_count"] = cart_qty + len(all_cal) + len(all_tickets)
|
||||||
ctx["cart_total"] = (total(all_cart) or 0) + (calendar_total(all_cal) or 0)
|
ctx["cart_total"] = (total(all_cart) or Decimal(0)) + (calendar_total(all_cal) or Decimal(0)) + (ticket_total(all_tickets) or Decimal(0))
|
||||||
|
|
||||||
# Page-scoped data when viewing a page cart
|
# Page-scoped data when viewing a page cart
|
||||||
page_post = getattr(g, "page_post", None)
|
page_post = getattr(g, "page_post", None)
|
||||||
if page_post:
|
if page_post:
|
||||||
page_cart = await get_cart_for_page(g.s, page_post.id)
|
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)
|
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
|
||||||
|
page_tickets = await get_tickets_for_page(g.s, page_post.id)
|
||||||
ctx["cart"] = page_cart
|
ctx["cart"] = page_cart
|
||||||
ctx["calendar_cart_entries"] = page_cal
|
ctx["calendar_cart_entries"] = page_cal
|
||||||
|
ctx["ticket_cart_entries"] = page_tickets
|
||||||
ctx["page_post"] = page_post
|
ctx["page_post"] = page_post
|
||||||
ctx["page_config"] = getattr(g, "page_config", None)
|
ctx["page_config"] = getattr(g, "page_config", None)
|
||||||
else:
|
else:
|
||||||
ctx["cart"] = all_cart
|
ctx["cart"] = all_cart
|
||||||
ctx["calendar_cart_entries"] = all_cal
|
ctx["calendar_cart_entries"] = all_cal
|
||||||
|
ctx["ticket_cart_entries"] = all_tickets
|
||||||
|
|
||||||
|
ctx["ticket_groups"] = group_tickets(ctx.get("ticket_cart_entries", []))
|
||||||
ctx["total"] = total
|
ctx["total"] = total
|
||||||
ctx["calendar_total"] = calendar_total
|
ctx["calendar_total"] = calendar_total
|
||||||
|
ctx["ticket_total"] = ticket_total
|
||||||
ctx["menu_items"] = await get_navigation_tree(g.s)
|
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@@ -99,6 +116,11 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
|
||||||
|
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
|
||||||
|
|
||||||
|
app.register_blueprint(register_fragments())
|
||||||
|
|
||||||
# --- Page slug hydration (follows events/market app pattern) ---
|
# --- Page slug hydration (follows events/market app pattern) ---
|
||||||
|
|
||||||
@app.url_value_preprocessor
|
@app.url_value_preprocessor
|
||||||
@@ -134,9 +156,6 @@ def create_app() -> "Quart":
|
|||||||
# --- Blueprint registration ---
|
# --- Blueprint registration ---
|
||||||
# Static prefixes first, dynamic (page_slug) last
|
# Static prefixes first, dynamic (page_slug) last
|
||||||
|
|
||||||
# Internal API (server-to-server, CSRF-exempt)
|
|
||||||
app.register_blueprint(register_cart_api())
|
|
||||||
|
|
||||||
# Orders blueprint
|
# Orders blueprint
|
||||||
app.register_blueprint(register_orders(url_prefix="/orders"))
|
app.register_blueprint(register_orders(url_prefix="/orders"))
|
||||||
|
|
||||||
@@ -158,6 +177,58 @@ def create_app() -> "Quart":
|
|||||||
url_prefix="/<page_slug>",
|
url_prefix="/<page_slug>",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Reconcile stale pending orders on startup ---
|
||||||
|
@app.before_serving
|
||||||
|
async def _reconcile_pending_orders():
|
||||||
|
"""Check SumUp status for orders stuck in 'pending' with a checkout ID.
|
||||||
|
|
||||||
|
Handles the case where SumUp webhooks fired while the service was down
|
||||||
|
or were rejected (e.g. CSRF). Runs once on boot.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from shared.db.session import get_session
|
||||||
|
from shared.models.order import Order
|
||||||
|
from bp.cart.services.check_sumup_status import check_sumup_status
|
||||||
|
|
||||||
|
log = logging.getLogger("cart.reconcile")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_session() as sess:
|
||||||
|
async with sess.begin():
|
||||||
|
# Orders that are pending, have a SumUp checkout, and are
|
||||||
|
# older than 2 minutes (avoid racing with in-flight checkouts)
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
|
||||||
|
result = await sess.execute(
|
||||||
|
select(Order)
|
||||||
|
.where(
|
||||||
|
Order.status == "pending",
|
||||||
|
Order.sumup_checkout_id.isnot(None),
|
||||||
|
Order.created_at < cutoff,
|
||||||
|
)
|
||||||
|
.options(selectinload(Order.page_config))
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
stale_orders = result.scalars().all()
|
||||||
|
|
||||||
|
if not stale_orders:
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("Reconciling %d stale pending orders", len(stale_orders))
|
||||||
|
for order in stale_orders:
|
||||||
|
try:
|
||||||
|
await check_sumup_status(sess, order)
|
||||||
|
log.info(
|
||||||
|
"Order %d reconciled: %s",
|
||||||
|
order.id, order.status,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Failed to reconcile order %d", order.id)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Order reconciliation failed")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from .cart.overview_routes import register as register_cart_overview
|
from .cart.overview_routes import register as register_cart_overview
|
||||||
from .cart.page_routes import register as register_page_cart
|
from .cart.page_routes import register as register_page_cart
|
||||||
from .cart.global_routes import register as register_cart_global
|
from .cart.global_routes import register as register_cart_global
|
||||||
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
|
||||||
|
from .fragments import register_fragments
|
||||||
|
|||||||
111
bp/cart/api.py
111
bp/cart/api.py
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Internal JSON API for the cart app.
|
|
||||||
|
|
||||||
These endpoints are called by other apps (coop, market) over HTTP.
|
|
||||||
They are CSRF-exempt because they are server-to-server calls.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from quart import Blueprint, g, request, jsonify
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from shared.models.market import CartItem
|
|
||||||
from shared.models.market_place import MarketPlace
|
|
||||||
from shared.browser.app.csrf import csrf_exempt
|
|
||||||
from shared.infrastructure.cart_identity import current_cart_identity
|
|
||||||
from shared.services.registry import services
|
|
||||||
|
|
||||||
|
|
||||||
def register() -> Blueprint:
|
|
||||||
bp = Blueprint("cart_api", __name__, url_prefix="/internal/cart")
|
|
||||||
|
|
||||||
@bp.get("/summary")
|
|
||||||
@csrf_exempt
|
|
||||||
async def summary():
|
|
||||||
"""
|
|
||||||
Return a lightweight cart summary (count + total) for the
|
|
||||||
current session/user. Called by coop and market apps to
|
|
||||||
populate the cart-mini widget without importing cart services.
|
|
||||||
|
|
||||||
Optional query param: ?page_slug=<slug>
|
|
||||||
When provided, returns only items scoped to that page.
|
|
||||||
"""
|
|
||||||
ident = current_cart_identity()
|
|
||||||
|
|
||||||
# Resolve optional page filter
|
|
||||||
page_slug = request.args.get("page_slug")
|
|
||||||
page_post_id = None
|
|
||||||
if page_slug:
|
|
||||||
post = await services.blog.get_post_by_slug(g.s, page_slug)
|
|
||||||
if post and post.is_page:
|
|
||||||
page_post_id = post.id
|
|
||||||
|
|
||||||
# --- product cart ---
|
|
||||||
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
|
|
||||||
if ident["user_id"] is not None:
|
|
||||||
cart_q = cart_q.where(CartItem.user_id == ident["user_id"])
|
|
||||||
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.container_type == "page",
|
|
||||||
MarketPlace.container_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_count = sum(ci.quantity for ci in cart_items)
|
|
||||||
cart_total = sum(
|
|
||||||
(ci.product.special_price or ci.product.regular_price or 0) * ci.quantity
|
|
||||||
for ci in cart_items
|
|
||||||
if ci.product and (ci.product.special_price or ci.product.regular_price)
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- calendar entries via service ---
|
|
||||||
if page_post_id is not None:
|
|
||||||
cal_entries = await services.calendar.entries_for_page(
|
|
||||||
g.s, page_post_id,
|
|
||||||
user_id=ident["user_id"],
|
|
||||||
session_id=ident["session_id"],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cal_entries = await services.calendar.pending_entries(
|
|
||||||
g.s,
|
|
||||||
user_id=ident["user_id"],
|
|
||||||
session_id=ident["session_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
calendar_count = len(cal_entries)
|
|
||||||
calendar_total = sum((e.cost or 0) for e in cal_entries if e.cost is not None)
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"slug": ci.product.slug if ci.product else None,
|
|
||||||
"title": ci.product.title if ci.product else None,
|
|
||||||
"image": ci.product.image if ci.product else None,
|
|
||||||
"quantity": ci.quantity,
|
|
||||||
"price": float(ci.product.special_price or ci.product.regular_price or 0)
|
|
||||||
if ci.product
|
|
||||||
else 0,
|
|
||||||
}
|
|
||||||
for ci in cart_items
|
|
||||||
]
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"count": cart_count,
|
|
||||||
"total": float(cart_total),
|
|
||||||
"calendar_count": calendar_count,
|
|
||||||
"calendar_total": float(calendar_total),
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return bp
|
|
||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
|
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from shared.models.market import CartItem
|
||||||
from shared.models.order import Order
|
from shared.models.order import Order
|
||||||
from shared.models.market_place import MarketPlace
|
from shared.models.market_place import MarketPlace
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
@@ -12,9 +13,10 @@ from .services import (
|
|||||||
current_cart_identity,
|
current_cart_identity,
|
||||||
get_cart,
|
get_cart,
|
||||||
total,
|
total,
|
||||||
clear_cart_for_order,
|
|
||||||
get_calendar_cart_entries,
|
get_calendar_cart_entries,
|
||||||
calendar_total,
|
calendar_total,
|
||||||
|
get_ticket_cart_entries,
|
||||||
|
ticket_total,
|
||||||
check_sumup_status,
|
check_sumup_status,
|
||||||
)
|
)
|
||||||
from .services.checkout import (
|
from .services.checkout import (
|
||||||
@@ -28,6 +30,7 @@ from .services.checkout import (
|
|||||||
get_order_with_details,
|
get_order_with_details,
|
||||||
)
|
)
|
||||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||||
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix: str) -> Blueprint:
|
def register(url_prefix: str) -> Blueprint:
|
||||||
@@ -53,24 +56,96 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
return redirect(url_for("cart_overview.overview"))
|
return redirect(url_for("cart_overview.overview"))
|
||||||
|
|
||||||
|
@bp.post("/quantity/<int:product_id>/")
|
||||||
|
async def update_quantity(product_id: int):
|
||||||
|
ident = current_cart_identity()
|
||||||
|
form = await request.form
|
||||||
|
count = int(form.get("count", 0))
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
CartItem.deleted_at.is_(None),
|
||||||
|
CartItem.product_id == product_id,
|
||||||
|
]
|
||||||
|
if ident["user_id"] is not None:
|
||||||
|
filters.append(CartItem.user_id == ident["user_id"])
|
||||||
|
else:
|
||||||
|
filters.append(CartItem.session_id == ident["session_id"])
|
||||||
|
|
||||||
|
existing = await g.s.scalar(select(CartItem).where(*filters))
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.quantity = max(count, 0)
|
||||||
|
await g.s.flush()
|
||||||
|
|
||||||
|
resp = await make_response("", 200)
|
||||||
|
resp.headers["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@bp.post("/ticket-quantity/")
|
||||||
|
async def update_ticket_quantity():
|
||||||
|
"""Adjust reserved ticket count (+/- pattern, like products)."""
|
||||||
|
ident = current_cart_identity()
|
||||||
|
form = await request.form
|
||||||
|
entry_id = int(form.get("entry_id", 0))
|
||||||
|
count = max(int(form.get("count", 0)), 0)
|
||||||
|
tt_raw = (form.get("ticket_type_id") or "").strip()
|
||||||
|
ticket_type_id = int(tt_raw) if tt_raw else None
|
||||||
|
|
||||||
|
await services.calendar.adjust_ticket_quantity(
|
||||||
|
g.s, entry_id, count,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=ticket_type_id,
|
||||||
|
)
|
||||||
|
await g.s.flush()
|
||||||
|
|
||||||
|
resp = await make_response("", 200)
|
||||||
|
resp.headers["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@bp.post("/delete/<int:product_id>/")
|
||||||
|
async def delete_item(product_id: int):
|
||||||
|
ident = current_cart_identity()
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
CartItem.deleted_at.is_(None),
|
||||||
|
CartItem.product_id == product_id,
|
||||||
|
]
|
||||||
|
if ident["user_id"] is not None:
|
||||||
|
filters.append(CartItem.user_id == ident["user_id"])
|
||||||
|
else:
|
||||||
|
filters.append(CartItem.session_id == ident["session_id"])
|
||||||
|
|
||||||
|
existing = await g.s.scalar(select(CartItem).where(*filters))
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
await g.s.delete(existing)
|
||||||
|
await g.s.flush()
|
||||||
|
|
||||||
|
resp = await make_response("", 200)
|
||||||
|
resp.headers["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
@bp.post("/checkout/")
|
@bp.post("/checkout/")
|
||||||
async def checkout():
|
async def checkout():
|
||||||
"""Legacy global checkout (for orphan items without page scope)."""
|
"""Legacy global checkout (for orphan items without page scope)."""
|
||||||
cart = await get_cart(g.s)
|
cart = await get_cart(g.s)
|
||||||
calendar_entries = await get_calendar_cart_entries(g.s)
|
calendar_entries = await get_calendar_cart_entries(g.s)
|
||||||
|
tickets = await get_ticket_cart_entries(g.s)
|
||||||
|
|
||||||
if not cart and not calendar_entries:
|
if not cart and not calendar_entries and not tickets:
|
||||||
return redirect(url_for("cart_overview.overview"))
|
return redirect(url_for("cart_overview.overview"))
|
||||||
|
|
||||||
product_total = total(cart) or 0
|
product_total = total(cart) or 0
|
||||||
calendar_amount = calendar_total(calendar_entries) or 0
|
calendar_amount = calendar_total(calendar_entries) or 0
|
||||||
cart_total = product_total + calendar_amount
|
ticket_amount = ticket_total(tickets) or 0
|
||||||
|
cart_total = product_total + calendar_amount + ticket_amount
|
||||||
|
|
||||||
if cart_total <= 0:
|
if cart_total <= 0:
|
||||||
return redirect(url_for("cart_overview.overview"))
|
return redirect(url_for("cart_overview.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page_config = await resolve_page_config(g.s, cart, calendar_entries)
|
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/cart/checkout_error.html",
|
"_types/cart/checkout_error.html",
|
||||||
@@ -88,6 +163,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
ident.get("session_id"),
|
ident.get("session_id"),
|
||||||
product_total,
|
product_total,
|
||||||
calendar_amount,
|
calendar_amount,
|
||||||
|
ticket_total=ticket_amount,
|
||||||
)
|
)
|
||||||
|
|
||||||
if page_config:
|
if page_config:
|
||||||
@@ -95,7 +171,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
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)
|
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, ticket_count=len(tickets))
|
||||||
|
|
||||||
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
||||||
webhook_url = build_webhook_url(webhook_base_url)
|
webhook_url = build_webhook_url(webhook_base_url)
|
||||||
@@ -107,8 +183,6 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
description=description,
|
description=description,
|
||||||
page_config=page_config,
|
page_config=page_config,
|
||||||
)
|
)
|
||||||
await clear_cart_for_order(g.s, order)
|
|
||||||
|
|
||||||
order.sumup_checkout_id = checkout_data.get("id")
|
order.sumup_checkout_id = checkout_data.get("id")
|
||||||
order.sumup_status = checkout_data.get("status")
|
order.sumup_status = checkout_data.get("status")
|
||||||
order.description = checkout_data.get("description")
|
order.description = checkout_data.get("description")
|
||||||
@@ -129,6 +203,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
return redirect(hosted_url)
|
return redirect(hosted_url)
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/checkout/webhook/<int:order_id>/")
|
@bp.post("/checkout/webhook/<int:order_id>/")
|
||||||
async def checkout_webhook(order_id: int):
|
async def checkout_webhook(order_id: int):
|
||||||
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
|
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
|
||||||
@@ -204,6 +279,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
status = (order.status or "pending").lower()
|
status = (order.status or "pending").lower()
|
||||||
|
|
||||||
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
|
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
|
||||||
|
order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
@@ -211,6 +287,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
order=order,
|
order=order,
|
||||||
status=status,
|
status=status,
|
||||||
calendar_entries=calendar_entries,
|
calendar_entries=calendar_entries,
|
||||||
|
order_tickets=order_tickets,
|
||||||
)
|
)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
|
|||||||
from shared.config import config
|
from shared.config import config
|
||||||
from .services import (
|
from .services import (
|
||||||
total,
|
total,
|
||||||
clear_cart_for_order,
|
|
||||||
calendar_total,
|
calendar_total,
|
||||||
|
ticket_total,
|
||||||
)
|
)
|
||||||
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page
|
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
|
||||||
|
from .services.ticket_groups import group_tickets
|
||||||
from .services.checkout import (
|
from .services.checkout import (
|
||||||
create_order_from_cart,
|
create_order_from_cart,
|
||||||
build_sumup_description,
|
build_sumup_description,
|
||||||
@@ -30,14 +31,20 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
post = g.page_post
|
post = g.page_post
|
||||||
cart = await get_cart_for_page(g.s, post.id)
|
cart = await get_cart_for_page(g.s, post.id)
|
||||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||||
|
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||||
|
|
||||||
|
ticket_groups = group_tickets(page_tickets)
|
||||||
|
|
||||||
tpl_ctx = dict(
|
tpl_ctx = dict(
|
||||||
page_post=post,
|
page_post=post,
|
||||||
page_config=getattr(g, "page_config", None),
|
page_config=getattr(g, "page_config", None),
|
||||||
cart=cart,
|
cart=cart,
|
||||||
calendar_cart_entries=cal_entries,
|
calendar_cart_entries=cal_entries,
|
||||||
|
ticket_cart_entries=page_tickets,
|
||||||
|
ticket_groups=ticket_groups,
|
||||||
total=total,
|
total=total,
|
||||||
calendar_total=calendar_total,
|
calendar_total=calendar_total,
|
||||||
|
ticket_total=ticket_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
@@ -53,13 +60,15 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
cart = await get_cart_for_page(g.s, post.id)
|
cart = await get_cart_for_page(g.s, post.id)
|
||||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||||
|
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||||
|
|
||||||
if not cart and not cal_entries:
|
if not cart and not cal_entries and not page_tickets:
|
||||||
return redirect(url_for("page_cart.page_view"))
|
return redirect(url_for("page_cart.page_view"))
|
||||||
|
|
||||||
product_total = total(cart) or 0
|
product_total = total(cart) or 0
|
||||||
calendar_amount = calendar_total(cal_entries) or 0
|
calendar_amount = calendar_total(cal_entries) or 0
|
||||||
cart_total = product_total + calendar_amount
|
ticket_amount = ticket_total(page_tickets) or 0
|
||||||
|
cart_total = product_total + calendar_amount + ticket_amount
|
||||||
|
|
||||||
if cart_total <= 0:
|
if cart_total <= 0:
|
||||||
return redirect(url_for("page_cart.page_view"))
|
return redirect(url_for("page_cart.page_view"))
|
||||||
@@ -74,6 +83,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
ident.get("session_id"),
|
ident.get("session_id"),
|
||||||
product_total,
|
product_total,
|
||||||
calendar_amount,
|
calendar_amount,
|
||||||
|
ticket_total=ticket_amount,
|
||||||
page_post_id=post.id,
|
page_post_id=post.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,7 +94,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
# Build SumUp checkout details — webhook/return use global routes
|
# Build SumUp checkout details — webhook/return use global routes
|
||||||
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
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)
|
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, ticket_count=len(page_tickets))
|
||||||
|
|
||||||
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
||||||
webhook_url = build_webhook_url(webhook_base_url)
|
webhook_url = build_webhook_url(webhook_base_url)
|
||||||
@@ -96,8 +106,6 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
description=description,
|
description=description,
|
||||||
page_config=page_config,
|
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_checkout_id = checkout_data.get("id")
|
||||||
order.sumup_status = checkout_data.get("status")
|
order.sumup_status = checkout_data.get("status")
|
||||||
order.description = checkout_data.get("description")
|
order.description = checkout_data.get("description")
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ from .get_cart import get_cart
|
|||||||
from .identity import current_cart_identity
|
from .identity import current_cart_identity
|
||||||
from .total import total
|
from .total import total
|
||||||
from .clear_cart_for_order import clear_cart_for_order
|
from .clear_cart_for_order import clear_cart_for_order
|
||||||
from .calendar_cart import get_calendar_cart_entries, calendar_total
|
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
|
||||||
from .check_sumup_status import check_sumup_status
|
from .check_sumup_status import check_sumup_status
|
||||||
from .page_cart import (
|
from .page_cart import (
|
||||||
get_cart_for_page,
|
get_cart_for_page,
|
||||||
get_calendar_entries_for_page,
|
get_calendar_entries_for_page,
|
||||||
|
get_tickets_for_page,
|
||||||
get_cart_grouped_by_page,
|
get_cart_grouped_by_page,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
from .identity import current_cart_identity
|
from .identity import current_cart_identity
|
||||||
|
|
||||||
@@ -17,12 +19,27 @@ async def get_calendar_cart_entries(session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def calendar_total(entries) -> float:
|
def calendar_total(entries) -> Decimal:
|
||||||
"""
|
"""
|
||||||
Total cost of pending calendar entries.
|
Total cost of pending calendar entries.
|
||||||
"""
|
"""
|
||||||
return sum(
|
return sum(
|
||||||
(e.cost or 0)
|
(Decimal(str(e.cost)) if e.cost else Decimal(0))
|
||||||
for e in entries
|
for e in entries
|
||||||
if e.cost is not None
|
if e.cost is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ticket_cart_entries(session):
|
||||||
|
"""Return all reserved tickets (as TicketDTOs) for the current identity."""
|
||||||
|
ident = current_cart_identity()
|
||||||
|
return await services.calendar.pending_tickets(
|
||||||
|
session,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_total(tickets) -> Decimal:
|
||||||
|
"""Total cost of reserved tickets."""
|
||||||
|
return sum((Decimal(str(t.price)) if t.price else Decimal(0) for t in tickets), Decimal(0))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
|
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
|
||||||
from shared.events import emit_event
|
from shared.events import emit_activity
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
from .clear_cart_for_order import clear_cart_for_order
|
||||||
|
|
||||||
|
|
||||||
async def check_sumup_status(session, order):
|
async def check_sumup_status(session, order):
|
||||||
@@ -16,10 +17,24 @@ async def check_sumup_status(session, order):
|
|||||||
await services.calendar.confirm_entries_for_order(
|
await services.calendar.confirm_entries_for_order(
|
||||||
session, order.id, order.user_id, order.session_id
|
session, order.id, order.user_id, order.session_id
|
||||||
)
|
)
|
||||||
await emit_event(session, "order.paid", "order", order.id, {
|
await services.calendar.confirm_tickets_for_order(session, order.id)
|
||||||
|
|
||||||
|
# Clear cart only after payment is confirmed
|
||||||
|
page_post_id = page_config.container_id if page_config else None
|
||||||
|
await clear_cart_for_order(session, order, page_post_id=page_post_id)
|
||||||
|
|
||||||
|
await emit_activity(
|
||||||
|
session,
|
||||||
|
activity_type="rose:OrderPaid",
|
||||||
|
actor_uri="internal:cart",
|
||||||
|
object_type="rose:Order",
|
||||||
|
object_data={
|
||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
"user_id": order.user_id,
|
"user_id": order.user_id,
|
||||||
})
|
},
|
||||||
|
source_type="order",
|
||||||
|
source_id=order.id,
|
||||||
|
)
|
||||||
elif sumup_status == "FAILED":
|
elif sumup_status == "FAILED":
|
||||||
order.status = "failed"
|
order.status = "failed"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from shared.models.page_config import PageConfig
|
|||||||
from shared.models.market_place import MarketPlace
|
from shared.models.market_place import MarketPlace
|
||||||
from shared.config import config
|
from shared.config import config
|
||||||
from shared.contracts.dtos import CalendarEntryDTO
|
from shared.contracts.dtos import CalendarEntryDTO
|
||||||
from shared.events import emit_event
|
from shared.events import emit_activity
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ async def resolve_page_config(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
cart: list[CartItem],
|
cart: list[CartItem],
|
||||||
calendar_entries: list[CalendarEntryDTO],
|
calendar_entries: list[CalendarEntryDTO],
|
||||||
|
tickets=None,
|
||||||
) -> Optional["PageConfig"]:
|
) -> Optional["PageConfig"]:
|
||||||
"""Determine the PageConfig for this order.
|
"""Determine the PageConfig for this order.
|
||||||
|
|
||||||
@@ -85,6 +86,11 @@ async def resolve_page_config(
|
|||||||
if entry.calendar_container_id:
|
if entry.calendar_container_id:
|
||||||
post_ids.add(entry.calendar_container_id)
|
post_ids.add(entry.calendar_container_id)
|
||||||
|
|
||||||
|
# From tickets via calendar_container_id
|
||||||
|
for tk in (tickets or []):
|
||||||
|
if tk.calendar_container_id:
|
||||||
|
post_ids.add(tk.calendar_container_id)
|
||||||
|
|
||||||
if len(post_ids) > 1:
|
if len(post_ids) > 1:
|
||||||
raise ValueError("Cannot checkout items from multiple pages")
|
raise ValueError("Cannot checkout items from multiple pages")
|
||||||
|
|
||||||
@@ -110,16 +116,17 @@ async def create_order_from_cart(
|
|||||||
product_total: float,
|
product_total: float,
|
||||||
calendar_total: float,
|
calendar_total: float,
|
||||||
*,
|
*,
|
||||||
|
ticket_total: float = 0,
|
||||||
page_post_id: int | None = None,
|
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 + tickets.
|
||||||
|
|
||||||
When *page_post_id* is given, only calendar entries whose calendar
|
When *page_post_id* is given, only calendar entries/tickets whose calendar
|
||||||
belongs to that page are marked as "ordered". Otherwise all pending
|
belongs to that page are marked as "ordered". Otherwise all pending
|
||||||
entries are updated (legacy behaviour).
|
entries are updated (legacy behaviour).
|
||||||
"""
|
"""
|
||||||
cart_total = product_total + calendar_total
|
cart_total = product_total + calendar_total + ticket_total
|
||||||
|
|
||||||
# Determine currency from first product
|
# Determine currency from first product
|
||||||
first_product = cart[0].product if cart else None
|
first_product = cart[0].product if cart else None
|
||||||
@@ -154,29 +161,46 @@ async def create_order_from_cart(
|
|||||||
session, order.id, user_id, session_id, page_post_id
|
session, order.id, user_id, session_id, page_post_id
|
||||||
)
|
)
|
||||||
|
|
||||||
await emit_event(session, "order.created", "order", order.id, {
|
# Claim reserved tickets for this order
|
||||||
|
await services.calendar.claim_tickets_for_order(
|
||||||
|
session, order.id, user_id, session_id, page_post_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await emit_activity(
|
||||||
|
session,
|
||||||
|
activity_type="Create",
|
||||||
|
actor_uri="internal:cart",
|
||||||
|
object_type="rose:Order",
|
||||||
|
object_data={
|
||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
})
|
},
|
||||||
|
source_type="order",
|
||||||
|
source_id=order.id,
|
||||||
|
)
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
def build_sumup_description(cart: list[CartItem], order_id: int) -> str:
|
def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str:
|
||||||
"""Build a human-readable description for SumUp checkout."""
|
"""Build a human-readable description for SumUp checkout."""
|
||||||
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
|
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
|
||||||
item_count = sum(ci.quantity for ci in cart)
|
item_count = sum(ci.quantity for ci in cart)
|
||||||
|
|
||||||
|
parts = []
|
||||||
if titles:
|
if titles:
|
||||||
if len(titles) <= 3:
|
if len(titles) <= 3:
|
||||||
summary = ", ".join(titles)
|
parts.append(", ".join(titles))
|
||||||
else:
|
else:
|
||||||
summary = ", ".join(titles[:3]) + f" + {len(titles) - 3} more"
|
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
|
||||||
else:
|
if ticket_count:
|
||||||
summary = "order items"
|
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
|
||||||
|
|
||||||
return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}"
|
summary = ", ".join(parts) if parts else "order items"
|
||||||
|
total_count = item_count + ticket_count
|
||||||
|
|
||||||
|
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
|
||||||
|
|
||||||
|
|
||||||
def build_sumup_reference(order_id: int, page_config=None) -> str:
|
def build_sumup_reference(order_id: int, page_config=None) -> str:
|
||||||
|
|||||||
@@ -57,15 +57,26 @@ async def get_calendar_entries_for_page(session, post_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tickets_for_page(session, post_id: int):
|
||||||
|
"""Return reserved tickets (DTOs) scoped to a specific page."""
|
||||||
|
ident = current_cart_identity()
|
||||||
|
return await services.calendar.tickets_for_page(
|
||||||
|
session, post_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_cart_grouped_by_page(session) -> list[dict]:
|
async def get_cart_grouped_by_page(session) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Load all cart items + calendar entries for the current identity,
|
Load all cart items + calendar entries for the current identity,
|
||||||
grouped by owning page (post_id).
|
grouped by market_place (one card per market).
|
||||||
|
|
||||||
Returns a list of dicts:
|
Returns a list of dicts:
|
||||||
{
|
{
|
||||||
"post": Post | None,
|
"post": Post | None,
|
||||||
"page_config": PageConfig | None,
|
"page_config": PageConfig | None,
|
||||||
|
"market_place": MarketPlace | None,
|
||||||
"cart_items": [...],
|
"cart_items": [...],
|
||||||
"calendar_entries": [...],
|
"calendar_entries": [...],
|
||||||
"product_count": int,
|
"product_count": int,
|
||||||
@@ -75,41 +86,83 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
|
|||||||
"total": float,
|
"total": float,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Calendar entries (no market concept) attach to a page-level group.
|
||||||
Items without a market_place go in an orphan bucket (post=None).
|
Items without a market_place go in an orphan bucket (post=None).
|
||||||
"""
|
"""
|
||||||
from .get_cart import get_cart
|
from .get_cart import get_cart
|
||||||
from .calendar_cart import get_calendar_cart_entries
|
from .calendar_cart import get_calendar_cart_entries, get_ticket_cart_entries
|
||||||
from .total import total as calc_product_total
|
from .total import total as calc_product_total
|
||||||
from .calendar_cart import calendar_total as calc_calendar_total
|
from .calendar_cart import calendar_total as calc_calendar_total, ticket_total as calc_ticket_total
|
||||||
|
|
||||||
cart_items = await get_cart(session)
|
cart_items = await get_cart(session)
|
||||||
cal_entries = await get_calendar_cart_entries(session)
|
cal_entries = await get_calendar_cart_entries(session)
|
||||||
|
all_tickets = await get_ticket_cart_entries(session)
|
||||||
|
|
||||||
# Group by container_id (all current data has container_type="page")
|
# Group cart items by market_place_id
|
||||||
groups: dict[int | None, dict] = defaultdict(lambda: {
|
market_groups: dict[int | None, dict] = {}
|
||||||
"post_id": None,
|
for ci in cart_items:
|
||||||
|
mp_id = ci.market_place_id if ci.market_place else None
|
||||||
|
if mp_id not in market_groups:
|
||||||
|
market_groups[mp_id] = {
|
||||||
|
"market_place": ci.market_place,
|
||||||
|
"post_id": ci.market_place.container_id if ci.market_place else None,
|
||||||
"cart_items": [],
|
"cart_items": [],
|
||||||
"calendar_entries": [],
|
"calendar_entries": [],
|
||||||
})
|
"tickets": [],
|
||||||
|
}
|
||||||
|
market_groups[mp_id]["cart_items"].append(ci)
|
||||||
|
|
||||||
for ci in cart_items:
|
# Attach calendar entries to an existing market group for the same page,
|
||||||
if ci.market_place and ci.market_place.container_id:
|
# or create a page-level group if no market group exists for that page.
|
||||||
pid = ci.market_place.container_id
|
page_to_market: dict[int | None, int | None] = {}
|
||||||
else:
|
for mp_id, grp in market_groups.items():
|
||||||
pid = None
|
pid = grp["post_id"]
|
||||||
groups[pid]["post_id"] = pid
|
if pid is not None and pid not in page_to_market:
|
||||||
groups[pid]["cart_items"].append(ci)
|
page_to_market[pid] = mp_id
|
||||||
|
|
||||||
for ce in cal_entries:
|
for ce in cal_entries:
|
||||||
if ce.calendar_container_id:
|
pid = ce.calendar_container_id or None
|
||||||
pid = ce.calendar_container_id
|
if pid in page_to_market:
|
||||||
|
market_groups[page_to_market[pid]]["calendar_entries"].append(ce)
|
||||||
else:
|
else:
|
||||||
pid = None
|
# Create a page-level group for calendar-only entries
|
||||||
groups[pid]["post_id"] = pid
|
key = ("cal", pid)
|
||||||
groups[pid]["calendar_entries"].append(ce)
|
if key not in market_groups:
|
||||||
|
market_groups[key] = {
|
||||||
|
"market_place": None,
|
||||||
|
"post_id": pid,
|
||||||
|
"cart_items": [],
|
||||||
|
"calendar_entries": [],
|
||||||
|
"tickets": [],
|
||||||
|
}
|
||||||
|
if pid is not None:
|
||||||
|
page_to_market[pid] = key
|
||||||
|
market_groups[key]["calendar_entries"].append(ce)
|
||||||
|
|
||||||
|
# Attach tickets to page groups (via calendar_container_id)
|
||||||
|
for tk in all_tickets:
|
||||||
|
pid = tk.calendar_container_id or None
|
||||||
|
if pid in page_to_market:
|
||||||
|
market_groups[page_to_market[pid]]["tickets"].append(tk)
|
||||||
|
else:
|
||||||
|
key = ("tk", pid)
|
||||||
|
if key not in market_groups:
|
||||||
|
market_groups[key] = {
|
||||||
|
"market_place": None,
|
||||||
|
"post_id": pid,
|
||||||
|
"cart_items": [],
|
||||||
|
"calendar_entries": [],
|
||||||
|
"tickets": [],
|
||||||
|
}
|
||||||
|
if pid is not None:
|
||||||
|
page_to_market[pid] = key
|
||||||
|
market_groups[key]["tickets"].append(tk)
|
||||||
|
|
||||||
# Batch-load Post DTOs and PageConfig objects
|
# Batch-load Post DTOs and PageConfig objects
|
||||||
post_ids = [pid for pid in groups if pid is not None]
|
post_ids = list({
|
||||||
|
grp["post_id"] for grp in market_groups.values()
|
||||||
|
if grp["post_id"] is not None
|
||||||
|
})
|
||||||
posts_by_id: dict[int, object] = {}
|
posts_by_id: dict[int, object] = {}
|
||||||
configs_by_post: dict[int, PageConfig] = {}
|
configs_by_post: dict[int, PageConfig] = {}
|
||||||
|
|
||||||
@@ -126,25 +179,34 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
|
|||||||
for pc in pc_result.scalars().all():
|
for pc in pc_result.scalars().all():
|
||||||
configs_by_post[pc.container_id] = pc
|
configs_by_post[pc.container_id] = pc
|
||||||
|
|
||||||
# Build result list (pages first, orphan last)
|
# Build result list (markets with pages first, orphan last)
|
||||||
result = []
|
result = []
|
||||||
for pid in sorted(groups, key=lambda x: (x is None, x)):
|
for _key, grp in sorted(
|
||||||
grp = groups[pid]
|
market_groups.items(),
|
||||||
|
key=lambda kv: (kv[1]["post_id"] is None, kv[1]["post_id"] or 0),
|
||||||
|
):
|
||||||
items = grp["cart_items"]
|
items = grp["cart_items"]
|
||||||
entries = grp["calendar_entries"]
|
entries = grp["calendar_entries"]
|
||||||
|
tks = grp["tickets"]
|
||||||
prod_total = calc_product_total(items) or 0
|
prod_total = calc_product_total(items) or 0
|
||||||
cal_total = calc_calendar_total(entries) or 0
|
cal_total = calc_calendar_total(entries) or 0
|
||||||
|
tk_total = calc_ticket_total(tks) or 0
|
||||||
|
pid = grp["post_id"]
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
"post": posts_by_id.get(pid) if pid else None,
|
"post": posts_by_id.get(pid) if pid else None,
|
||||||
"page_config": configs_by_post.get(pid) if pid else None,
|
"page_config": configs_by_post.get(pid) if pid else None,
|
||||||
|
"market_place": grp["market_place"],
|
||||||
"cart_items": items,
|
"cart_items": items,
|
||||||
"calendar_entries": entries,
|
"calendar_entries": entries,
|
||||||
|
"tickets": tks,
|
||||||
"product_count": sum(ci.quantity for ci in items),
|
"product_count": sum(ci.quantity for ci in items),
|
||||||
"product_total": prod_total,
|
"product_total": prod_total,
|
||||||
"calendar_count": len(entries),
|
"calendar_count": len(entries),
|
||||||
"calendar_total": cal_total,
|
"calendar_total": cal_total,
|
||||||
"total": prod_total + cal_total,
|
"ticket_count": len(tks),
|
||||||
|
"ticket_total": tk_total,
|
||||||
|
"total": prod_total + cal_total + tk_total,
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
43
bp/cart/services/ticket_groups.py
Normal file
43
bp/cart/services/ticket_groups.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Group individual TicketDTOs by (entry_id, ticket_type_id) for cart display."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
|
||||||
|
def group_tickets(tickets) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Group a flat list of TicketDTOs into aggregate rows.
|
||||||
|
|
||||||
|
Returns list of dicts:
|
||||||
|
{
|
||||||
|
"entry_id": int,
|
||||||
|
"entry_name": str,
|
||||||
|
"entry_start_at": datetime,
|
||||||
|
"entry_end_at": datetime | None,
|
||||||
|
"ticket_type_id": int | None,
|
||||||
|
"ticket_type_name": str | None,
|
||||||
|
"price": Decimal | None,
|
||||||
|
"quantity": int,
|
||||||
|
"line_total": float,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
groups: OrderedDict[tuple, dict] = OrderedDict()
|
||||||
|
|
||||||
|
for tk in tickets:
|
||||||
|
key = (tk.entry_id, getattr(tk, "ticket_type_id", None))
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = {
|
||||||
|
"entry_id": tk.entry_id,
|
||||||
|
"entry_name": tk.entry_name,
|
||||||
|
"entry_start_at": tk.entry_start_at,
|
||||||
|
"entry_end_at": tk.entry_end_at,
|
||||||
|
"ticket_type_id": getattr(tk, "ticket_type_id", None),
|
||||||
|
"ticket_type_name": tk.ticket_type_name,
|
||||||
|
"price": tk.price,
|
||||||
|
"quantity": 0,
|
||||||
|
"line_total": 0,
|
||||||
|
}
|
||||||
|
groups[key]["quantity"] += 1
|
||||||
|
groups[key]["line_total"] += float(tk.price or 0)
|
||||||
|
|
||||||
|
return list(groups.values())
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
def total(cart):
|
def total(cart):
|
||||||
return sum(
|
return sum(
|
||||||
(item.product.special_price or item.product.regular_price) * item.quantity
|
(
|
||||||
|
Decimal(str(item.product.special_price or item.product.regular_price))
|
||||||
|
* item.quantity
|
||||||
|
)
|
||||||
for item in cart
|
for item in cart
|
||||||
if (item.product.special_price or item.product.regular_price) is not None
|
if (item.product.special_price or item.product.regular_price) is not None
|
||||||
)
|
)
|
||||||
|
|||||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .routes import register as register_fragments
|
||||||
70
bp/fragments/routes.py
Normal file
70
bp/fragments/routes.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Cart app fragment endpoints.
|
||||||
|
|
||||||
|
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
|
by other coop apps via the fragment client.
|
||||||
|
|
||||||
|
Fragments:
|
||||||
|
cart-mini Cart icon with badge (or logo when empty)
|
||||||
|
account-nav-item "orders" link for account dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, request, render_template, g
|
||||||
|
|
||||||
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Fragment handlers
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _cart_mini():
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
user_id = request.args.get("user_id", type=int)
|
||||||
|
session_id = request.args.get("session_id")
|
||||||
|
|
||||||
|
summary = await services.cart.cart_summary(
|
||||||
|
g.s, user_id=user_id, session_id=session_id,
|
||||||
|
)
|
||||||
|
count = summary.count + summary.calendar_count + summary.ticket_count
|
||||||
|
return await render_template("fragments/cart_mini.html", cart_count=count)
|
||||||
|
|
||||||
|
async def _account_nav_item():
|
||||||
|
from shared.infrastructure.urls import cart_url
|
||||||
|
|
||||||
|
href = cart_url("/orders/")
|
||||||
|
return (
|
||||||
|
'<div class="relative nav-group">'
|
||||||
|
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
|
||||||
|
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
|
||||||
|
'orders</a></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
_handlers = {
|
||||||
|
"cart-mini": _cart_mini,
|
||||||
|
"account-nav-item": _account_nav_item,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Routing
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
async def _require_fragment_header():
|
||||||
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
|
return Response("", status=403)
|
||||||
|
|
||||||
|
@bp.get("/<fragment_type>")
|
||||||
|
async def get_fragment(fragment_type: str):
|
||||||
|
handler = _handlers.get(fragment_type)
|
||||||
|
if handler is None:
|
||||||
|
return Response("", status=200, content_type="text/html")
|
||||||
|
html = await handler()
|
||||||
|
return Response(html, status=200, content_type="text/html")
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -3,16 +3,17 @@ base_host: "wholesale.suma.coop"
|
|||||||
base_login: https://wholesale.suma.coop/customer/account/login/
|
base_login: https://wholesale.suma.coop/customer/account/login/
|
||||||
base_url: https://wholesale.suma.coop/
|
base_url: https://wholesale.suma.coop/
|
||||||
title: Rose Ash
|
title: Rose Ash
|
||||||
coop_root: /market
|
market_root: /market
|
||||||
coop_title: Market
|
market_title: Market
|
||||||
blog_root: /
|
blog_root: /
|
||||||
blog_title: all the news
|
blog_title: all the news
|
||||||
cart_root: /cart
|
cart_root: /cart
|
||||||
app_urls:
|
app_urls:
|
||||||
coop: "http://localhost:8000"
|
blog: "http://localhost:8000"
|
||||||
market: "http://localhost:8001"
|
market: "http://localhost:8001"
|
||||||
cart: "http://localhost:8002"
|
cart: "http://localhost:8002"
|
||||||
events: "http://localhost:8003"
|
events: "http://localhost:8003"
|
||||||
|
federation: "http://localhost:8004"
|
||||||
cache:
|
cache:
|
||||||
fs_root: _snapshot # <- absolute path to your snapshot dir
|
fs_root: _snapshot # <- absolute path to your snapshot dir
|
||||||
categories:
|
categories:
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ def register_domain_services() -> None:
|
|||||||
services.calendar = SqlCalendarService()
|
services.calendar = SqlCalendarService()
|
||||||
if not services.has("market"):
|
if not services.has("market"):
|
||||||
services.market = SqlMarketService()
|
services.market = SqlMarketService()
|
||||||
|
if not services.has("federation"):
|
||||||
|
from shared.services.federation_impl import SqlFederationService
|
||||||
|
services.federation = SqlFederationService()
|
||||||
|
|||||||
2
shared
2
shared
Submodule shared updated: b3a0e9922a...9ab4b7b3fe
12
templates/_types/auth/header/_header.html
Normal file
12
templates/_types/auth/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='auth-row', oob=oob) %}
|
||||||
|
{% call links.link(account_url('/'), hx_select_search ) %}
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
<div>account</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% include "_types/auth/_nav.html" %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
18
templates/_types/auth/index.html
Normal file
18
templates/_types/auth/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends oob.extends %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block root_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row(oob.child_id, oob.header) %}
|
||||||
|
{% block auth_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include oob.nav %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include oob.main %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% macro show_cart(oob=False) %}
|
{% macro show_cart(oob=False) %}
|
||||||
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
|
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
|
||||||
{# Empty cart #}
|
{# Empty cart #}
|
||||||
{% if not cart and not calendar_cart_entries %}
|
{% if not cart and not calendar_cart_entries and not ticket_cart_entries %}
|
||||||
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
|
<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">
|
<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>
|
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
|
||||||
@@ -60,8 +60,106 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ticket_groups is defined and ticket_groups %}
|
||||||
|
<div class="mt-6 border-t border-stone-200 pt-4">
|
||||||
|
<h2 class="text-base font-semibold mb-2">
|
||||||
|
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
||||||
|
Event tickets
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for tg in ticket_groups %}
|
||||||
|
<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
{{ tg.entry_name }}
|
||||||
|
</h3>
|
||||||
|
{% if tg.ticket_type_name %}
|
||||||
|
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||||
|
{{ tg.ticket_type_name }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||||
|
{{ tg.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% if tg.entry_end_at %}
|
||||||
|
– {{ tg.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-left sm:text-right">
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
£{{ "%.2f"|format(tg.price or 0) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||||
|
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||||
|
{% set qty_url = url_for('cart_global.update_ticket_quantity') %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="{{ qty_url }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ qty_url }}"
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
|
||||||
|
{% if tg.ticket_type_id %}
|
||||||
|
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="count" value="{{ [tg.quantity - 1, 0] | max }}">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
|
||||||
|
{{ tg.quantity }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="{{ qty_url }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ qty_url }}"
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
|
||||||
|
{% if tg.ticket_type_id %}
|
||||||
|
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="count" value="{{ tg.quantity + 1 }}">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between sm:justify-end gap-3">
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
Line total:
|
||||||
|
£{{ "%.2f"|format(tg.line_total) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{{summary(cart, total, calendar_total, calendar_cart_entries,)}}
|
{{summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries)}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +168,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro summary(cart, total, calendar_total, calendar_cart_entries, oob=False) %}
|
{% macro summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries, oob=False) %}
|
||||||
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
|
<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">
|
<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">
|
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
|
||||||
@@ -81,13 +179,15 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<dt class="text-stone-600">Items</dt>
|
<dt class="text-stone-600">Items</dt>
|
||||||
<dd class="text-stone-900">
|
<dd class="text-stone-900">
|
||||||
{{ cart | sum(attribute="quantity") }}
|
{% set product_qty = cart | sum(attribute="quantity") %}
|
||||||
|
{% set ticket_qty = ticket_cart_entries | length if ticket_cart_entries else 0 %}
|
||||||
|
{{ product_qty + ticket_qty }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<dt class="text-stone-600">Subtotal</dt>
|
<dt class="text-stone-600">Subtotal</dt>
|
||||||
<dd class="text-stone-900">
|
<dd class="text-stone-900">
|
||||||
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries ) }}
|
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -117,23 +217,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set href=login_url(request.url) %}
|
{% set href=login_url(request.url) %}
|
||||||
<div
|
<div class="w-full flex">
|
||||||
class="w-full flex"
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href="{{ href }}"
|
href="{{ href }}"
|
||||||
hx-get="{{ href }}"
|
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
|
||||||
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>
|
<i class="fa-solid fa-key"></i>
|
||||||
<span>sign in or register to checkout</span>
|
<span>sign in or register to checkout</span>
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,10 +244,11 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries) %}
|
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) %}
|
||||||
{% set product_total = total(cart) or 0 %}
|
{% set product_total = total(cart) or 0 %}
|
||||||
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
|
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
|
||||||
{% set grand = product_total + cal_total %}
|
{% set tk_total = ticket_total(ticket_cart_entries) or 0 %}
|
||||||
|
{% set grand = product_total + cal_total + tk_total %}
|
||||||
|
|
||||||
{% if cart and cart[0].product.regular_price_currency %}
|
{% 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 %}
|
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
|
||||||
|
|||||||
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>
|
||||||
45
templates/_types/cart/_mini.html
Normal file
45
templates/_types/cart/_mini.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{% macro mini(oob=False, count=None) %}
|
||||||
|
<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.
|
||||||
|
count param allows explicit override when macro is imported without context. #}
|
||||||
|
{% if count is not none %}
|
||||||
|
{% set _count = count %}
|
||||||
|
{% elif 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="{{ blog_url('/') }}"
|
||||||
|
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="{{ 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"
|
||||||
|
>
|
||||||
|
<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' %}
|
||||||
|
{% include '_types/order/_ticket_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(cart_url('/'), 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 %}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
{# Check if there are any items at all across all groups #}
|
{# Check if there are any items at all across all groups #}
|
||||||
{% set ns = namespace(has_items=false) %}
|
{% set ns = namespace(has_items=false) %}
|
||||||
{% for grp in page_groups %}
|
{% for grp in page_groups %}
|
||||||
{% if grp.cart_items or grp.calendar_entries %}
|
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
|
||||||
{% set ns.has_items = true %}
|
{% set ns.has_items = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for grp in page_groups %}
|
{% for grp in page_groups %}
|
||||||
{% if grp.cart_items or grp.calendar_entries %}
|
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
|
||||||
|
|
||||||
{% if grp.post %}
|
{% if grp.post %}
|
||||||
{# Page cart card #}
|
{# Market / page cart card #}
|
||||||
<a
|
<a
|
||||||
href="{{ cart_url('/' + grp.post.slug + '/') }}"
|
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"
|
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"
|
||||||
@@ -53,8 +53,15 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
|
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
|
||||||
|
{% if grp.market_place %}
|
||||||
|
{{ grp.market_place.name }}
|
||||||
|
{% else %}
|
||||||
{{ grp.post.title }}
|
{{ grp.post.title }}
|
||||||
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
{% if grp.market_place %}
|
||||||
|
<p class="text-xs text-stone-500 truncate">{{ grp.post.title }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
|
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
|
||||||
{% if grp.product_count > 0 %}
|
{% if grp.product_count > 0 %}
|
||||||
@@ -69,6 +76,12 @@
|
|||||||
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">
|
||||||
|
<i class="fa fa-ticket" aria-hidden="true"></i>
|
||||||
|
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,6 +121,12 @@
|
|||||||
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100">
|
||||||
|
<i class="fa fa-ticket" aria-hidden="true"></i>
|
||||||
|
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
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_product_url(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>
|
||||||
|
|
||||||
|
|
||||||
49
templates/_types/order/_ticket_items.html
Normal file
49
templates/_types/order/_ticket_items.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{# --- Tickets in this order --- #}
|
||||||
|
{% if order and order_tickets %}
|
||||||
|
<section class="mt-6 space-y-3">
|
||||||
|
<h2 class="text-base sm:text-lg font-semibold">
|
||||||
|
Event tickets in this order
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
|
||||||
|
{% for tk in order_tickets %}
|
||||||
|
<li class="px-4 py-3 flex items-start justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium flex items-center gap-2">
|
||||||
|
{{ tk.entry_name }}
|
||||||
|
{# Small status pill #}
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
|
||||||
|
{% if tk.state == 'confirmed' %}
|
||||||
|
bg-emerald-100 text-emerald-800
|
||||||
|
{% elif tk.state == 'reserved' %}
|
||||||
|
bg-amber-100 text-amber-800
|
||||||
|
{% elif tk.state == 'checked_in' %}
|
||||||
|
bg-blue-100 text-blue-800
|
||||||
|
{% else %}
|
||||||
|
bg-stone-100 text-stone-700
|
||||||
|
{% endif %}
|
||||||
|
">
|
||||||
|
{{ tk.state|replace('_', ' ')|capitalize }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if tk.ticket_type_name %}
|
||||||
|
<div class="text-xs text-stone-500">{{ tk.ticket_type_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-xs text-stone-500">
|
||||||
|
{{ tk.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% if tk.entry_end_at %}
|
||||||
|
– {{ tk.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-stone-400 font-mono mt-0.5">
|
||||||
|
{{ tk.code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 font-medium">
|
||||||
|
£{{ "%.2f"|format(tk.price or 0) }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
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 %}
|
||||||
|
{% from 'macros/search.html' import search_desktop %}
|
||||||
|
{{ search_desktop(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">
|
||||||
|
{% from 'macros/search.html' import search_mobile %}
|
||||||
|
{{ search_mobile(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 %}
|
||||||
|
{% from 'macros/search.html' import search_desktop %}
|
||||||
|
{{ search_desktop(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 %}
|
||||||
250
templates/_types/product/_cart.html
Normal file
250
templates/_types/product/_cart.html
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
{% macro add(slug, cart, oob='false') %}
|
||||||
|
{% set quantity = cart
|
||||||
|
| selectattr('product.slug', 'equalto', slug)
|
||||||
|
| sum(attribute='quantity') %}
|
||||||
|
|
||||||
|
<div id="cart-{{ slug }}" {% if oob=='true' %} hx-swap-oob="{{oob}}" {% endif %}>
|
||||||
|
|
||||||
|
{% if not quantity %}
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="rounded flex items-center"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="1"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
|
||||||
|
|
||||||
|
<!-- black + overlaid in the center -->
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded flex items-center gap-2">
|
||||||
|
<!-- minus -->
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ quantity - 1 }}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- basket with quantity badge -->
|
||||||
|
<a
|
||||||
|
class="relative inline-flex items-center justify-center text-emerald-700"
|
||||||
|
href="{{ cart_url('/') }}"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold"
|
||||||
|
>
|
||||||
|
{{ quantity }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- plus -->
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ quantity + 1 }}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% macro cart_item(oob=False) %}
|
||||||
|
|
||||||
|
{% set p = item.product %}
|
||||||
|
{% set unit_price = p.special_price or p.regular_price %}
|
||||||
|
<article
|
||||||
|
id="cart-item-{{p.slug}}"
|
||||||
|
{% if oob %}
|
||||||
|
hx-swap-oob="{{oob}}"
|
||||||
|
{% endif %}
|
||||||
|
class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">
|
||||||
|
{% if p.image %}
|
||||||
|
<img
|
||||||
|
src="{{ p.image }}"
|
||||||
|
alt="{{ p.title }}"
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"
|
||||||
|
>
|
||||||
|
No image
|
||||||
|
</div>'market', 'product', p.slug
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Details #}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
|
||||||
|
{% set href=url_for('market.browse.product.product_detail', product_slug=p.slug) %}
|
||||||
|
<a
|
||||||
|
href="{{ href }}"
|
||||||
|
hx_get="{{href}}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select ="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
{{ p.title }}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if p.brand %}
|
||||||
|
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||||
|
{{ p.brand }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.is_deleted %}
|
||||||
|
<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>
|
||||||
|
This item is no longer available or price has changed
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Unit price #}
|
||||||
|
<div class="text-left sm:text-right">
|
||||||
|
{% if unit_price %}
|
||||||
|
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
{{ symbol }}{{ "%.2f"|format(unit_price) }}
|
||||||
|
</p>
|
||||||
|
{% if p.special_price and p.special_price != p.regular_price %}
|
||||||
|
<p class="text-xs text-stone-400 line-through">
|
||||||
|
{{ symbol }}{{ "%.2f"|format(p.regular_price) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-stone-500">No price</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||||
|
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ item.quantity - 1 }}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
|
||||||
|
{{ item.quantity }}
|
||||||
|
</span>
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ item.quantity + 1 }}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between sm:justify-end gap-3">
|
||||||
|
{% if unit_price %}
|
||||||
|
{% set line_total = unit_price * item.quantity %}
|
||||||
|
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
Line total:
|
||||||
|
{{ symbol }}{{ "%.2f"|format(line_total) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% endmacro %}
|
||||||
27
templates/fragments/cart_mini.html
Normal file
27
templates/fragments/cart_mini.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div id="cart-mini">
|
||||||
|
{% if cart_count == 0 %}
|
||||||
|
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||||
|
<a
|
||||||
|
href="{{ blog_url('/') }}"
|
||||||
|
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ blog_url('/static/img/logo.jpg') }}"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{{ cart_count }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user