Modules

Events

Database-backed events with ticket types, addons, capacity, sale windows, membership requirements, Stripe payments and QR check-in.

Events and tickets, built on Payments. The module handles only events and tickets; money is delegated to Payments.

  • Prices live in the database (minor units) and are charged through Checkout price_data; there are no Stripe ids to maintain per environment.
  • Ticket state follows payment state. A paid ticket becomes paid only through the payments:paid hook, fed by verified Stripe events. Free tickets (total 0) are issued immediately without Stripe.
  • Purchased tickets are snapshots. Amount, currency and chosen addons are copied onto the ticket, which has no foreign keys to the catalogue, so editing or deleting catalogue rows never changes what someone bought.
  • Staff access is the host's decision through the events:authorize hook (denied by default).
Requires@nuxthub/core (KV on), @frigear-nu/module-kit, @frigear-nu/auth, @frigear-nu/mcp, @frigear-nu/users, @frigear-nu/ui, @frigear-nu/stripe and @frigear-nu/payments. Optional@frigear-nu/subscriptions (membership provider), @frigear-nu/menu, @frigear-nu/widget (event page tabs) and @frigear-nu/admin (the admin pages exist only with it), @frigear-nu/account (the left navigation and heading around the tickets page).

Quick setup

export default defineNuxtConfig({
  events: {
    settings: {
      pages: { events: true, event: true, tickets: true },
      reservationMinutes: 30,
    },
  },
})

Pages are opt-in and use @nuxt/ui. With @nuxtjs/i18n installed, translated texts follow the active locale; otherwise en is used.

Options

OptionTypeDefaultPurpose
settings.reservationMinutesnumber30How long an unpaid ticket keeps its seat; also the Stripe session lifetime.
settings.mcpbooleanfalseExpose the module's tools on the MCP endpoint (see MCP tools).
settings.pages.eventsstring | booleanoffEvent list. true = /events.
settings.pages.eventstring | booleanoffEvent detail; path must contain :slug. true = /events/:slug.
settings.pages.ticketsstring | booleanoffThe signed-in user's tickets. true = /account/tickets.
settings.pages.adminstring | booleanoff (needs @frigear-nu/admin)Staff event list. true = the admin route for events, /admin/events by default.
settings.pages.adminEventstring | booleanoff (needs @frigear-nu/admin)Staff event page; path must contain :id. true = /admin/events/:id.

Pages and their patterns

The pages follow the UX patterns of the ecosystem, one per area:

  • Admin (needs @frigear-nu/admin, listed in admin.nav): the list is a PageHeading ("New event"), a TableCard with a status badge and a RowMenu per row (Open, View public page, Duplicate | Delete), and a UEmpty when there are no events. "New event" opens EventFormSlideover. The event page has a breadcrumb back to the list, the status as a badge, "View public page" and a RowMenu in the heading, a key-facts SectionCard and the admin.events tabs (Details, Tickets, Attendees). Deleting asks for confirmation; events with paid tickets cannot be deleted, archive them instead.
  • Account: /account/tickets renders only its content, a grid of EventTicketCards or a "No tickets yet" empty state with "Browse events". The heading and left navigation come from the account layout of @frigear-nu/account. The menu item is registered in the account menu (order 40) with @frigear-nu/menu.
  • Public: /events and /events/:slug are UContainer > UPage > UPageHeader pages (the detail page has the breadcrumb in the header's headline) with the ticket picker beside the description.

Every link to an event goes through useEventLinks(), so a visitor on /en/events stays under /en when opening an event.

Server

Purchase rules

POST /api/events/:slug/purchase checks, in this order:

  1. The event exists and is not over (404 / 409).
  2. The ticket type exists and its sale window is open (409). A saleSchedule, if any, counts as part of the window.
  3. Membership at event and ticket level, then the ticket's exclusion rule (403). Without a membership provider a requirement fails closed and an exclusion excludes nobody.
  4. Addon rules: every addon belongs to the ticket type, required groups have a choice, single-choice groups have at most one (400).
  5. Addon capacity (409).
  6. Ticket capacity (409).
  7. The events:purchase:before hook.
  8. maxPerUser (409).

The seat is then taken. Capacity counts paid tickets plus pending ones inside the reservation window. D1 has no interactive transactions, so after inserting the ticket the module verifies it is among the first capacity seat holders (insertion order) and removes it if it lost a race. An expired or failed payment frees its seat.

type PurchaseBody = {
  ticketTypeId: string
  addonIds?: string[]
  successUrl?: string
  cancelUrl?: string
  locale?: string      // buyer's site language (e.g. 'da'), stored on the ticket for later mails and Stripe texts
}

type PurchaseResponse = {
  ticket: TicketDto
  url?: string         // Stripe redirect; absent for free tickets, which are already `paid`
}

Check-in is idempotent: a second scan returns alreadyCheckedIn with the first scan time, and only paid tickets validate. A refund starts at Stripe; the ticket turns refunded when charge.refunded arrives, and partial refunds keep the ticket valid.

Seasonal ticket sales

A ticket type can carry a saleSchedule on top of saleStartsAt / saleEndsAt; both must be open for a ticket to be sold. It uses the shared schedule of @frigear-nu/module-kit/schedule:

type Schedule =
  | { type: 'yearly', timezone: string, from: { month: number, day: number, time: string }, to: { month: number, day: number, time: string } }
  | { type: 'range', from: string, to: string }
// null = always. A yearly `to` before `from` wraps over new year.

Outside the schedule the ticket type is not_started (with saleStartsAt set to the next opening) and purchases are rejected with 409.

Membership providers

This module does not know what a membership is. A provider answers two Nitro hooks (see Hooks): events:membership:check decides whether a user holds a requirement, and events:membership:options turns on the membership pickers in the admin UI and lists the choices. With no provider the API rejects membership rules, because a requirement nobody can meet would silently lock the ticket.

Subscriptions is the provider out of the box: when installed, plans are the memberships and holding one means an active subscription to it. Subscriptions itself knows nothing about events. Another source (a partner list, an imported roster) can be added with its own Nitro plugin:

export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('events:membership:options', (ctx) => {
    ctx.available = true
    ctx.options.push({ value: 'volunteer', label: 'Volunteer' })
  })
  nitro.hooks.hook('events:membership:check', async (ctx) => {
    if (await isVolunteer(ctx.userId)) ctx.satisfied = true
  })
})

Staff authorization

Staff routes need the events:manage scope and an allowing events:authorize hook. events:manage and events:pii are only offered to users that hook lets in.

export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('events:authorize', async (ctx) => {
    ctx.allowed = await isEventStaff(ctx.userId)
  })
})

MCP tools

With settings.mcp: true, the tools below are exposed on the MCP endpoint. Staff tools need events:manage and the events:authorize policy; attendee names and emails also need events:pii. Prices and amounts in MCP inputs and outputs are whole currency units (52.2 = 52.20, 500 JPY), unlike the REST API and database, which use minor units.

ToolScopePurpose
list-events, get-eventtickets:readPublic events and details.
my-ticketstickets:readThe caller's tickets.
list-all-events, get-event-definition, create-event, update-event, update-ticket-type, list-attendeesevents:manageStaff catalogue management and attendees.

App

ExportDescription
useEvents(){ events, grouped, status, refresh }; grouped splits into ongoing, upcoming, past.
useEvent(slug){ event, status, error, refresh, purchase(ticketTypeId, addonIds?) }.
useMyTickets(){ tickets, status, refresh, pay(ticketId) }.
useEventLinks()Locale-aware links: events(), event(slug), tickets(), admin(), adminEvent(id). Follows settings.pages.
useEventActions()Staff actions: create() (slideover, resolves with the new event), duplicate(event), remove(event) (confirms), menu(event, { extra?, onRemoved? }) for the row menu.
useEventsLocale()Current locale: @nuxtjs/i18n's, else en.
useEventsFallbackLocale()The site's default locale, else en.
useEventsTranslate()tr(value): a Translated text in the current locale, falling back to the default, then en.

Components: EventCard, EventTicketPicker, EventTicketCard (with QR code), EventFormSlideover (create an event) and the EventAdmin* dialogs used by the admin pages. @frigear-nu/events/utils exports the DTO and hook types, translate, normalizeMembership, DEFAULT_CURRENCY and id helpers.

Extending the admin event page

The page shows status, dates and location, and below them a tab view over the admin.events tab area of Widget, which this module owns. It registers Details, Tickets and Attendees tabs; any module can add more with extendTabs(nuxt, 'admin.events', { ... }). Each tab component receives event (the response of GET /api/events/admin/events/:id) as a prop and may emit refresh. Without Widget the three built-in tabs render stacked. Hiding a tab is not access control: authorize its API calls through events:authorize.

Hooks

Register with nitro.hooks.hook(...) in a Nitro plugin.

HookWhenPayload
events:authorizeStaff gate for management routes, check-in and staff scopes.AuthorizeContext
events:purchase:beforeDuring purchase, after the capacity checks; throw to deny.PurchaseBeforeContext
events:ticket:paidA ticket became paid (free tickets immediately). Send confirmations here.TicketHookContext
events:ticket:refundedA ticket was fully refunded.TicketHookContext
events:membership:checkA membership requirement is evaluated.MembershipCheckContext
events:membership:optionsThe admin UI asks which memberships can be picked.MembershipOptionsContext
type AuthorizeContext = { event: H3Event, userId: number, allowed: boolean }          // set allowed = true
type PurchaseBeforeContext = { event: H3Event, userId: number, eventId: string, ticketTypeId: string, addonIds: string[] }
type TicketHookContext = { ticket: TicketDto }
type MembershipCheckContext = { userId: number, requirement: 'any' | string[], satisfied: boolean }
type MembershipOptionsContext = { available: boolean, options: Array<{ value: string, label: string }> }

requiresMembership and excludesMembership are null (nobody / everyone), 'any' (any active membership) or a list of membership keys.

Routes

All paths are under /api/events. User routes also accept bearer and OAuth tokens that carry the scope.

MethodPathAuthPurpose
GET/publicPublished events.
GET/:slugpublic (optional tickets:read)Event detail; includes per-user blocked flags when signed in.
POST/:slug/purchasetickets:writeBuy a ticket.
GET/tickets/metickets:readThe caller's tickets.
GET/tickets/:idtickets:readOne ticket (poll after returning from Stripe).
POST/tickets/:id/paytickets:writeResume payment of a pending ticket.
POST/check-instaffBody { code }. Check a ticket in.
GET/admin/membership-optionsstaffSelectable memberships from the provider.
GET, POST/admin/eventsstaffList events; create one (accepts the whole nested catalogue).
GET, PATCH, DELETE/admin/events/:idstaffOne event with all ticket types whatever its status; update; delete.
POST/admin/events/:id/clonestaffCopy into a new draft (<slug>-copy, -copy-2, ...). Tickets are not copied; dates stay.
GET/admin/events/:id/ticketsstaffAttendees.
POST/admin/events/:id/ticket-typesstaffAdd a ticket type.
PATCH, DELETE/admin/ticket-types/:idstaffUpdate or delete a ticket type.
POST/admin/ticket-types/:id/addon-groupsstaffAdd an addon group.
PATCH, DELETE/admin/addon-groups/:idstaffUpdate or delete an addon group.
POST/admin/addon-groups/:id/addonsstaffAdd an addon.
PATCH, DELETE/admin/addons/:idstaffUpdate or delete an addon.
POST/admin/tickets/:id/cancelstaffCancel a ticket.
POST/admin/tickets/:id/refundstaffStart a full refund (only paid tickets with a payment).

"staff" means the events:manage scope plus an allowing events:authorize hook.

Database

TablePurpose
eventsslug, translated name / description / body, image, location, locationUrl, startsAt / endsAt, status (draft, published, archived), event-level requiresMembership.
event_ticket_typesPer event: key (unique per event), name, description, price (0 = free), currency (default DKK), hidePrice, capacity, saleStartsAt / saleEndsAt, saleSchedule, maxPerUser (default 1), requiresMembership, excludesMembership, isDefault, sortOrder.
event_addon_groupsPer ticket type, e.g. "Food": required (pick at least one), multiple (pick several), sortOrder.
event_addonsChoices in a group: label, price, optional capacity, sortOrder.
event_ticketsPurchased or reserved tickets: status (pending, paid, cancelled, refunded, expired), addons, amount and currency snapshot, paymentId, unique QR code, locale, checkedInAt, checkedInBy.

Translated texts are a string or a { da, en } map (Translated).

The module changes the generated schema, not the database: generate and commit a migration in every host app and fixture that uses it (including the Payments, Users and Subscriptions tables it relies on). See Module Kit.

Translations

da and en locale files are registered with Module Kit under events.*. Key groups: upcoming.*, ongoing.*, past.*, detail.* (including the purchase flow), tickets.* (including tickets.status.*), cart.*, menu.*, admin.* (including admin.tabs.* and admin.statuses.*), plus top-level messages such as title, empty, loading, ticketReady, paymentReceived and purchaseFailed. Override any string in your app's i18n/locales/<code>.json (see overriding module strings):

{ "events": { "upcoming": { "title": "What's on" } } }
Copyright © 2026