feat: make events dynamic (#43)
--------- Co-authored-by: dni <office@dnilabs.com>
This commit is contained in:
parent
f06bd9a668
commit
9e477ac959
21 changed files with 1164 additions and 1143 deletions
|
|
@ -6,11 +6,12 @@ from loguru import logger
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .tasks import wait_for_paid_invoices
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import events_generic_router
|
from .views import events_generic_router
|
||||||
from .views_api import events_api_router
|
from .views_api import events_api_router, tickets_api_router
|
||||||
|
|
||||||
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
|
||||||
events_ext.include_router(events_generic_router)
|
events_ext.include_router(events_generic_router)
|
||||||
events_ext.include_router(events_api_router)
|
events_ext.include_router(events_api_router)
|
||||||
|
events_ext.include_router(tickets_api_router)
|
||||||
|
|
||||||
events_static_files = [
|
events_static_files = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"name": "Events",
|
"name": "Events",
|
||||||
"repo": "https://github.com/lnbits/events",
|
"repo": "https://github.com/lnbits/events",
|
||||||
"short_description": "Sell and register event tickets",
|
"short_description": "Sell and register event tickets",
|
||||||
"description": "",
|
"description": "",
|
||||||
"tile": "/events/static/image/events.png",
|
"tile": "/events/static/image/events.png",
|
||||||
"min_lnbits_version": "1.3.0",
|
"min_lnbits_version": "1.4.1",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "talvasconcelos",
|
"name": "talvasconcelos",
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"role": "Developer"
|
"role": "Developer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "DNI",
|
"name": "dni",
|
||||||
"uri": "https://github.com/dni",
|
"uri": "https://github.com/dni",
|
||||||
"role": "Developer"
|
"role": "Developer"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
25
models.py
25
models.py
|
|
@ -58,6 +58,17 @@ class Event(BaseModel):
|
||||||
extra: EventExtra = Field(default_factory=EventExtra)
|
extra: EventExtra = Field(default_factory=EventExtra)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicEvent(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
info: str
|
||||||
|
closing_date: str
|
||||||
|
canceled: bool
|
||||||
|
event_start_date: str
|
||||||
|
event_end_date: str
|
||||||
|
banner: str | None
|
||||||
|
|
||||||
|
|
||||||
class TicketExtra(BaseModel):
|
class TicketExtra(BaseModel):
|
||||||
applied_promo_code: str | None = None
|
applied_promo_code: str | None = None
|
||||||
sats_paid: int | None = None
|
sats_paid: int | None = None
|
||||||
|
|
@ -83,3 +94,17 @@ class Ticket(BaseModel):
|
||||||
time: datetime
|
time: datetime
|
||||||
reg_timestamp: datetime
|
reg_timestamp: datetime
|
||||||
extra: TicketExtra = Field(default_factory=TicketExtra)
|
extra: TicketExtra = Field(default_factory=TicketExtra)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTicket(BaseModel):
|
||||||
|
event: str
|
||||||
|
name: str
|
||||||
|
registered: bool
|
||||||
|
paid: bool
|
||||||
|
time: datetime
|
||||||
|
reg_timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPaymentRequest(BaseModel):
|
||||||
|
payment_hash: str
|
||||||
|
payment_request: str
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
window.app = Vue.createApp({
|
window.PageEventsDisplay = {
|
||||||
el: '#vue',
|
template: '#page-events-display',
|
||||||
mixins: [windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
eventErrorLabel: '',
|
||||||
|
event: null,
|
||||||
paymentReq: null,
|
paymentReq: null,
|
||||||
redirectUrl: null,
|
redirectUrl: null,
|
||||||
formDialog: {
|
formDialog: {
|
||||||
|
|
@ -23,15 +24,14 @@ window.app = Vue.createApp({
|
||||||
show: false,
|
show: false,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
paymentReq: null
|
paymentReq: null
|
||||||
}
|
},
|
||||||
|
paymentDismissMsg: null,
|
||||||
|
paymentWebsocket: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
this.info = event_info
|
this.eventId = this.$route.params.id
|
||||||
this.info = this.info.substring(1, this.info.length - 1)
|
this.event = await this.getEvent()
|
||||||
this.banner = event_banner
|
|
||||||
this.extra = event_extra
|
|
||||||
this.hasPromoCodes = has_promoCodes
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
formatDescription() {
|
formatDescription() {
|
||||||
|
|
@ -39,6 +39,18 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async getEvent() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/events/api/v1/events/${this.eventId}`
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
this.eventErrorLabel = 'Event unavailable.'
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
resetForm(e) {
|
resetForm(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.formDialog.data.name = ''
|
this.formDialog.data.name = ''
|
||||||
|
|
@ -47,10 +59,14 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
closeReceiveDialog() {
|
closeReceiveDialog() {
|
||||||
const checker = this.receive.paymentChecker
|
if (this.paymentDismissMsg) {
|
||||||
dismissMsg()
|
this.paymentDismissMsg()
|
||||||
clearInterval(paymentChecker)
|
this.paymentDismissMsg = null
|
||||||
setTimeout(() => {}, 10000)
|
}
|
||||||
|
if (this.paymentWebsocket) {
|
||||||
|
this.paymentWebsocket.close()
|
||||||
|
this.paymentWebsocket = null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
nameValidation(val) {
|
nameValidation(val) {
|
||||||
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g
|
||||||
|
|
@ -63,42 +79,14 @@ window.app = Vue.createApp({
|
||||||
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
|
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
|
||||||
return regex.test(val) || 'Please enter valid email.'
|
return regex.test(val) || 'Please enter valid email.'
|
||||||
},
|
},
|
||||||
Invoice() {
|
paymentSuccess(paymentHash) {
|
||||||
axios
|
if (this.paymentDismissMsg) {
|
||||||
.post(`/events/api/v1/tickets/${event_id}`, {
|
this.paymentDismissMsg()
|
||||||
name: this.formDialog.data.name,
|
this.paymentDismissMsg = null
|
||||||
email: this.formDialog.data.email,
|
|
||||||
promo_code: this.formDialog.data.promo_code || null
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
this.paymentReq = response.data.payment_request
|
|
||||||
this.paymentCheck = response.data.payment_hash
|
|
||||||
|
|
||||||
dismissMsg = Quasar.Notify.create({
|
|
||||||
timeout: 0,
|
|
||||||
message: 'Waiting for payment...'
|
|
||||||
})
|
|
||||||
|
|
||||||
this.receive = {
|
|
||||||
show: true,
|
|
||||||
status: 'pending',
|
|
||||||
paymentReq: this.paymentReq
|
|
||||||
}
|
}
|
||||||
paymentChecker = setInterval(() => {
|
this.paymentReq = null
|
||||||
axios
|
|
||||||
.post(`/events/api/v1/tickets/${event_id}/${this.paymentCheck}`, {
|
|
||||||
event: event_id,
|
|
||||||
event_name: event_name,
|
|
||||||
name: this.formDialog.data.name,
|
|
||||||
email: this.formDialog.data.email
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
if (res.data.paid) {
|
|
||||||
clearInterval(paymentChecker)
|
|
||||||
dismissMsg()
|
|
||||||
this.formDialog.data.name = ''
|
this.formDialog.data.name = ''
|
||||||
this.formDialog.data.email = ''
|
this.formDialog.data.email = ''
|
||||||
|
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Sent, thank you!',
|
message: 'Sent, thank you!',
|
||||||
|
|
@ -109,22 +97,75 @@ window.app = Vue.createApp({
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
paymentReq: null
|
paymentReq: null
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ticketLink = {
|
this.ticketLink = {
|
||||||
show: true,
|
show: true,
|
||||||
data: {
|
data: {
|
||||||
link: `/events/ticket/${res.data.ticket_id}`
|
link: `/events/ticket/${paymentHash}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/events/ticket/${res.data.ticket_id}`
|
window.location.href = `/events/ticket/${paymentHash}`
|
||||||
}, 5000)
|
}, 5000)
|
||||||
|
},
|
||||||
|
async createInvoice() {
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
`/events/api/v1/tickets/${this.eventId}`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
name: this.formDialog.data.name,
|
||||||
|
email: this.formDialog.data.email,
|
||||||
|
promo_code: this.formDialog.data.promo_code || null,
|
||||||
|
refund_address: this.formDialog.data.refund || null
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
this.paymentReq = data.payment_request
|
||||||
|
this.paymentHash = data.payment_hash
|
||||||
|
|
||||||
|
this.paymentDismissMsg = Quasar.Notify.create({
|
||||||
|
timeout: 0,
|
||||||
|
message: 'Waiting for payment...'
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
this.receive = {
|
||||||
}, 2000)
|
show: true,
|
||||||
})
|
status: 'pending',
|
||||||
.catch(LNbits.utils.notifyApiError)
|
paymentReq: this.paymentReq
|
||||||
|
}
|
||||||
|
this.websocketListener(this.paymentHash)
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
websocketListener(paymentHash) {
|
||||||
|
if (this.paymentWebsocket) {
|
||||||
|
this.paymentWebsocket.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(window.location)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
url.pathname = `/events/api/v1/tickets/ws/${paymentHash}`
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
|
||||||
|
const ws = new WebSocket(url)
|
||||||
|
this.paymentWebsocket = ws
|
||||||
|
|
||||||
|
ws.onmessage = event => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.paid) {
|
||||||
|
this.paymentSuccess(paymentHash)
|
||||||
|
ws.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
ws.onerror = error => {
|
||||||
|
console.error('WebSocket error:', error)
|
||||||
|
}
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (this.paymentWebsocket === ws) {
|
||||||
|
this.paymentWebsocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
125
static/js/display.vue
Normal file
125
static/js/display.vue
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<template id="page-events-display">
|
||||||
|
<div v-if="event" class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-img
|
||||||
|
v-if="event.banner"
|
||||||
|
:src="event.banner"
|
||||||
|
transition="slide-up"
|
||||||
|
></q-img>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h3 class="q-my-none q-pa-lg" v-text="event.name"></h3>
|
||||||
|
<div v-html="event.info" class="q-pa-lg"></div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h5 class="q-mt-none">Buy Ticket</h5>
|
||||||
|
<q-form @submit="createInvoice()" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.name"
|
||||||
|
label="Your name "
|
||||||
|
:rules="[val => nameValidation(val)]"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.email"
|
||||||
|
type="email"
|
||||||
|
label="Your email "
|
||||||
|
:rules="[
|
||||||
|
val => !!val || '* Required',
|
||||||
|
val => emailValidation(val)
|
||||||
|
]"
|
||||||
|
lazy-rules
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-if="this.extra?.conditional"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.refund"
|
||||||
|
label="Refund lnadress or LNURL "
|
||||||
|
:rules="[val => !!val || '* Required']"
|
||||||
|
lazy-rules
|
||||||
|
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.promo_code"
|
||||||
|
label="(optional) Promo Code "
|
||||||
|
></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialog.data.name == '' ||
|
||||||
|
formDialog.data.email == '' ||
|
||||||
|
Boolean(paymentReq)
|
||||||
|
"
|
||||||
|
type="submit"
|
||||||
|
>Submit</q-btn
|
||||||
|
>
|
||||||
|
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
|
||||||
|
>Clear</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
size="xl"
|
||||||
|
:href="ticketLink.data.link"
|
||||||
|
target="_blank"
|
||||||
|
color="primary"
|
||||||
|
type="a"
|
||||||
|
>Link to your ticket!</q-btn
|
||||||
|
>
|
||||||
|
<br /><br />
|
||||||
|
<p>You'll be redirected in a few moments...</p>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
||||||
|
<q-card
|
||||||
|
v-if="!receive.paymentReq"
|
||||||
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
|
>
|
||||||
|
</q-card>
|
||||||
|
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<lnbits-qrcode
|
||||||
|
:href="'lightning:' + receive.paymentReq"
|
||||||
|
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="utils.copyText(receive.paymentReq)"
|
||||||
|
>Copy invoice</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
<div v-else class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h3 class="q-my-none q-pa-lg" v-text="eventErrorLabel"></h3>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
const mapEvents = function (obj) {
|
window.PageEvents = {
|
||||||
obj.date = LNbits.utils.formatTimestamp(obj.time)
|
template: '#page-events',
|
||||||
obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
|
|
||||||
obj.displayUrl = ['/events/', obj.id].join('')
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
events: [],
|
events: [],
|
||||||
|
|
@ -105,6 +97,7 @@ window.app = Vue.createApp({
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
|
currency: 'sats',
|
||||||
extra: {
|
extra: {
|
||||||
promo_codes: []
|
promo_codes: []
|
||||||
}
|
}
|
||||||
|
|
@ -118,18 +111,15 @@ window.app = Vue.createApp({
|
||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/events/api/v1/tickets?all_wallets=true',
|
'/events/api/v1/tickets?all_wallets=true',
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.tickets = response.data
|
this.tickets = response.data.filter(e => e.paid)
|
||||||
.map(function (obj) {
|
|
||||||
return mapEvents(obj)
|
|
||||||
})
|
|
||||||
.filter(e => e.paid)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteTicket(ticketId) {
|
deleteTicket(ticketId) {
|
||||||
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
const tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||||
|
const wallet = _.findWhere(this.g.user.wallets, {id: tickets.wallet})
|
||||||
|
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this ticket')
|
.confirmDialog('Are you sure you want to delete this ticket')
|
||||||
|
|
@ -138,16 +128,14 @@ window.app = Vue.createApp({
|
||||||
.request(
|
.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
'/events/api/v1/tickets/' + ticketId,
|
'/events/api/v1/tickets/' + ticketId,
|
||||||
_.findWhere(this.g.user.wallets, {id: tickets.wallet}).inkey
|
wallet.adminkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.tickets = _.reject(this.tickets, function (obj) {
|
this.tickets = _.reject(this.tickets, function (obj) {
|
||||||
return obj.id == ticketId
|
return obj.id == ticketId
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(LNbits.utils.notifyApiError)
|
||||||
LNbits.utils.notifyApiError(error)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
exportticketsCSV() {
|
exportticketsCSV() {
|
||||||
|
|
@ -161,9 +149,7 @@ window.app = Vue.createApp({
|
||||||
this.g.user.wallets[0].inkey
|
this.g.user.wallets[0].inkey
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.events = response.data.map(obj => {
|
this.events = response.data
|
||||||
return mapEvents(obj)
|
|
||||||
})
|
|
||||||
this.checkCanceledEvents()
|
this.checkCanceledEvents()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -190,6 +176,7 @@ window.app = Vue.createApp({
|
||||||
this.formDialog.data = {...data}
|
this.formDialog.data = {...data}
|
||||||
} else {
|
} else {
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
|
currency: 'sats',
|
||||||
extra: {
|
extra: {
|
||||||
conditional: false,
|
conditional: false,
|
||||||
min_tickets: 1,
|
min_tickets: 1,
|
||||||
|
|
@ -212,7 +199,7 @@ window.app = Vue.createApp({
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
|
.request('POST', '/events/api/v1/events', wallet.adminkey, data)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.events.push(mapEvents(response.data))
|
this.events.push(response.data)
|
||||||
this.resetEventDialog()
|
this.resetEventDialog()
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
|
@ -233,7 +220,7 @@ window.app = Vue.createApp({
|
||||||
this.events = _.reject(this.events, function (obj) {
|
this.events = _.reject(this.events, function (obj) {
|
||||||
return obj.id == data.id
|
return obj.id == data.id
|
||||||
})
|
})
|
||||||
this.events.push(mapEvents(response.data))
|
this.events.push(response.data)
|
||||||
this.resetEventDialog()
|
this.resetEventDialog()
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
|
|
@ -255,7 +242,7 @@ window.app = Vue.createApp({
|
||||||
return obj.id == eventsId
|
return obj.id == eventsId
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError(error))
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
exporteventsCSV() {
|
exporteventsCSV() {
|
||||||
|
|
@ -279,9 +266,7 @@ window.app = Vue.createApp({
|
||||||
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
|
message: `Event ${ev.name} has been canceled and refunds have been issued.`,
|
||||||
icon: null
|
icon: null
|
||||||
})
|
})
|
||||||
this.events = this.events.map(e =>
|
this.events = this.events.map(e => (e.id === ev.id ? data : e))
|
||||||
e.id === ev.id ? mapEvents(data) : e
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +275,11 @@ window.app = Vue.createApp({
|
||||||
if (this.g.user.wallets.length) {
|
if (this.g.user.wallets.length) {
|
||||||
this.getTickets()
|
this.getTickets()
|
||||||
this.getEvents()
|
this.getEvents()
|
||||||
this.currencies = await LNbits.api.getCurrencies()
|
if (this.g.allowedCurrencies && this.g.allowedCurrencies.length > 0) {
|
||||||
|
this.currencies = ['sats', ...this.g.allowedCurrencies]
|
||||||
|
} else {
|
||||||
|
this.currencies = ['sats', ...this.g.currencies]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
511
static/js/index.vue
Normal file
511
static/js/index.vue
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
<template id="page-events">
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn unelevated color="primary" @click="openEventDialog"
|
||||||
|
>New Event</q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Events</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exporteventsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="events"
|
||||||
|
row-key="id"
|
||||||
|
:columns="eventsTable.columns"
|
||||||
|
v-model:pagination="eventsTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.label"></span>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
size="sm"
|
||||||
|
color="accent"
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
@click="props.expand = !props.expand"
|
||||||
|
:icon="props.expand ? 'expand_less' : 'expand_more'"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="link"
|
||||||
|
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/events/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="how_to_reg"
|
||||||
|
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/events/register/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
class="q-ml-xs"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="updateformDialog(props.row.id)"
|
||||||
|
icon="edit"
|
||||||
|
color="light-blue"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteEvent(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
class="q-ml-xs"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.value"></span>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
<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="column">
|
||||||
|
<div
|
||||||
|
v-if="props.row.extra.promo_codes.length == 0"
|
||||||
|
class="text-caption"
|
||||||
|
>
|
||||||
|
No 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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportticketsCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="tickets"
|
||||||
|
row-key="id"
|
||||||
|
:columns="ticketsTable.columns"
|
||||||
|
v-model:pagination="ticketsTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.label"></span>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="local_activity"
|
||||||
|
:color="$q.dark.isActive ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'/events/ticket/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.value"></span>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteTicket(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 ellipsis q-my-none">
|
||||||
|
<span v-text="SITE_TITLE"></span>
|
||||||
|
Events extension
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list>
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="Info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h5 class="text-subtitle1 q-my-none">
|
||||||
|
Events: Sell and register ticket waves for an event
|
||||||
|
</h5>
|
||||||
|
<p>
|
||||||
|
Events allows you to make a wave of tickets for an event,
|
||||||
|
each ticket is in the form of a unique QRcode, which the
|
||||||
|
user presents at registration. Events comes with a shareable
|
||||||
|
ticket scanner, which can be used to register attendees.<br />
|
||||||
|
<small>
|
||||||
|
Created by,
|
||||||
|
<a class="text-secondary" href="https://github.com/benarc"
|
||||||
|
>Ben Arc</a
|
||||||
|
>
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="Swagger API"
|
||||||
|
type="a"
|
||||||
|
href="../docs#/events"
|
||||||
|
></q-btn>
|
||||||
|
</q-expansion-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="formDialog.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||||
|
<q-form @submit="sendEventData" class="q-gutter-md">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.name"
|
||||||
|
type="name"
|
||||||
|
label="Title of event "
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col q-pl-sm">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.info"
|
||||||
|
type="textarea"
|
||||||
|
label="Info about the event"
|
||||||
|
hint="Markdown supported"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.banner"
|
||||||
|
type="url"
|
||||||
|
label="Image URL"
|
||||||
|
hint="Optional banner image to display on the event page"
|
||||||
|
></q-input>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col-4">Ticket closing date</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.closing_date"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">Event begins</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_start_date"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">Event ends</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.event_end_date"
|
||||||
|
type="date"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model="formDialog.data.currency"
|
||||||
|
type="text"
|
||||||
|
label="Unit"
|
||||||
|
:options="currencies"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.amount_tickets"
|
||||||
|
type="number"
|
||||||
|
label="Amount of tickets "
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.price_per_ticket"
|
||||||
|
type="number"
|
||||||
|
:label="'Price (' + formDialog.data.currency + ') *'"
|
||||||
|
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
|
||||||
|
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
|
||||||
|
fill-mask="0"
|
||||||
|
reverse-fill-mask
|
||||||
|
:disable="formDialog.data.currency == null"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-expansion-item
|
||||||
|
group="advanced"
|
||||||
|
icon="settings"
|
||||||
|
label="Advanced options"
|
||||||
|
>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
|
||||||
|
<div class="text-caption">
|
||||||
|
Make this event conditional if
|
||||||
|
<strong>minimum tickets</strong> are sold. User will be asked to
|
||||||
|
provide a Lightning Address or LNURL pay for refunds.
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<q-toggle
|
||||||
|
v-model="formDialog.data.extra.conditional"
|
||||||
|
label="Conditional Event"
|
||||||
|
left-label
|
||||||
|
></q-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="formDialog.data.extra.min_tickets"
|
||||||
|
type="number"
|
||||||
|
label="Minimum Tickets"
|
||||||
|
:disable="!formDialog.data.extra.conditional"
|
||||||
|
></q-input>
|
||||||
|
</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-8"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
||||||
|
type="text"
|
||||||
|
label="Promo Code"
|
||||||
|
>
|
||||||
|
<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-4"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="
|
||||||
|
formDialog.data.extra.promo_codes[index].discount_percent
|
||||||
|
"
|
||||||
|
type="number"
|
||||||
|
label="Discount (%)"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>Add Promo Code</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn
|
||||||
|
v-if="formDialog.data.id"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
>Update Event</q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="
|
||||||
|
formDialog.data.wallet == null ||
|
||||||
|
formDialog.data.name == null ||
|
||||||
|
formDialog.data.info == null ||
|
||||||
|
formDialog.data.closing_date == null ||
|
||||||
|
formDialog.data.event_start_date == null ||
|
||||||
|
formDialog.data.event_end_date == null ||
|
||||||
|
formDialog.data.amount_tickets == null ||
|
||||||
|
formDialog.data.price_per_ticket == null
|
||||||
|
"
|
||||||
|
type="submit"
|
||||||
|
>Create Event</q-btn
|
||||||
|
>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
const mapEvents = function (obj) {
|
window.PageEventsRegister = {
|
||||||
obj.date = Quasar.date.formatDate(
|
template: '#page-events-register',
|
||||||
new Date(obj.time * 1000),
|
|
||||||
'YYYY-MM-DD HH:mm'
|
|
||||||
)
|
|
||||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
|
||||||
obj.displayUrl = ['/events/', obj.id].join('')
|
|
||||||
return obj
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tickets: [],
|
tickets: [],
|
||||||
ticketsTable: {
|
ticketsTable: {
|
||||||
columns: [
|
columns: [
|
||||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{
|
{
|
||||||
name: 'registered',
|
name: 'registered',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'Registered',
|
label: 'Registered',
|
||||||
field: 'registered'
|
field: 'registered'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'paid',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Paid',
|
||||||
|
field: 'paid'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|
@ -49,30 +43,26 @@ window.app = Vue.createApp({
|
||||||
this.sendCamera.show = false
|
this.sendCamera.show = false
|
||||||
const value = res[0].rawValue.split('//')[1]
|
const value = res[0].rawValue.split('//')[1]
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', `/events/api/v1/register/ticket/${value}`)
|
.request('PUT', `/events/api/v1/tickets/register/${value}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Registered!'
|
message: 'Registered!'
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload()
|
|
||||||
}, 2000)
|
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
getEventTickets() {
|
getEventTickets() {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('GET', `/events/api/v1/eventtickets/${event_id}`)
|
.request('GET', `/events/api/v1/events/${this.eventId}/tickets`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.tickets = response.data.map(obj => {
|
this.tickets = response.data
|
||||||
return mapEvents(obj)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
|
this.eventId = this.$route.params.id
|
||||||
this.getEventTickets()
|
this.getEventTickets()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
|
||||||
64
static/js/register.vue
Normal file
64
static/js/register.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<template id="page-events-register">
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<h3 class="q-my-none">Registration</h3>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<q-btn unelevated color="primary" @click="showCamera" size="xl"
|
||||||
|
>Scan ticket</q-btn
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:rows="tickets"
|
||||||
|
row-key="id"
|
||||||
|
:columns="ticketsTable.columns"
|
||||||
|
v-model:pagination="ticketsTable.pagination"
|
||||||
|
>
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.label"></span>
|
||||||
|
</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<span v-text="col.value"></span>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="sendCamera.show" position="top">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<qrcode-stream
|
||||||
|
@detect="decodeQR"
|
||||||
|
class="rounded-borders"
|
||||||
|
></qrcode-stream>
|
||||||
|
</div>
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
static/js/ticket.js
Normal file
26
static/js/ticket.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
window.PageEventsTicket = {
|
||||||
|
template: '#page-events-ticket',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ticketId: null,
|
||||||
|
ticketName: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
printWindow() {
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
this.ticketId = this.$route.params.id
|
||||||
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/events/api/v1/tickets/${this.ticketId}`
|
||||||
|
)
|
||||||
|
this.ticketName = data.ticket_name
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
static/js/ticket.vue
Normal file
27
static/js/ticket.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<template id="page-events-ticket">
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<h3 class="q-my-none">Ticket</h3>
|
||||||
|
<br />
|
||||||
|
<h5 class="q-my-none">
|
||||||
|
Bookmark, print or screenshot this page,<br />
|
||||||
|
and present it for registration!
|
||||||
|
</h5>
|
||||||
|
<br />
|
||||||
|
<lnbits-qrcode
|
||||||
|
:value="`ticket://${ticketId}`"
|
||||||
|
:options="{width: 500}"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
<br />
|
||||||
|
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
||||||
|
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
static/routes.json
Normal file
26
static/routes.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"path": "/events/",
|
||||||
|
"name": "PageEvents",
|
||||||
|
"template": "/events/static/js/index.vue",
|
||||||
|
"component": "/events/static/js/index.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/events/:id",
|
||||||
|
"name": "PageEventsDisplay",
|
||||||
|
"template": "/events/static/js/display.vue",
|
||||||
|
"component": "/events/static/js/display.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/events/ticket/:id",
|
||||||
|
"name": "PageEventsTicket",
|
||||||
|
"template": "/events/static/js/ticket.vue",
|
||||||
|
"component": "/events/static/js/ticket.js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/events/register/:id",
|
||||||
|
"name": "PageEventsRegister",
|
||||||
|
"template": "/events/static/js/register.vue",
|
||||||
|
"component": "/events/static/js/register.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
25
tasks.py
25
tasks.py
|
|
@ -5,8 +5,24 @@ from lnbits.tasks import register_invoice_listener
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import get_ticket
|
from .crud import get_ticket
|
||||||
|
from .models import Ticket
|
||||||
from .services import set_ticket_paid
|
from .services import set_ticket_paid
|
||||||
|
|
||||||
|
payment_listeners: dict[str, list[asyncio.Queue[Ticket]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
|
||||||
|
if payment_hash not in payment_listeners:
|
||||||
|
payment_listeners[payment_hash] = []
|
||||||
|
payment_listeners[payment_hash].append(queue)
|
||||||
|
|
||||||
|
|
||||||
|
def deregister_payment_listener(payment_hash, queue: asyncio.Queue[Ticket]) -> None:
|
||||||
|
if payment_hash in payment_listeners:
|
||||||
|
payment_listeners[payment_hash].remove(queue)
|
||||||
|
if not payment_listeners[payment_hash]:
|
||||||
|
del payment_listeners[payment_hash]
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
invoice_queue = asyncio.Queue()
|
invoice_queue = asyncio.Queue()
|
||||||
|
|
@ -21,13 +37,12 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
if not payment.extra or "events" != payment.extra.get("tag"):
|
if not payment.extra or "events" != payment.extra.get("tag"):
|
||||||
return
|
return
|
||||||
|
|
||||||
if not payment.extra.get("name") or not payment.extra.get("email"):
|
|
||||||
logger.warning(f"Ticket {payment.payment_hash} missing name or email.")
|
|
||||||
return
|
|
||||||
|
|
||||||
ticket = await get_ticket(payment.payment_hash)
|
ticket = await get_ticket(payment.payment_hash)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
|
logger.warning(f"Ticket for payment {payment.payment_hash} not found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
await set_ticket_paid(ticket)
|
ticket = await set_ticket_paid(ticket)
|
||||||
|
if payment_listeners.get(payment.payment_hash):
|
||||||
|
for paid_ticket_queue in payment_listeners[payment.payment_hash]:
|
||||||
|
paid_ticket_queue.put_nowait(ticket)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<q-expansion-item
|
|
||||||
group="extras"
|
|
||||||
icon="swap_vertical_circle"
|
|
||||||
label="Info"
|
|
||||||
:content-inset-level="0.5"
|
|
||||||
>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<h5 class="text-subtitle1 q-my-none">
|
|
||||||
Events: Sell and register ticket waves for an event
|
|
||||||
</h5>
|
|
||||||
<p>
|
|
||||||
Events allows you to make a wave of tickets for an event, each ticket is
|
|
||||||
in the form of a unique QRcode, which the user presents at registration.
|
|
||||||
Events comes with a shareable ticket scanner, which can be used to
|
|
||||||
register attendees.<br />
|
|
||||||
<small>
|
|
||||||
Created by,
|
|
||||||
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>
|
|
||||||
</small>
|
|
||||||
</p>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
<q-btn flat label="Swagger API" type="a" href="../docs#/events"></q-btn>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
{% extends "public.html" %} {% block page %}
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
|
||||||
<q-card>
|
|
||||||
<q-img v-if="banner" :src="banner" transition="slide-up"></q-img>
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
|
|
||||||
<br />
|
|
||||||
<div v-html="formatDescription" class="q-pa-md"></div>
|
|
||||||
<br />
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<h5 class="q-mt-none">Buy Ticket</h5>
|
|
||||||
<q-form @submit="Invoice()" class="q-gutter-md">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.name"
|
|
||||||
label="Your name "
|
|
||||||
:rules="[val => nameValidation(val)]"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.email"
|
|
||||||
type="email"
|
|
||||||
label="Your email "
|
|
||||||
:rules="[val => !!val || '* Required', val => emailValidation(val)]"
|
|
||||||
lazy-rules
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
v-if="this.extra?.conditional"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.refund"
|
|
||||||
label="Refund lnadress or LNURL "
|
|
||||||
:rules="[val => !!val || '* Required']"
|
|
||||||
lazy-rules
|
|
||||||
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
v-if="hasPromoCodes"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.promo_code"
|
|
||||||
label="Apply Promo Code "
|
|
||||||
></q-input>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="formDialog.data.name == '' || formDialog.data.email == '' || Boolean(paymentReq)"
|
|
||||||
type="submit"
|
|
||||||
>Submit</q-btn
|
|
||||||
>
|
|
||||||
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
size="xl"
|
|
||||||
:href="ticketLink.data.link"
|
|
||||||
target="_blank"
|
|
||||||
color="primary"
|
|
||||||
type="a"
|
|
||||||
>Link to your ticket!</q-btn
|
|
||||||
>
|
|
||||||
<br /><br />
|
|
||||||
<p>You'll be redirected in a few moments...</p>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
|
||||||
<q-card
|
|
||||||
v-if="!receive.paymentReq"
|
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
|
||||||
>
|
|
||||||
</q-card>
|
|
||||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<lnbits-qrcode
|
|
||||||
:href="'lightning:' + receive.paymentReq"
|
|
||||||
:value="'lightning:' + receive.paymentReq.toUpperCase()"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
|
||||||
>Copy invoice</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
|
||||||
const event_id = '{{ event_id }}'
|
|
||||||
const event_name = '{{ event_name }}'
|
|
||||||
const event_info = '{{ event_info | tojson }}'
|
|
||||||
const event_banner = JSON.parse('{{ event_banner | tojson | safe }}')
|
|
||||||
const event_extra = JSON.parse('{{ event_extra | safe }}')
|
|
||||||
const has_promoCodes = {{ has_promo_codes | tojson }}
|
|
||||||
</script>
|
|
||||||
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
{% extends "public.html" %} {% block page %}
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<center>
|
|
||||||
<h3 class="q-my-none">{{ event_name }} error</h3>
|
|
||||||
<br />
|
|
||||||
<q-icon
|
|
||||||
name="warning"
|
|
||||||
class="text-grey"
|
|
||||||
style="font-size: 20rem"
|
|
||||||
></q-icon>
|
|
||||||
|
|
||||||
<h5 class="q-my-none">{{ event_error }}</h5>
|
|
||||||
<br />
|
|
||||||
</center>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
window.app = Vue.createApp({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,464 +0,0 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
|
||||||
%} {% block page %}
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<q-btn unelevated color="primary" @click="openEventDialog"
|
|
||||||
>New Event</q-btn
|
|
||||||
>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col">
|
|
||||||
<h5 class="text-subtitle1 q-my-none">Events</h5>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn flat color="grey" @click="exporteventsCSV"
|
|
||||||
>Export to CSV</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-table
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
:rows="events"
|
|
||||||
row-key="id"
|
|
||||||
:columns="eventsTable.columns"
|
|
||||||
v-model:pagination="eventsTable.pagination"
|
|
||||||
>
|
|
||||||
<template v-slot:header="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.label"></span>
|
|
||||||
</q-th>
|
|
||||||
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
size="sm"
|
|
||||||
color="accent"
|
|
||||||
round
|
|
||||||
dense
|
|
||||||
@click="props.expand = !props.expand"
|
|
||||||
:icon="props.expand ? 'expand_less' : 'expand_more'"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
icon="link"
|
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
|
||||||
type="a"
|
|
||||||
:href="props.row.displayUrl"
|
|
||||||
target="_blank"
|
|
||||||
></q-btn>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
icon="how_to_reg"
|
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
|
||||||
type="a"
|
|
||||||
:href="'/events/register/' + props.row.id"
|
|
||||||
target="_blank"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.value"></span>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
@click="updateformDialog(props.row.id)"
|
|
||||||
icon="edit"
|
|
||||||
color="light-blue"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
@click="deleteEvent(props.row.id)"
|
|
||||||
icon="cancel"
|
|
||||||
color="pink"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
<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="column">
|
|
||||||
<div
|
|
||||||
v-if="props.row.extra.promo_codes.length == 0"
|
|
||||||
class="text-caption"
|
|
||||||
>
|
|
||||||
No 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">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<div class="row items-center no-wrap q-mb-md">
|
|
||||||
<div class="col">
|
|
||||||
<h5 class="text-subtitle1 q-my-none">Tickets</h5>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn flat color="grey" @click="exportticketsCSV"
|
|
||||||
>Export to CSV</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-table
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
:rows="tickets"
|
|
||||||
row-key="id"
|
|
||||||
:columns="ticketsTable.columns"
|
|
||||||
v-model:pagination="ticketsTable.pagination"
|
|
||||||
>
|
|
||||||
<template v-slot:header="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.label"></span>
|
|
||||||
</q-th>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
icon="local_activity"
|
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
|
||||||
type="a"
|
|
||||||
:href="'/events/ticket/' + props.row.id"
|
|
||||||
target="_blank"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.value"></span>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
flat
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
@click="deleteTicket(props.row.id)"
|
|
||||||
icon="cancel"
|
|
||||||
color="pink"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<h6 class="text-subtitle1 q-my-none">
|
|
||||||
{{SITE_TITLE}} Events extension
|
|
||||||
</h6>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<q-separator></q-separator>
|
|
||||||
<q-list> {% include "events/_api_docs.html" %} </q-list>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-dialog v-model="formDialog.show" position="top">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
||||||
<q-form @submit="sendEventData" class="q-gutter-md">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.name"
|
|
||||||
type="name"
|
|
||||||
label="Title of event "
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col q-pl-sm">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="formDialog.data.wallet"
|
|
||||||
:options="g.user.walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
>
|
|
||||||
</q-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.info"
|
|
||||||
type="textarea"
|
|
||||||
label="Info about the event"
|
|
||||||
hint="Markdown supported"
|
|
||||||
></q-input>
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.banner"
|
|
||||||
type="url"
|
|
||||||
label="Image URL"
|
|
||||||
hint="Optional banner image to display on the event page"
|
|
||||||
></q-input>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<div class="col-4">Ticket closing date</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.closing_date"
|
|
||||||
type="date"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">Event begins</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.event_start_date"
|
|
||||||
type="date"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">Event ends</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.event_end_date"
|
|
||||||
type="date"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<div class="col">
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model="formDialog.data.currency"
|
|
||||||
type="text"
|
|
||||||
label="Unit"
|
|
||||||
:options="currencies"
|
|
||||||
></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.amount_tickets"
|
|
||||||
type="number"
|
|
||||||
label="Amount of tickets "
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.price_per_ticket"
|
|
||||||
type="number"
|
|
||||||
:label="'Price (' + formDialog.data.currency + ') *'"
|
|
||||||
:step="formDialog.data.currency != 'sats' ? '0.01' : '1'"
|
|
||||||
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
|
|
||||||
fill-mask="0"
|
|
||||||
reverse-fill-mask
|
|
||||||
:disable="formDialog.data.currency == null"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-expansion-item
|
|
||||||
group="advanced"
|
|
||||||
icon="settings"
|
|
||||||
label="Advanced options"
|
|
||||||
>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
|
|
||||||
<div class="text-caption">
|
|
||||||
Make this event conditional if
|
|
||||||
<strong>minimum tickets</strong> are sold. User will be asked to
|
|
||||||
provide a Lightning Address or LNURL pay for refunds.
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.extra.conditional"
|
|
||||||
label="Conditional Event"
|
|
||||||
left-label
|
|
||||||
></q-toggle>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.extra.min_tickets"
|
|
||||||
type="number"
|
|
||||||
label="Minimum Tickets"
|
|
||||||
:disable="!formDialog.data.extra.conditional"
|
|
||||||
></q-input>
|
|
||||||
</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-8"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.extra.promo_codes[index].code"
|
|
||||||
type="text"
|
|
||||||
label="Promo Code"
|
|
||||||
>
|
|
||||||
<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-4"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.number="formDialog.data.extra.promo_codes[index].discount_percent"
|
|
||||||
type="number"
|
|
||||||
label="Discount (%)"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
>
|
|
||||||
<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})"
|
|
||||||
>Add Promo Code</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn
|
|
||||||
v-if="formDialog.data.id"
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
>Update Event</q-btn
|
|
||||||
>
|
|
||||||
<q-btn
|
|
||||||
v-else
|
|
||||||
unelevated
|
|
||||||
color="primary"
|
|
||||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
|
|
||||||
type="submit"
|
|
||||||
>Create Event</q-btn
|
|
||||||
>
|
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-form>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
|
||||||
<style>
|
|
||||||
.q-field__native span {
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script src="{{ static_url_for('events/static', path='js/index.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
{% extends "public.html" %} {% block page %}
|
|
||||||
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<center>
|
|
||||||
<h3 class="q-my-none">{{ event_name }} Registration</h3>
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<q-btn unelevated color="primary" @click="showCamera" size="xl"
|
|
||||||
>Scan ticket</q-btn
|
|
||||||
>
|
|
||||||
</center>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<q-table
|
|
||||||
dense
|
|
||||||
flat
|
|
||||||
:rows="tickets"
|
|
||||||
row-key="id"
|
|
||||||
:columns="ticketsTable.columns"
|
|
||||||
v-model:pagination="ticketsTable.pagination"
|
|
||||||
>
|
|
||||||
<template v-slot:header="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-th auto-width></q-th>
|
|
||||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.label"></span>
|
|
||||||
</q-th>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
<template v-slot:body="props">
|
|
||||||
<q-tr :props="props">
|
|
||||||
<q-td auto-width>
|
|
||||||
<q-btn
|
|
||||||
unelevated
|
|
||||||
dense
|
|
||||||
size="xs"
|
|
||||||
icon="local_activity"
|
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
|
||||||
type="a"
|
|
||||||
:href="'/events/ticket/' + props.row.id"
|
|
||||||
target="_blank"
|
|
||||||
></q-btn>
|
|
||||||
</q-td>
|
|
||||||
|
|
||||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
|
||||||
<span v-text="col.value"></span>
|
|
||||||
</q-td>
|
|
||||||
</q-tr>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-dialog v-model="sendCamera.show" position="top">
|
|
||||||
<q-card class="q-pa-lg q-pt-xl">
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<qrcode-stream
|
|
||||||
@detect="decodeQR"
|
|
||||||
class="rounded-borders"
|
|
||||||
></qrcode-stream>
|
|
||||||
</div>
|
|
||||||
<div class="row q-mt-lg">
|
|
||||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
|
||||||
>Cancel</q-btn
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
|
||||||
const event_id = '{{ event_id }}'
|
|
||||||
</script>
|
|
||||||
<script src="{{ static_url_for('events/static', path='js/register.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
{% extends "public.html" %} {% block page %}
|
|
||||||
<div class="row q-col-gutter-md justify-center">
|
|
||||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
|
||||||
<q-card class="q-pa-lg">
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<center>
|
|
||||||
<h3 class="q-my-none">{{ ticket_name }} Ticket</h3>
|
|
||||||
<br />
|
|
||||||
<h5 class="q-my-none">
|
|
||||||
Bookmark, print or screenshot this page,<br />
|
|
||||||
and present it for registration!
|
|
||||||
</h5>
|
|
||||||
<br />
|
|
||||||
<lnbits-qrcode
|
|
||||||
:value="'ticket://{{ ticket_id }}'"
|
|
||||||
:options="{width: 500}"
|
|
||||||
></lnbits-qrcode>
|
|
||||||
<br />
|
|
||||||
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
|
||||||
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
|
|
||||||
>
|
|
||||||
</center>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
|
||||||
window.app = Vue.createApp({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
methods: {
|
|
||||||
printWindow() {
|
|
||||||
window.print()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
151
views.py
151
views.py
|
|
@ -1,139 +1,24 @@
|
||||||
from datetime import date, datetime
|
from fastapi import APIRouter, Depends
|
||||||
from http import HTTPStatus
|
from lnbits.core.views.generic import index, index_public
|
||||||
|
from lnbits.decorators import check_account_id_exists
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from lnbits.core.models import User
|
|
||||||
from lnbits.decorators import check_user_exists
|
|
||||||
from lnbits.helpers import template_renderer
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event
|
|
||||||
from .services import refund_tickets
|
|
||||||
|
|
||||||
events_generic_router = APIRouter()
|
events_generic_router = APIRouter()
|
||||||
|
|
||||||
|
events_generic_router.add_api_route(
|
||||||
|
"/",
|
||||||
|
methods=["GET"],
|
||||||
|
endpoint=index,
|
||||||
|
dependencies=[Depends(check_account_id_exists)],
|
||||||
|
)
|
||||||
|
|
||||||
def events_renderer():
|
events_generic_router.add_api_route(
|
||||||
return template_renderer(["events/templates"])
|
"/{event_id}", methods=["GET"], endpoint=index_public
|
||||||
|
)
|
||||||
|
|
||||||
|
events_generic_router.add_api_route(
|
||||||
|
"/ticket/{ticket_id}", methods=["GET"], endpoint=index_public
|
||||||
|
)
|
||||||
|
|
||||||
@events_generic_router.get("/", response_class=HTMLResponse)
|
events_generic_router.add_api_route(
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
"/register/{event_id}", methods=["GET"], endpoint=index_public
|
||||||
return events_renderer().TemplateResponse(
|
)
|
||||||
"events/index.html", {"request": request, "user": user.json()}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@events_generic_router.get("/{event_id}", response_class=HTMLResponse)
|
|
||||||
async def display(request: Request, event_id):
|
|
||||||
event = await get_event(event_id)
|
|
||||||
if not event:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
await purge_unpaid_tickets(event_id)
|
|
||||||
|
|
||||||
is_window_open = (
|
|
||||||
date.today() < datetime.strptime(event.closing_date, "%Y-%m-%d").date()
|
|
||||||
)
|
|
||||||
is_min_tickets_met = (
|
|
||||||
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
|
||||||
)
|
|
||||||
|
|
||||||
if event.amount_tickets < 1:
|
|
||||||
return events_renderer().TemplateResponse(
|
|
||||||
"events/error.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"event_name": event.name,
|
|
||||||
"event_error": "Sorry, tickets are sold out :(",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
|
|
||||||
event.canceled = True
|
|
||||||
await update_event(event)
|
|
||||||
await refund_tickets(event_id)
|
|
||||||
|
|
||||||
return events_renderer().TemplateResponse(
|
|
||||||
"events/error.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"event_name": event.name,
|
|
||||||
"event_error": "Sorry, event was cancelled.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not is_window_open:
|
|
||||||
return events_renderer().TemplateResponse(
|
|
||||||
"events/error.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"event_name": event.name,
|
|
||||||
"event_error": "Sorry, ticket closing date has passed :(",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(event.extra.promo_codes) > 0:
|
|
||||||
has_promo_codes = True
|
|
||||||
else:
|
|
||||||
has_promo_codes = False
|
|
||||||
|
|
||||||
event.extra.promo_codes = []
|
|
||||||
return events_renderer().TemplateResponse(
|
|
||||||
"events/display.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"event_id": event_id,
|
|
||||||
"event_name": event.name,
|
|
||||||
"event_info": event.info,
|
|
||||||
"event_price": event.price_per_ticket,
|
|
||||||
"event_banner": event.banner,
|
|
||||||
"event_extra": event.extra.json(),
|
|
||||||
"has_promo_codes": has_promo_codes,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@events_generic_router.get("/ticket/{ticket_id}", response_class=HTMLResponse)
|
|
||||||
async def ticket(request: Request, ticket_id):
|
|
||||||
ticket = await get_ticket(ticket_id)
|
|
||||||
if not ticket:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
event = await get_event(ticket.event)
|
|
||||||
if not event:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
return events_renderer().TemplateResponse(
|
|
||||||
"events/ticket.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"ticket_id": ticket_id,
|
|
||||||
"ticket_name": event.name,
|
|
||||||
"ticket_info": event.info,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@events_generic_router.get("/register/{event_id}", response_class=HTMLResponse)
|
|
||||||
async def register(request: Request, event_id):
|
|
||||||
event = await get_event(event_id)
|
|
||||||
if not event:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
return events_renderer().TemplateResponse(
|
|
||||||
"events/register.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"event_id": event_id,
|
|
||||||
"event_name": event.name,
|
|
||||||
"wallet_id": event.wallet,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
|
||||||
241
views_api.py
241
views_api.py
|
|
@ -1,8 +1,17 @@
|
||||||
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import (
|
||||||
from lnbits.core.crud import get_standalone_payment, get_user
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketDisconnect,
|
||||||
|
)
|
||||||
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.core.services import create_invoice
|
from lnbits.core.services import create_invoice
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
|
|
@ -13,7 +22,6 @@ from lnbits.utils.exchange_rates import (
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
get_fiat_rate_satoshis,
|
get_fiat_rate_satoshis,
|
||||||
)
|
)
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_event,
|
create_event,
|
||||||
|
|
@ -26,36 +34,79 @@ from .crud import (
|
||||||
get_events,
|
get_events,
|
||||||
get_ticket,
|
get_ticket,
|
||||||
get_tickets,
|
get_tickets,
|
||||||
|
purge_unpaid_tickets,
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import CreateEvent, CreateTicket, Ticket
|
from .models import (
|
||||||
from .services import refund_tickets, set_ticket_paid
|
CreateEvent,
|
||||||
|
CreateTicket,
|
||||||
|
Event,
|
||||||
|
PublicEvent,
|
||||||
|
PublicTicket,
|
||||||
|
Ticket,
|
||||||
|
TicketPaymentRequest,
|
||||||
|
)
|
||||||
|
from .services import refund_tickets
|
||||||
|
from .tasks import deregister_payment_listener, register_payment_listener
|
||||||
|
|
||||||
events_api_router = APIRouter()
|
events_api_router = APIRouter(prefix="/api/v1/events")
|
||||||
|
tickets_api_router = APIRouter(prefix="/api/v1/tickets")
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("/api/v1/events")
|
@events_api_router.get("")
|
||||||
async def api_events(
|
async def api_events(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||||
):
|
) -> list[Event]:
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(wallet.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
return [event.dict() for event in await get_events(wallet_ids)]
|
return await get_events(wallet_ids)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.post("/api/v1/events")
|
@events_api_router.get("/{event_id}", response_model=PublicEvent)
|
||||||
@events_api_router.put("/api/v1/events/{event_id}")
|
async def api_get_event(event_id: str) -> Event:
|
||||||
|
event = await get_event(event_id)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
await purge_unpaid_tickets(event_id)
|
||||||
|
|
||||||
|
is_window_open = datetime.now(timezone.utc) < datetime.strptime(
|
||||||
|
event.closing_date, "%Y-%m-%d"
|
||||||
|
).replace(tzinfo=timezone.utc)
|
||||||
|
is_min_tickets_met = (
|
||||||
|
event.sold >= event.extra.min_tickets if event.extra.conditional else True
|
||||||
|
)
|
||||||
|
if event.amount_tickets < 1:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
|
if event.extra.conditional and not is_min_tickets_met and not is_window_open:
|
||||||
|
event.canceled = True
|
||||||
|
await update_event(event)
|
||||||
|
await refund_tickets(event_id)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event canceled.")
|
||||||
|
|
||||||
|
if not is_window_open:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.GONE, detail="Ticket closing date has passed."
|
||||||
|
)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@events_api_router.post("")
|
||||||
|
@events_api_router.put("/{event_id}")
|
||||||
async def api_event_create(
|
async def api_event_create(
|
||||||
data: CreateEvent,
|
data: CreateEvent,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
event_id: str | None = None,
|
event_id: str | None = None,
|
||||||
):
|
) -> Event:
|
||||||
if event_id:
|
if event_id:
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
|
|
@ -73,14 +124,14 @@ async def api_event_create(
|
||||||
else:
|
else:
|
||||||
event = await create_event(data)
|
event = await create_event(data)
|
||||||
|
|
||||||
return event.dict()
|
return event
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.put("/api/v1/events/{event_id}/cancel")
|
@events_api_router.put("/{event_id}/cancel")
|
||||||
async def api_event_cancel(
|
async def api_event_cancel(
|
||||||
event_id: str,
|
event_id: str,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
):
|
) -> Event:
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -93,13 +144,13 @@ async def api_event_cancel(
|
||||||
event = await update_event(event)
|
event = await update_event(event)
|
||||||
await refund_tickets(event.id)
|
await refund_tickets(event.id)
|
||||||
|
|
||||||
return event.dict()
|
return event
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.delete("/api/v1/events/{event_id}")
|
@events_api_router.delete("/{event_id}")
|
||||||
async def api_form_delete(
|
async def api_form_delete(
|
||||||
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
) -> None:
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -111,47 +162,65 @@ async def api_form_delete(
|
||||||
|
|
||||||
await delete_event(event_id)
|
await delete_event(event_id)
|
||||||
await delete_event_tickets(event_id)
|
await delete_event_tickets(event_id)
|
||||||
return "", HTTPStatus.NO_CONTENT
|
|
||||||
|
|
||||||
|
|
||||||
#########Tickets##########
|
@events_api_router.get(
|
||||||
|
"/{event_id}/tickets",
|
||||||
|
response_model=list[PublicTicket],
|
||||||
|
)
|
||||||
|
async def api_event_tickets(event_id: str) -> list[Ticket]:
|
||||||
|
return await get_event_tickets(event_id)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("/api/v1/tickets")
|
@tickets_api_router.get("")
|
||||||
async def api_tickets(
|
async def api_tickets(
|
||||||
all_wallets: bool = Query(False),
|
all_wallets: bool = Query(False),
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[Ticket]:
|
) -> list[Ticket]:
|
||||||
wallet_ids = [wallet.wallet.id]
|
wallet_ids = [key_info.wallet.id]
|
||||||
|
|
||||||
if all_wallets:
|
if all_wallets:
|
||||||
user = await get_user(wallet.wallet.user)
|
user = await get_user(key_info.wallet.user)
|
||||||
wallet_ids = user.wallet_ids if user else []
|
wallet_ids = user.wallet_ids if user else []
|
||||||
|
|
||||||
return await get_tickets(wallet_ids)
|
return await get_tickets(wallet_ids)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.post("/api/v1/tickets/{event_id}")
|
@tickets_api_router.get("/{ticket_id}", response_model=PublicTicket)
|
||||||
async def api_ticket_create(event_id: str, data: CreateTicket):
|
async def api_get_ticket(ticket_id: str) -> Ticket:
|
||||||
name = data.name
|
ticket = await get_ticket(ticket_id)
|
||||||
email = data.email
|
if not ticket:
|
||||||
promo_code = data.promo_code.upper() if data.promo_code else None
|
raise HTTPException(
|
||||||
refund_address = data.refund_address
|
status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist."
|
||||||
return await api_ticket_make_ticket(
|
|
||||||
event_id, name, email, promo_code, refund_address
|
|
||||||
)
|
)
|
||||||
|
event = await get_event(ticket.event)
|
||||||
|
if not event:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
|
)
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}")
|
@tickets_api_router.post("/{event_id}")
|
||||||
async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address):
|
async def api_ticket_create(event_id: str, data: CreateTicket) -> TicketPaymentRequest:
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if event.canceled:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is canceled.")
|
||||||
|
|
||||||
|
if event.amount_tickets > 0 and event.sold >= event.amount_tickets:
|
||||||
|
raise HTTPException(status_code=HTTPStatus.GONE, detail="Event is sold out.")
|
||||||
|
|
||||||
|
name = data.name
|
||||||
|
email = data.email
|
||||||
|
promo_code = data.promo_code.upper() if data.promo_code else None
|
||||||
|
refund_address = data.refund_address
|
||||||
price = event.price_per_ticket
|
price = event.price_per_ticket
|
||||||
extra = {"tag": "events", "name": name, "email": email}
|
extra: dict[str, Any] = {"tag": "events", "name": name, "email": email}
|
||||||
|
|
||||||
if promo_code:
|
if promo_code:
|
||||||
# check if promo_code exists in event.extra.promo_codes
|
# check if promo_code exists in event.extra.promo_codes
|
||||||
|
|
@ -172,7 +241,6 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre
|
||||||
|
|
||||||
price = await fiat_amount_as_satoshis(price, event.currency)
|
price = await fiat_amount_as_satoshis(price, event.currency)
|
||||||
|
|
||||||
try:
|
|
||||||
payment = await create_invoice(
|
payment = await create_invoice(
|
||||||
wallet_id=event.wallet,
|
wallet_id=event.wallet,
|
||||||
amount=price,
|
amount=price,
|
||||||
|
|
@ -191,65 +259,58 @@ async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_addre
|
||||||
"sats_paid": int(price),
|
"sats_paid": int(price),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
|
|
||||||
|
|
||||||
|
return TicketPaymentRequest(
|
||||||
@events_api_router.post("/api/v1/tickets/{event_id}/{payment_hash}")
|
payment_hash=payment.payment_hash, payment_request=payment.bolt11
|
||||||
async def api_ticket_send_ticket(event_id, payment_hash):
|
|
||||||
event = await get_event(event_id)
|
|
||||||
if not event:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Event could not be fetched.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tickets_api_router.websocket("/ws/{payment_hash}")
|
||||||
|
async def websocket_endpoint(payment_hash: str, websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
queue: asyncio.Queue[Ticket] = asyncio.Queue()
|
||||||
|
register_payment_listener(payment_hash, queue)
|
||||||
|
disconnect_task: asyncio.Task | None = None
|
||||||
|
payment_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
ticket = await get_ticket(payment_hash)
|
ticket = await get_ticket(payment_hash)
|
||||||
if not ticket:
|
if ticket and ticket.paid:
|
||||||
raise HTTPException(
|
await websocket.send_json({"paid": True})
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
return
|
||||||
detail="Ticket could not be fetched.",
|
|
||||||
)
|
|
||||||
payment = await get_standalone_payment(payment_hash, incoming=True)
|
|
||||||
assert payment
|
|
||||||
|
|
||||||
if ticket.extra.applied_promo_code:
|
while True:
|
||||||
promo = next(
|
disconnect_task = asyncio.create_task(websocket.receive_text())
|
||||||
(
|
payment_task = asyncio.create_task(queue.get())
|
||||||
pc
|
done, pending = await asyncio.wait(
|
||||||
for pc in event.extra.promo_codes
|
{disconnect_task, payment_task}, return_when=asyncio.FIRST_COMPLETED
|
||||||
if pc.code == ticket.extra.applied_promo_code
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if promo:
|
|
||||||
event.price_per_ticket *= 1 - promo.discount_percent / 100
|
|
||||||
|
|
||||||
price = (
|
|
||||||
event.price_per_ticket * 1000
|
|
||||||
if event.currency == "sats"
|
|
||||||
else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
|
|
||||||
* 1000
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# check if price is equal to payment.amount
|
for task in pending:
|
||||||
lower_bound = price * 0.99 # 1% decrease
|
task.cancel()
|
||||||
|
|
||||||
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error
|
if disconnect_task in done:
|
||||||
ticket.extra.sats_paid = int(payment.amount / 1000)
|
try:
|
||||||
await set_ticket_paid(ticket)
|
disconnect_task.result()
|
||||||
return {"paid": True, "ticket_id": ticket.id}
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
return {"paid": False}
|
ticket = payment_task.result()
|
||||||
|
await websocket.send_json({"paid": ticket.paid})
|
||||||
|
if ticket.paid:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
for pending_task in (disconnect_task, payment_task):
|
||||||
|
if pending_task and not pending_task.done():
|
||||||
|
pending_task.cancel()
|
||||||
|
deregister_payment_listener(payment_hash, queue)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.delete("/api/v1/tickets/{ticket_id}")
|
@tickets_api_router.delete("/{ticket_id}")
|
||||||
async def api_ticket_delete(
|
async def api_ticket_delete(
|
||||||
ticket_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
|
ticket_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
):
|
) -> None:
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -262,14 +323,8 @@ async def api_ticket_delete(
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
|
|
||||||
|
|
||||||
@events_api_router.get("/api/v1/eventtickets/{event_id}")
|
@tickets_api_router.put("/register/{ticket_id}", response_model=PublicTicket)
|
||||||
async def api_event_tickets(event_id: str) -> list[Ticket]:
|
async def api_event_register_ticket(ticket_id) -> Ticket:
|
||||||
return await get_event_tickets(event_id)
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: PUT, updates db! @tal
|
|
||||||
@events_api_router.get("/api/v1/register/ticket/{ticket_id}")
|
|
||||||
async def api_event_register_ticket(ticket_id) -> list[Ticket]:
|
|
||||||
ticket = await get_ticket(ticket_id)
|
ticket = await get_ticket(ticket_id)
|
||||||
|
|
||||||
if not ticket:
|
if not ticket:
|
||||||
|
|
@ -289,5 +344,5 @@ async def api_event_register_ticket(ticket_id) -> list[Ticket]:
|
||||||
|
|
||||||
ticket.registered = True
|
ticket.registered = True
|
||||||
ticket.reg_timestamp = datetime.now(timezone.utc)
|
ticket.reg_timestamp = datetime.now(timezone.utc)
|
||||||
await update_ticket(ticket)
|
ticket = await update_ticket(ticket)
|
||||||
return await get_event_tickets(ticket.event)
|
return ticket
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue