events/static/js/index.js
Padreug 07519bec1b feat(admin): max uses column + used count in the promo editor
The Quasar editor gains a "Max uses" input per code (blank = unlimited,
normalised to null on save) and shows "Used n / max" from the derived
count. The public buy page's Clear button now also clears the promo field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-13 18:43:34 +02:00

715 lines
22 KiB
JavaScript

window.PageEvents = {
template: '#page-events',
data() {
return {
events: [],
tickets: [],
resendingTicketEmails: [],
currencies: [],
pendingEvents: [],
allUserEvents: [],
isAdmin: false,
republishing: false,
republishingMine: false,
settings: {
auto_approve: false
},
allUsersEventsTable: {
// Shown on the admin All Users' Events card. Includes the
// wallet owner (`wallet_user_id` resolved server-side) so
// cross-tenant rows are attributable to a user.
columns: [
{
name: 'wallet_user_id',
align: 'left',
label: 'Owner',
field: 'wallet_user_id'
},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'event_start_date',
align: 'left',
label: 'Start date',
field: 'event_start_date'
},
{
name: 'event_end_date',
align: 'left',
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
label: 'Canceled',
field: row => {
if (row.extra && row.extra.conditional && row.canceled) {
return 'Yes'
}
return 'No'
}
},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
]
},
eventsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'event_start_date',
align: 'left',
label: 'Start date',
field: 'event_start_date',
format: val => this.formatEventDate(val)
},
{
name: 'event_end_date',
align: 'left',
label: 'End date',
field: 'event_end_date',
format: val => this.formatEventDate(val)
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
label: 'Canceled',
field: row => {
if (row.extra.conditional && row.canceled) {
return 'Yes'
}
return 'No'
}
},
{
name: 'price_per_ticket',
align: 'left',
label: 'Price',
field: row => {
if (this.isFiatCurrency(row.currency)) {
return LNbits.utils.formatCurrency(
row.price_per_ticket.toFixed(2),
row.currency
)
}
return row.price_per_ticket
}
},
{
name: 'amount_tickets',
align: 'left',
label: 'No tickets',
field: 'amount_tickets'
},
{
name: 'sold',
align: 'left',
label: 'Sold',
field: 'sold'
},
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'},
{name: 'status', align: 'left', label: 'Status', field: 'status'}
],
pagination: {
rowsPerPage: 10
}
},
ticketsTable: {
columns: [
{name: 'event', align: 'left', label: 'Event', field: 'event'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{
name: 'email_sent',
align: 'left',
label: 'Email sent',
field: row =>
!row.email
? 'no email'
: row.extra?.email_notification_sent
? '\u2713 sent'
: row.paid
? 'not sent'
: 'unpaid'
},
{
name: 'registered',
align: 'left',
label: 'Registered',
field: 'registered'
},
{
name: 'promo_code',
align: 'left',
label: 'Promo Code',
field: row => row.extra.applied_promo_code || ''
},
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
}
},
// Rails an organizer can enable per event. Mirrors the webapp's
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
// has no fiat provider (see `hasFiatProvider`).
paymentMethodOptions: [
{
value: 'lightning',
label: 'Lightning',
hint: 'Pay with any Lightning wallet'
},
{
value: 'fiat',
label: 'Card',
hint: 'Card or bank through your configured fiat provider'
}
],
// Same list the webapp offers (src/modules/events/types/category.ts);
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
categoryOptions: [
'concert',
'workshop',
'market',
'festival',
'exhibition',
'sport',
'theater',
'cinema',
'party',
'talk',
'conference',
'meetup',
'food',
'outdoor',
'kids',
'wellness',
'technology',
'art',
'music',
'dance',
'literature',
'comedy',
'charity',
'tradition',
'other'
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
formDialog: {
show: false,
data: {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
location: '',
categories: [],
extra: {
payment_methods: ['lightning'],
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
}
}
},
computed: {
hasFiatProvider() {
return (this.g.user?.fiat_providers || []).length > 0
},
fiatProviderNames() {
return (this.g.user?.fiat_providers || [])
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join(', ')
},
acceptsFiat() {
return (this.formDialog.data.extra?.payment_methods || []).includes(
'fiat'
)
},
isSatPrice() {
return !this.isFiatCurrency(this.formDialog.data.currency)
},
fiatCurrencyOptions() {
return this.currencies.filter(c => this.isFiatCurrency(c))
}
},
methods: {
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
},
getTickets() {
LNbits.api
.request(
'GET',
'/events/api/v1/tickets?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(response => {
this.tickets = response.data.filter(e => e.paid)
})
},
deleteTicket(ticketId) {
const tickets = _.findWhere(this.tickets, {id: ticketId})
const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
LNbits.utils
.confirmDialog('Are you sure you want to delete this ticket')
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/events/api/v1/tickets/' + ticketId,
wallet.adminkey
)
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
})
.catch(LNbits.utils.notifyApiError)
})
},
resendTicketEmail(ticket) {
if (!ticket.paid || !ticket.email) return
const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet})
if (!wallet) return
this.resendingTicketEmails.push(ticket.id)
LNbits.api
.request(
'POST',
'/events/api/v1/tickets/' + ticket.id + '/resend-email',
wallet.adminkey
)
.then(response => {
this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj
)
Quasar.Notify.create({
type: 'positive',
message: 'Ticket email resent.',
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.resendingTicketEmails = this.resendingTicketEmails.filter(
ticketId => ticketId !== ticket.id
)
})
},
exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
},
getEvents() {
LNbits.api
.request(
'GET',
'/events/api/v1/events?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(response => {
this.events = response.data
this.checkCanceledEvents()
})
// Admin probe: a 200 from /all means we're an LNbits admin.
LNbits.api
.request('GET', '/events/api/v1/events/all')
.then(response => {
this.isAdmin = true
const ownWalletIds = this.g.user.wallets.map(w => w.id)
this.allUserEvents = response.data.filter(
e => !ownWalletIds.includes(e.wallet)
)
})
.catch(() => {
this.isAdmin = false
this.allUserEvents = []
})
},
getSettings() {
LNbits.api
.request('GET', '/events/api/v1/events/settings')
.then(response => {
this.settings = response.data
})
.catch(() => {
// Not admin or settings unavailable; keep defaults.
})
},
saveSettings() {
LNbits.api
.request('PUT', '/events/api/v1/events/settings', null, this.settings)
.then(() => {
Quasar.Notify.create({type: 'positive', message: 'Settings saved'})
})
.catch(LNbits.utils.notifyApiError)
},
getPendingEvents() {
LNbits.api
.request('GET', '/events/api/v1/events/pending')
.then(response => {
this.pendingEvents = response.data
})
.catch(() => {
this.pendingEvents = []
})
},
approveEvent(eventId) {
LNbits.utils.confirmDialog('Approve this event?').onOk(() => {
LNbits.api
.request('PUT', '/events/api/v1/events/' + eventId + '/approve')
.then(() => {
Quasar.Notify.create({
type: 'positive',
message: 'Event approved'
})
this.getEvents()
this.getPendingEvents()
})
.catch(LNbits.utils.notifyApiError)
})
},
rejectEvent(eventId) {
LNbits.utils.confirmDialog('Reject this event?').onOk(() => {
LNbits.api
.request('PUT', '/events/api/v1/events/' + eventId + '/reject')
.then(() => {
Quasar.Notify.create({
type: 'positive',
message: 'Event rejected'
})
this.getEvents()
this.getPendingEvents()
})
.catch(LNbits.utils.notifyApiError)
})
},
republishAllEvents() {
LNbits.utils
.confirmDialog(
'Re-emit every approved event to Nostr relays? This is safe ' +
'to run multiple times but generates one event per approved row.'
)
.onOk(() => {
this.republishing = true
LNbits.api
.request('POST', '/events/api/v1/events/republish-all')
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Republished ' +
response.data.republished +
' of ' +
response.data.total +
' events'
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.republishing = false
})
})
},
republishMyEvents() {
LNbits.utils
.confirmDialog('Re-emit your approved events to Nostr relays?')
.onOk(() => {
this.republishingMine = true
LNbits.api
.request(
'POST',
'/events/api/v1/events/republish-mine?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(response => {
Quasar.Notify.create({
type: 'positive',
message:
'Republished ' +
response.data.republished +
' of your ' +
response.data.total +
' events'
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.republishingMine = false
})
})
},
foldDateTime(day, time) {
// Combine separate date/time inputs into the wire format
// expected by the events extension: "YYYY-MM-DD" or
// "YYYY-MM-DDTHH:MM" (time is optional).
if (!day) return null
return time ? `${day}T${time}` : day
},
splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to. Slicing to HH:MM also
// drops the seconds + offset suffix withLocalTzOffset stamps on
// submit, so the organizer sees the wall-clock they entered.
if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)}
},
withLocalTzOffset(value) {
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
// event entered as 18:00 in CEST would otherwise go out on Nostr
// as 18:00 UTC. Same transform the webapp applies; date-only
// values pass through unchanged (they map to NIP-52 kind 31922).
if (!value || !value.includes('T')) return value
const offMin = -new Date(value).getTimezoneOffset()
const sign = offMin >= 0 ? '+' : '-'
const abs = Math.abs(offMin)
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
const mm = String(abs % 60).padStart(2, '0')
return `${value}:00${sign}${hh}:${mm}`
},
formatEventDate(value) {
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
if (!value) return ''
const {day, time} = this.splitDateTime(value)
return time ? `${day} ${time}` : day
},
validateEndDate() {
// Cross-field rule for the end-day input: end >= start, compared
// on the folded date+time so an equal-day earlier time is caught.
const d = this.formDialog.data
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
if (!start || !end) return true
return end >= start || 'End must be on or after start'
},
sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
const data = {...this.formDialog.data}
data.event_start_date = this.withLocalTzOffset(
this.foldDateTime(data.event_start_day, data.event_start_time)
)
data.event_end_date = this.withLocalTzOffset(
this.foldDateTime(data.event_end_day, data.event_end_time)
)
delete data.event_start_day
delete data.event_start_time
delete data.event_end_day
delete data.event_end_time
// Optional NIP-52 fields: blank location is "unset", not "".
data.location = (data.location || '').trim() || null
data.categories = data.categories || []
data.closing_date = data.closing_date || null
if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
code: code.code.trim().toUpperCase(),
// blank / 0 = unlimited; used_count is derived server-side
max_uses: code.max_uses ? Number(code.max_uses) : null
}))
}
const methods = data.extra?.payment_methods || []
if (methods.length === 0) {
Quasar.Notify.create({
type: 'warning',
message: 'Select at least one payment method.'
})
return
}
// allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat')
if (this.isFiatCurrency(data.currency)) {
// A fiat-priced event settles in its price currency; mirror it so
// the payload (and the tickets_fiat_currency tag) stay coherent.
data.fiat_currency = data.currency
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
if (data.id) {
this.updateEvent(wallet, data)
} else {
this.createEvent(wallet, data)
}
},
openEventDialog(data = false) {
if (data && data.id) {
const start = this.splitDateTime(data.event_start_date)
const end = this.splitDateTime(data.event_end_date)
this.formDialog.data = {
...data,
extra: {
...(data.extra || {}),
// Events created before extra.payment_methods existed carry an
// empty list; show the rails the backend actually accepts for
// them (Lightning always, fiat when allow_fiat).
payment_methods: data.extra?.payment_methods?.length
? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
},
location: data.location || '',
categories: [...(data.categories || [])],
event_start_day: start.day,
event_start_time: start.time,
event_end_day: end.day,
event_end_time: end.time
}
} else {
this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
location: '',
categories: [],
event_start_day: '',
event_start_time: '',
event_end_day: '',
event_end_time: '',
extra: {
payment_methods: ['lightning'],
conditional: false,
min_tickets: 1,
email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
}
this.formDialog.show = true
},
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
location: '',
categories: [],
extra: {
payment_methods: ['lightning'],
email_notifications: false,
nostr_notifications: false,
promo_codes: [],
notification_subject: '',
notification_body: ''
}
}
},
createEvent(wallet, data) {
LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => {
this.events.push(response.data)
this.resetEventDialog()
})
.catch(LNbits.utils.notifyApiError)
},
updateformDialog(formId) {
const link = _.findWhere(this.events, {id: formId})
this.openEventDialog(link)
},
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('Are you sure you want to delete this form link?')
.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.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
)
Quasar.Notify.create({
type: 'warning',
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
icon: null
})
this.events = this.events.map(e => (e.id === ev.id ? data : e))
}
})
}
},
async created() {
if (this.g.user.wallets.length) {
this.getTickets()
this.getEvents()
this.getSettings()
this.getPendingEvents()
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
this.currencies = ['sats', ...this.g.allowedCurrencies]
} else {
this.currencies = ['sats', ...this.g.currencies]
}
}
}
}