Some checks failed
lint.yml / fix: require a capacity on every ticket wave (pull_request) Failing after 0s
"Capacity is always required, there is no unlimited" was settled on #34 and enforced in #62 — but as a single number on the event. Since v1.6.8 capacity is per-wave and `event.amount_tickets` is a derived roll-up (`sync_event_ticket_waves`), so the rule had nothing holding it up at the level organisers actually set: the wave form's capacity input had no minimum, and nothing on the backend checked one at all. A zero-capacity wave can never be active — `get_active_ticket_waves` requires `amount_tickets > 0` — so it is the wave-level form of exactly what #62 removed: an event that looks on sale but refuses every purchase, on the card and at checkout both. `_validate_wave_capacity` now runs on create and update. Two details that matter: - it checks `ensure_ticket_waves(data)` rather than the raw list, so an event submitted with no waves is checked through the primary wave it is about to be given, not vacuously passed - on an edit it only checks waves NEW to the event. Selling out is the legitimate route to zero, and rejecting it would make a sold-out event uneditable — including the legacy zero-capacity rows this rule exists to let organisers fix Frontend: the wave dialog's capacity input gains the `min="1"` and hint the event-level field already had, and a new wave opens at 1 rather than 0. That default uses `??`, not `||` — a sold-out wave holds 0 and must keep showing it instead of silently regaining stock when saved. Also drops the stale `0 = unlimited / not ticketed` contract still documented on `CreateEvent.amount_tickets`, which has not been true since #62. 6 new tests; 120 pass. ruff, black, prettier clean; mypy error set unchanged from baseline.
1133 lines
35 KiB
JavaScript
1133 lines
35 KiB
JavaScript
window.PageEvents = {
|
|
template: '#page-events',
|
|
data() {
|
|
return {
|
|
events: [],
|
|
tickets: [],
|
|
allPaidTickets: [],
|
|
resendingTicketEmails: [],
|
|
isUploadingTicketTemplate: false,
|
|
ticketImageUploadTarget: null,
|
|
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: row => this.shortenId(row.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: '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: {
|
|
loading: false,
|
|
columns: [
|
|
{
|
|
name: 'event',
|
|
align: 'left',
|
|
label: 'Event',
|
|
field: row => this.shortenId(row.event)
|
|
},
|
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
|
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
|
{
|
|
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: 'nostr',
|
|
align: 'left',
|
|
label: 'Nostr',
|
|
field: row => row.extra?.nostr_identifier || ''
|
|
},
|
|
{
|
|
name: 'promo_code',
|
|
align: 'left',
|
|
label: 'Promo Code',
|
|
field: row => row.extra.applied_promo_code || ''
|
|
},
|
|
{name: 'id', align: 'left', label: 'ID', field: 'id'}
|
|
],
|
|
pagination: {
|
|
sortBy: 'time',
|
|
descending: true,
|
|
page: 1,
|
|
rowsPerPage: 10,
|
|
rowsNumber: 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'],
|
|
ticket_waves: [],
|
|
promo_codes: [],
|
|
notification_subject: '',
|
|
notification_body: ''
|
|
}
|
|
}
|
|
},
|
|
ticketWaveDialog: {
|
|
show: false,
|
|
eventId: null,
|
|
wallet: null,
|
|
editingWaveId: null,
|
|
data: {
|
|
id: null,
|
|
title: '',
|
|
opening_date: '',
|
|
closing_date: '',
|
|
currency: 'sats',
|
|
use_ticket_image: false,
|
|
ticket_image_id: null,
|
|
allow_fiat: false,
|
|
fiat_currency: 'GBP',
|
|
amount_tickets: 0,
|
|
price_per_ticket: 0
|
|
}
|
|
},
|
|
promoCodesDialog: {
|
|
show: false,
|
|
data: {
|
|
id: null,
|
|
wallet: null,
|
|
name: '',
|
|
extra: {
|
|
promo_codes: []
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
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: {
|
|
shortenId(value) {
|
|
if (!value) return ''
|
|
return value.length > 4 ? `${value.slice(0, 4)}...` : value
|
|
},
|
|
primaryTicketWave(data = this.formDialog.data) {
|
|
if (!data.extra) data.extra = {}
|
|
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
|
|
data.extra.ticket_waves = [
|
|
{
|
|
id: 'primary',
|
|
title: 'Primary wave',
|
|
opening_date: data.closing_date || '',
|
|
closing_date: data.closing_date || '',
|
|
currency: data.currency || 'sats',
|
|
use_ticket_image: false,
|
|
ticket_image_id: null,
|
|
allow_fiat: Boolean(data.allow_fiat),
|
|
fiat_currency: data.fiat_currency || 'GBP',
|
|
amount_tickets: data.amount_tickets || 0,
|
|
price_per_ticket: data.price_per_ticket || 0
|
|
}
|
|
]
|
|
}
|
|
return data.extra.ticket_waves[0]
|
|
},
|
|
syncPrimaryWaveFromForm(data = this.formDialog.data) {
|
|
const primaryWave = this.primaryTicketWave(data)
|
|
primaryWave.title = primaryWave.title || 'Primary wave'
|
|
primaryWave.opening_date = primaryWave.opening_date || ''
|
|
primaryWave.closing_date = data.closing_date || ''
|
|
primaryWave.currency = data.currency || 'sats'
|
|
primaryWave.use_ticket_image = Boolean(primaryWave.use_ticket_image)
|
|
primaryWave.ticket_image_id = primaryWave.ticket_image_id || null
|
|
primaryWave.allow_fiat = Boolean(data.allow_fiat)
|
|
primaryWave.fiat_currency = data.fiat_currency || 'GBP'
|
|
primaryWave.amount_tickets = Number(data.amount_tickets || 0)
|
|
primaryWave.price_per_ticket = Number(data.price_per_ticket || 0)
|
|
return primaryWave
|
|
},
|
|
hydrateEventForm(data) {
|
|
const formData = {
|
|
...data,
|
|
extra: {
|
|
...(data.extra || {}),
|
|
ticket_waves: [...((data.extra && data.extra.ticket_waves) || [])]
|
|
}
|
|
}
|
|
const primaryWave = this.primaryTicketWave(formData)
|
|
formData.currency = primaryWave.currency || formData.currency || 'sats'
|
|
formData.allow_fiat = Boolean(primaryWave.allow_fiat)
|
|
formData.fiat_currency = primaryWave.fiat_currency || 'GBP'
|
|
formData.amount_tickets = primaryWave.amount_tickets
|
|
formData.price_per_ticket = primaryWave.price_per_ticket
|
|
formData.closing_date =
|
|
primaryWave.closing_date || formData.closing_date || ''
|
|
return formData
|
|
},
|
|
isFiatCurrency(currency) {
|
|
return !['sat', 'sats'].includes((currency || '').toLowerCase())
|
|
},
|
|
normalizePromoCodes(promoCodes = []) {
|
|
return promoCodes
|
|
.filter(code => code.code?.trim() !== '')
|
|
.map(code => ({
|
|
...code,
|
|
code: code.code.trim().toUpperCase(),
|
|
// fork-only: blank / 0 = unlimited; used_count is derived server-side
|
|
max_uses: code.max_uses ? Number(code.max_uses) : null
|
|
}))
|
|
},
|
|
templateDownloadUrl() {
|
|
return '/events/static/image/ticket.jpg'
|
|
},
|
|
async uploadAssetFile(file) {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
form.append('public_asset', 'true')
|
|
const {data} = await LNbits.api.request(
|
|
'POST',
|
|
'/api/v1/assets?public_asset=true',
|
|
null,
|
|
form
|
|
)
|
|
return data.id
|
|
},
|
|
triggerTicketImageUpload(target) {
|
|
this.ticketImageUploadTarget = target
|
|
this.$refs.ticketImageUpload.value = null
|
|
this.$refs.ticketImageUpload.click()
|
|
},
|
|
async handleTicketImageSelected(event) {
|
|
const file = event.target.files?.[0]
|
|
if (!file || !this.ticketImageUploadTarget) return
|
|
|
|
this.isUploadingTicketTemplate = true
|
|
try {
|
|
const assetId = await this.uploadAssetFile(file)
|
|
if (this.ticketImageUploadTarget === 'primary') {
|
|
const wave = this.primaryTicketWave()
|
|
wave.use_ticket_image = true
|
|
wave.ticket_image_id = assetId
|
|
} else if (this.ticketImageUploadTarget === 'dialog') {
|
|
this.ticketWaveDialog.data.use_ticket_image = true
|
|
this.ticketWaveDialog.data.ticket_image_id = assetId
|
|
}
|
|
Quasar.Notify.create({
|
|
type: 'positive',
|
|
message: 'Ticket template uploaded.',
|
|
icon: null
|
|
})
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
} finally {
|
|
this.isUploadingTicketTemplate = false
|
|
this.ticketImageUploadTarget = null
|
|
}
|
|
},
|
|
soldTicketsForWave(eventId, waveId) {
|
|
return this.allPaidTickets.filter(
|
|
ticket =>
|
|
ticket.event === eventId &&
|
|
ticket.paid &&
|
|
(ticket.extra?.ticket_wave_id === waveId ||
|
|
(!ticket.extra?.ticket_wave_id && waveId === 'primary'))
|
|
).length
|
|
},
|
|
async getAllTickets() {
|
|
try {
|
|
const {data} = await LNbits.api.request(
|
|
'GET',
|
|
'/events/api/v1/tickets?all_wallets=true',
|
|
this.g.user.wallets[0].adminkey
|
|
)
|
|
this.allPaidTickets = data.filter(ticket => ticket.paid)
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
}
|
|
},
|
|
async getTickets(props) {
|
|
try {
|
|
this.ticketsTable.loading = true
|
|
const params = LNbits.utils.prepareFilterQuery(this.ticketsTable, props)
|
|
const {data} = await LNbits.api.request(
|
|
'GET',
|
|
`/events/api/v1/tickets/paginated?all_wallets=true&${params}`,
|
|
this.g.user.wallets[0].adminkey
|
|
)
|
|
this.tickets = data.data
|
|
this.ticketsTable.pagination.rowsNumber = data.total
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
} finally {
|
|
this.ticketsTable.loading = false
|
|
}
|
|
},
|
|
deleteTicket(ticketId) {
|
|
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
|
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(async () => {
|
|
await this.getTickets()
|
|
await this.getAllTickets()
|
|
})
|
|
.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 => {
|
|
const result = response.data
|
|
this.tickets = this.tickets.map(obj =>
|
|
obj.id === ticket.id ? result.ticket : obj
|
|
)
|
|
|
|
if (result.email?.attempted) {
|
|
Quasar.Notify.create({
|
|
type: result.email.sent ? 'positive' : 'negative',
|
|
message: result.email.sent
|
|
? 'Ticket email resent.'
|
|
: `Ticket email failed: ${result.email.error || 'Unknown error.'}`,
|
|
icon: null
|
|
})
|
|
}
|
|
|
|
if (result.nostr?.attempted) {
|
|
Quasar.Notify.create({
|
|
type: result.nostr.sent ? 'positive' : 'negative',
|
|
message: result.nostr.sent
|
|
? 'Ticket Nostr DM resent.'
|
|
: `Ticket Nostr DM failed: ${result.nostr.error || 'Unknown error.'}`,
|
|
icon: null
|
|
})
|
|
}
|
|
})
|
|
.catch(LNbits.utils.notifyApiError)
|
|
.finally(() => {
|
|
this.resendingTicketEmails = this.resendingTicketEmails.filter(
|
|
ticketId => ticketId !== ticket.id
|
|
)
|
|
})
|
|
},
|
|
exportticketsCSV() {
|
|
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
|
|
},
|
|
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
|
|
// Fold the form's day+time pairs first, then let upstream's helper
|
|
// mirror the form fields onto the primary wave — order matters, the
|
|
// sync reads closing_date/currency/amount_tickets off `data`.
|
|
this.syncPrimaryWaveFromForm(data)
|
|
if (data.extra?.promo_codes) {
|
|
data.extra.promo_codes = this.normalizePromoCodes(
|
|
data.extra.promo_codes
|
|
)
|
|
}
|
|
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'
|
|
}
|
|
this.syncPrimaryWaveFromForm(data)
|
|
|
|
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)
|
|
// Seed from upstream's hydrator (it materialises ticket_waves and
|
|
// mirrors the primary wave onto the flat form fields), then layer
|
|
// our fork-only fields on top.
|
|
const hydrated = this.hydrateEventForm(data)
|
|
this.formDialog.data = {
|
|
...hydrated,
|
|
extra: {
|
|
...(hydrated.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,
|
|
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: ''
|
|
}
|
|
}
|
|
}
|
|
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'],
|
|
conditional: false,
|
|
min_tickets: 1,
|
|
email_notifications: false,
|
|
nostr_notifications: false,
|
|
ticket_waves: [
|
|
{
|
|
id: 'primary',
|
|
title: 'Primary wave',
|
|
opening_date: '',
|
|
closing_date: '',
|
|
currency: 'sats',
|
|
use_ticket_image: false,
|
|
ticket_image_id: null,
|
|
allow_fiat: false,
|
|
fiat_currency: 'GBP',
|
|
amount_tickets: 0,
|
|
price_per_ticket: 0
|
|
}
|
|
],
|
|
promo_codes: [],
|
|
notification_subject: '',
|
|
notification_body: ''
|
|
}
|
|
}
|
|
},
|
|
|
|
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)
|
|
},
|
|
openTicketWaveDialog(event, wave = null) {
|
|
const primaryWave = (event.extra?.ticket_waves || [])[0] || {}
|
|
const isEditing = Boolean(wave)
|
|
this.ticketWaveDialog = {
|
|
show: true,
|
|
eventId: event.id,
|
|
wallet: event.wallet,
|
|
editingWaveId: wave?.id || null,
|
|
data: {
|
|
id: wave?.id || null,
|
|
title: wave?.title || '',
|
|
opening_date: wave?.opening_date || '',
|
|
closing_date: wave?.closing_date || '',
|
|
currency:
|
|
wave?.currency || primaryWave.currency || event.currency || 'sats',
|
|
use_ticket_image: Boolean(wave?.use_ticket_image),
|
|
ticket_image_id: wave?.ticket_image_id || null,
|
|
allow_fiat: isEditing
|
|
? Boolean(wave?.allow_fiat)
|
|
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
|
|
fiat_currency:
|
|
wave?.fiat_currency ||
|
|
primaryWave.fiat_currency ||
|
|
event.fiat_currency ||
|
|
'GBP',
|
|
// `??` not `||`: a sold-out wave legitimately holds 0 and must
|
|
// show it rather than silently regaining stock on save. A NEW
|
|
// wave opens at 1, the smallest capacity the backend accepts —
|
|
// there is no unlimited (#34).
|
|
amount_tickets: isEditing ? (wave?.amount_tickets ?? 0) : 1,
|
|
price_per_ticket:
|
|
wave?.price_per_ticket ||
|
|
primaryWave.price_per_ticket ||
|
|
event.price_per_ticket ||
|
|
0
|
|
}
|
|
}
|
|
},
|
|
resetTicketWaveDialog() {
|
|
this.ticketWaveDialog = {
|
|
show: false,
|
|
eventId: null,
|
|
wallet: null,
|
|
editingWaveId: null,
|
|
data: {
|
|
id: null,
|
|
title: '',
|
|
opening_date: '',
|
|
closing_date: '',
|
|
currency: 'sats',
|
|
use_ticket_image: false,
|
|
ticket_image_id: null,
|
|
allow_fiat: false,
|
|
fiat_currency: 'GBP',
|
|
amount_tickets: 0,
|
|
price_per_ticket: 0
|
|
}
|
|
}
|
|
},
|
|
saveTicketWave() {
|
|
const event = _.findWhere(this.events, {
|
|
id: this.ticketWaveDialog.eventId
|
|
})
|
|
const wallet = _.findWhere(this.g.user.wallets, {
|
|
id: this.ticketWaveDialog.wallet
|
|
})
|
|
if (!event || !wallet) return
|
|
|
|
const payload = {
|
|
...event,
|
|
extra: {
|
|
...event.extra,
|
|
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
|
|
existingWave.id === this.ticketWaveDialog.editingWaveId
|
|
? {...this.ticketWaveDialog.data}
|
|
: existingWave
|
|
)
|
|
}
|
|
}
|
|
|
|
if (!this.ticketWaveDialog.editingWaveId) {
|
|
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
|
|
}
|
|
|
|
if (payload.extra?.promo_codes) {
|
|
payload.extra.promo_codes = this.normalizePromoCodes(
|
|
payload.extra.promo_codes
|
|
)
|
|
}
|
|
|
|
LNbits.api
|
|
.request(
|
|
'PUT',
|
|
'/events/api/v1/events/' + payload.id,
|
|
wallet.adminkey,
|
|
payload
|
|
)
|
|
.then(response => {
|
|
this.events = this.events.map(item =>
|
|
item.id === payload.id ? response.data : item
|
|
)
|
|
Quasar.Notify.create({
|
|
type: 'positive',
|
|
message: this.ticketWaveDialog.editingWaveId
|
|
? 'Ticket wave updated.'
|
|
: 'Ticket wave added.',
|
|
icon: null
|
|
})
|
|
this.resetTicketWaveDialog()
|
|
})
|
|
.catch(LNbits.utils.notifyApiError)
|
|
},
|
|
openPromoCodesDialog(event) {
|
|
this.promoCodesDialog.data = {
|
|
...event,
|
|
extra: {
|
|
...event.extra,
|
|
promo_codes: [...(event.extra?.promo_codes || [])]
|
|
}
|
|
}
|
|
this.promoCodesDialog.show = true
|
|
},
|
|
resetPromoCodesDialog() {
|
|
this.promoCodesDialog.show = false
|
|
this.promoCodesDialog.data = {
|
|
id: null,
|
|
wallet: null,
|
|
name: '',
|
|
extra: {
|
|
promo_codes: []
|
|
}
|
|
}
|
|
},
|
|
addPromoCodeToDialog() {
|
|
this.promoCodesDialog.data.extra.promo_codes.push({
|
|
code: '',
|
|
discount_percent: 0,
|
|
active: true,
|
|
// fork-only: null = unlimited uses
|
|
max_uses: null
|
|
})
|
|
},
|
|
savePromoCodes() {
|
|
const data = this.promoCodesDialog.data
|
|
const wallet = _.findWhere(this.g.user.wallets, {
|
|
id: data.wallet
|
|
})
|
|
if (!wallet) return
|
|
|
|
const payload = {
|
|
...data,
|
|
extra: {
|
|
...data.extra,
|
|
promo_codes: this.normalizePromoCodes(data.extra?.promo_codes || [])
|
|
}
|
|
}
|
|
|
|
LNbits.api
|
|
.request(
|
|
'PUT',
|
|
'/events/api/v1/events/' + data.id,
|
|
wallet.adminkey,
|
|
payload
|
|
)
|
|
.then(response => {
|
|
this.events = this.events.map(event =>
|
|
event.id === data.id ? response.data : event
|
|
)
|
|
Quasar.Notify.create({
|
|
type: 'positive',
|
|
message: 'Promo codes updated.',
|
|
icon: null
|
|
})
|
|
this.resetPromoCodesDialog()
|
|
})
|
|
.catch(LNbits.utils.notifyApiError)
|
|
},
|
|
updateEvent(wallet, data) {
|
|
LNbits.api
|
|
.request(
|
|
'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.getAllTickets()
|
|
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]
|
|
}
|
|
}
|
|
}
|
|
}
|