Add tickets & bookings to account page
Add TicketDTO, user_tickets/user_bookings to CalendarService protocol and SqlCalendarService implementation, plus nav links and panel templates for the auth account sub-pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
44
browser/templates/_types/auth/_bookings_panel.html
Normal file
44
browser/templates/_types/auth/_bookings_panel.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
|
||||
|
||||
{% if bookings %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for booking in bookings %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if booking.end_at %}
|
||||
<span>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
||||
{% endif %}
|
||||
{% if booking.calendar_name %}
|
||||
<span>· {{ booking.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if booking.cost %}
|
||||
<span>· £{{ booking.cost }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if booking.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% elif booking.state == 'provisional' %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No bookings yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,12 @@
|
||||
{% call links.link(coop_url('/auth/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
newsletters
|
||||
{% endcall %}
|
||||
{% call links.link(coop_url('/auth/tickets/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
tickets
|
||||
{% endcall %}
|
||||
{% call links.link(coop_url('/auth/bookings/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
bookings
|
||||
{% endcall %}
|
||||
<div class="relative nav-group">
|
||||
<a href="{{ cart_url('/orders/') }}" class="{{styles.nav_button}}" data-hx-disable>
|
||||
orders
|
||||
|
||||
44
browser/templates/_types/auth/_tickets_panel.html
Normal file
44
browser/templates/_types/auth/_tickets_panel.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<div class="w-full max-w-3xl mx-auto px-4 py-6">
|
||||
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
|
||||
|
||||
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
|
||||
|
||||
{% if tickets %}
|
||||
<div class="divide-y divide-stone-100">
|
||||
{% for ticket in tickets %}
|
||||
<div class="py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
|
||||
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
|
||||
{{ ticket.entry_name }}
|
||||
</a>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
|
||||
{% if ticket.calendar_name %}
|
||||
<span>· {{ ticket.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if ticket.ticket_type_name %}
|
||||
<span>· {{ ticket.ticket_type_name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
{% if ticket.state == 'checked_in' %}
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
|
||||
{% elif ticket.state == 'confirmed' %}
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-stone-500">No tickets yet.</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,6 +43,20 @@ class CalendarDTO:
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TicketDTO:
|
||||
id: int
|
||||
code: str
|
||||
state: str
|
||||
entry_name: str
|
||||
entry_start_at: datetime
|
||||
entry_end_at: datetime | None = None
|
||||
ticket_type_name: str | None = None
|
||||
calendar_name: str | None = None
|
||||
created_at: datetime | None = None
|
||||
checked_in_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarEntryDTO:
|
||||
id: int
|
||||
|
||||
@@ -13,6 +13,7 @@ from .dtos import (
|
||||
PostDTO,
|
||||
CalendarDTO,
|
||||
CalendarEntryDTO,
|
||||
TicketDTO,
|
||||
MarketPlaceDTO,
|
||||
ProductDTO,
|
||||
CartItemDTO,
|
||||
@@ -69,6 +70,14 @@ class CalendarService(Protocol):
|
||||
self, session: AsyncSession, order_id: int,
|
||||
) -> list[CalendarEntryDTO]: ...
|
||||
|
||||
async def user_tickets(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[TicketDTO]: ...
|
||||
|
||||
async def user_bookings(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[CalendarEntryDTO]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MarketService(Protocol):
|
||||
|
||||
@@ -9,8 +9,8 @@ from sqlalchemy import select, update, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost
|
||||
from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO
|
||||
from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost, Ticket
|
||||
from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO, TicketDTO
|
||||
|
||||
|
||||
def _cal_to_dto(cal: Calendar) -> CalendarDTO:
|
||||
@@ -47,6 +47,24 @@ def _entry_to_dto(entry: CalendarEntry) -> CalendarEntryDTO:
|
||||
)
|
||||
|
||||
|
||||
def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
|
||||
entry = getattr(ticket, "entry", None)
|
||||
tt = getattr(ticket, "ticket_type", None)
|
||||
cal = getattr(entry, "calendar", None) if entry else None
|
||||
return TicketDTO(
|
||||
id=ticket.id,
|
||||
code=ticket.code,
|
||||
state=ticket.state,
|
||||
entry_name=entry.name if entry else "",
|
||||
entry_start_at=entry.start_at if entry else ticket.created_at,
|
||||
entry_end_at=entry.end_at if entry else None,
|
||||
ticket_type_name=tt.name if tt else None,
|
||||
calendar_name=cal.name if cal else None,
|
||||
created_at=ticket.created_at,
|
||||
checked_in_at=ticket.checked_in_at,
|
||||
)
|
||||
|
||||
|
||||
class SqlCalendarService:
|
||||
|
||||
# -- reads ----------------------------------------------------------------
|
||||
@@ -210,6 +228,38 @@ class SqlCalendarService:
|
||||
)
|
||||
return [_entry_to_dto(e) for e in result.scalars().all()]
|
||||
|
||||
async def user_tickets(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[TicketDTO]:
|
||||
result = await session.execute(
|
||||
select(Ticket)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.state != "cancelled",
|
||||
)
|
||||
.order_by(Ticket.created_at.desc())
|
||||
.options(
|
||||
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
|
||||
selectinload(Ticket.ticket_type),
|
||||
)
|
||||
)
|
||||
return [_ticket_to_dto(t) for t in result.scalars().all()]
|
||||
|
||||
async def user_bookings(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
result = await session.execute(
|
||||
select(CalendarEntry)
|
||||
.where(
|
||||
CalendarEntry.user_id == user_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
CalendarEntry.state.in_(("ordered", "provisional", "confirmed")),
|
||||
)
|
||||
.order_by(CalendarEntry.start_at.desc())
|
||||
.options(selectinload(CalendarEntry.calendar))
|
||||
)
|
||||
return [_entry_to_dto(e) for e in result.scalars().all()]
|
||||
|
||||
# -- batch reads (not in protocol — convenience for blog service) ---------
|
||||
|
||||
async def confirmed_entries_for_posts(
|
||||
|
||||
@@ -13,6 +13,7 @@ from shared.contracts.dtos import (
|
||||
PostDTO,
|
||||
CalendarDTO,
|
||||
CalendarEntryDTO,
|
||||
TicketDTO,
|
||||
MarketPlaceDTO,
|
||||
ProductDTO,
|
||||
CartItemDTO,
|
||||
@@ -82,6 +83,16 @@ class StubCalendarService:
|
||||
) -> list[CalendarEntryDTO]:
|
||||
return []
|
||||
|
||||
async def user_tickets(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[TicketDTO]:
|
||||
return []
|
||||
|
||||
async def user_bookings(
|
||||
self, session: AsyncSession, *, user_id: int,
|
||||
) -> list[CalendarEntryDTO]:
|
||||
return []
|
||||
|
||||
|
||||
class StubMarketService:
|
||||
async def marketplaces_for_container(
|
||||
|
||||
Reference in New Issue
Block a user