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:
parent
4bf867eef0
commit
777c107c63
12 changed files with 1557 additions and 302 deletions
|
|
@ -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
45
crud.py
|
|
@ -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
117
models.py
|
|
@ -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
|
||||||
|
]
|
||||||
|
|
|
||||||
241
services.py
241
services.py
|
|
@ -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,7 +39,16 @@ 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
|
||||||
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)
|
await update_event(event)
|
||||||
|
|
||||||
return ticket
|
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.")
|
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}"
|
||||||
|
|
||||||
|
|
||||||
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
|
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
if "@" in identifier:
|
ticket_image_url = _ticket_image_url(ticket, event)
|
||||||
await send_user_notification(
|
if not ticket_image_url:
|
||||||
UserNotifications(nostr_identifier=identifier),
|
return base_message
|
||||||
message,
|
|
||||||
"text_message",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
|
return f"{base_message}\n\nTicket image: {ticket_image_url}"
|
||||||
public_key = normalize_public_key(identifier)
|
|
||||||
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
|
|
||||||
|
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:
|
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
BIN
static/image/ticket.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
Quasar.Notify.create({
|
|
||||||
type: 'positive',
|
if (result.email?.attempted) {
|
||||||
message: 'Ticket email resent.',
|
Quasar.Notify.create({
|
||||||
icon: null
|
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)
|
.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]
|
||||||
|
|
|
||||||
|
|
@ -100,45 +100,102 @@
|
||||||
<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
|
||||||
|
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 class="column">
|
||||||
<div
|
<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"
|
class="text-caption"
|
||||||
>
|
>
|
||||||
No promo codes for this event.
|
No active promo codes for this event.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="row q-col-gutter-sm">
|
||||||
v-for="(code, index) in props.row.extra.promo_codes"
|
<div
|
||||||
:key="index"
|
v-for="(
|
||||||
class="row items-center q-col-gutter-sm q-mb-sm"
|
code, index
|
||||||
>
|
) in props.row.extra.promo_codes.filter(
|
||||||
<div class="col-auto">
|
code => code.active
|
||||||
|
)"
|
||||||
|
:key="index"
|
||||||
|
class="col-auto q-mb-sm"
|
||||||
|
>
|
||||||
<q-chip
|
<q-chip
|
||||||
square
|
square
|
||||||
size="md"
|
|
||||||
clickable
|
clickable
|
||||||
@click="utils.copyText(code.code.toUpperCase())"
|
@click="utils.copyText(code.code.toUpperCase())"
|
||||||
>
|
>
|
||||||
<q-avatar
|
<span
|
||||||
icon="bookmark"
|
v-text="
|
||||||
:color="code.active ? 'green' : 'grey'"
|
`${code.code.toUpperCase()} - ${code.discount_percent}%`
|
||||||
text-color="white"
|
"
|
||||||
></q-avatar>
|
></span>
|
||||||
<span v-text="code.code.toUpperCase()"></span>
|
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</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>
|
</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,112 +558,50 @@
|
||||||
</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>
|
||||||
<q-toggle
|
<div class="row items-center q-col-gutter-md">
|
||||||
v-model="formDialog.data.extra.email_notifications"
|
<div class="col-auto">
|
||||||
label="Email notifications"
|
<q-toggle
|
||||||
left-label
|
v-model="formDialog.data.extra.email_notifications"
|
||||||
></q-toggle>
|
label="Email notifications"
|
||||||
<q-toggle
|
left-label
|
||||||
v-model="formDialog.data.extra.nostr_notifications"
|
></q-toggle>
|
||||||
label="Nostr notifications"
|
</div>
|
||||||
left-label
|
<div class="col-auto">
|
||||||
></q-toggle>
|
<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-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">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
v-if="formDialog.data.id"
|
v-if="formDialog.data.id"
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
240
views_api.py
240
views_api.py
|
|
@ -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:
|
raise HTTPException(
|
||||||
nostr_identifier = normalize_public_key(nostr_identifier)
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
except Exception as exc:
|
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(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="Invalid Nostr identifier.",
|
detail="Invalid ticket wave selected.",
|
||||||
) from exc
|
)
|
||||||
price = event.price_per_ticket
|
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}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue