feat: ticket purchase flow, QR display, and admin check-in
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Ticket purchase:
- tickets blueprint with routes for my tickets list, ticket detail with QR
- Buy tickets form on entry detail page (HTMX-powered)
- Ticket services: create, query, availability checking

Admin check-in:
- ticket_admin blueprint with dashboard, lookup, and check-in routes
- QR scanner/lookup interface with real-time search
- Per-entry ticket list view
- Check-in transitions ticket state to checked_in

Internal API:
- GET /internal/events/tickets endpoint for cross-app queries
- POST /internal/events/tickets/<code>/checkin for programmatic check-in

Template fixes:
- All templates updated: blog.post.calendars.* → calendars.*
- Removed slug=post.slug parameters (standalone events service)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-10 00:00:35 +00:00
parent 59a69ed320
commit 1bab546dfc
63 changed files with 1421 additions and 125 deletions

8
app.py
View File

@@ -46,6 +46,14 @@ def create_app() -> "Quart":
url_prefix="/calendars",
)
# Tickets blueprint — user-facing ticket views and QR codes
from .bp.tickets.routes import register as register_tickets
app.register_blueprint(register_tickets())
# Ticket admin — check-in interface (admin only)
from .bp.ticket_admin.routes import register as register_ticket_admin
app.register_blueprint(register_ticket_admin())
# Internal API (server-to-server, CSRF-exempt)
from .events_api import register as register_events_api
app.register_blueprint(register_events_api())

View File

@@ -160,10 +160,13 @@ def register():
@bp.context_processor
async def inject_root():
from ..tickets.services.tickets import get_available_ticket_count
view_args = getattr(request, "view_args", {}) or {}
entry_id = view_args.get("entry_id")
calendar_entry = None
entry_posts = []
ticket_remaining = None
stmt = (
select(CalendarEntry)
@@ -185,10 +188,13 @@ def register():
await g.s.refresh(calendar_entry, ['slot'])
# Fetch associated posts
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
# Get ticket availability
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
return {
"entry": calendar_entry,
"entry_posts": entry_posts,
"ticket_remaining": ticket_remaining,
}
@bp.get("/")
@require_admin

View File

166
bp/ticket_admin/routes.py Normal file
View File

@@ -0,0 +1,166 @@
"""
Ticket admin blueprint — check-in interface and ticket management.
Routes:
GET /admin/tickets/ — Ticket dashboard (scan + list)
GET /admin/tickets/entry/<id>/ — Tickets for a specific entry
POST /admin/tickets/<code>/checkin — Check in a ticket
GET /admin/tickets/<code>/ — Ticket admin detail
"""
from __future__ import annotations
import logging
from quart import (
Blueprint, g, request, render_template, make_response, jsonify,
)
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType
from suma_browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache
from ..tickets.services.tickets import (
get_ticket_by_code,
get_tickets_for_entry,
checkin_ticket,
)
logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("ticket_admin", __name__, url_prefix="/admin/tickets")
@bp.get("/")
@require_admin
async def dashboard():
"""Ticket admin dashboard with QR scanner and recent tickets."""
from suma_browser.app.utils.htmx import is_htmx_request
# Get recent tickets
result = await g.s.execute(
select(Ticket)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
.limit(50)
)
tickets = result.scalars().all()
# Stats
total = await g.s.scalar(select(func.count(Ticket.id)))
confirmed = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "confirmed")
)
checked_in = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "checked_in")
)
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
stats = {
"total": total or 0,
"confirmed": confirmed or 0,
"checked_in": checked_in or 0,
"reserved": reserved or 0,
}
if not is_htmx_request():
html = await render_template(
"_types/ticket_admin/index.html",
tickets=tickets,
stats=stats,
)
else:
html = await render_template(
"_types/ticket_admin/_main_panel.html",
tickets=tickets,
stats=stats,
)
return await make_response(html, 200)
@bp.get("/entry/<int:entry_id>/")
@require_admin
async def entry_tickets(entry_id: int):
"""List all tickets for a specific calendar entry."""
from suma_browser.app.utils.htmx import is_htmx_request
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.calendar))
)
if not entry:
return await make_response("Entry not found", 404)
tickets = await get_tickets_for_entry(g.s, entry_id)
html = await render_template(
"_types/ticket_admin/_entry_tickets.html",
entry=entry,
tickets=tickets,
)
return await make_response(html, 200)
@bp.get("/lookup/")
@require_admin
async def lookup():
"""Look up a ticket by code (used by scanner)."""
code = request.args.get("code", "").strip()
if not code:
return await make_response(
'<div class="text-sm text-stone-500">Enter a ticket code</div>',
200,
)
ticket = await get_ticket_by_code(g.s, code)
if not ticket:
html = await render_template(
"_types/ticket_admin/_lookup_result.html",
ticket=None,
error="Ticket not found",
)
return await make_response(html, 200)
html = await render_template(
"_types/ticket_admin/_lookup_result.html",
ticket=ticket,
error=None,
)
return await make_response(html, 200)
@bp.post("/<code>/checkin/")
@require_admin
@clear_cache(tag="calendars", tag_scope="all")
async def do_checkin(code: str):
"""Check in a ticket by its code."""
success, error = await checkin_ticket(g.s, code)
if not success:
html = await render_template(
"_types/ticket_admin/_checkin_result.html",
success=False,
error=error,
ticket=None,
)
return await make_response(html, 200)
ticket = await get_ticket_by_code(g.s, code)
html = await render_template(
"_types/ticket_admin/_checkin_result.html",
success=True,
error=None,
ticket=ticket,
)
return await make_response(html, 200)
return bp

View File

0
bp/tickets/__init__.py Normal file
View File

181
bp/tickets/routes.py Normal file
View File

@@ -0,0 +1,181 @@
"""
Tickets blueprint — user-facing ticket views and QR codes.
Routes:
GET /tickets/ — My tickets list
GET /tickets/<code>/ — Ticket detail with QR code
POST /tickets/buy/ — Purchase tickets for an entry
"""
from __future__ import annotations
import logging
from quart import (
Blueprint, g, request, render_template, make_response,
)
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry
from shared.cart_identity import current_cart_identity
from suma_browser.app.redis_cacher import clear_cache
from .services.tickets import (
create_ticket,
get_ticket_by_code,
get_user_tickets,
get_available_ticket_count,
get_tickets_for_entry,
)
logger = logging.getLogger(__name__)
def register() -> Blueprint:
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
async def my_tickets():
"""List all tickets for the current user/session."""
from suma_browser.app.utils.htmx import is_htmx_request
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
if not is_htmx_request():
html = await render_template(
"_types/tickets/index.html",
tickets=tickets,
)
else:
html = await render_template(
"_types/tickets/_main_panel.html",
tickets=tickets,
)
return await make_response(html, 200)
@bp.get("/<code>/")
async def ticket_detail(code: str):
"""View a single ticket with QR code."""
from suma_browser.app.utils.htmx import is_htmx_request
ticket = await get_ticket_by_code(g.s, code)
if not ticket:
return await make_response("Ticket not found", 404)
# Verify ownership
ident = current_cart_identity()
if ident["user_id"] is not None:
if ticket.user_id != ident["user_id"]:
return await make_response("Ticket not found", 404)
elif ident["session_id"] is not None:
if ticket.session_id != ident["session_id"]:
return await make_response("Ticket not found", 404)
else:
return await make_response("Ticket not found", 404)
if not is_htmx_request():
html = await render_template(
"_types/tickets/detail.html",
ticket=ticket,
)
else:
html = await render_template(
"_types/tickets/_detail_panel.html",
ticket=ticket,
)
return await make_response(html, 200)
@bp.post("/buy/")
@clear_cache(tag="calendars", tag_scope="all")
async def buy_tickets():
"""
Purchase tickets for a calendar entry.
Creates ticket records with state='reserved' (awaiting payment).
Form fields:
entry_id — the calendar entry ID
ticket_type_id (optional) — specific ticket type
quantity — number of tickets (default 1)
"""
form = await request.form
entry_id_raw = form.get("entry_id", "").strip()
if not entry_id_raw:
return await make_response("Entry ID required", 400)
try:
entry_id = int(entry_id_raw)
except ValueError:
return await make_response("Invalid entry ID", 400)
# Load entry
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
if not entry:
return await make_response("Entry not found", 404)
if entry.ticket_price is None:
return await make_response("Tickets not available for this entry", 400)
# Check availability
available = await get_available_ticket_count(g.s, entry_id)
quantity = int(form.get("quantity", 1))
if quantity < 1:
quantity = 1
if available is not None and quantity > available:
return await make_response(
f"Only {available} ticket(s) remaining", 400
)
# Ticket type (optional)
ticket_type_id = None
tt_raw = form.get("ticket_type_id", "").strip()
if tt_raw:
try:
ticket_type_id = int(tt_raw)
except ValueError:
pass
ident = current_cart_identity()
# Create tickets
created = []
for _ in range(quantity):
ticket = await create_ticket(
g.s,
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
state="reserved",
)
created.append(ticket)
# Re-check availability for display
remaining = await get_available_ticket_count(g.s, entry_id)
all_tickets = await get_tickets_for_entry(g.s, entry_id)
html = await render_template(
"_types/tickets/_buy_result.html",
entry=entry,
created_tickets=created,
remaining=remaining,
all_tickets=all_tickets,
)
return await make_response(html, 200)
return bp

View File

View File

@@ -0,0 +1,238 @@
"""
Ticket service layer — create, query, and manage tickets.
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from typing import Optional
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.calendars import Ticket, TicketType, CalendarEntry
async def create_ticket(
session: AsyncSession,
*,
entry_id: int,
ticket_type_id: Optional[int] = None,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
order_id: Optional[int] = None,
state: str = "reserved",
) -> Ticket:
"""Create a single ticket with a unique code."""
ticket = Ticket(
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=user_id,
session_id=session_id,
order_id=order_id,
code=uuid.uuid4().hex,
state=state,
)
session.add(ticket)
await session.flush()
return ticket
async def create_tickets_for_order(
session: AsyncSession,
order_id: int,
user_id: Optional[int],
session_id: Optional[str],
) -> list[Ticket]:
"""
Create ticket records for all calendar entries in an order
that have ticket_price configured.
Called during checkout after calendar entries are transitioned to 'ordered'.
"""
# Find all ordered entries for this order that have ticket pricing
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.order_id == order_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.ticket_price.isnot(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
entries = result.scalars().all()
tickets = []
for entry in entries:
if entry.ticket_types:
# Entry has specific ticket types — create one ticket per type
# (quantity handling can be added later)
for tt in entry.ticket_types:
if tt.deleted_at is None:
ticket = await create_ticket(
session,
entry_id=entry.id,
ticket_type_id=tt.id,
user_id=user_id,
session_id=session_id,
order_id=order_id,
state="reserved",
)
tickets.append(ticket)
else:
# Simple ticket — one per entry
ticket = await create_ticket(
session,
entry_id=entry.id,
user_id=user_id,
session_id=session_id,
order_id=order_id,
state="reserved",
)
tickets.append(ticket)
return tickets
async def confirm_tickets_for_order(
session: AsyncSession,
order_id: int,
) -> int:
"""
Transition tickets from reserved → confirmed when payment succeeds.
Returns the number of tickets confirmed.
"""
result = await session.execute(
update(Ticket)
.where(
Ticket.order_id == order_id,
Ticket.state == "reserved",
)
.values(state="confirmed")
)
return result.rowcount
async def get_ticket_by_code(
session: AsyncSession,
code: str,
) -> Optional[Ticket]:
"""Look up a ticket by its unique code."""
result = await session.execute(
select(Ticket)
.where(Ticket.code == code)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
)
return result.scalar_one_or_none()
async def get_user_tickets(
session: AsyncSession,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
state: Optional[str] = None,
) -> list[Ticket]:
"""Get all tickets for a user or session."""
filters = []
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return []
if state:
filters.append(Ticket.state == state)
else:
# Exclude cancelled by default
filters.append(Ticket.state != "cancelled")
result = await session.execute(
select(Ticket)
.where(*filters)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
)
return result.scalars().all()
async def get_tickets_for_entry(
session: AsyncSession,
entry_id: int,
) -> list[Ticket]:
"""Get all non-cancelled tickets for a calendar entry."""
result = await session.execute(
select(Ticket)
.where(
Ticket.entry_id == entry_id,
Ticket.state != "cancelled",
)
.options(
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.asc())
)
return result.scalars().all()
async def get_available_ticket_count(
session: AsyncSession,
entry_id: int,
) -> Optional[int]:
"""
Get number of remaining tickets for an entry.
Returns None if unlimited.
"""
entry = await session.scalar(
select(CalendarEntry).where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
)
if not entry or entry.ticket_price is None:
return None
if entry.ticket_count is None:
return None # Unlimited
# Count non-cancelled tickets
sold = await session.scalar(
select(func.count(Ticket.id)).where(
Ticket.entry_id == entry_id,
Ticket.state != "cancelled",
)
)
return max(0, entry.ticket_count - (sold or 0))
async def checkin_ticket(
session: AsyncSession,
code: str,
) -> tuple[bool, Optional[str]]:
"""
Check in a ticket by its code.
Returns (success, error_message).
"""
from datetime import datetime, timezone
ticket = await get_ticket_by_code(session, code)
if not ticket:
return False, "Ticket not found"
if ticket.state == "checked_in":
return False, "Ticket already checked in"
if ticket.state == "cancelled":
return False, "Ticket is cancelled"
if ticket.state not in ("confirmed", "reserved"):
return False, f"Ticket in unexpected state: {ticket.state}"
ticket.state = "checked_in"
ticket.checked_in_at = datetime.now(timezone.utc)
return True, None

View File

@@ -10,7 +10,7 @@ from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Calendar
from models.calendars import CalendarEntry, Calendar, Ticket
from suma_browser.app.csrf import csrf_exempt
@@ -129,4 +129,70 @@ def register() -> Blueprint:
"calendar_slug": entry.calendar.slug if entry.calendar else None,
})
@bp.get("/tickets")
@csrf_exempt
async def tickets():
"""
Return tickets for a user/session.
Query params: user_id, session_id, order_id, state
"""
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
order_id = request.args.get("order_id", type=int)
state = request.args.get("state")
filters = []
if order_id is not None:
filters.append(Ticket.order_id == order_id)
elif user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id:
filters.append(Ticket.session_id == session_id)
else:
return jsonify([])
if state:
filters.append(Ticket.state == state)
result = await g.s.execute(
select(Ticket)
.where(*filters)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
)
tix = result.scalars().all()
return jsonify([
{
"id": t.id,
"code": t.code,
"state": t.state,
"entry_name": t.entry.name if t.entry else None,
"entry_start_at": t.entry.start_at.isoformat() if t.entry and t.entry.start_at else None,
"calendar_name": t.entry.calendar.name if t.entry and t.entry.calendar else None,
"ticket_type_name": t.ticket_type.name if t.ticket_type else None,
"ticket_type_cost": float(t.ticket_type.cost) if t.ticket_type and t.ticket_type.cost else None,
"checked_in_at": t.checked_in_at.isoformat() if t.checked_in_at else None,
}
for t in tix
])
@bp.post("/tickets/<code>/checkin")
@csrf_exempt
async def checkin(code: str):
"""
Check in a ticket by code.
Used by admin check-in interface.
"""
from .bp.tickets.services.tickets import checkin_ticket
success, error = await checkin_ticket(g.s, code)
if not success:
return jsonify({"ok": False, "error": error}), 400
return jsonify({"ok": True})
return bp

View File

@@ -6,13 +6,11 @@
{# Outer left: -1 year #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
href="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=prev_year,
month=month) }}"
@@ -27,13 +25,11 @@
{# Inner left: -1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
href="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=prev_month_year,
month=prev_month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=prev_month_year,
month=prev_month) }}"
@@ -52,13 +48,11 @@
{# Inner right: +1 month #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
href="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=next_month_year,
month=next_month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=next_month_year,
month=next_month) }}"
@@ -73,13 +67,11 @@
{# Outer right: +1 year #}
<a
class="{{styles.pill}} text-xl"
href="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
href="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=next_year,
month=month) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.get',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.get',
calendar_slug=calendar.slug,
year=next_year,
month=month) }}"
@@ -118,14 +110,12 @@
{# Clickable day number: goes to day detail view #}
<a
class="{{styles.pill}}"
href="{{ url_for('blog.post.calendars.calendar.day.show_day',
slug=post.slug,
href="{{ url_for('calendars.calendar.day.show_day',
calendar_slug=calendar.slug,
year=day.date.year,
month=day.date.month,
day=day.date.day) }}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.show_day',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.day.show_day',
calendar_slug=calendar.slug,
year=day.date.year,
month=day.date.month,

View File

@@ -1,7 +1,6 @@
<!-- Desktop nav -->
{% import 'macros/links.html' as links %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug=calendar.slug),
hx_select_search,
select_colours,
aclass=styles.nav_button
@@ -13,5 +12,4 @@
{% endcall %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug))}}
{% endif %}

View File

@@ -13,8 +13,7 @@
type="button"
class="mt-2 text-xs underline"
hx-get="{{ url_for(
'blog.post.calendars.calendar.admin.calendar_description_edit',
slug=post.slug,
'calendars.calendar.admin.calendar_description_edit',
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"

View File

@@ -1,8 +1,7 @@
<div id="calendar-description">
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.admin.calendar_description_save',
slug=post.slug,
'calendars.calendar.admin.calendar_description_save',
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"
@@ -29,8 +28,7 @@
type="button"
class="px-3 py-1 rounded border"
hx-get="{{ url_for(
'blog.post.calendars.calendar.admin.calendar_description_view',
slug=post.slug,
'calendars.calendar.admin.calendar_description_view',
calendar_slug=calendar.slug,
) }}"
hx-target="#calendar-description"

View File

@@ -14,7 +14,6 @@
<form
id="calendar-form"
method="post"
hx-put="{{ url_for('blog.post.calendars.calendar.put', slug=post.slug, calendar_slug=calendar.slug ) }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"

View File

@@ -2,7 +2,6 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug),
hx_select_search
) %}
{{ links.admin() }}

View File

@@ -1,7 +1,6 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendar-row', oob=oob) %}
{% call links.link(url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug= calendar.slug), hx_select_search) %}
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
<div class="flex flex-row items-center gap-2">
<i class="fa fa-calendar"></i>

View File

@@ -3,7 +3,6 @@
<div class="mt-6 border rounded-lg p-4">
<div class="flex items-center justify-between gap-3">
{% set calendar_href = url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug=cal.slug)|host%}
<a
class="flex items-baseline gap-3"
href="{{ calendar_href }}"
@@ -27,7 +26,6 @@
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('blog.post.calendars.calendar.delete', slug=post.slug, calendar_slug=cal.slug) }}"
hx-trigger="confirmed"
hx-target="#calendars-list"
hx-select="#calendars-list"

View File

@@ -1,11 +1,11 @@
<section class="p-4">
{% if has_access('blog.post.calendars.create_calendar') %}
{% if has_access('calendars.create_calendar') %}
<!-- error container under the inputs -->
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="mt-4 flex gap-2 items-end"
hx-post="{{ url_for('blog.post.calendars.create_calendar', slug=post.slug) }}"
hx-post="{{ url_for('calendars.create_calendar', slug=post.slug) }}"
hx-target="#calendars-list"
hx-select="#calendars-list"
hx-swap="outerHTML"

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='calendars-row', oob=oob) %}
{% call links.link(url_for('blog.post.calendars.home', slug=post.slug), hx_select_search) %}
{% call links.link(url_for('calendars.home', slug=post.slug), hx_select_search) %}
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>
Calendars

View File

@@ -3,8 +3,7 @@
<form
class="mt-4 grid grid-cols-1 md:grid-cols-4 gap-2"
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.add_entry',
slug=post.slug,
'calendars.calendar.day.calendar_entries.add_entry',
calendar_slug=calendar.slug,
day=day,
month=month,
@@ -124,8 +123,7 @@
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.add_button',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.add_button',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -3,8 +3,7 @@
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.add_form',
slug=post.slug,
'calendars.calendar.day.calendar_entries.add_form',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -6,8 +6,7 @@
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,
@@ -30,8 +29,7 @@
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for(
'blog.post.calendars.calendar.day.admin.admin',
slug=post.slug,
'calendars.calendar.day.admin.admin',
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,

View File

@@ -4,8 +4,7 @@
<div class="font-medium">
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.get',
calendar_slug=calendar.slug,
day=day,
month=month,
@@ -24,8 +23,7 @@
<div class="text-xs font-medium">
{% call links.link(
url_for(
'blog.post.calendars.calendar.slots.slot.get',
slug=post.slug,
'calendars.calendar.slots.slot.get',
calendar_slug=calendar.slug,
slot_id=entry.slot.id
),

View File

@@ -9,8 +9,7 @@
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
<a
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,

View File

@@ -3,8 +3,7 @@
{% call links.menu_row(id='day-admin-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.admin.admin',
slug=post.slug,
'calendars.calendar.day.admin.admin',
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,

View File

@@ -3,8 +3,7 @@
{% call links.menu_row(id='day-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.show_day',
slug=post.slug,
'calendars.calendar.day.show_day',
calendar_slug=calendar.slug,
year=day_date.year,
month=day_date.month,

View File

@@ -7,8 +7,7 @@
<form
class="space-y-3 mt-4"
hx-put="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.put',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.put',
calendar_slug=calendar.slug,
day=day, month=month, year=year,
entry_id=entry.id
@@ -163,8 +162,7 @@
type="button"
class="{{ styles.cancel_button }}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.get',
calendar_slug=calendar.slug,
day=day, month=month, year=year,
entry_id=entry.id

View File

@@ -80,6 +80,9 @@
</div>
</div>
<!-- Buy Tickets (public-facing) -->
{% include '_types/tickets/_buy_form.html' %}
<!-- Date -->
<div class="flex flex-col mb-4">
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
@@ -108,9 +111,8 @@
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
'calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
entry_id=entry.id,
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -28,8 +28,7 @@
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -2,8 +2,7 @@
{% if entry.state == 'provisional' %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
calendar_slug=calendar.slug,
day=day,
month=month,
@@ -32,8 +31,7 @@
</form>
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
calendar_slug=calendar.slug,
day=day,
month=month,
@@ -64,8 +62,7 @@
{% if entry.state == 'confirmed' %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -1,8 +1,7 @@
{% for search_post in search_posts %}
<form
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.add_post',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.add_post',
calendar_slug=calendar.slug,
day=day,
month=month,
@@ -42,8 +41,7 @@
<div
id="post-search-sentinel-{{ page }}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -23,8 +23,7 @@
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
calendar_slug=calendar.slug,
day=day,
month=month,
@@ -56,8 +55,7 @@
placeholder="Search posts..."
class="w-full px-3 py-2 border rounded text-sm"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -44,9 +44,8 @@
id="ticket-form-{{entry.id}}"
class="{% if entry.ticket_price is not none %}hidden{% endif %} space-y-3 mt-2 p-3 border rounded bg-stone-50"
hx-post="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.update_tickets',
'calendars.calendar.day.calendar_entries.calendar_entry.update_tickets',
entry_id=entry.id,
slug=post.slug,
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -1,8 +1,7 @@
{% import 'macros/links.html' as links %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,

View File

@@ -3,8 +3,7 @@
{% call links.menu_row(id='entry-admin-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -3,8 +3,7 @@
{% call links.menu_row(id='entry-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.get',
calendar_slug=calendar.slug,
day=day,
month=month,

View File

@@ -35,7 +35,6 @@
</div>
</summary>
<div class="p-4 border-t"
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
hx-trigger="intersect once"
hx-swap="innerHTML">
<div class="text-sm text-stone-400">Loading calendar...</div>

View File

@@ -3,8 +3,7 @@
<div id="slot-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="space-y-3 mt-4"
hx-put="{{ url_for('blog.post.calendars.calendar.slots.slot.put',
slug=post.slug,
hx-put="{{ url_for('calendars.calendar.slots.slot.put',
calendar_slug=calendar.slug,
slot_id=slot.id) }}"
hx-target="#slot-{{ slot.id }}"
@@ -154,8 +153,7 @@
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.slot.get_view',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.slots.slot.get_view',
calendar_slug=calendar.slug,
slot_id=slot.id) }}"
hx-target="#slot-{{ slot.id }}"

View File

@@ -54,9 +54,8 @@
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.slots.slot.get_edit',
'calendars.calendar.slots.slot.get_edit',
slot_id=slot.id,
slug=post.slug,
calendar_slug=calendar.slug,
) }}"
hx-target="#slot-{{slot.id}}"

View File

@@ -2,7 +2,6 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='slot-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=slot.id),
hx_select_search,
) %}
<div class="flex flex-col md:flex-row md:gap-2 items-center">

View File

@@ -1,6 +1,5 @@
<form
hx-post="{{ url_for('blog.post.calendars.calendar.slots.post',
slug=post.slug,
hx-post="{{ url_for('calendars.calendar.slots.post',
calendar_slug=calendar.slug) }}"
hx-target="#slots-table"
hx-select="#slots-table"
@@ -99,8 +98,7 @@
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_button',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.slots.add_button',
calendar_slug=calendar.slug) }}"
hx-target="#slot-add-container"
hx-swap="innerHTML"

View File

@@ -2,8 +2,7 @@
<button
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_form',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.slots.add_form',
calendar_slug=calendar.slug) }}"
hx-target="#slot-add-container"
hx-swap="innerHTML"

View File

@@ -3,7 +3,6 @@
<td class="p-2 align-top w-1/6">
<div class="font-medium">
{% call links.link(
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=s.id),
hx_select_search,
aclass=styles.pill
) %}
@@ -46,8 +45,7 @@
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('blog.post.calendars.calendar.slots.slot.slot_delete',
slug=post.slug,
hx-delete="{{ url_for('calendars.calendar.slots.slot.slot_delete',
calendar_slug=calendar.slug,
slot_id=s.id) }}"
hx-target="#slots-table"

View File

@@ -2,7 +2,6 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='slots-row', oob=oob) %}
{% call links.link(
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug= calendar.slug),
hx_select_search,
) %}
<i class="fa fa-clock"></i>

View File

@@ -0,0 +1,39 @@
{# Check-in result — replaces ticket row or action area #}
{% if success and ticket %}
<tr class="bg-blue-50" id="ticket-row-{{ ticket.code }}">
<td class="px-4 py-3">
<span class="font-mono text-xs">{{ ticket.code[:12] }}...</span>
</td>
<td class="px-4 py-3">
<div class="font-medium">{{ ticket.entry.name if ticket.entry else '—' }}</div>
{% if ticket.entry and ticket.entry.start_at %}
<div class="text-xs text-stone-500">
{{ ticket.entry.start_at.strftime('%d %b %Y, %H:%M') }}
</div>
{% endif %}
</td>
<td class="px-4 py-3 text-sm">
{{ ticket.ticket_type.name if ticket.ticket_type else '—' }}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800">
Checked in
</span>
</td>
<td class="px-4 py-3">
<span class="text-xs text-blue-600">
<i class="fa fa-check-circle" aria-hidden="true"></i>
{% if ticket.checked_in_at %}
{{ ticket.checked_in_at.strftime('%H:%M') }}
{% else %}
Just now
{% endif %}
</span>
</td>
</tr>
{% elif not success %}
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
<i class="fa fa-exclamation-circle mr-2" aria-hidden="true"></i>
{{ error or 'Check-in failed' }}
</div>
{% endif %}

View File

@@ -0,0 +1,75 @@
{# Tickets for a specific calendar entry — admin view #}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">
Tickets for: {{ entry.name }}
</h3>
<span class="text-sm text-stone-500">
{{ tickets|length }} ticket{{ 's' if tickets|length != 1 else '' }}
</span>
</div>
{% if tickets %}
<div class="overflow-x-auto rounded-xl border border-stone-200">
<table class="w-full text-sm">
<thead class="bg-stone-50">
<tr>
<th class="px-4 py-2 text-left font-medium text-stone-600">Code</th>
<th class="px-4 py-2 text-left font-medium text-stone-600">Type</th>
<th class="px-4 py-2 text-left font-medium text-stone-600">State</th>
<th class="px-4 py-2 text-left font-medium text-stone-600">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100">
{% for ticket in tickets %}
<tr class="hover:bg-stone-50" id="entry-ticket-row-{{ ticket.code }}">
<td class="px-4 py-2 font-mono text-xs">{{ ticket.code[:12] }}...</td>
<td class="px-4 py-2">{{ ticket.ticket_type.name if ticket.ticket_type else '—' }}</td>
<td class="px-4 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{% if ticket.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif ticket.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% elif ticket.state == 'reserved' %}
bg-amber-100 text-amber-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ ticket.state|replace('_', ' ')|capitalize }}
</span>
</td>
<td class="px-4 py-2">
{% if ticket.state in ('confirmed', 'reserved') %}
<form
hx-post="{{ url_for('ticket_admin.do_checkin', code=ticket.code) }}"
hx-target="#entry-ticket-row-{{ ticket.code }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button
type="submit"
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
Check in
</button>
</form>
{% elif ticket.state == 'checked_in' %}
<span class="text-xs text-blue-600">
<i class="fa fa-check-circle" aria-hidden="true"></i>
{% if ticket.checked_in_at %}{{ ticket.checked_in_at.strftime('%H:%M') }}{% endif %}
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-6 text-stone-500 text-sm">
No tickets for this entry
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,82 @@
{# Ticket lookup result — rendered into #lookup-result #}
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<i class="fa fa-exclamation-circle mr-2" aria-hidden="true"></i>
{{ error }}
</div>
{% elif ticket %}
<div class="rounded-lg border border-stone-200 bg-stone-50 p-4">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="font-semibold text-lg">
{{ ticket.entry.name if ticket.entry else 'Unknown event' }}
</div>
{% if ticket.ticket_type %}
<div class="text-sm text-stone-600">{{ ticket.ticket_type.name }}</div>
{% endif %}
{% if ticket.entry and ticket.entry.start_at %}
<div class="text-sm text-stone-500 mt-1">
{{ ticket.entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }}
</div>
{% endif %}
{% if ticket.entry and ticket.entry.calendar %}
<div class="text-xs text-stone-400 mt-0.5">
{{ ticket.entry.calendar.name }}
</div>
{% endif %}
<div class="mt-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{% if ticket.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif ticket.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% elif ticket.state == 'reserved' %}
bg-amber-100 text-amber-800
{% elif ticket.state == 'cancelled' %}
bg-red-100 text-red-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ ticket.state|replace('_', ' ')|capitalize }}
</span>
<span class="text-xs text-stone-400 ml-2 font-mono">{{ ticket.code }}</span>
</div>
{% if ticket.checked_in_at %}
<div class="text-xs text-blue-600 mt-1">
Checked in: {{ ticket.checked_in_at.strftime('%B %d, %Y at %H:%M') }}
</div>
{% endif %}
</div>
<div id="checkin-action-{{ ticket.code }}">
{% if ticket.state in ('confirmed', 'reserved') %}
<form
hx-post="{{ url_for('ticket_admin.do_checkin', code=ticket.code) }}"
hx-target="#checkin-action-{{ ticket.code }}"
hx-swap="innerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button
type="submit"
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg"
>
<i class="fa fa-check mr-2" aria-hidden="true"></i>
Check In
</button>
</form>
{% elif ticket.state == 'checked_in' %}
<div class="text-blue-600 text-center">
<i class="fa fa-check-circle text-3xl" aria-hidden="true"></i>
<div class="text-sm font-medium mt-1">Checked In</div>
</div>
{% elif ticket.state == 'cancelled' %}
<div class="text-red-600 text-center">
<i class="fa fa-times-circle text-3xl" aria-hidden="true"></i>
<div class="text-sm font-medium mt-1">Cancelled</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,148 @@
<section id="ticket-admin" class="{{styles.list_container}}">
<h1 class="text-2xl font-bold mb-6">Ticket Admin</h1>
{# Stats row #}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
<div class="rounded-xl border border-stone-200 bg-white p-4 text-center">
<div class="text-2xl font-bold text-stone-900">{{ stats.total }}</div>
<div class="text-xs text-stone-500 uppercase tracking-wide">Total</div>
</div>
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-center">
<div class="text-2xl font-bold text-emerald-700">{{ stats.confirmed }}</div>
<div class="text-xs text-emerald-600 uppercase tracking-wide">Confirmed</div>
</div>
<div class="rounded-xl border border-blue-200 bg-blue-50 p-4 text-center">
<div class="text-2xl font-bold text-blue-700">{{ stats.checked_in }}</div>
<div class="text-xs text-blue-600 uppercase tracking-wide">Checked In</div>
</div>
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-center">
<div class="text-2xl font-bold text-amber-700">{{ stats.reserved }}</div>
<div class="text-xs text-amber-600 uppercase tracking-wide">Reserved</div>
</div>
</div>
{# Scanner section #}
<div class="rounded-xl border border-stone-200 bg-white p-6 mb-8">
<h2 class="text-lg font-semibold mb-4">
<i class="fa fa-qrcode mr-2" aria-hidden="true"></i>
Scan / Look Up Ticket
</h2>
<div class="flex gap-3 mb-4">
<input
type="text"
id="ticket-code-input"
name="code"
placeholder="Enter or scan ticket code..."
class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
hx-get="{{ url_for('ticket_admin.lookup') }}"
hx-trigger="keyup changed delay:300ms"
hx-target="#lookup-result"
hx-include="this"
autofocus
/>
<button
type="button"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
onclick="document.getElementById('ticket-code-input').dispatchEvent(new Event('keyup'))"
>
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</div>
<div id="lookup-result">
<div class="text-sm text-stone-400 text-center py-4">
Enter a ticket code to look it up
</div>
</div>
</div>
{# Recent tickets table #}
<div class="rounded-xl border border-stone-200 bg-white overflow-hidden">
<h2 class="text-lg font-semibold px-6 py-4 border-b border-stone-100">
Recent Tickets
</h2>
{% if tickets %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-stone-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-stone-600">Code</th>
<th class="px-4 py-3 text-left font-medium text-stone-600">Event</th>
<th class="px-4 py-3 text-left font-medium text-stone-600">Type</th>
<th class="px-4 py-3 text-left font-medium text-stone-600">State</th>
<th class="px-4 py-3 text-left font-medium text-stone-600">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-100">
{% for ticket in tickets %}
<tr class="hover:bg-stone-50 transition" id="ticket-row-{{ ticket.code }}">
<td class="px-4 py-3">
<span class="font-mono text-xs">{{ ticket.code[:12] }}...</span>
</td>
<td class="px-4 py-3">
<div class="font-medium">{{ ticket.entry.name if ticket.entry else '—' }}</div>
{% if ticket.entry and ticket.entry.start_at %}
<div class="text-xs text-stone-500">
{{ ticket.entry.start_at.strftime('%d %b %Y, %H:%M') }}
</div>
{% endif %}
</td>
<td class="px-4 py-3 text-sm">
{{ ticket.ticket_type.name if ticket.ticket_type else '—' }}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{% if ticket.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif ticket.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% elif ticket.state == 'reserved' %}
bg-amber-100 text-amber-800
{% elif ticket.state == 'cancelled' %}
bg-red-100 text-red-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ ticket.state|replace('_', ' ')|capitalize }}
</span>
</td>
<td class="px-4 py-3">
{% if ticket.state in ('confirmed', 'reserved') %}
<form
hx-post="{{ url_for('ticket_admin.do_checkin', code=ticket.code) }}"
hx-target="#ticket-row-{{ ticket.code }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button
type="submit"
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"
>
<i class="fa fa-check mr-1" aria-hidden="true"></i>
Check in
</button>
</form>
{% elif ticket.state == 'checked_in' %}
<span class="text-xs text-blue-600">
<i class="fa fa-check-circle" aria-hidden="true"></i>
{% if ticket.checked_in_at %}
{{ ticket.checked_in_at.strftime('%H:%M') }}
{% endif %}
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-8 text-center text-stone-500">
No tickets yet
</div>
{% endif %}
</div>
</section>

View File

@@ -0,0 +1,8 @@
{% extends '_types/root/index.html' %}
{% block _main_mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/ticket_admin/_main_panel.html' %}
{% endblock %}

View File

@@ -3,8 +3,7 @@
<div id="ticket-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="space-y-3 mt-4"
hx-put="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
slug=post.slug,
hx-put="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
calendar_slug=calendar.slug,
year=year,
month=month,
@@ -71,8 +70,7 @@
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,

View File

@@ -33,9 +33,8 @@
type="button"
class="{{styles.pre_action_button}}"
hx-get="{{ url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit',
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit',
ticket_type_id=ticket_type.id,
slug=post.slug,
calendar_slug=calendar.slug,
year=year,
month=month,

View File

@@ -3,8 +3,7 @@
{% call links.menu_row(id='ticket_type-row', oob=oob) %}
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
calendar_slug=calendar.slug,
year=year,
month=month,

View File

@@ -1,6 +1,5 @@
<form
hx-post="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
slug=post.slug,
hx-post="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,
@@ -56,8 +55,7 @@
<button
type="button"
class="{{styles.cancel_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,

View File

@@ -1,7 +1,6 @@
<button
class="{{styles.action_button}}"
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
slug=post.slug,
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,

View File

@@ -4,8 +4,7 @@
<div class="font-medium">
{% call links.link(
url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
calendar_slug=calendar.slug,
year=year,
month=month,
@@ -36,8 +35,7 @@
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
slug=post.slug,
hx-delete="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
calendar_slug=calendar.slug,
year=year,
month=month,

View File

@@ -2,8 +2,7 @@
{% macro header_row(oob=False) %}
{% call links.menu_row(id='ticket_types-row', oob=oob) %}
{% call links.link(url_for(
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
slug=post.slug,
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
calendar_slug=calendar.slug,
entry_id=entry.id,
year=year,

View File

@@ -0,0 +1,98 @@
{# Ticket purchase form — shown on entry detail when tickets are available #}
{% if entry.ticket_price is not none and entry.state == 'confirmed' %}
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-stone-200 bg-white p-4">
<h3 class="text-sm font-semibold text-stone-700 mb-3">
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
Buy Tickets
</h3>
{% if entry.ticket_types %}
{# Multiple ticket types #}
<div class="space-y-2 mb-4">
{% for tt in entry.ticket_types %}
{% if tt.deleted_at is none %}
<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">
<div>
<div class="font-medium text-sm">{{ tt.name }}</div>
<div class="text-xs text-stone-500">
£{{ '%.2f'|format(tt.cost) }}
</div>
</div>
<form
hx-post="{{ url_for('tickets.buy_tickets') }}"
hx-target="#ticket-buy-{{ entry.id }}"
hx-swap="outerHTML"
class="flex items-center gap-2"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
<input
type="number"
name="quantity"
value="1"
min="1"
max="10"
class="w-16 px-2 py-1 text-sm border rounded text-center"
/>
<button
type="submit"
class="px-3 py-1 bg-emerald-600 text-white text-sm rounded hover:bg-emerald-700 transition"
>
Buy
</button>
</form>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
{# Simple ticket (single price) #}
<div class="flex items-center justify-between mb-4">
<div>
<span class="font-medium text-green-600">
£{{ '%.2f'|format(entry.ticket_price) }}
</span>
<span class="text-sm text-stone-500 ml-2">per ticket</span>
</div>
{% if ticket_remaining is not none %}
<span class="text-xs text-stone-500">
{{ ticket_remaining }} remaining
</span>
{% endif %}
</div>
<form
hx-post="{{ url_for('tickets.buy_tickets') }}"
hx-target="#ticket-buy-{{ entry.id }}"
hx-swap="outerHTML"
class="flex items-center gap-3"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
<label class="text-sm text-stone-600">Qty:</label>
<input
type="number"
name="quantity"
value="1"
min="1"
max="10"
class="w-16 px-2 py-1 text-sm border rounded text-center"
/>
<button
type="submit"
class="px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition font-medium"
>
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
Buy Tickets
</button>
</form>
{% endif %}
</div>
{% elif entry.ticket_price is not none %}
{# Tickets configured but entry not confirmed yet #}
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500">
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
Tickets available once this event is confirmed.
</div>
{% endif %}

View File

@@ -0,0 +1,39 @@
{# Shown after ticket purchase — replaces the buy form #}
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center gap-2 mb-3">
<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>
<span class="font-semibold text-emerald-800">
{{ created_tickets|length }} ticket{{ 's' if created_tickets|length != 1 else '' }} reserved
</span>
</div>
<div class="space-y-2 mb-4">
{% for ticket in created_tickets %}
<a
href="{{ url_for('tickets.ticket_detail', code=ticket.code) }}"
class="flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"
>
<div class="flex items-center gap-2">
<i class="fa fa-ticket text-emerald-500" aria-hidden="true"></i>
<span class="font-mono text-xs text-stone-500">{{ ticket.code[:12] }}...</span>
</div>
<span class="text-xs text-emerald-600 font-medium">View ticket</span>
</a>
{% endfor %}
</div>
{% if remaining is not none %}
<p class="text-xs text-stone-500">
{{ remaining }} ticket{{ 's' if remaining != 1 else '' }} remaining
</p>
{% endif %}
<div class="mt-3 flex gap-2">
<a
href="{{ url_for('tickets.my_tickets') }}"
class="text-sm text-emerald-700 hover:text-emerald-900 underline"
>
View all my tickets
</a>
</div>
</div>

View File

@@ -0,0 +1,124 @@
<section id="ticket-detail" class="{{styles.list_container}} max-w-lg mx-auto">
{# Back link #}
<a href="{{ url_for('tickets.my_tickets') }}"
class="inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4">
<i class="fa fa-arrow-left" aria-hidden="true"></i>
Back to my tickets
</a>
{# Ticket card #}
<div class="rounded-2xl border border-stone-200 bg-white overflow-hidden">
{# Header with state #}
<div class="px-6 py-4 border-b border-stone-100
{% if ticket.state == 'confirmed' %}
bg-emerald-50
{% elif ticket.state == 'checked_in' %}
bg-blue-50
{% elif ticket.state == 'reserved' %}
bg-amber-50
{% else %}
bg-stone-50
{% endif %}
">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold">
{{ ticket.entry.name if ticket.entry else 'Ticket' }}
</h1>
<span class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{% if ticket.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif ticket.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% elif ticket.state == 'reserved' %}
bg-amber-100 text-amber-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ ticket.state|replace('_', ' ')|capitalize }}
</span>
</div>
{% if ticket.ticket_type %}
<div class="text-sm text-stone-600 mt-1">
{{ ticket.ticket_type.name }}
</div>
{% endif %}
</div>
{# QR Code #}
<div class="px-6 py-8 flex flex-col items-center border-b border-stone-100">
<div id="ticket-qr-{{ ticket.code }}" class="bg-white p-4 rounded-lg border border-stone-200">
{# QR code rendered via JavaScript #}
</div>
<p class="text-xs text-stone-400 mt-3 font-mono select-all">
{{ ticket.code }}
</p>
</div>
{# Event details #}
<div class="px-6 py-4 space-y-3">
{% if ticket.entry %}
<div class="flex items-start gap-3">
<i class="fa fa-calendar text-stone-400 mt-0.5" aria-hidden="true"></i>
<div>
<div class="text-sm font-medium">
{{ ticket.entry.start_at.strftime('%A, %B %d, %Y') }}
</div>
<div class="text-sm text-stone-500">
{{ ticket.entry.start_at.strftime('%H:%M') }}
{% if ticket.entry.end_at %}
{{ ticket.entry.end_at.strftime('%H:%M') }}
{% endif %}
</div>
</div>
</div>
{% if ticket.entry.calendar %}
<div class="flex items-start gap-3">
<i class="fa fa-map-pin text-stone-400 mt-0.5" aria-hidden="true"></i>
<div class="text-sm">
{{ ticket.entry.calendar.name }}
</div>
</div>
{% endif %}
{% endif %}
{% if ticket.ticket_type and ticket.ticket_type.cost %}
<div class="flex items-start gap-3">
<i class="fa fa-tag text-stone-400 mt-0.5" aria-hidden="true"></i>
<div class="text-sm">
{{ ticket.ticket_type.name }} — £{{ '%.2f'|format(ticket.ticket_type.cost) }}
</div>
</div>
{% endif %}
{% if ticket.checked_in_at %}
<div class="flex items-start gap-3">
<i class="fa fa-check-circle text-blue-500 mt-0.5" aria-hidden="true"></i>
<div class="text-sm text-blue-700">
Checked in: {{ ticket.checked_in_at.strftime('%B %d, %Y at %H:%M') }}
</div>
</div>
{% endif %}
</div>
</div>
{# QR code generation script #}
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script>
(function() {
var container = document.getElementById('ticket-qr-{{ ticket.code }}');
if (container && typeof QRCode !== 'undefined') {
var canvas = document.createElement('canvas');
QRCode.toCanvas(canvas, '{{ ticket.code }}', {
width: 200,
margin: 2,
color: { dark: '#1c1917', light: '#ffffff' }
}, function(error) {
if (!error) container.appendChild(canvas);
});
}
})();
</script>
</section>

View File

@@ -0,0 +1,65 @@
<section id="tickets-list" class="{{styles.list_container}}">
<h1 class="text-2xl font-bold mb-6">My Tickets</h1>
{% if tickets %}
<div class="space-y-4">
{% for ticket in tickets %}
<a
href="{{ url_for('tickets.ticket_detail', code=ticket.code) }}"
class="block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="font-semibold text-lg truncate">
{{ ticket.entry.name if ticket.entry else 'Unknown event' }}
</div>
{% if ticket.ticket_type %}
<div class="text-sm text-stone-600 mt-0.5">
{{ ticket.ticket_type.name }}
</div>
{% endif %}
{% if ticket.entry %}
<div class="text-sm text-stone-500 mt-1">
{{ ticket.entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }}
{% if ticket.entry.end_at %}
{{ ticket.entry.end_at.strftime('%H:%M') }}
{% endif %}
</div>
{% if ticket.entry.calendar %}
<div class="text-xs text-stone-400 mt-0.5">
{{ ticket.entry.calendar.name }}
</div>
{% endif %}
{% endif %}
</div>
<div class="flex flex-col items-end gap-1 flex-shrink-0">
{# State badge #}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{% if ticket.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif ticket.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% elif ticket.state == 'reserved' %}
bg-amber-100 text-amber-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ ticket.state|replace('_', ' ')|capitalize }}
</span>
<span class="text-xs text-stone-400 font-mono">
{{ ticket.code[:8] }}...
</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-12 text-stone-500">
<i class="fa fa-ticket text-4xl mb-4 block" aria-hidden="true"></i>
<p class="text-lg">No tickets yet</p>
<p class="text-sm mt-1">Tickets will appear here after you purchase them.</p>
</div>
{% endif %}
</section>

View File

@@ -0,0 +1,8 @@
{% extends '_types/root/index.html' %}
{% block _main_mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/tickets/_detail_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends '_types/root/index.html' %}
{% block _main_mobile_menu %}
{% endblock %}
{% block content %}
{% include '_types/tickets/_main_panel.html' %}
{% endblock %}