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:
@@ -6,13 +6,11 @@
|
||||
{# Outer left: -1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_year,
|
||||
month=month) }}"
|
||||
@@ -27,13 +25,11 @@
|
||||
{# Inner left: -1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=prev_month_year,
|
||||
month=prev_month) }}"
|
||||
@@ -52,13 +48,11 @@
|
||||
{# Inner right: +1 month #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_month_year,
|
||||
month=next_month) }}"
|
||||
@@ -73,13 +67,11 @@
|
||||
{# Outer right: +1 year #}
|
||||
<a
|
||||
class="{{styles.pill}} text-xl"
|
||||
href="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.get',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=next_year,
|
||||
month=month) }}"
|
||||
@@ -118,14 +110,12 @@
|
||||
{# Clickable day number: goes to day detail view #}
|
||||
<a
|
||||
class="{{styles.pill}}"
|
||||
href="{{ url_for('blog.post.calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.day.show_day',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
day=day.date.day) }}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.day.show_day',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day.date.year,
|
||||
month=day.date.month,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!-- Desktop nav -->
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(
|
||||
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug=calendar.slug),
|
||||
hx_select_search,
|
||||
select_colours,
|
||||
aclass=styles.nav_button
|
||||
@@ -13,5 +12,4 @@
|
||||
{% endcall %}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug))}}
|
||||
{% endif %}
|
||||
@@ -13,8 +13,7 @@
|
||||
type="button"
|
||||
class="mt-2 text-xs underline"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.admin.calendar_description_edit',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.admin.calendar_description_edit',
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<div id="calendar-description">
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.admin.calendar_description_save',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.admin.calendar_description_save',
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
@@ -29,8 +28,7 @@
|
||||
type="button"
|
||||
class="px-3 py-1 rounded border"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.admin.calendar_description_view',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.admin.calendar_description_view',
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#calendar-description"
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
<form
|
||||
id="calendar-form"
|
||||
method="post"
|
||||
hx-put="{{ url_for('blog.post.calendars.calendar.put', slug=post.slug, calendar_slug=calendar.slug ) }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-on::before-request="document.querySelector('#cal-put-errors').textContent='';"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendar-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for('blog.post.calendars.calendar.admin.admin', slug=post.slug, calendar_slug=calendar.slug),
|
||||
hx_select_search
|
||||
) %}
|
||||
{{ links.admin() }}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendar-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug= calendar.slug), hx_select_search) %}
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i class="fa fa-calendar"></i>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="mt-6 border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
{% set calendar_href = url_for('blog.post.calendars.calendar.get', slug=post.slug, calendar_slug=cal.slug)|host%}
|
||||
<a
|
||||
class="flex items-baseline gap-3"
|
||||
href="{{ calendar_href }}"
|
||||
@@ -27,7 +26,6 @@
|
||||
data-confirm-confirm-text="Yes, delete it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
hx-delete="{{ url_for('blog.post.calendars.calendar.delete', slug=post.slug, calendar_slug=cal.slug) }}"
|
||||
hx-trigger="confirmed"
|
||||
hx-target="#calendars-list"
|
||||
hx-select="#calendars-list"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<section class="p-4">
|
||||
{% if has_access('blog.post.calendars.create_calendar') %}
|
||||
{% if has_access('calendars.create_calendar') %}
|
||||
<!-- error container under the inputs -->
|
||||
<div id="cal-create-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
|
||||
<form
|
||||
class="mt-4 flex gap-2 items-end"
|
||||
hx-post="{{ url_for('blog.post.calendars.create_calendar', slug=post.slug) }}"
|
||||
hx-post="{{ url_for('calendars.create_calendar', slug=post.slug) }}"
|
||||
hx-target="#calendars-list"
|
||||
hx-select="#calendars-list"
|
||||
hx-swap="outerHTML"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='calendars-row', oob=oob) %}
|
||||
{% call links.link(url_for('blog.post.calendars.home', slug=post.slug), hx_select_search) %}
|
||||
{% call links.link(url_for('calendars.home', slug=post.slug), hx_select_search) %}
|
||||
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||
<div>
|
||||
Calendars
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<form
|
||||
class="mt-4 grid grid-cols-1 md:grid-cols-4 gap-2"
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.add_entry',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.add_entry',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
@@ -124,8 +123,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.add_button',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.add_button',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.add_form',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.add_form',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
||||
<a
|
||||
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
@@ -30,8 +29,7 @@
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.admin.admin',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<div class="font-medium">
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
@@ -24,8 +23,7 @@
|
||||
<div class="text-xs font-medium">
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.slots.slot.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.slots.slot.get',
|
||||
calendar_slug=calendar.slug,
|
||||
slot_id=entry.slot.id
|
||||
),
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry) scrolling_menu('day-entries-container', confirmed_entries) %}
|
||||
<a
|
||||
href="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
slug=post.slug,
|
||||
href="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
{% call links.menu_row(id='day-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.admin.admin',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
{% call links.menu_row(id='day-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.show_day',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.show_day',
|
||||
calendar_slug=calendar.slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
<form
|
||||
class="space-y-3 mt-4"
|
||||
hx-put="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.put',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.put',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day, month=month, year=year,
|
||||
entry_id=entry.id
|
||||
@@ -163,8 +162,7 @@
|
||||
type="button"
|
||||
class="{{ styles.cancel_button }}"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day, month=month, year=year,
|
||||
entry_id=entry.id
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buy Tickets (public-facing) -->
|
||||
{% include '_types/tickets/_buy_form.html' %}
|
||||
|
||||
<!-- Date -->
|
||||
<div class="flex flex-col mb-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
@@ -108,9 +111,8 @@
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get_edit',
|
||||
entry_id=entry.id,
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
{% if entry.state == 'provisional' %}
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.confirm_entry',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
@@ -32,8 +31,7 @@
|
||||
</form>
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.decline_entry',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
@@ -64,8 +62,7 @@
|
||||
{% if entry.state == 'confirmed' %}
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.provisional_entry',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{% for search_post in search_posts %}
|
||||
<form
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.add_post',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.add_post',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
@@ -42,8 +41,7 @@
|
||||
<div
|
||||
id="post-search-sentinel-{{ page }}"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
hx-delete="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.remove_post',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
@@ -56,8 +55,7 @@
|
||||
placeholder="Search posts..."
|
||||
class="w-full px-3 py-2 border rounded text-sm"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.search_posts',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -44,9 +44,8 @@
|
||||
id="ticket-form-{{entry.id}}"
|
||||
class="{% if entry.ticket_price is not none %}hidden{% endif %} space-y-3 mt-2 p-3 border rounded bg-stone-50"
|
||||
hx-post="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.update_tickets',
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.update_tickets',
|
||||
entry_id=entry.id,
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
||||
calendar_slug=calendar.slug,
|
||||
entry_id=entry.id,
|
||||
year=year,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
{% call links.menu_row(id='entry-admin-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.admin.admin',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
{% call links.menu_row(id='entry-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.get',
|
||||
calendar_slug=calendar.slug,
|
||||
day=day,
|
||||
month=month,
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-4 border-t"
|
||||
hx-get="{{ url_for('blog.post.admin.calendar_view', slug=post.slug, calendar_id=calendar.id) }}"
|
||||
hx-trigger="intersect once"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-sm text-stone-400">Loading calendar...</div>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
<div id="slot-errors" class="mt-2 text-sm text-red-600"></div>
|
||||
<form
|
||||
class="space-y-3 mt-4"
|
||||
hx-put="{{ url_for('blog.post.calendars.calendar.slots.slot.put',
|
||||
slug=post.slug,
|
||||
hx-put="{{ url_for('calendars.calendar.slots.slot.put',
|
||||
calendar_slug=calendar.slug,
|
||||
slot_id=slot.id) }}"
|
||||
hx-target="#slot-{{ slot.id }}"
|
||||
@@ -154,8 +153,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.slots.slot.get_view',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.slots.slot.get_view',
|
||||
calendar_slug=calendar.slug,
|
||||
slot_id=slot.id) }}"
|
||||
hx-target="#slot-{{ slot.id }}"
|
||||
|
||||
@@ -54,9 +54,8 @@
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.slots.slot.get_edit',
|
||||
'calendars.calendar.slots.slot.get_edit',
|
||||
slot_id=slot.id,
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
) }}"
|
||||
hx-target="#slot-{{slot.id}}"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='slot-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=slot.id),
|
||||
hx_select_search,
|
||||
) %}
|
||||
<div class="flex flex-col md:flex-row md:gap-2 items-center">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form
|
||||
hx-post="{{ url_for('blog.post.calendars.calendar.slots.post',
|
||||
slug=post.slug,
|
||||
hx-post="{{ url_for('calendars.calendar.slots.post',
|
||||
calendar_slug=calendar.slug) }}"
|
||||
hx-target="#slots-table"
|
||||
hx-select="#slots-table"
|
||||
@@ -99,8 +98,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_button',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.slots.add_button',
|
||||
calendar_slug=calendar.slug) }}"
|
||||
hx-target="#slot-add-container"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.slots.add_form',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.slots.add_form',
|
||||
calendar_slug=calendar.slug) }}"
|
||||
hx-target="#slot-add-container"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<td class="p-2 align-top w-1/6">
|
||||
<div class="font-medium">
|
||||
{% call links.link(
|
||||
url_for('blog.post.calendars.calendar.slots.slot.get', slug=post.slug, calendar_slug=calendar.slug, slot_id=s.id),
|
||||
hx_select_search,
|
||||
aclass=styles.pill
|
||||
) %}
|
||||
@@ -46,8 +45,7 @@
|
||||
data-confirm-confirm-text="Yes, delete it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
hx-delete="{{ url_for('blog.post.calendars.calendar.slots.slot.slot_delete',
|
||||
slug=post.slug,
|
||||
hx-delete="{{ url_for('calendars.calendar.slots.slot.slot_delete',
|
||||
calendar_slug=calendar.slug,
|
||||
slot_id=s.id) }}"
|
||||
hx-target="#slots-table"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='slots-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for('blog.post.calendars.calendar.slots.get', slug=post.slug, calendar_slug= calendar.slug),
|
||||
hx_select_search,
|
||||
) %}
|
||||
<i class="fa fa-clock"></i>
|
||||
|
||||
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>
|
||||
<form
|
||||
class="space-y-3 mt-4"
|
||||
hx-put="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
|
||||
slug=post.slug,
|
||||
hx-put="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.put',
|
||||
calendar_slug=calendar.slug,
|
||||
year=year,
|
||||
month=month,
|
||||
@@ -71,8 +70,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_view',
|
||||
calendar_slug=calendar.slug,
|
||||
entry_id=entry.id,
|
||||
year=year,
|
||||
|
||||
@@ -33,9 +33,8 @@
|
||||
type="button"
|
||||
class="{{styles.pre_action_button}}"
|
||||
hx-get="{{ url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit',
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit',
|
||||
ticket_type_id=ticket_type.id,
|
||||
slug=post.slug,
|
||||
calendar_slug=calendar.slug,
|
||||
year=year,
|
||||
month=month,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
{% call links.menu_row(id='ticket_type-row', oob=oob) %}
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=year,
|
||||
month=month,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<form
|
||||
hx-post="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
|
||||
slug=post.slug,
|
||||
hx-post="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.post',
|
||||
calendar_slug=calendar.slug,
|
||||
entry_id=entry.id,
|
||||
year=year,
|
||||
@@ -56,8 +55,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{styles.cancel_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_button',
|
||||
calendar_slug=calendar.slug,
|
||||
entry_id=entry.id,
|
||||
year=year,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<button
|
||||
class="{{styles.action_button}}"
|
||||
hx-get="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
|
||||
slug=post.slug,
|
||||
hx-get="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form',
|
||||
calendar_slug=calendar.slug,
|
||||
entry_id=entry.id,
|
||||
year=year,
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
<div class="font-medium">
|
||||
{% call links.link(
|
||||
url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get',
|
||||
calendar_slug=calendar.slug,
|
||||
year=year,
|
||||
month=month,
|
||||
@@ -36,8 +35,7 @@
|
||||
data-confirm-confirm-text="Yes, delete it"
|
||||
data-confirm-cancel-text="Cancel"
|
||||
data-confirm-event="confirmed"
|
||||
hx-delete="{{ url_for('blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
|
||||
slug=post.slug,
|
||||
hx-delete="{{ url_for('calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete',
|
||||
calendar_slug=calendar.slug,
|
||||
year=year,
|
||||
month=month,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='ticket_types-row', oob=oob) %}
|
||||
{% call links.link(url_for(
|
||||
'blog.post.calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
||||
slug=post.slug,
|
||||
'calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.get',
|
||||
calendar_slug=calendar.slug,
|
||||
entry_id=entry.id,
|
||||
year=year,
|
||||
|
||||
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