feat: UI fixes, ticket waves, ticket image (#52)

* email input fixes

* promo code ui fix

* ticket waves

* works, but horrible slop

* slightly cleaner

* filter most recent

* make

* pyright
This commit is contained in:
Arc 2026-05-22 20:18:39 +01:00 • committed by GitHub
commit 777c107c63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1557 additions and 302 deletions

View file

@ -6,12 +6,13 @@ from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import events_generic_router from .views import events_generic_router
from .views_api import events_api_router, tickets_api_router from .views_api import events_api_router, qr_api_router, tickets_api_router
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router) events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router) events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router) events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router)
events_static_files = [ events_static_files = [
{ {

45
crud.py
View file

@ -1,9 +1,17 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import cast
from lnbits.db import Database from lnbits.db import Database, Filters, Page
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, Ticket, TicketExtra from .models import (
CreateEvent,
Event,
Ticket,
TicketExtra,
TicketFilters,
sync_event_ticket_waves,
)
db = Database("ext_events") db = Database("ext_events")
@ -51,6 +59,31 @@ async def get_tickets(wallet_ids: str | list[str]) -> list[Ticket]:
) )
async def get_tickets_paginated(
wallet_ids: str | list[str], filters: Filters[TicketFilters] | None = None
) -> Page[Ticket]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
wallet_where = []
values = {}
for idx, wallet_id in enumerate(wallet_ids):
key = f"wallet_id_{idx}"
wallet_where.append(f":{key}")
values[key] = wallet_id
where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"]
return await db.fetch_page(
"SELECT * FROM events.ticket",
where=where,
values=values,
filters=filters,
model=Ticket,
table_name="events.ticket",
)
async def delete_ticket(payment_hash: str) -> None: async def delete_ticket(payment_hash: str) -> None:
await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash}) await db.execute("DELETE FROM events.ticket WHERE id = :id", {"id": payment_hash})
@ -75,31 +108,35 @@ async def purge_unpaid_tickets(event_id: str) -> None:
async def create_event(data: CreateEvent) -> Event: async def create_event(data: CreateEvent) -> Event:
event_id = urlsafe_short_hash() event_id = urlsafe_short_hash()
event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict())
event = cast(Event, sync_event_ticket_waves(event))
await db.insert("events.events", event) await db.insert("events.events", event)
return event return event
async def update_event(event: Event) -> Event: async def update_event(event: Event) -> Event:
event = cast(Event, sync_event_ticket_waves(event))
await db.update("events.events", event) await db.update("events.events", event)
return event return event
async def get_event(event_id: str) -> Event | None: async def get_event(event_id: str) -> Event | None:
return await db.fetchone( event = await db.fetchone(
"SELECT * FROM events.events WHERE id = :id", "SELECT * FROM events.events WHERE id = :id",
{"id": event_id}, {"id": event_id},
Event, Event,
) )
return cast(Event, sync_event_ticket_waves(event)) if event else None
async def get_events(wallet_ids: str | list[str]) -> list[Event]: async def get_events(wallet_ids: str | list[str]) -> list[Event]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids]) q = ",".join([f"'{wallet_id}'" for wallet_id in wallet_ids])
return await db.fetchall( events = await db.fetchall(
f"SELECT * FROM events.events WHERE wallet IN ({q})", f"SELECT * FROM events.events WHERE wallet IN ({q})",
model=Event, model=Event,
) )
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
async def delete_event(event_id: str) -> None: async def delete_event(event_id: str) -> None:

117
models.py
View file

@ -1,6 +1,8 @@
from datetime import datetime from datetime import date, datetime
from uuid import uuid4
from fastapi import Query from fastapi import Query
from lnbits.db import FilterModel
from pydantic import BaseModel, EmailStr, Field, validator from pydantic import BaseModel, EmailStr, Field, validator
@ -20,8 +22,23 @@ class PromoCode(BaseModel):
return v return v
class TicketWave(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex[:8])
title: str = "Primary wave"
opening_date: str
closing_date: str
currency: str = "sat"
use_ticket_image: bool = False
ticket_image_id: str | None = None
allow_fiat: bool = False
fiat_currency: str = "GBP"
amount_tickets: int = Field(default=0, ge=0)
price_per_ticket: float = Field(default=0, ge=0)
class EventExtra(BaseModel): class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list) promo_codes: list[PromoCode] = Field(default_factory=list)
ticket_waves: list[TicketWave] = Field(default_factory=list)
conditional: bool = False conditional: bool = False
min_tickets: int = 1 min_tickets: int = 1
email_notifications: bool = False email_notifications: bool = False
@ -84,6 +101,8 @@ class PublicEvent(BaseModel):
class TicketExtra(BaseModel): class TicketExtra(BaseModel):
applied_promo_code: str | None = None applied_promo_code: str | None = None
ticket_wave_id: str | None = None
ticket_wave_title: str | None = None
sats_paid: int | None = None sats_paid: int | None = None
refund_address: str | None = None refund_address: str | None = None
nostr_identifier: str | None = None nostr_identifier: str | None = None
@ -96,6 +115,7 @@ class TicketExtra(BaseModel):
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
name: str name: str
email: EmailStr email: EmailStr
ticket_wave_id: str | None = None
promo_code: str | None = None promo_code: str | None = None
refund_address: str | None = None refund_address: str | None = None
nostr_identifier: str | None = None nostr_identifier: str | None = None
@ -116,6 +136,22 @@ class Ticket(BaseModel):
extra: TicketExtra = Field(default_factory=TicketExtra) extra: TicketExtra = Field(default_factory=TicketExtra)
class NotificationDeliveryResult(BaseModel):
attempted: bool = False
sent: bool = False
error: str | None = None
class TicketResendResult(BaseModel):
ticket: Ticket
email: NotificationDeliveryResult = Field(
default_factory=NotificationDeliveryResult
)
nostr: NotificationDeliveryResult = Field(
default_factory=NotificationDeliveryResult
)
class PublicTicket(BaseModel): class PublicTicket(BaseModel):
event: str event: str
name: str name: str
@ -131,3 +167,82 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None fiat_payment_request: str | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
is_fiat: bool = False is_fiat: bool = False
class TicketFilters(FilterModel):
__search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012
__sort_fields__ = [ # noqa: RUF012
"time",
"event",
"name",
"email",
"registered",
"id",
]
event: str | None = None
name: str | None = None
email: str | None = None
registered: bool | None = None
paid: bool | None = None
id: str | None = None
def _parse_date(value: str) -> date:
return datetime.strptime(value, "%Y-%m-%d").date()
def ensure_ticket_waves(event: Event | PublicEvent | CreateEvent) -> list[TicketWave]:
ticket_waves = list(getattr(event.extra, "ticket_waves", []) or [])
if ticket_waves:
return ticket_waves
fallback_opening_date = None
event_time = getattr(event, "time", None)
if event_time:
fallback_opening_date = event_time.date().isoformat()
if not fallback_opening_date:
fallback_opening_date = event.closing_date
return [
TicketWave(
id="primary",
title="Primary wave",
opening_date=fallback_opening_date,
closing_date=event.closing_date,
currency=event.currency,
allow_fiat=event.allow_fiat,
fiat_currency=event.fiat_currency,
amount_tickets=getattr(event, "amount_tickets", 0),
price_per_ticket=event.price_per_ticket,
)
]
def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent:
ticket_waves = ensure_ticket_waves(event)
event.extra.ticket_waves = ticket_waves
primary_wave = ticket_waves[0]
event.closing_date = max(wave.closing_date for wave in ticket_waves)
event.currency = primary_wave.currency
event.allow_fiat = primary_wave.allow_fiat
event.fiat_currency = primary_wave.fiat_currency
event.amount_tickets = sum(wave.amount_tickets for wave in ticket_waves)
event.price_per_ticket = primary_wave.price_per_ticket
return event
def get_active_ticket_waves(
event: Event | PublicEvent, today: date | None = None
) -> list[TicketWave]:
current_day = today or datetime.utcnow().date()
return [
wave
for wave in ensure_ticket_waves(event)
if _parse_date(wave.opening_date)
<= current_day
<= _parse_date(wave.closing_date)
and wave.amount_tickets > 0
]

View file

@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
import smtplib
from asyncio.tasks import create_task from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from html import escape
from lnbits.core.models.users import UserNotifications from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.notifications import send_user_notification
from lnbits.core.services.notifications import ( from lnbits.helpers import is_valid_email_address
send_email_notification,
send_user_notification,
)
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
from lnurl import execute from lnurl import execute
from loguru import logger from loguru import logger
@ -20,13 +20,13 @@ from .crud import (
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import Event, Ticket from .models import (
Event,
DEFAULT_NOSTR_RELAYS = [ NotificationDeliveryResult,
"wss://relay.damus.io", Ticket,
"wss://relay.primal.net", TicketResendResult,
"wss://relay.nostr.band", ensure_ticket_waves,
] )
async def set_ticket_paid(ticket: Ticket) -> Ticket: async def set_ticket_paid(ticket: Ticket) -> Ticket:
@ -39,6 +39,15 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event) event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid" assert event, "Couldn't get event from ticket being paid"
event.sold += 1 event.sold += 1
ticket_waves = event.extra.ticket_waves or []
if ticket_waves:
selected_wave = next(
(wave for wave in ticket_waves if wave.id == ticket.extra.ticket_wave_id),
ticket_waves[0],
)
if selected_wave.amount_tickets > 0:
selected_wave.amount_tickets -= 1
elif event.amount_tickets > 0:
event.amount_tickets -= 1 event.amount_tickets -= 1
await update_event(event) await update_event(event)
@ -55,40 +64,12 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
logger.warning(f"Event {ticket.event} not found for ticket notification.") logger.warning(f"Event {ticket.event} not found for ticket notification.")
return return
subject, message = _ticket_notification_message(ticket, event) await _deliver_ticket_notifications(ticket, event)
updated = False
if (
event.extra.email_notifications
and settings.lnbits_email_notifications_enabled
and ticket.email
):
try:
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
if (
event.extra.nostr_notifications
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
):
try:
await _send_nostr_ticket_notification(
ticket.extra.nostr_identifier, message
)
ticket.extra.nostr_notification_sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
if updated:
await update_ticket(ticket)
async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: async def resend_ticket_email_notification(
ticket: Ticket, base_url: str | None = None
) -> TicketResendResult:
event = await get_event(ticket.event) event = await get_event(ticket.event)
if not event: if not event:
raise ValueError("Event does not exist.") raise ValueError("Event does not exist.")
@ -96,11 +77,10 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
raise ValueError("Email notifications are not enabled.") raise ValueError("Email notifications are not enabled.")
if not ticket.email: if not ticket.email:
raise ValueError("Ticket does not have an email address.") raise ValueError("Ticket does not have an email address.")
if base_url:
ticket.extra.ticket_base_url = base_url.rstrip("/")
subject, message = _ticket_notification_message(ticket, event) return await _deliver_ticket_notifications(ticket, event)
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
return await update_ticket(ticket)
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
@ -117,18 +97,144 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
return subject, f"{body}\n\nOpen it here: {ticket_url}" return subject, f"{body}\n\nOpen it here: {ticket_url}"
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return base_message
return f"{base_message}\n\nTicket image: {ticket_image_url}"
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
text_message = _ticket_delivery_message(ticket, event, base_message)
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
ticket_image_url = _ticket_image_url(ticket, event)
if not ticket_image_url:
return html_message
return (
f"{html_message}"
f'<p><img src="{escape(ticket_image_url, quote=True)}" alt="Ticket image" '
'style="max-width: 200px; height: auto;" /></p>'
)
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
subject, base_message = _ticket_notification_message(ticket, event)
text_message = _ticket_delivery_message(ticket, event, base_message)
html_message = _ticket_email_html_message(ticket, event, base_message)
return subject, text_message, html_message
def _supports_nostr_delivery(identifier: str | None) -> bool:
return bool(identifier and "@" in identifier)
async def _deliver_ticket_notifications(
ticket: Ticket, event: Event
) -> TicketResendResult:
subject, text_message, html_message = _ticket_notification_payload(ticket, event)
updated = False
result = TicketResendResult(
ticket=ticket,
email=NotificationDeliveryResult(
attempted=bool(
event.extra.email_notifications
and settings.lnbits_email_notifications_enabled
and ticket.email
)
),
nostr=NotificationDeliveryResult(
attempted=bool(
event.extra.nostr_notifications
and settings.is_nostr_notifications_configured()
and ticket.extra.nostr_identifier
)
),
)
if result.email.attempted:
try:
await _send_ticket_email_notification(
[ticket.email], text_message, subject, html_message
)
ticket.extra.email_notification_sent = True
result.email.sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to email ticket {ticket.id}: {exc}")
result.email.error = str(exc)
if result.nostr.attempted and not _supports_nostr_delivery(
ticket.extra.nostr_identifier
):
result.nostr.error = "Only NIP-05 Nostr identifiers are supported."
elif result.nostr.attempted:
try:
identifier = ticket.extra.nostr_identifier
assert identifier is not None
await _send_nostr_ticket_notification(identifier, text_message)
ticket.extra.nostr_notification_sent = True
result.nostr.sent = True
updated = True
except Exception as exc:
logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}")
result.nostr.error = str(exc)
if updated:
result.ticket = await update_ticket(ticket)
return result
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
if "@" in identifier:
await send_user_notification( await send_user_notification(
UserNotifications(nostr_identifier=identifier), UserNotifications(nostr_identifier=identifier),
message, message,
"text_message", "text_message",
) )
return
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
public_key = normalize_public_key(identifier) async def _send_ticket_email_notification(
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS) to_emails: list[str],
message: str,
subject: str,
html_message: str | None = None,
) -> None:
if not settings.lnbits_email_notifications_enabled:
raise ValueError("Email notifications are disabled")
if not is_valid_email_address(settings.lnbits_email_notifications_email):
raise ValueError(
f"Invalid from email address: {settings.lnbits_email_notifications_email}"
)
if not to_emails:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart("alternative")
msg["From"] = settings.lnbits_email_notifications_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
if html_message:
msg.attach(MIMEText(html_message, "html"))
username = (
settings.lnbits_email_notifications_username
or settings.lnbits_email_notifications_email
)
with smtplib.SMTP(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, settings.lnbits_email_notifications_password)
smtp_server.sendmail(
settings.lnbits_email_notifications_email,
to_emails,
msg.as_string(),
)
def _ticket_url(ticket: Ticket) -> str: def _ticket_url(ticket: Ticket) -> str:
@ -136,6 +242,19 @@ def _ticket_url(ticket: Ticket) -> str:
return f"{base_url}/events/ticket/{ticket.id}" return f"{base_url}/events/ticket/{ticket.id}"
def _ticket_image_url(ticket: Ticket, event: Event) -> str | None:
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
if not wave.use_ticket_image:
return None
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
return f"{base_url}/events/api/v1/qr/{ticket.id}"
async def refund_tickets(event_id: str): async def refund_tickets(event_id: str):
""" """
Refund tickets for an event that has not met the minimum ticket requirement. Refund tickets for an event that has not met the minimum ticket requirement.

BIN
static/image/ticket.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -13,6 +13,7 @@ window.PageEventsDisplay = {
email: '', email: '',
refund: '', refund: '',
nostr_identifier: '', nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning' payment_method: 'lightning'
} }
}, },
@ -40,16 +41,37 @@ window.PageEventsDisplay = {
formatDescription() { formatDescription() {
return LNbits.utils.convertMarkdown(this.event?.info || '') return LNbits.utils.convertMarkdown(this.event?.info || '')
}, },
activeTicketWaves() {
const today = new Date().toISOString().slice(0, 10)
return (this.event?.extra?.ticket_waves || []).filter(
wave =>
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
},
selectedTicketWave() {
return (
this.activeTicketWaves.find(
wave => wave.id === this.formDialog.data.ticket_wave_id
) ||
this.activeTicketWaves[0] ||
null
)
},
showTicketWaveSelector() {
return this.activeTicketWaves.length > 1
},
allowFiatCheckout() { allowFiatCheckout() {
return Boolean(this.event?.allow_fiat) return Boolean(this.selectedTicketWave?.allow_fiat)
}, },
fiatCheckoutLabel() { fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat' if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes( const unit = ['sat', 'sats'].includes(
(this.event?.currency || '').toLowerCase() (this.selectedTicketWave?.currency || '').toLowerCase()
) )
? this.event?.fiat_currency ? this.selectedTicketWave?.fiat_currency
: this.event?.currency : this.selectedTicketWave?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})` return `Fiat (${(unit || 'GBP').toUpperCase()})`
}, },
allowEmailNotifications() { allowEmailNotifications() {
@ -66,6 +88,16 @@ window.PageEventsDisplay = {
'GET', 'GET',
`/events/api/v1/events/${this.eventId}` `/events/api/v1/events/${this.eventId}`
) )
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
const today = new Date().toISOString().slice(0, 10)
return (
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
})
this.formDialog.data.ticket_wave_id =
activeWaves.length === 1 ? activeWaves[0].id : null
return data return data
} catch (error) { } catch (error) {
this.eventErrorLabel = 'Event unavailable.' this.eventErrorLabel = 'Event unavailable.'
@ -78,6 +110,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.payment_method = 'lightning' this.formDialog.data.payment_method = 'lightning'
}, },
@ -112,6 +148,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = '' this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = '' this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.payment_method = 'lightning' this.formDialog.data.payment_method = 'lightning'
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: 'positive',
@ -141,10 +181,13 @@ window.PageEventsDisplay = {
{ {
name: this.formDialog.data.name, name: this.formDialog.data.name,
email: this.formDialog.data.email, email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null, promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null, refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null, nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.formDialog.data.payment_method payment_method: this.allowFiatCheckout
? this.formDialog.data.payment_method
: 'lightning'
} }
) )
const isFiat = Boolean(data.is_fiat) const isFiat = Boolean(data.is_fiat)
@ -178,7 +221,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location) const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}` url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.search = '' url.search = ''
url.hash = '' url.hash = ''
@ -187,7 +230,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => { ws.onmessage = event => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.pending === false) { if (data.paid === true) {
this.paymentSuccess(paymentHash) this.paymentSuccess(paymentHash)
ws.close() ws.close()
} }

View file

@ -49,7 +49,7 @@
filled filled
dense dense
v-model.trim="formDialog.data.nostr_identifier" v-model.trim="formDialog.data.nostr_identifier"
label="(optional) Nostr NIP-05 or npub" label="(optional) Nostr NIP-05"
hint="If provided, we'll DM your ticket link after payment." hint="If provided, we'll DM your ticket link after payment."
></q-input> ></q-input>
</div> </div>
@ -64,6 +64,21 @@
lazy-rules lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`" :hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input> ></q-input>
<q-select
v-if="showTicketWaveSelector"
filled
dense
v-model="formDialog.data.ticket_wave_id"
emit-value
map-options
label="Ticket wave"
:options="
activeTicketWaves.map(wave => ({
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
value: wave.id
}))
"
></q-select>
<div class="row q-col-gutter-md q-pt-lg items-center"> <div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="allowFiatCheckout" class="col-auto"> <div v-if="allowFiatCheckout" class="col-auto">
<q-option-group <q-option-group
@ -94,6 +109,7 @@
:disable=" :disable="
formDialog.data.name == '' || formDialog.data.name == '' ||
formDialog.data.email == '' || formDialog.data.email == '' ||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
Boolean(paymentReq) Boolean(paymentReq)
" "
type="submit" type="submit"

View file

@ -4,11 +4,19 @@ window.PageEvents = {
return { return {
events: [], events: [],
tickets: [], tickets: [],
allPaidTickets: [],
resendingTicketEmails: [], resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [], currencies: [],
eventsTable: { eventsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {
name: 'id',
align: 'left',
label: 'ID',
field: row => this.shortenId(row.id)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{ {
name: 'event_start_date', name: 'event_start_date',
@ -22,12 +30,6 @@ window.PageEvents = {
label: 'End date', label: 'End date',
field: 'event_end_date' field: 'event_end_date'
}, },
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{ {
name: 'canceled', name: 'canceled',
align: 'left', align: 'left',
@ -38,43 +40,21 @@ window.PageEvents = {
} }
return 'No' return 'No'
} }
},
{
name: 'price_per_ticket',
align: 'left',
label: 'Price',
field: row => {
if (this.isFiatCurrency(row.currency)) {
return LNbits.utils.formatCurrency(
row.price_per_ticket.toFixed(2),
row.currency
)
} }
return row.price_per_ticket
}
},
{
name: 'amount_tickets',
align: 'left',
label: 'No tickets',
field: 'amount_tickets'
},
{
name: 'sold',
align: 'left',
label: 'Sold',
field: 'sold'
},
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
} }
}, },
ticketsTable: { ticketsTable: {
loading: false,
columns: [ columns: [
{name: 'event', align: 'left', label: 'Event', field: 'event'}, {
name: 'event',
align: 'left',
label: 'Event',
field: row => this.shortenId(row.event)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'}, {name: 'email', align: 'left', label: 'Email', field: 'email'},
{ {
@ -83,6 +63,12 @@ window.PageEvents = {
label: 'Registered', label: 'Registered',
field: 'registered' field: 'registered'
}, },
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{ {
name: 'promo_code', name: 'promo_code',
align: 'left', align: 'left',
@ -92,7 +78,11 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'} {name: 'id', align: 'left', label: 'ID', field: 'id'}
], ],
pagination: { pagination: {
rowsPerPage: 10 sortBy: 'time',
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
} }
}, },
formDialog: { formDialog: {
@ -102,28 +92,198 @@ window.PageEvents = {
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
extra: { extra: {
ticket_waves: [],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }
} }
},
ticketWaveDialog: {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
},
promoCodesDialog: {
show: false,
data: {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
} }
} }
}, },
methods: { methods: {
shortenId(value) {
if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value
},
primaryTicketWave(data = this.formDialog.data) {
if (!data.extra) data.extra = {}
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
data.extra.ticket_waves = [
{
id: 'primary',
title: 'Primary wave',
opening_date: data.closing_date || '',
closing_date: data.closing_date || '',
currency: data.currency || 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: Boolean(data.allow_fiat),
fiat_currency: data.fiat_currency || 'GBP',
amount_tickets: data.amount_tickets || 0,
price_per_ticket: data.price_per_ticket || 0
}
]
}
return data.extra.ticket_waves[0]
},
syncPrimaryWaveFromForm(data = this.formDialog.data) {
const primaryWave = this.primaryTicketWave(data)
primaryWave.title = primaryWave.title || 'Primary wave'
primaryWave.opening_date = primaryWave.opening_date || ''
primaryWave.closing_date = data.closing_date || ''
primaryWave.currency = data.currency || 'sats'
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
primaryWave.allow_fiat = Boolean(data.allow_fiat)
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
return primaryWave
},
hydrateEventForm(data) {
const formData = {
...data,
extra: {
...(data.extra || {}),
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
}
}
const primaryWave = this.primaryTicketWave(formData)
formData.currency = primaryWave.currency || formData.currency || 'sats'
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
formData.amount_tickets = primaryWave.amount_tickets
formData.price_per_ticket = primaryWave.price_per_ticket
formData.closing_date =
primaryWave.closing_date || formData.closing_date || ''
return formData
},
isFiatCurrency(currency) { isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase()) return !['sat', 'sats'].includes((currency || '').toLowerCase())
}, },
getTickets() { normalizePromoCodes(promoCodes = []) {
LNbits.api return promoCodes
.request( .filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase()
}))
},
templateDownloadUrl() {
return '/events/static/image/ticket.jpg'
},
async uploadAssetFile(file) {
const form = new FormData()
form.append('file', file)
form.append('public_asset', 'true')
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
form
)
return data.id
},
triggerTicketImageUpload(target) {
this.ticketImageUploadTarget = target
this.$refs.ticketImageUpload.value = null
this.$refs.ticketImageUpload.click()
},
async handleTicketImageSelected(event) {
const file = event.target.files?.[0]
if (!file || !this.ticketImageUploadTarget) return
this.isUploadingTicketTemplate = true
try {
const assetId = await this.uploadAssetFile(file)
if (this.ticketImageUploadTarget === 'primary') {
const wave = this.primaryTicketWave()
wave.use_ticket_image = true
wave.ticket_image_id = assetId
} else if (this.ticketImageUploadTarget === 'dialog') {
this.ticketWaveDialog.data.use_ticket_image = true
this.ticketWaveDialog.data.ticket_image_id = assetId
}
Quasar.Notify.create({
type: 'positive',
message: 'Ticket template uploaded.',
icon: null
})
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.isUploadingTicketTemplate = false
this.ticketImageUploadTarget = null
}
},
soldTicketsForWave(eventId, waveId) {
return this.allPaidTickets.filter(
ticket =>
ticket.event === eventId &&
ticket.paid &&
(ticket.extra?.ticket_wave_id === waveId ||
(!ticket.extra?.ticket_wave_id && waveId === 'primary'))
).length
},
async getAllTickets() {
try {
const {data} = await LNbits.api.request(
'GET', 'GET',
'/events/api/v1/tickets?all_wallets=true', '/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
.then(response => { this.allPaidTickets = data.filter(ticket => ticket.paid)
this.tickets = response.data.filter(e => e.paid) } catch (error) {
}) LNbits.utils.notifyApiError(error)
}
},
async getTickets(props) {
try {
this.ticketsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
this.g.user.wallets[0].adminkey
)
this.tickets = data.data
this.ticketsTable.pagination.rowsNumber = data.total
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.ticketsTable.loading = false
}
}, },
deleteTicket(ticketId) { deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId}) const tickets = _.findWhere(this.tickets, {id: ticketId})
@ -138,10 +298,9 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId, '/events/api/v1/tickets/' + ticketId,
wallet.adminkey wallet.adminkey
) )
.then(response => { .then(async () => {
this.tickets = _.reject(this.tickets, function (obj) { await this.getTickets()
return obj.id == ticketId await this.getAllTickets()
})
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}) })
@ -159,14 +318,30 @@ window.PageEvents = {
wallet.adminkey wallet.adminkey
) )
.then(response => { .then(response => {
const result = response.data
this.tickets = this.tickets.map(obj => this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj obj.id === ticket.id ? result.ticket : obj
) )
if (result.email?.attempted) {
Quasar.Notify.create({ Quasar.Notify.create({
type: 'positive', type: result.email.sent ? 'positive' : 'negative',
message: 'Ticket email resent.', message: result.email.sent
? 'Ticket email resent.'
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
icon: null icon: null
}) })
}
if (result.nostr?.attempted) {
Quasar.Notify.create({
type: result.nostr.sent ? 'positive' : 'negative',
message: result.nostr.sent
? 'Ticket Nostr DM resent.'
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
icon: null
})
}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
.finally(() => { .finally(() => {
@ -176,7 +351,7 @@ window.PageEvents = {
}) })
}, },
exportticketsCSV() { exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets) LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
}, },
getEvents() { getEvents() {
LNbits.api LNbits.api
@ -195,19 +370,18 @@ window.PageEvents = {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data const data = this.formDialog.data
this.syncPrimaryWaveFromForm(data)
if (data.extra?.promo_codes) { if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = this.normalizePromoCodes(
.filter(code => code.code?.trim() !== '') data.extra.promo_codes
.map(code => ({ )
...code,
code: code.code.trim().toUpperCase()
}))
} }
if (!this.isFiatCurrency(data.currency)) { if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) { if (!data.allow_fiat) {
data.fiat_currency = 'GBP' data.fiat_currency = 'GBP'
} }
} }
this.syncPrimaryWaveFromForm(data)
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -218,7 +392,7 @@ window.PageEvents = {
openEventDialog(data = false) { openEventDialog(data = false) {
if (data && data.id) { if (data && data.id) {
this.formDialog.data = {...data} this.formDialog.data = this.hydrateEventForm(data)
} else { } else {
this.formDialog.data = { this.formDialog.data = {
currency: 'sats', currency: 'sats',
@ -229,6 +403,21 @@ window.PageEvents = {
min_tickets: 1, min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
@ -244,8 +433,25 @@ window.PageEvents = {
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
extra: { extra: {
conditional: false,
min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
ticket_waves: [
{
id: 'primary',
title: 'Primary wave',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
],
promo_codes: [], promo_codes: [],
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
@ -266,6 +472,177 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId}) const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link) this.openEventDialog(link)
}, },
openTicketWaveDialog(event, wave = null) {
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
const isEditing = Boolean(wave)
this.ticketWaveDialog = {
show: true,
eventId: event.id,
wallet: event.wallet,
editingWaveId: wave?.id || null,
data: {
id: wave?.id || null,
title: wave?.title || '',
opening_date: wave?.opening_date || '',
closing_date: wave?.closing_date || '',
currency:
wave?.currency || primaryWave.currency || event.currency || 'sats',
use_ticket_image: Boolean(wave?.use_ticket_image),
ticket_image_id: wave?.ticket_image_id || null,
allow_fiat: isEditing
? Boolean(wave?.allow_fiat)
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
fiat_currency:
wave?.fiat_currency ||
primaryWave.fiat_currency ||
event.fiat_currency ||
'GBP',
amount_tickets: wave?.amount_tickets || 0,
price_per_ticket:
wave?.price_per_ticket ||
primaryWave.price_per_ticket ||
event.price_per_ticket ||
0
}
}
},
resetTicketWaveDialog() {
this.ticketWaveDialog = {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
}
},
saveTicketWave() {
const event = _.findWhere(this.events, {
id: this.ticketWaveDialog.eventId
})
const wallet = _.findWhere(this.g.user.wallets, {
id: this.ticketWaveDialog.wallet
})
if (!event || !wallet) return
const payload = {
...event,
extra: {
...event.extra,
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
existingWave.id === this.ticketWaveDialog.editingWaveId
? {...this.ticketWaveDialog.data}
: existingWave
)
}
}
if (!this.ticketWaveDialog.editingWaveId) {
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
}
if (payload.extra?.promo_codes) {
payload.extra.promo_codes = this.normalizePromoCodes(
payload.extra.promo_codes
)
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + payload.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(item =>
item.id === payload.id ? response.data : item
)
Quasar.Notify.create({
type: 'positive',
message: this.ticketWaveDialog.editingWaveId
? 'Ticket wave updated.'
: 'Ticket wave added.',
icon: null
})
this.resetTicketWaveDialog()
})
.catch(LNbits.utils.notifyApiError)
},
openPromoCodesDialog(event) {
this.promoCodesDialog.data = {
...event,
extra: {
...event.extra,
promo_codes: [...(event.extra?.promo_codes || [])]
}
}
this.promoCodesDialog.show = true
},
resetPromoCodesDialog() {
this.promoCodesDialog.show = false
this.promoCodesDialog.data = {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
},
addPromoCodeToDialog() {
this.promoCodesDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
},
savePromoCodes() {
const data = this.promoCodesDialog.data
const wallet = _.findWhere(this.g.user.wallets, {
id: data.wallet
})
if (!wallet) return
const payload = {
...data,
extra: {
...data.extra,
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
}
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + data.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(event =>
event.id === data.id ? response.data : event
)
Quasar.Notify.create({
type: 'positive',
message: 'Promo codes updated.',
icon: null
})
this.resetPromoCodesDialog()
})
.catch(LNbits.utils.notifyApiError)
},
updateEvent(wallet, data) { updateEvent(wallet, data) {
LNbits.api LNbits.api
.request( .request(
@ -332,6 +709,7 @@ window.PageEvents = {
async created() { async created() {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getTickets() this.getTickets()
this.getAllTickets()
this.getEvents() this.getEvents()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) { if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
this.currencies = ['sats', ...this.g.allowedCurrencies] this.currencies = ['sats', ...this.g.allowedCurrencies]

View file

@ -100,44 +100,101 @@
<q-tr v-show="props.expand" :props="props"> <q-tr v-show="props.expand" :props="props">
<q-td colspan="100%"> <q-td colspan="100%">
<div class="q-pa-md"> <div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div> <div class="row items-center q-mb-md">
<div class="text-subtitle1">Ticket waves</div>
<q-btn
round
dense
unelevated
color="primary"
icon="add"
class="q-ml-sm"
@click="openTicketWaveDialog(props.row)"
></q-btn>
</div>
<div class="column q-mb-lg">
<div class="column"> <div class="column">
<div <div
v-if="props.row.extra.promo_codes.length == 0" v-for="(wave, index) in props.row.extra.ticket_waves"
:key="wave.id || index"
class="q-mb-sm"
>
<q-chip
square
clickable
class="q-py-xs"
style="height: auto"
@click="openTicketWaveDialog(props.row, wave)"
>
<span
style="white-space: normal; line-height: 1.3"
v-text="
`${wave.title} - ${wave.opening_date} to ${
wave.closing_date
} - ${
isFiatCurrency(wave.currency)
? LNbits.utils.formatCurrency(
Number(
wave.price_per_ticket || 0
).toFixed(2),
wave.currency
)
: `${wave.price_per_ticket} sats`
} - ${wave.amount_tickets} tickets - ${soldTicketsForWave(
props.row.id,
wave.id
)} sold`
"
></span>
</q-chip>
</div>
</div>
</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Promo codes</div>
<q-btn
round
dense
unelevated
color="primary"
icon="edit"
class="q-ml-sm"
@click="openPromoCodesDialog(props.row)"
></q-btn>
</div>
<div class="column">
<div
v-if="
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
class="text-caption" class="text-caption"
>
No active promo codes for this event.
</div>
<div class="row q-col-gutter-sm">
<div
v-for="(
code, index
) in props.row.extra.promo_codes.filter(
code => code.active
)"
:key="index"
class="col-auto q-mb-sm"
> >
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip <q-chip
square square
size="md"
clickable clickable
@click="utils.copyText(code.code.toUpperCase())" @click="utils.copyText(code.code.toUpperCase())"
> >
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span <span
:class="code.active ? 'text-green' : 'text-grey'" v-text="
v-text="code.active ? 'Active' : 'Inactive'" `${code.code.toUpperCase()} - ${code.discount_percent}%`
"
></span> ></span>
</q-chip>
</div> </div>
</div> </div>
</div> </div>
@ -165,9 +222,11 @@
dense dense
flat flat
:rows="tickets" :rows="tickets"
:loading="ticketsTable.loading"
row-key="id" row-key="id"
:columns="ticketsTable.columns" :columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination" v-model:pagination="ticketsTable.pagination"
@request="getTickets"
> >
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
@ -319,17 +378,6 @@
hint="Optional banner image to display on the event page" hint="Optional banner image to display on the event page"
></q-input> ></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-8">
<q-input <q-input
@ -340,7 +388,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4">Event ends</div> <div class="col-4">Event ends</div>
<div class="col-8"> <div class="col-8">
@ -352,6 +399,39 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mt-lg q-mb-md">
Primary ticket wave (you can add other waves later)
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -387,6 +467,43 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<q-toggle
v-model="primaryTicketWave().use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="primaryTicketWave().use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('primary')"
>Replace</q-btn
>
</div>
<div
v-if="primaryTicketWave().ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle <q-toggle
v-model="formDialog.data.allow_fiat" v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout" label="Allow fiat checkout"
@ -441,95 +558,30 @@
</div> </div>
</div> </div>
<q-separator class="q-my-md"></q-separator> <q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true
})
"
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div> <div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption"> <div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM. Send the paid ticket link automatically by email or Nostr DM.
</div> </div>
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle <q-toggle
v-model="formDialog.data.extra.email_notifications" v-model="formDialog.data.extra.email_notifications"
label="Email notifications" label="Email notifications"
left-label left-label
></q-toggle> ></q-toggle>
</div>
<div class="col-auto">
<q-toggle <q-toggle
v-model="formDialog.data.extra.nostr_notifications" v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications" label="Nostr notifications"
left-label left-label
></q-toggle> ></q-toggle>
</q-expansion-item> </div>
</div>
<q-separator class="q-my-md"></q-separator> <div
v-if="formDialog.data.extra.email_notifications"
class="q-mt-md"
>
<q-input <q-input
filled filled
dense dense
@ -539,6 +591,7 @@
hint="Used as the email subject when sending paid ticket links." hint="Used as the email subject when sending paid ticket links."
></q-input> ></q-input>
<q-input <q-input
class="q-mt-md"
filled filled
dense dense
v-model.trim="formDialog.data.extra.notification_body" v-model.trim="formDialog.data.extra.notification_body"
@ -546,6 +599,8 @@
label="Ticket notification body" label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification." hint="Shown before the ticket link in the paid ticket notification."
></q-input> ></q-input>
</div>
</q-expansion-item>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@ -564,6 +619,8 @@
formDialog.data.name == null || formDialog.data.name == null ||
formDialog.data.info == null || formDialog.data.info == null ||
formDialog.data.closing_date == null || formDialog.data.closing_date == null ||
primaryTicketWave().title == null ||
primaryTicketWave().opening_date == null ||
formDialog.data.event_start_date == null || formDialog.data.event_start_date == null ||
formDialog.data.event_end_date == null || formDialog.data.event_end_date == null ||
formDialog.data.amount_tickets == null || formDialog.data.amount_tickets == null ||
@ -579,5 +636,252 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="promoCodesDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="savePromoCodes" class="q-gutter-md">
<div class="text-subtitle1">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="
promoCodesDialog.data.extra.promo_codes[index].active
"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
promoCodesDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Save Promo Codes</q-btn
>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetPromoCodesDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="ticketWaveDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="saveTicketWave" class="q-gutter-md">
<div
class="text-subtitle1"
v-text="
ticketWaveDialog.editingWaveId
? 'Edit Ticket Wave'
: 'Add Ticket Wave'
"
></div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="ticketWaveDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.amount_tickets"
type="number"
label="Amount of tickets"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.price_per_ticket"
type="number"
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="ticketWaveDialog.data.currency == null"
></q-input>
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="ticketWaveDialog.data.use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('dialog')"
>Replace</q-btn
>
</div>
<div
v-if="ticketWaveDialog.data.ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using this wave currency."
></q-toggle>
<q-select
v-if="
ticketWaveDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(ticketWaveDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="ticketWaveDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">{{
ticketWaveDialog.editingWaveId
? 'Update Ticket Wave'
: 'Save Ticket Wave'
}}</q-btn>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetTicketWaveDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<input
ref="ticketImageUpload"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="handleTicketImageSelected"
/>
</div> </div>
</template> </template>

View file

@ -3,16 +3,36 @@ window.PageEventsTicket = {
data() { data() {
return { return {
ticketId: null, ticketId: null,
ticket: null ticket: null,
printMode: false,
qrSrc: ''
} }
}, },
methods: { methods: {
printWindow() { async printWindow() {
window.print() this.printMode = true
await this.$nextTick()
await this.waitForPrintAssets()
setTimeout(() => window.print(), 50)
},
async waitForPrintAssets() {
await this.$nextTick()
const img = document.querySelector('.ticket-print-qr')
if (!img) return
if (img.complete && img.naturalWidth > 0) return
await new Promise(resolve => {
const done = () => resolve()
img.addEventListener('load', done, {once: true})
img.addEventListener('error', done, {once: true})
setTimeout(done, 500)
})
} }
}, },
async created() { async created() {
this.ticketId = this.$route.params.id this.ticketId = this.$route.params.id
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
`ticket://${this.ticketId}`
)}`
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
@ -22,5 +42,8 @@ window.PageEventsTicket = {
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
window.addEventListener('afterprint', () => {
this.printMode = false
})
} }
} }

View file

@ -36,4 +36,53 @@
</q-card> </q-card>
</div> </div>
</div> </div>
<Teleport to="body">
<div class="ticket-print-sheet" v-if="printMode">
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
</div>
</Teleport>
</template> </template>
<style>
@media print {
@page {
size: auto;
margin: 0;
}
html {
font-size: 12px !important;
}
* {
color: black !important;
background: white !important;
box-shadow: none !important;
}
body > * {
display: none !important;
}
.ticket-print-sheet {
display: flex !important;
align-items: center;
justify-content: center;
width: 100vw;
min-height: 100vh;
margin: 0 !important;
padding: 0 !important;
background: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.ticket-print-qr {
display: block;
width: 320px;
height: 320px;
object-fit: contain;
}
}
</style>

View file

@ -1,8 +1,11 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
from pathlib import Path
from typing import Any from typing import Any
import pyqrcode # type: ignore[import-untyped]
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Depends, Depends,
@ -12,22 +15,27 @@ from fastapi import (
WebSocket, WebSocket,
WebSocketDisconnect, WebSocketDisconnect,
) )
from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.core.crud.assets import get_public_asset
from lnbits.core.crud.wallets import get_wallet from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request from lnbits.core.services import create_payment_request
from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.helpers import generate_filter_params_openapi
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis, get_fiat_rate_satoshis,
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from lnbits.utils.nostr import normalize_public_key from PIL import Image, ImageDraw
from .crud import ( from .crud import (
create_event, create_event,
@ -39,6 +47,7 @@ from .crud import (
get_events, get_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
get_tickets_paginated,
purge_unpaid_tickets, purge_unpaid_tickets,
update_event, update_event,
update_ticket, update_ticket,
@ -50,19 +59,53 @@ from .models import (
PublicEvent, PublicEvent,
PublicTicket, PublicTicket,
Ticket, Ticket,
TicketFilters,
TicketPaymentRequest, TicketPaymentRequest,
TicketResendResult,
ensure_ticket_waves,
get_active_ticket_waves,
) )
from .services import refund_tickets, resend_ticket_email_notification from .services import refund_tickets, resend_ticket_email_notification
from .tasks import deregister_payment_listener, register_payment_listener from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
tickets_api_router = APIRouter(prefix="/api/v1/tickets") tickets_api_router = APIRouter(prefix="/api/v1/tickets")
qr_api_router = APIRouter(prefix="/api/v1")
tickets_filters = parse_filters(TicketFilters)
def _is_fiat_currency(currency: str | None) -> bool: def _is_fiat_currency(currency: str | None) -> bool:
return str(currency or "").lower() not in {"sat", "sats"} return str(currency or "").lower() not in {"sat", "sats"}
def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image:
qr = pyqrcode.create(data)
matrix = qr.code
modules = len(matrix)
total_modules = modules + border * 2
box_size = max(1, size // total_modules)
img_size = total_modules * box_size
img = Image.new("RGBA", (img_size, img_size), "white")
draw = ImageDraw.Draw(img)
for y, row in enumerate(matrix):
for x, cell in enumerate(row):
if cell:
x0 = (x + border) * box_size
y0 = (y + border) * box_size
draw.rectangle(
[x0, y0, x0 + box_size - 1, y0 + box_size - 1],
fill="black",
)
if img_size != size:
img = img.resize((size, size), Image.Resampling.NEAREST)
return img
@events_api_router.get("") @events_api_router.get("")
async def api_events( async def api_events(
all_wallets: bool = Query(False), all_wallets: bool = Query(False),
@ -86,24 +129,29 @@ async def api_get_event(event_id: str) -> Event:
) )
await purge_unpaid_tickets(event_id) await purge_unpaid_tickets(event_id)
is_window_open = datetime.now(timezone.utc) < datetime.strptime( today = datetime.now(timezone.utc).date()
event.closing_date, "%Y-%m-%d" active_waves = get_active_ticket_waves(event, today)
).replace(tzinfo=timezone.utc) is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date()
is_min_tickets_met = ( is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True event.sold >= event.extra.min_tickets if event.extra.conditional else True
) )
if event.amount_tickets < 1: if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and not is_window_open: if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
event.canceled = True event.canceled = True
await update_event(event) await update_event(event)
await refund_tickets(event_id) await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open: if not active_waves:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed." status_code=HTTPStatus.GONE,
detail=(
"Ticket closing date has passed."
if is_sales_closed
else "No ticket wave is currently open."
),
) )
return event return event
@ -127,8 +175,17 @@ async def api_event_create(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your event." status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
) )
for k, v in data.dict().items(): event = Event(
setattr(event, k, v) **{
**event.dict(),
**data.dict(),
"id": event.id,
"wallet": event.wallet,
"time": event.time,
"sold": event.sold,
"canceled": event.canceled,
}
)
event = await update_event(event) event = await update_event(event)
else: else:
event = await create_event(data) event = await create_event(data)
@ -187,6 +244,31 @@ async def api_tickets(
return await get_tickets(wallet_ids) return await get_tickets(wallet_ids)
@tickets_api_router.get(
"/paginated",
summary="Get paginated list of tickets",
openapi_extra=generate_filter_params_openapi(TicketFilters),
response_model=Page[Ticket],
)
async def api_tickets_paginated(
all_wallets: bool = Query(False),
filters: Filters = Depends(tickets_filters),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Page[Ticket]:
wallet_ids = [key_info.wallet.id]
if all_wallets:
user = await get_user(key_info.wallet.user)
wallet_ids = user.wallet_ids if user else []
if not filters.sortby:
filters.sortby = "time"
if not filters.direction:
filters.direction = "desc"
return await get_tickets_paginated(wallet_ids, filters)
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket) @tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
async def api_get_ticket(ticket_id: str) -> Ticket: async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
@ -202,6 +284,71 @@ async def api_get_ticket(ticket_id: str) -> Ticket:
return ticket return ticket
@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
async def api_ticket_qr(ticket_id: str):
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
event = await get_event(ticket.event)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
waves = ensure_ticket_waves(event)
wave = next(
(wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
waves[0],
)
qr_img = make_qr_png(f"ticket://{ticket_id}", size=157)
output = BytesIO()
if not wave.use_ticket_image:
qr_img.save(output, format="PNG")
output.seek(0)
return StreamingResponse(
output,
media_type="image/png",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
background_bytes = None
if wave.ticket_image_id:
asset = await get_public_asset(wave.ticket_image_id)
if asset:
background_bytes = asset.data
if background_bytes:
ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
else:
default_template = (
Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
)
ticket_image = Image.open(default_template).convert("RGBA")
ticket_image.paste(qr_img, (122, 505))
ticket_image.save(output, format="PNG")
output.seek(0)
return StreamingResponse(
output,
media_type="image/png",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
@tickets_api_router.post("/{event_id}") @tickets_api_router.post("/{event_id}")
async def api_ticket_create( async def api_ticket_create(
event_id: str, data: CreateTicket, request: Request event_id: str, data: CreateTicket, request: Request
@ -215,7 +362,7 @@ async def api_ticket_create(
if event.canceled: if event.canceled:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
if event.amount_tickets > 0 and event.sold >= event.amount_tickets: if event.amount_tickets < 1:
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.") raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
name = data.name name = data.name
@ -230,14 +377,36 @@ async def api_ticket_create(
detail="Unsupported payment method.", detail="Unsupported payment method.",
) )
if nostr_identifier and "@" not in nostr_identifier: if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid Nostr identifier.", detail="Only NIP-05 Nostr identifiers are supported.",
) from exc )
price = event.price_per_ticket active_waves = get_active_ticket_waves(event)
if not active_waves:
raise HTTPException(
status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
)
selected_wave = None
if data.ticket_wave_id:
selected_wave = next(
(wave for wave in active_waves if wave.id == data.ticket_wave_id),
None,
)
if not selected_wave:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid ticket wave selected.",
)
elif len(active_waves) == 1:
selected_wave = active_waves[0]
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Please select a ticket wave.",
)
price = selected_wave.price_per_ticket
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email} extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
if promo_code: if promo_code:
@ -249,31 +418,31 @@ async def api_ticket_create(
# get the promocode # get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
extra["promo_code"] = promo.code extra["promo_code"] = promo.code
price = event.price_per_ticket * (1 - promo.discount_percent / 100) price = selected_wave.price_per_ticket * (1 - promo.discount_percent / 100)
if payment_method == "fiat" and not event.allow_fiat: if payment_method == "fiat" and not selected_wave.allow_fiat:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Fiat payments are not enabled for this event.", detail="Fiat payments are not enabled for this event.",
) )
if _is_fiat_currency(event.currency): if _is_fiat_currency(selected_wave.currency):
extra["fiat"] = True extra["fiat"] = True
extra["currency"] = event.currency extra["currency"] = selected_wave.currency
extra["fiatAmount"] = price extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
if payment_method != "fiat": if payment_method != "fiat":
price = await fiat_amount_as_satoshis(price, event.currency) price = await fiat_amount_as_satoshis(price, selected_wave.currency)
invoice_unit = event.currency invoice_unit = selected_wave.currency
fiat_amount = price fiat_amount = price
fiat_provider = None fiat_provider = None
if payment_method == "fiat": if payment_method == "fiat":
if _is_fiat_currency(event.currency): if _is_fiat_currency(selected_wave.currency):
invoice_unit = event.currency invoice_unit = selected_wave.currency
else: else:
invoice_unit = event.fiat_currency invoice_unit = selected_wave.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit) fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True extra["fiat"] = True
extra["currency"] = invoice_unit extra["currency"] = invoice_unit
@ -314,6 +483,8 @@ async def api_ticket_create(
email=email, email=email,
extra={ extra={
"applied_promo_code": promo_code, "applied_promo_code": promo_code,
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address, "refund_address": refund_address,
"nostr_identifier": nostr_identifier, "nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"), "ticket_base_url": str(request.base_url).rstrip("/"),
@ -388,10 +559,12 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
@tickets_api_router.post("/{ticket_id}/resend-email") @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
async def api_ticket_resend_email( async def api_ticket_resend_email(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) ticket_id: str,
) -> Ticket: request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult:
ticket = await get_ticket(ticket_id) ticket = await get_ticket(ticket_id)
if not ticket: if not ticket:
raise HTTPException( raise HTTPException(
@ -408,16 +581,13 @@ async def api_ticket_resend_email(
) )
try: try:
return await resend_ticket_email_notification(ticket) return await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc ) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to resend ticket email.",
) from exc
@tickets_api_router.put("/register/{ticket_id}") @tickets_api_router.put("/register/{ticket_id}")