feat: ticket purchase flow, QR display, and admin check-in
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
8
app.py
8
app.py
@@ -46,6 +46,14 @@ def create_app() -> "Quart":
|
|||||||
url_prefix="/calendars",
|
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)
|
# Internal API (server-to-server, CSRF-exempt)
|
||||||
from .events_api import register as register_events_api
|
from .events_api import register as register_events_api
|
||||||
app.register_blueprint(register_events_api())
|
app.register_blueprint(register_events_api())
|
||||||
|
|||||||
@@ -160,10 +160,13 @@ def register():
|
|||||||
|
|
||||||
@bp.context_processor
|
@bp.context_processor
|
||||||
async def inject_root():
|
async def inject_root():
|
||||||
|
from ..tickets.services.tickets import get_available_ticket_count
|
||||||
|
|
||||||
view_args = getattr(request, "view_args", {}) or {}
|
view_args = getattr(request, "view_args", {}) or {}
|
||||||
entry_id = view_args.get("entry_id")
|
entry_id = view_args.get("entry_id")
|
||||||
calendar_entry = None
|
calendar_entry = None
|
||||||
entry_posts = []
|
entry_posts = []
|
||||||
|
ticket_remaining = None
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CalendarEntry)
|
select(CalendarEntry)
|
||||||
@@ -185,10 +188,13 @@ def register():
|
|||||||
await g.s.refresh(calendar_entry, ['slot'])
|
await g.s.refresh(calendar_entry, ['slot'])
|
||||||
# Fetch associated posts
|
# Fetch associated posts
|
||||||
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
|
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 {
|
return {
|
||||||
"entry": calendar_entry,
|
"entry": calendar_entry,
|
||||||
"entry_posts": entry_posts,
|
"entry_posts": entry_posts,
|
||||||
|
"ticket_remaining": ticket_remaining,
|
||||||
}
|
}
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
|
|||||||
0
bp/ticket_admin/__init__.py
Normal file
0
bp/ticket_admin/__init__.py
Normal file
166
bp/ticket_admin/routes.py
Normal file
166
bp/ticket_admin/routes.py
Normal 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
|
||||||
0
bp/ticket_admin/services/__init__.py
Normal file
0
bp/ticket_admin/services/__init__.py
Normal file
0
bp/tickets/__init__.py
Normal file
0
bp/tickets/__init__.py
Normal file
181
bp/tickets/routes.py
Normal file
181
bp/tickets/routes.py
Normal 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
|
||||||
0
bp/tickets/services/__init__.py
Normal file
0
bp/tickets/services/__init__.py
Normal file
238
bp/tickets/services/tickets.py
Normal file
238
bp/tickets/services/tickets.py
Normal 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
|
||||||
@@ -10,7 +10,7 @@ from quart import Blueprint, g, request, jsonify
|
|||||||
from sqlalchemy import select, update, func
|
from sqlalchemy import select, update, func
|
||||||
from sqlalchemy.orm import selectinload
|
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
|
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,
|
"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
|
return bp
|
||||||
|
|||||||
@@ -6,13 +6,11 @@
|
|||||||
{# Outer left: -1 year #}
|
{# Outer left: -1 year #}
|
||||||
<a
|
<a
|
||||||
class="{{styles.pill}} text-xl"
|
class="{{styles.pill}} text-xl"
|
||||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
href="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=month) }}"
|
month=month) }}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
hx-get="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=prev_year,
|
year=prev_year,
|
||||||
month=month) }}"
|
month=month) }}"
|
||||||
@@ -27,13 +25,11 @@
|
|||||||
{# Inner left: -1 month #}
|
{# Inner left: -1 month #}
|
||||||
<a
|
<a
|
||||||
class="{{styles.pill}} text-xl"
|
class="{{styles.pill}} text-xl"
|
||||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
href="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=prev_month_year,
|
year=prev_month_year,
|
||||||
month=prev_month) }}"
|
month=prev_month) }}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
hx-get="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=prev_month_year,
|
year=prev_month_year,
|
||||||
month=prev_month) }}"
|
month=prev_month) }}"
|
||||||
@@ -52,13 +48,11 @@
|
|||||||
{# Inner right: +1 month #}
|
{# Inner right: +1 month #}
|
||||||
<a
|
<a
|
||||||
class="{{styles.pill}} text-xl"
|
class="{{styles.pill}} text-xl"
|
||||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
href="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=next_month_year,
|
year=next_month_year,
|
||||||
month=next_month) }}"
|
month=next_month) }}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
hx-get="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=next_month_year,
|
year=next_month_year,
|
||||||
month=next_month) }}"
|
month=next_month) }}"
|
||||||
@@ -73,13 +67,11 @@
|
|||||||
{# Outer right: +1 year #}
|
{# Outer right: +1 year #}
|
||||||
<a
|
<a
|
||||||
class="{{styles.pill}} text-xl"
|
class="{{styles.pill}} text-xl"
|
||||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
href="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=next_year,
|
year=next_year,
|
||||||
month=month) }}"
|
month=month) }}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
hx-get="{{ url_for('calendars.calendar.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=next_year,
|
year=next_year,
|
||||||
month=month) }}"
|
month=month) }}"
|
||||||
@@ -118,14 +110,12 @@
|
|||||||
{# Clickable day number: goes to day detail view #}
|
{# Clickable day number: goes to day detail view #}
|
||||||
<a
|
<a
|
||||||
class="{{styles.pill}}"
|
class="{{styles.pill}}"
|
||||||
href="{{ url_for('blog.post.calendars.calendar.day.show_day',
|
href="{{ url_for('calendars.calendar.day.show_day',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day.date.year,
|
year=day.date.year,
|
||||||
month=day.date.month,
|
month=day.date.month,
|
||||||
day=day.date.day) }}"
|
day=day.date.day) }}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.show_day',
|
hx-get="{{ url_for('calendars.calendar.day.show_day',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day.date.year,
|
year=day.date.year,
|
||||||
month=day.date.month,
|
month=day.date.month,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<!-- Desktop nav -->
|
<!-- Desktop nav -->
|
||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug=calendar.slug),
|
|
||||||
hx_select_search,
|
hx_select_search,
|
||||||
select_colours,
|
select_colours,
|
||||||
aclass=styles.nav_button
|
aclass=styles.nav_button
|
||||||
@@ -13,5 +12,4 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% if g.rights.admin %}
|
{% if g.rights.admin %}
|
||||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
{% 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 %}
|
{% endif %}
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="mt-2 text-xs underline"
|
class="mt-2 text-xs underline"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.admin.calendar_description_edit',
|
'calendars.calendar.admin.calendar_description_edit',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
) }}"
|
) }}"
|
||||||
hx-target="#calendar-description"
|
hx-target="#calendar-description"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<div id="calendar-description">
|
<div id="calendar-description">
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for(
|
hx-post="{{ url_for(
|
||||||
'blog.post.calendars.calendar.admin.calendar_description_save',
|
'calendars.calendar.admin.calendar_description_save',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
) }}"
|
) }}"
|
||||||
hx-target="#calendar-description"
|
hx-target="#calendar-description"
|
||||||
@@ -29,8 +28,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="px-3 py-1 rounded border"
|
class="px-3 py-1 rounded border"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.admin.calendar_description_view',
|
'calendars.calendar.admin.calendar_description_view',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
) }}"
|
) }}"
|
||||||
hx-target="#calendar-description"
|
hx-target="#calendar-description"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<form
|
<form
|
||||||
id="calendar-form"
|
id="calendar-form"
|
||||||
method="post"
|
method="post"
|
||||||
hx-put="{{ url_for('blog.post.calendars.calendar.put', slug=post.slug, calendar_slug=calendar.slug ) }}"
|
|
||||||
hx-target="#main-panel"
|
hx-target="#main-panel"
|
||||||
hx-select="{{ hx_select_search }}"
|
hx-select="{{ hx_select_search }}"
|
||||||
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"
|
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
|
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug),
|
|
||||||
hx_select_search
|
hx_select_search
|
||||||
) %}
|
) %}
|
||||||
{{ links.admin() }}
|
{{ links.admin() }}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='calendar-row', oob=oob) %}
|
{% 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-col md:flex-row md:gap-2 items-center min-w-0">
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<i class="fa fa-calendar"></i>
|
<i class="fa fa-calendar"></i>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<div class="mt-6 border rounded-lg p-4">
|
<div class="mt-6 border rounded-lg p-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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
|
<a
|
||||||
class="flex items-baseline gap-3"
|
class="flex items-baseline gap-3"
|
||||||
href="{{ calendar_href }}"
|
href="{{ calendar_href }}"
|
||||||
@@ -27,7 +26,6 @@
|
|||||||
data-confirm-confirm-text="Yes, delete it"
|
data-confirm-confirm-text="Yes, delete it"
|
||||||
data-confirm-cancel-text="Cancel"
|
data-confirm-cancel-text="Cancel"
|
||||||
data-confirm-event="confirmed"
|
data-confirm-event="confirmed"
|
||||||
hx-delete="{{ url_for('blog.post.calendars.calendar.delete', slug=post.slug, calendar_slug=cal.slug) }}"
|
|
||||||
hx-trigger="confirmed"
|
hx-trigger="confirmed"
|
||||||
hx-target="#calendars-list"
|
hx-target="#calendars-list"
|
||||||
hx-select="#calendars-list"
|
hx-select="#calendars-list"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<section class="p-4">
|
<section class="p-4">
|
||||||
{% if has_access('blog.post.calendars.create_calendar') %}
|
{% if has_access('calendars.create_calendar') %}
|
||||||
<!-- error container under the inputs -->
|
<!-- error container under the inputs -->
|
||||||
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
|
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
class="mt-4 flex gap-2 items-end"
|
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-target="#calendars-list"
|
||||||
hx-select="#calendars-list"
|
hx-select="#calendars-list"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='calendars-row', oob=oob) %}
|
{% 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>
|
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||||
<div>
|
<div>
|
||||||
Calendars
|
Calendars
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
<form
|
<form
|
||||||
class="mt-4 grid grid-cols-1 md:grid-cols-4 gap-2"
|
class="mt-4 grid grid-cols-1 md:grid-cols-4 gap-2"
|
||||||
hx-post="{{ url_for(
|
hx-post="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.add_entry',
|
'calendars.calendar.day.calendar_entries.add_entry',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -124,8 +123,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{{styles.cancel_button}}"
|
class="{{styles.cancel_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.add_button',
|
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.add_button',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="{{styles.pre_action_button}}"
|
class="{{styles.pre_action_button}}"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.add_form',
|
'calendars.calendar.day.calendar_entries.add_form',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||||
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day_date.year,
|
year=day_date.year,
|
||||||
month=day_date.month,
|
month=day_date.month,
|
||||||
@@ -30,8 +29,7 @@
|
|||||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||||
{{admin_nav_item(
|
{{admin_nav_item(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.admin.admin',
|
'calendars.calendar.day.admin.admin',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day_date.year,
|
year=day_date.year,
|
||||||
month=day_date.month,
|
month=day_date.month,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -24,8 +23,7 @@
|
|||||||
<div class="text-xs font-medium">
|
<div class="text-xs font-medium">
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.slots.slot.get',
|
'calendars.calendar.slots.slot.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
slot_id=entry.slot.id
|
slot_id=entry.slot.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||||
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day_date.year,
|
year=day_date.year,
|
||||||
month=day_date.month,
|
month=day_date.month,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
{% call links.menu_row(id='day-admin-row', oob=oob) %}
|
{% call links.menu_row(id='day-admin-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.admin.admin',
|
'calendars.calendar.day.admin.admin',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day_date.year,
|
year=day_date.year,
|
||||||
month=day_date.month,
|
month=day_date.month,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
{% call links.menu_row(id='day-row', oob=oob) %}
|
{% call links.menu_row(id='day-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.show_day',
|
'calendars.calendar.day.show_day',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=day_date.year,
|
year=day_date.year,
|
||||||
month=day_date.month,
|
month=day_date.month,
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
<form
|
<form
|
||||||
class="space-y-3 mt-4"
|
class="space-y-3 mt-4"
|
||||||
hx-put="{{ url_for(
|
hx-put="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.put',
|
'calendars.calendar.day.calendar_entries.calendar_entry.put',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day, month=month, year=year,
|
day=day, month=month, year=year,
|
||||||
entry_id=entry.id
|
entry_id=entry.id
|
||||||
@@ -163,8 +162,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="{{ styles.cancel_button }}"
|
class="{{ styles.cancel_button }}"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day, month=month, year=year,
|
day=day, month=month, year=year,
|
||||||
entry_id=entry.id
|
entry_id=entry.id
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Buy Tickets (public-facing) -->
|
||||||
|
{% include '_types/tickets/_buy_form.html' %}
|
||||||
|
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<div class="flex flex-col mb-4">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||||
@@ -108,9 +111,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="{{styles.pre_action_button}}"
|
class="{{styles.pre_action_button}}"
|
||||||
hx-get="{{ url_for(
|
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,
|
entry_id=entry.id,
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||||
{{admin_nav_item(
|
{{admin_nav_item(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
{% if entry.state == 'provisional' %}
|
{% if entry.state == 'provisional' %}
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for(
|
hx-post="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
|
'calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -32,8 +31,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for(
|
hx-post="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
|
'calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -64,8 +62,7 @@
|
|||||||
{% if entry.state == 'confirmed' %}
|
{% if entry.state == 'confirmed' %}
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for(
|
hx-post="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
|
'calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{% for search_post in search_posts %}
|
{% for search_post in search_posts %}
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for(
|
hx-post="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.add_post',
|
'calendars.calendar.day.calendar_entries.calendar_entry.add_post',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -42,8 +41,7 @@
|
|||||||
<div
|
<div
|
||||||
id="post-search-sentinel-{{ page }}"
|
id="post-search-sentinel-{{ page }}"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
'calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -23,8 +23,7 @@
|
|||||||
data-confirm-cancel-text="Cancel"
|
data-confirm-cancel-text="Cancel"
|
||||||
data-confirm-event="confirmed"
|
data-confirm-event="confirmed"
|
||||||
hx-delete="{{ url_for(
|
hx-delete="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
|
'calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -56,8 +55,7 @@
|
|||||||
placeholder="Search posts..."
|
placeholder="Search posts..."
|
||||||
class="w-full px-3 py-2 border rounded text-sm"
|
class="w-full px-3 py-2 border rounded text-sm"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
'calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -44,9 +44,8 @@
|
|||||||
id="ticket-form-{{entry.id}}"
|
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"
|
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(
|
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,
|
entry_id=entry.id,
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
year=year,
|
year=year,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
{% call links.menu_row(id='entry-admin-row', oob=oob) %}
|
{% call links.menu_row(id='entry-admin-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
{% call links.menu_row(id='entry-row', oob=oob) %}
|
{% call links.menu_row(id='entry-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
day=day,
|
day=day,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="p-4 border-t"
|
<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-trigger="intersect once"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="text-sm text-stone-400">Loading calendar...</div>
|
<div class="text-sm text-stone-400">Loading calendar...</div>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
<div id="slot-errors" class="mt-2 text-sm text-red-600"></div>
|
<div id="slot-errors" class="mt-2 text-sm text-red-600"></div>
|
||||||
<form
|
<form
|
||||||
class="space-y-3 mt-4"
|
class="space-y-3 mt-4"
|
||||||
hx-put="{{ url_for('blog.post.calendars.calendar.slots.slot.put',
|
hx-put="{{ url_for('calendars.calendar.slots.slot.put',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
slot_id=slot.id) }}"
|
slot_id=slot.id) }}"
|
||||||
hx-target="#slot-{{ slot.id }}"
|
hx-target="#slot-{{ slot.id }}"
|
||||||
@@ -154,8 +153,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{{styles.cancel_button}}"
|
class="{{styles.cancel_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.slots.slot.get_view',
|
hx-get="{{ url_for('calendars.calendar.slots.slot.get_view',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
slot_id=slot.id) }}"
|
slot_id=slot.id) }}"
|
||||||
hx-target="#slot-{{ slot.id }}"
|
hx-target="#slot-{{ slot.id }}"
|
||||||
|
|||||||
@@ -54,9 +54,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="{{styles.pre_action_button}}"
|
class="{{styles.pre_action_button}}"
|
||||||
hx-get="{{ url_for(
|
hx-get="{{ url_for(
|
||||||
'blog.post.calendars.calendar.slots.slot.get_edit',
|
'calendars.calendar.slots.slot.get_edit',
|
||||||
slot_id=slot.id,
|
slot_id=slot.id,
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
) }}"
|
) }}"
|
||||||
hx-target="#slot-{{slot.id}}"
|
hx-target="#slot-{{slot.id}}"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='slot-row', oob=oob) %}
|
{% call links.menu_row(id='slot-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% 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,
|
hx_select_search,
|
||||||
) %}
|
) %}
|
||||||
<div class="flex flex-col md:flex-row md:gap-2 items-center">
|
<div class="flex flex-col md:flex-row md:gap-2 items-center">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<form
|
<form
|
||||||
hx-post="{{ url_for('blog.post.calendars.calendar.slots.post',
|
hx-post="{{ url_for('calendars.calendar.slots.post',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug) }}"
|
calendar_slug=calendar.slug) }}"
|
||||||
hx-target="#slots-table"
|
hx-target="#slots-table"
|
||||||
hx-select="#slots-table"
|
hx-select="#slots-table"
|
||||||
@@ -99,8 +98,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{{styles.cancel_button}}"
|
class="{{styles.cancel_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_button',
|
hx-get="{{ url_for('calendars.calendar.slots.add_button',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug) }}"
|
calendar_slug=calendar.slug) }}"
|
||||||
hx-target="#slot-add-container"
|
hx-target="#slot-add-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{{styles.pre_action_button}}"
|
class="{{styles.pre_action_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_form',
|
hx-get="{{ url_for('calendars.calendar.slots.add_form',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug) }}"
|
calendar_slug=calendar.slug) }}"
|
||||||
hx-target="#slot-add-container"
|
hx-target="#slot-add-container"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<td class="p-2 align-top w-1/6">
|
<td class="p-2 align-top w-1/6">
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{% call links.link(
|
{% 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,
|
hx_select_search,
|
||||||
aclass=styles.pill
|
aclass=styles.pill
|
||||||
) %}
|
) %}
|
||||||
@@ -46,8 +45,7 @@
|
|||||||
data-confirm-confirm-text="Yes, delete it"
|
data-confirm-confirm-text="Yes, delete it"
|
||||||
data-confirm-cancel-text="Cancel"
|
data-confirm-cancel-text="Cancel"
|
||||||
data-confirm-event="confirmed"
|
data-confirm-event="confirmed"
|
||||||
hx-delete="{{ url_for('blog.post.calendars.calendar.slots.slot.slot_delete',
|
hx-delete="{{ url_for('calendars.calendar.slots.slot.slot_delete',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
slot_id=s.id) }}"
|
slot_id=s.id) }}"
|
||||||
hx-target="#slots-table"
|
hx-target="#slots-table"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='slots-row', oob=oob) %}
|
{% call links.menu_row(id='slots-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug= calendar.slug),
|
|
||||||
hx_select_search,
|
hx_select_search,
|
||||||
) %}
|
) %}
|
||||||
<i class="fa fa-clock"></i>
|
<i class="fa fa-clock"></i>
|
||||||
|
|||||||
39
templates/_types/ticket_admin/_checkin_result.html
Normal file
39
templates/_types/ticket_admin/_checkin_result.html
Normal 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 %}
|
||||||
75
templates/_types/ticket_admin/_entry_tickets.html
Normal file
75
templates/_types/ticket_admin/_entry_tickets.html
Normal 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>
|
||||||
82
templates/_types/ticket_admin/_lookup_result.html
Normal file
82
templates/_types/ticket_admin/_lookup_result.html
Normal 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 %}
|
||||||
148
templates/_types/ticket_admin/_main_panel.html
Normal file
148
templates/_types/ticket_admin/_main_panel.html
Normal 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>
|
||||||
8
templates/_types/ticket_admin/index.html
Normal file
8
templates/_types/ticket_admin/index.html
Normal 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 %}
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
<div id="ticket-errors" class="mt-2 text-sm text-red-600"></div>
|
<div id="ticket-errors" class="mt-2 text-sm text-red-600"></div>
|
||||||
<form
|
<form
|
||||||
class="space-y-3 mt-4"
|
class="space-y-3 mt-4"
|
||||||
hx-put="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
|
hx-put="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=year,
|
year=year,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -71,8 +70,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{{styles.cancel_button}}"
|
class="{{styles.cancel_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
|
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
year=year,
|
year=year,
|
||||||
|
|||||||
@@ -33,9 +33,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="{{styles.pre_action_button}}"
|
class="{{styles.pre_action_button}}"
|
||||||
hx-get="{{ url_for(
|
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,
|
ticket_type_id=ticket_type.id,
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=year,
|
year=year,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
{% call links.menu_row(id='ticket_type-row', oob=oob) %}
|
{% call links.menu_row(id='ticket_type-row', oob=oob) %}
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=year,
|
year=year,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<form
|
<form
|
||||||
hx-post="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
|
hx-post="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
year=year,
|
year=year,
|
||||||
@@ -56,8 +55,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{{styles.cancel_button}}"
|
class="{{styles.cancel_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
|
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
year=year,
|
year=year,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<button
|
<button
|
||||||
class="{{styles.action_button}}"
|
class="{{styles.action_button}}"
|
||||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
|
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
year=year,
|
year=year,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{% call links.link(
|
{% call links.link(
|
||||||
url_for(
|
url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=year,
|
year=year,
|
||||||
month=month,
|
month=month,
|
||||||
@@ -36,8 +35,7 @@
|
|||||||
data-confirm-confirm-text="Yes, delete it"
|
data-confirm-confirm-text="Yes, delete it"
|
||||||
data-confirm-cancel-text="Cancel"
|
data-confirm-cancel-text="Cancel"
|
||||||
data-confirm-event="confirmed"
|
data-confirm-event="confirmed"
|
||||||
hx-delete="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
|
hx-delete="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
year=year,
|
year=year,
|
||||||
month=month,
|
month=month,
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='ticket_types-row', oob=oob) %}
|
{% call links.menu_row(id='ticket_types-row', oob=oob) %}
|
||||||
{% call links.link(url_for(
|
{% call links.link(url_for(
|
||||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
||||||
slug=post.slug,
|
|
||||||
calendar_slug=calendar.slug,
|
calendar_slug=calendar.slug,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
year=year,
|
year=year,
|
||||||
|
|||||||
98
templates/_types/tickets/_buy_form.html
Normal file
98
templates/_types/tickets/_buy_form.html
Normal 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 %}
|
||||||
39
templates/_types/tickets/_buy_result.html
Normal file
39
templates/_types/tickets/_buy_result.html
Normal 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>
|
||||||
124
templates/_types/tickets/_detail_panel.html
Normal file
124
templates/_types/tickets/_detail_panel.html
Normal 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>
|
||||||
65
templates/_types/tickets/_main_panel.html
Normal file
65
templates/_types/tickets/_main_panel.html
Normal 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>
|
||||||
8
templates/_types/tickets/detail.html
Normal file
8
templates/_types/tickets/detail.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends '_types/root/index.html' %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/tickets/_detail_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
8
templates/_types/tickets/index.html
Normal file
8
templates/_types/tickets/index.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends '_types/root/index.html' %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/tickets/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user