diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js
index ca3c2ea..93f2723 100644
--- a/static/js/i18n/de.js
+++ b/static/js/i18n/de.js
@@ -4,19 +4,16 @@ window.i18n.global.mergeLocaleMessage('de', {
events_title: 'Veranstaltungen',
export_csv: 'Als CSV exportieren',
tickets_title: 'Tickets',
- ticket_waves: 'Ticket-Wellen',
promo_codes: 'Promo-Codes',
- no_active_promo_codes: 'Keine aktiven Promo-Codes für diese Veranstaltung.',
- wave_chip:
- '{title} - {opening} bis {closing} - {price} - {amount} Tickets - {sold} verkauft',
+ 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',
info_label: 'Info',
extension_desc_title:
- 'Events: Ticket-Wellen für eine Veranstaltung verkaufen und registrieren',
+ 'Events: Tickets für eine Veranstaltung verkaufen und registrieren',
extension_desc:
- 'Mit Events können Sie eine Welle von Tickets für eine Veranstaltung erstellen. Jedes Ticket ist ein einzigartiger QR-Code, den der Nutzer bei der Anmeldung vorzeigt. Events wird mit einem teilbaren Ticket-Scanner geliefert, mit dem Teilnehmer registriert werden können.',
+ 'Mit Events können Sie Ticket-Typen für eine Veranstaltung erstellen. Jedes Ticket ist ein einzigartiger QR-Code, den der Nutzer bei der Anmeldung vorzeigt. Events wird mit einem teilbaren Ticket-Scanner geliefert, mit dem Teilnehmer registriert werden können.',
created_by: 'Erstellt von,',
swagger_api: 'Swagger API',
event_title_label: 'Titel der Veranstaltung',
@@ -27,25 +24,25 @@ window.i18n.global.mergeLocaleMessage('de', {
image_url_hint: 'Optionales Bannerbild für die Veranstaltungsseite',
event_begins: 'Veranstaltung beginnt',
event_ends: 'Veranstaltung endet',
- primary_wave_hint:
- 'Primäre Ticket-Welle (weitere Wellen können später hinzugefügt werden)',
- wave_title_label: 'Wellenbezeichnung',
opening_date_label: 'Öffnungsdatum der Tickets',
- closing_date_label: 'Schlussdatum der Tickets',
- unit_label: 'Einheit',
+
+ currency_unit: 'Währungseinheit',
amount_tickets_label: 'Anzahl der Tickets',
price_label: 'Preis ({currency}) *',
use_ticket_image: 'Ticket-Bild verwenden',
download_template: 'Vorlage herunterladen',
replace_template: 'Ersetzen',
custom_template_uploaded: 'Benutzerdefinierte Ticket-Vorlage hochgeladen.',
- allow_fiat_checkout: 'Fiat-Checkout erlauben',
- allow_fiat_hint:
- 'Ermöglicht Teilnehmern, über einen konfigurierten Fiat-Anbieter mit der Veranstaltungswährung zu bezahlen.',
- wave_allow_fiat_hint:
- 'Ermöglicht Teilnehmern, über einen konfigurierten Fiat-Anbieter mit der Wellenwährung zu bezahlen.',
- fiat_checkout_currency: 'Fiat-Checkout-Währung',
- advanced_options: 'Erweiterte Optionen',
+ payment_methods: 'Zahlungsmethoden',
+ lightning: 'Lightning',
+ fiat: 'Fiat',
+ onchain: 'Onchain',
+ ln_wallet_label: 'LN Wallet',
+ onchain_wallet_label: 'Onchain Wallet',
+ onchain_disabled_hint: 'Die Onchain Wallet (WatchOnly) Erweiterung muss aktiviert sein und eine Wallet haben.',
+ fiat_provider_hint: 'Fiat-Anbieter müssen in den LNbits-Servereinstellungen konfiguriert und dein Benutzer autorisiert sein.',
+ fiat_provider_label: 'Fiat-Anbieter',
+
conditional_events_title: 'Bedingte Veranstaltungen',
conditional_events_desc:
'Machen Sie diese Veranstaltung bedingt, wenn eine Mindestanzahl an Tickets verkauft wird. Der Nutzer wird gebeten, eine Lightning-Adresse oder LNURL-Pay für Rückerstattungen anzugeben.',
@@ -55,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.',
@@ -79,13 +78,12 @@ 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: 'Speichern',
save_promo_codes: 'Promo-Codes speichern',
- edit_ticket_wave: 'Ticket-Welle bearbeiten',
- add_ticket_wave: 'Ticket-Welle hinzufügen',
- update_ticket_wave: 'Ticket-Welle aktualisieren',
- save_ticket_wave: 'Ticket-Welle speichern',
email_resent: 'Ticket-E-Mail erneut gesendet.',
email_resend_failed: 'Ticket-E-Mail fehlgeschlagen: {error}',
nostr_resent: 'Ticket Nostr DM erneut gesendet.',
@@ -93,8 +91,6 @@ window.i18n.global.mergeLocaleMessage('de', {
unknown_error: 'Unbekannter Fehler.',
onchain_confirmed: 'Onchain-Zahlung bestätigt.',
ticket_template_uploaded: 'Ticket-Vorlage hochgeladen.',
- ticket_wave_updated: 'Ticket-Welle aktualisiert.',
- ticket_wave_added: 'Ticket-Welle hinzugefügt.',
promo_codes_updated: 'Promo-Codes aktualisiert.',
delete_ticket_confirm:
'Sind Sie sicher, dass Sie dieses Ticket löschen möchten?',
@@ -109,23 +105,23 @@ 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',
col_no: 'Nein',
submit: 'Absenden',
promo_code_optional: '(optional) Promo-Code',
buy_ticket: 'Ticket kaufen',
- your_name_label: 'Ihr Name',
+ 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, optional)',
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.',
- ticket_wave_label: 'Ticket-Welle',
link_to_ticket: 'Link zu Ihrem Ticket!',
continue_to_checkout: 'Weiter zur Kasse',
fiat_checkout_opened:
@@ -157,8 +153,78 @@ 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'
+ print: 'Drucken',
+ ticket_types: 'Ticket-Typen',
+ no_ticket_types: 'Keine Ticket-Typen konfiguriert.',
+ no_ticket_types_available: 'Keine Ticket-Typen für diese Veranstaltung verfügbar.',
+ available_until: 'Verfügbar bis',
+ tt_chip: '{name} - {price} {currency}',
+ ticket_type_updated: 'Ticket-Typ aktualisiert.',
+ ticket_type_added: 'Ticket-Typ hinzugefügt.',
+ delete_ticket_type_confirm: 'Möchten Sie diesen Ticket-Typ wirklich löschen?',
+ ticket_type_deleted: 'Ticket-Typ gelöscht.',
+ edit_ticket_type: 'Ticket-Typ bearbeiten',
+ add_ticket_type: 'Ticket-Typ hinzufügen',
+ tt_name_label: 'Name',
+ tt_description_label: 'Beschreibung',
+ tt_image_url_label: 'Bild-URL',
+ max_tickets_hint: 'Maximale Anzahl verkaufbarer Tickets (0 = unbegrenzt)',
+ sort_order_label: 'Sortierreihenfolge',
+ available_from_label: 'Verfügbar ab',
+ available_to_label: 'Verfügbar bis',
+ update_ticket_type: 'Ticket-Typ aktualisieren',
+ save_ticket_type: 'Ticket-Typ speichern',
+ promo_combinable: 'Kombinierbar',
+ promo_max_uses: 'Max. Verwendungen',
+ promo_max_uses_hint: 'Maximale Anzahl der Verwendungen dieses Codes (0 = unbegrenzt)',
+ promo_uses_label: 'Verwendet: {used} / {max}',
+ promo_uses_unlimited: 'Verwendet: {used} mal (unbegrenzt)',
+ discount_type: 'Rabatt-Typ',
+ 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',
+ email_all_attendees_title: 'E-Mail an alle Teilnehmer',
+ email_subject_label: 'Betreff',
+ email_message_label: 'Nachricht',
+ send: 'Senden',
+ 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',
+ copy_details_to_all: 'Auf alle übertragen',
+ attendee: 'Teilnehmer',
+ attendees_for: 'Teilnehmer für',
+ name: 'Name',
+ basket: 'Warenkorb',
+ promo_code_comma_separated: 'Promo-Code(s) (durch Kommas getrennt)',
+ promo_code_hint: 'Promo-Codes durch Kommas getrennt eingeben',
+ checkout: 'Zur Kasse',
+ apply: 'Anwenden',
+ loading: 'Loading...',
+ basket_not_found: 'Warenkorb nicht gefunden.',
+ basket_confirmed: 'Zahlung bestätigt! Ihre Tickets sind bereit.',
+ basket_pending: 'Warten auf Zahlungsbestätigung...',
+ buyer: 'Käufer',
+ your_tickets: 'Ihre Tickets',
+ buy_more: 'Mehr Tickets kaufen',
+ anon: 'Anonym',
+ col_ticket_type: 'Ticket-Typ',
+ ticket_count_one: '1 Ticket',
+ ticket_count_many: '{count} Tickets',
+ show_ticket: 'Ticket anzeigen',
+ copy_url: 'Link kopieren',
+ continue_payment: 'Zahlung fortsetzen',
+ basket_auto_update: 'Warenkorb aktualisiert...',
+ total: 'Gesamt',
}
})
diff --git a/static/js/i18n/en.js b/static/js/i18n/en.js
index 6f8d08f..1a93f2a 100644
--- a/static/js/i18n/en.js
+++ b/static/js/i18n/en.js
@@ -4,18 +4,15 @@ window.i18n.global.mergeLocaleMessage('en', {
events_title: 'Events',
export_csv: 'Export to CSV',
tickets_title: 'Tickets',
- ticket_waves: 'Ticket waves',
- promo_codes: 'Promo codes',
- no_active_promo_codes: 'No active promo codes for this event.',
- wave_chip:
- '{title} - {opening} to {closing} - {price} - {amount} tickets - {sold} sold',
+ 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',
extension_title: 'Events extension',
info_label: 'Info',
- extension_desc_title: 'Events: Sell and register ticket waves for an event',
+ extension_desc_title: 'Events: Sell and register tickets for an event',
extension_desc:
- 'Events allows you to make a wave of tickets for an event, each ticket is in the form of a unique QRcode, which the user presents at registration. Events comes with a shareable ticket scanner, which can be used to register attendees.',
+ 'Events allows you to create ticket types for an event, each ticket is in the form of a unique QRcode, which the user presents at registration. Events comes with a shareable ticket scanner, which can be used to register attendees.',
created_by: 'Created by,',
swagger_api: 'Swagger API',
event_title_label: 'Title of event',
@@ -26,24 +23,25 @@ window.i18n.global.mergeLocaleMessage('en', {
image_url_hint: 'Optional banner image to display on the event page',
event_begins: 'Event begins',
event_ends: 'Event ends',
- primary_wave_hint: 'Primary ticket wave (you can add other waves later)',
- wave_title_label: 'Wave title',
opening_date_label: 'Ticket opening date',
- closing_date_label: 'Ticket closing date',
- unit_label: 'Unit',
+
+ currency_unit: 'Currency unit',
amount_tickets_label: 'Amount of tickets',
price_label: 'Price ({currency}) *',
use_ticket_image: 'Use ticket image',
download_template: 'Download template',
replace_template: 'Replace',
custom_template_uploaded: 'Custom ticket template uploaded.',
- allow_fiat_checkout: 'Allow fiat checkout',
- allow_fiat_hint:
- 'Lets attendees pay through a configured fiat provider using the event currency.',
- wave_allow_fiat_hint:
- 'Lets attendees pay through a configured fiat provider using this wave currency.',
- fiat_checkout_currency: 'Fiat checkout currency',
- advanced_options: 'Advanced options',
+ payment_methods: 'Payment Methods',
+ lightning: 'Lightning',
+ fiat: 'Fiat',
+ onchain: 'Onchain',
+ ln_wallet_label: 'LN Wallet',
+ onchain_wallet_label: 'Onchain Wallet',
+ onchain_disabled_hint: 'Onchain Wallet (WatchOnly) extension must be activated and have a wallet.',
+ fiat_provider_hint: 'Fiat providers must be configured in LNbits core server settings and your user must be authorized.',
+ fiat_provider_label: 'Fiat Provider',
+
conditional_events_title: 'Conditional Events',
conditional_events_desc:
'Make this event conditional if minimum tickets are sold. User will be asked to provide a Lightning Address or LNURL pay for refunds.',
@@ -53,13 +51,17 @@ 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.',
notification_body_label: 'Ticket notification body',
notification_body_hint:
'Shown before the ticket link in the paid ticket notification.',
+ preview_ticket_email: 'Preview ticket email',
+ preview_admin_email: 'Preview admin email',
onchain_payments: 'Onchain payments',
onchain_desc:
'Accept Bitcoin onchain payments. Requires the SatsPay and Watchonly extensions to be installed and enabled.',
@@ -76,13 +78,12 @@ window.i18n.global.mergeLocaleMessage('en', {
promo_code_label: 'Promo Code',
active: 'Active',
inactive: 'Inactive',
- discount_label: 'Discount (%)',
- add_promo_code: 'Add Promo Code',
- save_promo_codes: 'Save Promo Codes',
- edit_ticket_wave: 'Edit Ticket Wave',
- add_ticket_wave: 'Add Ticket Wave',
- update_ticket_wave: 'Update Ticket Wave',
- save_ticket_wave: 'Save Ticket Wave',
+ discount_label: 'Discount (%)',
+ add_promo_code: 'Add Promo Code',
+ edit_promo_code: 'Edit Promo Code',
+ promo_code_exists: 'This promo code already exists.',
+ 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.',
@@ -90,8 +91,6 @@ window.i18n.global.mergeLocaleMessage('en', {
unknown_error: 'Unknown error.',
onchain_confirmed: 'Onchain payment confirmed.',
ticket_template_uploaded: 'Ticket template uploaded.',
- ticket_wave_updated: 'Ticket wave updated.',
- ticket_wave_added: 'Ticket wave added.',
promo_codes_updated: 'Promo codes updated.',
delete_ticket_confirm: 'Are you sure you want to delete this ticket?',
delete_event_confirm: 'Are you sure you want to delete this event?',
@@ -104,22 +103,23 @@ 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',
col_no: 'No',
submit: 'Submit',
promo_code_optional: '(optional) Promo Code',
buy_ticket: 'Buy Ticket',
- your_name_label: 'Your name',
+ 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, optional)',
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.',
- ticket_wave_label: 'Ticket wave',
link_to_ticket: 'Link to your ticket!',
continue_to_checkout: 'Continue to checkout',
fiat_checkout_opened:
@@ -150,8 +150,78 @@ 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'
+ print: 'Print',
+ ticket_types: 'Ticket Types',
+ no_ticket_types: 'No ticket types configured.',
+ no_ticket_types_available: 'No ticket types available for this event.',
+ available_until: 'Available until',
+ tt_chip: '{name} - {price} {currency}',
+ ticket_type_updated: 'Ticket type updated.',
+ ticket_type_added: 'Ticket type added.',
+ delete_ticket_type_confirm: 'Are you sure you want to delete this ticket type?',
+ ticket_type_deleted: 'Ticket type deleted.',
+ edit_ticket_type: 'Edit Ticket Type',
+ add_ticket_type: 'Add Ticket Type',
+ tt_name_label: 'Name',
+ tt_description_label: 'Description',
+ tt_image_url_label: 'Image URL',
+ max_tickets_hint: 'Maximum number of tickets that can be sold (0 = unlimited)',
+ sort_order_label: 'Sort Order',
+ available_from_label: 'Available From',
+ available_to_label: 'Available To',
+ update_ticket_type: 'Update Ticket Type',
+ save_ticket_type: 'Save Ticket Type',
+ promo_combinable: 'Combinable',
+ promo_max_uses: 'Max Uses',
+ promo_max_uses_hint: 'Maximum number of times this code can be used (0 = unlimited)',
+ promo_uses_label: 'Uses: {used} / {max}',
+ promo_uses_unlimited: 'Used: {used} times (unlimited)',
+ discount_type: 'Discount Type',
+ 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',
+ email_all_attendees_title: 'Email All Attendees',
+ email_subject_label: 'Subject',
+ email_message_label: 'Message',
+ send: 'Send',
+ 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',
+ copy_details_to_all: 'Copy to All',
+ attendee: 'Attendee',
+ attendees_for: 'Attendees for',
+ name: 'Name',
+ basket: 'Basket',
+ promo_code_comma_separated: 'Promo Code(s) (comma separated)',
+ promo_code_hint: 'Enter promo codes separated by commas',
+ checkout: 'Checkout',
+ apply: 'Apply',
+ loading: 'Loading...',
+ basket_not_found: 'Basket not found.',
+ basket_confirmed: 'Payment confirmed! Your tickets are ready.',
+ basket_pending: 'Waiting for payment confirmation...',
+ buyer: 'Buyer',
+ your_tickets: 'Your Tickets',
+ buy_more: 'Buy More Tickets',
+ anon: 'Anonymous',
+ col_ticket_type: 'Ticket Type',
+ ticket_count_one: '1 Ticket',
+ ticket_count_many: '{count} Tickets',
+ show_ticket: 'Show Ticket',
+ copy_url: 'Copy URL',
+ continue_payment: 'Continue to Payment',
+ basket_auto_update: 'Basket auto-updating...',
+ total: 'Total',
}
})
diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js
index 24c6e48..c4e1f18 100644
--- a/static/js/i18n/es.js
+++ b/static/js/i18n/es.js
@@ -4,20 +4,16 @@ window.i18n.global.mergeLocaleMessage('es', {
events_title: 'Eventos',
export_csv: 'Exportar a CSV',
tickets_title: 'Entradas',
- ticket_waves: 'Oleadas de entradas',
promo_codes: 'Códigos promocionales',
- no_active_promo_codes:
- 'No hay códigos promocionales activos para este evento.',
- wave_chip:
- '{title} - {opening} al {closing} - {price} - {amount} entradas - {sold} vendidas',
+ 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',
info_label: 'Info',
extension_desc_title:
- 'Eventos: Vender y registrar oleadas de entradas para un evento',
+ 'Eventos: Vender y registrar entradas para un evento',
extension_desc:
- 'Eventos te permite crear oleadas de entradas para un evento. Cada entrada es un código QR único que el usuario presenta al registrarse. Eventos incluye un escáner de entradas compartible que puede usarse para registrar asistentes.',
+ 'Eventos te permite crear tipos de entradas para un evento. Cada entrada es un código QR único que el usuario presenta al registrarse. Eventos incluye un escáner de entradas compartible que puede usarse para registrar asistentes.',
created_by: 'Creado por,',
swagger_api: 'Swagger API',
event_title_label: 'Título del evento',
@@ -29,25 +25,25 @@ window.i18n.global.mergeLocaleMessage('es', {
'Imagen de banner opcional para mostrar en la página del evento',
event_begins: 'El evento comienza',
event_ends: 'El evento termina',
- primary_wave_hint:
- 'Oleada de entradas primaria (se pueden añadir más oleadas más adelante)',
- wave_title_label: 'Título de la oleada',
opening_date_label: 'Fecha de apertura de entradas',
- closing_date_label: 'Fecha de cierre de entradas',
- unit_label: 'Unidad',
+
+ currency_unit: 'Unidad monetaria',
amount_tickets_label: 'Cantidad de entradas',
price_label: 'Precio ({currency}) *',
use_ticket_image: 'Usar imagen de entrada',
download_template: 'Descargar plantilla',
replace_template: 'Reemplazar',
custom_template_uploaded: 'Plantilla de entrada personalizada subida.',
- allow_fiat_checkout: 'Permitir pago fiat',
- allow_fiat_hint:
- 'Permite a los asistentes pagar mediante un proveedor fiat configurado usando la moneda del evento.',
- wave_allow_fiat_hint:
- 'Permite a los asistentes pagar mediante un proveedor fiat configurado usando la moneda de esta oleada.',
- fiat_checkout_currency: 'Moneda de pago fiat',
- advanced_options: 'Opciones avanzadas',
+ payment_methods: 'Métodos de Pago',
+ lightning: 'Lightning',
+ fiat: 'Fiat',
+ onchain: 'Onchain',
+ ln_wallet_label: 'Monedero LN',
+ onchain_wallet_label: 'Monedero Onchain',
+ onchain_disabled_hint: 'La extensión Onchain Wallet (WatchOnly) debe estar activada y tener un monedero.',
+ fiat_provider_hint: 'Los proveedores fiat deben configurarse en los ajustes del servidor LNbits y tu usuario debe estar autorizado.',
+ fiat_provider_label: 'Proveedor Fiat',
+
conditional_events_title: 'Eventos condicionales',
conditional_events_desc:
'Haz este evento condicional si se vende un mínimo de entradas. Se pedirá al usuario que proporcione una dirección Lightning o LNURL pay para reembolsos.',
@@ -57,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.',
@@ -81,13 +79,12 @@ 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: 'Guardar',
save_promo_codes: 'Guardar códigos promocionales',
- edit_ticket_wave: 'Editar oleada de entradas',
- add_ticket_wave: 'Añadir oleada de entradas',
- update_ticket_wave: 'Actualizar oleada de entradas',
- save_ticket_wave: 'Guardar oleada de entradas',
email_resent: 'Email de entrada reenviado.',
email_resend_failed: 'Email de entrada fallido: {error}',
nostr_resent: 'Nostr DM de entrada reenviado.',
@@ -95,8 +92,6 @@ window.i18n.global.mergeLocaleMessage('es', {
unknown_error: 'Error desconocido.',
onchain_confirmed: 'Pago onchain confirmado.',
ticket_template_uploaded: 'Plantilla de entrada subida.',
- ticket_wave_updated: 'Oleada de entradas actualizada.',
- ticket_wave_added: 'Oleada de entradas añadida.',
promo_codes_updated: 'Códigos promocionales actualizados.',
delete_ticket_confirm: '¿Está seguro de que quiere eliminar esta entrada?',
delete_event_confirm: '¿Está seguro de que quiere eliminar este evento?',
@@ -109,23 +104,23 @@ 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í',
col_no: 'No',
submit: 'Enviar',
promo_code_optional: '(opcional) Código promocional',
buy_ticket: 'Comprar entrada',
- your_name_label: 'Tu nombre',
+ 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, opcional)',
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.',
- ticket_wave_label: 'Oleada de entradas',
link_to_ticket: '¡Enlace a tu entrada!',
continue_to_checkout: 'Continuar al pago',
fiat_checkout_opened:
@@ -157,8 +152,78 @@ 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'
+ print: 'Imprimir',
+ ticket_types: 'Tipos de entrada',
+ no_ticket_types: 'No hay tipos de entrada configurados.',
+ no_ticket_types_available: 'No hay tipos de entrada disponibles para este evento.',
+ available_until: 'Disponible hasta',
+ tt_chip: '{name} - {price} {currency}',
+ ticket_type_updated: 'Tipo de entrada actualizado.',
+ ticket_type_added: 'Tipo de entrada añadido.',
+ delete_ticket_type_confirm: '¿Está seguro de que quiere eliminar este tipo de entrada?',
+ ticket_type_deleted: 'Tipo de entrada eliminado.',
+ edit_ticket_type: 'Editar tipo de entrada',
+ add_ticket_type: 'Añadir tipo de entrada',
+ tt_name_label: 'Nombre',
+ tt_description_label: 'Descripción',
+ tt_image_url_label: 'URL de imagen',
+ max_tickets_hint: 'Número máximo de entradas que se pueden vender (0 = ilimitado)',
+ sort_order_label: 'Orden',
+ available_from_label: 'Disponible desde',
+ available_to_label: 'Disponible hasta',
+ update_ticket_type: 'Actualizar tipo de entrada',
+ save_ticket_type: 'Guardar tipo de entrada',
+ promo_combinable: 'Combinable',
+ 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)',
+ promo_uses_label: 'Usos: {used} / {max}',
+ promo_uses_unlimited: 'Usado: {used} veces (ilimitado)',
+ discount_type: 'Tipo de descuento',
+ 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',
+ email_all_attendees_title: 'Enviar email a todos los asistentes',
+ email_subject_label: 'Asunto',
+ email_message_label: 'Mensaje',
+ send: 'Enviar',
+ 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',
+ copy_details_to_all: 'Copiar a todos',
+ attendee: 'Asistente',
+ attendees_for: 'Asistentes para',
+ name: 'Nombre',
+ basket: 'Cesta',
+ promo_code_comma_separated: 'Código(s) promocional(es) (separados por comas)',
+ promo_code_hint: 'Introduce códigos promocionales separados por comas',
+ checkout: 'Pagar',
+ apply: 'Aplicar',
+ loading: 'Loading...',
+ basket_not_found: 'Cesta no encontrada.',
+ basket_confirmed: '¡Pago confirmado! Sus entradas están listas.',
+ basket_pending: 'Esperando confirmación de pago...',
+ buyer: 'Comprador',
+ your_tickets: 'Sus Entradas',
+ buy_more: 'Comprar más entradas',
+ anon: 'Anónimo',
+ col_ticket_type: 'Tipo de entrada',
+ ticket_count_one: '1 entrada',
+ ticket_count_many: '{count} entradas',
+ show_ticket: 'Mostrar entrada',
+ copy_url: 'Copiar enlace',
+ continue_payment: 'Continuar con el pago',
+ basket_auto_update: 'Cesta actualizándose...',
+ total: 'Total',
}
})
diff --git a/static/js/index.js b/static/js/index.js
index 0954da4..6e26a0a 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',
@@ -88,8 +94,8 @@ window.PageEvents = {
tickets: [],
allPaidTickets: [],
resendingTicketEmails: [],
+ resendingAllEmailsFor: [],
isUploadingTicketTemplate: false,
- ticketImageUploadTarget: null,
currencies: [],
eventsTable: {
pagination: {
@@ -113,32 +119,31 @@ window.PageEvents = {
allow_fiat: false,
fiat_currency: 'GBP',
extra: {
- ticket_waves: [],
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
},
- ticketWaveDialog: {
- show: false,
- eventId: null,
- wallet: null,
- editingWaveId: null,
- data: {
- id: null,
- title: '',
- opening_date: '',
- closing_date: '',
- currency: 'sats',
- use_ticket_image: false,
- ticket_image_id: null,
- allow_fiat: false,
- fiat_currency: 'GBP',
- amount_tickets: 0,
- price_per_ticket: 0
- }
- },
+ ticketTypeDialog: {
+ show: false,
+ eventId: null,
+ isEdit: false,
+ wallet: null,
+ data: {
+ id: null,
+ name: '',
+ description: '',
+ image_url: null,
+ price: 0,
+ max_tickets: 0,
+ available_from: '',
+ available_to: '',
+ sort_order: 0,
+ active: true
+ }
+ },
+ ticketTypesByEvent: {},
promoCodesDialog: {
show: false,
data: {
@@ -150,122 +155,72 @@ window.PageEvents = {
}
}
},
- onchainWallets: []
+ 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'
+ },
+ watchonlyWallets: [],
+ paymentMethods: {
+ ln: false,
+ onchain: false,
+ fiat: false
+ },
+ emailAllDialog: {
+ show: false,
+ eventId: null,
+ subject: '',
+ message: '',
+ loading: false
+ }
}
},
methods: {
- waveChipLabel(event, wave) {
- const price = this.isFiatCurrency(wave.currency)
+ ticketTypeChipLabel(tt) {
+ const event = this.events.find(e => e.id === tt.event_id)
+ const currency = event?.currency || 'sat'
+ const price = this.isFiatCurrency(currency)
? LNbits.utils.formatCurrency(
- Number(wave.price_per_ticket || 0).toFixed(2),
- wave.currency
+ Number(tt.price || 0).toFixed(2),
+ currency
)
- : `${wave.price_per_ticket} sats`
- return this.$t('events.wave_chip', {
- title: wave.title,
- opening: wave.opening_date,
- closing: wave.closing_date,
+ : `${tt.price} sats`
+ return this.$t('events.tt_chip', {
+ name: tt.name,
price,
- amount: wave.amount_tickets,
- sold: this.soldTicketsForWave(event.id, wave.id)
+ sold: tt.sold || 0,
+ max: tt.max_tickets
})
},
+ soldTicketsForType(ttId) {
+ return this.allPaidTickets.filter(
+ ticket =>
+ ticket.paid &&
+ ticket.ticket_type_id === ttId
+ ).length
+ },
shortenId(value) {
if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value
},
- async loadOnchainWallets() {
- const wallet = _.findWhere(this.g.user.wallets, {
- id: this.formDialog.data.wallet
- })
- if (!wallet) return
- try {
- const {data} = await LNbits.api.request(
- 'GET',
- '/events/api/v1/events/onchain/status',
- wallet.adminkey
- )
- this.onchainWallets = data.available ? data.wallets || [] : []
- } catch {
- this.onchainWallets = []
- }
- },
- async confirmOnchainTicket(ticket) {
- const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
- if (!wallet) return
- try {
- await LNbits.api.request(
- 'PUT',
- `/events/api/v1/tickets/${ticket.id}/onchain-confirm`,
- wallet.adminkey
- )
- Quasar.Notify.create({
- type: 'positive',
- message: this.$t('events.onchain_confirmed'),
- icon: null
- })
- await this.getTickets()
- await this.getAllTickets()
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
- },
- primaryTicketWave(data = this.formDialog.data) {
- if (!data.extra) data.extra = {}
- if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
- data.extra.ticket_waves = [
- {
- id: 'primary',
- title: 'Primary wave',
- opening_date: data.closing_date || '',
- closing_date: data.closing_date || '',
- currency: data.currency || 'sats',
- use_ticket_image: false,
- ticket_image_id: null,
- allow_fiat: Boolean(data.allow_fiat),
- fiat_currency: data.fiat_currency || 'GBP',
- amount_tickets: data.amount_tickets || 0,
- price_per_ticket: data.price_per_ticket || 0
- }
- ]
- }
- return data.extra.ticket_waves[0]
- },
- syncPrimaryWaveFromForm(data = this.formDialog.data) {
- const primaryWave = this.primaryTicketWave(data)
- primaryWave.title = primaryWave.title || 'Primary wave'
- primaryWave.opening_date = primaryWave.opening_date || ''
- primaryWave.closing_date = data.closing_date || ''
- primaryWave.currency = data.currency || 'sats'
- primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
- primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
- primaryWave.allow_fiat = Boolean(data.allow_fiat)
- primaryWave.fiat_currency = data.fiat_currency || 'GBP'
- primaryWave.amount_tickets = Number(data.amount_tickets || 0)
- primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
- return primaryWave
- },
- hydrateEventForm(data) {
- const formData = {
- ...data,
- extra: {
- ...(data.extra || {}),
- ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
- }
- }
- const primaryWave = this.primaryTicketWave(formData)
- formData.currency = primaryWave.currency || formData.currency || 'sats'
- formData.allow_fiat = Boolean(primaryWave.allow_fiat)
- formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
- formData.amount_tickets = primaryWave.amount_tickets
- formData.price_per_ticket = primaryWave.price_per_ticket
- formData.closing_date =
- primaryWave.closing_date || formData.closing_date || ''
- return formData
- },
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
+ getEventCurrency(eventId) {
+ const event = this.events.find(e => e.id === eventId)
+ return event?.currency || 'sats'
+ },
normalizePromoCodes(promoCodes = []) {
return promoCodes
.filter(code => code.code?.trim() !== '')
@@ -289,79 +244,39 @@ window.PageEvents = {
)
return data.id
},
- triggerTicketImageUpload(target) {
- this.ticketImageUploadTarget = target
- this.$refs.ticketImageUpload.value = null
- this.$refs.ticketImageUpload.click()
- },
- async handleTicketImageSelected(event) {
- const file = event.target.files?.[0]
- if (!file || !this.ticketImageUploadTarget) return
-
- this.isUploadingTicketTemplate = true
- try {
- const assetId = await this.uploadAssetFile(file)
- if (this.ticketImageUploadTarget === 'primary') {
- const wave = this.primaryTicketWave()
- wave.use_ticket_image = true
- wave.ticket_image_id = assetId
- } else if (this.ticketImageUploadTarget === 'dialog') {
- this.ticketWaveDialog.data.use_ticket_image = true
- this.ticketWaveDialog.data.ticket_image_id = assetId
- }
- Quasar.Notify.create({
- type: 'positive',
- message: this.$t('events.ticket_template_uploaded'),
- icon: null
- })
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- } finally {
- this.isUploadingTicketTemplate = false
- this.ticketImageUploadTarget = null
- }
- },
- soldTicketsForWave(eventId, waveId) {
- return this.allPaidTickets.filter(
- ticket =>
- ticket.event === eventId &&
- ticket.paid &&
- (ticket.extra?.ticket_wave_id === waveId ||
- (!ticket.extra?.ticket_wave_id && waveId === 'primary'))
- ).length
- },
- async getAllTickets() {
- try {
- const {data} = await LNbits.api.request(
+ getAllTickets() {
+ LNbits.api
+ .request(
'GET',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey
)
- this.allPaidTickets = data.filter(ticket => ticket.paid)
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- }
+ .then(response => {
+ this.allPaidTickets = response.data.filter(ticket => ticket.paid)
+ })
+ .catch(LNbits.utils.notifyApiError)
},
- async getTickets(props) {
- try {
- this.ticketsTable.loading = true
- const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
- const {data} = await LNbits.api.request(
+ getTickets(props) {
+ this.ticketsTable.loading = true
+ const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
+ LNbits.api
+ .request(
'GET',
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
this.g.user.wallets[0].adminkey
)
- this.tickets = data.data
- this.ticketsTable.pagination.rowsNumber = data.total
- } catch (error) {
- LNbits.utils.notifyApiError(error)
- } finally {
- this.ticketsTable.loading = false
- }
+ .then(response => {
+ this.tickets = response.data.data
+ this.ticketsTable.pagination.rowsNumber = response.data.total
+ })
+ .catch(LNbits.utils.notifyApiError)
+ .finally(() => {
+ this.ticketsTable.loading = false
+ })
},
deleteTicket(ticketId) {
- const tickets = _.findWhere(this.tickets, {id: ticketId})
- const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
+ const ticket = _.findWhere(this.tickets, {id: ticketId})
+ const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
LNbits.utils
.confirmDialog(this.$t('events.delete_ticket_confirm'))
@@ -422,14 +337,37 @@ 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)
+ LNbits.utils.exportCSV(this.ticketsColumns, this.allPaidTickets)
},
getEvents() {
LNbits.api
@@ -441,14 +379,40 @@ window.PageEvents = {
.then(response => {
this.events = response.data
this.checkCanceledEvents()
+ this.events.forEach(ev => this.loadTicketTypes(ev.id))
+ })
+ },
+ fetchWatchOnlyWallets() {
+ LNbits.api
+ .request(
+ 'GET',
+ '/watchonly/api/v1/wallet',
+ this.g.user.wallets[0].adminkey
+ )
+ .then(response => {
+ this.watchonlyWallets = (response.data || []).map(w => ({
+ value: w.id,
+ label: (w.title || w.name || 'Wallet') + ' - ' + w.id
+ }))
+ })
+ .catch(() => {
+ console.warn('WatchOnly extension not available, onchain disabled')
})
},
sendEventData() {
- const wallet = _.findWhere(this.g.user.wallets, {
- id: this.formDialog.data.wallet
- })
const data = this.formDialog.data
- this.syncPrimaryWaveFromForm(data)
+ if (!data.id) {
+ 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
+ }
+ if (this.paymentMethods.onchain && !data.extra?.onchain_wallet_id) {
+ this.$q.notify({message: 'Select an onchain wallet for onchain payments.', type: 'negative'})
+ return
+ }
if (data.extra?.promo_codes) {
data.extra.promo_codes = this.normalizePromoCodes(
data.extra.promo_codes
@@ -459,20 +423,36 @@ window.PageEvents = {
data.fiat_currency = 'GBP'
}
}
- this.syncPrimaryWaveFromForm(data)
-
+ const methods = []
+ if (this.paymentMethods.ln) methods.push('ln')
+ if (this.paymentMethods.onchain) methods.push('onchain')
+ if (this.paymentMethods.fiat) methods.push('fiat')
+ data.extra.payment_methods = methods
if (data.id) {
this.updateEvent(wallet, data)
} else {
this.createEvent(wallet, data)
}
},
-
openEventDialog(data = false) {
if (data && data.id) {
- this.formDialog.data = this.hydrateEventForm(data)
+ this.formDialog.data = {
+ ...data,
+ extra: {
+ ...(data.extra || {}),
+ promo_codes: [...((data.extra && data.extra.promo_codes) || [])],
+ }
+ }
+ const pm = (data.extra && data.extra.payment_methods) || []
+ this.paymentMethods.ln = pm.includes('ln')
+ this.paymentMethods.onchain = pm.includes('onchain')
+ this.paymentMethods.fiat = pm.includes('fiat')
+ if (!data.extra.fiat_provider) {
+ data.extra.fiat_provider = ''
+ }
} else {
this.formDialog.data = {
+ wallet: null,
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
@@ -481,42 +461,25 @@ window.PageEvents = {
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
- onchain_enabled: false,
- onchain_wallet_id: null,
- onchain_zeroconf: false,
- onchain_fasttrack: false,
- ticket_waves: [
- {
- id: 'primary',
- title: 'Primary wave',
- opening_date: '',
- closing_date: '',
- currency: 'sats',
- use_ticket_image: false,
- ticket_image_id: null,
- allow_fiat: false,
- fiat_currency: 'GBP',
- amount_tickets: 0,
- price_per_ticket: 0
- }
- ],
promo_codes: [],
notification_subject: '',
- notification_body: ''
+ notification_body: '',
+ payment_methods: [],
+ ln_wallet_id: null,
+ onchain_wallet_id: null,
+ fiat_provider: ''
}
}
+ this.paymentMethods.ln = false
+ this.paymentMethods.onchain = false
+ this.paymentMethods.fiat = false
}
this.formDialog.show = true
- if (
- this.formDialog.data.wallet &&
- this.formDialog.data.extra?.onchain_enabled
- ) {
- this.loadOnchainWallets()
- }
},
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
+ wallet: this.g.user.wallets?.[0]?.id,
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
@@ -525,30 +488,19 @@ window.PageEvents = {
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
- onchain_enabled: false,
- onchain_wallet_id: null,
- ticket_waves: [
- {
- id: 'primary',
- title: 'Primary wave',
- opening_date: '',
- closing_date: '',
- currency: 'sats',
- use_ticket_image: false,
- ticket_image_id: null,
- allow_fiat: false,
- fiat_currency: 'GBP',
- amount_tickets: 0,
- price_per_ticket: 0
- }
- ],
promo_codes: [],
notification_subject: '',
- notification_body: ''
+ notification_body: '',
+ payment_methods: [],
+ ln_wallet_id: null,
+ onchain_wallet_id: null,
+ fiat_provider: ''
}
}
+ this.paymentMethods.ln = false
+ this.paymentMethods.onchain = false
+ this.paymentMethods.fiat = false
},
-
createEvent(wallet, data) {
LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
@@ -562,114 +514,216 @@ window.PageEvents = {
const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link)
},
- openTicketWaveDialog(event, wave = null) {
- const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
- const isEditing = Boolean(wave)
- this.ticketWaveDialog = {
+ updateEvent(wallet, data) {
+ LNbits.api
+ .request(
+ 'PUT',
+ '/events/api/v1/events/' + data.id,
+ wallet.adminkey,
+ data
+ )
+ .then(response => {
+ this.events = _.reject(this.events, function (obj) {
+ return obj.id == data.id
+ })
+ this.events.push(response.data)
+ this.resetEventDialog()
+ })
+ .catch(LNbits.utils.notifyApiError)
+ },
+ deleteEvent(eventsId) {
+ const events = _.findWhere(this.events, {id: eventsId})
+
+ LNbits.utils
+ .confirmDialog(this.$t('events.delete_event_confirm'))
+ .onOk(() => {
+ LNbits.api
+ .request(
+ 'DELETE',
+ '/events/api/v1/events/' + eventsId,
+ _.findWhere(this.g.user.wallets, {id: events.wallet}).adminkey
+ )
+ .then(response => {
+ this.events = _.reject(this.events, function (obj) {
+ return obj.id == eventsId
+ })
+ })
+ .catch(LNbits.utils.notifyApiError)
+ })
+ },
+ exporteventsCSV() {
+ LNbits.utils.exportCSV(this.eventsColumns, this.events)
+ },
+ async checkCanceledEvents() {
+ const events = this.events
+ .filter(event => event.extra.conditional)
+ .filter(e => !e.canceled)
+ if (!events.length) return
+ const now = new Date()
+ events.forEach(async ev => {
+ if (new Date(ev.event_end_date) < now && ev.sold < ev.extra.min_tickets) {
+ const {data} = await LNbits.api.request(
+ 'PUT',
+ '/events/api/v1/events/' + ev.id + '/cancel',
+ _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey
+ )
+ Quasar.Notify.create({
+ type: 'warning',
+ message: this.$t('events.event_canceled_notify', {name: ev.name}),
+ icon: null
+ })
+ this.events = this.events.map(e => (e.id === ev.id ? data : e))
+ }
+ })
+ },
+
+ loadTicketTypes(eventId) {
+ const wallet = _.findWhere(this.g.user.wallets, {
+ id: _.findWhere(this.events, {id: eventId})?.wallet
+ })
+ if (!wallet) return
+ LNbits.api
+ .request(
+ 'GET',
+ `/events/api/v1/ticket-types/${eventId}`,
+ wallet.inkey
+ )
+ .then(response => {
+ this.ticketTypesByEvent[eventId] = response.data
+ })
+ .catch(LNbits.utils.notifyApiError)
+ },
+ openTicketTypeDialog(eventId, tt = null) {
+ const event = _.findWhere(this.events, {id: eventId})
+ if (!event) return
+ const isEdit = Boolean(tt)
+ this.ticketTypeDialog = {
show: true,
- eventId: event.id,
+ eventId,
+ isEdit,
wallet: event.wallet,
- editingWaveId: wave?.id || null,
data: {
- id: wave?.id || null,
- title: wave?.title || '',
- opening_date: wave?.opening_date || '',
- closing_date: wave?.closing_date || '',
- currency:
- wave?.currency || primaryWave.currency || event.currency || 'sats',
- use_ticket_image: Boolean(wave?.use_ticket_image),
- ticket_image_id: wave?.ticket_image_id || null,
- allow_fiat: isEditing
- ? Boolean(wave?.allow_fiat)
- : Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
- fiat_currency:
- wave?.fiat_currency ||
- primaryWave.fiat_currency ||
- event.fiat_currency ||
- 'GBP',
- amount_tickets: wave?.amount_tickets || 0,
- price_per_ticket:
- wave?.price_per_ticket ||
- primaryWave.price_per_ticket ||
- event.price_per_ticket ||
- 0
+ id: tt?.id || null,
+ name: tt?.name || '',
+ description: tt?.description || '',
+ image_url: tt?.image_url || null,
+ price: tt?.price || 0,
+ 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,
+ active: tt?.extra?.active ?? true
}
}
},
- resetTicketWaveDialog() {
- this.ticketWaveDialog = {
+ resetTicketTypeDialog() {
+ this.ticketTypeDialog = {
show: false,
eventId: null,
+ isEdit: false,
wallet: null,
- editingWaveId: null,
data: {
id: null,
- title: '',
- opening_date: '',
- closing_date: '',
- currency: 'sats',
- use_ticket_image: false,
- ticket_image_id: null,
- allow_fiat: false,
- fiat_currency: 'GBP',
- amount_tickets: 0,
- price_per_ticket: 0
+ name: '',
+ description: '',
+ image_url: null,
+ price: 0,
+ max_tickets: 0,
+ available_from: '',
+ available_to: '',
+ sort_order: 0,
+ active: true
}
}
},
- saveTicketWave() {
+ saveTicketType() {
const event = _.findWhere(this.events, {
- id: this.ticketWaveDialog.eventId
+ id: this.ticketTypeDialog.eventId
})
const wallet = _.findWhere(this.g.user.wallets, {
- id: this.ticketWaveDialog.wallet
+ id: this.ticketTypeDialog.wallet
})
if (!event || !wallet) return
const payload = {
- ...event,
+ ...this.ticketTypeDialog.data,
+ event_id: this.ticketTypeDialog.eventId,
extra: {
- ...event.extra,
- ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
- existingWave.id === this.ticketWaveDialog.editingWaveId
- ? {...this.ticketWaveDialog.data}
- : existingWave
- )
+ ...this.ticketTypeDialog.data.extra,
+ active: this.ticketTypeDialog.data.active
}
}
-
- if (!this.ticketWaveDialog.editingWaveId) {
- payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
+ if (!this.ticketTypeDialog.isEdit) {
+ delete payload.id
}
+ delete payload.active
- if (payload.extra?.promo_codes) {
- payload.extra.promo_codes = this.normalizePromoCodes(
- payload.extra.promo_codes
- )
- }
+ const request = this.ticketTypeDialog.isEdit
+ ? LNbits.api.request(
+ 'PUT',
+ `/events/api/v1/ticket-types/${this.ticketTypeDialog.eventId}/${this.ticketTypeDialog.data.id}`,
+ wallet.adminkey,
+ payload
+ )
+ : LNbits.api.request(
+ 'POST',
+ `/events/api/v1/ticket-types/${this.ticketTypeDialog.eventId}`,
+ wallet.adminkey,
+ payload
+ )
- LNbits.api
- .request(
- 'PUT',
- '/events/api/v1/events/' + payload.id,
- wallet.adminkey,
- payload
- )
+ request
.then(response => {
- this.events = this.events.map(item =>
- item.id === payload.id ? response.data : item
- )
+ const tts = this.ticketTypesByEvent[this.ticketTypeDialog.eventId] || []
+ if (this.ticketTypeDialog.isEdit) {
+ this.ticketTypesByEvent[this.ticketTypeDialog.eventId] = tts.map(
+ t => (t.id === response.data.id ? response.data : t)
+ )
+ } else {
+ this.ticketTypesByEvent[this.ticketTypeDialog.eventId] = [
+ ...tts,
+ response.data
+ ]
+ }
Quasar.Notify.create({
type: 'positive',
- message: this.ticketWaveDialog.editingWaveId
- ? this.$t('events.ticket_wave_updated')
- : this.$t('events.ticket_wave_added'),
+ message: this.ticketTypeDialog.isEdit
+ ? this.$t('events.ticket_type_updated')
+ : this.$t('events.ticket_type_added'),
icon: null
})
- this.resetTicketWaveDialog()
+ this.resetTicketTypeDialog()
})
.catch(LNbits.utils.notifyApiError)
},
+ deleteTicketType(eventId, ttId) {
+ const wallet = _.findWhere(this.g.user.wallets, {
+ id: _.findWhere(this.events, {id: eventId})?.wallet
+ })
+ if (!wallet) return
+ LNbits.utils
+ .confirmDialog(this.$t('events.delete_ticket_type_confirm'))
+ .onOk(() => {
+ LNbits.api
+ .request(
+ 'DELETE',
+ `/events/api/v1/ticket-types/${eventId}/${ttId}`,
+ wallet.adminkey
+ )
+ .then(() => {
+ const tts = this.ticketTypesByEvent[eventId] || []
+ this.ticketTypesByEvent[eventId] = tts.filter(
+ t => t.id !== ttId
+ )
+ Quasar.Notify.create({
+ type: 'positive',
+ message: this.$t('events.ticket_type_deleted'),
+ icon: null
+ })
+ })
+ .catch(LNbits.utils.notifyApiError)
+ })
+ },
openPromoCodesDialog(event) {
this.promoCodesDialog.data = {
...event,
@@ -678,6 +732,14 @@ window.PageEvents = {
promo_codes: [...(event.extra?.promo_codes || [])]
}
}
+ this.promoDiscountTypes = {}
+ ;(event.extra?.promo_codes || []).forEach((code, idx) => {
+ if (code.discount_fixed != null) {
+ this.promoDiscountTypes[idx] = 'fixed'
+ } else {
+ this.promoDiscountTypes[idx] = 'percent'
+ }
+ })
this.promoCodesDialog.show = true
},
resetPromoCodesDialog() {
@@ -690,13 +752,30 @@ window.PageEvents = {
promo_codes: []
}
}
+ this.promoDiscountTypes = {}
},
addPromoCodeToDialog() {
+ const idx = this.promoCodesDialog.data.extra.promo_codes.length
this.promoCodesDialog.data.extra.promo_codes.push({
code: '',
- discount_percent: 0,
- active: true
+ discount_percent: undefined,
+ discount_fixed: undefined,
+ active: true,
+ combinable: true,
+ max_uses: null,
+ used_count: 0
})
+ this.promoDiscountTypes[idx] = 'percent'
+ },
+ onPromoDiscountTypeChange(index, type) {
+ this.promoDiscountTypes[index] = type
+ const code = this.promoCodesDialog.data.extra.promo_codes[index]
+ if (!code) return
+ if (type === 'fixed') {
+ code.discount_percent = undefined
+ } else {
+ code.discount_fixed = undefined
+ }
},
savePromoCodes() {
const data = this.promoCodesDialog.data
@@ -733,79 +812,216 @@ window.PageEvents = {
})
.catch(LNbits.utils.notifyApiError)
},
- updateEvent(wallet, data) {
+ 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
+ },
+ 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})
+ 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/' + data.id,
- wallet.adminkey,
- data
- )
+ .request('PUT', '/events/api/v1/events/' + eventId, wallet.adminkey, payload)
.then(response => {
- this.events = _.reject(this.events, function (obj) {
- return obj.id == data.id
+ 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.events.push(response.data)
- this.resetEventDialog()
+ this.editPromoCodeDialog.show = false
})
.catch(LNbits.utils.notifyApiError)
},
- deleteEvent(eventsId) {
- const events = _.findWhere(this.events, {id: eventsId})
+ 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
- LNbits.utils
- .confirmDialog(this.$t('events.delete_event_confirm'))
- .onOk(() => {
- LNbits.api
- .request(
- 'DELETE',
- '/events/api/v1/events/' + eventsId,
- _.findWhere(this.g.user.wallets, {id: events.wallet}).adminkey
- )
- .then(response => {
- this.events = _.reject(this.events, function (obj) {
- return obj.id == eventsId
- })
- })
- .catch(LNbits.utils.notifyApiError)
+ 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)
},
- exporteventsCSV() {
- LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
- },
- async checkCanceledEvents() {
- const events = this.events
- .filter(event => event.extra.conditional)
- .filter(e => !e.canceled)
- if (!events.length) return
- const now = new Date()
- events.forEach(async ev => {
- if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) {
- const {data} = await LNbits.api.request(
- 'PUT',
- '/events/api/v1/events/' + ev.id + '/cancel',
- _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey
+ resendAllTicketEmails(eventId) {
+ const event = _.findWhere(this.events, {id: eventId})
+ if (!event) return
+ const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet})
+ if (!wallet) return
+
+ 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 = {
+ show: true,
+ eventId,
+ subject: '',
+ message: '',
+ loading: false
+ }
+ },
+ resetEmailAllDialog() {
+ this.emailAllDialog = {
+ show: false,
+ eventId: null,
+ subject: '',
+ message: '',
+ loading: false
+ }
+ },
+ sendEmailToAttendees() {
+ if (!this.emailAllDialog.subject || !this.emailAllDialog.message) return
+ const event = _.findWhere(this.events, {id: this.emailAllDialog.eventId})
+ if (!event) return
+ const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet})
+ if (!wallet) return
+
+ this.emailAllDialog.loading = true
+ LNbits.api
+ .request(
+ 'POST',
+ `/events/api/v1/events/${this.emailAllDialog.eventId}/email-message`,
+ wallet.adminkey,
+ {
+ subject: this.emailAllDialog.subject,
+ message: this.emailAllDialog.message
+ }
+ )
+ .then(() => {
Quasar.Notify.create({
- type: 'warning',
- message: this.$t('events.event_canceled_notify', {name: ev.name}),
+ type: 'positive',
+ message: this.$t('events.bulk_message_sent'),
icon: null
})
- this.events = this.events.map(e => (e.id === ev.id ? data : e))
- }
- })
+ this.resetEmailAllDialog()
+ })
+ .catch(LNbits.utils.notifyApiError)
+ .finally(() => {
+ this.emailAllDialog.loading = false
+ })
+ },
+ previewTicketEmailUrl(event) {
+ const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet})
+ if (!wallet) return '#'
+ return `/events/api/v1/events/${event.id}/preview/ticket-email?key=${wallet.inkey}`
+ },
+ previewAdminEmailUrl(event) {
+ const wallet = _.findWhere(this.g.user.wallets, {id: event.wallet})
+ if (!wallet) return '#'
+ return `/events/api/v1/events/${event.id}/preview/admin-email?key=${wallet.inkey}`
}
},
- async created() {
+ created() {
if (this.g.user.wallets.length) {
this.getTickets()
this.getAllTickets()
this.getEvents()
+ this.fetchWatchOnlyWallets()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
this.currencies = ['sats', ...this.g.allowedCurrencies]
} else {
this.currencies = ['sats', ...this.g.currencies]
}
}
- }
+ },
}
diff --git a/static/js/index.vue b/static/js/index.vue
index e7ddaee..46bec15 100644
--- a/static/js/index.vue
+++ b/static/js/index.vue
@@ -1,3 +1,4 @@
+# page-events
@@ -109,93 +110,140 @@
-
+
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -234,12 +282,16 @@
>
+
+
-
+
+ {{ $t('events.col_satspay') }}
+
@@ -271,28 +323,23 @@
+
+
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+ {{ shortenId(props.row.extra.satspay_charge_id) }}
+
+ —
+
@@ -359,26 +422,14 @@
+
@@ -419,264 +478,168 @@
>
-
-
-
-
-
+
-
-
-
-
-
- 400/733 jpg
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
{{ $t('events.payment_methods') }}
+
+
+
+ >
-
-
+
+ emit-value
+ v-model="formDialog.data.extra.ln_wallet_id"
+ :options="g.user.walletOptions"
+ :label="$t('events.ln_wallet_label')"
+ >
-
-
-
-
-
-
+
+
+ >
+
+ {{ $t('events.onchain_disabled_hint') }}
+
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+ filled
+ dense
+ v-model.trim="formDialog.data.extra.notification_body"
+ type="textarea"
+ :label="$t('events.notification_body_label')"
+ :hint="$t('events.notification_body_hint')"
+ >
+
+
+
+
+
+
val && loadOnchainWallets()"
>
-
+
+
-
-
-
-
+ v-model.number="formDialog.data.extra.min_tickets"
+ type="number"
+ :label="$t('events.min_tickets_label')"
+ :disable="!formDialog.data.extra.conditional"
+ >
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+ {{ code.code || $t('events.promo_code_label') }}
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+ type="number"
+ :label="$t('events.discount_percent_label')"
+ min="0"
+ max="100"
+ @update:model-value="onPromoDiscountTypeChange(index, 'percent')"
+ >
+
+ %
+
+
+
+
+ {{ promoCodesDialog.data.currency === 'sat' ? 'sats' : promoCodesDialog.data.currency }}
+
+
+
+
+
-
+
@@ -814,167 +955,175 @@
-
-
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
-
+
%
+
+
+ {{ getEventCurrency(editPromoCodeDialog.eventId) }}
+
+
+
+
+
-
- 400/733 jpg
-
-
-
+ flat
+ color="grey"
+ @click="editPromoCodeDialog.show = false"
+ v-text="$t('cancel')"
+ >
-
-
-
+
+
+
+
+
+
+
+
+ v-model.trim="emailAllDialog.subject"
+ type="text"
+ :label="$t('events.email_subject_label')"
+ :rules="[val => !!val || $t('events.required')]"
+ >
+
- {{
- ticketWaveDialog.editingWaveId
- ? $t('events.update_ticket_wave')
- : $t('events.save_ticket_wave')
- }}
+
-
+
+
diff --git a/static/js/register.js b/static/js/register.js
index 1c83c2b..69973b8 100644
--- a/static/js/register.js
+++ b/static/js/register.js
@@ -1,4 +1,4 @@
-window.PageEventsRegister = {
+;window.PageEventsRegister = {
template: '#page-events-register',
computed: {
ticketsColumns() {
@@ -7,26 +7,26 @@ window.PageEventsRegister = {
name: 'name',
align: 'left',
label: this.$t('events.col_name'),
- field: 'name'
+ field: row => row.name || this.$t('events.anon')
},
{
- name: 'email',
+ name: 'ticket_type',
align: 'left',
- label: this.$t('email'),
- field: 'email'
+ label: this.$t('events.col_ticket_type'),
+ field: row => row.ticket_type_name || '-'
},
{
name: 'id',
align: 'left',
- label: this.$t('id'),
- field: 'id',
- format: val => this.shortId(val)
+ label: 'Ticket ID',
+ field: 'id'
}
]
}
},
data() {
return {
+ eventName: '',
tickets: [],
ticketsTable: {
pagination: {
@@ -37,7 +37,8 @@ window.PageEventsRegister = {
show: false,
camera: 'auto'
},
- lastScan: null
+ lastScan: null,
+ ticketTypes: []
}
},
methods: {
@@ -48,18 +49,30 @@ window.PageEventsRegister = {
this.tickets = Quasar.LocalStorage.getItem(this.storageKey()) || []
},
saveScannedTicket(ticket) {
+ if (!ticket.name) ticket.name = 'Anon'
+ if (ticket.ticket_type_id && this.ticketTypes.length) {
+ const tt = this.ticketTypes.find(t => t.id === ticket.ticket_type_id)
+ if (tt) ticket.ticket_type_name = tt.name
+ }
+ const existingIndex = this.tickets.findIndex(t => t.id === ticket.id)
+ if (existingIndex >= 0) {
+ this.tickets.splice(existingIndex, 1)
+ }
this.tickets.unshift(ticket)
Quasar.LocalStorage.set(this.storageKey(), this.tickets)
},
+ resolveTypeName(ticket) {
+ if (ticket.ticket_type_id && this.ticketTypes.length) {
+ const tt = this.ticketTypes.find(t => t.id === ticket.ticket_type_id)
+ if (tt) ticket.ticket_type_name = tt.name
+ }
+ },
closeCamera() {
this.sendCamera.show = false
},
showCamera() {
this.sendCamera.show = true
},
- shortId(id) {
- return id ? `${id.slice(0, 6)}...${id.slice(-4)}` : ''
- },
decodeQR(res) {
this.sendCamera.show = false
const value = res[0].rawValue.split('//')[1]
@@ -68,7 +81,6 @@ window.PageEventsRegister = {
.then(response => {
this.saveScannedTicket(response.data)
this.lastScan = {success: true, ticket: response.data}
- Quasar.Notify.create({type: 'positive', message: 'Registered!'})
})
.catch(error => {
this.lastScan = {
@@ -77,12 +89,30 @@ window.PageEventsRegister = {
error:
error.response?.data?.detail || error.message || 'Unknown error'
}
- LNbits.utils.notifyApiError(error)
})
}
},
- created() {
+ async created() {
this.eventId = this.$route.params.id
+ try {
+ const {data} = await LNbits.api.request(
+ 'GET',
+ `/events/api/v1/events/${this.eventId}`
+ )
+ this.eventName = data.name || ''
+ } catch (error) {
+ // ignore
+ }
+ try {
+ const {data} = await LNbits.api.request(
+ 'GET',
+ `/events/api/v1/ticket-types/${this.eventId}`
+ )
+ this.ticketTypes = data || []
+ } catch (error) {
+ this.ticketTypes = []
+ }
this.loadScannedTickets()
+ this.tickets.forEach(t => this.resolveTypeName(t))
}
}
diff --git a/static/js/register.vue b/static/js/register.vue
index 24bc242..9b84af9 100644
--- a/static/js/register.vue
+++ b/static/js/register.vue
@@ -2,61 +2,31 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
-
-
-
- {{ lastScan.ticket.name }}
-
-
-
- {{ lastScan.ticket.email }}
-
-
-
- {{ lastScan.ticket.paid }}
-
-
-
- {{ shortId(lastScan.ticket.id) }}
+
+
+
+
+
-
-
- {{ shortId(lastScan.ticketId) }}
-
-
-
- {{ lastScan.error }}
-
+
@@ -81,7 +51,8 @@
-
+
+
diff --git a/static/js/ticket.js b/static/js/ticket.js
index 6ff55b6..91ae4da 100644
--- a/static/js/ticket.js
+++ b/static/js/ticket.js
@@ -1,14 +1,22 @@
-window.PageEventsTicket = {
+;window.PageEventsTicket = {
template: '#page-events-ticket',
data() {
return {
ticketId: null,
ticket: null,
+ eventName: '',
+ ticketTypeName: '',
printMode: false,
qrSrc: ''
}
},
- methods: {
+ methods: {
+ copyTicketUrl() {
+ const url = `${window.location.origin}/events/ticket/${this.ticketId}`
+ navigator.clipboard.writeText(url).then(() => {
+ this.$q.notify({message: 'Link copied', type: 'positive'})
+ })
+ },
async printWindow() {
this.printMode = true
await this.$nextTick()
@@ -39,6 +47,8 @@ window.PageEventsTicket = {
`/events/api/v1/tickets/${this.ticketId}`
)
this.ticket = data
+ this.eventName = data.event_name || ''
+ this.ticketTypeName = data.ticket_type_name || ''
} catch (error) {
LNbits.utils.notifyApiError(error)
}
diff --git a/static/js/ticket.vue b/static/js/ticket.vue
index a1ea2f8..98fc115 100644
--- a/static/js/ticket.vue
+++ b/static/js/ticket.vue
@@ -2,45 +2,30 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -48,6 +33,8 @@
+
+
@@ -76,6 +63,7 @@
.ticket-print-sheet {
display: flex !important;
+ flex-direction: column;
align-items: center;
justify-content: center;
width: 100vw;
@@ -87,6 +75,18 @@
print-color-adjust: exact;
}
+ .ticket-print-event {
+ text-align: center;
+ margin-bottom: 8px;
+ }
+
+ .ticket-print-type {
+ text-align: center;
+ margin-top: 0;
+ margin-bottom: 20px;
+ color: #666 !important;
+ }
+
.ticket-print-qr {
display: block;
width: 320px;
diff --git a/static/routes.json b/static/routes.json
index f94b832..f2b90ec 100644
--- a/static/routes.json
+++ b/static/routes.json
@@ -6,6 +6,13 @@
"component": "/events/static/js/index.js",
"i18n": "/events/static/js/i18n"
},
+ {
+ "path": "/events/basket/:id",
+ "name": "PageEventsBasket",
+ "template": "/events/static/js/basket.vue",
+ "component": "/events/static/js/basket.js",
+ "i18n": "/events/static/js/i18n"
+ },
{
"path": "/events/:id",
"name": "PageEventsDisplay",
diff --git a/tasks.py b/tasks.py
index 1d30dce..a26c426 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,12 +1,6 @@
import asyncio
-from lnbits.core.models import Payment
-from lnbits.tasks import register_invoice_listener
-from loguru import logger
-
-from .crud import get_ticket
from .models import Ticket
-from .services import send_ticket_notification_in_background, set_ticket_paid
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
@@ -22,28 +16,3 @@ def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> N
payment_listeners[payment_hash].remove(queue)
if not payment_listeners[payment_hash]:
del payment_listeners[payment_hash]
-
-
-async def wait_for_paid_invoices():
- invoice_queue = asyncio.Queue()
- register_invoice_listener(invoice_queue, "ext_events")
-
- while True:
- payment = await invoice_queue.get()
- await on_invoice_paid(payment)
-
-
-async def on_invoice_paid(payment: Payment) -> None:
- if not payment.extra or "events" != payment.extra.get("tag"):
- return
-
- ticket = await get_ticket(payment.payment_hash)
- if not ticket:
- logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
- return
-
- ticket = await set_ticket_paid(ticket)
- send_ticket_notification_in_background(ticket)
- if payment_listeners.get(payment.payment_hash):
- for paid_ticket_queue in payment_listeners[payment.payment_hash]:
- paid_ticket_queue.put_nowait(ticket)
diff --git a/views.py b/views.py
index 4a3f142..0d6a33f 100644
--- a/views.py
+++ b/views.py
@@ -22,3 +22,7 @@
events_generic_router.add_api_route(
"/register/{event_id}", methods=["GET"], endpoint=index_public
)
+
+events_generic_router.add_api_route(
+ "/basket/{basket_id}", methods=["GET"], endpoint=index_public
+)
diff --git a/views_api.py b/views_api.py
index c55ffa0..d5559ac 100644
--- a/views_api.py
+++ b/views_api.py
@@ -1,9 +1,6 @@
-import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from io import BytesIO
-from pathlib import Path
-from typing import Any
import pyqrcode # type: ignore[import-untyped]
from fastapi import (
@@ -12,116 +9,76 @@
HTTPException,
Query,
Request,
- WebSocket,
- WebSocketDisconnect,
)
-from fastapi.responses import StreamingResponse
+from fastapi.responses import HTMLResponse, StreamingResponse
from lnbits.core.crud import get_user
-from lnbits.core.crud.assets import get_public_asset
-from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import WalletTypeInfo
-from lnbits.core.models.payments import CreateInvoice
-from lnbits.core.services import create_payment_request
-from lnbits.db import Filters, Page
from lnbits.decorators import (
- parse_filters,
require_admin_key,
require_invoice_key,
)
-from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash
+from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import settings
-from lnbits.utils.exchange_rates import (
- fiat_amount_as_satoshis,
- get_fiat_rate_satoshis,
- satoshis_amount_as_fiat,
-)
from loguru import logger
from PIL import Image, ImageDraw
from .crud import (
- create_event,
- create_ticket,
- delete_event,
+ create_event as create_event_crud,
+ create_ticket_type,
+ delete_event as delete_event_crud,
+ delete_event_ticket_types,
delete_event_tickets,
delete_ticket,
+ delete_ticket_type,
+ get_basket,
+ get_basket_tickets,
get_event,
- get_events,
+ get_event_tickets,
get_ticket,
+ get_ticket_types,
get_tickets,
+ get_tickets_by_satspay_charge_id,
get_tickets_paginated,
purge_unpaid_tickets,
- update_event,
+ update_event as update_event_crud,
update_ticket,
+ update_ticket_type,
+ get_events,
)
from .models import (
+ BasketItem,
+ BasketResponse,
+ BasketTotals,
+ CreateBasket,
CreateEvent,
CreateTicket,
Event,
+ PromoValidateRequest,
PublicEvent,
PublicTicket,
Ticket,
- TicketFilters,
TicketPaymentRequest,
- TicketResendResult,
- ensure_ticket_waves,
- get_active_ticket_waves,
+ TicketType,
)
from .services import (
- create_satspay_charge,
- fetch_watchonly_config,
- fetch_watchonly_wallets,
+ calculate_basket_total,
+ create_basket_with_charge,
get_satspay_charge,
+ handle_basket_payment,
+ preview_admin_email_html,
+ preview_ticket_email_html,
refund_tickets,
resend_ticket_email_notification,
- send_ticket_notification_in_background,
- set_ticket_paid,
-)
-from .tasks import (
- deregister_payment_listener,
- payment_listeners,
- register_payment_listener,
+ send_bulk_message,
+ send_bulk_ticket_emails,
+ toggle_ticket_deactivation,
)
-events_api_router = APIRouter(prefix="/api/v1/events")
-tickets_api_router = APIRouter(prefix="/api/v1/tickets")
+events_api_router = APIRouter(prefix="/api/v1")
qr_api_router = APIRouter(prefix="/api/v1")
-tickets_filters = parse_filters(TicketFilters)
-
-
-async def _get_watchonly_status(wallet) -> dict[str, Any]:
- try:
- config = await fetch_watchonly_config(wallet.inkey)
- network = config.get("network")
- if not network:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Watchonly extension returned an invalid network.",
- )
- wallets = await fetch_watchonly_wallets(wallet.inkey, network)
- except HTTPException:
- raise
- except Exception as exc:
- return {
- "available": False,
- "message": f"Watchonly extension is not reachable: {exc!s}",
- "network": None,
- "wallets": [],
- "mempool_endpoint": None,
- }
- return {
- "available": True,
- "message": None,
- "network": network,
- "wallets": wallets,
- "mempool_endpoint": config.get("mempool_endpoint"),
- }
-
-
-def _is_fiat_currency(currency: str | None) -> bool:
- return str(currency or "").lower() not in {"sat", "sats"}
-def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image:
+def _make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image:
qr = pyqrcode.create(data)
matrix = qr.code
modules = len(matrix)
@@ -149,101 +106,119 @@ def make_qr_png(data: str, size: int = 235, border: int = 4) -> Image.Image:
return img
-@events_api_router.get("")
+@events_api_router.post("/events")
+async def api_create_event(
+ data: CreateEvent,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> Event:
+ event = await create_event_crud(data)
+ if data.amount_tickets > 0 or data.price_per_ticket > 0:
+ default_tt = TicketType(
+ event_id=event.id,
+ name="General Admission",
+ price=data.price_per_ticket,
+ max_tickets=data.amount_tickets,
+ available_from=data.event_start_date,
+ available_to=data.event_end_date,
+ )
+ await create_ticket_type(default_tt)
+ return event
+
+
+@events_api_router.put("/events/{event_id}")
+async def api_update_event(
+ event_id: str,
+ data: CreateEvent,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> Event:
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
+ )
+ if event.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
+ )
+ event = Event(
+ **{
+ **event.dict(),
+ **data.dict(),
+ "id": event.id,
+ "wallet": event.wallet,
+ "time": event.time,
+ "sold": event.sold,
+ "canceled": event.canceled,
+ }
+ )
+ event = await update_event_crud(event)
+ return event
+
+
+@events_api_router.get("/events")
async def api_events(
all_wallets: bool = Query(False),
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Event]:
wallet_ids = [wallet.wallet.id]
-
if all_wallets:
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
-
return await get_events(wallet_ids)
-@events_api_router.get("/onchain/status")
-async def api_onchain_status(
- wallet: WalletTypeInfo = Depends(require_admin_key),
-) -> dict[str, Any]:
- return await _get_watchonly_status(wallet.wallet)
-
-
-@events_api_router.get("/{event_id}", response_model=PublicEvent)
+@events_api_router.get("/events/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event:
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
+ if event.canceled:
+ raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
+
await purge_unpaid_tickets(event_id)
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
+ )
+
+ ticket_types = await get_ticket_types(event_id)
+ if not ticket_types:
+ raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
+ today_date = datetime.now(timezone.utc).date()
+ available_types = [
+ tt for tt in ticket_types
+ if datetime.strptime(tt.available_from, "%Y-%m-%d").date() <= today_date
+ and datetime.strptime(tt.available_to, "%Y-%m-%d").date() >= today_date
+ and (tt.max_tickets == 0 or tt.sold < tt.max_tickets)
+ ]
+ if not available_types:
+ raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
- today = datetime.now(timezone.utc).date()
- active_waves = get_active_ticket_waves(event, today)
- is_sales_closed = today > datetime.strptime(event.closing_date, "%Y-%m-%d").date()
+ latest_available_to = max(
+ datetime.strptime(tt.available_to, "%Y-%m-%d").date() for tt in ticket_types
+ )
+ is_sales_closed = today_date > latest_available_to
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
- if event.amount_tickets < 1:
- raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
if event.extra.conditional and not is_min_tickets_met and is_sales_closed:
event.canceled = True
- await update_event(event)
+ await update_event_crud(event)
await refund_tickets(event_id)
-
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
- if not active_waves:
+ if is_sales_closed:
raise HTTPException(
- status_code=HTTPStatus.GONE,
- detail=(
- "Ticket closing date has passed."
- if is_sales_closed
- else "No ticket wave is currently open."
- ),
+ status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
)
return event
-@events_api_router.post("")
-@events_api_router.put("/{event_id}")
-async def api_event_create(
- data: CreateEvent,
- wallet: WalletTypeInfo = Depends(require_admin_key),
- event_id: str | None = None,
-) -> Event:
- if event_id:
- event = await get_event(event_id)
- if not event:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
- )
-
- if event.wallet != wallet.wallet.id:
- raise HTTPException(
- status_code=HTTPStatus.FORBIDDEN, detail="Not your event."
- )
- event = Event(
- **{
- **event.dict(),
- **data.dict(),
- "id": event.id,
- "wallet": event.wallet,
- "time": event.time,
- "sold": event.sold,
- "canceled": event.canceled,
- }
- )
- event = await update_event(event)
- else:
- event = await create_event(data)
-
- return event
-
-
-@events_api_router.put("/{event_id}/cancel")
+@events_api_router.put("/events/{event_id}/cancel")
async def api_event_cancel(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
@@ -253,18 +228,16 @@ async def api_event_cancel(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
-
if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
event.canceled = True
- event = await update_event(event)
+ event = await update_event_crud(event)
await refund_tickets(event.id)
-
return event
-@events_api_router.delete("/{event_id}")
-async def api_form_delete(
+@events_api_router.delete("/events/{event_id}")
+async def api_event_delete(
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> None:
event = await get_event(event_id)
@@ -272,405 +245,491 @@ async def api_form_delete(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
+ if event.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
+ await delete_event_tickets(event_id)
+ await delete_event_ticket_types(event_id)
+ await delete_event_crud(event_id)
+
+@events_api_router.post("/events/{event_id}/email-tickets")
+async def api_event_email_tickets(
+ event_id: str,
+ request: Request,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+ subject: str | None = Query(None),
+ body: str | None = Query(None),
+):
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
+ )
if event.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
+ base_url = str(request.base_url).rstrip("/")
+ results = await send_bulk_ticket_emails(event_id, base_url)
+ return {"results": [r.dict() for r in results]}
- await delete_event(event_id)
- await delete_event_tickets(event_id)
+
+@events_api_router.post("/events/{event_id}/email-message")
+async def api_event_email_message(
+ event_id: str,
+ body: dict,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+):
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
+ )
+ if event.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
+ subject = body.get("subject")
+ message = body.get("message")
+ if not subject or not message:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Both 'subject' and 'message' are required.",
+ )
+ results = await send_bulk_message(event_id, subject, message)
+ return {"results": results}
-@tickets_api_router.get("")
-async def api_tickets(
- all_wallets: bool = Query(False),
- key_info: WalletTypeInfo = Depends(require_admin_key),
-) -> list[Ticket]:
- wallet_ids = [key_info.wallet.id]
+@events_api_router.get("/events/{event_id}/preview/ticket-email")
+async def api_preview_ticket_email(
+ event_id: str,
+ request: Request,
+ key: str | None = Query(None),
+ basket_id: str | None = Query(None),
+):
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
+ )
+ wallet = await _check_preview_key(event, key)
+ if not wallet:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Invalid key."
+ )
+ base_url = str(request.base_url).rstrip("/")
+
+ basket = None
+ tickets: list[Ticket] = []
+ if basket_id:
+ basket = await get_basket(basket_id)
+ if basket and basket.event_id == event_id:
+ tickets = await get_basket_tickets(basket_id)
+ if not tickets:
+ tickets = [t for t in await get_event_tickets(event_id) if t.paid][:1]
+ if not tickets:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="No paid tickets found for this event.",
+ )
+ for t in tickets:
+ if not t.extra.ticket_base_url:
+ t.extra.ticket_base_url = base_url
- if all_wallets:
- user = await get_user(key_info.wallet.user)
- wallet_ids = user.wallet_ids if user else []
+ subject, html = await preview_ticket_email_html(event, basket, tickets, base_url)
+ return HTMLResponse(content=html)
- return await get_tickets(wallet_ids)
+@events_api_router.get("/events/{event_id}/preview/admin-email")
+async def api_preview_admin_email(
+ event_id: str,
+ request: Request,
+ key: str | None = Query(None),
+ basket_id: str | None = Query(None),
+):
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
+ )
+ wallet = await _check_preview_key(event, key)
+ if not wallet:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Invalid key."
+ )
+ base_url = str(request.base_url).rstrip("/")
+
+ basket = None
+ tickets: list[Ticket] = []
+ if basket_id:
+ basket = await get_basket(basket_id)
+ if basket and basket.event_id == event_id:
+ tickets = await get_basket_tickets(basket_id)
+ if not basket or not tickets:
+ all_tickets = [t for t in await get_event_tickets(event_id) if t.paid]
+ if all_tickets:
+ tickets = all_tickets[:2]
+ for t in all_tickets:
+ if t.basket_id:
+ basket = await get_basket(t.basket_id)
+ if basket:
+ break
+ if not basket or not tickets:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="No paid tickets/baskets found for this event.",
+ )
+ for t in tickets:
+ if not t.extra.ticket_base_url:
+ t.extra.ticket_base_url = base_url
-@tickets_api_router.get(
- "/paginated",
- summary="Get paginated list of tickets",
- openapi_extra=generate_filter_params_openapi(TicketFilters),
- response_model=Page[Ticket],
-)
-async def api_tickets_paginated(
- all_wallets: bool = Query(False),
- filters: Filters = Depends(tickets_filters),
- key_info: WalletTypeInfo = Depends(require_admin_key),
-) -> Page[Ticket]:
- wallet_ids = [key_info.wallet.id]
+ subject, html = await preview_admin_email_html(event, basket, tickets, base_url)
+ return HTMLResponse(content=html)
- if all_wallets:
- user = await get_user(key_info.wallet.user)
- wallet_ids = user.wallet_ids if user else []
- if not filters.sortby:
- filters.sortby = "time"
- if not filters.direction:
- filters.direction = "desc"
+async def _check_preview_key(event: Event, key: str | None):
+ from lnbits.core.crud import get_wallet_for_key
+ wallet = await get_wallet_for_key(key)
+ if wallet and wallet.id == event.wallet:
+ return wallet
+ return None
- return await get_tickets_paginated(wallet_ids, filters)
+@events_api_router.get("/ticket-types/{event_id}")
+async def api_ticket_types(event_id: str) -> list[TicketType]:
+ return await get_ticket_types(event_id)
-@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
-async def api_get_ticket(ticket_id: str) -> Ticket:
- ticket = await get_ticket(ticket_id)
- if not ticket:
+
+@events_api_router.post("/ticket-types/{event_id}")
+async def api_create_ticket_type(
+ event_id: str,
+ data: TicketType,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> TicketType:
+ event = await get_event(event_id)
+ if not event:
raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- event = await get_event(ticket.event)
+ if event.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
+ data.event_id = event_id
+ return await create_ticket_type(data)
+
+
+@events_api_router.put("/ticket-types/{event_id}/{type_id}")
+async def api_update_ticket_type(
+ event_id: str,
+ type_id: str,
+ data: TicketType,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> TicketType:
+ event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- return ticket
+ if event.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
+ data.event_id = event_id
+ data.id = type_id
+ return await update_ticket_type(data)
-@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
-async def api_ticket_qr(ticket_id: str):
- ticket = await get_ticket(ticket_id)
- if not ticket:
+@events_api_router.delete("/ticket-types/{event_id}/{type_id}")
+async def api_delete_ticket_type(
+ event_id: str,
+ type_id: str,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+) -> None:
+ event = await get_event(event_id)
+ if not event:
raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
+ if event.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.")
+ await delete_ticket_type(type_id)
- event = await get_event(ticket.event)
+
+@events_api_router.post("/baskets/{event_id}")
+async def api_create_basket(
+ event_id: str,
+ data: CreateBasket,
+ request: Request,
+) -> BasketResponse:
+ event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
+ if event.canceled:
+ raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
- waves = ensure_ticket_waves(event)
- wave = next(
- (wave for wave in waves if wave.id == ticket.extra.ticket_wave_id),
- waves[0],
- )
+ from lnbits.core.crud.wallets import get_wallet as get_wallet_crud
- qr_img = make_qr_png(f"ticket://{ticket_id}", size=157)
- output = BytesIO()
+ wallet_record = await get_wallet_crud(event.wallet)
+ if not wallet_record:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
+ )
- if not wave.use_ticket_image:
- qr_img.save(output, format="PNG")
- output.seek(0)
- return StreamingResponse(
- output,
- media_type="image/png",
- headers={
- "Cache-Control": "no-cache, no-store, must-revalidate",
- "Pragma": "no-cache",
- "Expires": "0",
- },
- )
-
- background_bytes = None
- if wave.ticket_image_id:
- asset = await get_public_asset(wave.ticket_image_id)
- if asset:
- background_bytes = asset.data
-
- if background_bytes:
- ticket_image = Image.open(BytesIO(background_bytes)).convert("RGBA")
- else:
- default_template = (
- Path(__file__).resolve().parent / "static" / "image" / "ticket.jpg"
- )
- ticket_image = Image.open(default_template).convert("RGBA")
-
- ticket_image.paste(qr_img, (122, 505))
- ticket_image.save(output, format="PNG")
- output.seek(0)
+ try:
+ basket, tickets, totals, charge = await create_basket_with_charge(
+ event=event,
+ data=data,
+ wallet_inkey=wallet_record.inkey,
+ base_url=str(request.base_url).rstrip("/"),
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc))
- return StreamingResponse(
- output,
- media_type="image/png",
- headers={
- "Cache-Control": "no-cache, no-store, must-revalidate",
- "Pragma": "no-cache",
- "Expires": "0",
- },
+ payment_request = None
+ if charge:
+ payment_request = TicketPaymentRequest(
+ payment_hash=basket.id,
+ onchain_amount_sat=totals.total,
+ satspay_charge_url=f"/satspay/{charge['id']}",
+ basket_id=basket.id,
+ )
+
+ return BasketResponse(
+ basket=basket,
+ tickets=tickets,
+ totals=totals,
+ payment_request=payment_request,
+ event_name=event.name,
+ event_currency=event.currency,
+ event_fiat_currency=event.fiat_currency,
)
-@tickets_api_router.post("/{event_id}")
-async def api_ticket_create(
- event_id: str, data: CreateTicket, request: Request
-) -> TicketPaymentRequest:
- event = await get_event(event_id)
+@events_api_router.get("/baskets/{basket_id}")
+async def api_get_basket(basket_id: str) -> BasketResponse:
+ basket = await get_basket(basket_id)
+ if not basket:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Basket does not exist."
+ )
+
+ event = await get_event(basket.event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- if event.canceled:
- raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
+ if not basket.paid and basket.satspay_charge_id:
+ from lnbits.core.crud.wallets import get_wallet as get_wallet_crud
- if event.amount_tickets < 1:
- raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
+ wallet_record = await get_wallet_crud(event.wallet)
+ if wallet_record:
+ try:
+ charge = await get_satspay_charge(
+ wallet_record.inkey, basket.satspay_charge_id
+ )
+ if charge.get("paid"):
+ await handle_basket_payment(basket_id, wallet_record.inkey)
+ basket = await get_basket(basket_id) or basket
+ except Exception as exc:
+ logger.warning(f"Failed to reconcile basket {basket_id}: {exc}")
+
+ from .crud import get_basket_tickets as get_basket_tickets_crud
+ from .services import calculate_basket_total
+
+ tickets = await get_basket_tickets_crud(basket_id)
+ totals = await calculate_basket_total(
+ event,
+ [{"ticket_type_id": t.ticket_type_id or "", "quantity": 1}
+ for t in tickets],
+ basket.promo_codes,
+ )
- name = data.name
- email = data.email
- promo_code = data.promo_code.upper() if data.promo_code else None
- refund_address = data.refund_address
- nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
- payment_method = (data.payment_method or "lightning").lower()
- if payment_method not in {"lightning", "fiat", "onchain"}:
+ return BasketResponse(
+ basket=basket,
+ tickets=tickets,
+ totals=totals,
+ payment_request=None,
+ event_name=event.name,
+ event_currency=event.currency,
+ event_fiat_currency=event.fiat_currency,
+ )
+
+
+@events_api_router.post("/promo/validate/{event_id}")
+async def api_validate_promo_codes(
+ event_id: str, data: PromoValidateRequest
+) -> BasketTotals:
+ event = await get_event(event_id)
+ if not event:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Unsupported payment method.",
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- if nostr_identifier and "@" not in nostr_identifier:
+ return await calculate_basket_total(event, data.items, data.codes)
+
+
+@events_api_router.post("/baskets/{basket_id}/satspay-webhook")
+async def api_basket_satspay_webhook(
+ basket_id: str,
+ request: Request,
+):
+ body = await request.json()
+ charge_id = body.get("charge_id")
+ logger.debug(f"SatsPay webhook for basket {basket_id}, charge {charge_id}")
+
+ basket = await get_basket(basket_id)
+ if not basket:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Only NIP-05 Nostr identifiers are supported.",
+ status_code=HTTPStatus.NOT_FOUND, detail="Basket does not exist."
)
- active_waves = get_active_ticket_waves(event)
- if not active_waves:
+
+ event = await get_event(basket.event_id)
+ if not event:
raise HTTPException(
- status_code=HTTPStatus.GONE, detail="No ticket wave is currently open."
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- selected_wave = None
- if data.ticket_wave_id:
- selected_wave = next(
- (wave for wave in active_waves if wave.id == data.ticket_wave_id),
- None,
- )
- if not selected_wave:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Invalid ticket wave selected.",
- )
- elif len(active_waves) == 1:
- selected_wave = active_waves[0]
- else:
+ from lnbits.core.crud.wallets import get_wallet as get_wallet_crud
+
+ wallet_record = await get_wallet_crud(event.wallet)
+ if not wallet_record:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Please select a ticket wave.",
+ status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
- price = selected_wave.price_per_ticket
- extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
+ await handle_basket_payment(basket_id, wallet_record.inkey)
+ return {"status": "ok"}
+
+
+@events_api_router.get("/tickets")
+async def api_tickets(
+ all_wallets: bool = Query(False),
+ key_info: WalletTypeInfo = Depends(require_invoice_key),
+) -> list[Ticket]:
+ wallet_ids = [key_info.wallet.id]
+ if all_wallets:
+ user = await get_user(key_info.wallet.user)
+ wallet_ids = user.wallet_ids if user else []
+ return await get_tickets(wallet_ids)
+
+
+@events_api_router.get("/tickets/paginated")
+async def api_tickets_paginated(
+ all_wallets: bool = Query(False),
+ key_info: WalletTypeInfo = Depends(require_invoice_key),
+):
+ wallet_ids = [key_info.wallet.id]
+ if all_wallets:
+ user = await get_user(key_info.wallet.user)
+ wallet_ids = user.wallet_ids if user else []
+ return await get_tickets_paginated(wallet_ids)
- if promo_code:
- # check if promo_code exists in event.extra.promo_codes
- if promo_code not in [pc.code for pc in event.extra.promo_codes]:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code."
- )
- # get the promocode
- promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code)
- extra["promo_code"] = promo.code
- price = selected_wave.price_per_ticket * (1 - promo.discount_percent / 100)
- if payment_method == "fiat" and not selected_wave.allow_fiat:
+@events_api_router.get("/tickets/by-charge/{charge_id}")
+async def api_get_tickets_by_charge(charge_id: str) -> list[PublicTicket]:
+ tickets = await get_tickets_by_satspay_charge_id(charge_id)
+ if not tickets:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Fiat payments are not enabled for this event.",
- )
-
- if _is_fiat_currency(selected_wave.currency):
- extra["fiat"] = True
- extra["currency"] = selected_wave.currency
- extra["fiatAmount"] = price
- extra["rate"] = await get_fiat_rate_satoshis(selected_wave.currency)
-
- if payment_method != "fiat":
- price = await fiat_amount_as_satoshis(price, selected_wave.currency)
-
- invoice_unit = selected_wave.currency
- fiat_amount = price
- fiat_provider = None
- onchain_amount_sat = None
-
- if payment_method == "fiat":
- if _is_fiat_currency(selected_wave.currency):
- invoice_unit = selected_wave.currency
- else:
- invoice_unit = selected_wave.fiat_currency
- fiat_amount = await satoshis_amount_as_fiat(price, invoice_unit)
- extra["fiat"] = True
- extra["currency"] = invoice_unit
- extra["fiatAmount"] = fiat_amount
- extra["rate"] = await get_fiat_rate_satoshis(invoice_unit)
- wallet = await get_wallet(event.wallet)
- if not wallet:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND,
- detail="Event wallet does not exist.",
- )
- providers = settings.get_fiat_providers_for_user(wallet.user)
- fiat_provider = data.fiat_provider or (providers[0] if providers else None)
- if not fiat_provider:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="No fiat payment provider configured for this event.",
- )
- elif payment_method == "onchain":
- onchain_amount_sat = int(price)
- wallet_record = await get_wallet(event.wallet)
- if not wallet_record:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND,
- detail="Event wallet does not exist.",
- )
- if not event.extra.onchain_enabled:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Onchain payments are not enabled for this event.",
- )
- if not event.extra.onchain_wallet_id:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="No onchain wallet configured for this event.",
+ status_code=HTTPStatus.NOT_FOUND, detail="No tickets found for this charge."
+ )
+ event = await get_event(tickets[0].event)
+ event_name = event.name if event else ""
+ from .crud import get_ticket_types as get_event_ticket_types
+ ticket_types = await get_event_ticket_types(tickets[0].event)
+ tt_map = {tt.id: tt.name for tt in ticket_types}
+ result = []
+ for ticket in tickets:
+ result.append(
+ PublicTicket(
+ event=ticket.event,
+ event_name=event_name,
+ ticket_type_name=tt_map.get(ticket.ticket_type_id or "", ""),
+ name=ticket.name,
+ registered=ticket.registered,
+ paid=ticket.paid,
+ time=ticket.time,
+ reg_timestamp=ticket.reg_timestamp,
)
+ )
+ return result
- ticket_id = urlsafe_short_hash()
- base_url = str(request.base_url).rstrip("/")
- # Use internal address for webhook — SatsPay calls it server-side and
- # cannot reach the public domain from within the container.
- internal_base = f"http://{settings.host}:{settings.port}"
- webhook_url = (
- f"{internal_base}/events/api/v1/tickets/{ticket_id}/satspay-webhook"
- )
- complete_url = f"{base_url}/events/ticket/{ticket_id}"
- try:
- charge = await create_satspay_charge(
- api_key=wallet_record.inkey,
- data={
- "amount": onchain_amount_sat,
- "description": f"Ticket for {event.name}",
- "name": name,
- "onchainwallet": event.extra.onchain_wallet_id,
- "zeroconf": event.extra.onchain_zeroconf,
- "fasttrack": event.extra.onchain_fasttrack,
- "webhook": webhook_url,
- "completelink": complete_url,
- "completelinktext": "View your ticket",
- "time": 1440,
- },
- )
- except Exception as exc:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail=f"Failed to create SatsPay charge: {exc}",
- ) from exc
-
- await create_ticket(
- payment_hash=ticket_id,
- wallet=event.wallet,
- event=event.id,
- name=name,
- email=email,
- extra={
- "applied_promo_code": promo_code,
- "ticket_wave_id": selected_wave.id,
- "ticket_wave_title": selected_wave.title,
- "refund_address": refund_address,
- "nostr_identifier": nostr_identifier,
- "ticket_base_url": base_url,
- "sats_paid": onchain_amount_sat,
- "onchain": True,
- "satspay_charge_id": charge["id"],
- },
+
+@events_api_router.get("/tickets/{ticket_id}", response_model=PublicTicket)
+async def api_get_ticket(ticket_id: str) -> Ticket:
+ ticket = await get_ticket(ticket_id)
+ if not ticket:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
+ )
+ event = await get_event(ticket.event)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
+ return ticket
- return TicketPaymentRequest(
- payment_hash=ticket_id,
- onchain_amount_sat=onchain_amount_sat,
- satspay_charge_url=f"/satspay/{charge['id']}",
+
+@events_api_router.post("/tickets/{event_id}")
+async def api_ticket_create_legacy(
+ event_id: str, data: CreateTicket, request: Request
+) -> TicketPaymentRequest:
+ event = await get_event(event_id)
+ if not event:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- else:
- invoice_unit = "sat"
-
- payment = await create_payment_request(
- wallet_id=event.wallet,
- invoice_data=CreateInvoice(
- out=False,
- amount=fiat_amount if payment_method == "fiat" else price,
- unit=invoice_unit,
- fiat_provider=fiat_provider,
- memo=f"{event_id}",
- extra=extra,
- ),
- )
- await create_ticket(
- payment_hash=payment.payment_hash,
- wallet=event.wallet,
- event=event.id,
- name=name,
- email=email,
- extra={
- "applied_promo_code": promo_code,
- "ticket_wave_id": selected_wave.id,
- "ticket_wave_title": selected_wave.title,
- "refund_address": refund_address,
- "nostr_identifier": nostr_identifier,
- "ticket_base_url": str(request.base_url).rstrip("/"),
- "sats_paid": payment.sat,
- "onchain": False,
- },
- )
+ if event.canceled:
+ raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
- return TicketPaymentRequest(
- payment_hash=payment.payment_hash,
- payment_request=getattr(payment, "bolt11", None),
- fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"),
- fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider,
- is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider),
+ ticket_types = await get_ticket_types(event_id)
+ if not ticket_types:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No ticket types available."
+ )
+
+ selected_tt_id = data.ticket_type_id
+ if not selected_tt_id:
+ selected_tt_id = ticket_types[0].id
+
+ basket_data = CreateBasket(
+ name=data.name,
+ email=data.email,
+ items=[BasketItem(ticket_type_id=selected_tt_id, quantity=1)],
+ promo_codes=[data.promo_code] if data.promo_code else [],
+ payment_method=data.payment_method,
+ fiat_provider=data.fiat_provider,
+ nostr_identifier=data.nostr_identifier,
+ refund_address=data.refund_address,
)
+ from lnbits.core.crud.wallets import get_wallet as get_wallet_crud
-@tickets_api_router.websocket("/ws/{payment_hash}")
-async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None:
- await websocket.accept()
- queue: asyncio.Queue[Ticket] = asyncio.Queue()
- register_payment_listener(payment_hash, queue)
- disconnect_task: asyncio.Task | None = None
- payment_task: asyncio.Task | None = None
+ wallet_record = await get_wallet_crud(event.wallet)
+ if not wallet_record:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
+ )
try:
- ticket = await get_ticket(payment_hash)
- if ticket and ticket.paid:
- await websocket.send_json({"paid": True})
- return
-
- while True:
- disconnect_task = asyncio.create_task(websocket.receive_text())
- payment_task = asyncio.create_task(queue.get())
- done, pending = await asyncio.wait(
- {disconnect_task, payment_task}, return_when=asyncio.FIRST_COMPLETED
- )
-
- for task in pending:
- task.cancel()
+ basket, tickets, totals, charge = await create_basket_with_charge(
+ event=event,
+ data=basket_data,
+ wallet_inkey=wallet_record.inkey,
+ base_url=str(request.base_url).rstrip("/"),
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc))
- if disconnect_task in done:
- try:
- disconnect_task.result()
- except WebSocketDisconnect:
- pass
- break
+ if charge:
+ return TicketPaymentRequest(
+ payment_hash=basket.id,
+ onchain_amount_sat=totals.total,
+ satspay_charge_url=f"/satspay/{charge['id']}",
+ basket_id=basket.id,
+ )
- ticket = payment_task.result()
- await websocket.send_json({"paid": ticket.paid})
- if ticket.paid:
- break
- finally:
- for pending_task in (disconnect_task, payment_task):
- if pending_task and not pending_task.done():
- pending_task.cancel()
- deregister_payment_listener(payment_hash, queue)
+ return TicketPaymentRequest(
+ payment_hash=basket.id,
+ basket_id=basket.id,
+ )
-@tickets_api_router.delete("/{ticket_id}")
+@events_api_router.delete("/tickets/{ticket_id}")
async def api_ticket_delete(
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> None:
@@ -679,131 +738,102 @@ async def api_ticket_delete(
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.")
-
await delete_ticket(ticket_id)
-@tickets_api_router.post("/{ticket_id}/satspay-webhook")
-async def api_ticket_satspay_webhook(ticket_id: str) -> None:
+@events_api_router.put("/tickets/{ticket_id}/deactivate")
+async def api_ticket_deactivate(
+ ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
+) -> Ticket:
ticket = await get_ticket(ticket_id)
if not ticket:
- logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.")
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
- if ticket.paid:
- logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.")
- return
- if not ticket.extra.satspay_charge_id:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket."
- )
- wallet = await get_wallet(ticket.wallet)
- if not wallet:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist."
- )
- try:
- charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id)
- except Exception as exc:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}"
- ) from exc
- if not charge.get("paid"):
- logger.warning(
- f"SatsPay webhook for ticket {ticket_id}: charge"
- f" {ticket.extra.satspay_charge_id} not paid."
- )
- return
-
- ticket = await set_ticket_paid(ticket)
- send_ticket_notification_in_background(ticket)
- for queue in payment_listeners.get(ticket_id, []):
- queue.put_nowait(ticket)
+ if ticket.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
+ return await toggle_ticket_deactivation(ticket)
-@tickets_api_router.put("/{payment_hash}/onchain-confirm")
-async def api_ticket_onchain_confirm(
- payment_hash: str,
- wallet: WalletTypeInfo = Depends(require_admin_key),
-) -> Ticket:
- ticket = await get_ticket(payment_hash)
+@events_api_router.put("/tickets/register/{ticket_id}")
+async def api_event_register_ticket(ticket_id: str) -> 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.")
- if ticket.paid:
+ if not ticket.paid:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid."
+ status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for."
)
- if not ticket.extra.onchain:
+ if ticket.extra.deactivated:
raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST,
- detail="Ticket is not an onchain payment.",
+ status_code=HTTPStatus.FORBIDDEN, detail="Ticket has been deactivated."
)
- ticket = await set_ticket_paid(ticket)
- send_ticket_notification_in_background(ticket)
- for queue in payment_listeners.get(payment_hash, []):
- queue.put_nowait(ticket)
+ if ticket.registered:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Ticket already registered."
+ )
+ ticket.registered = True
+ ticket.reg_timestamp = datetime.now(timezone.utc)
+ ticket = await update_ticket(ticket)
return ticket
-@tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
+@events_api_router.post("/tickets/{ticket_id}/resend-email")
async def api_ticket_resend_email(
ticket_id: str,
request: Request,
wallet: WalletTypeInfo = Depends(require_admin_key),
-) -> TicketResendResult:
+):
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.")
-
if not ticket.paid:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only paid tickets can be resent by email.",
)
-
try:
- return await resend_ticket_email_notification(
+ result = await resend_ticket_email_notification(
ticket, str(request.base_url).rstrip("/")
)
+ return result.dict()
except ValueError as exc:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
- ) from exc
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(exc))
-@tickets_api_router.put("/register/{ticket_id}")
-async def api_event_register_ticket(ticket_id) -> Ticket:
+@qr_api_router.get("/qr/{ticket_id}", response_class=StreamingResponse)
+async def api_ticket_qr(ticket_id: str):
ticket = await get_ticket(ticket_id)
-
if not ticket:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
)
- if not ticket.paid:
+ event = await get_event(ticket.event)
+ if not event:
raise HTTPException(
- status_code=HTTPStatus.FORBIDDEN, detail="Ticket not paid for."
+ status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
- if ticket.registered is True:
- raise HTTPException(
- status_code=HTTPStatus.FORBIDDEN, detail="Ticket already registered"
- )
+ qr_img = _make_qr_png(f"ticket://{ticket_id}", size=157)
+ output = BytesIO()
+ qr_img.save(output, format="PNG")
+ output.seek(0)
- ticket.registered = True
- ticket.reg_timestamp = datetime.now(timezone.utc)
- ticket = await update_ticket(ticket)
- return ticket
+ return StreamingResponse(
+ output,
+ media_type="image/png",
+ headers={
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ },
+ )