From 1cbf60190e993f47324f9731c09e626d781f0622 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Sat, 27 Jun 2026 13:44:37 +0100 Subject: [PATCH 01/66] feat: rewrite for ticket types, baskets, enhanced promos, SatsPay-only payments, admin email, bulk actions, and i18n --- __init__.py | 9 +- crud.py | 102 ++++- migrations.py | 94 +++++ models.py | 174 +++++--- services.py | 395 +++++++++++++++--- static/js/display.js | 428 ++++++++++--------- static/js/display.vue | 325 +++++++-------- static/js/i18n/de.js | 48 ++- static/js/i18n/en.js | 48 ++- static/js/i18n/es.js | 48 ++- static/js/index.js | 649 ++++++++++++++--------------- static/js/index.vue | 688 +++++++++++++++--------------- tasks.py | 31 -- views.py | 4 + views_api.py | 944 +++++++++++++++++------------------------- 15 files changed, 2173 insertions(+), 1814 deletions(-) diff --git a/__init__.py b/__init__.py index 827b8d5..c91748b 100644 --- a/__init__.py +++ b/__init__.py @@ -4,14 +4,12 @@ from loguru import logger from .crud import db -from .tasks import wait_for_paid_invoices from .views import events_generic_router -from .views_api import events_api_router, qr_api_router, tickets_api_router +from .views_api import events_api_router, qr_api_router events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"]) events_ext.include_router(events_generic_router) events_ext.include_router(events_api_router) -events_ext.include_router(tickets_api_router) events_ext.include_router(qr_api_router) events_static_files = [ @@ -33,10 +31,7 @@ def events_stop(): def events_start(): - from lnbits.tasks import create_permanent_unique_task - - task = create_permanent_unique_task("ext_events", wait_for_paid_invoices) - scheduled_tasks.append(task) + pass __all__ = ["db", "events_ext", "events_start", "events_static_files", "events_stop"] diff --git a/crud.py b/crud.py index 930915e..640ba63 100644 --- a/crud.py +++ b/crud.py @@ -5,19 +5,28 @@ from lnbits.helpers import urlsafe_short_hash from .models import ( + Basket, CreateEvent, Event, Ticket, TicketExtra, TicketFilters, - sync_event_ticket_waves, + TicketType, + sync_event_from_ticket_types, ) db = Database("ext_events") async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict + payment_hash: str, + wallet: str, + event: str, + name: str, + email: str, + extra: dict, + ticket_type_id: str | None = None, + basket_id: str | None = None, ) -> Ticket: now = datetime.now(timezone.utc) ticket = Ticket( @@ -30,6 +39,8 @@ async def create_ticket( paid=False, reg_timestamp=now, time=now, + ticket_type_id=ticket_type_id, + basket_id=basket_id, extra=TicketExtra(**extra) if extra else TicketExtra(), ) await db.insert("events.ticket", ticket) @@ -108,13 +119,11 @@ async def purge_unpaid_tickets(event_id: str) -> None: async def create_event(data: CreateEvent) -> Event: event_id = urlsafe_short_hash() event = Event(id=event_id, time=datetime.now(timezone.utc), **data.dict()) - event = cast(Event, sync_event_ticket_waves(event)) await db.insert("events.events", event) return event async def update_event(event: Event) -> Event: - event = cast(Event, sync_event_ticket_waves(event)) await db.update("events.events", event) return event @@ -125,7 +134,10 @@ async def get_event(event_id: str) -> Event | None: {"id": event_id}, Event, ) - return cast(Event, sync_event_ticket_waves(event)) if event else None + if event: + ticket_types = await get_ticket_types(event_id) + event = cast(Event, sync_event_from_ticket_types(event, ticket_types)) + return event async def get_events(wallet_ids: str | list[str]) -> list[Event]: @@ -136,7 +148,11 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]: f"SELECT * FROM events.events WHERE wallet IN ({q})", model=Event, ) - return [cast(Event, sync_event_ticket_waves(event)) for event in events] + result = [] + for event in events: + ticket_types = await get_ticket_types(event.id) + result.append(cast(Event, sync_event_from_ticket_types(event, ticket_types))) + return result async def delete_event(event_id: str) -> None: @@ -149,3 +165,77 @@ async def get_event_tickets(event_id: str) -> list[Ticket]: {"event": event_id}, Ticket, ) + + +async def get_ticket_types(event_id: str) -> list[TicketType]: + return await db.fetchall( + "SELECT * FROM events.ticket_types WHERE event_id = :event_id" + " ORDER BY sort_order ASC", + {"event_id": event_id}, + TicketType, + ) + + +async def get_ticket_type(ticket_type_id: str) -> TicketType | None: + return await db.fetchone( + "SELECT * FROM events.ticket_types WHERE id = :id", + {"id": ticket_type_id}, + TicketType, + ) + + +async def create_ticket_type(data: TicketType) -> TicketType: + await db.insert("events.ticket_types", data) + return data + + +async def update_ticket_type(data: TicketType) -> TicketType: + await db.update("events.ticket_types", data) + return data + + +async def delete_ticket_type(ticket_type_id: str) -> None: + await db.execute( + "DELETE FROM events.ticket_types WHERE id = :id", {"id": ticket_type_id} + ) + + +async def delete_event_ticket_types(event_id: str) -> None: + await db.execute( + "DELETE FROM events.ticket_types WHERE event_id = :event_id", + {"event_id": event_id}, + ) + + +async def create_basket(data: Basket) -> Basket: + await db.insert("events.baskets", data) + return data + + +async def get_basket(basket_id: str) -> Basket | None: + return await db.fetchone( + "SELECT * FROM events.baskets WHERE id = :id", + {"id": basket_id}, + Basket, + ) + + +async def update_basket(basket: Basket) -> Basket: + await db.update("events.baskets", basket) + return basket + + +async def get_event_baskets(event_id: str) -> list[Basket]: + return await db.fetchall( + "SELECT * FROM events.baskets WHERE event_id = :event_id", + {"event_id": event_id}, + Basket, + ) + + +async def get_basket_tickets(basket_id: str) -> list[Ticket]: + return await db.fetchall( + "SELECT * FROM events.ticket WHERE basket_id = :basket_id", + {"basket_id": basket_id}, + Ticket, + ) diff --git a/migrations.py b/migrations.py index 512540d..900591f 100644 --- a/migrations.py +++ b/migrations.py @@ -195,3 +195,97 @@ async def m008_add_fiat_currency(db): ALTER TABLE events.events ADD COLUMN fiat_currency TEXT NOT NULL DEFAULT 'GBP'; """) + + +async def m009_ticket_types_and_baskets(db): + await db.execute( + """ + CREATE TABLE events.ticket_types ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + image_url TEXT, + price REAL NOT NULL DEFAULT 0, + max_tickets INTEGER NOT NULL DEFAULT 0, + sold INTEGER NOT NULL DEFAULT 0, + available_from TEXT NOT NULL, + available_to TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + extra TEXT + ); + """ + ) + + await db.execute( + """ + CREATE TABLE events.baskets ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + wallet TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NOT NULL, + promo_codes TEXT, + payment_method TEXT, + fiat_provider TEXT, + nostr_identifier TEXT, + refund_address TEXT, + satspay_charge_id TEXT, + paid BOOLEAN NOT NULL DEFAULT FALSE, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + await db.execute("ALTER TABLE events.events ADD COLUMN admin_email TEXT;") + await db.execute("ALTER TABLE events.ticket ADD COLUMN ticket_type_id TEXT;") + await db.execute("ALTER TABLE events.ticket ADD COLUMN basket_id TEXT;") + + import json + + events_rows = await db.fetchall("SELECT id, extra FROM events.events") + for row in events_rows: + event_id = row["id"] + extra_str = row["extra"] + if not extra_str: + continue + try: + extra = json.loads(extra_str) + except (json.JSONDecodeError, TypeError): + continue + ticket_waves = extra.get("ticket_waves", []) or [] + sort_order = 0 + for wave in ticket_waves: + await db.execute( + """ + INSERT INTO events.ticket_types ( + id, event_id, name, description, image_url, price, max_tickets, sold, + available_from, available_to, + sort_order, extra + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + wave.get("id", ""), + event_id, + wave.get("title", "General Admission"), + "", + wave.get("ticket_image_id"), + wave.get("price_per_ticket", 0), + wave.get("amount_tickets", 0), + 0, + wave.get("opening_date", ""), + wave.get("closing_date", ""), + sort_order, + "{}", + ), + ) + sort_order += 1 + + extra.pop("ticket_waves", None) + await db.execute( + "UPDATE events.events SET extra = ? WHERE id = ?", + (json.dumps(extra), event_id), + ) diff --git a/models.py b/models.py index ac0d6b5..1145bf7 100644 --- a/models.py +++ b/models.py @@ -8,47 +8,56 @@ class PromoCode(BaseModel): code: str - discount_percent: float = 0.0 + discount_percent: float | None = None + discount_fixed: int | None = None active: bool = True + combinable: bool = True + max_uses: int | None = None + used_count: int = 0 - # make the promo code uppercase @validator("code") def uppercase_code(cls, v): return v.upper() @validator("discount_percent") def validate_discount_percent(cls, v): - assert 0 <= v <= 100, "Discount must be between 0 and 100." + if v is not None: + assert 0 <= v <= 100, "Discount must be between 0 and 100." + return v + + @validator("discount_fixed") + def validate_discount_fixed(cls, v): + if v is not None: + assert v >= 0, "Fixed discount must be >= 0." return v -class TicketWave(BaseModel): +class TicketType(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex[:8]) - title: str = "Primary wave" - opening_date: str - closing_date: str + event_id: str + name: str = "General Admission" + description: str = "" + image_url: str | None = None + price: float = Field(default=0, ge=0) currency: str = "sat" - use_ticket_image: bool = False - ticket_image_id: str | None = None + max_tickets: int = Field(default=0, ge=0) + sold: int = 0 + available_from: str + available_to: str 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) + sort_order: int = 0 + extra: dict = Field(default_factory=dict) class EventExtra(BaseModel): promo_codes: list[PromoCode] = Field(default_factory=list) - ticket_waves: list[TicketWave] = Field(default_factory=list) conditional: bool = False min_tickets: int = 1 email_notifications: bool = False nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" - onchain_enabled: bool = False - onchain_wallet_id: str | None = None - onchain_zeroconf: bool = False - onchain_fasttrack: bool = False class CreateEvent(BaseModel): @@ -61,8 +70,9 @@ class CreateEvent(BaseModel): currency: str = "sat" allow_fiat: bool = False fiat_currency: str = "GBP" - amount_tickets: int = Query(..., ge=0) - price_per_ticket: float = Query(..., ge=0) + admin_email: str | None = None + amount_tickets: int = Query(default=0, ge=0) + price_per_ticket: float = Query(default=0, ge=0) banner: str | None = None extra: EventExtra = Field(default_factory=EventExtra) @@ -77,6 +87,7 @@ class Event(BaseModel): event_start_date: str event_end_date: str currency: str + admin_email: str | None = None allow_fiat: bool = False fiat_currency: str = "GBP" amount_tickets: int @@ -96,6 +107,7 @@ class PublicEvent(BaseModel): event_start_date: str event_end_date: str currency: str + admin_email: str | None = None allow_fiat: bool = False fiat_currency: str = "GBP" price_per_ticket: float @@ -107,6 +119,7 @@ class TicketExtra(BaseModel): applied_promo_code: str | None = None ticket_wave_id: str | None = None ticket_wave_title: str | None = None + ticket_type_id: str | None = None sats_paid: int | None = None refund_address: str | None = None nostr_identifier: str | None = None @@ -114,14 +127,13 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False - onchain: bool = False - onchain_address: str | None = None satspay_charge_id: str | None = None class CreateTicket(BaseModel): name: str email: EmailStr + ticket_type_id: str | None = None ticket_wave_id: str | None = None promo_code: str | None = None refund_address: str | None = None @@ -140,6 +152,8 @@ class Ticket(BaseModel): paid: bool time: datetime reg_timestamp: datetime + ticket_type_id: str | None = None + basket_id: str | None = None extra: TicketExtra = Field(default_factory=TicketExtra) @@ -176,11 +190,12 @@ class TicketPaymentRequest(BaseModel): is_fiat: bool = False onchain_amount_sat: int | None = None satspay_charge_url: str | None = None + basket_id: str | None = None class TicketFilters(FilterModel): - __search_fields__ = ["event", "name", "email", "id"] # noqa: RUF012 - __sort_fields__ = [ # noqa: RUF012 + __search_fields__ = ["event", "name", "email", "id"] + __sort_fields__ = [ "time", "event", "name", @@ -197,61 +212,88 @@ class TicketFilters(FilterModel): id: str | None = None -def _parse_date(value: str) -> date: - return datetime.strptime(value, "%Y-%m-%d").date() +class BasketItem(BaseModel): + ticket_type_id: str + quantity: int = 1 -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 +class CreateBasket(BaseModel): + name: str + email: EmailStr + items: list[BasketItem] + promo_codes: list[str] = Field(default_factory=list) + payment_method: str | None = None + fiat_provider: str | None = None + nostr_identifier: str | None = None + refund_address: str | None = None - 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, - ) - ] +class BasketDiscount(BaseModel): + code: str + discount_percent: float | None = None + discount_fixed: int | None = None + amount_saved: int = 0 -def sync_event_ticket_waves(event: Event | CreateEvent) -> Event | CreateEvent: - ticket_waves = ensure_ticket_waves(event) - event.extra.ticket_waves = ticket_waves +class BasketTotals(BaseModel): + subtotal: int = 0 + discount: int = 0 + total: int = 0 + discounts_applied: list[BasketDiscount] = Field(default_factory=list) - 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 +class Basket(BaseModel): + id: str + event_id: str + wallet: str + email: str + name: str + promo_codes: list[str] = Field(default_factory=list) + payment_method: str | None = None + fiat_provider: str | None = None + nostr_identifier: str | None = None + refund_address: str | None = None + satspay_charge_id: str | None = None + paid: bool = False + time: datetime + + +class BasketResponse(BaseModel): + basket: Basket + tickets: list[Ticket] + totals: BasketTotals + payment_request: TicketPaymentRequest | None = None -def get_active_ticket_waves( - event: Event | PublicEvent, today: date | None = None -) -> list[TicketWave]: +def _parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +def get_active_ticket_types( + ticket_types: list[TicketType], today: date | None = None +) -> list[TicketType]: current_day = today or datetime.utcnow().date() return [ - wave - for wave in ensure_ticket_waves(event) - if _parse_date(wave.opening_date) + tt + for tt in ticket_types + if _parse_date(tt.available_from) <= current_day - <= _parse_date(wave.closing_date) - and wave.amount_tickets > 0 + <= _parse_date(tt.available_to) + and (tt.max_tickets == 0 or tt.sold < tt.max_tickets) ] + + +def sync_event_from_ticket_types( + event: Event | CreateEvent, ticket_types: list[TicketType] +) -> Event | CreateEvent: + if not ticket_types: + return event + + event.amount_tickets = sum(tt.max_tickets for tt in ticket_types) + event.price_per_ticket = ticket_types[0].price + event.currency = ticket_types[0].currency + event.allow_fiat = any(tt.allow_fiat for tt in ticket_types) + event.fiat_currency = ticket_types[0].fiat_currency + event.closing_date = max(tt.available_to for tt in ticket_types) + + return event diff --git a/services.py b/services.py index fbfac2a..41d40ff 100644 --- a/services.py +++ b/services.py @@ -10,77 +10,335 @@ import httpx from lnbits.core.models.users import UserNotifications from lnbits.core.services.notifications import send_user_notification -from lnbits.helpers import is_valid_email_address +from lnbits.helpers import is_valid_email_address, urlsafe_short_hash from lnbits.settings import settings from lnurl import execute from loguru import logger from .crud import ( + create_basket, + create_ticket, + get_basket, + get_basket_tickets, get_event, get_event_tickets, + get_ticket, + get_ticket_type, + get_ticket_types, purge_unpaid_tickets, + update_basket, update_event, update_ticket, + update_ticket_type, ) from .models import ( + Basket, + BasketDiscount, + BasketTotals, + CreateBasket, Event, + EventExtra, NotificationDeliveryResult, Ticket, TicketResendResult, - ensure_ticket_waves, + TicketType, ) -async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: +async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge", headers={"X-API-KEY": api_key}, + json=data, ) resp.raise_for_status() return resp.json() -async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: +async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]: async with httpx.AsyncClient() as client: resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}", headers={"X-API-KEY": api_key}, - params={"network": network}, ) resp.raise_for_status() return resp.json() -async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet/{wallet_id}", - headers={"X-API-KEY": api_key}, +def _effective_discount(promo, price: int) -> int: + discount = 0 + if promo.discount_percent is not None: + discount = int(price * promo.discount_percent / 100) + if promo.discount_fixed is not None: + discount += promo.discount_fixed + return min(discount, price) + + +def _apply_promo_codes( + promos: list, price: int +) -> tuple[int, list[BasketDiscount]]: + discounts_applied: list[BasketDiscount] = [] + current_price = price + total_discount = 0 + + for promo in promos: + if not promo.active: + continue + if promo.max_uses is not None and promo.used_count >= promo.max_uses: + continue + discount_amount = _effective_discount(promo, current_price) + if discount_amount <= 0: + continue + total_discount += discount_amount + current_price -= discount_amount + discounts_applied.append( + BasketDiscount( + code=promo.code, + discount_percent=promo.discount_percent, + discount_fixed=promo.discount_fixed, + amount_saved=discount_amount, + ) ) - resp.raise_for_status() - return resp.json() + if not promo.combinable: + break + return total_discount, discounts_applied -async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}", - headers={"X-API-KEY": api_key}, + +async def _increment_promo_uses(event: Event, promo_codes: list[BasketDiscount]) -> None: + if not promo_codes: + return + applied_codes = {d.code for d in promo_codes} + modified = False + for promo in event.extra.promo_codes: + if promo.code in applied_codes: + promo.used_count += 1 + modified = True + if modified: + await update_event(event) + + +async def calculate_basket_total( + event: Event, items: list[dict], promo_codes: list[str] +) -> BasketTotals: + ticket_types = await get_ticket_types(event.id) + types_by_id = {tt.id: tt for tt in ticket_types} + + subtotal = 0 + for item in items: + tt = types_by_id.get(item["ticket_type_id"]) + if not tt: + continue + subtotal += int(tt.price) * item.get("quantity", 1) + + promos = [ + p + for p in event.extra.promo_codes + if p.code in [c.upper() for c in promo_codes] + ] + promos.sort( + key=lambda p: _effective_discount(p, subtotal), + reverse=True, + ) + + total_discount, discounts_applied = _apply_promo_codes(promos, subtotal) + + return BasketTotals( + subtotal=subtotal, + discount=total_discount, + total=max(0, subtotal - total_discount), + discounts_applied=discounts_applied, + ) + + +async def create_basket_with_charge( + event: Event, + data: CreateBasket, + wallet_inkey: str, + base_url: str, +) -> tuple[Basket, list[Ticket], BasketTotals, dict | None]: + today = __import__("datetime").datetime.utcnow().date() + from .models import _parse_date + + ticket_types = await get_ticket_types(event.id) + active_types = [ + tt for tt in ticket_types + if _parse_date(tt.available_from) <= today <= _parse_date(tt.available_to) + and (tt.max_tickets == 0 or tt.sold < tt.max_tickets) + ] + active_ids = {tt.id for tt in active_types} + + for item in data.items: + if item.ticket_type_id not in active_ids: + raise ValueError(f"Ticket type {item.ticket_type_id} is not available.") + tt = next(tt for tt in active_types if tt.id == item.ticket_type_id) + remaining = tt.max_tickets - tt.sold if tt.max_tickets > 0 else float("inf") + if item.quantity > remaining: + raise ValueError( + f"Only {remaining} tickets available for '{tt.name}'." + ) + + totals = await calculate_basket_total( + event, + [{"ticket_type_id": i.ticket_type_id, "quantity": i.quantity} for i in data.items], + data.promo_codes, + ) + + basket_id = urlsafe_short_hash() + promo_code_strings = [c.upper() for c in data.promo_codes] + + basket = Basket( + id=basket_id, + event_id=event.id, + wallet=event.wallet, + email=data.email, + name=data.name, + promo_codes=promo_code_strings, + payment_method=data.payment_method, + fiat_provider=data.fiat_provider, + nostr_identifier=data.nostr_identifier, + refund_address=data.refund_address, + ) + + charge = None + tickets: list[Ticket] = [] + + if totals.total > 0 and data.payment_method == "onchain": + internal_base = f"http://{settings.host}:{settings.port}" + webhook_url = f"{internal_base}/events/api/v1/baskets/webhook" + complete_url = f"{base_url}/events/basket/{basket_id}" + + charge = await create_satspay_charge( + api_key=wallet_inkey, + data={ + "amount": totals.total, + "description": f"Tickets for {event.name}", + "name": data.name, + "webhook": webhook_url, + "completelink": complete_url, + "completelinktext": "View your tickets", + "time": 1440, + }, ) - resp.raise_for_status() - return resp.json() + basket.satspay_charge_id = charge["id"] + + await create_basket_from_model(basket) + + for item in data.items: + tt = next(tt for tt in ticket_types if tt.id == item.ticket_type_id) + for _ in range(item.quantity): + ticket_id = urlsafe_short_hash() + ticket = await create_ticket( + payment_hash=ticket_id, + wallet=event.wallet, + event=event.id, + name=data.name, + email=data.email, + extra={ + "ticket_type_id": tt.id, + "ticket_wave_title": tt.name, + "refund_address": data.refund_address, + "nostr_identifier": data.nostr_identifier, + "ticket_base_url": base_url, + "sats_paid": totals.total, + }, + ticket_type_id=tt.id, + basket_id=basket_id, + ) + tickets.append(ticket) + if totals.total == 0: + await _activate_basket_tickets(basket, tickets, event) -async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.post( - url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge", - headers={"X-API-KEY": api_key}, - json=data, + return basket, tickets, totals, charge + + +async def create_basket_from_model(basket: Basket) -> Basket: + return await create_basket(basket) + + +async def _activate_basket_tickets( + basket: Basket, tickets: list[Ticket], event: Event +) -> None: + for ticket in tickets: + await set_ticket_paid(ticket) + basket.paid = True + await update_basket(basket) + await _increment_promo_uses_from_basket(event, basket) + for ticket in tickets: + send_ticket_notification_in_background(ticket) + if event.admin_email: + create_task(_send_admin_sale_notification(event, basket, tickets)) + + +async def handle_basket_payment( + basket_id: str, wallet_inkey: str +) -> dict | None: + basket = await get_basket(basket_id) + if not basket: + logger.warning(f"Basket {basket_id} not found.") + return None + if basket.paid: + logger.warning(f"Basket {basket_id} already paid.") + return None + if not basket.satspay_charge_id: + raise ValueError("Basket has no SatsPay charge.") + + charge = await get_satspay_charge(wallet_inkey, basket.satspay_charge_id) + if not charge.get("paid"): + logger.warning(f"Charge {basket.satspay_charge_id} not paid.") + return charge + + event = await get_event(basket.event_id) + if not event: + raise ValueError("Event not found for basket.") + + tickets = await get_basket_tickets(basket_id) + await _activate_basket_tickets(basket, tickets, event) + + return charge + + +async def _send_admin_sale_notification( + event: Event, basket: Basket, tickets: list[Ticket] +) -> None: + if not event.admin_email or not settings.lnbits_email_notifications_enabled: + return + + ticket_details = "\n".join( + f"- {t.name} ({t.email}) — {t.id}" for t in tickets + ) + message = ( + f"New ticket sale for '{event.name}'\n\n" + f"Basket: {basket.id}\n" + f"Buyer: {basket.name} ({basket.email})\n" + f"Tickets: {len(tickets)}\n\n" + f"{ticket_details}" + ) + subject = f"New sale: {len(tickets)} ticket(s) for '{event.name}'" + + try: + await _send_ticket_email_notification( + [event.admin_email], message, subject ) - resp.raise_for_status() - return resp.json() + except Exception as exc: + logger.warning(f"Failed to send admin notification: {exc}") + + +async def _increment_promo_uses_from_basket( + event: Event, basket: Basket +) -> None: + if not basket.promo_codes: + return + applied_codes = set(basket.promo_codes) + modified = False + for promo in event.extra.promo_codes: + if promo.code in applied_codes: + promo.used_count += 1 + modified = True + if modified: + await update_event(event) async def set_ticket_paid(ticket: Ticket) -> Ticket: @@ -93,21 +351,65 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: event = await get_event(ticket.event) assert event, "Couldn't get event from ticket being paid" event.sold += 1 - 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 + + if ticket.ticket_type_id: + ticket_type = await get_ticket_type(ticket.ticket_type_id) + if ticket_type: + ticket_type.sold += 1 + await update_ticket_type(ticket_type) + await update_event(event) return ticket +async def send_bulk_ticket_emails( + event_id: str, base_url: str +) -> list[TicketResendResult]: + tickets = await get_event_tickets(event_id) + results: list[TicketResendResult] = [] + for ticket in tickets: + if not ticket.paid: + continue + try: + result = await resend_ticket_email_notification( + ticket, base_url.rstrip("/") + ) + results.append(result) + except Exception as exc: + logger.warning(f"Failed to resend ticket {ticket.id}: {exc}") + results.append( + TicketResendResult( + ticket=ticket, + email=NotificationDeliveryResult( + attempted=True, sent=False, error=str(exc) + ), + ) + ) + return results + + +async def send_bulk_message( + event_id: str, subject: str, message: str +) -> list[dict]: + tickets = await get_event_tickets(event_id) + results: list[dict] = [] + for ticket in tickets: + if not ticket.email or not ticket.paid: + continue + try: + await _send_ticket_email_notification( + [ticket.email], message, subject + ) + results.append({"ticket_id": ticket.id, "sent": True}) + except Exception as exc: + logger.warning(f"Failed to send message for ticket {ticket.id}: {exc}") + results.append( + {"ticket_id": ticket.id, "sent": False, "error": str(exc)} + ) + return results + + def send_ticket_notification_in_background(ticket: Ticket) -> None: create_task(_send_ticket_notification(ticket)) @@ -297,24 +599,11 @@ def _ticket_url(ticket: Ticket) -> str: def _ticket_image_url(ticket: Ticket, event: Event) -> str | None: - waves = ensure_ticket_waves(event) - wave = next( - (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id), - waves[0], - ) - if not wave.use_ticket_image: - return None - base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/") return f"{base_url}/events/api/v1/qr/{ticket.id}" async def refund_tickets(event_id: str): - """ - Refund tickets for an event that has not met the minimum ticket requirement. - This function should be called when the event is closed and the minimum ticket - condition is not met. - """ await purge_unpaid_tickets(event_id) tickets = await get_event_tickets(event_id) diff --git a/static/js/display.js b/static/js/display.js index 427d460..6361a8a 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -1,161 +1,205 @@ -window.PageEventsDisplay = { +window.app = Vue.createApp({ template: '#page-events-display', data() { return { eventErrorLabel: '', event: null, - paymentReq: null, - redirectUrl: null, - formDialog: { - show: false, - data: { - name: '', - email: '', - refund: '', - nostr_identifier: '', - ticket_wave_id: null, - payment_method: 'lightning' - } - }, + ticketTypes: [], + itemQuantities: {}, + attendeeFields: {}, + copyDetailsToAll: {}, + promoCodeInput: '', + discountBreakdown: [], + basketTotal: 0, + basketItems: [], + submitting: false, + checkoutLoading: false, ticketLink: { show: false, data: { link: '' } - }, - receive: { - show: false, - status: 'pending', - paymentReq: null, - isFiat: false - }, - paymentDismissMsg: null, - paymentWebsocket: null + } } }, - async created() { + async mounted() { this.eventId = this.$route.params.id - this.event = await this.getEvent() + await this.loadEvent() + if (this.event) { + await this.loadTicketTypes() + } }, computed: { formatDescription() { return LNbits.utils.convertMarkdown(this.event?.info || '') }, - activeTicketWaves() { - const today = new Date().toISOString().slice(0, 10) - return (this.event?.extra?.ticket_waves || []).filter( - wave => - wave.amount_tickets > 0 && - wave.opening_date <= today && - wave.closing_date >= today - ) - }, - selectedTicketWave() { - return ( - this.activeTicketWaves.find( - wave => wave.id === this.formDialog.data.ticket_wave_id - ) || - this.activeTicketWaves[0] || - null - ) - }, - showTicketWaveSelector() { - return this.activeTicketWaves.length > 1 - }, - allowFiatCheckout() { - return Boolean(this.selectedTicketWave?.allow_fiat) + basket() { + return this.basketItems + .map(item => { + const tt = this.ticketTypes.find(t => t.id === item.ticketTypeId) + if (!tt) return null + return { + ticketTypeId: tt.id, + name: tt.name, + quantity: item.quantity, + price: tt.price, + currency: tt.currency, + subtotal: tt.price * item.quantity + } + }) + .filter(Boolean) }, - fiatCheckoutLabel() { - if (!this.allowFiatCheckout) return 'Fiat' - const unit = ['sat', 'sats'].includes( - (this.selectedTicketWave?.currency || '').toLowerCase() + canCheckout() { + if (this.submitting || this.checkoutLoading) return false + const hasItems = this.basketItems.some( + item => item.quantity > 0 ) - ? this.selectedTicketWave?.fiat_currency - : this.selectedTicketWave?.currency - return this.$t('events.payment_fiat', { - unit: (unit || 'GBP').toUpperCase() - }) + if (!hasItems) return false + for (const item of this.basketItems) { + if (item.quantity <= 0) continue + const attendees = this.attendeeFields[item.ticketTypeId] || [] + for (const a of attendees) { + if (!a.name || !a.email) return false + } + } + return true }, allowEmailNotifications() { return Boolean(this.event?.extra?.email_notifications) }, allowNostrNotifications() { return Boolean(this.event?.extra?.nostr_notifications) - }, - allowOnchain() { - return Boolean(this.event?.extra?.onchain_enabled) - }, - showPaymentMethodSelector() { - return this.allowFiatCheckout || this.allowOnchain - }, - paymentMethodOptions() { - const options = [ - {label: this.$t('events.payment_lightning'), value: 'lightning'} - ] - if (this.allowFiatCheckout) { - options.push({label: this.fiatCheckoutLabel, value: 'fiat'}) - } - if (this.allowOnchain) { - options.push({ - label: this.$t('events.payment_bitcoin'), - value: 'onchain' - }) - } - return options } }, methods: { - async getEvent() { + async loadEvent() { try { const {data} = await LNbits.api.request( 'GET', `/events/api/v1/events/${this.eventId}` ) - const activeWaves = (data.extra?.ticket_waves || []).filter(wave => { - const today = new Date().toISOString().slice(0, 10) - return ( - wave.amount_tickets > 0 && - wave.opening_date <= today && - wave.closing_date >= today - ) - }) - this.formDialog.data.ticket_wave_id = - activeWaves.length === 1 ? activeWaves[0].id : null - return data + this.event = data } catch (error) { this.eventErrorLabel = this.$t('events.event_unavailable') LNbits.utils.notifyApiError(error) } }, - resetForm(e) { - e.preventDefault() - this.formDialog.data.name = '' - this.formDialog.data.email = '' - this.formDialog.data.refund = '' - this.formDialog.data.nostr_identifier = '' - this.formDialog.data.ticket_wave_id = - this.activeTicketWaves.length === 1 - ? this.activeTicketWaves[0].id - : null - this.formDialog.data.payment_method = 'lightning' + async loadTicketTypes() { + try { + const {data} = await LNbits.api.request( + 'GET', + `/events/api/v1/ticket-types/${this.eventId}` + ) + const today = new Date().toISOString().slice(0, 10) + this.ticketTypes = data.filter( + tt => + tt.available_from <= today && + tt.available_to >= today && + (tt.max_tickets === 0 || tt.sold < tt.max_tickets) + ) + for (const tt of this.ticketTypes) { + this.itemQuantities[tt.id] = 0 + this.attendeeFields[tt.id] = [] + this.copyDetailsToAll[tt.id] = false + } + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + addToBasket(tt, qty) { + if (qty <= 0) return + const existing = this.basketItems.find( + item => item.ticketTypeId === tt.id + ) + if (existing) { + existing.quantity = qty + } else { + this.basketItems.push({ticketTypeId: tt.id, quantity: qty}) + } + this.itemQuantities[tt.id] = qty + this.rebuildBasket() }, - - closeReceiveDialog() { - if (this.paymentDismissMsg) { - this.paymentDismissMsg() - this.paymentDismissMsg = null + removeFromBasket(ttId) { + this.basketItems = this.basketItems.filter( + item => item.ticketTypeId !== ttId + ) + this.itemQuantities[ttId] = 0 + this.attendeeFields[ttId] = [] + this.rebuildBasket() + }, + updateQuantity(ttId, qty) { + if (qty <= 0) { + this.removeFromBasket(ttId) + return } - if (this.paymentWebsocket) { - this.paymentWebsocket.close() - this.paymentWebsocket = null + const existing = this.basketItems.find( + item => item.ticketTypeId === ttId + ) + if (existing) { + existing.quantity = qty + } else { + this.basketItems.push({ticketTypeId: ttId, quantity: qty}) } - this.paymentReq = null - this.receive = { - show: false, - status: 'pending', - paymentReq: null, - isFiat: false + this.rebuildBasket() + }, + expandAttendeeFor(ttId) { + const item = this.basketItems.find(i => i.ticketTypeId === ttId) + if (!item) return + const current = this.attendeeFields[ttId] || [] + while (current.length < item.quantity) { + current.push({name: '', email: ''}) + } + while (current.length > item.quantity) { + current.pop() } + this.attendeeFields[ttId] = current + }, + updateAttendeeName(ttId, idx, val) { + if (!this.attendeeFields[ttId]) return + this.attendeeFields[ttId][idx].name = val + if (this.copyDetailsToAll[ttId]) { + for (const a of this.attendeeFields[ttId]) { + a.name = val + } + } + }, + updateAttendeeEmail(ttId, idx, val) { + if (!this.attendeeFields[ttId]) return + this.attendeeFields[ttId][idx].email = val + if (this.copyDetailsToAll[ttId]) { + for (const a of this.attendeeFields[ttId]) { + a.email = val + } + } + }, + onCopyDetails(ttId) { + if (!this.copyDetailsToAll[ttId]) return + const attendees = this.attendeeFields[ttId] || [] + if (attendees.length === 0) return + const first = attendees[0] + for (let i = 1; i < attendees.length; i++) { + attendees[i].name = first.name + attendees[i].email = first.email + } + }, + rebuildBasket() { + this.basketItems = this.basketItems.filter( + item => item.quantity > 0 + ) + if (this.basketItems.length === 0) { + this.discountBreakdown = [] + this.basketTotal = 0 + return + } + let subtotal = 0 + for (const bi of this.basketItems) { + const tt = this.ticketTypes.find(t => t.id === bi.ticketTypeId) + if (tt) { + subtotal += tt.price * bi.quantity + } + } + this.basketTotal = subtotal + this.discountBreakdown = [] }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -165,120 +209,70 @@ window.PageEventsDisplay = { const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ return regex.test(val) || this.$t('events.email_validation') }, - paymentSuccess(paymentHash) { - if (this.paymentDismissMsg) { - this.paymentDismissMsg() - this.paymentDismissMsg = null - } - this.paymentReq = null - this.formDialog.data.name = '' - this.formDialog.data.email = '' - this.formDialog.data.refund = '' - this.formDialog.data.nostr_identifier = '' - this.formDialog.data.ticket_wave_id = - this.activeTicketWaves.length === 1 - ? this.activeTicketWaves[0].id - : null - this.formDialog.data.payment_method = 'lightning' - Quasar.Notify.create({ - type: 'positive', - message: this.$t('events.payment_sent'), - icon: null - }) - this.receive = { - show: false, - status: 'complete', - paymentReq: null, - isFiat: false - } - this.ticketLink = { - show: true, - data: { - link: `/events/ticket/${paymentHash}` - } - } - window.open(`/events/ticket/${paymentHash}`, '_blank', 'noopener') - }, - async createInvoice() { + async checkout() { + if (this.checkoutLoading || !this.canCheckout) return + this.checkoutLoading = true try { + const items = this.basketItems.map(bi => ({ + ticket_type_id: bi.ticketTypeId, + quantity: bi.quantity + })) + const primaryItem = this.basketItems[0] + const primaryAttendees = + this.attendeeFields[primaryItem.ticketTypeId] || [] + const primary = primaryAttendees[0] || {name: '', email: ''} + const promo_codes = this.promoCodeInput + .split(',') + .map(c => c.trim().toUpperCase()) + .filter(c => c.length > 0) + const refundAddr = (this.event?.extra?.conditional) + ? '' + : undefined + + const body = { + name: primary.name, + email: primary.email, + items, + promo_codes, + payment_method: null, + fiat_provider: null, + nostr_identifier: null, + refund_address: refundAddr || null + } + const {data} = await LNbits.api.request( 'POST', - `/events/api/v1/tickets/${this.eventId}`, + `/events/api/v1/baskets/${this.eventId}`, null, - { - name: this.formDialog.data.name, - email: this.formDialog.data.email, - ticket_wave_id: this.formDialog.data.ticket_wave_id || null, - promo_code: this.formDialog.data.promo_code || null, - refund_address: this.formDialog.data.refund || null, - nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.showPaymentMethodSelector - ? this.formDialog.data.payment_method - : 'lightning' - } + body ) - if (data.satspay_charge_url) { - window.location.href = data.satspay_charge_url + + if (data.payment_request?.satspay_charge_url) { + window.location.href = data.payment_request.satspay_charge_url return } - const isFiat = Boolean(data.is_fiat) - this.paymentReq = isFiat - ? data.fiat_payment_request || null - : data.payment_request - this.paymentHash = data.payment_hash - - this.paymentDismissMsg = Quasar.Notify.create({ - timeout: 0, - message: this.$t('events.waiting_for_payment') - }) - this.receive = { - show: true, - status: 'pending', - paymentReq: this.paymentReq, - isFiat - } - if (isFiat && this.paymentReq) { - window.open(this.paymentReq, '_blank', 'noopener') + const firstTicket = data.tickets?.[0] + if (data.basket && data.totals?.total === 0 && firstTicket) { + this.ticketLink.show = true + this.ticketLink.data.link = `/events/ticket/${firstTicket.id}` + } else if (firstTicket) { + this.ticketLink.show = true + this.ticketLink.data.link = `/events/ticket/${firstTicket.id}` } - this.paymentWatcher(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) - } - }, - paymentWatcher(paymentHash) { - if (this.paymentWebsocket) { - this.paymentWebsocket.close() - } - - const url = new URL(window.location) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/events/api/v1/tickets/ws/${paymentHash}` - url.search = '' - url.hash = '' - - const ws = new WebSocket(url.toString()) - this.paymentWebsocket = ws - - ws.onmessage = event => { - const data = JSON.parse(event.data) - if (data.paid === true) { - this.paymentSuccess(paymentHash) - ws.close() - } - } - ws.onerror = error => { - console.error('WebSocket error:', error) - } - ws.onclose = () => { - if (this.paymentWebsocket !== ws) return - this.paymentWebsocket = null - if (this.receive.show) { - setTimeout(() => { - if (this.receive.show) this.paymentWatcher(paymentHash) - }, 3000) - } + } finally { + this.checkoutLoading = false } } } +}) + +app.config.globalProperties.utils = { + copyText(text) { + navigator.clipboard.writeText(text) + } } + +app.mount('#page-events-display') diff --git a/static/js/display.vue b/static/js/display.vue index 17a271f..9fccc32 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -1,3 +1,4 @@ +# page-events-display From 0e60b3d47decf98e0925341d301b23e43f364031 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Wed, 8 Jul 2026 14:29:48 +0100 Subject: [PATCH 28/66] feat: add ticket deactivation toggle and promo code inline editing - Add deactivated field to TicketExtra model (defaults to False) - Add toggle_ticket_deactivation service function - Add PUT /api/v1/tickets/{ticket_id}/deactivate endpoint - Reject deactivated tickets at registration - Add deactivated column + toggle button to admin tickets table - Show deactivated badge on public ticket page - Add i18n keys (en/es/de) for deactivation UI - Promo code inline editing: inline add input, edit dialog, show all codes with strikethrough for inactive --- models.py | 1 + services.py | 6 ++ static/js/i18n/de.js | 12 ++- static/js/i18n/en.js | 12 ++- static/js/i18n/es.js | 13 ++- static/js/index.js | 188 ++++++++++++++++++++++++++++++++++++++++++- static/js/index.vue | 169 +++++++++++++++++++++++++++++++++----- static/js/ticket.vue | 6 ++ views_api.py | 19 +++++ 9 files changed, 395 insertions(+), 31 deletions(-) diff --git a/models.py b/models.py index 6976bf7..5909dc4 100644 --- a/models.py +++ b/models.py @@ -133,6 +133,7 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False + deactivated: bool = False satspay_charge_id: str | None = None diff --git a/services.py b/services.py index 18b58ac..21b320f 100644 --- a/services.py +++ b/services.py @@ -399,6 +399,12 @@ async def _increment_promo_uses_from_basket( await update_event(event) +async def toggle_ticket_deactivation(ticket: Ticket) -> Ticket: + ticket.extra.deactivated = not ticket.extra.deactivated + await update_ticket(ticket) + return ticket + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index b7a4f53..929b2af 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -5,7 +5,7 @@ window.i18n.global.mergeLocaleMessage('de', { export_csv: 'Als CSV exportieren', tickets_title: 'Tickets', promo_codes: 'Promo-Codes', - no_active_promo_codes: 'Keine aktiven Promo-Codes für diese Veranstaltung.', + no_promo_codes: 'Keine Promo-Codes für diese Veranstaltung.', resend_ticket_email: 'Ticket-E-Mail erneut senden', confirm_onchain_payment: 'Onchain-Zahlung bestätigen', extension_title: 'Events-Erweiterung', @@ -76,8 +76,10 @@ window.i18n.global.mergeLocaleMessage('de', { promo_code_label: 'Promo-Code', active: 'Aktiv', inactive: 'Inaktiv', - discount_label: 'Rabatt (%)', - add_promo_code: 'Promo-Code hinzufügen', + discount_label: 'Rabatt (%)', + add_promo_code: 'Promo-Code hinzufügen', + edit_promo_code: 'Promo-Code bearbeiten', + promo_code_exists: 'Dieser Promo-Code existiert bereits.', save_promo_codes: 'Promo-Codes speichern', email_resent: 'Ticket-E-Mail erneut gesendet.', email_resend_failed: 'Ticket-E-Mail fehlgeschlagen: {error}', @@ -100,6 +102,7 @@ window.i18n.global.mergeLocaleMessage('de', { col_event: 'Veranstaltung', col_registered: 'Registriert', col_nostr: 'Nostr', + col_deactivated: 'Deaktiviert', col_promo_code: 'Promo-Code', col_satspay: 'Zahlung', col_yes: 'Ja', @@ -148,6 +151,9 @@ window.i18n.global.mergeLocaleMessage('de', { 'Setzen Sie ein Lesezeichen, drucken oder erstellen Sie einen Screenshot dieser Seite und zeigen Sie ihn bei der Registrierung vor!', ticket_paid: 'Bezahlt', ticket_not_paid: 'Nicht bezahlt', + ticket_deactivated: 'Deaktiviert', + deactivate_ticket: 'Ticket deaktivieren', + activate_ticket: 'Ticket aktivieren', checked_in: 'Eingecheckt', not_checked_in: 'Nicht eingecheckt', print: 'Drucken', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 4ebc27b..33e7dac 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -5,7 +5,7 @@ window.i18n.global.mergeLocaleMessage('en', { export_csv: 'Export to CSV', tickets_title: 'Tickets', promo_codes: 'Promo codes', - no_active_promo_codes: 'No active promo codes for this event.', + no_promo_codes: 'No promo codes for this event.', resend_ticket_email: 'Resend ticket email', confirm_onchain_payment: 'Confirm onchain payment', extension_title: 'Events extension', @@ -74,8 +74,10 @@ window.i18n.global.mergeLocaleMessage('en', { promo_code_label: 'Promo Code', active: 'Active', inactive: 'Inactive', - discount_label: 'Discount (%)', - add_promo_code: 'Add Promo Code', + discount_label: 'Discount (%)', + add_promo_code: 'Add Promo Code', + edit_promo_code: 'Edit Promo Code', + promo_code_exists: 'This promo code already exists.', save_promo_codes: 'Save Promo Codes', email_resent: 'Ticket email resent.', email_resend_failed: 'Ticket email failed: {error}', @@ -96,6 +98,7 @@ window.i18n.global.mergeLocaleMessage('en', { col_event: 'Event', col_registered: 'Registered', col_nostr: 'Nostr', + col_deactivated: 'Deactivated', col_promo_code: 'Promo Code', col_satspay: 'Payment', col_yes: 'Yes', @@ -142,6 +145,9 @@ window.i18n.global.mergeLocaleMessage('en', { 'Bookmark, print or screenshot this page, and present it for registration!', ticket_paid: 'Paid', ticket_not_paid: 'Not Paid', + ticket_deactivated: 'Deactivated', + deactivate_ticket: 'Deactivate ticket', + activate_ticket: 'Activate ticket', checked_in: 'Checked In', not_checked_in: 'Not Checked In', print: 'Print', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 84a9de0..51b6eb6 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -5,8 +5,7 @@ window.i18n.global.mergeLocaleMessage('es', { export_csv: 'Exportar a CSV', tickets_title: 'Entradas', promo_codes: 'Códigos promocionales', - no_active_promo_codes: - 'No hay códigos promocionales activos para este evento.', + no_promo_codes: 'No hay códigos promocionales para este evento.', resend_ticket_email: 'Reenviar email de entrada', confirm_onchain_payment: 'Confirmar pago onchain', extension_title: 'Extensión de Eventos', @@ -78,8 +77,10 @@ window.i18n.global.mergeLocaleMessage('es', { promo_code_label: 'Código promocional', active: 'Activo', inactive: 'Inactivo', - discount_label: 'Descuento (%)', - add_promo_code: 'Añadir código promocional', + discount_label: 'Descuento (%)', + add_promo_code: 'Añadir código promocional', + edit_promo_code: 'Editar código promocional', + promo_code_exists: 'Este código promocional ya existe.', save_promo_codes: 'Guardar códigos promocionales', email_resent: 'Email de entrada reenviado.', email_resend_failed: 'Email de entrada fallido: {error}', @@ -100,6 +101,7 @@ window.i18n.global.mergeLocaleMessage('es', { col_event: 'Evento', col_registered: 'Registrado', col_nostr: 'Nostr', + col_deactivated: 'Desactivado', col_promo_code: 'Código promocional', col_satspay: 'Pago', col_yes: 'Sí', @@ -148,6 +150,9 @@ window.i18n.global.mergeLocaleMessage('es', { '¡Guarda esta página como marcador, imprímela o toma una captura de pantalla y preséntala para registrarte!', ticket_paid: 'Pagado', ticket_not_paid: 'No pagado', + ticket_deactivated: 'Desactivada', + deactivate_ticket: 'Desactivar entrada', + activate_ticket: 'Activar entrada', checked_in: 'Registrado', not_checked_in: 'No registrado', print: 'Imprimir', diff --git a/static/js/index.js b/static/js/index.js index 4e96d23..664a62a 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -72,6 +72,12 @@ window.PageEvents = { label: this.$t('events.col_nostr'), field: row => row.extra?.nostr_identifier || '' }, + { + name: 'deactivated', + align: 'left', + label: this.$t('events.col_deactivated'), + field: row => row.extra?.deactivated ? this.$t('events.col_yes') : this.$t('events.col_no') + }, { name: 'promo_code', align: 'left', @@ -155,6 +161,22 @@ window.PageEvents = { } }, promoDiscountTypes: {}, + editPromoCodeDialog: { + show: false, + eventId: null, + codeIndex: -1, + data: { + code: '', + discount_percent: null, + discount_fixed: null, + active: true, + combinable: true, + max_uses: null, + used_count: 0 + }, + discountType: 'percent' + }, + newPromoCodeInput: {}, watchonlyWallets: [], paymentMethods: { ln: false, @@ -321,12 +343,35 @@ window.PageEvents = { } }) .catch(LNbits.utils.notifyApiError) - .finally(() => { + .finally(() => { this.resendingTicketEmails = this.resendingTicketEmails.filter( ticketId => ticketId !== ticket.id ) }) }, + toggleTicketDeactivation(ticket) { + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) + if (!wallet) return + LNbits.api + .request( + 'PUT', + '/events/api/v1/tickets/' + ticket.id + '/deactivate', + wallet.adminkey + ) + .then(response => { + const updated = response.data + const action = updated.extra?.deactivated ? 'deactivated' : 'activated' + this.tickets = this.tickets.map(t => + t.id === ticket.id ? updated : t + ) + Quasar.Notify.create({ + type: 'positive', + message: `Ticket ${action}.`, + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + }, exportticketsCSV() { LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets) }, @@ -763,6 +808,147 @@ window.PageEvents = { }) .catch(LNbits.utils.notifyApiError) }, + openEditPromoCodeDialog(eventId, codeIndex) { + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const code = event.extra?.promo_codes?.[codeIndex] + if (!code) return + this.editPromoCodeDialog.eventId = eventId + this.editPromoCodeDialog.codeIndex = codeIndex + this.editPromoCodeDialog.data = {...code} + this.editPromoCodeDialog.discountType = + code.discount_fixed != null ? 'fixed' : 'percent' + this.editPromoCodeDialog.show = true + }, + saveEditPromoCode() { + const {eventId, codeIndex, data, discountType} = this.editPromoCodeDialog + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return + + const code = {...data} + if (discountType === 'fixed') { + code.discount_percent = null + } else { + code.discount_fixed = null + } + code.code = code.code.trim().toUpperCase() + + const codes = [...(event.extra?.promo_codes || [])] + if (codeIndex >= 0 && codeIndex < codes.length) { + codes[codeIndex] = code + } else { + codes.push(code) + } + + const payload = { + ...event, + extra: { + ...event.extra, + promo_codes: codes + } + } + + LNbits.api + .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload) + .then(response => { + this.events = this.events.map(e => + e.id === eventId ? response.data : e + ) + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.promo_codes_updated'), + icon: null + }) + this.editPromoCodeDialog.show = false + }) + .catch(LNbits.utils.notifyApiError) + }, + deletePromoCode(eventId, codeIndex) { + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return + + const codes = [...(event.extra?.promo_codes || [])] + codes.splice(codeIndex, 1) + + const payload = { + ...event, + extra: { + ...event.extra, + promo_codes: codes + } + } + + LNbits.api + .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload) + .then(response => { + this.events = this.events.map(e => + e.id === eventId ? response.data : e + ) + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.promo_codes_updated'), + icon: null + }) + this.editPromoCodeDialog.show = false + }) + .catch(LNbits.utils.notifyApiError) + }, + addPromoCodeInline(eventId) { + const code = (this.newPromoCodeInput[eventId] || '').trim().toUpperCase() + if (!code) return + + const event = _.findWhere(this.events, {id: eventId}) + if (!event) return + const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) + if (!wallet) return + + const codes = [...(event.extra?.promo_codes || [])] + if (codes.find(c => c.code === code)) { + Quasar.Notify.create({ + type: 'warning', + message: this.$t('events.promo_code_exists'), + icon: null + }) + return + } + + codes.push({ + code, + discount_percent: null, + discount_fixed: null, + active: true, + combinable: true, + max_uses: null, + used_count: 0 + }) + + const payload = { + ...event, + extra: { + ...event.extra, + promo_codes: codes + } + } + + LNbits.api + .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload) + .then(response => { + this.events = this.events.map(e => + e.id === eventId ? response.data : e + ) + this.newPromoCodeInput[eventId] = '' + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.promo_codes_updated'), + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + }, resendAllTicketEmails(eventId) { const event = _.findWhere(this.events, {id: eventId}) if (!event) return diff --git a/static/js/index.vue b/static/js/index.vue index e7f05d8..4d88924 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -159,33 +159,18 @@ class="text-subtitle1" v-text="$t('events.promo_codes')" > - -
+
@@ -194,7 +179,8 @@ clickable class="q-py-xs" style="height: auto" - @click="utils.copyText(code.code.toUpperCase())" + :class="{ 'text-strikethrough': !code.active }" + @click="openEditPromoCodeDialog(props.row.id, index)" >
+
+ + + +
@@ -307,6 +313,21 @@ + + + + + + + + @@ -900,6 +921,114 @@ + + + +
+ + + +
+
+ +
+
+ +
+
+ + + +
+
+ +
+ + + + + + +
+ +
+ + +
+
+
+
+
+ diff --git a/static/js/ticket.vue b/static/js/ticket.vue index a1ea2f8..c97e3f6 100644 --- a/static/js/ticket.vue +++ b/static/js/ticket.vue @@ -12,6 +12,12 @@ v-text="$t('events.ticket_instructions')" >
+ Ticket: + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") + return await toggle_ticket_deactivation(ticket) + + @events_api_router.put("/tickets/register/{ticket_id}") async def api_event_register_ticket(ticket_id: str) -> Ticket: ticket = await get_ticket(ticket_id) @@ -602,6 +617,10 @@ async def api_event_register_ticket(ticket_id: str) -> Ticket: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for." ) + if ticket.extra.deactivated: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Ticket has been deactivated." + ) if ticket.registered: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Ticket already registered." From e40379a3b1d489d1ed0ad27ca8961604e4b017d0 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Wed, 8 Jul 2026 14:50:26 +0100 Subject: [PATCH 29/66] refactor: replace inline promo code input with + button that opens add dialog - Remove inline q-input + add button below promo code chips - Add + icon button next to Promo Codes title - + opens editPromoCodeDialog in add mode (codeIndex: -1) - Dialog header shows Add/Edit based on codeIndex - Remove addPromoCodeInline method and newPromoCodeInput data --- static/js/i18n/en.js | 2 +- static/js/index.js | 68 ++++++++++---------------------------------- static/js/index.vue | 32 +++++++-------------- 3 files changed, 26 insertions(+), 76 deletions(-) diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 33e7dac..67ded05 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -4,7 +4,7 @@ window.i18n.global.mergeLocaleMessage('en', { events_title: 'Events', export_csv: 'Export to CSV', tickets_title: 'Tickets', - promo_codes: 'Promo codes', + promo_codes: 'Promo Codes', no_promo_codes: 'No promo codes for this event.', resend_ticket_email: 'Resend ticket email', confirm_onchain_payment: 'Confirm onchain payment', diff --git a/static/js/index.js b/static/js/index.js index 664a62a..5070a35 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -176,7 +176,6 @@ window.PageEvents = { }, discountType: 'percent' }, - newPromoCodeInput: {}, watchonlyWallets: [], paymentMethods: { ln: false, @@ -820,6 +819,21 @@ window.PageEvents = { code.discount_fixed != null ? 'fixed' : 'percent' this.editPromoCodeDialog.show = true }, + openAddPromoCodeDialog(eventId) { + this.editPromoCodeDialog.eventId = eventId + this.editPromoCodeDialog.codeIndex = -1 + this.editPromoCodeDialog.data = { + code: '', + discount_percent: null, + discount_fixed: null, + active: true, + combinable: true, + max_uses: null, + used_count: 0 + } + this.editPromoCodeDialog.discountType = 'percent' + this.editPromoCodeDialog.show = true + }, saveEditPromoCode() { const {eventId, codeIndex, data, discountType} = this.editPromoCodeDialog const event = _.findWhere(this.events, {id: eventId}) @@ -897,58 +911,6 @@ window.PageEvents = { }) .catch(LNbits.utils.notifyApiError) }, - addPromoCodeInline(eventId) { - const code = (this.newPromoCodeInput[eventId] || '').trim().toUpperCase() - if (!code) return - - const event = _.findWhere(this.events, {id: eventId}) - if (!event) return - const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) - if (!wallet) return - - const codes = [...(event.extra?.promo_codes || [])] - if (codes.find(c => c.code === code)) { - Quasar.Notify.create({ - type: 'warning', - message: this.$t('events.promo_code_exists'), - icon: null - }) - return - } - - codes.push({ - code, - discount_percent: null, - discount_fixed: null, - active: true, - combinable: true, - max_uses: null, - used_count: 0 - }) - - const payload = { - ...event, - extra: { - ...event.extra, - promo_codes: codes - } - } - - LNbits.api - .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload) - .then(response => { - this.events = this.events.map(e => - e.id === eventId ? response.data : e - ) - this.newPromoCodeInput[eventId] = '' - Quasar.Notify.create({ - type: 'positive', - message: this.$t('events.promo_codes_updated'), - icon: null - }) - }) - .catch(LNbits.utils.notifyApiError) - }, resendAllTicketEmails(eventId) { const event = _.findWhere(this.events, {id: eventId}) if (!event) return diff --git a/static/js/index.vue b/static/js/index.vue index 4d88924..b8c6716 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -154,11 +154,19 @@
-
+
+
-
- - - -
@@ -926,7 +914,7 @@
Date: Wed, 8 Jul 2026 14:55:05 +0100 Subject: [PATCH 30/66] style: clean up promo code dialog discount labels and save button - Remove '%' prefix from percent discount toggle label - Remove '%' suffix from discount_percent input - Change save button text to 'Save' with i18n key - Add 'save' i18n key to en/es/de --- static/js/i18n/de.js | 1 + static/js/i18n/en.js | 5 +++-- static/js/i18n/es.js | 1 + static/js/index.vue | 15 +++++++-------- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 929b2af..9b29d2e 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -80,6 +80,7 @@ window.i18n.global.mergeLocaleMessage('de', { add_promo_code: 'Promo-Code hinzufügen', edit_promo_code: 'Promo-Code bearbeiten', promo_code_exists: 'Dieser Promo-Code existiert bereits.', + save: 'Speichern', save_promo_codes: 'Promo-Codes speichern', email_resent: 'Ticket-E-Mail erneut gesendet.', email_resend_failed: 'Ticket-E-Mail fehlgeschlagen: {error}', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 67ded05..02d8df7 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -78,7 +78,8 @@ window.i18n.global.mergeLocaleMessage('en', { add_promo_code: 'Add Promo Code', edit_promo_code: 'Edit Promo Code', promo_code_exists: 'This promo code already exists.', - save_promo_codes: 'Save Promo Codes', + save: 'Save', + save_promo_codes: 'Save Promo Codes', email_resent: 'Ticket email resent.', email_resend_failed: 'Ticket email failed: {error}', nostr_resent: 'Ticket Nostr DM resent.', @@ -175,7 +176,7 @@ window.i18n.global.mergeLocaleMessage('en', { promo_max_uses: 'Max Uses', promo_max_uses_hint: 'Maximum number of times this code can be used (0 = unlimited)', discount_type: 'Discount Type', - discount_percent_label: 'Discount (%)', + discount_percent_label: 'Discount', discount_fixed_label: 'Discount (fixed)', bulk_actions: 'Bulk Actions', resend_all_emails: 'Resend All Ticket Emails', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 51b6eb6..ca0251a 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -81,6 +81,7 @@ window.i18n.global.mergeLocaleMessage('es', { add_promo_code: 'Añadir código promocional', edit_promo_code: 'Editar código promocional', promo_code_exists: 'Este código promocional ya existe.', + save: 'Guardar', save_promo_codes: 'Guardar códigos promocionales', email_resent: 'Email de entrada reenviado.', email_resend_failed: 'Email de entrada fallido: {error}', diff --git a/static/js/index.vue b/static/js/index.vue index b8c6716..0594608 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -111,18 +111,17 @@
-
+
@@ -959,8 +958,8 @@ v-model="editPromoCodeDialog.discountType" toggle-color="primary" :options="[ - {label: '% ' + $t('events.discount_percent_label'), value: 'percent'}, - {label: ' ' + $t('events.discount_fixed_label'), value: 'fixed'} + {label: $t('events.discount_percent_label'), value: 'percent'}, + {label: $t('events.discount_fixed_label'), value: 'fixed'} ]" spread no-caps @@ -1009,7 +1008,7 @@ unelevated color="primary" type="submit" - v-text="$t('events.save_promo_codes')" + v-text="$t('events.save')" >
From eda53d9f0ab75b8d1eed3540c9a126210e4ce535 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Wed, 8 Jul 2026 14:57:53 +0100 Subject: [PATCH 31/66] refactor: consistent save/cancel/delete buttons between ticket and promo code dialogs - Ticket type dialog: save always says 'Save', delete shown only when editing - Button layout matches promo code dialog: delete left, cancel + save right --- static/js/index.vue | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/static/js/index.vue b/static/js/index.vue index 0594608..9ad5bf4 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -728,19 +728,29 @@ >
-
- {{ - ticketTypeDialog.isEdit - ? $t('events.update_ticket_type') - : $t('events.save_ticket_type') - }} +
+
+ + +
From e23bc897db43da693e6ffa883b3239bc0f1c5dd1 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Wed, 8 Jul 2026 15:00:44 +0100 Subject: [PATCH 32/66] fix: align price field with other full-width fields in ticket type dialog --- static/js/index.vue | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/static/js/index.vue b/static/js/index.vue index 9ad5bf4..9d49449 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -672,19 +672,15 @@ v-model.trim="ticketTypeDialog.data.image_url" type="url" :label="$t('events.tt_image_url_label')" + > + -
-
- -
-
Date: Wed, 8 Jul 2026 15:04:07 +0100 Subject: [PATCH 33/66] fix: align form fields in ticket type and promo code dialogs - Remove q-col-gutter-sm from row wrappers (caused 4px offset) - Use q-mr-xs on first column as manual spacer instead --- static/js/index.vue | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/static/js/index.vue b/static/js/index.vue index 9d49449..d71c91c 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -681,8 +681,8 @@ :label="$t('events.price_label', {currency: getEventCurrency(ticketTypeDialog.eventId)})" min="0" > -
-
+
+
-
-
+
+
-
-
+
+
Date: Wed, 8 Jul 2026 15:07:00 +0100 Subject: [PATCH 34/66] feat: update discount labels and add currency suffix to fixed field - Toggle labels: '% Discount' / 'Fixed Discount' - Fixed discount input shows event currency as suffix (like % on percent) - Updated en/es/de i18n --- static/js/i18n/de.js | 4 ++-- static/js/i18n/en.js | 4 ++-- static/js/i18n/es.js | 4 ++-- static/js/index.vue | 4 +++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 9b29d2e..034128f 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -182,8 +182,8 @@ window.i18n.global.mergeLocaleMessage('de', { promo_max_uses: 'Max. Verwendungen', promo_max_uses_hint: 'Maximale Anzahl der Verwendungen dieses Codes (0 = unbegrenzt)', discount_type: 'Rabatt-Typ', - discount_percent_label: 'Rabatt (%)', - discount_fixed_label: 'Rabatt (fest)', + discount_percent_label: '% Rabatt', + discount_fixed_label: 'Fester Rabatt', bulk_actions: 'Massenaktionen', resend_all_emails: 'Alle Ticket-E-Mails erneut senden', email_all_attendees: 'E-Mail an alle Teilnehmer', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 02d8df7..eef1d0e 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -176,8 +176,8 @@ window.i18n.global.mergeLocaleMessage('en', { promo_max_uses: 'Max Uses', promo_max_uses_hint: 'Maximum number of times this code can be used (0 = unlimited)', discount_type: 'Discount Type', - discount_percent_label: 'Discount', - discount_fixed_label: 'Discount (fixed)', + discount_percent_label: '% Discount', + discount_fixed_label: 'Fixed Discount', bulk_actions: 'Bulk Actions', resend_all_emails: 'Resend All Ticket Emails', email_all_attendees: 'Email All Attendees', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index ca0251a..a5fe5ab 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -181,8 +181,8 @@ window.i18n.global.mergeLocaleMessage('es', { promo_max_uses: 'Usos máximos', promo_max_uses_hint: 'Número máximo de veces que se puede usar este código (0 = ilimitado)', discount_type: 'Tipo de descuento', - discount_percent_label: 'Descuento (%)', - discount_fixed_label: 'Descuento (fijo)', + discount_percent_label: '% Descuento', + discount_fixed_label: 'Descuento fijo', bulk_actions: 'Acciones masivas', resend_all_emails: 'Reenviar todos los emails de entradas', email_all_attendees: 'Enviar email a todos los asistentes', diff --git a/static/js/index.vue b/static/js/index.vue index d71c91c..866ef42 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -993,7 +993,9 @@ type="number" :label="$t('events.discount_fixed_label')" min="0" - > + > + +
Date: Wed, 8 Jul 2026 15:08:37 +0100 Subject: [PATCH 35/66] fix: save/cancel always on right in ticket and promo modals - Replace justify-between with row + q-ml-auto on button group - Keeps save/cancel right-aligned even when delete button is hidden --- static/js/index.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/js/index.vue b/static/js/index.vue index 866ef42..0ba17ac 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -724,7 +724,7 @@ >
-
+
-
+
{{ getEventCurrency(editPromoCodeDialog.eventId) }} -
+
-
+
Date: Wed, 8 Jul 2026 15:10:01 +0100 Subject: [PATCH 36/66] feat: add confirmation dialog before resending all ticket emails --- static/js/i18n/de.js | 1 + static/js/i18n/en.js | 1 + static/js/i18n/es.js | 1 + static/js/index.js | 47 +++++++++++++++++++++++++------------------- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 034128f..6e97cc5 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -194,6 +194,7 @@ window.i18n.global.mergeLocaleMessage('de', { admin_email_label: 'Admin-E-Mail', admin_email_hint: 'E-Mail-Benachrichtigungen über Ticketverkäufe erhalten', bulk_email_sent: 'Ticket-E-Mails erneut gesendet.', + confirm_resend_all: 'Sind Sie sicher, dass Sie alle Ticket-E-Mails für diese Veranstaltung erneut senden möchten?', bulk_message_sent: 'Nachricht an alle Teilnehmer gesendet.', quantity: 'Anzahl', add_to_basket: 'In den Warenkorb', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index eef1d0e..dcca941 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -188,6 +188,7 @@ window.i18n.global.mergeLocaleMessage('en', { admin_email_label: 'Admin Email', admin_email_hint: 'Receive email notifications for ticket sales', bulk_email_sent: 'Ticket emails resent.', + confirm_resend_all: 'Are you sure you want to resend all ticket emails for this event?', bulk_message_sent: 'Message sent to all attendees.', quantity: 'Quantity', add_to_basket: 'Add to Basket', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index a5fe5ab..24204e5 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -193,6 +193,7 @@ window.i18n.global.mergeLocaleMessage('es', { admin_email_label: 'Email del administrador', admin_email_hint: 'Recibir notificaciones por email de ventas de entradas', bulk_email_sent: 'Emails de entradas reenviados.', + confirm_resend_all: '¿Está seguro de que quiere reenviar todos los emails de entradas para este evento?', bulk_message_sent: 'Mensaje enviado a todos los asistentes.', quantity: 'Cantidad', add_to_basket: 'Añadir a la cesta', diff --git a/static/js/index.js b/static/js/index.js index 5070a35..d5b53aa 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -917,27 +917,34 @@ window.PageEvents = { const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet}) if (!wallet) return - this.resendingAllEmailsFor.push(eventId) - LNbits.api - .request( - 'POST', - `/events/api/v1/events/${eventId}/email-tickets`, - wallet.adminkey, - null - ) - .then(response => { - Quasar.Notify.create({ - type: 'positive', - message: this.$t('events.bulk_email_sent'), - icon: null - }) - }) - .catch(LNbits.utils.notifyApiError) - .finally(() => { - this.resendingAllEmailsFor = this.resendingAllEmailsFor.filter( - id => id !== eventId + Quasar.Dialog.create({ + title: this.$t('events.resend_all_emails'), + message: this.$t('events.confirm_resend_all'), + cancel: true, + persistent: true + }).onOk(() => { + this.resendingAllEmailsFor.push(eventId) + LNbits.api + .request( + 'POST', + `/events/api/v1/events/${eventId}/email-tickets`, + wallet.adminkey, + null ) - }) + .then(response => { + Quasar.Notify.create({ + type: 'positive', + message: this.$t('events.bulk_email_sent'), + icon: null + }) + }) + .catch(LNbits.utils.notifyApiError) + .finally(() => { + this.resendingAllEmailsFor = this.resendingAllEmailsFor.filter( + id => id !== eventId + ) + }) + }) }, openEmailAllDialog(eventId) { this.emailAllDialog = { From 923e3b63c969a0682e7936d90b399f1974ceded4 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Wed, 8 Jul 2026 15:53:01 +0100 Subject: [PATCH 37/66] chore: bump to v2.0.0, update docs for ticket types, baskets, promo codes, fiat --- README.md | 13 ++++++++++++- config.json | 7 ++++++- description.md | 7 ++++++- pyproject.toml | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 15748c7..f9b41f4 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,21 @@ ## Sell tickets for events and use the built-in scanner for registering attendees -Events allows you to create tickets for an event. Each ticket is in the form of a unique QR code. After registering and paying, the user gets a QR code to present at registration/entrance. +Events allows you to create events with multiple ticket types and sell tickets. Each ticket is in the form of a unique QR code. After paying, the user gets a QR code to present at registration/entrance. Events includes a shareable ticket scanner, which can be used to register attendees. +### Features + +- **Multi-ticket basket checkout** — buy multiple tickets of different types in one transaction +- **Promo codes** — percentage or fixed discounts, combinable or exclusive +- **Payment methods** — Lightning, on-chain, and fiat (Stripe, PayPal, Square, Revolut) configured per event +- **Ticket types** — multiple ticket tiers with different pricing, availability windows, and max limits +- **Email & Nostr delivery** — automatic ticket link delivery via email or Nostr DM +- **Bulk actions** — resend all ticket emails, send custom messages to all attendees +- **Ticket management** — deactivation, refunds, registration tracking +- **Fiat provider** — credentials configured at LNbits server level, authorized per user + ## Usage 1. Create an event\ diff --git a/config.json b/config.json index 2c76fc7..c9a1102 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.9", + "version": "2.0.0", "name": "Events", "repo": "https://github.com/lnbits/events", "short_description": "Sell and register event tickets", @@ -32,6 +32,11 @@ "name": "motorina0", "uri": "https://github.com/motorina0", "role": "Developer" + }, + { + "name": "Nathan Day", + "uri": "https://github.com/dadofsambonzuki", + "role": "Developer" } ], "images": [ diff --git a/description.md b/description.md index 6e22c7d..0988235 100644 --- a/description.md +++ b/description.md @@ -2,9 +2,14 @@ Sell tickets for events and manage attendee registration with a built-in QR scan Its features include: -- Creating events with ticket pricing +- Creating events with multiple ticket types and pricing - Generating unique QR code tickets after payment - Providing a shareable ticket scanner for check-in - Tracking registered and checked-in attendees +- Multi-ticket basket checkout with promo codes (percentage or fixed discount) +- Payment via Lightning, on-chain, or fiat (Stripe, PayPal, Square, Revolut) +- Email and Nostr notification delivery +- Bulk email actions (resend tickets, custom messages to all attendees) +- Ticket deactivation and refund management A complete ticketing solution for event organizers, meetup hosts, and conference planners who want to sell tickets and manage attendance with Bitcoin. diff --git a/pyproject.toml b/pyproject.toml index 0640a6c..10d2898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lnbits-events" -version = "0.0.0" +version = "2.0.0" requires-python = ">=3.10,<3.13" description = "LNbits, free and open-source Lightning wallet and accounts system." authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] From 71e23bcb31a28a18a2552eaf135b9b71ae19e1a8 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Wed, 8 Jul 2026 16:08:19 +0100 Subject: [PATCH 38/66] feat: add active/inactive toggle for ticket types, strikethrough for inactive chips --- static/js/display.js | 1 + static/js/index.js | 18 +++++++++++++----- static/js/index.vue | 11 +++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/static/js/display.js b/static/js/display.js index 6f0d2f7..2d3e01f 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -99,6 +99,7 @@ window.PageEventsDisplay = { const today = new Date().toISOString().slice(0, 10) this.ticketTypes = data.filter( tt => + (tt.extra?.active ?? true) && tt.available_from <= today && tt.available_to >= today && (tt.max_tickets === 0 || tt.sold < tt.max_tickets) diff --git a/static/js/index.js b/static/js/index.js index d5b53aa..2c0b879 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -142,10 +142,11 @@ window.PageEvents = { description: '', image_url: null, price: 0, - max_tickets: 0, + max_tickets: 0, available_from: '', available_to: '', - sort_order: 0 + sort_order: 0, + active: true } }, ticketTypesByEvent: {}, @@ -613,7 +614,8 @@ window.PageEvents = { max_tickets: tt?.max_tickets || 0, available_from: tt?.available_from || event.event_start_date || '', available_to: tt?.available_to || event.event_end_date || '', - sort_order: tt?.sort_order || 0 + sort_order: tt?.sort_order || 0, + active: tt?.extra?.active ?? true } } }, @@ -632,7 +634,8 @@ window.PageEvents = { max_tickets: 0, available_from: '', available_to: '', - sort_order: 0 + sort_order: 0, + active: true } } }, @@ -647,11 +650,16 @@ window.PageEvents = { const payload = { ...this.ticketTypeDialog.data, - event_id: this.ticketTypeDialog.eventId + event_id: this.ticketTypeDialog.eventId, + extra: { + ...this.ticketTypeDialog.data.extra, + active: this.ticketTypeDialog.data.active + } } if (!this.ticketTypeDialog.isEdit) { delete payload.id } + delete payload.active const request = this.ticketTypeDialog.isEdit ? LNbits.api.request( diff --git a/static/js/index.vue b/static/js/index.vue index 0ba17ac..4636354 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -141,6 +141,7 @@ square clickable class="q-py-xs" + :class="{ 'text-strikethrough': !(tt.extra?.active ?? true) }" style="height: auto" @click="openTicketTypeDialog(props.row.id, tt)" > @@ -724,6 +725,11 @@ >
+
+ From d017e9025079c87cb41e77013f8884ac31157889 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 12:07:51 +0100 Subject: [PATCH 39/66] chore: lower min_lnbits_version to 1.5.4 for testing --- config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.json b/config.json index c9a1102..4d4e701 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,7 @@ "short_description": "Sell and register event tickets", "description": "", "tile": "/events/static/image/events.png", - "min_lnbits_version": "1.5.5", + "min_lnbits_version": "1.5.4", "contributors": [ { "name": "talvasconcelos", From 3edaf3532aa559ee475eb8c15b7d2b457abf27b4 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 12:15:50 +0100 Subject: [PATCH 40/66] Move promo below nostr field, update nostr/email labels, fix spacing, send nostr in checkout --- static/js/display.js | 3 ++- static/js/display.vue | 23 ++++++++++++----------- static/js/i18n/de.js | 7 +++---- static/js/i18n/en.js | 6 +++--- static/js/i18n/es.js | 7 +++---- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/static/js/display.js b/static/js/display.js index 2d3e01f..d66eb7c 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -15,6 +15,7 @@ window.PageEventsDisplay = { basketItems: [], submitting: false, checkoutLoading: false, + nostrIdentifier: '', ticketLink: { show: false, data: { @@ -218,7 +219,7 @@ window.PageEventsDisplay = { promo_codes, payment_method: null, fiat_provider: this.event?.extra?.fiat_provider || null, - nostr_identifier: null, + nostr_identifier: this.nostrIdentifier || null, refund_address: refundAddr || null } diff --git a/static/js/display.vue b/static/js/display.vue index db2c195..da38ab2 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -133,7 +133,17 @@ >
-
+
+ +
+ +
-
+
{{ d.label }}
-
- -
Total: {{ basketTotal }} {{ basketCurrency }}
diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 6e97cc5..7e01e78 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -113,11 +113,10 @@ window.i18n.global.mergeLocaleMessage('de', { buy_ticket: 'Ticket kaufen', your_name_label: 'Ihr Name (optional)', your_email_label: 'Ihre E-Mail', - your_email_delivery_label: 'Ihre E-Mail (Ticket-Lieferung)', + your_email_delivery_label: 'Ihre E-Mail (Ticket-Zustellung, erforderlich)', required: '* Erforderlich', - nostr_nip05_label: '(optional) Nostr NIP-05', - nostr_nip05_hint: - 'Falls angegeben, senden wir Ihnen den Ticket-Link per DM nach der Zahlung.', + nostr_nip05_label: 'Ihre Nostr NIP-05 (Ticket-Zustellung, optional)', + nostr_nip05_hint: '', refund_label: 'Rückerstattungs-Lnaddress oder LNURL', refund_hint: 'Wenn die Mindestanzahl von {min_tickets} Tickets nicht erreicht wird, wird eine Rückerstattung gesendet.', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index dcca941..5e1e960 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -109,10 +109,10 @@ window.i18n.global.mergeLocaleMessage('en', { buy_ticket: 'Buy Ticket', your_name_label: 'Your name (optional)', your_email_label: 'Your email', - your_email_delivery_label: 'Your email (ticket delivery)', + your_email_delivery_label: 'Your email (ticket delivery, mandatory)', required: '* Required', - nostr_nip05_label: '(optional) Nostr NIP-05', - nostr_nip05_hint: "If provided, we'll DM your ticket link after payment.", + nostr_nip05_label: 'Your Nostr NIP-05 (ticket delivery, optional)', + nostr_nip05_hint: '', refund_label: 'Refund lnaddress or LNURL', refund_hint: 'If minimum tickets ({min_tickets}) are not met, refund will be sent.', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 24204e5..6315ed9 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -112,11 +112,10 @@ window.i18n.global.mergeLocaleMessage('es', { buy_ticket: 'Comprar entrada', your_name_label: 'Tu nombre (opcional)', your_email_label: 'Tu email', - your_email_delivery_label: 'Tu email (entrega de entrada)', + your_email_delivery_label: 'Su email (entrega de tickets, obligatorio)', required: '* Obligatorio', - nostr_nip05_label: '(opcional) Nostr NIP-05', - nostr_nip05_hint: - 'Si se proporciona, enviaremos el enlace de tu entrada por DM tras el pago.', + nostr_nip05_label: 'Su Nostr NIP-05 (entrega de tickets, opcional)', + nostr_nip05_hint: '', refund_label: 'Lnaddress o LNURL de reembolso', refund_hint: 'Si no se alcanzan las entradas mínimas ({min_tickets}), se enviará un reembolso.', From 602a20846e15058e6b696f4dd67d3a1ae00f0f03 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 12:56:14 +0100 Subject: [PATCH 41/66] feat: UI check for email/nostr config, notification body shared, subject only for email --- static/js/i18n/de.js | 2 ++ static/js/i18n/en.js | 2 ++ static/js/i18n/es.js | 2 ++ static/js/index.vue | 31 ++++++++++++++++++++----------- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 7e01e78..9b615b0 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -52,7 +52,9 @@ window.i18n.global.mergeLocaleMessage('de', { ticket_delivery_desc: 'Senden Sie den bezahlten Ticket-Link automatisch per E-Mail oder Nostr DM.', email_notifications: 'E-Mail-Benachrichtigungen', + email_not_configured: 'E-Mail in LNbits-Kerneinstellungen nicht konfiguriert', nostr_notifications: 'Nostr-Benachrichtigungen', + nostr_not_configured: 'Nostr in LNbits-Kerneinstellungen nicht konfiguriert', notification_subject_label: 'Betreff der Ticket-Benachrichtigung', notification_subject_hint: 'Wird als E-Mail-Betreff beim Versenden von bezahlten Ticket-Links verwendet.', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index 5e1e960..b6d61fa 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -51,7 +51,9 @@ window.i18n.global.mergeLocaleMessage('en', { ticket_delivery_desc: 'Send the paid ticket link automatically by email or Nostr DM.', email_notifications: 'Email notifications', + email_not_configured: 'Email not configured in LNbits core settings', nostr_notifications: 'Nostr notifications', + nostr_not_configured: 'Nostr not configured in LNbits core settings', notification_subject_label: 'Ticket notification subject', notification_subject_hint: 'Used as the email subject when sending paid ticket links.', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 6315ed9..13b3e28 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -53,7 +53,9 @@ window.i18n.global.mergeLocaleMessage('es', { ticket_delivery_desc: 'Envía el enlace de la entrada pagada automáticamente por email o Nostr DM.', email_notifications: 'Notificaciones por email', + email_not_configured: 'Email no configurado en ajustes de LNbits', nostr_notifications: 'Notificaciones por Nostr', + nostr_not_configured: 'Nostr no configurado en ajustes de LNbits', notification_subject_label: 'Asunto de notificación de entrada', notification_subject_hint: 'Se usa como asunto del email al enviar enlaces de entradas pagadas.', diff --git a/static/js/index.vue b/static/js/index.vue index 4636354..ff25672 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -547,21 +547,30 @@ class="text-caption" v-text="$t('events.ticket_delivery_desc')" >
- - +
+ +
+
+
+ +
+
Date: Thu, 9 Jul 2026 14:04:27 +0100 Subject: [PATCH 42/66] Make email optional in label and checkout validation --- static/js/display.js | 3 +-- static/js/i18n/de.js | 2 +- static/js/i18n/en.js | 2 +- static/js/i18n/es.js | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/static/js/display.js b/static/js/display.js index d66eb7c..6cf9f4d 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -60,8 +60,7 @@ window.PageEventsDisplay = { item => item.quantity > 0 ) if (!hasItems) return false - if (!this.basketEmail) return false - if (!/^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/.test(this.basketEmail)) return false + if (this.basketEmail && !/^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/.test(this.basketEmail)) return false return true }, allowEmailNotifications() { diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 9b615b0..cf6e75d 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -115,7 +115,7 @@ window.i18n.global.mergeLocaleMessage('de', { buy_ticket: 'Ticket kaufen', your_name_label: 'Ihr Name (optional)', your_email_label: 'Ihre E-Mail', - your_email_delivery_label: 'Ihre E-Mail (Ticket-Zustellung, erforderlich)', + your_email_delivery_label: 'Ihre E-Mail (Ticket-Zustellung, optional)', required: '* Erforderlich', nostr_nip05_label: 'Ihre Nostr NIP-05 (Ticket-Zustellung, optional)', nostr_nip05_hint: '', diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js index b6d61fa..e2d7fff 100644 --- a/static/js/i18n/en.js +++ b/static/js/i18n/en.js @@ -111,7 +111,7 @@ window.i18n.global.mergeLocaleMessage('en', { buy_ticket: 'Buy Ticket', your_name_label: 'Your name (optional)', your_email_label: 'Your email', - your_email_delivery_label: 'Your email (ticket delivery, mandatory)', + your_email_delivery_label: 'Your email (ticket delivery, optional)', required: '* Required', nostr_nip05_label: 'Your Nostr NIP-05 (ticket delivery, optional)', nostr_nip05_hint: '', diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 13b3e28..866424b 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -114,7 +114,7 @@ window.i18n.global.mergeLocaleMessage('es', { buy_ticket: 'Comprar entrada', your_name_label: 'Tu nombre (opcional)', your_email_label: 'Tu email', - your_email_delivery_label: 'Su email (entrega de tickets, obligatorio)', + your_email_delivery_label: 'Su email (entrega de tickets, opcional)', required: '* Obligatorio', nostr_nip05_label: 'Su Nostr NIP-05 (entrega de tickets, opcional)', nostr_nip05_hint: '', From f256baeac1da52e7d3298a99537384cbe379c65d Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 14:11:28 +0100 Subject: [PATCH 43/66] Allow empty email in backend models and send null from frontend --- models.py | 6 +++--- static/js/display.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/models.py b/models.py index 5909dc4..0700868 100644 --- a/models.py +++ b/models.py @@ -3,7 +3,7 @@ from fastapi import Query from lnbits.db import FilterModel -from pydantic import BaseModel, EmailStr, Field, root_validator, validator +from pydantic import BaseModel, Field, root_validator, validator class PromoCode(BaseModel): @@ -139,7 +139,7 @@ class TicketExtra(BaseModel): class CreateTicket(BaseModel): name: str - email: EmailStr + email: str | None = None ticket_type_id: str | None = None ticket_wave_id: str | None = None promo_code: str | None = None @@ -226,7 +226,7 @@ class BasketItem(BaseModel): class CreateBasket(BaseModel): name: str - email: EmailStr + email: str | None = None items: list[BasketItem] promo_codes: list[str] = Field(default_factory=list) payment_method: str | None = None diff --git a/static/js/display.js b/static/js/display.js index 6cf9f4d..31d255e 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -213,7 +213,7 @@ window.PageEventsDisplay = { const body = { name: this.basketName, - email: this.basketEmail, + email: this.basketEmail || null, items, promo_codes, payment_method: null, From 150dc7f7b06020091609632f38fd1574ec13bff9 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 14:40:28 +0100 Subject: [PATCH 44/66] Fix m009 migration: use IF NOT EXISTS, named placeholders, try/except for ALTER --- migrations.py | 72 ++++++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/migrations.py b/migrations.py index 900591f..d149f5b 100644 --- a/migrations.py +++ b/migrations.py @@ -198,9 +198,10 @@ async def m008_add_fiat_currency(db): async def m009_ticket_types_and_baskets(db): - await db.execute( - """ - CREATE TABLE events.ticket_types ( + from sqlalchemy.exc import OperationalError + + await db.execute(""" + CREATE TABLE IF NOT EXISTS events.ticket_types ( id TEXT PRIMARY KEY, event_id TEXT NOT NULL, name TEXT NOT NULL, @@ -214,12 +215,9 @@ async def m009_ticket_types_and_baskets(db): sort_order INTEGER NOT NULL DEFAULT 0, extra TEXT ); - """ - ) - - await db.execute( - """ - CREATE TABLE events.baskets ( + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS events.baskets ( id TEXT PRIMARY KEY, event_id TEXT NOT NULL, wallet TEXT NOT NULL, @@ -236,12 +234,16 @@ async def m009_ticket_types_and_baskets(db): + db.timestamp_now + """ ); - """ - ) - - await db.execute("ALTER TABLE events.events ADD COLUMN admin_email TEXT;") - await db.execute("ALTER TABLE events.ticket ADD COLUMN ticket_type_id TEXT;") - await db.execute("ALTER TABLE events.ticket ADD COLUMN basket_id TEXT;") + """) + for alter in [ + "ALTER TABLE events.events ADD COLUMN admin_email TEXT;", + "ALTER TABLE events.ticket ADD COLUMN ticket_type_id TEXT;", + "ALTER TABLE events.ticket ADD COLUMN basket_id TEXT;", + ]: + try: + await db.execute(alter) + except OperationalError: + pass import json @@ -261,31 +263,35 @@ async def m009_ticket_types_and_baskets(db): await db.execute( """ INSERT INTO events.ticket_types ( - id, event_id, name, description, image_url, price, max_tickets, sold, + id, event_id, name, description, + image_url, price, max_tickets, sold, available_from, available_to, sort_order, extra ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (:id, :event_id, :name, :description, + :image_url, :price, :max_tickets, :sold, + :available_from, :available_to, + :sort_order, :extra) """, - ( - wave.get("id", ""), - event_id, - wave.get("title", "General Admission"), - "", - wave.get("ticket_image_id"), - wave.get("price_per_ticket", 0), - wave.get("amount_tickets", 0), - 0, - wave.get("opening_date", ""), - wave.get("closing_date", ""), - sort_order, - "{}", - ), + { + "id": wave.get("id", ""), + "event_id": event_id, + "name": wave.get("title", "General Admission"), + "description": "", + "image_url": wave.get("ticket_image_id"), + "price": wave.get("price_per_ticket", 0), + "max_tickets": wave.get("amount_tickets", 0), + "sold": 0, + "available_from": wave.get("opening_date", ""), + "available_to": wave.get("closing_date", ""), + "sort_order": sort_order, + "extra": "{}", + }, ) sort_order += 1 extra.pop("ticket_waves", None) await db.execute( - "UPDATE events.events SET extra = ? WHERE id = ?", - (json.dumps(extra), event_id), + "UPDATE events.events SET extra = :extra WHERE id = :id", + {"extra": json.dumps(extra), "id": event_id}, ) From 6ea22409a3f7c56603a563094a91209c6c56062c Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:02:30 +0100 Subject: [PATCH 45/66] Fix event page crash: init attendeeFields/copyDetailsToAll, use :img-style binding --- static/js/display.js | 2 ++ static/js/display.vue | 2 ++ 2 files changed, 4 insertions(+) diff --git a/static/js/display.js b/static/js/display.js index 31d255e..73e0012 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -16,6 +16,8 @@ window.PageEventsDisplay = { submitting: false, checkoutLoading: false, nostrIdentifier: '', + attendeeFields: {}, + copyDetailsToAll: {}, ticketLink: { show: false, data: { diff --git a/static/js/display.vue b/static/js/display.vue index da38ab2..5704065 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -7,6 +7,8 @@ v-if="event.banner" :src="event.banner" transition="slide-up" + style="max-height: 300px" + :img-style="{ objectFit: 'contain' }" >

From 7e07d9c96779a162b323eda5f6688e8b919f5db1 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:12:31 +0100 Subject: [PATCH 46/66] Fix create event button disabled: auto-set wallet to first wallet --- static/js/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/js/index.js b/static/js/index.js index 2c0b879..0819fbd 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -457,6 +457,7 @@ window.PageEvents = { } } else { this.formDialog.data = { + wallet: this.g.user.wallets?.[0]?.id, currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', @@ -483,6 +484,7 @@ window.PageEvents = { resetEventDialog() { this.formDialog.show = false this.formDialog.data = { + wallet: this.g.user.wallets?.[0]?.id, currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', From 5a2f98270f331037f73a8bba055b68f6b524aa8c Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:16:10 +0100 Subject: [PATCH 47/66] Remove wallet default, disable button based on payment method requirements instead --- static/js/index.js | 7 +++---- static/js/index.vue | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 0819fbd..b4ecd88 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -406,10 +406,9 @@ window.PageEvents = { }) }, sendEventData() { - const wallet = _.findWhere(this.g.user.wallets, { - id: this.formDialog.data.wallet - }) const data = this.formDialog.data + data.wallet = data.extra?.ln_wallet_id || this.g.user.wallets?.[0]?.id + const wallet = _.findWhere(this.g.user.wallets, {id: data.wallet}) if (this.paymentMethods.ln && !data.extra?.ln_wallet_id) { this.$q.notify({message: 'Select an LN wallet for Lightning payments.', type: 'negative'}) return @@ -457,7 +456,7 @@ window.PageEvents = { } } else { this.formDialog.data = { - wallet: this.g.user.wallets?.[0]?.id, + wallet: null, currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', diff --git a/static/js/index.vue b/static/js/index.vue index ff25672..4223d55 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -630,11 +630,13 @@ unelevated color="primary" :disable=" - formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.event_start_date == null || - formDialog.data.event_end_date == null + formDialog.data.event_end_date == null || + (!paymentMethods.ln && !paymentMethods.onchain && !paymentMethods.fiat) || + (paymentMethods.ln && !formDialog.data.extra?.ln_wallet_id) || + (paymentMethods.onchain && !formDialog.data.extra?.onchain_wallet_id) " type="submit" v-text="$t('events.create_event')" From d30aa20d7c6ddb3fdc77f68085f8e8861cc71e44 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:24:50 +0100 Subject: [PATCH 48/66] Fix basket email None error: coerce to empty string in service --- services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services.py b/services.py index 21b320f..b96f2e4 100644 --- a/services.py +++ b/services.py @@ -292,7 +292,7 @@ async def create_basket_with_charge( wallet=event.wallet, event=event.id, name=data.name, - email=data.email, + email=data.email or '', extra={ "ticket_type_id": tt.id, "ticket_wave_title": tt.name, From 36beb1699b5dad4b18e986de8db6dc7eb56bb109 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:27:49 +0100 Subject: [PATCH 49/66] Fix Basket email None error at the actual Basket construction site --- services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services.py b/services.py index b96f2e4..f7f61a1 100644 --- a/services.py +++ b/services.py @@ -204,7 +204,7 @@ async def create_basket_with_charge( id=basket_id, event_id=event.id, wallet=event.wallet, - email=data.email, + email=data.email or '', name=data.name, promo_codes=promo_code_strings, payment_method=data.payment_method, From 883b623a6fa07e44c6e2392ee87e70b0892d65b2 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:41:50 +0100 Subject: [PATCH 50/66] Add missing closing_date field to models and frontend --- models.py | 3 +++ static/js/index.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/models.py b/models.py index 0700868..b610517 100644 --- a/models.py +++ b/models.py @@ -73,6 +73,7 @@ class CreateEvent(BaseModel): wallet: str name: str info: str + closing_date: str event_start_date: str event_end_date: str currency: str = "sat" @@ -91,6 +92,7 @@ class Event(BaseModel): name: str info: str canceled: bool = False + closing_date: str event_start_date: str event_end_date: str currency: str @@ -110,6 +112,7 @@ class PublicEvent(BaseModel): name: str info: str canceled: bool + closing_date: str event_start_date: str event_end_date: str currency: str diff --git a/static/js/index.js b/static/js/index.js index b4ecd88..0445991 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -432,6 +432,9 @@ window.PageEvents = { if (this.paymentMethods.onchain) methods.push('onchain') if (this.paymentMethods.fiat) methods.push('fiat') data.extra.payment_methods = methods + if (!data.id) { + data.closing_date = data.event_end_date + } if (data.id) { this.updateEvent(wallet, data) } else { From 85d2a48118105be79cfb54e71992bb0d6237d392 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:47:02 +0100 Subject: [PATCH 51/66] Drop closing_date column, amend m009 to DROP COLUMN --- migrations.py | 1 + models.py | 3 --- static/js/index.js | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/migrations.py b/migrations.py index d149f5b..23598d0 100644 --- a/migrations.py +++ b/migrations.py @@ -237,6 +237,7 @@ async def m009_ticket_types_and_baskets(db): """) for alter in [ "ALTER TABLE events.events ADD COLUMN admin_email TEXT;", + "ALTER TABLE events.events DROP COLUMN closing_date;", "ALTER TABLE events.ticket ADD COLUMN ticket_type_id TEXT;", "ALTER TABLE events.ticket ADD COLUMN basket_id TEXT;", ]: diff --git a/models.py b/models.py index b610517..0700868 100644 --- a/models.py +++ b/models.py @@ -73,7 +73,6 @@ class CreateEvent(BaseModel): wallet: str name: str info: str - closing_date: str event_start_date: str event_end_date: str currency: str = "sat" @@ -92,7 +91,6 @@ class Event(BaseModel): name: str info: str canceled: bool = False - closing_date: str event_start_date: str event_end_date: str currency: str @@ -112,7 +110,6 @@ class PublicEvent(BaseModel): name: str info: str canceled: bool - closing_date: str event_start_date: str event_end_date: str currency: str diff --git a/static/js/index.js b/static/js/index.js index 0445991..b4ecd88 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -432,9 +432,6 @@ window.PageEvents = { if (this.paymentMethods.onchain) methods.push('onchain') if (this.paymentMethods.fiat) methods.push('fiat') data.extra.payment_methods = methods - if (!data.id) { - data.closing_date = data.event_end_date - } if (data.id) { this.updateEvent(wallet, data) } else { From f8b3ffd3149ab34fb64779978004caf30625b63a Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Thu, 9 Jul 2026 15:54:04 +0100 Subject: [PATCH 52/66] Fix tickets table header/body column count mismatch --- static/js/index.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/js/index.vue b/static/js/index.vue index 4223d55..aaaacbd 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -262,12 +262,14 @@ >