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']" >
@@ -504,6 +506,30 @@ label="Info about the event" hint="Markdown supported" > + + + + -
-
Ticket closing date
-
- -
-
-
Event begins
+
Event begins *
@@ -552,6 +569,10 @@ dense v-model.trim="formDialog.data.event_end_day" type="date" + hint="Defaults to the start date" + lazy-rules + reactive-rules + :rules="[validateEndDate]" >
@@ -564,6 +585,18 @@ >
+
+
Ticket sales close
+
+ +
+
@@ -581,7 +614,9 @@ dense v-model.number="formDialog.data.amount_tickets" type="number" - label="Amount of tickets " + min="0" + label="Amount of tickets" + hint="0 = unlimited" >
@@ -600,34 +635,57 @@
-
Payment methods *
- +
Payment methods *
- Card / fiat checkout goes through the fiat provider configured on - this LNbits instance. Untick Lightning for a card-only event. + Pick the rails buyers can pay with. Untick Lightning for a + card-only sale. +
+
+
+ + + +
+ + +
+ + 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. + +
+