Merge upstream v1.6.8 into the aio fork

Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated
ticket endpoint, the organiser ticket-image template, and the SatsPay
on-chain surface. Refs #33.

Resolutions that were not mechanical, and why:

- set_ticket_paid debits the wave named on the ticket, keeping upstream's
  `> 0` guards; ours decremented unconditionally and could go negative.
  The purchase and free-ticket paths now stamp ticket_wave_id /
  ticket_wave_title, without which every sale would debit the primary wave.

- Pricing moved onto the selected wave (basket_totals takes it as a required
  argument, the promo-validate endpoint resolves the same wave through the
  shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the
  PRIMARY wave since sync_event_ticket_waves, so pricing off the event
  quoted and charged the first wave's price to buyers who picked a later
  one. Regression test added.

- Kept npub support in two places upstream removed it: the purchase
  endpoint's normalize_public_key path and the notification dispatcher.
  Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are
  supported", which is false for this fork. The purchase-side rejection had
  merged in outside any conflict marker.

- _ticket_image_url existed on both sides as two unrelated features. Ours
  (always-attached rendered QR card) is now _ticket_card_url; upstream's
  (organiser template, opt-in per wave) keeps the name. Both are wired into
  the mail, and the /qr/{ticket_id} endpoint — also duplicated on both
  sides, on the same route — is merged into one handler rather than
  registered twice, where the second copy would have been unreachable.

- models._parse_date now accepts a full ISO datetime. Upstream's date-only
  strptime raised ValueError on any event whose closing_date carries a time,
  which create_event produces by defaulting it from event_end_date — it
  would have 500'd the purchase path, the public event gate and the promo
  preview. Reproduced before fixing.

- Dropped upstream's inline make_qr_png (we import a superset from .qr) and
  its duplicate paymentMethodOptions in display.js, which re-derived payment
  options from per-method booleans and offered an on-chain option the
  backend rejects; the submit gate now matches the template's condition.

- Restored imports the merge silently dropped with upstream's npub removal
  (normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS).

Event create/update stays ours: upstream's combined endpoint would have
replaced the approval workflow and the explicit field allowlist that keeps
`status` out of the request body.

mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
This commit is contained in:
Padreug 2026-09-28 22:09:18 +02:00
commit ae5affa44f
14 changed files with 2228 additions and 275 deletions

BIN
static/image/ticket.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -13,6 +13,7 @@ window.PageEventsDisplay = {
email: '',
refund: '',
nostr_identifier: '',
ticket_wave_id: null,
payment_method: 'lightning'
}
},
@ -46,26 +47,68 @@ window.PageEventsDisplay = {
paymentMethods() {
// Mirrors `effective_payment_methods` on the backend: an explicit
// extra.payment_methods list wins, else Lightning + fiat-if-allow_fiat.
// Since v1.6.8 `allow_fiat` is a per-wave flag and `event.allow_fiat`
// is only the PRIMARY wave's, so prefer the active wave when there is
// one — otherwise a later fiat-enabled wave would not offer fiat.
const explicit = this.event?.extra?.payment_methods || []
if (explicit.length) return explicit
return ['lightning', ...(this.event?.allow_fiat ? ['fiat'] : [])]
const allowFiat = this.selectedTicketWave
? this.selectedTicketWave.allow_fiat
: this.event?.allow_fiat
return ['lightning', ...(allowFiat ? ['fiat'] : [])]
},
activeTicketWaves() {
const today = new Date().toISOString().slice(0, 10)
return (this.event?.extra?.ticket_waves || []).filter(
wave =>
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
},
selectedTicketWave() {
return (
this.activeTicketWaves.find(
wave => wave.id === this.formDialog.data.ticket_wave_id
) ||
this.activeTicketWaves[0] ||
null
)
},
showTicketWaveSelector() {
return this.activeTicketWaves.length > 1
},
allowFiatCheckout() {
return this.paymentMethods.includes('fiat')
},
paymentMethodOptions() {
// Options come from `paymentMethods` — the same list the backend
// validates against in `effective_payment_methods` — rather than being
// re-derived from per-method booleans. v1.6.8 added a second copy of
// this computed that built the list from `allow_fiat`/`onchain_enabled`
// instead; because it was defined later in the object it silently won
// the merge, and it offers a "Bitcoin" option for any event with
// `extra.onchain_enabled` even though the backend rejects `onchain`
// unless it is in `extra.payment_methods` (views_api: "not in
// effective_payment_methods"). Labels below are upstream's; the source
// of the list is ours.
const labels = {
lightning: 'Lightning',
fiat: this.fiatCheckoutLabel,
onchain: 'Bitcoin'
}
return this.paymentMethods.map(method => ({
value: method,
label: method === 'fiat' ? this.fiatCheckoutLabel : 'Lightning'
label: labels[method] || method
}))
},
fiatCheckoutLabel() {
if (!this.allowFiatCheckout) return 'Fiat'
const unit = ['sat', 'sats'].includes(
(this.event?.currency || '').toLowerCase()
(this.selectedTicketWave?.currency || '').toLowerCase()
)
? this.event?.fiat_currency
: this.event?.currency
? this.selectedTicketWave?.fiat_currency
: this.selectedTicketWave?.currency
return `Fiat (${(unit || 'GBP').toUpperCase()})`
},
allowEmailNotifications() {
@ -82,6 +125,16 @@ window.PageEventsDisplay = {
'GET',
`/events/api/v1/events/${this.eventId}`
)
const activeWaves = (data.extra?.ticket_waves || []).filter(wave => {
const today = new Date().toISOString().slice(0, 10)
return (
wave.amount_tickets > 0 &&
wave.opening_date <= today &&
wave.closing_date >= today
)
})
this.formDialog.data.ticket_wave_id =
activeWaves.length === 1 ? activeWaves[0].id : null
return data
} catch (error) {
this.eventErrorLabel = 'Event unavailable.'
@ -94,6 +147,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.promo_code = ''
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
@ -108,6 +165,13 @@ window.PageEventsDisplay = {
this.paymentWebsocket.close()
this.paymentWebsocket = null
}
this.paymentReq = null
this.receive = {
show: false,
status: 'pending',
paymentReq: null,
isFiat: false
}
},
nameValidation(val) {
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
@ -130,6 +194,10 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
this.formDialog.data.ticket_wave_id =
this.activeTicketWaves.length === 1
? this.activeTicketWaves[0].id
: null
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
Quasar.Notify.create({
@ -160,12 +228,26 @@ window.PageEventsDisplay = {
{
name: this.formDialog.data.name,
email: this.formDialog.data.email,
ticket_wave_id: this.formDialog.data.ticket_wave_id || null,
promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.formDialog.data.payment_method
// Gate matches the template's (`paymentMethods.length > 1`).
// v1.6.8 gated on `showPaymentMethodSelector`
// (`allowFiatCheckout || allowOnchain`), a different condition:
// where the two disagreed the buyer's visible choice was
// silently replaced with 'lightning'.
payment_method:
this.paymentMethods.length > 1
? this.formDialog.data.payment_method
: this.paymentMethods[0] || 'lightning'
}
)
if (data.satspay_charge_url) {
window.location.href = data.satspay_charge_url
return
}
const isFiat = Boolean(data.is_fiat)
this.paymentReq = isFiat
? data.fiat_payment_request || null
@ -197,7 +279,7 @@ window.PageEventsDisplay = {
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/api/v1/ws/${paymentHash}`
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
url.search = ''
url.hash = ''
@ -206,7 +288,7 @@ window.PageEventsDisplay = {
ws.onmessage = event => {
const data = JSON.parse(event.data)
if (data.pending === false) {
if (data.paid === true) {
this.paymentSuccess(paymentHash)
ws.close()
}
@ -215,8 +297,12 @@ window.PageEventsDisplay = {
console.error('WebSocket error:', error)
}
ws.onclose = () => {
if (this.paymentWebsocket === ws) {
this.paymentWebsocket = null
if (this.paymentWebsocket !== ws) return
this.paymentWebsocket = null
if (this.receive.show) {
setTimeout(() => {
if (this.receive.show) this.paymentWatcher(paymentHash)
}, 3000)
}
}
}

View file

@ -74,7 +74,7 @@
filled
dense
v-model.trim="formDialog.data.nostr_identifier"
label="(optional) Nostr NIP-05 or npub"
label="(optional) Nostr NIP-05"
hint="If provided, we'll DM your ticket link after payment."
></q-input>
</div>
@ -89,6 +89,21 @@
lazy-rules
:hint="`If minimum tickets (${event.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-select
v-if="showTicketWaveSelector"
filled
dense
v-model="formDialog.data.ticket_wave_id"
emit-value
map-options
label="Ticket wave"
:options="
activeTicketWaves.map(wave => ({
label: `${wave.title} - ${wave.price_per_ticket} ${wave.currency}`,
value: wave.id
}))
"
></q-select>
<div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="paymentMethods.length > 1" class="col-auto">
<q-option-group
@ -117,7 +132,8 @@
:disable="
formDialog.data.name == '' ||
formDialog.data.email == '' ||
Boolean(paymentReq)
(showTicketWaveSelector && !formDialog.data.ticket_wave_id) ||
(receive.show && Boolean(paymentReq))
"
type="submit"
>Submit</q-btn

View file

@ -4,7 +4,10 @@ window.PageEvents = {
return {
events: [],
tickets: [],
allPaidTickets: [],
resendingTicketEmails: [],
isUploadingTicketTemplate: false,
ticketImageUploadTarget: null,
currencies: [],
pendingEvents: [],
allUserEvents: [],
@ -25,7 +28,12 @@ window.PageEvents = {
label: 'Owner',
field: 'wallet_user_id'
},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'id',
align: 'left',
label: 'ID',
field: row => this.shortenId(row.id)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'event_start_date',
@ -39,12 +47,6 @@ window.PageEvents = {
label: 'End date',
field: 'event_end_date'
},
{
name: 'closing_date',
align: 'left',
label: 'Ticket close',
field: 'closing_date'
},
{
name: 'canceled',
align: 'left',
@ -129,8 +131,14 @@ window.PageEvents = {
}
},
ticketsTable: {
loading: false,
columns: [
{name: 'event', align: 'left', label: 'Event', field: 'event'},
{
name: 'event',
align: 'left',
label: 'Event',
field: row => this.shortenId(row.event)
},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
{
@ -152,6 +160,12 @@ window.PageEvents = {
label: 'Registered',
field: 'registered'
},
{
name: 'nostr',
align: 'left',
label: 'Nostr',
field: row => row.extra?.nostr_identifier || ''
},
{
name: 'promo_code',
align: 'left',
@ -161,7 +175,11 @@ window.PageEvents = {
{name: 'id', align: 'left', label: 'ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
sortBy: 'time',
descending: true,
page: 1,
rowsPerPage: 10,
rowsNumber: 10
}
},
// Rails an organizer can enable per event. Mirrors the webapp's
@ -218,12 +236,44 @@ window.PageEvents = {
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: []
}
}
},
onchainWallets: []
}
},
computed: {
@ -248,19 +298,196 @@ window.PageEvents = {
}
},
methods: {
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: 'Onchain payment 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())
},
getTickets() {
LNbits.api
.request(
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
)
.then(response => {
this.tickets = response.data.filter(e => e.paid)
})
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})
@ -275,10 +502,9 @@ window.PageEvents = {
'/events/api/v1/tickets/' + ticketId,
wallet.adminkey
)
.then(response => {
this.tickets = _.reject(this.tickets, function (obj) {
return obj.id == ticketId
})
.then(async () => {
await this.getTickets()
await this.getAllTickets()
})
.catch(LNbits.utils.notifyApiError)
})
@ -296,14 +522,30 @@ window.PageEvents = {
wallet.adminkey
)
.then(response => {
const result = response.data
this.tickets = this.tickets.map(obj =>
obj.id === ticket.id ? response.data : obj
obj.id === ticket.id ? result.ticket : obj
)
Quasar.Notify.create({
type: 'positive',
message: 'Ticket email resent.',
icon: null
})
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(() => {
@ -313,7 +555,7 @@ window.PageEvents = {
})
},
exportticketsCSV() {
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
LNbits.utils.exportCSV(this.ticketsTable.columns, this.allPaidTickets)
},
getEvents() {
LNbits.api
@ -520,16 +762,14 @@ window.PageEvents = {
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 = 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
}))
data.extra.promo_codes = this.normalizePromoCodes(
data.extra.promo_codes
)
}
const methods = data.extra?.payment_methods || []
if (methods.length === 0) {
@ -549,6 +789,7 @@ window.PageEvents = {
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP'
}
this.syncPrimaryWaveFromForm(data)
if (data.id) {
this.updateEvent(wallet, data)
@ -561,10 +802,14 @@ window.PageEvents = {
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 = {
...data,
...hydrated,
extra: {
...(data.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).
@ -596,6 +841,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: ''
@ -603,6 +867,12 @@ window.PageEvents = {
}
}
this.formDialog.show = true
if (
this.formDialog.data.wallet &&
this.formDialog.data.extra?.onchain_enabled
) {
this.loadOnchainWallets()
}
},
resetEventDialog() {
this.formDialog.show = false
@ -614,8 +884,27 @@ window.PageEvents = {
categories: [],
extra: {
payment_methods: ['lightning'],
conditional: false,
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: ''
@ -636,6 +925,179 @@ 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 = {
show: true,
eventId: event.id,
wallet: event.wallet,
editingWaveId: wave?.id || null,
data: {
id: wave?.id || null,
title: wave?.title || '',
opening_date: wave?.opening_date || '',
closing_date: wave?.closing_date || '',
currency:
wave?.currency || primaryWave.currency || event.currency || 'sats',
use_ticket_image: Boolean(wave?.use_ticket_image),
ticket_image_id: wave?.ticket_image_id || null,
allow_fiat: isEditing
? Boolean(wave?.allow_fiat)
: Boolean(primaryWave.allow_fiat ?? event.allow_fiat),
fiat_currency:
wave?.fiat_currency ||
primaryWave.fiat_currency ||
event.fiat_currency ||
'GBP',
amount_tickets: wave?.amount_tickets || 0,
price_per_ticket:
wave?.price_per_ticket ||
primaryWave.price_per_ticket ||
event.price_per_ticket ||
0
}
}
},
resetTicketWaveDialog() {
this.ticketWaveDialog = {
show: false,
eventId: null,
wallet: null,
editingWaveId: null,
data: {
id: null,
title: '',
opening_date: '',
closing_date: '',
currency: 'sats',
use_ticket_image: false,
ticket_image_id: null,
allow_fiat: false,
fiat_currency: 'GBP',
amount_tickets: 0,
price_per_ticket: 0
}
}
},
saveTicketWave() {
const event = _.findWhere(this.events, {
id: this.ticketWaveDialog.eventId
})
const wallet = _.findWhere(this.g.user.wallets, {
id: this.ticketWaveDialog.wallet
})
if (!event || !wallet) return
const payload = {
...event,
extra: {
...event.extra,
ticket_waves: (event.extra?.ticket_waves || []).map(existingWave =>
existingWave.id === this.ticketWaveDialog.editingWaveId
? {...this.ticketWaveDialog.data}
: existingWave
)
}
}
if (!this.ticketWaveDialog.editingWaveId) {
payload.extra.ticket_waves.push({...this.ticketWaveDialog.data})
}
if (payload.extra?.promo_codes) {
payload.extra.promo_codes = this.normalizePromoCodes(
payload.extra.promo_codes
)
}
LNbits.api
.request(
'PUT',
'/events/api/v1/events/' + payload.id,
wallet.adminkey,
payload
)
.then(response => {
this.events = this.events.map(item =>
item.id === payload.id ? response.data : item
)
Quasar.Notify.create({
type: 'positive',
message: this.ticketWaveDialog.editingWaveId
? 'Ticket wave updated.'
: 'Ticket wave added.',
icon: null
})
this.resetTicketWaveDialog()
})
.catch(LNbits.utils.notifyApiError)
},
openPromoCodesDialog(event) {
this.promoCodesDialog.data = {
...event,
extra: {
...event.extra,
promo_codes: [...(event.extra?.promo_codes || [])]
}
}
this.promoCodesDialog.show = true
},
resetPromoCodesDialog() {
this.promoCodesDialog.show = false
this.promoCodesDialog.data = {
id: null,
wallet: null,
name: '',
extra: {
promo_codes: []
}
}
},
addPromoCodeToDialog() {
this.promoCodesDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true,
// 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(
@ -702,6 +1164,7 @@ window.PageEvents = {
async created() {
if (this.g.user.wallets.length) {
this.getTickets()
this.getAllTickets()
this.getEvents()
this.getSettings()
this.getPendingEvents()

View file

@ -243,45 +243,102 @@
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Ticket waves</div>
<q-btn
round
dense
unelevated
color="primary"
icon="add"
class="q-ml-sm"
@click="openTicketWaveDialog(props.row)"
></q-btn>
</div>
<div class="column q-mb-lg">
<div class="column">
<div
v-for="(wave, index) in props.row.extra.ticket_waves"
:key="wave.id || index"
class="q-mb-sm"
>
<q-chip
square
clickable
class="q-py-xs"
style="height: auto"
@click="openTicketWaveDialog(props.row, wave)"
>
<span
style="white-space: normal; line-height: 1.3"
v-text="
`${wave.title} - ${wave.opening_date} to ${
wave.closing_date
} - ${
isFiatCurrency(wave.currency)
? LNbits.utils.formatCurrency(
Number(
wave.price_per_ticket || 0
).toFixed(2),
wave.currency
)
: `${wave.price_per_ticket} sats`
} - ${wave.amount_tickets} tickets - ${soldTicketsForWave(
props.row.id,
wave.id
)} sold`
"
></span>
</q-chip>
</div>
</div>
</div>
<div class="row items-center q-mb-md">
<div class="text-subtitle1">Promo codes</div>
<q-btn
round
dense
unelevated
color="primary"
icon="edit"
class="q-ml-sm"
@click="openPromoCodesDialog(props.row)"
></q-btn>
</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
v-if="
props.row.extra.promo_codes.filter(
code => code.active
).length == 0
"
class="text-caption"
>
No promo codes for this event.
No active promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<div class="row q-col-gutter-sm">
<div
v-for="(
code, index
) in props.row.extra.promo_codes.filter(
code => code.active
)"
:key="index"
class="col-auto q-mb-sm"
>
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
<span
v-text="
`${code.code.toUpperCase()} - ${code.discount_percent}%`
"
></span>
</q-chip>
</div>
<div class="col-auto">
Discount:
<span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
@ -308,9 +365,11 @@
dense
flat
:rows="tickets"
:loading="ticketsTable.loading"
row-key="id"
:columns="ticketsTable.columns"
v-model:pagination="ticketsTable.pagination"
@request="getTickets"
>
<template v-slot:header="props">
<q-tr :props="props">
@ -349,6 +408,18 @@
>
<q-tooltip>Resend ticket email</q-tooltip>
</q-btn>
<q-btn
v-if="props.row.extra?.onchain && !props.row.paid"
flat
dense
size="xs"
@click="confirmOnchainTicket(props.row)"
icon="currency_bitcoin"
color="orange"
class="q-ml-xs"
>
<q-tooltip>Confirm onchain payment</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
@ -597,6 +668,39 @@
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mt-lg q-mb-md">
Primary ticket wave (you can add other waves later)
</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="primaryTicketWave().opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
@ -674,6 +778,49 @@
</div>
</div>
</div>
<q-toggle
v-model="primaryTicketWave().use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="primaryTicketWave().use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('primary')"
>Replace</q-btn
>
</div>
<div
v-if="primaryTicketWave().ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="formDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using the event currency."
></q-toggle>
<q-select
v-if="acceptsFiat && isSatPrice"
filled
@ -718,135 +865,98 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-6"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
:hint="
formDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
formDialog.data.extra.promo_codes[index].used_count +
(formDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
formDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
formDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-3"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
min="0"
max="100"
></q-input>
<q-input
class="col-3"
filled
dense
v-model.number="
formDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="
formDialog.data.extra.promo_codes.push({
code: '',
discount_percent: 0,
active: true,
max_uses: null
})
"
>Add Promo Code</q-btn
>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Ticket Delivery</div>
<div class="text-caption">
Send the paid ticket link automatically by email or Nostr DM.
</div>
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.email_notifications"
label="Email notifications"
left-label
></q-toggle>
</div>
<div class="col-auto">
<q-toggle
v-model="formDialog.data.extra.nostr_notifications"
label="Nostr notifications"
left-label
></q-toggle>
</div>
</div>
<div
v-if="formDialog.data.extra.email_notifications"
class="q-mt-md"
>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
class="q-mt-md"
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
</div>
</q-expansion-item>
<q-separator class="q-my-md"></q-separator>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_subject"
type="text"
label="Ticket notification subject"
hint="Used as the email subject when sending paid ticket links."
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.extra.notification_body"
type="textarea"
label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification."
></q-input>
<q-expansion-item
group="advanced"
icon="view_in_ar"
label="Onchain payments"
>
<div class="q-mt-lg">
<div class="text-caption q-mb-md">
Accept Bitcoin onchain payments. Requires the
<strong>SatsPay</strong> and <strong>Watchonly</strong>
extensions to be installed and enabled.
</div>
<q-toggle
v-model="formDialog.data.extra.onchain_enabled"
label="Accept onchain payments"
left-label
@update:model-value="val => val && loadOnchainWallets()"
></q-toggle>
<q-select
v-if="formDialog.data.extra.onchain_enabled"
filled
dense
class="q-mt-md"
v-model="formDialog.data.extra.onchain_wallet_id"
label="Watchonly wallet"
:options="
onchainWallets.map(w => ({
label: w.title || w.id,
value: w.id
}))
"
emit-value
map-options
hint="Bitcoin watchonly wallet for receiving onchain payments"
></q-select>
<div v-if="formDialog.data.extra.onchain_enabled" class="q-mt-md">
<q-toggle
v-model="formDialog.data.extra.onchain_zeroconf"
label="Zero-conf (accept unconfirmed transactions)"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.onchain_fasttrack"
label="Fasttrack (treat pending as paid)"
left-label
></q-toggle>
</div>
</div>
</q-expansion-item>
<div class="row q-mt-lg">
<q-btn
@ -863,7 +973,9 @@
:disable="
!formDialog.data.wallet ||
!formDialog.data.name ||
!formDialog.data.event_start_day
!formDialog.data.event_start_day ||
primaryTicketWave().title == null ||
primaryTicketWave().opening_date == null
"
type="submit"
>Create Event</q-btn
@ -875,5 +987,275 @@
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="promoCodesDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="savePromoCodes" class="q-gutter-md">
<div class="text-subtitle1">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in promoCodesDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="promoCodesDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="
promoCodesDialog.data.extra.promo_codes[index].active
"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="
promoCodesDialog.data.extra.promo_codes[index].active
? 'Active'
: 'Inactive'
"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].discount_percent
"
type="number"
label="Discount (%)"
:hint="
promoCodesDialog.data.extra.promo_codes[index].used_count
? 'Used ' +
promoCodesDialog.data.extra.promo_codes[index].used_count +
(promoCodesDialog.data.extra.promo_codes[index].max_uses
? ' / ' +
promoCodesDialog.data.extra.promo_codes[index].max_uses
: '')
: ''
"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="
promoCodesDialog.data.extra.promo_codes.splice(index, 1)
"
></q-btn>
</template>
</q-input>
<q-input
class="col-2"
filled
dense
v-model.number="
promoCodesDialog.data.extra.promo_codes[index].max_uses
"
type="number"
label="Max uses"
placeholder="∞"
min="1"
hint="Blank = unlimited"
></q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn @click="addPromoCodeToDialog">Add Promo Code</q-btn>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit"
>Save Promo Codes</q-btn
>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetPromoCodesDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="ticketWaveDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="saveTicketWave" class="q-gutter-md">
<div
class="text-subtitle1"
v-text="
ticketWaveDialog.editingWaveId
? 'Edit Ticket Wave'
: 'Add Ticket Wave'
"
></div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.title"
type="text"
label="Wave title"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.opening_date"
type="date"
label="Ticket opening date"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-input
filled
dense
v-model.trim="ticketWaveDialog.data.closing_date"
type="date"
label="Ticket closing date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
filled
dense
v-model="ticketWaveDialog.data.currency"
type="text"
label="Unit"
:options="currencies"
></q-select>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.amount_tickets"
type="number"
label="Amount of tickets"
></q-input>
</div>
<div class="col">
<q-input
filled
dense
v-model.number="ticketWaveDialog.data.price_per_ticket"
type="number"
:label="'Price (' + ticketWaveDialog.data.currency + ') *'"
:step="ticketWaveDialog.data.currency != 'sats' ? '0.01' : '1'"
:mask="ticketWaveDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="ticketWaveDialog.data.currency == null"
></q-input>
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.use_ticket_image"
label="Use ticket image"
left-label
></q-toggle>
<div
v-if="ticketWaveDialog.data.use_ticket_image"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-btn
unelevated
color="primary"
type="a"
:href="templateDownloadUrl()"
download="ticket.jpg"
>
Download template
<q-tooltip>400/733 jpg</q-tooltip>
</q-btn>
</div>
<div class="col-auto">
<q-btn
outline
color="primary"
:loading="isUploadingTicketTemplate"
@click="triggerTicketImageUpload('dialog')"
>Replace</q-btn
>
</div>
<div
v-if="ticketWaveDialog.data.ticket_image_id"
class="col-12 text-caption"
>
Custom ticket template uploaded.
</div>
</div>
<q-toggle
v-model="ticketWaveDialog.data.allow_fiat"
label="Allow fiat checkout"
left-label
hint="Lets attendees pay through a configured fiat provider using this wave currency."
></q-toggle>
<q-select
v-if="
ticketWaveDialog.data.allow_fiat &&
['sat', 'sats'].includes(
(ticketWaveDialog.data.currency || '').toLowerCase()
)
"
filled
dense
v-model="ticketWaveDialog.data.fiat_currency"
label="Fiat checkout currency"
:options="
currencies.filter(
c => !['sat', 'sats'].includes((c || '').toLowerCase())
)
"
></q-select>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">{{
ticketWaveDialog.editingWaveId
? 'Update Ticket Wave'
: 'Save Ticket Wave'
}}</q-btn>
<q-btn
flat
color="grey"
class="q-ml-auto"
@click="resetTicketWaveDialog"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<input
ref="ticketImageUpload"
type="file"
accept="image/png,image/jpeg,image/webp"
style="display: none"
@change="handleTicketImageSelected"
/>
</div>
</template>

View file

@ -3,16 +3,36 @@ window.PageEventsTicket = {
data() {
return {
ticketId: null,
ticket: null
ticket: null,
printMode: false,
qrSrc: ''
}
},
methods: {
printWindow() {
window.print()
async printWindow() {
this.printMode = true
await this.$nextTick()
await this.waitForPrintAssets()
setTimeout(() => window.print(), 50)
},
async waitForPrintAssets() {
await this.$nextTick()
const img = document.querySelector('.ticket-print-qr')
if (!img) return
if (img.complete && img.naturalWidth > 0) return
await new Promise(resolve => {
const done = () => resolve()
img.addEventListener('load', done, {once: true})
img.addEventListener('error', done, {once: true})
setTimeout(done, 500)
})
}
},
async created() {
this.ticketId = this.$route.params.id
this.qrSrc = `/api/v1/qrcode?data=${encodeURIComponent(
`ticket://${this.ticketId}`
)}`
try {
const {data} = await LNbits.api.request(
'GET',
@ -22,5 +42,8 @@ window.PageEventsTicket = {
} catch (error) {
LNbits.utils.notifyApiError(error)
}
window.addEventListener('afterprint', () => {
this.printMode = false
})
}
}

View file

@ -36,4 +36,53 @@
</q-card>
</div>
</div>
<Teleport to="body">
<div class="ticket-print-sheet" v-if="printMode">
<img class="ticket-print-qr" :src="qrSrc" alt="Ticket QR" v-if="qrSrc" />
</div>
</Teleport>
</template>
<style>
@media print {
@page {
size: auto;
margin: 0;
}
html {
font-size: 12px !important;
}
* {
color: black !important;
background: white !important;
box-shadow: none !important;
}
body > * {
display: none !important;
}
.ticket-print-sheet {
display: flex !important;
align-items: center;
justify-content: center;
width: 100vw;
min-height: 100vh;
margin: 0 !important;
padding: 0 !important;
background: white !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.ticket-print-qr {
display: block;
width: 320px;
height: 320px;
object-fit: contain;
}
}
</style>