Events
Part of @cobuntu/embed-react: headless, data via props, links through your router.
Event filters
EventFilters is the community app's events browse strip, minus the events: a
search box, a row of category pills (and a row of sub-category pills once a category
is chosen), a Filters button with When, Sort, Price and Format, Clear, and a result
line. EventMiniCalendar is the month grid for jumping to a day. Neither renders
event cards: you fetch with events.list and keep your own list.
The state is one plain object:
interface EventFilterState {
q: string;
when: "upcoming" | "past";
category: string; // "" = all
subCategory: string; // "" = all
sort: "soonest" | "name";
price: "" | "free" | "paid";
format: "" | "in_person" | "online" | "hybrid";
date: string | null; // "YYYY-MM-DD"
}Behaviour, as in the app:
- Choosing a different category clears the sub-category.
- Picking the selected pill again sets it back to All.
- Every option shows its count from
events.facets. Upcoming and Soonest are the defaults and do not count as filters. - Clear resets everything except the search text.
Server-side usage
Read the URL, fetch the page and the counts, render. The helpers are also exported
from @cobuntu/embed-react/events, which is not a client module, so a server
component can call them.
// app/events/page.tsx (server component)
import { fromSearchParams, toEventQuery, toEventFacetsQuery } from "@cobuntu/embed-react/events";
import { cobuntu } from "@/lib/cobuntu";
import { EventsBrowser } from "./EventsBrowser";
export default async function EventsPage({ searchParams }: { searchParams: Promise<Record<string, string>> }) {
const state = fromSearchParams(await searchParams);
const [list, facets] = await Promise.all([
cobuntu.events.list({ ...toEventQuery(state), limit: 24 }),
cobuntu.events.facets(toEventFacetsQuery(state)),
]);
return <EventsBrowser initial={state} list={list} facets={facets} />;
}URL sync
toSearchParams(state, base?) writes the state (defaults left out, other keys in
base kept); fromSearchParams(params) reads it back from URLSearchParams or a
page's searchParams object. The keys are q, when, categoryId,
subCategoryId, sort, price, format and date.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { EventFilters, EventMiniCalendar, toSearchParams, type EventFilterState } from "@cobuntu/embed-react";
export function EventsBrowser({ initial, list, facets }) {
const router = useRouter();
const [state, setState] = useState<EventFilterState>(initial);
const update = (next: EventFilterState) => {
setState(next);
const qs = toSearchParams(next, window.location.search).toString();
router.replace(qs ? `?${qs}` : "?", { scroll: false }); // re-runs the server component
};
return (
<>
<EventFilters value={state} onChange={update} facets={facets} resultCount={list.events.length} />
<EventMiniCalendar value={state.date} onChange={(date) => update({ ...state, date })} events={list.events} />
<MyEventList events={list.events} />
</>
);
}Debounce the search text before you navigate or fetch, if you fetch per keystroke.
Without value, the component is uncontrolled: pass defaultValue and read changes
from onChange.
EventFilters props
| Prop | |
|---|---|
value / defaultValue | Controlled state, or the starting state when uncontrolled (partial). |
onChange(next) | Every change, with the sub-category already cleared when the category changed. |
facets | The events.facets() result. Supplies the categories and every count. Without it, no category rows and no counts. |
resultCount | Number for the result line and the sheet's Show button. Omit to hide the line. |
show | { search?, categories?, when?, sort?, price?, format? }: set any to false to hide it. |
trailing | A node at the end of the toolbar row. |
categoriesIn | "inline" (default) or "sheet". With "sheet", the category row (and the sub-category row once a category is chosen) move to the top of the Filters sheet, the toolbar stays search plus one Filters button, and the button's badge and the result line still count the chosen category. The facet is titled labels.category. |
labels | Any subset of EventFilterLabels (results: (n) => ..., show: (n) => ...). |
EventMiniCalendar props
| Prop | |
|---|---|
value, onChange(day | null) | The selected YYYY-MM-DD. Picking it again clears it. toEventQuery sends it as from = to. |
events | Anything with a startDate: draws up to three dots per day, and only days with events can be picked. Omit to make every day pickable. |
locale, weekStartsOn | Month and weekday names; 0 Sunday (default) or 1 Monday. |
initialMonth | YYYY-MM shown first. Defaults to the selected day's month, else today's. |
Helpers
| Helper | |
|---|---|
toEventQuery(state) | The events.list params. |
toEventFacetsQuery(state) | The events.facets params (when, search, day). |
fromSearchParams(params), toSearchParams(state, base?) | URL sync. Unknown values fall back to defaults. |
eventFacetsToOptions(facets, category, labels) | Facets to { categories, subcategories, price, format, total } option lists, for your own controls. |
applyEventFilterChange(prev, next) | The sub-category clearing rule. |
activeEventFilterCount(state), EMPTY_EVENT_FILTERS, dayToRange(day) |
Event price
The price a card or an event page shows, read from the event's tiers. Same rules as Cobuntu's community app:
- Only paid tiers count. Tiers on sale right now are preferred; when none is on sale, every paid tier is a candidate, so an event between sales windows still shows a price.
- One candidate shows its price; more than one shows "From" the cheapest.
- No paid tier is "Free". Every tier sold out is "Sold out".
- The member price comes from the server (
memberPrice, only for a signed-in member who qualifies) and is shown only when it is lower.
The helpers are pure and live in the server-safe @cobuntu/embed-react/events
entry as well as the main one.
import { eventPrice, eventPriceTags, instalmentLabel } from "@cobuntu/embed-react/events";
const p = eventPrice(event, { locale: "en-IE", labels: { from: (x) => `from ${x}`, free: "free" } });
// { label: "from €15.00", kind: "from", amount: 1500, currency: "EUR", price: "€15.00",
// memberAmount: 1200, memberPrice: "€12.00", memberLabel: "€12.00 for members" }Or the headless component:
import { EventPrice } from "@cobuntu/embed-react";
<EventPrice event={event} locale="en-IE" labels={{ soldOut: "Sold out" }} />| Helper | |
|---|---|
eventPrice(event, { locale?, labels? }) | { label, kind, amount, currency, price, memberAmount?, memberPrice?, memberLabel? }. kind is free, single, from or soldOut. |
eventPriceTags(event, { locale? }) | One tag per candidate paid tier, cheapest first, duplicates collapsed: { list, member, amount, memberAmount, currency }. Empty for a free event. |
isEventFree(event), isEventSoldOut(event) | Ask the event, not the label. |
isTierSoldOut(tier), tierRemaining(tier) | Per tier. tierRemaining is null when uncapped or unknown. |
instalmentPlan(tier), instalmentLabel(tier, { locale?, format? }) | The tier's instalment plan, and "or €20.33 every month × 3 charges". Null without a plan. |
defaultEventPriceLabels | free, from(price), soldOut, member(price). |
EventPrice props: event, locale, labels, showMember (default true), className.
Classes: cb-event-price (data-kind), __label, __member.
Class map
See the class map conventions.
Event filters
| Class | Element | State attributes |
|---|---|---|
cb-event-filters | Root | data-active, data-when |
__row | Toolbar row | |
__search / __search-input / __search-clear | Search | data-filled |
__toggle / __toggle-label / __badge | Filters button and active count | data-active, aria-expanded |
__clear | Clear (only while something is applied) | |
__pills | A pill row | data-facet="category | subCategory", data-depth="0 | 1" |
__pill / __pill-label / __pill-count | A pill (the first is All) | data-state="on | off", data-value, data-empty when its count is 0 |
__summary / __count | Result line | |
__backdrop, __sheet, __sheet-header, __sheet-title, __close, __body, __footer, __reset, __done | The sheet | |
__facet / __facet-title / __options | A group in the sheet | data-facet="when | sort | price | format" |
__option / __option-label / __option-count | An option | as __pill |
Mini calendar
| Class | Element | State attributes |
|---|---|---|
cb-mini-cal | Root | data-has-selection |
__header, __month, __nav, __prev, __today, __next | Month and navigation | |
__weekdays / __weekday | Weekday initials | |
__grid / __day | Days (a button when pickable) | data-date, data-state, data-today, data-count, data-empty, data-outside |
__dots / __dot | Up to three event dots | |
__selection, __selected, __clear | The chosen day and its clear button |