Non-admin event submissions now land in a "proposed" queue that LNbits
admins review before the event becomes ticketable and publicly listed.
- m008 adds events.events.status (proposed/approved/rejected); m010 seeds
an events.settings singleton row with the auto_approve toggle.
- Models: Event/CreateEvent.status, EventsSettings, optional date fields
with sensible defaults (closing_date defaults to event_end_date which
defaults to event_start_date), PublicEvent.status surfaces the workflow
state on the public endpoint.
- crud: get_all/public/pending_events for the admin views; get/update_settings
for the auto_approve toggle; create_event auto-fills missing date defaults.
- views_api:
* POST /api/v1/events accepts wallet invoice keys so anyone can submit;
handler stamps status="proposed" for non-admins when auto_approve is off
* /public, /all, /pending, /settings (GET+PUT), /{id}/{approve,reject},
/{id}/tickets endpoints; literal-prefix routes declared before /{event_id}
so FastAPI matches them correctly
* Public GET /{event_id} bypasses sold-out / closing-window gates for
proposed/rejected events and returns the trimmed PublicEvent so the SFC
can render a "pending approval" banner
* POST /tickets/{event_id} rejects when event.status != "approved"
- Frontend: index.vue gains an admin Settings card, Pending Approvals list,
status badge column and approve/reject row actions, plus an All Users'
Events admin table; index.js gains the data + methods + an isAdmin probe
via GET /events/all; display.vue shows pending/rejected banners and
hides the Buy Ticket form unless status === "approved".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
657 lines
22 KiB
Vue
657 lines
22 KiB
Vue
<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 v-if="isAdmin">
|
|
<q-card-section>
|
|
<div class="row items-center justify-between">
|
|
<div class="col">
|
|
<span class="text-subtitle1">Settings</span>
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-toggle
|
|
v-model="settings.auto_approve"
|
|
label="Auto-approve events"
|
|
@update:model-value="saveSettings"
|
|
></q-toggle>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<q-card>
|
|
<q-card-section>
|
|
<q-btn unelevated color="primary" @click="openEventDialog"
|
|
>New Event</q-btn
|
|
>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<q-card v-if="pendingEvents.length > 0">
|
|
<q-card-section>
|
|
<div class="row items-center no-wrap q-mb-md">
|
|
<div class="col">
|
|
<h5 class="text-subtitle1 q-my-none">
|
|
<q-icon name="pending" color="orange" class="q-mr-sm"></q-icon>
|
|
Pending Approvals
|
|
<q-badge
|
|
color="orange"
|
|
:label="pendingEvents.length"
|
|
class="q-ml-sm"
|
|
></q-badge>
|
|
</h5>
|
|
</div>
|
|
</div>
|
|
<q-list separator>
|
|
<q-item v-for="event in pendingEvents" :key="event.id">
|
|
<q-item-section>
|
|
<q-item-label v-text="event.name"></q-item-label>
|
|
<q-item-label caption>
|
|
<span v-text="event.event_start_date"></span>
|
|
—
|
|
<span v-text="event.info.substring(0, 80)"></span
|
|
><span v-if="event.info.length > 80">...</span>
|
|
</q-item-label>
|
|
<q-item-label caption>
|
|
<span v-text="event.amount_tickets"></span> tickets •
|
|
<span v-text="event.price_per_ticket"></span>
|
|
<span v-text="event.currency"></span>
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<div class="row q-gutter-sm">
|
|
<q-btn
|
|
dense
|
|
color="green"
|
|
icon="check_circle"
|
|
label="Approve"
|
|
size="sm"
|
|
@click="approveEvent(event.id)"
|
|
></q-btn>
|
|
<q-btn
|
|
dense
|
|
outline
|
|
color="red"
|
|
icon="block"
|
|
label="Reject"
|
|
size="sm"
|
|
@click="rejectEvent(event.id)"
|
|
></q-btn>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</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
|
|
v-if="isAdmin && props.row.status === 'proposed'"
|
|
flat
|
|
dense
|
|
size="xs"
|
|
@click="approveEvent(props.row.id)"
|
|
icon="check_circle"
|
|
color="green"
|
|
>
|
|
<q-tooltip>Approve</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
v-if="isAdmin && props.row.status === 'proposed'"
|
|
flat
|
|
dense
|
|
size="xs"
|
|
@click="rejectEvent(props.row.id)"
|
|
icon="block"
|
|
color="red"
|
|
>
|
|
<q-tooltip>Reject</q-tooltip>
|
|
</q-btn>
|
|
<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">
|
|
<q-badge
|
|
v-if="col.name === 'status'"
|
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
|
:label="col.value"
|
|
></q-badge>
|
|
<span v-else 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 v-if="isAdmin && allUserEvents.length > 0">
|
|
<q-card-section>
|
|
<div class="row items-center no-wrap q-mb-md">
|
|
<div class="col">
|
|
<h5 class="text-subtitle1 q-my-none">
|
|
All Users' Events
|
|
<q-badge
|
|
color="blue"
|
|
:label="allUserEvents.length"
|
|
class="q-ml-sm"
|
|
></q-badge>
|
|
</h5>
|
|
</div>
|
|
</div>
|
|
<q-table
|
|
dense
|
|
flat
|
|
:rows="allUserEvents"
|
|
row-key="id"
|
|
:columns="eventsTable.columns"
|
|
:pagination="{rowsPerPage: 10}"
|
|
>
|
|
<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">
|
|
<q-badge
|
|
v-if="col.name === 'status'"
|
|
:color="col.value === 'approved' ? 'green' : col.value === 'proposed' ? 'orange' : 'red'"
|
|
:label="col.value"
|
|
></q-badge>
|
|
<span v-else v-text="col.value"></span>
|
|
</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>
|