diff --git a/README.md b/README.md
index cd02134..4ac72c5 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend
- **Stripe session.** The buyer's email is passed as `customer_email`
(prefilled and locked on the hosted page); the line item is named after the
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
+- **Promo codes.** `extra.promo_codes` (`code`, `discount_percent`, `active`,
+ `max_uses`; `used_count` is derived from paid tickets) are organizer-only: they are
+ never part of public responses. Buyers preview a code with
+ `POST /events/api/v1/promo/validate/{event_id}` (`{codes, quantity}` → v2-shaped
+ `BasketTotals` + `currency`); purchase enforces `active` and `max_uses` (each ticket
+ of a multi-ticket purchase consumes one use) and rejects bad codes with a distinct
+ `detail`. Updates that omit `extra.promo_codes` keep the stored list.
- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
attached — a self-describing PNG (site, event, when, where, QR with the
instance logo, name on ticket, ticket id) also served at
diff --git a/__init__.py b/__init__.py
index 394bc6d..a0b330d 100644
--- a/__init__.py
+++ b/__init__.py
@@ -6,13 +6,19 @@ from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
from .views import events_generic_router
-from .views_api import events_api_router, qr_api_router, tickets_api_router
+from .views_api import (
+ events_api_router,
+ promo_api_router,
+ qr_api_router,
+ tickets_api_router,
+)
events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
events_ext.include_router(events_generic_router)
events_ext.include_router(events_api_router)
events_ext.include_router(tickets_api_router)
events_ext.include_router(qr_api_router)
+events_ext.include_router(promo_api_router)
events_static_files = [
{
diff --git a/config.json b/config.json
index 68e908a..d2d5cb7 100644
--- a/config.json
+++ b/config.json
@@ -1,6 +1,6 @@
{
"id": "events",
- "version": "1.6.1-aio.10",
+ "version": "1.6.1-aio.14",
"name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",
diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md
index 93e6c2f..56e2ee7 100644
--- a/docs/upstream-candidates.md
+++ b/docs/upstream-candidates.md
@@ -4,16 +4,17 @@ Running log of fork features that are shaped so they could be offered to
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
in an upstream-compatible form; strike it when the PR merges upstream.
-| Feature | Where | Upstream target | Readiness |
-| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
-| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
-| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
-| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
-| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
-| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
-| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
-| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
-| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
-| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
-| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
-| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
+| Feature | Where | Upstream target | Readiness |
+| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
+| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
+| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
+| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
+| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
+| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
+| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
+| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
+| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
+| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
+| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `
` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
+| Promo `max_uses` + derived `used_count` (per ticket; v2 counts per basket), `POST /promo/validate/{event_id}` with `quantity` instead of `items`, `PublicEventExtra` projection (v2 still exposes `extra` fully) | `promo.py`, `models.py`, `views_api.py` | lnbits/events (v2 PR #64) | review input on #64 |
diff --git a/models.py b/models.py
index e36c60a..b349794 100644
--- a/models.py
+++ b/models.py
@@ -11,20 +11,41 @@ class PromoCode(BaseModel):
code: str
discount_percent: float = 0.0
active: bool = True
+ # Redemption cap; None / 0 = unlimited. Field names follow upstream v2.
+ max_uses: int | None = None
+ # Derived on read from PAID tickets whose extra.applied_promo_code matches
+ # (see promo.promo_usage / services.hydrate_promo_usage). Whatever a
+ # client sends back here is ignored — it is never the source of truth.
+ used_count: int = 0
- # make the promo code uppercase
+ # stored form: stripped + upper-case, never empty
@validator("code")
def uppercase_code(cls, v):
- return v.upper()
+ v = (v or "").strip().upper()
+ if not v:
+ raise ValueError("Promo code cannot be empty.")
+ return v
@validator("discount_percent")
def validate_discount_percent(cls, v):
assert 0 <= v <= 100, "Discount must be between 0 and 100."
return v
+ @validator("max_uses", pre=True)
+ def normalize_max_uses(cls, v):
+ if v in (None, "", 0, "0"):
+ return None
+ v = int(v)
+ if v < 1:
+ raise ValueError("max_uses must be at least 1.")
+ return v
+
+
+class EventExtraBase(BaseModel):
+ """Everything in `extra` that is safe to show anyone. `EventExtra` adds
+ the organizer-only promo codes on top; `PublicEventExtra` is this base,
+ so anonymous responses can never carry them."""
-class EventExtra(BaseModel):
- promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
email_notifications: bool = False
@@ -53,6 +74,13 @@ class EventExtra(BaseModel):
return seen
+class EventExtra(EventExtraBase):
+ promo_codes: list[PromoCode] = Field(default_factory=list)
+
+
+PublicEventExtra = EventExtraBase
+
+
class CreateEvent(BaseModel):
wallet: str | None = None # filled from caller's wallet if absent
name: str # title (required)
@@ -120,7 +148,9 @@ class PublicEvent(BaseModel):
banner: str | None
location: str | None = None
categories: list[str] = Field(default_factory=list)
- extra: EventExtra = Field(default_factory=EventExtra)
+ # PublicEventExtra: promo codes are organizer-only (a buyer who can read
+ # every code can mint every discount).
+ extra: PublicEventExtra = Field(default_factory=PublicEventExtra)
status: str = "approved" # surfaces "proposed"/"rejected" so SFC can render banner
@validator("categories", pre=True)
@@ -146,6 +176,29 @@ def effective_payment_methods(event: "Event | PublicEvent | CreateEvent") -> lis
return methods
+class PromoValidateRequest(BaseModel):
+ """Upstream v2 shape. v2 sends `items` (ticket types); this fork has one
+ price per event, so a plain `quantity` replaces it."""
+
+ codes: list[str] = Field(default_factory=list)
+ quantity: int = Field(default=1, ge=1, le=10)
+
+
+class BasketDiscount(BaseModel):
+ code: str
+ discount_percent: float | None = None
+ discount_fixed: int | None = None # always None here (percent-only); v2 shape
+ amount_saved: float = 0
+
+
+class BasketTotals(BaseModel):
+ subtotal: float = 0
+ discount: float = 0
+ total: float = 0
+ discounts_applied: list[BasketDiscount] = Field(default_factory=list)
+ currency: str = "sat" # fork addition so a client can format the numbers
+
+
class EventsSettings(BaseModel):
"""Extension-level settings for the events extension."""
diff --git a/promo.py b/promo.py
new file mode 100644
index 0000000..4bfa8ff
--- /dev/null
+++ b/promo.py
@@ -0,0 +1,108 @@
+"""Promo-code arithmetic shared by the validate endpoint and the purchase path.
+
+Pure functions (no DB, no settings) so the number a buyer sees in the
+"Apply" preview is exactly the number the invoice / Stripe session charges.
+Field names and the `BasketTotals` shape follow upstream lnbits/events v2
+(PR #64) so the eventual rebase (#33) merges cleanly; deviations are noted
+inline.
+"""
+
+from __future__ import annotations
+
+from collections import Counter
+
+from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket
+
+SAT_UNITS = ("sat", "sats")
+
+
+def normalize_code(raw: str | None) -> str | None:
+ """Buyer input → stored form (stripped, upper-cased); empty → None."""
+ if raw is None:
+ return None
+ code = raw.strip().upper()
+ return code or None
+
+
+def find_promo(event: Event, code: str) -> PromoCode | None:
+ return next((pc for pc in event.extra.promo_codes if pc.code == code), None)
+
+
+def promo_usage(tickets: list[Ticket]) -> dict[str, int]:
+ """Redemptions per code = PAID tickets carrying it in
+ `extra.applied_promo_code`. Every row counts, so a multi-ticket
+ purchase consumes `quantity` uses (upstream v2 counts one per basket).
+
+ Paid only: pending rows live up to 24 h (`purge_unpaid_tickets`), so
+ counting them would let an abandoned Stripe session lock out the last
+ uses of a limited code for a day. The cost is a bounded overshoot when
+ several buyers pass the check before any of them pays — accepted.
+ """
+ counter: Counter[str] = Counter()
+ for ticket in tickets:
+ code = ticket.extra.applied_promo_code
+ if ticket.paid and code:
+ counter[code] += 1
+ return dict(counter)
+
+
+def remaining_uses(promo: PromoCode, used: int) -> int | None:
+ """None = unlimited (`max_uses` unset / 0)."""
+ if not promo.max_uses:
+ return None
+ return max(promo.max_uses - used, 0)
+
+
+def round_amount(amount: float, currency: str | None) -> float:
+ """Sats are integers; fiat is 2 dp. Applied once, at the end, so
+ subtotal - total == discount holds for what is actually charged."""
+ if (currency or "sat").lower() in SAT_UNITS:
+ return float(int(amount))
+ return round(amount, 2)
+
+
+def basket_totals(
+ event: Event,
+ codes: list[str],
+ quantity: int,
+ usage: dict[str, int],
+) -> BasketTotals:
+ """Price `quantity` tickets with the first applicable code in `codes`.
+
+ A code is applicable when it exists, is active, has enough uses left
+ for the whole quantity, and actually saves something. Anything else is
+ simply absent from `discounts_applied` (upstream v2 semantics — the
+ purchase endpoint is where hard errors are raised). Only one code is
+ applied; v2's `combinable` stacking is out of scope here.
+ """
+ currency = event.currency or "sat"
+ subtotal = round_amount(event.price_per_ticket * quantity, currency)
+ totals = BasketTotals(
+ subtotal=subtotal, discount=0, total=subtotal, currency=currency
+ )
+ for raw in codes:
+ code = normalize_code(raw)
+ if not code:
+ continue
+ promo = find_promo(event, code)
+ if not promo or not promo.active:
+ continue
+ remaining = remaining_uses(promo, usage.get(promo.code, 0))
+ if remaining is not None and remaining < quantity:
+ continue
+ total = round_amount(subtotal * (1 - promo.discount_percent / 100), currency)
+ saved = round_amount(subtotal - total, currency)
+ if saved <= 0:
+ continue
+ totals.total = total
+ totals.discount = saved
+ totals.discounts_applied = [
+ BasketDiscount(
+ code=promo.code,
+ discount_percent=promo.discount_percent,
+ discount_fixed=None,
+ amount_saved=saved,
+ )
+ ]
+ break
+ return totals
diff --git a/services.py b/services.py
index 9e6d1ce..f69b76e 100644
--- a/services.py
+++ b/services.py
@@ -28,6 +28,7 @@ from .crud import (
)
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
from .nostr_hooks import publish_or_delete_nostr_event
+from .promo import promo_usage
from .qr import (
format_event_when,
image_png_bytes,
@@ -82,6 +83,22 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
return ticket
+async def event_promo_usage(event_id: str) -> dict[str, int]:
+ """Paid redemptions per promo code for one event (see promo.promo_usage)."""
+ return promo_usage(await get_event_tickets(event_id))
+
+
+async def hydrate_promo_usage(event: Event) -> Event:
+ """Fill `used_count` on each of the event's promo codes. No query when
+ the event has no codes, so listing stays cheap."""
+ if not event.extra.promo_codes:
+ return event
+ usage = await event_promo_usage(event.id)
+ for promo in event.extra.promo_codes:
+ promo.used_count = usage.get(promo.code, 0)
+ return event
+
+
def send_ticket_notification_in_background(ticket: Ticket) -> None:
create_task(_send_ticket_notification(ticket))
diff --git a/static/js/display.js b/static/js/display.js
index ad7e358..f687512 100644
--- a/static/js/display.js
+++ b/static/js/display.js
@@ -94,6 +94,7 @@ window.PageEventsDisplay = {
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
this.formDialog.data.nostr_identifier = ''
+ this.formDialog.data.promo_code = ''
this.formDialog.data.payment_method =
this.paymentMethods[0] || 'lightning'
},
diff --git a/static/js/index.js b/static/js/index.js
index 646b37b..962203a 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -67,13 +67,15 @@ window.PageEvents = {
name: 'event_start_date',
align: 'left',
label: 'Start date',
- field: 'event_start_date'
+ field: 'event_start_date',
+ format: val => this.formatEventDate(val)
},
{
name: 'event_end_date',
align: 'left',
label: 'End date',
- field: 'event_end_date'
+ field: 'event_end_date',
+ format: val => this.formatEventDate(val)
},
{
name: 'closing_date',
@@ -131,6 +133,19 @@ window.PageEvents = {
{name: 'event', align: 'left', label: 'Event', field: 'event'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'},
+ {
+ name: 'email_sent',
+ align: 'left',
+ label: 'Email sent',
+ field: row =>
+ !row.email
+ ? 'no email'
+ : row.extra?.email_notification_sent
+ ? '\u2713 sent'
+ : row.paid
+ ? 'not sent'
+ : 'unpaid'
+ },
{
name: 'registered',
align: 'left',
@@ -149,12 +164,58 @@ window.PageEvents = {
rowsPerPage: 10
}
},
+ // Rails an organizer can enable per event. Mirrors the webapp's
+ // CreateEventDialog; `fiat` is rendered disabled when the LNbits user
+ // has no fiat provider (see `hasFiatProvider`).
+ paymentMethodOptions: [
+ {
+ value: 'lightning',
+ label: 'Lightning',
+ hint: 'Pay with any Lightning wallet'
+ },
+ {
+ value: 'fiat',
+ label: 'Card',
+ hint: 'Card or bank through your configured fiat provider'
+ }
+ ],
+ // Same list the webapp offers (src/modules/events/types/category.ts);
+ // published as NIP-52 `t` tags so both clients filter on one vocabulary.
+ categoryOptions: [
+ 'concert',
+ 'workshop',
+ 'market',
+ 'festival',
+ 'exhibition',
+ 'sport',
+ 'theater',
+ 'cinema',
+ 'party',
+ 'talk',
+ 'conference',
+ 'meetup',
+ 'food',
+ 'outdoor',
+ 'kids',
+ 'wellness',
+ 'technology',
+ 'art',
+ 'music',
+ 'dance',
+ 'literature',
+ 'comedy',
+ 'charity',
+ 'tradition',
+ 'other'
+ ].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
formDialog: {
show: false,
data: {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
+ location: '',
+ categories: [],
extra: {
payment_methods: ['lightning'],
promo_codes: [],
@@ -165,6 +226,27 @@ window.PageEvents = {
}
}
},
+ computed: {
+ hasFiatProvider() {
+ return (this.g.user?.fiat_providers || []).length > 0
+ },
+ fiatProviderNames() {
+ return (this.g.user?.fiat_providers || [])
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
+ .join(', ')
+ },
+ acceptsFiat() {
+ return (this.formDialog.data.extra?.payment_methods || []).includes(
+ 'fiat'
+ )
+ },
+ isSatPrice() {
+ return !this.isFiatCurrency(this.formDialog.data.currency)
+ },
+ fiatCurrencyOptions() {
+ return this.currencies.filter(c => this.isFiatCurrency(c))
+ }
+ },
methods: {
isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase())
@@ -382,36 +464,71 @@ window.PageEvents = {
},
splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the
- // day/time pieces the form inputs bind to.
+ // day/time pieces the form inputs bind to. Slicing to HH:MM also
+ // drops the seconds + offset suffix withLocalTzOffset stamps on
+ // submit, so the organizer sees the wall-clock they entered.
if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)}
},
+ withLocalTzOffset(value) {
+ // Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
+ // The publisher's `_to_unix` treats a naive datetime as UTC, so an
+ // event entered as 18:00 in CEST would otherwise go out on Nostr
+ // as 18:00 UTC. Same transform the webapp applies; date-only
+ // values pass through unchanged (they map to NIP-52 kind 31922).
+ if (!value || !value.includes('T')) return value
+ const offMin = -new Date(value).getTimezoneOffset()
+ const sign = offMin >= 0 ? '+' : '-'
+ const abs = Math.abs(offMin)
+ const hh = String(Math.floor(abs / 60)).padStart(2, '0')
+ const mm = String(abs % 60).padStart(2, '0')
+ return `${value}:00${sign}${hh}:${mm}`
+ },
+ formatEventDate(value) {
+ // Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
+ if (!value) return ''
+ const {day, time} = this.splitDateTime(value)
+ return time ? `${day} ${time}` : day
+ },
+ validateEndDate() {
+ // Cross-field rule for the end-day input: end >= start, compared
+ // on the folded date+time so an equal-day earlier time is caught.
+ const d = this.formDialog.data
+ const start = this.foldDateTime(d.event_start_day, d.event_start_time)
+ const end = this.foldDateTime(d.event_end_day, d.event_end_time)
+ if (!start || !end) return true
+ return end >= start || 'End must be on or after start'
+ },
sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
const data = {...this.formDialog.data}
- data.event_start_date = this.foldDateTime(
- data.event_start_day,
- data.event_start_time
+ data.event_start_date = this.withLocalTzOffset(
+ this.foldDateTime(data.event_start_day, data.event_start_time)
)
- data.event_end_date = this.foldDateTime(
- data.event_end_day,
- data.event_end_time
+ data.event_end_date = this.withLocalTzOffset(
+ this.foldDateTime(data.event_end_day, data.event_end_time)
)
delete data.event_start_day
delete data.event_start_time
delete data.event_end_day
delete data.event_end_time
+ // Optional NIP-52 fields: blank location is "unset", not "".
+ data.location = (data.location || '').trim() || null
+ data.categories = data.categories || []
+ data.closing_date = data.closing_date || null
if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes
.filter(code => code.code?.trim() !== '')
.map(code => ({
...code,
- code: code.code.trim().toUpperCase()
+ code: code.code.trim().toUpperCase(),
+ // blank / 0 = unlimited; used_count is derived server-side
+ max_uses: code.max_uses ? Number(code.max_uses) : null
}))
}
const methods = data.extra?.payment_methods || []
@@ -425,10 +542,12 @@ window.PageEvents = {
// allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat')
- if (!this.isFiatCurrency(data.currency)) {
- if (!data.allow_fiat) {
- data.fiat_currency = 'GBP'
- }
+ if (this.isFiatCurrency(data.currency)) {
+ // A fiat-priced event settles in its price currency; mirror it so
+ // the payload (and the tickets_fiat_currency tag) stay coherent.
+ data.fiat_currency = data.currency
+ } else if (!data.allow_fiat) {
+ data.fiat_currency = 'GBP'
}
if (data.id) {
@@ -453,6 +572,8 @@ window.PageEvents = {
? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
},
+ location: data.location || '',
+ categories: [...(data.categories || [])],
event_start_day: start.day,
event_start_time: start.time,
event_end_day: end.day,
@@ -463,6 +584,8 @@ window.PageEvents = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
+ location: '',
+ categories: [],
event_start_day: '',
event_start_time: '',
event_end_day: '',
@@ -487,6 +610,8 @@ window.PageEvents = {
currency: 'sats',
allow_fiat: false,
fiat_currency: 'GBP',
+ location: '',
+ categories: [],
extra: {
payment_methods: ['lightning'],
email_notifications: false,
diff --git a/static/js/index.vue b/static/js/index.vue
index 502d9ad..d53f4c8 100644
--- a/static/js/index.vue
+++ b/static/js/index.vue
@@ -479,8 +479,10 @@
filled
dense
v-model.trim="formDialog.data.name"
- type="name"
- label="Title of event "
+ type="text"
+ label="Title of event *"
+ lazy-rules
+ :rules="[val => !!val || 'Title is required']"
>