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 .tasks import wait_for_paid_invoices
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.include_router(events_generic_router)
events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router)
events_static_files = [
{

45
crud.py
View file

@ -1,9 +1,17 @@
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 .models import CreateEvent, Event, Ticket, TicketExtra
from .models import (
CreateEvent,
Event,
Ticket,
TicketExtra,
TicketFilters,
sync_event_ticket_waves,
)
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:
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:
event_id = urlsafe_short_hash()
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)
return event
async def update_event(event: Event) -> Event:
event = cast(Event, sync_event_ticket_waves(event))
await db.update("events.events", event)
return event
async def get_event(event_id: str) -> Event | None:
return await db.fetchone(
event = await db.fetchone(
"SELECT * FROM events.events WHERE id = :id",
{"id": event_id},
Event,
)
return cast(Event, sync_event_ticket_waves(event)) if event else None
async def get_events(wallet_ids: str | list[str]) -> list[Event]:
if isinstance(wallet_ids, str):
wallet_ids = [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})",
model=Event,
)
return [cast(Event, sync_event_ticket_waves(event)) for event in events]
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 lnbits.db import FilterModel
from pydantic import BaseModel, EmailStr, Field, validator
@ -20,8 +22,23 @@ class PromoCode(BaseModel):
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):
promo_codes: list[PromoCode] = Field(default_factory=list)
ticket_waves: list[TicketWave] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
email_notifications: bool = False
@ -84,6 +101,8 @@ class PublicEvent(BaseModel):
class TicketExtra(BaseModel):
applied_promo_code: str | None = None
ticket_wave_id: str | None = None
ticket_wave_title: str | None = None
sats_paid: int | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -96,6 +115,7 @@ class TicketExtra(BaseModel):
class CreateTicket(BaseModel):
name: str
email: EmailStr
ticket_wave_id: str | None = None
promo_code: str | None = None
refund_address: str | None = None
nostr_identifier: str | None = None
@ -116,6 +136,22 @@ class Ticket(BaseModel):
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):
event: str
name: str
@ -131,3 +167,82 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None
fiat_provider: str | None = None
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
import smtplib
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.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import (
send_email_notification,
send_user_notification,
)
from lnbits.core.services.notifications import send_user_notification
from lnbits.helpers import is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
from lnurl import execute
from loguru import logger
@ -20,13 +20,13 @@ from .crud import (
update_event,
update_ticket,
)
from .models import Event, Ticket
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.band",
]
from .models import (
Event,
NotificationDeliveryResult,
Ticket,
TicketResendResult,
ensure_ticket_waves,
)
async def set_ticket_paid(ticket: Ticket) -> Ticket:
@ -39,7 +39,16 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
event = await get_event(ticket.event)
assert event, "Couldn't get event from ticket being paid"
event.sold += 1
event.amount_tickets -= 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
await update_event(event)
return ticket
@ -55,40 +64,12 @@ async def _send_ticket_notification(ticket: Ticket) -> None:
logger.warning(f"Event {ticket.event} not found for ticket notification.")
return
subject, message = _ticket_notification_message(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)
await _deliver_ticket_notifications(ticket, event)
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)
if not event:
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.")
if not ticket.email:
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)
await send_email_notification([ticket.email], message, subject)
ticket.extra.email_notification_sent = True
return await update_ticket(ticket)
return await _deliver_ticket_notifications(ticket, event)
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}"
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
if "@" in identifier:
await send_user_notification(
UserNotifications(nostr_identifier=identifier),
message,
"text_message",
)
return
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
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
public_key = normalize_public_key(identifier)
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
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:
await send_user_notification(
UserNotifications(nostr_identifier=identifier),
message,
"text_message",
)
async def _send_ticket_email_notification(
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:
@ -136,6 +242,19 @@ def _ticket_url(ticket: Ticket) -> str:
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):
"""
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: '',
refund: '',
nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning'
}
},
@ -40,16 +41,37 @@ window.PageEventsDisplay = {
formatDescription() {
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() {
return Boolean(this.event?.allow_fiat)
return Boolean(this.selectedTicketWave?.allow_fiat)
},
fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes(
(this.event?.currency || '').toLowerCase()
(this.selectedTicketWave?.currency || '').toLowerCase()
)
? this.event?.fiat_currency
: this.event?.currency
? this.selectedTicketWave?.fiat_currency
: this.selectedTicketWave?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})`
},
allowEmailNotifications() {
@ -66,6 +88,16 @@ window.PageEventsDisplay = {
'GET',
`/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
} catch (error) {
this.eventErrorLabel = 'Event unavailable.'
@ -78,6 +110,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
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'
},
@ -112,6 +148,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
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'
Quasar.Notify.create({
type: 'positive',
@ -141,10 +181,13 @@ window.PageEventsDisplay = {
{
name: this.formDialog.data.name,
email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || 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)
@ -178,7 +221,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}`
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.search = ''
url.hash = ''
@ -187,7 +230,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => {
const data = JSON.parse(event.data)
if (data.pending === false) {
if (data.paid === true) {
this.paymentSuccess(paymentHash)
ws.close()
}

View file

@ -49,7 +49,7 @@
filled
dense
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."
></q-input>
</div>
@ -64,6 +64,21 @@
lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></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 v-if="allowFiatCheckout" class="col-auto">
<q-option-group
@ -94,6 +109,7 @@
:disable="
formDialog.data.name == '' ||
formDialog.data.email == '' ||
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
Boolean(paymentReq)
"
type="submit"

View file

@ -4,11 +4,19 @@ window.PageEvents = {
return {
events: [],
tickets: [],
allPaidTickets: [],
resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [],
eventsTable: {
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: 'event_start_date',
@ -22,12 +30,6 @@ window.PageEvents = {
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
@ -38,43 +40,21 @@ window.PageEvents = {
}
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: {
rowsPerPage: 10
}
},
ticketsTable: {
loading: false,
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: 'email', align: 'left', label: 'Email', field: 'email'},
{
@ -83,6 +63,12 @@ window.PageEvents = {
label: 'Registered',
field: 'registered'
},
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{
name: 'promo_code',
align: 'left',
@ -92,7 +78,11 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
sortBy: 'time',
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
}
},
formDialog: {
@ -102,28 +92,198 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
ticket_waves: [],
promo_codes: [],
notification_subject: '',
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: {
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) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
getTickets() {
LNbits.api
.request(
normalizePromoCodes(promoCodes = []) {
return promoCodes
.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',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(response => {
this.tickets = response.data.filter(e => e.paid)
})
this.allPaidTickets = data.filter(ticket => ticket.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) {
const tickets = _.findWhere(this.tickets, {id: ticketId})
@ -138,10 +298,9 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId,
wallet.adminkey
)
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
.then(async () => {
await this.getTickets()
await this.getAllTickets()
})
.catch(LNbits.utils.notifyApiError)
})
@ -159,14 +318,30 @@ window.PageEvents = {
wallet.adminkey
)
.then(response => {
const result = response.data
this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj
obj.id === ticket.id ? result.ticket : obj
)
Quasar.Notify.create({
type: 'positive',
message: 'Ticket email resent.',
icon: null
})
if (result.email?.attempted) {
Quasar.Notify.create({
type: result.email.sent ? 'positive' : 'negative',
message: result.email.sent
? 'Ticket email resent.'
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
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)
.finally(() => {
@ -176,7 +351,7 @@ window.PageEvents = {
})
},
exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
},
getEvents() {
LNbits.api
@ -195,19 +370,18 @@ window.PageEvents = {
id: this.formDialog.data.wallet
})
const data = this.formDialog.data
this.syncPrimaryWaveFromForm(data)
if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase()
}))
data.extra.promo_codes = this.normalizePromoCodes(
data.extra.promo_codes
)
}
if (!this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
}
this.syncPrimaryWaveFromForm(data)
if (data.id) {
this.updateEvent(wallet, data)
@ -218,7 +392,7 @@ window.PageEvents = {
openEventDialog(data = false) {
if (data && data.id) {
this.formDialog.data = {...data}
this.formDialog.data = this.hydrateEventForm(data)
} else {
this.formDialog.data = {
currency: 'sats',
@ -229,6 +403,21 @@ window.PageEvents = {
min_tickets: 1,
email_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: [],
notification_subject: '',
notification_body: ''
@ -244,8 +433,25 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
conditional: false,
min_tickets: 1,
email_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: [],
notification_subject: '',
notification_body: ''
@ -266,6 +472,177 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId})
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) {
LNbits.api
.request(
@ -332,6 +709,7 @@ window.PageEvents = {
async created() {
if (this.g.user.wallets.length) {
this.getTickets()
this.getAllTickets()
this.getEvents()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
this.currencies = ['sats', ...this.g.allowedCurrencies]

View file

@ -100,45 +100,102 @@
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<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
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.length == 0"
v-if="
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
class="text-caption"
>
No promo codes for this event.
No active 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">
<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"
>
<q-chip
square
size="md"
clickable
@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>
<span
v-text="
`${code.code.toUpperCase()} - ${code.discount_percent}%`
"
></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
@ -165,9 +222,11 @@
dense
flat
:rows="tickets"
:loading="ticketsTable.loading"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
@request="getTickets"
>
<template v-slot:header="props">
<q-tr :props="props">
@ -319,17 +378,6 @@
hint="Optional banner image to display on the event page"
></q-input>
<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-8">
<q-input
@ -340,7 +388,6 @@
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event ends</div>
<div class="col-8">
@ -352,6 +399,39 @@
></q-input>
</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="col">
<q-select
@ -387,6 +467,43 @@
></q-input>
</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
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
@ -441,112 +558,50 @@
</div>
</div>
<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-caption">
Send the paid ticket link automatically by email or Nostr DM.
</div>
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
</div>
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
</div>
</div>
<div
v-if="formDialog.data.extra.email_notifications"
class="q-mt-md"
>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
class="q-mt-md"
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
</div>
</q-expansion-item>
<q-separator class="q-my-md"></q-separator>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
@ -564,6 +619,8 @@
formDialog.data.name == null ||
formDialog.data.info == null ||
formDialog.data.closing_date == null ||
primaryTicketWave().title == null ||
primaryTicketWave().opening_date == null ||
formDialog.data.event_start_date == null ||
formDialog.data.event_end_date == null ||
formDialog.data.amount_tickets == null ||
@ -579,5 +636,252 @@
</q-form>
</q-card>
</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>
</template>

View file

@ -3,16 +3,36 @@ window.PageEventsTicket = {
data() {
return {
ticketId: null,
ticket: null
ticket: null,
printMode: false,
qrSrc: ''
}
},
methods: {
printWindow() {
window.print()
async printWindow() {
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() {
this.ticketId = this.$route.params.id
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
`ticket://${this.ticketId}`
)}`
try {
const {data} = await LNbits.api.request(
'GET',
@ -22,5 +42,8 @@ window.PageEventsTicket = {
} catch (error) {
LNbits.utils.notifyApiError(error)
}
window.addEventListener('afterprint', () => {
this.printMode = false
})
}
}

View file

@ -36,4 +36,53 @@
</q-card>
</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>
<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
from datetime import datetime, timezone
from http import HTTPStatus
from io import BytesIO
from pathlib import Path
from typing import Any
import pyqrcode # type: ignore[import-untyped]
from fastapi import (
APIRouter,
Depends,
@ -12,22 +15,27 @@ from fastapi import (
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import StreamingResponse
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.models import WalletTypeInfo
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.db import Filters, Page
from lnbits.decorators import (
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
get_fiat_rate_satoshis,
satoshis_amount_as_fiat,
)
from lnbits.utils.nostr import normalize_public_key
from PIL import Image, ImageDraw
from .crud import (
create_event,
@ -39,6 +47,7 @@ from .crud import (
get_events,
get_ticket,
get_tickets,
get_tickets_paginated,
purge_unpaid_tickets,
update_event,
update_ticket,
@ -50,19 +59,53 @@ from .models import (
PublicEvent,
PublicTicket,
Ticket,
TicketFilters,
TicketPaymentRequest,
TicketResendResult,
ensure_ticket_waves,
get_active_ticket_waves,
)
from .services import refund_tickets, resend_ticket_email_notification
from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events")
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:
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("")
async def api_events(
all_wallets: bool = Query(False),
@ -86,24 +129,29 @@ async def api_get_event(event_id: str) -> Event:
)
await purge_unpaid_tickets(event_id)
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
event.closing_date, "%Y-%m-%d"
).replace(tzinfo=timezone.utc)
today = datetime.now(timezone.utc).date()
active_waves = get_active_ticket_waves(event, today)
is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date()
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1:
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
await update_event(event)
await refund_tickets(event_id)
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
if not is_window_open:
if not active_waves:
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
@ -127,8 +175,17 @@ async def api_event_create(
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
)
for k, v in data.dict().items():
setattr(event, k, v)
event = Event(
**{
**event.dict(),
**data.dict(),
"id": event.id,
"wallet": event.wallet,
"time": event.time,
"sold": event.sold,
"canceled": event.canceled,
}
)
event = await update_event(event)
else:
event = await create_event(data)
@ -187,6 +244,31 @@ async def api_tickets(
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)
async def api_get_ticket(ticket_id: str) -> Ticket:
ticket = await get_ticket(ticket_id)
@ -202,6 +284,71 @@ async def api_get_ticket(ticket_id: str) -> 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}")
async def api_ticket_create(
event_id: str, data: CreateTicket, request: Request
@ -215,7 +362,7 @@ async def api_ticket_create(
if event.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.")
name = data.name
@ -230,14 +377,36 @@ async def api_ticket_create(
detail="Unsupported payment method.",
)
if nostr_identifier and "@" not in nostr_identifier:
try:
nostr_identifier = normalize_public_key(nostr_identifier)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only NIP-05 Nostr identifiers are supported.",
)
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 Nostr identifier.",
) from exc
price = event.price_per_ticket
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}
if promo_code:
@ -249,31 +418,31 @@ async def api_ticket_create(
# get the promocode
promo = next(pc for pc in event.extra.promo_codes if pc.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(
status_code=HTTPStatus.BAD_REQUEST,
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["currency"] = event.currency
extra["currency"] = selected_wave.currency
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":
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_provider = None
if payment_method == "fiat":
if _is_fiat_currency(event.currency):
invoice_unit = event.currency
if _is_fiat_currency(selected_wave.currency):
invoice_unit = selected_wave.currency
else:
invoice_unit = event.fiat_currency
invoice_unit = selected_wave.fiat_currency
fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
extra["fiat"] = True
extra["currency"] = invoice_unit
@ -314,6 +483,8 @@ async def api_ticket_create(
email=email,
extra={
"applied_promo_code": promo_code,
"ticket_wave_id": selected_wave.id,
"ticket_wave_title": selected_wave.title,
"refund_address": refund_address,
"nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"),
@ -388,10 +559,12 @@ async def api_ticket_delete(
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(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Ticket:
ticket_id: str,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> TicketResendResult:
ticket = await get_ticket(ticket_id)
if not ticket:
raise HTTPException(
@ -408,16 +581,13 @@ async def api_ticket_resend_email(
)
try:
return await resend_ticket_email_notification(ticket)
return await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(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}")