@themoonlitcompany/mbevents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @themoonlitcompany/mbevents
2
+
3
+ Client SDK for [Momentum Events](../application), a white-label RSVP engine. The
4
+ backend workflow is prebuilt; this package gives your front end ready-made
5
+ tools to talk to it. Zero dependencies, works in the browser and Node.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @themoonlitcompany/mbevents
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ Every Momentum event has a slug and a public event key (find both on the
16
+ event's API tab in the console). The key only grants reading the published
17
+ event and submitting RSVPs, so it is safe in browser code.
18
+
19
+ ```ts
20
+ import { createEventClient } from "@themoonlitcompany/mbevents";
21
+
22
+ const client = createEventClient({
23
+ baseUrl: "https://your-momentum-deployment.com",
24
+ slug: "spring-launch-dinner",
25
+ eventKey: "me_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
26
+ });
27
+
28
+ // 1. Read the event and its question definitions
29
+ const event = await client.getEvent();
30
+
31
+ // 2. Submit an RSVP (idempotency key is generated for you)
32
+ const result = await client.accept({
33
+ email: "guest@example.com",
34
+ fullName: "Guest Name",
35
+ partySize: 2,
36
+ answers: { meal_preference: "Vegetarian" },
37
+ });
38
+
39
+ console.log(result); // { rsvpId: "...", status: "accepted", deduped: false }
40
+ ```
41
+
42
+ ## Tools included
43
+
44
+ | Tool | What it does |
45
+ | --- | --- |
46
+ | `client.getEvent()` | Fetches the published event with questions |
47
+ | `client.submitRsvp(input)` | Submits or updates an RSVP (any status) |
48
+ | `client.accept(input)` / `client.decline(input)` | Status shorthands |
49
+ | `toFormFields(event)` | Flattens built-in fields + questions into an ordered, renderable field list |
50
+ | `validateAnswers(event, answers)` | Client-side validation mirroring the server, for instant feedback |
51
+ | `isRsvpClosed(event)` | Whether the RSVP deadline has passed |
52
+ | `MomentumError` | Thrown on API errors; carries `.status` and a human-readable message |
53
+
54
+ ## Rendering a form from the event definition
55
+
56
+ ```ts
57
+ import { createEventClient, toFormFields, validateAnswers } from "@themoonlitcompany/mbevents";
58
+
59
+ const client = createEventClient({ baseUrl, slug, eventKey });
60
+ const event = await client.getEvent();
61
+
62
+ // Drive your markup from the definition; it stays in sync with the console.
63
+ for (const field of toFormFields(event)) {
64
+ renderInput(field); // your UI, your brand
65
+ }
66
+
67
+ // Validate before submitting
68
+ const errors = validateAnswers(event, collectedAnswers);
69
+ if (Object.keys(errors).length === 0) {
70
+ await client.accept({ email, fullName, answers: collectedAnswers });
71
+ }
72
+ ```
73
+
74
+ ## Error handling
75
+
76
+ ```ts
77
+ import { MomentumError } from "@themoonlitcompany/mbevents";
78
+
79
+ try {
80
+ await client.accept({ email, fullName });
81
+ } catch (error) {
82
+ if (error instanceof MomentumError) {
83
+ // Server messages are written to be shown to guests,
84
+ // e.g. "This event is at capacity." or "Meal preference is required."
85
+ showMessage(error.message);
86
+ }
87
+ }
88
+ ```
89
+
90
+ ## What the engine enforces server-side
91
+
92
+ Required questions, capacity, party-size limits, RSVP deadlines, one response
93
+ per email (repeat submissions update the existing RSVP), and idempotent
94
+ retries. Your front end never reimplements the workflow.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Momentum Events client SDK.
3
+ *
4
+ * Wraps the two public endpoints every Momentum event exposes:
5
+ *
6
+ * GET {baseUrl}/api/public/events/{slug} -> event details + questions
7
+ * POST {baseUrl}/api/public/events/{slug}/rsvps -> submit an RSVP
8
+ *
9
+ * The event key (`me_...`) only grants access to published event details and
10
+ * RSVP submission, so it is safe to use in browser code.
11
+ */
12
+ export type QuestionType = "text" | "textarea" | "select" | "boolean";
13
+ export type EventQuestion = {
14
+ key: string;
15
+ label: string;
16
+ type: QuestionType;
17
+ required: boolean;
18
+ options: string[];
19
+ };
20
+ export type MomentumEvent = {
21
+ title: string;
22
+ slug: string;
23
+ description: string | null;
24
+ location: string | null;
25
+ startsAt: string | null;
26
+ endsAt: string | null;
27
+ capacity: number | null;
28
+ rsvpDeadline: string | null;
29
+ allowPlusOnes: boolean;
30
+ maxPartySize: number;
31
+ confirmationEmailEnabled: boolean;
32
+ questions: EventQuestion[];
33
+ };
34
+ export type RsvpStatus = "accepted" | "declined" | "waitlisted";
35
+ export type AnswerValue = string | number | boolean | null;
36
+ export type RsvpInput = {
37
+ email: string;
38
+ fullName: string;
39
+ status?: RsvpStatus;
40
+ partySize?: number;
41
+ answers?: Record<string, AnswerValue>;
42
+ /** Provide your own to dedupe retries; one is generated when omitted. */
43
+ idempotencyKey?: string;
44
+ };
45
+ export type RsvpResult = {
46
+ rsvpId: string;
47
+ status: RsvpStatus;
48
+ /** True when the server matched a previous submission by idempotency key. */
49
+ deduped: boolean;
50
+ };
51
+ /** A renderable field description: the built-in fields plus custom questions. */
52
+ export type FormField = {
53
+ key: string;
54
+ label: string;
55
+ kind: "builtin" | "question";
56
+ type: QuestionType | "email" | "number";
57
+ required: boolean;
58
+ options: string[];
59
+ };
60
+ export declare class MomentumError extends Error {
61
+ readonly status: number;
62
+ constructor(message: string, status: number);
63
+ }
64
+ export type ClientConfig = {
65
+ /** Origin of the Momentum deployment, e.g. "https://events.example.com". */
66
+ baseUrl: string;
67
+ /** The event's URL slug. */
68
+ slug: string;
69
+ /** The event's public key (starts with "me_"). */
70
+ eventKey: string;
71
+ /** Custom fetch implementation; defaults to globalThis.fetch. */
72
+ fetch?: typeof globalThis.fetch;
73
+ };
74
+ export type EventClient = {
75
+ /** Fetch the published event with its question definitions. */
76
+ getEvent: () => Promise<MomentumEvent>;
77
+ /** Submit or update an RSVP. Defaults to status "accepted", party of 1. */
78
+ submitRsvp: (input: RsvpInput) => Promise<RsvpResult>;
79
+ /** Shorthand for submitRsvp with status "accepted". */
80
+ accept: (input: Omit<RsvpInput, "status">) => Promise<RsvpResult>;
81
+ /** Shorthand for submitRsvp with status "declined". */
82
+ decline: (input: Omit<RsvpInput, "status">) => Promise<RsvpResult>;
83
+ };
84
+ export declare function createEventClient(config: ClientConfig): EventClient;
85
+ /**
86
+ * Validate answers against the event's question definitions before
87
+ * submitting. Returns a map of question key to error message; empty when
88
+ * everything passes. Mirrors the server's validation so users get instant
89
+ * feedback instead of a round trip.
90
+ */
91
+ export declare function validateAnswers(event: MomentumEvent, answers: Record<string, AnswerValue>): Record<string, string>;
92
+ /**
93
+ * Flatten an event into an ordered list of renderable form fields: the
94
+ * built-in fields (name, email, party size when plus-ones are allowed)
95
+ * followed by the event's custom questions. Drive your form markup from this
96
+ * and it stays in sync with the console automatically.
97
+ */
98
+ export declare function toFormFields(event: MomentumEvent): FormField[];
99
+ /** True when the event's RSVP deadline has passed. */
100
+ export declare function isRsvpClosed(event: MomentumEvent, now?: Date): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Momentum Events client SDK.
3
+ *
4
+ * Wraps the two public endpoints every Momentum event exposes:
5
+ *
6
+ * GET {baseUrl}/api/public/events/{slug} -> event details + questions
7
+ * POST {baseUrl}/api/public/events/{slug}/rsvps -> submit an RSVP
8
+ *
9
+ * The event key (`me_...`) only grants access to published event details and
10
+ * RSVP submission, so it is safe to use in browser code.
11
+ */
12
+ export class MomentumError extends Error {
13
+ constructor(message, status) {
14
+ super(message);
15
+ this.name = "MomentumError";
16
+ this.status = status;
17
+ }
18
+ }
19
+ export function createEventClient(config) {
20
+ const { slug, eventKey } = config;
21
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
22
+ const doFetch = config.fetch ?? globalThis.fetch.bind(globalThis);
23
+ const endpoint = `${baseUrl}/api/public/events/${encodeURIComponent(slug)}`;
24
+ async function parse(response, pick) {
25
+ const body = (await response.json().catch(() => null));
26
+ if (!response.ok) {
27
+ const message = body && typeof body.error === "string"
28
+ ? body.error
29
+ : `Request failed with status ${response.status}.`;
30
+ throw new MomentumError(message, response.status);
31
+ }
32
+ return (body?.[pick] ?? body);
33
+ }
34
+ const getEvent = async () => {
35
+ const response = await doFetch(endpoint, {
36
+ headers: { "x-momentum-public-key": eventKey },
37
+ });
38
+ return await parse(response, "event");
39
+ };
40
+ const submitRsvp = async (input) => {
41
+ const response = await doFetch(`${endpoint}/rsvps`, {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({
45
+ publicKey: eventKey,
46
+ email: input.email,
47
+ fullName: input.fullName,
48
+ status: input.status ?? "accepted",
49
+ partySize: input.partySize ?? 1,
50
+ answers: input.answers ?? {},
51
+ idempotencyKey: input.idempotencyKey ?? generateIdempotencyKey(),
52
+ }),
53
+ });
54
+ return await parse(response, "rsvp");
55
+ };
56
+ return {
57
+ getEvent,
58
+ submitRsvp,
59
+ accept: (input) => submitRsvp({ ...input, status: "accepted" }),
60
+ decline: (input) => submitRsvp({ ...input, status: "declined" }),
61
+ };
62
+ }
63
+ function generateIdempotencyKey() {
64
+ const cryptoApi = globalThis.crypto;
65
+ if (cryptoApi && "randomUUID" in cryptoApi) {
66
+ return cryptoApi.randomUUID();
67
+ }
68
+ return `mk_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
69
+ }
70
+ /**
71
+ * Validate answers against the event's question definitions before
72
+ * submitting. Returns a map of question key to error message; empty when
73
+ * everything passes. Mirrors the server's validation so users get instant
74
+ * feedback instead of a round trip.
75
+ */
76
+ export function validateAnswers(event, answers) {
77
+ const errors = {};
78
+ for (const question of event.questions) {
79
+ const value = answers[question.key];
80
+ const empty = value === undefined || value === null || value === "";
81
+ if (question.required && empty) {
82
+ errors[question.key] = `${question.label} is required.`;
83
+ continue;
84
+ }
85
+ if (empty) {
86
+ continue;
87
+ }
88
+ if (question.type === "select" &&
89
+ typeof value === "string" &&
90
+ question.options.length > 0 &&
91
+ !question.options.includes(value)) {
92
+ errors[question.key] =
93
+ `${question.label} must be one of: ${question.options.join(", ")}.`;
94
+ }
95
+ if (question.type === "boolean" && typeof value !== "boolean") {
96
+ errors[question.key] = `${question.label} must be true or false.`;
97
+ }
98
+ }
99
+ return errors;
100
+ }
101
+ /**
102
+ * Flatten an event into an ordered list of renderable form fields: the
103
+ * built-in fields (name, email, party size when plus-ones are allowed)
104
+ * followed by the event's custom questions. Drive your form markup from this
105
+ * and it stays in sync with the console automatically.
106
+ */
107
+ export function toFormFields(event) {
108
+ const fields = [
109
+ {
110
+ key: "fullName",
111
+ label: "Full name",
112
+ kind: "builtin",
113
+ type: "text",
114
+ required: true,
115
+ options: [],
116
+ },
117
+ {
118
+ key: "email",
119
+ label: "Email",
120
+ kind: "builtin",
121
+ type: "email",
122
+ required: true,
123
+ options: [],
124
+ },
125
+ ];
126
+ if (event.allowPlusOnes && event.maxPartySize > 1) {
127
+ fields.push({
128
+ key: "partySize",
129
+ label: `Party size (up to ${event.maxPartySize})`,
130
+ kind: "builtin",
131
+ type: "number",
132
+ required: true,
133
+ options: [],
134
+ });
135
+ }
136
+ for (const question of event.questions) {
137
+ fields.push({
138
+ key: question.key,
139
+ label: question.label,
140
+ kind: "question",
141
+ type: question.type,
142
+ required: question.required,
143
+ options: question.options,
144
+ });
145
+ }
146
+ return fields;
147
+ }
148
+ /** True when the event's RSVP deadline has passed. */
149
+ export function isRsvpClosed(event, now = new Date()) {
150
+ if (!event.rsvpDeadline) {
151
+ return false;
152
+ }
153
+ const deadline = Date.parse(event.rsvpDeadline);
154
+ return !Number.isNaN(deadline) && now.getTime() > deadline;
155
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@themoonlitcompany/mbevents",
3
+ "version": "0.1.0",
4
+ "description": "Client SDK for Momentum Events: a white-label RSVP engine. Read events, render your own form, submit RSVPs.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "rsvp",
26
+ "events",
27
+ "momentum",
28
+ "white-label",
29
+ "backend-as-a-service"
30
+ ],
31
+ "devDependencies": {
32
+ "typescript": "^5.7.2"
33
+ }
34
+ }