Compare commits

...

2 commits

Author SHA1 Message Date
3d47862181 feat: per-event organizer name + reply-to on ticket emails (1.6.1-aio.14)
Some checks failed
lint.yml / feat: per-event organizer name + reply-to on ticket emails (1.6.1-aio.14) (pull_request) Failing after 0s
Each organizer is different, so the sender identity of ticket emails is
now per event rather than per instance:

- `extra.organizer_name` → From display name "Organizer via <site title>"
  (site title alone when unset). The From address stays the instance
  mailbox — that is what DKIM signs — so this costs nothing in mail auth.
- `extra.reply_to_email` → Reply-To; blank falls back to the email on the
  LNbits account that owns the event wallet; no header when neither
  exists. When a reply-to exists the body says "Questions? Reply to this
  email and it reaches <organizer>", and "Organizer: <name>" is listed
  with the ticket details.

Both fields sit next to the existing per-event subject/body in the admin
dialog. The organizer's own wording (in whatever language) remains
`notification_body`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-13 17:39:34 +02:00
f835766935 feat(admin): event form parity with the webapp (1.6.1-aio.13)
Some checks failed
lint.yml / feat(admin): event form parity with the webapp (1.6.1-aio.13) (pull_request) Failing after 0s
lint.yml / feat(admin): event form parity with the webapp (1.6.1-aio.13) (push) Failing after 0s
The LNbits admin form lagged the webapp's CreateEventDialog:

- Payment methods never rendered. c2d9a96 wired the template to
  `paymentMethodOptions` / `acceptsFiat` but never defined them, so the
  q-option-group got `options=undefined` and the fiat-currency select
  was gated on `undefined`. Rails are now two q-checkboxes; Card is
  disabled with an explanatory tooltip when `g.user.fiat_providers` is
  empty (same rule as the webapp) and names the providers otherwise.
- Location (NIP-52 `location` tag) and Categories (NIP-52 `t` tags,
  same 25-item list as the webapp's category.ts) were missing from the
  form even though the model, CRUD and publisher already carry them.
- Datetimes are stamped with the browser's UTC offset on submit, as the
  webapp does; `_to_unix` treats naive values as UTC, so 18:00 CEST
  entered here went out on Nostr as 18:00 UTC. Table columns render
  "YYYY-MM-DD HH:MM" instead of the raw ISO string.
- Validation: title + start date required, end >= start on the folded
  date+time, fiat currency required when a sat-priced event accepts
  card. Create is enabled once wallet + title + start are set; info,
  closing date, tickets and price were all effectively required before
  because the disable check compared undefined fields to null.
- Labels follow the payment-rails vocabulary: "Unit" -> "Price
  currency", "Fiat checkout currency" -> "Fiat currency"; ticket
  closing date and end date explain their defaults.
- A fiat-priced event mirrors `fiat_currency = currency` on save so the
  payload and the `tickets_fiat_currency` tag stay coherent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018b1bDExMX7W3a47wcgFUjb
2026-09-10 13:43:53 +02:00
8 changed files with 415 additions and 84 deletions

View file

@ -67,7 +67,10 @@ Events includes a shareable ticket scanner, which can be used to register attend
instance logo, name on ticket, ticket id) also served at instance logo, name on ticket, ticket id) also served at
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at `GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a `GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email` From display name — `extra.organizer_name` gives "Organizer via <site title>",
otherwise the site title. Replies go to `extra.reply_to_email`, falling back
to the event owner's account email (no Reply-To when neither exists). The
From _address_ is always the instance mailbox, which is what DKIM signs. `POST /events/api/v1/tickets/{ticket_id}/resend-email`
returns a `TicketResendResult` with per-channel outcome. returns a `TicketResendResult` with per-channel outcome.
## Powered by LNbits ## Powered by LNbits

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.1-aio.10", "version": "1.6.1-aio.14",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

View file

@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
in an upstream-compatible form; strike it when the PR merges upstream. in an upstream-compatible form; strike it when the PR merges upstream.
| Feature | Where | Upstream target | Readiness | | 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 | | `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 | | 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.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 |
@ -17,3 +17,4 @@ in an upstream-compatible form; strike it when the PR merges upstream.
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | | 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 | | `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 `<img>` (`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) | | Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`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) |
| Per-event sender identity: `extra.organizer_name` (From display name "X via <site>") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone |

View file

@ -31,6 +31,20 @@ class EventExtra(BaseModel):
nostr_notifications: bool = False nostr_notifications: bool = False
notification_subject: str = "" notification_subject: str = ""
notification_body: str = "" notification_body: str = ""
# Sender identity for ticket emails, per event: the From display name
# becomes "<organizer_name> via <site title>" and replies go to
# `reply_to_email` (falls back to the event owner's account email).
# The From *address* stays the instance mailbox — that is what DKIM
# signs — so organizers get a human sender without touching mail auth.
organizer_name: str = ""
reply_to_email: EmailStr | None = None
@validator("reply_to_email", pre=True)
def blank_reply_to_is_none(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
# Rails the organizer accepts for this event. Empty = legacy rule # Rails the organizer accepts for this event. Empty = legacy rule
# ("lightning" always, "fiat" when allow_fiat) — see # ("lightning" always, "fiat" when allow_fiat) — see
# `effective_payment_methods`. Same field name/shape as upstream v2 so the # `effective_payment_methods`. Same field name/shape as upstream v2 so the

View file

@ -10,6 +10,8 @@ from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid from email.utils import formataddr, formatdate, make_msgid
from html import escape from html import escape
from lnbits.core.crud import get_user
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.users import UserNotifications from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import send_user_notification from lnbits.core.services.notifications import send_user_notification
@ -138,22 +140,38 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
"or open the link above on your phone, and show the QR code at the " "or open the link above on your phone, and show the QR code at the "
"door to be scanned in." "door to be scanned in."
) )
if event.extra.organizer_name:
lines.append(f"Organizer: {event.extra.organizer_name}")
return "\n".join(lines) return "\n".join(lines)
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: def _ticket_details_with_reply_hint(
ticket: Ticket, event: Event, reply_to: str | None
) -> str:
details = _ticket_details(ticket, event)
if reply_to:
who = event.extra.organizer_name or "the organizer"
details += f"\nQuestions? Reply to this email and it reaches {who}."
return details
def _ticket_delivery_message(
ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
) -> str:
return ( return (
f"{base_message}\n\n{_ticket_details(ticket, event)}" f"{base_message}\n\n{_ticket_details_with_reply_hint(ticket, event, reply_to)}"
f"\n\nTicket image: {_ticket_image_url(ticket)}" f"\n\nTicket image: {_ticket_image_url(ticket)}"
) )
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: def _ticket_email_html_message(
ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
) -> str:
"""HTML twin of the text part. Deliberately no <img>: the card travels """HTML twin of the text part. Deliberately no <img>: the card travels
as an attachment (renders inline in most clients, works offline, and as an attachment (renders inline in most clients, works offline, and
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
links.""" links."""
text_message = _ticket_delivery_message(ticket, event, base_message) text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
html = escape(text_message) html = escape(text_message)
html = re.sub( html = re.sub(
r"(https?://[^\s<]+)", r"(https?://[^\s<]+)",
@ -163,13 +181,41 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
return f"<p>{html.replace(chr(10), '<br />')}</p>" return f"<p>{html.replace(chr(10), '<br />')}</p>"
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: def _ticket_notification_payload(
ticket: Ticket, event: Event, reply_to: str | None = None
) -> tuple[str, str, str]:
subject, base_message = _ticket_notification_message(ticket, event) subject, base_message = _ticket_notification_message(ticket, event)
text_message = _ticket_delivery_message(ticket, event, base_message) text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
html_message = _ticket_email_html_message(ticket, event, base_message) html_message = _ticket_email_html_message(ticket, event, base_message, reply_to)
return subject, text_message, html_message return subject, text_message, html_message
async def organizer_reply_to(event: Event) -> str | None:
"""Where replies to a ticket email should go: the event's explicit
`reply_to_email`, else the email on the LNbits account that owns the
event wallet, else nothing (no Reply-To header)."""
if event.extra.reply_to_email:
return str(event.extra.reply_to_email)
try:
wallet = await get_wallet(event.wallet)
if not wallet:
return None
user = await get_user(wallet.user)
email = (user.email or "").strip() if user else ""
return email if is_valid_email_address(email) else None
except Exception as exc:
logger.warning(f"Could not resolve organizer email for {event.id}: {exc}")
return None
def organizer_sender_name(event: Event) -> str:
"""From display name: "<organizer> via <site title>" when the event
names an organizer, else the site title."""
site = (settings.lnbits_site_title or "").strip() or "Tickets"
organizer = (event.extra.organizer_name or "").strip()
return f"{organizer} via {site}" if organizer else site
async def _deliver_ticket_notifications( async def _deliver_ticket_notifications(
ticket: Ticket, ticket: Ticket,
event: Event, event: Event,
@ -180,7 +226,10 @@ async def _deliver_ticket_notifications(
"""Send the ticket by every configured channel and report per-channel """Send the ticket by every configured channel and report per-channel
outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's
opt-ins when not None; the instance-level prerequisites always apply.""" opt-ins when not None; the instance-level prerequisites always apply."""
subject, text_message, html_message = _ticket_notification_payload(ticket, event) reply_to = await organizer_reply_to(event)
subject, text_message, html_message = _ticket_notification_payload(
ticket, event, reply_to
)
updated = False updated = False
email_wanted = event.extra.email_notifications if email is None else email email_wanted = event.extra.email_notifications if email is None else email
@ -220,6 +269,8 @@ async def _deliver_ticket_notifications(
attachments=[ attachments=[
(ticket_card_filename(ticket, event), image_png_bytes(card)) (ticket_card_filename(ticket, event), image_png_bytes(card))
], ],
reply_to=reply_to,
sender_name=organizer_sender_name(event),
) )
ticket.extra.email_notification_sent = True ticket.extra.email_notification_sent = True
result.email.sent = True result.email.sent = True
@ -251,6 +302,8 @@ async def _send_ticket_email_notification(
subject: str, subject: str,
html_message: str | None = None, html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None, attachments: list[tuple[str, bytes]] | None = None,
reply_to: str | None = None,
sender_name: str | None = None,
) -> None: ) -> None:
"""Multipart (text + HTML) ticket email through the instance SMTP """Multipart (text + HTML) ticket email through the instance SMTP
settings. Core's `send_email_notification` is plain-text only, which is settings. Core's `send_email_notification` is plain-text only, which is
@ -269,7 +322,14 @@ async def _send_ticket_email_notification(
raise ValueError(f"Invalid email address: {address}") raise ValueError(f"Invalid email address: {address}")
msg = build_ticket_email( msg = build_ticket_email(
from_email, to_emails, subject, message, html_message, attachments from_email,
to_emails,
subject,
message,
html_message,
attachments,
reply_to=reply_to,
sender_name=sender_name,
) )
username = settings.lnbits_email_notifications_username or from_email username = settings.lnbits_email_notifications_username or from_email
await asyncio.to_thread( await asyncio.to_thread(
@ -291,6 +351,8 @@ def build_ticket_email(
message: str, message: str,
html_message: str | None = None, html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None, attachments: list[tuple[str, bytes]] | None = None,
reply_to: str | None = None,
sender_name: str | None = None,
) -> MIMEMultipart: ) -> MIMEMultipart:
"""Assemble the ticket email: text + HTML alternatives, PNG attachments """Assemble the ticket email: text + HTML alternatives, PNG attachments
(the ticket card), and the headers receivers score on — a Date and a (the ticket card), and the headers receivers score on — a Date and a
@ -312,9 +374,13 @@ def build_ticket_email(
else: else:
msg = body msg = body
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" display_name = (
msg["From"] = formataddr((sender_name, from_email)) sender_name or settings.lnbits_site_title or ""
).strip() or "Tickets"
msg["From"] = formataddr((display_name, from_email))
msg["To"] = ", ".join(to_emails) msg["To"] = ", ".join(to_emails)
if reply_to and is_valid_email_address(reply_to):
msg["Reply-To"] = reply_to
msg["Subject"] = subject msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True) msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])

View file

@ -67,13 +67,15 @@ window.PageEvents = {
name: 'event_start_date', name: 'event_start_date',
align: 'left', align: 'left',
label: 'Start date', label: 'Start date',
field: 'event_start_date' field: 'event_start_date',
format: val => this.formatEventDate(val)
}, },
{ {
name: 'event_end_date', name: 'event_end_date',
align: 'left', align: 'left',
label: 'End date', label: 'End date',
field: 'event_end_date' field: 'event_end_date',
format: val => this.formatEventDate(val)
}, },
{ {
name: 'closing_date', name: 'closing_date',
@ -149,15 +151,63 @@ window.PageEvents = {
rowsPerPage: 10 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: { formDialog: {
show: false, show: false,
data: { data: {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
promo_codes: [], promo_codes: [],
organizer_name: '',
reply_to_email: '',
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }
@ -165,6 +215,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: { methods: {
isFiatCurrency(currency) { isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase()) return !['sat', 'sats'].includes((currency || '').toLowerCase())
@ -382,29 +453,62 @@ window.PageEvents = {
}, },
splitDateTime(value) { splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the // 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: ''} if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T') const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored. // Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)} 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() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = {...this.formDialog.data} const data = {...this.formDialog.data}
data.event_start_date = this.foldDateTime( data.event_start_date = this.withLocalTzOffset(
data.event_start_day, this.foldDateTime(data.event_start_day, data.event_start_time)
data.event_start_time
) )
data.event_end_date = this.foldDateTime( data.event_end_date = this.withLocalTzOffset(
data.event_end_day, this.foldDateTime(data.event_end_day, data.event_end_time)
data.event_end_time
) )
delete data.event_start_day delete data.event_start_day
delete data.event_start_time delete data.event_start_time
delete data.event_end_day delete data.event_end_day
delete data.event_end_time 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) { if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
@ -425,11 +529,13 @@ window.PageEvents = {
// allow_fiat stays the fiat-currency carrier the backend and the // allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list. // NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat') data.allow_fiat = methods.includes('fiat')
if (!this.isFiatCurrency(data.currency)) { if (this.isFiatCurrency(data.currency)) {
if (!data.allow_fiat) { // 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' data.fiat_currency = 'GBP'
} }
}
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -453,6 +559,8 @@ window.PageEvents = {
? data.extra.payment_methods ? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
}, },
location: data.location || '',
categories: [...(data.categories || [])],
event_start_day: start.day, event_start_day: start.day,
event_start_time: start.time, event_start_time: start.time,
event_end_day: end.day, event_end_day: end.day,
@ -463,6 +571,8 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
event_start_day: '', event_start_day: '',
event_start_time: '', event_start_time: '',
event_end_day: '', event_end_day: '',
@ -474,6 +584,8 @@ window.PageEvents = {
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
promo_codes: [], promo_codes: [],
organizer_name: '',
reply_to_email: '',
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }
@ -487,11 +599,15 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
promo_codes: [], promo_codes: [],
organizer_name: '',
reply_to_email: '',
notification_subject: '', notification_subject: '',
notification_body: '' notification_body: ''
} }

View file

@ -479,8 +479,10 @@
filled filled
dense dense
v-model.trim="formDialog.data.name" v-model.trim="formDialog.data.name"
type="name" type="text"
label="Title of event " label="Title of event *"
lazy-rules
:rules="[val => !!val || 'Title is required']"
></q-input> ></q-input>
</div> </div>
<div class="col q-pl-sm"> <div class="col q-pl-sm">
@ -504,6 +506,30 @@
label="Info about the event" label="Info about the event"
hint="Markdown supported" hint="Markdown supported"
></q-input> ></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.location"
type="text"
label="Location"
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
>
<template v-slot:prepend>
<q-icon name="place"></q-icon>
</template>
</q-input>
<q-select
filled
dense
multiple
use-chips
emit-value
map-options
v-model="formDialog.data.categories"
:options="categoryOptions"
label="Categories"
hint="Published as NIP-52 hashtags so clients can filter the feed."
></q-select>
<q-input <q-input
filled filled
dense dense
@ -512,25 +538,16 @@
label="Image URL" label="Image URL"
hint="Optional banner image to display on the event page" hint="Optional banner image to display on the event page"
></q-input> ></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 q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins</div> <div class="col-4">Event begins *</div>
<div class="col-5"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_day" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
lazy-rules
:rules="[val => !!val || 'Start date is required']"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -552,6 +569,10 @@
dense dense
v-model.trim="formDialog.data.event_end_day" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
hint="Defaults to the start date"
lazy-rules
reactive-rules
:rules="[validateEndDate]"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -564,6 +585,18 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm">
<div class="col-4">Ticket sales close</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
hint="Optional. Defaults to the event end date."
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -571,7 +604,7 @@
dense dense
v-model="formDialog.data.currency" v-model="formDialog.data.currency"
type="text" type="text"
label="Unit" label="Price currency"
:options="currencies" :options="currencies"
></q-select> ></q-select>
</div> </div>
@ -581,7 +614,9 @@
dense dense
v-model.number="formDialog.data.amount_tickets" v-model.number="formDialog.data.amount_tickets"
type="number" type="number"
min="0"
label="Amount of tickets" label="Amount of tickets"
hint="0 = unlimited"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
@ -600,34 +635,57 @@
</div> </div>
</div> </div>
<div class="q-mt-sm"> <div class="q-mt-sm">
<div class="text-caption text-grey-7">Payment methods *</div> <div class="text-subtitle2">Payment methods *</div>
<q-option-group
v-model="formDialog.data.extra.payment_methods"
type="checkbox"
inline
:options="paymentMethodOptions"
></q-option-group>
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
Card / fiat checkout goes through the fiat provider configured on Pick the rails buyers can pay with. Untick Lightning for a
this LNbits instance. Untick Lightning for a card-only event. card-only sale.
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div
v-for="opt in paymentMethodOptions"
:key="opt.value"
class="col-12 col-sm-6"
>
<!-- The span carries the tooltip: a disabled checkbox
swallows pointer events, the wrapper still hovers. -->
<span class="inline-block full-width">
<q-checkbox
v-model="formDialog.data.extra.payment_methods"
:val="opt.value"
:label="opt.label"
:disable="opt.value === 'fiat' && !hasFiatProvider"
></q-checkbox>
<div class="text-caption text-grey-7 q-pl-lg">
<span v-text="opt.hint"></span>
<span
v-if="opt.value === 'fiat' && hasFiatProvider"
v-text="' (' + fiatProviderNames + ')'"
></span>
</div>
<q-tooltip
v-if="opt.value === 'fiat' && !hasFiatProvider"
max-width="280px"
>
Your LNbits user has no fiat provider configured. Ask the
instance admin to enable Stripe, PayPal or Square (Admin →
Fiat providers) to accept card payments.
</q-tooltip>
</span>
</div>
</div> </div>
</div> </div>
<q-select <q-select
v-if=" v-if="acceptsFiat && isSatPrice"
acceptsFiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled filled
dense dense
v-model="formDialog.data.fiat_currency" v-model="formDialog.data.fiat_currency"
label="Fiat checkout currency" label="Fiat currency"
:options=" hint="Currency card buyers are charged in."
currencies.filter( :options="fiatCurrencyOptions"
c => !['sat', 'sats'].includes((c || '').toLowerCase()) lazy-rules
) :rules="[
" val => !!val || 'Pick a fiat currency for buyers paying by card'
]"
></q-select> ></q-select>
<q-expansion-item <q-expansion-item
group="advanced" group="advanced"
@ -765,6 +823,28 @@
label="Ticket notification body" label="Ticket notification body"
hint="Shown before the ticket link in the paid ticket notification." hint="Shown before the ticket link in the paid ticket notification."
></q-input> ></q-input>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
filled
dense
v-model.trim="formDialog.data.extra.organizer_name"
type="text"
label="Organizer name (email sender)"
hint="Shown as 'Name via <site>' in the From line of ticket emails."
></q-input>
</div>
<div class="col-12 col-md-6">
<q-input
filled
dense
v-model.trim="formDialog.data.extra.reply_to_email"
type="email"
label="Reply-to email"
hint="Where attendee replies go. Blank = your account email."
></q-input>
</div>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@ -779,14 +859,9 @@
unelevated unelevated
color="primary" color="primary"
:disable=" :disable="
formDialog.data.wallet == null || !formDialog.data.wallet ||
formDialog.data.name == null || !formDialog.data.name ||
formDialog.data.info == null || !formDialog.data.event_start_day
formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null ||
formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
" "
type="submit" type="submit"
>Create Event</q-btn >Create Event</q-btn

View file

@ -87,3 +87,59 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
assert "/events/api/v1/ticket-card/tkt1" in text assert "/events/api/v1/ticket-card/tkt1" in text
assert "<img" not in html and "Ticket ID: tkt1" in html assert "<img" not in html and "Ticket ID: tkt1" in html
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
def test_reply_to_and_organizer_sender_name(monkeypatch):
from ..services import organizer_sender_name
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
event = _event(
extra={
"organizer_name": "Château du Faune",
"reply_to_email": "hello@faune.example",
}
)
assert organizer_sender_name(event) == "Château du Faune via Oyez!"
assert organizer_sender_name(_event()) == "Oyez!"
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
None,
reply_to="hello@faune.example",
sender_name=organizer_sender_name(event),
)
assert msg["Reply-To"] == "hello@faune.example"
# Non-ASCII display names are RFC 2047-encoded on the wire; decode to compare.
from email.header import decode_header, make_header
assert str(make_header(decode_header(msg["From"]))) == (
"Château du Faune via Oyez! <tickets@example.org>"
)
no_reply = build_ticket_email(
"tickets@example.org", ["ada@example.com"], "Subj", "text"
)
assert no_reply["Reply-To"] is None
def test_reply_hint_only_when_reply_to_exists(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
event = _event(extra={"organizer_name": "Château du Faune"})
_, text, _ = _ticket_notification_payload(
_ticket(), event, reply_to="hello@faune.example"
)
assert "Organizer: Château du Faune" in text
assert "Reply to this email and it reaches Château du Faune" in text
_, text, _ = _ticket_notification_payload(_ticket(), event, reply_to=None)
assert "Reply to this email" not in text
def test_blank_reply_to_email_is_none():
from ..models import EventExtra
assert EventExtra(reply_to_email="").reply_to_email is None
assert EventExtra(reply_to_email=" ").reply_to_email is None
assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example"