Replace ticket qty input with +/- buttons, show sold/basket counts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
- Entry page shows tickets sold count, remaining, and "in basket" count - Replace numeric input + Buy button with add-to-basket / +/- controls - New POST /tickets/adjust/ route creates/cancels tickets to target count - Keep buy form active after adding (no confirmation replacement) - New service functions: get_sold_ticket_count, get_user_reserved_count, cancel_latest_reserved_ticket Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -160,13 +160,22 @@ 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
|
from ..tickets.services.tickets import (
|
||||||
|
get_available_ticket_count,
|
||||||
|
get_sold_ticket_count,
|
||||||
|
get_user_reserved_count,
|
||||||
|
)
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
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
|
ticket_remaining = None
|
||||||
|
ticket_sold_count = 0
|
||||||
|
user_ticket_count = 0
|
||||||
|
user_ticket_counts_by_type = {}
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CalendarEntry)
|
select(CalendarEntry)
|
||||||
@@ -174,6 +183,7 @@ def register():
|
|||||||
CalendarEntry.id == entry_id,
|
CalendarEntry.id == entry_id,
|
||||||
CalendarEntry.deleted_at.is_(None),
|
CalendarEntry.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
.options(selectinload(CalendarEntry.ticket_types))
|
||||||
)
|
)
|
||||||
result = await g.s.execute(stmt)
|
result = await g.s.execute(stmt)
|
||||||
calendar_entry = result.scalar_one_or_none()
|
calendar_entry = result.scalar_one_or_none()
|
||||||
@@ -190,11 +200,33 @@ def register():
|
|||||||
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
|
# Get ticket availability
|
||||||
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
|
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
|
||||||
|
# Get sold count
|
||||||
|
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
|
||||||
|
# Get current user's reserved count
|
||||||
|
ident = current_cart_identity()
|
||||||
|
user_ticket_count = await get_user_reserved_count(
|
||||||
|
g.s, calendar_entry.id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
# Per-type counts for multi-type entries
|
||||||
|
if calendar_entry.ticket_types:
|
||||||
|
for tt in calendar_entry.ticket_types:
|
||||||
|
if tt.deleted_at is None:
|
||||||
|
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
||||||
|
g.s, calendar_entry.id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entry": calendar_entry,
|
"entry": calendar_entry,
|
||||||
"entry_posts": entry_posts,
|
"entry_posts": entry_posts,
|
||||||
"ticket_remaining": ticket_remaining,
|
"ticket_remaining": ticket_remaining,
|
||||||
|
"ticket_sold_count": ticket_sold_count,
|
||||||
|
"user_ticket_count": user_ticket_count,
|
||||||
|
"user_ticket_counts_by_type": user_ticket_counts_by_type,
|
||||||
}
|
}
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Routes:
|
|||||||
GET /tickets/ — My tickets list
|
GET /tickets/ — My tickets list
|
||||||
GET /tickets/<code>/ — Ticket detail with QR code
|
GET /tickets/<code>/ — Ticket detail with QR code
|
||||||
POST /tickets/buy/ — Purchase tickets for an entry
|
POST /tickets/buy/ — Purchase tickets for an entry
|
||||||
|
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ from .services.tickets import (
|
|||||||
get_user_tickets,
|
get_user_tickets,
|
||||||
get_available_ticket_count,
|
get_available_ticket_count,
|
||||||
get_tickets_for_entry,
|
get_tickets_for_entry,
|
||||||
|
get_sold_ticket_count,
|
||||||
|
get_user_reserved_count,
|
||||||
|
cancel_latest_reserved_ticket,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -178,4 +182,119 @@ def register() -> Blueprint:
|
|||||||
)
|
)
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
@bp.post("/adjust/")
|
||||||
|
@clear_cache(tag="calendars", tag_scope="all")
|
||||||
|
async def adjust_quantity():
|
||||||
|
"""
|
||||||
|
Adjust ticket quantity for a calendar entry (+/- pattern).
|
||||||
|
Creates or cancels tickets to reach the target count.
|
||||||
|
|
||||||
|
Form fields:
|
||||||
|
entry_id — the calendar entry ID
|
||||||
|
ticket_type_id — (optional) specific ticket type
|
||||||
|
count — target quantity of reserved tickets
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
target = max(int(form.get("count", 0)), 0)
|
||||||
|
ident = current_cart_identity()
|
||||||
|
|
||||||
|
current = await get_user_reserved_count(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=ticket_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if target > current:
|
||||||
|
# Need to add tickets
|
||||||
|
to_add = target - current
|
||||||
|
available = await get_available_ticket_count(g.s, entry_id)
|
||||||
|
if available is not None and to_add > available:
|
||||||
|
return await make_response(
|
||||||
|
f"Only {available} ticket(s) remaining", 400
|
||||||
|
)
|
||||||
|
for _ in range(to_add):
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
elif target < current:
|
||||||
|
# Need to remove tickets
|
||||||
|
to_remove = current - target
|
||||||
|
for _ in range(to_remove):
|
||||||
|
await cancel_latest_reserved_ticket(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=ticket_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build context for re-rendering the buy form
|
||||||
|
ticket_remaining = await get_available_ticket_count(g.s, entry_id)
|
||||||
|
ticket_sold_count = await get_sold_ticket_count(g.s, entry_id)
|
||||||
|
user_ticket_count = await get_user_reserved_count(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-type counts for multi-type entries
|
||||||
|
user_ticket_counts_by_type = {}
|
||||||
|
if entry.ticket_types:
|
||||||
|
for tt in entry.ticket_types:
|
||||||
|
if tt.deleted_at is None:
|
||||||
|
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/tickets/_adjust_response.html",
|
||||||
|
entry=entry,
|
||||||
|
ticket_remaining=ticket_remaining,
|
||||||
|
ticket_sold_count=ticket_sold_count,
|
||||||
|
user_ticket_count=user_ticket_count,
|
||||||
|
user_ticket_counts_by_type=user_ticket_counts_by_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -182,6 +182,80 @@ async def get_tickets_for_entry(
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_sold_ticket_count(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Count all non-cancelled tickets for an entry (total sold/reserved)."""
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
Ticket.entry_id == entry_id,
|
||||||
|
Ticket.state != "cancelled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_reserved_count(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
ticket_type_id: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Count reserved tickets for a specific user/session + entry + optional type."""
|
||||||
|
filters = [
|
||||||
|
Ticket.entry_id == entry_id,
|
||||||
|
Ticket.state == "reserved",
|
||||||
|
]
|
||||||
|
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 0
|
||||||
|
if ticket_type_id is not None:
|
||||||
|
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.count(Ticket.id)).where(*filters)
|
||||||
|
)
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_latest_reserved_ticket(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
ticket_type_id: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Cancel the most recently created reserved ticket. Returns True if one was cancelled."""
|
||||||
|
filters = [
|
||||||
|
Ticket.entry_id == entry_id,
|
||||||
|
Ticket.state == "reserved",
|
||||||
|
]
|
||||||
|
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 False
|
||||||
|
if ticket_type_id is not None:
|
||||||
|
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||||
|
|
||||||
|
ticket = await session.scalar(
|
||||||
|
select(Ticket)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(Ticket.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if ticket:
|
||||||
|
ticket.state = "cancelled"
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_available_ticket_count(
|
async def get_available_ticket_count(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
|
|||||||
4
templates/_types/tickets/_adjust_response.html
Normal file
4
templates/_types/tickets/_adjust_response.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Response for ticket adjust — buy form + OOB cart-mini update #}
|
||||||
|
{% from '_types/cart/_mini.html' import mini %}
|
||||||
|
{{ mini(oob='true') }}
|
||||||
|
{% include '_types/tickets/_buy_form.html' %}
|
||||||
@@ -3,14 +3,31 @@
|
|||||||
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-stone-200 bg-white p-4">
|
<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">
|
<h3 class="text-sm font-semibold text-stone-700 mb-3">
|
||||||
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
||||||
Buy Tickets
|
Tickets
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{# Sold / remaining info #}
|
||||||
|
<div class="flex items-center gap-3 mb-3 text-xs text-stone-500">
|
||||||
|
{% if ticket_sold_count is defined and ticket_sold_count %}
|
||||||
|
<span>{{ ticket_sold_count }} sold</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if ticket_remaining is not none %}
|
||||||
|
<span>{{ ticket_remaining }} remaining</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if user_ticket_count is defined and user_ticket_count %}
|
||||||
|
<span class="text-emerald-600 font-medium">
|
||||||
|
<i class="fa fa-shopping-cart text-[0.6rem]" aria-hidden="true"></i>
|
||||||
|
{{ user_ticket_count }} in basket
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if entry.ticket_types %}
|
{% if entry.ticket_types %}
|
||||||
{# Multiple ticket types #}
|
{# Multiple ticket types #}
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2">
|
||||||
{% for tt in entry.ticket_types %}
|
{% for tt in entry.ticket_types %}
|
||||||
{% if tt.deleted_at is none %}
|
{% if tt.deleted_at is none %}
|
||||||
|
{% set type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type is defined else 0 %}
|
||||||
<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">
|
<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium text-sm">{{ tt.name }}</div>
|
<div class="font-medium text-sm">{{ tt.name }}</div>
|
||||||
@@ -18,34 +35,83 @@
|
|||||||
£{{ '%.2f'|format(tt.cost) }}
|
£{{ '%.2f'|format(tt.cost) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
|
||||||
hx-post="{{ url_for('tickets.buy_tickets') }}"
|
{% if type_count == 0 %}
|
||||||
hx-target="#ticket-buy-{{ entry.id }}"
|
{# Add to basket button #}
|
||||||
hx-swap="outerHTML"
|
<form
|
||||||
class="flex items-center gap-2"
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
>
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
hx-swap="outerHTML"
|
||||||
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
class="flex items-center"
|
||||||
<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
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
</button>
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
</form>
|
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
|
||||||
|
<input type="hidden" name="count" value="1" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
|
||||||
|
>
|
||||||
|
<i class="fa fa-cart-plus text-2xl" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# +/- controls #}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<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="hidden" name="count" value="{{ type_count - 1 }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="relative inline-flex items-center justify-center text-emerald-700"
|
||||||
|
href="{{ url_for('tickets.my_tickets') }}"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||||
|
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">
|
||||||
|
{{ type_count }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<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="hidden" name="count" value="{{ type_count + 1 }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Simple ticket (single price) #}
|
{# Simple ticket (single price) #}
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -55,38 +121,80 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-stone-500 ml-2">per ticket</span>
|
<span class="text-sm text-stone-500 ml-2">per ticket</span>
|
||||||
</div>
|
</div>
|
||||||
{% if ticket_remaining is not none %}
|
|
||||||
<span class="text-xs text-stone-500">
|
|
||||||
{{ ticket_remaining }} remaining
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
{% set qty = user_ticket_count if user_ticket_count is defined else 0 %}
|
||||||
hx-post="{{ url_for('tickets.buy_tickets') }}"
|
|
||||||
hx-target="#ticket-buy-{{ entry.id }}"
|
{% if qty == 0 %}
|
||||||
hx-swap="outerHTML"
|
{# Add to basket button #}
|
||||||
class="flex items-center gap-3"
|
<form
|
||||||
>
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
hx-swap="outerHTML"
|
||||||
<label class="text-sm text-stone-600">Qty:</label>
|
class="flex items-center"
|
||||||
<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>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
Buy Tickets
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
</button>
|
<input type="hidden" name="count" value="1" />
|
||||||
</form>
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# +/- controls #}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
|
<input type="hidden" name="count" value="{{ qty - 1 }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="relative inline-flex items-center justify-center text-emerald-700"
|
||||||
|
href="{{ url_for('tickets.my_tickets') }}"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||||
|
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">
|
||||||
|
{{ qty }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
|
<input type="hidden" name="count" value="{{ qty + 1 }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elif entry.ticket_price is not none %}
|
{% elif entry.ticket_price is not none %}
|
||||||
|
|||||||
Reference in New Issue
Block a user