Skip to content

10 — Events Addon

Status: 🎨 Vision — Nog niet gebouwd. Afhankelijk van het addon-systeem (05). Tier: Game Pack (functional addon, Laag 2). Aanmelding-model: Extern. Beam genereert geen aanmeldingen of tickets; events linken vanaf de single-page door naar een externe registratie-URL. Vertical packs die Events gebruiken: Yoga Studio, Restaurant, Galerie, en de nog te bouwen Community Hub.

De Events addon brengt evenementen-functionaliteit naar Beam: meetups, workshops, festivals, openingen en webinars. Eénmalig, met of zonder aanmelding via een externe dienst (Eventbrite, Google Forms, Mailchimp, eigen ticketing). Centraal doel: een event publiceren op de site én op Google Events binnen 1 minuut — Schema.org JSON-LD en iCal-feeds zijn first-class burgers.

Functionaliteit die buiten deze scope valt (eigen RSVP, capaciteit, ticketing, recurring, categorie/tag-archieven, maand-archief) staat in §14 Backlog.


  1. Filosofie & Scope
  2. Events vs Bookings — De Scheidslijn
  3. Aanmelding via Externe URL
  4. Addon Manifest
  5. Content Schema
  6. Blocks (Beam Codex)
  7. Templates & Routes
  8. Backend — Crons, Hooks
  9. SEO & Discoverability
  10. Synergieën met Andere Addons
  11. Architectuurintegratie met Beam Core
  12. Vertical Packs die Events Gebruiken
  13. Database Schema
  14. Backlog
  15. MVP & Implementatie Roadmap
  16. Open Vragen

Een event in Beam is een gebeurtenis met een vaste datum/tijd, een locatie (fysiek of online), en optioneel een externe aanmeldingslink. De host bepaalt wanneer en waar — de bezoeker volgt de aanmeldingslink naar de externe dienst (Eventbrite, Google Forms, eigen platform).

DoelHoe
Snel publicerenMinimaal verplicht: titel, start_at, locatie. Aanmeldings-URL is optioneel.
Google Events visibilitySchema.org Event JSON-LD volledig conform spec, inclusief eventStatus en eventAttendanceMode.
Kalender-integratieiCal feed per site (RFC 5545).
Beamse uitstralingEvents erven brand tokens, geen eigen styling-eilanden.
Geen vendor lock-inEvents exporteerbaar als JSON + iCal.
Geen attendee-data in BeamAanmeldingen leven extern; Beam slaat geen PII van bezoekers op.
  • Een Yoga-workshop op zaterdag 10:00, registratie via Eventbrite
  • Een vernissage op vrijdag 19:00, open inloop, geen aanmelding
  • Een online webinar met aanmeldingslink naar Zoom Events
  • Een meetup met aanmelding via Meetup.com
  • Een klant boekt een tijdslot voor een 1-op-1 sessie → Bookings
  • Een blog post over een afgelopen event → Blog (eventueel met Events × Blog synergie)
  • Een terugkerend openingstijden-rooster → site-instelling
  • Eigen aanmelding / ticketing / capaciteit / wachtlijst → §14 Backlog
  • Recurring events met RRULE → §14 Backlog
  • Categorie- en tag-archieven → §14 Backlog
  • Maand-archieven → niet gepland

Beide addons werken met tijd, maar de richting van keuze is omgekeerd.

AspectEventsBookings
Wie kiest de tijd?Host bepaalt voorafKlant kiest uit beschikbare slots
Aantal momenten1 per eventContinu rolling beschikbaarheid
AanmeldingExterne URL (out-of-scope voor Beam)In Beam (klant kiest slot)
OutputSchema.org EventSchema.org Reservation
DistributieiCal feed, Google EventsBeschikbaarheid via API
Voorbeeld”Yoga workshop zaterdag 10:00""Boek een 1-op-1 yoga sessie”

Overlap: Een betaalde groepsworkshop met inschrijving past in beide, maar Beam-keuze: Events. Reden: de host bepaalt datum/tijd, de bezoeker volgt de externe aanmeldlink — niet kiest uit slots.

Wanneer beide: Een yoga-studio kan Events gebruiken voor groepslessen (“Yin Yoga zaterdag 10:00, aanmelden via Momoyoga”) én Bookings voor 1-op-1 sessies (“Boek een privéles, kies een tijd”). De synergie Events × Bookings (zie §10) maakt cross-promotie mogelijk.

Architectuurbeslissing: Events en Bookings zijn aparte addons. Ze delen geen tabel, geen URL-prefix en geen capabilities. Wel synergieën. Dit voorkomt dat Bookings te breed wordt en houdt Schema.org/iCal-complexiteit bij Events.


Elk event heeft een optioneel registration_url veld. Geen wachtlijst, geen capaciteit, geen ticketing in Beam zelf — dat is bewust uit scope gehouden.

registration_url is gevuld:
→ Primaire CTA "Aanmelden" verschijnt op single page + event-card
→ Link opent in nieuw tabblad (target="_blank")
→ rel="noopener noreferrer nofollow ugc"
→ Optioneel iconlabel: externe-link pictogram
registration_url is leeg:
→ Geen CTA-knop
→ Event toont alleen info (datum, locatie, beschrijving)
→ Use case: open inloop, vernissage, gratis meetup zonder aanmelding
  • registration_url gevalideerd via sanitizeUrl() bij save (max 2048 chars)
  • isPrivateUrl() check blokkeert 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x, localhost
  • HTTP automatisch geweigerd, alleen HTTPS toegestaan
  • Geen pre-fetch of preview (privacy + voorkomt SSRF leak)
ConceptWaar
Capaciteit / wachtlijstOp de externe registratiesite
Ticket-types & prijzenOp de externe registratiesite
Bevestigings- en herinnerings-emailsOp de externe registratiesite
Custom velden bij aanmeldingOp de externe registratiesite
Attendee-lijst & check-inOp de externe registratiesite

Status sold_out blijft als handmatige toggle bestaan — als de externe site uitverkocht is, kan de host dat in Beam markeren, waardoor de “Aanmelden” CTA verdwijnt en Schema.org offers.availability: SoldOut wordt gerenderd.


Conform §4 van het expansion system:

const eventsManifest: AddonManifest = {
id: 'events',
name: 'Events',
description: 'Evenementen op je site — meetups, workshops, festivals. Aanmelding via externe URL.',
icon: 'calendar-days',
version: '1.0.0',
tier: 'game-pack',
// Content & Rendering
blocks: [
eventsListBlock,
eventCardBlock,
eventCalendarBlock,
eventCountdownBlock,
eventScheduleBlock,
],
collections: [eventsCollection],
templates: [eventArchiveTemplate, eventSingleTemplate],
routes: [
{ pattern: '/events', template: 'events:archive', data: 'events' },
{ pattern: '/events/:slug', template: 'events:single', data: 'events:single' },
],
// Dashboard
settings: eventsSettingsSchema,
dashboardWidgets: [upcomingEventsWidget],
menuItemTypes: [
{ id: 'event', label: 'Event', icon: 'calendar' },
{ id: 'events-archive', label: 'Events archief' },
],
// Backend
crons: [
{ id: 'expire-completed', schedule: '0 * * * *', handler: 'expireCompletedEvents' },
{ id: 'archive-cleanup', schedule: '0 9 * * *', handler: 'archiveOldEvents' },
],
publicApiRoutes: [
{ method: 'GET', pattern: '/api/events/:id/calendar.ics', handler: 'singleEventIcs' },
],
hooks: [
{ event: 'event:published', handler: 'broadcastNewEvent' },
{ event: 'event:cancelled', handler: 'broadcastCancellation' },
{ event: 'event:rescheduled', handler: 'broadcastReschedule' },
{ event: 'event:completed', handler: 'triggerReviewBlock' },
],
emailTemplates: [], // Geen attendee-emails in v1
// SEO
seoAssets: [
{ type: 'sitemap', route: '/sitemap-events.xml', collection: 'events' },
{ type: 'rss', route: '/events/feed.xml', collection: 'events' },
{ type: 'json-ld', route: ':event-single', collection: 'events' },
// iCal volgt hetzelfde patroon:
{ type: 'ical' as const, route: '/events/feed.ics', collection: 'events' },
],
// Permissions
permissions: [
{ id: 'events:author', label: 'Event auteur', capabilities: ['events:create', 'events:update'] },
],
// WoW-systemen
specializations: [], // v1: geen specialisaties — zie §14 Backlog
provides: [
{ type: 'time-bound-content', description: 'Content met datum/tijd, voor synergie met Blog/Popups' },
{ type: 'calendar-feed', description: 'iCal feeds voor externe kalenders' },
],
enhancedBy: [
{ addonId: 'reviews', capability: 'open-review-form', unlocks: 'Open review-block op event page' },
{ addonId: 'blog', capability: 'content-feed', unlocks: '"Aankomende events" sidebar block' },
{ addonId: 'bookings', capability: 'time-slots', unlocks: '"Boek intake na workshop" CTA' },
{ addonId: 'popups', capability: 'scarcity-popup', unlocks: '"Mis dit event niet" exit-intent popup' },
{ addonId: 'portfolio', capability: 'showcase', unlocks: 'Event recap als case study' },
],
}

interface EventItem {
// Beam core (gedeeld met andere addon items)
id: string
site_id: string
title: string
slug: string
description_html: string // Sanitized via @beam/shared/sanitize
featured_image?: MediaRef
status: 'draft' | 'published' | 'cancelled' | 'rescheduled' | 'sold_out' | 'completed'
blocks: Block[] // Full Beam editor body voor de single page
categories: TaxonomyRef[] // Wel opslaan, geen filter-routes in v1 (zie §14)
tags: TaxonomyRef[] // idem
created_at: string
updated_at: string
published_at?: string
// Events-specifieke metadata
start_at: string // ISO 8601 — VERPLICHT
end_at?: string // ISO 8601
all_day: boolean // Toggle: 'hele dag' (verbergt tijd, niet datum)
timezone: string // IANA tz, default 'Europe/Amsterdam'
location: {
type: 'physical' | 'online' | 'hybrid'
venue_name?: string // 'Yoga Studio Zen'
address?: string // 'Prinsengracht 123, 1015 ND Amsterdam'
coords?: { lat: number, lng: number }
online_url?: string // sanitizeUrl() bij save
online_instructions?: string // 'Link verschijnt 1 uur voor start'
}
// Externe aanmelding
registration_url?: string // sanitizeUrl(), HTTPS-only, max 2048 chars
registration_label?: string // Default 'Aanmelden', mag bv. 'Tickets', 'Inschrijven'
// Prijs (puur weergave — Beam verwerkt geen betalingen; mig 093)
price?: string | null // Canonieke decimale string '1234.56' of null. UI maskeert naar NL '1.234,56'
price_donation?: boolean // true ⇒ 'Op donatiebasis' i.p.v. bedrag; price is dan null
price_from?: boolean // true ⇒ 'Vanaf €X' tonen (alleen bij numerieke price)
// Organisator (voor Schema.org)
organizer?: {
name: string
url?: string // sanitizeUrl()
email?: string // Voor "Vragen?" mailto, niet voor RSVP
}
// Speaker / performer (optioneel, voor Schema.org)
performer?: {
name: string
url?: string
}
// Cancellation
cancellation_reason?: string // Schema.org vereist dit bij status='cancelled'
rescheduled_from?: string // Oorspronkelijke start_at, voor Schema.org previousStartDate
}
RegelReden
start_at verplichtZonder datum geen event
end_at >= start_at als gegevenLogica
location.type === 'physical'venue_name OR address verplichtSchema.org Place vereist een naam of adres
location.type === 'online'online_url verplichtBezoeker moet ergens heen kunnen
location.type === 'hybrid' ⇒ beide velden verplichtSchema.org MixedEventAttendanceMode
registration_url HTTPS-only + sanitizeUrl() + isPrivateUrl() blokSecurity
price alleen cijfers, max 2 decimalen (NL-masking via maskPriceInputNL)Consistente weergave editor === public
price_donation === trueprice wordt null, price_from wordt falseDonatiebasis vervangt het bedrag
price_from alleen actief bij een numerieke price”Vanaf” zonder bedrag is betekenisloos
status === 'cancelled'cancellation_reason verplichtUX + Schema.org best practice
timezone is valide IANA tzAnders Schema.org accepteert het niet

Vijf blocks bij activatie. Conform §8 Codex:

BlockRarityType-affiniteitFunctie
events-list★ Rare🌿 Growth / ⚡ ConnectUpcoming events grid/lijst
event-card★ Rare⚡ ConnectSingle featured event als hero
event-calendar★ Rare💧 FlowMaand/week-view
event-countdown★ Rare🔥 Impact”Nog 3 dagen, 12 uur” — launches
event-schedule★ Rare💧 FlowMulti-day programma
{
source: 'upcoming' | 'past' | 'manual',
manualIds?: string[],
limit: number, // Default 6
layout: 'grid' | 'list' | 'cards',
columns: 2 | 3 | 4, // Bij grid/cards
showImage: boolean,
showDate: boolean,
showLocation: boolean,
showRegistrationButton: boolean, // 'Aanmelden' knop op de card
dateFormat: 'short' | 'long' | 'relative', // '23 mrt' / '23 maart 2026' / 'over 5 dagen'
emptyStateText: string, // 'Geen aankomende events'
// Standaard Beam props:
spacing, background, corners, textColorMode
}

Noot: Filteren op categorie/tag uit de block is uitgesteld naar §14 Backlog. Bron blijft upcoming, past, of manual (handmatig gekozen events) in v1.

Featured single event. Drie layout-modes (hero, side-by-side, compact). Toont start_at, locatie, korte beschrijving en — indien gevuld — een externe “Aanmelden” CTA.

{
view: 'month' | 'week' | 'agenda',
showWeekends: boolean,
firstDayOfWeek: 1 | 7, // Maandag of zondag
highlightToday: boolean,
clickBehavior: 'tooltip' | 'navigate', // Tooltip met details OF naar single
// Standaard Beam props
}

Implementatienotitie: De calendar block is client-interactief (klik → tooltip/navigate). Voor Astro public site: hydraten als island (client:visible). Minimal JS, geen frameworks — vanilla Web Components of een lichte React island.

{
eventId: string | 'next', // Specifiek event of automatisch volgende
units: ('days' | 'hours' | 'minutes' | 'seconds')[],
expiredBehavior: 'hide' | 'show-message' | 'show-next',
expiredMessage: string,
// Standaard Beam props
}

Multi-day programma met dag-tabs of vertical timeline. Voor festivals en open-deur-dagen.

{
eventId: string, // Het multi-day event
view: 'tabs' | 'timeline',
groupBy: 'day' | 'track',
showSpeakers: boolean, // Vereist Portfolio synergy voor profielen
}

Onontgrendeld tot de juiste combinatie actief is:

CombinatieSecret Block (✧)
Events × Blogevents-blog-sidebar — “Aankomende events” in blog post sidebar
Events × Reviewsevent-reviews-wall — Open review-block op event page
Events × Bookingsevent-intake-cta — “Plan een intake voor deze workshop”
Events × Popupsevent-scarcity-popup — “Mis dit event niet” exit-intent
Events × Portfolioevent-recap-grid — Afgeronde events als case studies

/events → Archive: alle upcoming events
/events/[slug] → Single event detail page
/events/feed.xml → RSS feed
/events/feed.ics → iCal feed
/sitemap-events.xml → XML sitemap met <lastmod>

Niet in v1: maandarchief, categorie- en tag-filters, categorie-specifieke iCal feeds. Zie §14 Backlog.

Conform §5.3 van het expansion system:

Bezoeker → /events
Bestaat er een custom archief page in pages-tabel met slug 'events'?
→ JA: render custom page (block editor layout), inject events data
→ NEE: render eventArchiveTemplate.defaultLayout
Dynamic blocks in template resolven:
{{events-list}} → grid van komende events
{{events-pagination}} → paginering
{{events-ical-link}} → "Abonneer op kalender" knop
const eventArchiveTemplate = {
id: 'events:archive',
type: 'archive',
defaultLayout: {
blocks: [
heroBlock({ title: 'Events', subtitle: 'Bekijk onze aankomende evenementen' }),
eventsListBlock({ source: 'upcoming', layout: 'cards', columns: 3, limit: 12 }),
eventsPaginationBlock(),
ctaBlock({ title: 'Op de hoogte blijven?', subtitle: 'Abonneer op onze iCal-kalender', button: 'Kalender toevoegen' }),
],
},
}
const eventSingleTemplate = {
id: 'events:single',
type: 'single',
defaultLayout: {
blocks: [
eventHeroBlock(), // Image + title + datum + locatie + externe 'Aanmelden' CTA
contentBlock({ html: '{{event-description}}' }),
eventLocationBlock(), // Map + adres + instructies (online_instructions als online)
eventDetailsBlock(), // Organisator, performer, links
relatedEventsBlock({ limit: 3 }),
eventsCalendarFeedBlock(), // "Toevoegen aan agenda" knoppen
],
},
}

Minimaal omdat alle aanmelding-flows extern zijn. Geen public API voor RSVPs, attendees of check-in.

EndpointAuthDoel
GET /api/events/:id/calendar.icsPubliciCal-file voor single event (“Toevoegen aan agenda”)
GET /events/feed.icsPublicSite-brede iCal feed
GET /events/feed.xmlPublicRSS feed
CronScheduleFunctie
expireCompletedEvents0 * * * * (hourly)Events met end_at < now()status='completed', trigger hook
archiveOldEvents0 9 * * * (09:00 daily)Events > 12 maanden oud → notify host (geen auto-delete)
EventTriggerStandaard handler
event:createdNieuw event opgeslagen (draft)
event:publishedStatus → publishedKV cache invalidate, BroadcastChannel, Realtime update
event:cancelledStatus → cancelledKV cache invalidate, Schema.org status update
event:rescheduledstart_at verandertKV invalidate, update calendar feeds met SEQUENCE-increment
event:completedCron promotes naar completedTrigger Reviews synergy block (geen email)
event:sold_outHost markeert handmatigUpdate status, verberg externe CTA, render availability:SoldOut

Volledig conform spec — dit is de hoofdreden waarom Events een eigen addon is (Bookings genereert Reservation, niet Event).

<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Yoga Workshop: Yin & Restorative",
"startDate": "2026-03-23T10:00:00+01:00",
"endDate": "2026-03-23T12:00:00+01:00",
"eventStatus": "https://schema.org/EventScheduled",
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
"location": {
"@type": "Place",
"name": "Yoga Studio Zen",
"address": {
"@type": "PostalAddress",
"streetAddress": "Prinsengracht 123",
"postalCode": "1015 ND",
"addressLocality": "Amsterdam",
"addressCountry": "NL"
}
},
"image": ["https://media.builtwithbeam.com/cdn-cgi/image/w=1200,q=80,fit=cover/.../hero.jpg"],
"description": "Een rustige tweeurige workshop voor diep ontspannen.",
"organizer": {
"@type": "Organization",
"name": "Yoga Studio Zen",
"url": "https://yogastudio.nl"
},
"offers": {
"@type": "Offer",
"url": "https://eventbrite.com/e/yoga-workshop-yin-restorative-...",
"availability": "https://schema.org/InStock"
},
"performer": {
"@type": "Person",
"name": "Anna Janssen"
}
}
</script>

Belangrijk: offers.url verwijst naar de externe registratie-URL — niet naar een Beam-route. Google Events accepteert dit als geldige offer-link.

Beam statusSchema.org eventStatus
publishedEventScheduled
rescheduledEventRescheduled (vereist previousStartDate)
cancelledEventCancelled (vereist description met reden)
sold_outEventScheduled + offers.availability: SoldOut
completedEventScheduled met endDate in verleden
Beam location.typeSchema.org eventAttendanceMode
physicalOfflineEventAttendanceMode
onlineOnlineEventAttendanceMode (+ location: VirtualLocation)
hybridMixedEventAttendanceMode (+ beide locaties)
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Beam//Events//NL
METHOD:PUBLISH
NAME:Yoga Studio Zen — Events
X-WR-CALNAME:Yoga Studio Zen — Events
X-WR-TIMEZONE:Europe/Amsterdam
REFRESH-INTERVAL;VALUE=DURATION:PT1H
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:event-abc123@yogastudio.nl
DTSTAMP:20260223T140000Z
DTSTART;TZID=Europe/Amsterdam:20260323T100000
DTEND;TZID=Europe/Amsterdam:20260323T120000
SUMMARY:Yoga Workshop: Yin & Restorative
DESCRIPTION:Een rustige tweeurige workshop... Aanmelden via https://eventbrite.com/...
LOCATION:Yoga Studio Zen\, Prinsengracht 123\, Amsterdam
URL:https://yogastudio.nl/events/yoga-workshop-23-mrt
STATUS:CONFIRMED
ORGANIZER;CN=Yoga Studio Zen:mailto:info@yogastudio.nl
END:VEVENT
END:VCALENDAR
  • URL wijst naar de Beam single page (niet de externe registratie), zodat agenda-clients de detail-pagina openen
  • REFRESH-INTERVAL: PT1H — kalender-clients ververen elk uur
  • UID uniek per event, stabiel over wijzigingen
  • STATUS mapped: CONFIRMED / CANCELLED / TENTATIVE (rescheduled)
  • RescheduleSTATUS:CONFIRMED met SEQUENCE increment, zodat clients de wijziging oppikken
<meta property="og:type" content="event" />
<meta property="event:start_time" content="2026-03-23T10:00:00+01:00" />
<meta property="event:end_time" content="2026-03-23T12:00:00+01:00" />
<meta property="event:location:locality" content="Amsterdam" />
<meta name="twitter:card" content="summary_large_image" />

Conform §2 van het expansion system:

EventProperties
event_viewevent_id, source (organic, archive, related)
event_registration_clickevent_id, destination_host (eventbrite.com, etc.)
event_ical_subscribedscope (site, single)
event_shareevent_id, channel (email, twitter, copy-link)

Conform §7 van het expansion system. Synergie-matrix v1:

CombinatieUnlock
Events × Blogevents-blog-sidebar block (aankomende events in blog post)
{{upcoming-events}} inline rich-text element
• “Event recap” post-template
• Auto-link events naar gerelateerde blog posts
Events × Reviewsevent-reviews-wall block — open review-formulier op event detail page
• Reviews zichtbaar op volgend event (“Vorig jaar: 4.8/5”)
• Geen email-prompt (Beam heeft geen attendee-lijst)
Events × Bookings• “Boek 1-op-1 intake voor deze workshop” CTA op single event
• Pre-event consultation slots
• Post-event follow-up sessie aanbieden
Events × Popups• Site-level “Mis dit event niet” exit-intent popup
• Geo-targeted “Event in jouw stad”
Events × Portfolio• Event als case study na afloop
event-recap-grid block met portfolio-stijl tegels
• Auto-import event foto’s → portfolio item

Niet in v1: Events × Forms (custom RSVP-velden) en Events × Shop (ticketed events) — beide leven extern. Zie §14 Backlog.

Gebruiker activeert Events + Reviews
Systeem detecteert: events.enhancedBy bevat { addonId: 'reviews', capability: 'open-review-form' }
Synergie ontgrendeld:
→ Nieuw block: 'event-reviews-wall' beschikbaar in single event template
→ Open formulier: "Was je hier? Laat een review achter"
→ Reviews gekoppeld aan event_id, aggregaat ★ gemiddelde
Codex melding: "Events + Reviews actief — bezoekers kunnen reviews achterlaten op event-pagina's"

Events erven brand tokens uit brand_profiles — geen eigen palet. Specifieke token-mappings:

ElementToken
Event card achtergrondsurface
Event card borderborder
Event datum/tijd accentaccent
”Aanmelden” CTA-knopprimary button style
Status badge “Sold out”error.bg + error.text
Status badge “Geannuleerd”warning.bg + warning.text
Calendar today highlightaccent met 12% opacity

Conform .claude/rules/data-fetching.md en ADR-008:

// API Worker (authoritative writer)
const kvKeys = {
eventsList: (siteId: string) => `events:list:${siteId}`,
event: (siteId: string, slug: string) => `events:single:${siteId}:${slug}`,
eventsIcs: (siteId: string) => `events:ics:${siteId}`,
eventsRss: (siteId: string) => `events:rss:${siteId}`,
}
// Invalidatie bij elke mutatie:
async function onEventChanged(siteId: string, event: EventItem) {
await Promise.all([
api.cache.delete(kvKeys.eventsList(siteId)),
api.cache.delete(kvKeys.event(siteId, event.slug)),
api.cache.delete(kvKeys.eventsIcs(siteId)),
api.cache.delete(kvKeys.eventsRss(siteId)),
])
}

Conform .claude/rules/realtime.md:

// Dashboard (editor)
const channel = new BroadcastChannel('beam-events-updated')
channel.postMessage({ siteId, eventId, action: 'updated', timestamp: Date.now() })
// Cross-device:
supabase.channel('events-changes')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'addon_items',
filter: `site_id=eq.${siteId}`,
}, handler)
.subscribe()
-- Lees: iedereen in het team mag events lezen
CREATE POLICY "events_read" ON addon_items FOR SELECT USING (
collection_id IN (SELECT id FROM addon_collections WHERE site_id IN (
SELECT id FROM sites WHERE owner_id = get_team_owner_id((select auth.uid()))
))
);
-- Schrijf: editors+
CREATE POLICY "events_write" ON addon_items FOR ALL USING (
collection_id IN (
SELECT c.id FROM addon_collections c
JOIN sites s ON c.site_id = s.id
JOIN team_members tm ON tm.owner_id = s.owner_id
WHERE tm.user_id = (select auth.uid())
AND tm.role IN ('owner', 'admin', 'editor', 'events:author')
)
);

Geen attendee-data: Omdat aanmeldingen extern leven, slaat Beam geen event_rsvps op. Geen public-insert policies, geen PII-exposure, geen Turnstile, geen retentie-beleid voor bezoekers.

Featured images via shared image-cache.ts (.claude/rules/images.md):

  • getBackgroundImageUrl() voor event-hero blocks
  • getImageSrc() + getImageSrcSet() voor event-card blocks
  • LQIP via blur_data_url uit media.blur_data_url
  • CF Image Resizing widths: 480/640/768/960/1280/1440
VeldMethode
description_htmlsanitizeHtml() via DOMPurify (dashboard + Astro)
online_url, registration_url, organizer.url, performer.urlsanitizeUrl() (max 2048, HTTPS-only) + isPrivateUrl() blok
online_instructionssanitizeHtml() met whitelist <p>, <a>, <br>
cancellation_reasonPlain text only (geen HTML)
registration_labelPlain text only, max 40 chars

Events vertaalbaar op item-niveau (core taxonomy):

  • Eén event per taal of één event met translations map (open vraag, zie §16)
  • iCal feed per taal: /events/feed.ics?lang=nl
  • Schema.org inLanguage veld populeren
  • Datum/tijd-formattering per locale (Intl.DateTimeFormat)
  • Geen attendee-data in Beam — alle aanmelding-PII leeft op de externe registratiesite, wat Beam’s GDPR-oppervlak drastisch verkleint
  • Wel data: organisator-email (publiek opgegeven door host) — geen retentie-beleid nodig
  • event_view analytics zijn anoniem (geen IP-opslag)
  • Externe registration-clicks worden geteld met event_registration_click (anoniem)

Vertical PackArchetypeVoorbeeld eventsExterne registratie via
Yoga Studio (bestaat)Healer × GuideWorkshops naast vaste lessenMomoyoga, Eversports
Restaurant (bestaat)Host × CuratorChef’s table, themadinersFormitable, eigen formulier
Galerie (uitbreiding Photographer)Creator × CuratorVernissages, expositiesGeen registratie (open inloop)
Community Hub (nieuw)Host × EntertainerMeetups, borrels, netwerk-eventsMeetup.com, Eventbrite
Business Coach (bestaat)Builder × GuideWebinars, masterclassesZoom Events, Eventbrite
Photographer (bestaat)Creator × StorytellerFoto-walks, workshopsEventbrite

Conferentie Site (Scholar × Curator) is uit de vertical pack-roadmap verwijderd zolang Ticketed Events niet beschikbaar is. Zie §14 Backlog.


Conform 02-brand-json-spec § Database. Events leeft op het bestaande addon_collections + addon_items schema, met events-specifieke kolommen via metadata JSONB.

In tegenstelling tot eerdere ontwerpen worden event_rsvps en event_email_log niet geïntroduceerd in v1. Beide horen bij eigen RSVP-functionaliteit en staan in §14 Backlog.

Bestaande Tabellen — Geen Wijzigingen Nodig

Section titled “Bestaande Tabellen — Geen Wijzigingen Nodig”
TabelHoe Events het gebruikt
addon_collectionsEén row: id=events, slug='events', metadata_schema=eventMetadataSchema
addon_itemsEén row per event. Events metadata in metadata JSONB
addon_taxonomiesCategorieën en tags opgeslagen, niet filterbaar via routes in v1
addon_archive_pagesCustom /events page override
content_referencesCross-content: blog post ↔ event, event ↔ portfolio recap
site_milestonesVertical pack voortgang (bijv. “Eerste event toegevoegd”)
  • addon_items.metadata->>'start_at' queries → expression index:
    CREATE INDEX idx_events_start_at
    ON addon_items ((metadata->>'start_at'))
    WHERE collection_id IN (SELECT id FROM addon_collections WHERE slug = 'events');
  • iCal-feed materialiseert in KV — geen DB hit op cache-hit
  • Default LIMIT 200 op alle list-queries (conform .claude/rules/security.md)

Functionaliteit die bewust uit v1 is gehouden. Volgorde indicatief, geen harde commitment.

B-1 Eigen RSVP (specialisatie “RSVP Events”)

Section titled “B-1 Eigen RSVP (specialisatie “RSVP Events”)”

In plaats van uitsluitend extern linken zou Beam zelf aanmeldingen kunnen verwerken.

Vervangt of vult registration_url aan met:

  • Capaciteit + wachtlijst
  • Aanmeldformulier met basisvelden (naam, email, +1)
  • Bevestigings-email (rsvp-confirmed)
  • Reminder-email 24u voor start (cron)
  • Cancel via tokenized email-link
  • Host dashboard: attendees-lijst + export CSV
  • Database: event_rsvps tabel + event_email_log (idempotentie)
  • Public API: POST /api/events/:id/rsvp met Turnstile + rate-limit
  • Crons: sendReminders24h, promoteFromWaitlist
  • Permissions: events:host
  • GDPR: retentie-beleid voor attendee-PII (default 12 maanden)

B-2 Ticketed Events (specialisatie “Ticketed Events”)

Section titled “B-2 Ticketed Events (specialisatie “Ticketed Events”)”

Bouwt op B-1 en vereist Shop addon:

  • Ticket-types met prijzen
  • Shop checkout integratie
  • QR-tickets als digital product
  • Check-in API + scanner-PWA
  • Refund flow
  • Permissions: events:checkin
  • Schema.org offers.price/priceCurrency
  • RRULE editor in event-form
  • Recurring event expansion in feeds + blocks
  • Multi-day schedule met dag-tabs (block bestaat al, RRULE-data ontbreekt)
  • Exception dates (EXDATE) management
  • Schema.org expansion per instance

Vereist B-1. Custom velden in RSVP-form (dieetwensen, t-shirt maat, +1 details). Form responses gekoppeld aan attendee record.

Dekt B-2.

Vereist B-1 (Beam heeft dan attendee-lijst). Post-event review-prompt email via cron. Voor v1: alleen open review-block op event page (geen email).

  • Routes: /events/categorie/[slug], /events/tag/[slug]
  • Filter-bar block op archive-page
  • events-list block uitbreiden met source: 'category' | 'tag' + categoryId/tagId props
  • Categorie-specifieke iCal feeds: /events/categorie/[slug]/feed.ics

Niet gepland — gebruikers zoeken bij events bijna nooit per maand vooruit; ze willen weten “wat komt er”. Hier expliciet uit scope gehouden.

Vereist B-2 (Ticketed). Op de plank zetten tot Shop + Ticketed beschikbaar zijn.

"Conferentie Site" Vertical Pack (backlog)
├── Archetype: Scholar × Curator
├── Requires: Events (ticketed), Shop, Portfolio, Forms
├── Style Pack: "Scholar Authority"
├── Niche Blocks: Speaker Grid, Multi-day Schedule, Sponsor Wall, Venue Map
└── Milestones: speaker, programma, ticket-types, eerste verkoop, conferentie live

Push events naar externe Mailchimp/Eventbrite/Google Calendar bij event:published. Out-of-scope tot er vraag is.

  • events-blog-sidebar, event-intake-cta, event-scarcity-popup, event-recap-grid zijn gepland in v1 (zie §6)
  • ticket-checkout (vereist B-2)
  • event-custom-rsvp (vereist B-4)

Scope: Events met externe registratie-URL, het noodzakelijke om Schema.org en iCal werkend te krijgen.

#TaakOutput
1Manifest + collection registratieAddon zichtbaar in dashboard activatie-UI
2Event-editor metadata-form (datum, locatie, registration_url, organisator)Editor kan event aanmaken
3Twee blocks: events-list + event-card met externe CTA-knopTe plaatsen op pages
4Archive template /events + single /events/[slug]Public site routes werken
5Schema.org Event JSON-LD op singleGoogle Events ready
6iCal feed /events/feed.ics + /api/events/:id/calendar.icsKalender-abonnement werkt
7OG/Twitter meta-tags voor eventsSocial sharing werkt
8Sitemap inclusief eventsSEO-discovery
9KV cache invalidatie hooksPublic site refresh werkt
10Crons: expireCompletedEvents, archiveOldEventsStatus-housekeeping
11E2E tests: create event → publish → public site → iCal validatieQuality gate

Niet in MVP: RSVP, capaciteit, recurring, ticketing, calendar block, countdown, multi-day schedule, categorie/tag filters, attendee-emails. Zie §14 Backlog.

Fase 2 — Block-uitbreiding & RSS (1 week)

Section titled “Fase 2 — Block-uitbreiding & RSS (1 week)”
#Taak
12event-calendar block (maand/week view, hydrated island)
13event-countdown block
14event-schedule block (multi-day, statisch, geen RRULE)
15RSS feed /events/feed.xml
#Taak
16Blog synergy: events-blog-sidebar + {{upcoming-events}} inline
17Reviews synergy: event-reviews-wall open review-form
18Bookings synergy: event-intake-cta
19Popups synergy: event-scarcity-popup
20Portfolio synergy: event-recap-grid

Eigen RSVP (B-1), recurring (B-3), categorie-filters (B-7) en verder.


#VraagBeslissing nodig voor
OQ-E1Timezone-handling: user’s browser tz of altijd event.timezone? Default Europe/Amsterdam versus auto-detect.MVP
OQ-E2Multi-language events: één item met translations map, of één item per taal met translation_group_id?Fase 3+
OQ-E3Bij verzetten event: stuur SEQUENCE-update naar iCal subscribers of een aparte cancel+create?MVP
OQ-E4Event-categorieën opslaan in addon_taxonomies (gedeeld met blog) of events-specifiek? Filter-routes komen pas in B-7.MVP
OQ-E5Toont event-card block “Externe link” pictogram naast CTA, of voelt dat te techy? UX-keuze.MVP
OQ-E6Past sanitizeUrl() ook een nofollow SEO-attribuut toe op registration_url links, of vertrouwen we de host?MVP
OQ-E7Hoe verwerken we event:rescheduled als de cancel-mail in B-1 niet bestaat? Alleen iCal SEQUENCE-update?MVP

Open vragen rond eigen RSVP / Ticketed / Recurring zijn verplaatst naar de relevante backlog-items in §14.


Bijlage A — Voorbeeld: Hoe Een Event Wordt Aangemaakt

Section titled “Bijlage A — Voorbeeld: Hoe Een Event Wordt Aangemaakt”
User klikt "+ Nieuw event" in dashboard
Editor opent met events-specifieke metadata-form:
• Titel: "Yoga Workshop: Yin & Restorative"
• Slug: "yoga-workshop-23-mrt" (auto-slugify)
• Datum: 2026-03-23
• Tijd: 10:00 — 12:00 (timezone: Europe/Amsterdam)
• Locatie: Fysiek
• Venue: "Yoga Studio Zen"
• Adres: "Prinsengracht 123, 1015 ND Amsterdam"
• Aanmelding: https://eventbrite.com/e/yin-restorative-...
(label: "Aanmelden via Eventbrite")
• Featured image: upload of Unsplash
• Organisator: Yoga Studio Zen
Hoofdblock-editor: voeg blocks toe voor de detail-pagina
"Publiceren" klikken
Onder water:
→ INSERT INTO addon_items
→ KV cache invalidate (eventsList, eventIcs, eventsRss)
→ BroadcastChannel + Supabase Realtime notify
→ Hook 'event:published' fires → analytics event
Direct daarna:
→ /events archief toont nieuwe event
→ /events/yoga-workshop-23-mrt single page live met Schema.org JSON-LD
→ /events/feed.ics bevat nieuwe VEVENT (offers.url naar Eventbrite)
→ Google bot kan event indexeren binnen 24u

Specifieke valkuilen voor de Events addon, naast de algemene anti-patronen in .claude/rules/data-fetching.md:

Anti-patroonWaarom nietCorrect alternatief
Event als gewone page met Block[] zonder eigen collectionGeen Schema.org, geen iCal, geen filtersGebruik events collection met metadata schema
Timezone uit user’s browser opslaan in start_atVerschillende attendees zien andere tijdenSla start_at op met expliciete timezone veld, render via Intl.DateTimeFormat
registration_url zonder sanitizeUrl() + isPrivateUrl()XSS via javascript: URL, SSRF via private IPAltijd valideren in API én client
registration_url link zonder target="_blank" + rel="noopener noreferrer nofollow ugc"Phishing surface, SEO-vervuilingStrikt rel-attribuut afdwingen
Cancellation zonder Schema.org eventStatus updateGoogle blijft cancelled event als live tonenUpdate eventStatus: EventCancelled + cancellation_reason
iCal feed zonder REFRESH-INTERVALApple/Google Calendar ververst maandelijks i.p.v. uurlijksAltijd REFRESH-INTERVAL:PT1H of korter
Eigen attendee-tabel toevoegen “voor later”Dode kolommen, RLS-complexiteit, GDPR-oppervlakWachten tot B-1 daadwerkelijk gebouwd wordt
Categorie/tag in events-list block toevoegen “voor later”Cluttered UI, dode prop in editorWachten tot B-7 daadwerkelijk gebouwd wordt