@acms-subir/core 0.3.6 → 0.3.8

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/integration.mjs +38 -2
  3. package/package.json +1 -1
  4. package/src/actions/index.ts +146 -2
  5. package/src/components/EmailSettings.tsx +6 -0
  6. package/src/components/EventPage.astro +25 -5
  7. package/src/components/booking/DurationEditor.astro +156 -0
  8. package/src/components/calendar/BookingsHead.astro +4 -3
  9. package/src/components/calendar/RoomHolds.tsx +13 -8
  10. package/src/components/editor/EventPanel.tsx +9 -383
  11. package/src/components/editor/LivePane.tsx +16 -3
  12. package/src/components/editor/PostEditor.tsx +59 -46
  13. package/src/components/editor/PreviewOverlay.tsx +7 -1
  14. package/src/components/editor/event/EventSheet.tsx +136 -0
  15. package/src/components/editor/event/LocationField.tsx +266 -0
  16. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  17. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  18. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  19. package/src/components/editor/event/Section.tsx +27 -0
  20. package/src/components/editor/event/WhenCard.tsx +217 -0
  21. package/src/components/editor/event/dates.ts +28 -0
  22. package/src/components/editor/event/registration.ts +39 -0
  23. package/src/components/editor/extensions/form.tsx +60 -0
  24. package/src/components/editor/extensions/slash.tsx +5 -0
  25. package/src/components/editor/frameNav.ts +20 -0
  26. package/src/components/forms/Canvas.tsx +149 -0
  27. package/src/components/forms/FormBuildPage.tsx +155 -0
  28. package/src/components/forms/FormBuilder.tsx +283 -0
  29. package/src/components/forms/FormHeader.astro +31 -0
  30. package/src/components/forms/InsertMenu.tsx +77 -0
  31. package/src/components/forms/Inspector.tsx +232 -0
  32. package/src/components/forms/Outline.tsx +93 -0
  33. package/src/components/forms/ResponsesTable.tsx +123 -0
  34. package/src/components/forms/TypeIcon.tsx +24 -0
  35. package/src/components/forms/dnd.ts +3 -0
  36. package/src/components/forms/meta.ts +84 -0
  37. package/src/components/forms/useFormHistory.ts +41 -0
  38. package/src/layouts/Admin.astro +3 -2
  39. package/src/lib/backup/content.ts +10 -5
  40. package/src/lib/booking/forms.ts +67 -0
  41. package/src/lib/booking/service.ts +6 -2
  42. package/src/lib/calendar/agent-schema.ts +7 -1
  43. package/src/lib/calendar/agent.ts +28 -3
  44. package/src/lib/calendar/api.ts +2 -0
  45. package/src/lib/calendar/ics-write.ts +2 -0
  46. package/src/lib/calendar/ics.ts +4 -0
  47. package/src/lib/calendar/import.ts +3 -3
  48. package/src/lib/calendar/schedule.ts +6 -2
  49. package/src/lib/calendar/seo.ts +4 -1
  50. package/src/lib/content-service.ts +30 -4
  51. package/src/lib/drafts.ts +9 -1
  52. package/src/lib/dynamicBlocks.ts +14 -3
  53. package/src/lib/editor/documents.ts +12 -3
  54. package/src/lib/editor/schemas.ts +4 -3
  55. package/src/lib/email/readiness.ts +14 -0
  56. package/src/lib/email/service.ts +8 -3
  57. package/src/lib/email/templates.ts +8 -2
  58. package/src/lib/email/types.ts +1 -0
  59. package/src/lib/forms/events.ts +40 -0
  60. package/src/lib/forms/export.ts +62 -0
  61. package/src/lib/forms/fields.ts +50 -0
  62. package/src/lib/forms/http.ts +29 -0
  63. package/src/lib/forms/render.ts +11 -0
  64. package/src/lib/forms/schema.ts +56 -0
  65. package/src/lib/forms/service.ts +38 -0
  66. package/src/lib/forms/store.ts +78 -0
  67. package/src/lib/forms/validate.ts +71 -0
  68. package/src/lib/maps/config.ts +64 -0
  69. package/src/lib/maps/migration.ts +12 -0
  70. package/src/lib/maps/parse.ts +180 -0
  71. package/src/lib/maps/places.ts +109 -0
  72. package/src/lib/maps/providers/geoapify.ts +36 -0
  73. package/src/lib/maps/providers/google.ts +54 -0
  74. package/src/lib/maps/providers/http.ts +96 -0
  75. package/src/lib/maps/providers/index.ts +37 -0
  76. package/src/lib/maps/providers/locationiq.ts +35 -0
  77. package/src/lib/maps/providers/mapbox.ts +38 -0
  78. package/src/lib/maps/providers/osm.ts +59 -0
  79. package/src/lib/maps/providers/pelias.ts +51 -0
  80. package/src/lib/maps/service.ts +281 -0
  81. package/src/lib/maps/types.ts +35 -0
  82. package/src/lib/mobile/bookings.ts +1 -1
  83. package/src/lib/mobile/schemas.ts +2 -2
  84. package/src/lib/repo/migrations.ts +8 -0
  85. package/src/lib/repo/sql.ts +99 -10
  86. package/src/lib/repo/types.ts +21 -1
  87. package/src/lib/schema.ts +38 -0
  88. package/src/lib/umami.ts +8 -1
  89. package/src/pages/admin/calendar/bookings.astro +23 -12
  90. package/src/pages/admin/calendar/events/[id].astro +5 -2
  91. package/src/pages/admin/calendar/events/index.astro +3 -0
  92. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  93. package/src/pages/admin/calendar/resources/link.astro +95 -49
  94. package/src/pages/admin/calendar/settings.astro +140 -3
  95. package/src/pages/admin/forms/[id]/share.astro +121 -0
  96. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  97. package/src/pages/admin/forms/[id].astro +27 -0
  98. package/src/pages/admin/forms/index.astro +198 -0
  99. package/src/pages/admin/integrations.astro +57 -0
  100. package/src/pages/admin/settings.astro +1 -1
  101. package/src/pages/api/agent/index.ts +1 -1
  102. package/src/pages/api/calendar/docs.ts +2 -0
  103. package/src/pages/api/calendar/events/[id].ts +1 -1
  104. package/src/pages/api/calendar/events/index.ts +1 -1
  105. package/src/pages/api/email/settings.ts +4 -2
  106. package/src/pages/api/mcp.ts +1 -1
  107. package/src/pages/book/manage/[token].astro +4 -4
  108. package/src/pages/events/[slug].astro +24 -10
  109. package/src/pages/forms/[id].astro +27 -0
  110. package/src/styles/admin.css +2 -2
  111. package/src/styles/editor.css +275 -34
  112. package/src/styles/form-block.css +38 -0
  113. package/src/styles/forms-admin.css +184 -0
  114. package/src/styles/forms.css +281 -0
  115. package/src/styles/site.css +43 -0
  116. package/src/virtual.d.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.8
4
+
5
+ ### Patch Changes
6
+
7
+ - Read saved email-provider settings on the bookings screen so configured Mailgun and SES connections are recognized. Show actionable Site URL setup warnings separately from provider readiness, report specific booking email preparation errors, and clarify that a successful connection test does not validate email links.
8
+
9
+ Allow HTTP email links for explicitly configured localhost and loopback development URLs, preserving the port. Public sites still require HTTPS.
10
+
11
+ Offer custom booking lengths with hours and minutes, either as one fixed duration or an editable list of choices. Validate lengths before saving and preserve existing duration settings.
12
+
13
+ ## 0.3.7
14
+
15
+ ### Patch Changes
16
+
17
+ - Bound analytics and timezone formatter caches so changing keys cannot accumulate indefinitely in long-lived Node servers. Add cache retention regression tests and an isolated production Node/Docker memory test in the framework repository.
18
+ - Event settings now live on the edit page, in one settings column above the event's content: When, Where, Registration, Organize and Status sections, each with its label and help on the left and roomy controls on the right, plus a sticky index showing each section's current value (a jump bar in Split mode and on phones). Rooms and people to hold are a checklist table, repeats read as one sentence, and Cancelled has its own Status section. The settings sidebar for events is just URL & SEO. Button and RSVP merge into one Registration choice (None / Link / RSVP).
19
+
20
+ Events get maps and saved places. The Where field searches saved places, reads pasted Google, Apple and OpenStreetMap links or Google embed code, and — with a maps provider you configure (`acms({ maps: { provider } })` plus `MAPS_API_KEY`, or `MAPS_URL` for self-hosted Photon/Nominatim/Pelias) — suggests addresses and adds a static map image to event pages. Nothing runs without configuration. Saved places (Calendar settings → Places) fill in an event's location, can be the default for new events, rooms can belong to one, and editing a place updates its upcoming events. Adds migration v43.
21
+
22
+ - Forms. Build reusable forms and templates under Content → Forms, publish them at `/forms/:id` or place them on any page with the Form block, and collect responses.
23
+
24
+ - **Builder:** questions on the left, the live form in the middle, and settings for the selected question on the right. Edit labels and help text right on the form, add questions from a searchable menu (or press `/`), drag to reorder, and use undo/redo. Drafts save automatically, and visitors only see changes after you click Publish changes. On phones, editing happens in a question list with a bottom sheet.
25
+ - **Question types:** short and long text, multiple choice and checkboxes (with an optional "Other" answer), dropdown, email, phone, website, number, date, agree checkbox, and section headings. Fields can be half width, so two sit side by side. Forms saved before this release keep working unchanged.
26
+ - **Responses:** a table with one column per question, search, date range, a side drawer with every answer (including questions you've since removed), bulk export and delete, and a CSV with one row per response.
27
+ - **Share:** public link, embed code, the pages that use the form, and a switch to stop accepting responses. In the post editor, the Form block shows a preview of the actual form.
28
+ - **Event registration:** an event's built-in RSVP form opens in the same builder, full screen. Name, email and seats are always asked; start from a starter or a saved event template, and save your questions as a template for other events.
29
+
3
30
  ## 0.3.6
4
31
 
5
32
  ### Patch Changes
package/integration.mjs CHANGED
@@ -249,6 +249,28 @@ function resolveSearch(search) {
249
249
  return { enabled };
250
250
  }
251
251
 
252
+ // Defaults + validation for acms({ maps }). Maps are bring-your-own: the
253
+ // provider is named here, its key/URL come from MAPS_API_KEY / MAPS_URL at
254
+ // runtime (never serialized into the build). Omitted provider = picked from
255
+ // the environment (lib/maps/config.ts); nothing runs without configuration.
256
+ const MAPS_PROVIDERS = ['geoapify', 'google', 'mapbox', 'locationiq', 'stadia', 'photon', 'nominatim', 'pelias'];
257
+ function resolveMaps(maps) {
258
+ if (maps === undefined || maps === null) return { provider: '' };
259
+ if (typeof maps !== 'object' || Array.isArray(maps)) throw new Error('acms: maps must be an object like { provider: "geoapify" }');
260
+ const unknown = Object.keys(maps).filter((k) => k !== 'provider');
261
+ if (unknown.length > 0) {
262
+ throw new Error(
263
+ `acms: unknown maps key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — ` +
264
+ 'the only valid key is maps.provider (keys go in the MAPS_API_KEY / MAPS_URL environment variables)'
265
+ );
266
+ }
267
+ const provider = maps.provider ?? '';
268
+ if (provider !== '' && !MAPS_PROVIDERS.includes(provider)) {
269
+ throw new Error(`acms: maps.provider must be one of ${MAPS_PROVIDERS.map((p) => `"${p}"`).join(', ')} — got "${provider}"`);
270
+ }
271
+ return { provider };
272
+ }
273
+
252
274
  // Defaults + validation for acms({ calendar }). The calendar module ships on:
253
275
  // a main calendar page at `base`, event pages under `eventsBase`, ICS feeds,
254
276
  // and the admin Calendar section. `calendar: false` (or enabled: false)
@@ -459,6 +481,13 @@ function acmsVitePlugin(userConfig) {
459
481
  * embeds, and the admin Calendar section. `eventFields` adds custom fields
460
482
  * to every event (edited in the event sidebar, exposed to templates and the
461
483
  * API). On by default — `calendar: false` removes all of it.
484
+ * @param {{provider?: 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias'}} [options.maps]
485
+ * Maps for events (bring your own provider): address suggestions in the
486
+ * event editor and a static map image on event pages. Set MAPS_API_KEY
487
+ * (hosted providers) or MAPS_URL (self-hosted Photon/Nominatim/Pelias) in
488
+ * the environment. Without a provider, MAPS_URL picks photon and
489
+ * MAPS_API_KEY picks geoapify; with neither, maps are off — saved places and
490
+ * pasted map links still work.
462
491
  * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
463
492
  * Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
464
493
  * @returns {import('astro').AstroIntegration}
@@ -473,6 +502,7 @@ export default function acms(options = {}) {
473
502
  const search = resolveSearch(options.search);
474
503
  const mobile = resolveMobile(options.mobile);
475
504
  const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
505
+ const maps = resolveMaps(options.maps);
476
506
  const legal = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
477
507
  if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
478
508
  if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
@@ -486,11 +516,17 @@ export default function acms(options = {}) {
486
516
  search,
487
517
  mobile,
488
518
  calendar,
519
+ maps,
489
520
  legal,
490
521
  };
491
522
  const routes = [...ROUTES,
492
523
  ['/api/v1/site', 'api/v1/site.ts'],
493
524
  ['/api/v1/theme', 'api/v1/theme.ts'],
525
+ ['/admin/forms', 'admin/forms/index.astro'],
526
+ ['/admin/forms/[id]', 'admin/forms/[id].astro'],
527
+ ['/admin/forms/[id]/submissions', 'admin/forms/[id]/submissions.astro'],
528
+ ['/admin/forms/[id]/share', 'admin/forms/[id]/share.astro'],
529
+ ['/forms/[id]', 'forms/[id].astro'],
494
530
  ['/api/v1/pages/home', 'api/v1/pages/home.ts'],
495
531
  ['/api/v1/pages/[...slug]', 'api/v1/pages/[...slug].ts'],
496
532
  ['/api/v1/openapi.json', 'api/v1/openapi.json.ts'],
@@ -567,11 +603,12 @@ export default function acms(options = {}) {
567
603
  ['/admin/calendar/calendars', 'admin/calendar/calendars.astro'],
568
604
  ['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
569
605
  ['/api/calendar/docs', 'api/calendar/docs.ts'],
606
+ [`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
607
+ ['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
570
608
  );
571
609
  if (calendar.bookings) {
572
610
  routes.push(
573
611
  [calendar.bookBase, 'book/index.astro'],
574
- [`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
575
612
  [`${calendar.bookBase}/[resource].ics`, 'book/[resource].ics.ts'],
576
613
  [`${calendar.bookBase}/[resource]`, 'book/[resource]/index.astro'],
577
614
  [`${calendar.bookBase}/[resource]/[link]`, 'book/[resource]/[link].astro'],
@@ -583,7 +620,6 @@ export default function acms(options = {}) {
583
620
  ['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
584
621
  ['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
585
622
  ['/api/calendar/resources', 'api/calendar/resources.ts'],
586
- ['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
587
623
  ['/admin/calendar/resources', 'admin/calendar/resources/index.astro'],
588
624
  ['/admin/calendar/resources/[id]', 'admin/calendar/resources/[id].astro'],
589
625
  ['/admin/calendar/resources/[id]/links/[link]', 'admin/calendar/resources/link.astro'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,3 +1,4 @@
1
+ import { FormDefinition } from '../lib/forms/schema';
1
2
  import { pickDesignDraft } from '../lib/drafts';
2
3
  import { ApiError } from '../lib/api';
3
4
  import { validateSectionDesign } from '../lib/design-service';
@@ -36,6 +37,8 @@ import { shareEpoch } from '../lib/drafts';
36
37
  import { createDesignToken } from '../lib/preview';
37
38
  import { getTheme } from '../lib/theme';
38
39
  import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
40
+ import { resolveMapLink, searchPlaces } from '../lib/maps/service';
41
+ import { savePlace } from '../lib/maps/places';
39
42
 
40
43
  // "What changes" for the Design screen: the staged draft vs the live design
41
44
  async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
@@ -67,6 +70,22 @@ const BlockInput = z.object({
67
70
  type: z.string(),
68
71
  config: z.record(z.any()),
69
72
  });
73
+ // an event's Where. The pin fields are optional so older callers that send
74
+ // only name/address/url keep working (unchanged text keeps the pin).
75
+ const Lat = z.number().min(-90).max(90).nullable();
76
+ const Lng = z.number().min(-180).max(180).nullable();
77
+ const LocationInput = z.object({
78
+ name: z.string().max(300),
79
+ address: z.string().max(500),
80
+ url: z.string().max(1000),
81
+ lat: Lat.optional(),
82
+ lng: Lng.optional(),
83
+ placeId: z.string().max(100).optional(),
84
+ showMap: z.boolean().optional(),
85
+ mapImage: z.string().max(500).optional(),
86
+ mapImageDark: z.string().max(500).optional(),
87
+ });
88
+ const mapsCtx = (ctx: ActionAPIContext) => ({ locals: ctx.locals, origin: ctx.url.origin });
70
89
 
71
90
 
72
91
  // ---- calendar helpers ------------------------------------------------------
@@ -105,6 +124,70 @@ async function importEvents(
105
124
  }
106
125
 
107
126
  export const server = {
127
+ forms: {
128
+ list: defineAction({ handler: async (_, ctx) => {
129
+ await requireAdmin(ctx);
130
+ return { forms: await (await getRepo(ctx.locals)).forms.list() };
131
+ } }),
132
+ // Static preview for the post editor's Form block: the published (live)
133
+ // definition rendered with the public field markup.
134
+ preview: defineAction({
135
+ input: z.object({ id: z.string().min(1).max(80) }),
136
+ handler: async (input, ctx) => {
137
+ await requireAdmin(ctx);
138
+ const form = await (await getRepo(ctx.locals)).forms.get(input.id);
139
+ if (!form || form.kind !== 'form') throw new ActionError({ code: 'NOT_FOUND', message: 'Form not found.' });
140
+ const { renderFields } = await import('../lib/forms/fields');
141
+ const d = form.definition;
142
+ return { name: form.name, status: form.status, hasDraft: Boolean(form.draft), title: d.title, intro: d.intro, submitLabel: d.submitLabel || 'Submit', html: renderFields(d, {}, `pv-${form.id.slice(0, 8)}`) };
143
+ },
144
+ }),
145
+ saveTemplate: defineAction({
146
+ input: z.object({ name: z.string().trim().min(1).max(120), definition: z.any(), purpose: z.enum(['event', 'general']) }),
147
+ handler: async (input, ctx) => {
148
+ await requireAdmin(ctx);
149
+ const parsed = FormDefinition.safeParse(input.definition);
150
+ if (!parsed.success) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error.issues[0]?.message || 'Invalid form.' });
151
+ const repo = await getRepo(ctx.locals);
152
+ const form = await repo.forms.create(input.name, parsed.data, 'template', input.purpose);
153
+ return { form };
154
+ },
155
+ }),
156
+ // Builder autosave + publish. `save` on a published form stages a draft;
157
+ // `publish` makes the definition live. The revision guards against edits
158
+ // from another window (409 → reload).
159
+ save: defineAction({
160
+ input: z.object({ id: z.string(), revision: z.number().int(), name: z.string().trim().min(1).max(120), definition: z.any(), op: z.enum(['save', 'publish', 'archive']) }),
161
+ handler: async (input, ctx) => {
162
+ await requireAdmin(ctx);
163
+ const parsed = FormDefinition.safeParse(input.definition);
164
+ if (!parsed.success) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error.issues[0]?.message || 'Invalid form.' });
165
+ const repo = await getRepo(ctx.locals);
166
+ const current = await repo.forms.get(input.id);
167
+ if (!current) throw new ActionError({ code: 'NOT_FOUND', message: 'Form not found.' });
168
+ // Editing a published form back to what's live drops the staged draft
169
+ // (re-publishing an identical definition changes nothing visitors see).
170
+ const backToLive = input.op === 'save' && current.status === 'published' && JSON.stringify(parsed.data) === JSON.stringify(current.definition);
171
+ try { await repo.forms.save(input.id, input.revision, input.name, parsed.data, backToLive ? 'publish' : input.op); } catch (e) { asActionError(e); }
172
+ const form = await repo.forms.get(input.id);
173
+ if (!form) throw new ActionError({ code: 'NOT_FOUND', message: 'Form not found.' });
174
+ return { revision: form.revision, status: form.status, hasDraft: Boolean(form.draft) };
175
+ },
176
+ }),
177
+ // "Use template" from a general template's Build page: an independent form copy.
178
+ useTemplate: defineAction({
179
+ input: z.object({ id: z.string(), name: z.string().trim().min(1).max(120).optional() }),
180
+ handler: async (input, ctx) => {
181
+ await requireAdmin(ctx);
182
+ const repo = await getRepo(ctx.locals);
183
+ const source = await repo.forms.get(input.id);
184
+ if (!source || source.kind !== 'template' || source.purpose !== 'general') throw new ActionError({ code: 'NOT_FOUND', message: 'Template not found.' });
185
+ const form = await repo.forms.create(input.name || source.name, source.draft ?? source.definition, 'form', 'general');
186
+ return { id: form.id };
187
+ },
188
+ }),
189
+ },
190
+
108
191
  posts: {
109
192
  save: defineAction({
110
193
  input: z.object({
@@ -883,11 +966,11 @@ export const server = {
883
966
  rrule: z.string().max(500).optional(),
884
967
  exdates: z.array(z.string()).max(500).optional(),
885
968
  cancelled: z.boolean().optional(),
886
- location: z.object({ name: z.string().max(300), address: z.string().max(500), url: z.string().max(1000) }).optional(),
969
+ location: LocationInput.optional(),
887
970
  cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
888
971
  calendarIds: z.array(z.string()).max(50).optional(),
889
972
  fields: z.record(z.string().max(2000)).optional(),
890
- rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).optional(),
973
+ rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50), form: z.any().optional() }).optional(),
891
974
  roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
892
975
  scheduledAt: nullableTs.optional(),
893
976
  }),
@@ -1186,6 +1269,67 @@ export const server = {
1186
1269
  }),
1187
1270
  },
1188
1271
 
1272
+ // Maps (bring your own provider — lib/maps/config.ts). Keys never leave
1273
+ // the server; with nothing configured, search returns [] and resolve still
1274
+ // parses links.
1275
+ maps: {
1276
+ search: defineAction({
1277
+ input: z.object({ q: z.string().max(200), near: z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) }).optional() }),
1278
+ handler: async ({ q, near }, ctx) => {
1279
+ await requireAdmin(ctx);
1280
+ try { return { results: await searchPlaces(q, near, mapsCtx(ctx)) }; }
1281
+ catch (err) {
1282
+ console.warn('[acms maps] search failed:', err instanceof Error ? err.message : err);
1283
+ throw new ActionError({ code: 'BAD_GATEWAY', message: 'Address suggestions aren’t answering right now.' });
1284
+ }
1285
+ },
1286
+ }),
1287
+ // a pasted map link / embed code / "lat, lng" → location (short links
1288
+ // are expanded server-side through Google's hosts only)
1289
+ resolve: defineAction({
1290
+ input: z.object({ url: z.string().max(10_000) }),
1291
+ handler: async ({ url }, ctx) => {
1292
+ await requireAdmin(ctx);
1293
+ return { location: await resolveMapLink(url, mapsCtx(ctx)) };
1294
+ },
1295
+ }),
1296
+ },
1297
+
1298
+ places: {
1299
+ list: defineAction({
1300
+ handler: async (_input, ctx) => {
1301
+ await requireAdmin(ctx);
1302
+ return { places: await (await getRepo(ctx.locals)).listPlaces() };
1303
+ },
1304
+ }),
1305
+ // create (no id) or update; refreshes upcoming events that use the place
1306
+ save: defineAction({
1307
+ input: z.object({
1308
+ id: z.string().max(100).optional(),
1309
+ name: z.string().max(300),
1310
+ address: z.string().max(500).default(''),
1311
+ url: z.string().max(1000).default(''),
1312
+ lat: Lat.default(null),
1313
+ lng: Lng.default(null),
1314
+ notes: z.string().max(2000).default(''),
1315
+ isDefault: z.boolean().default(false),
1316
+ }),
1317
+ handler: async (input, ctx) => {
1318
+ await requireAdmin(ctx);
1319
+ const repo = await getRepo(ctx.locals);
1320
+ try { return await savePlace(repo, input, mapsCtx(ctx)); } catch (e) { asActionError(e); }
1321
+ },
1322
+ }),
1323
+ delete: defineAction({
1324
+ input: z.object({ id: z.string().max(100) }),
1325
+ handler: async ({ id }, ctx) => {
1326
+ await requireAdmin(ctx);
1327
+ await (await getRepo(ctx.locals)).deletePlace(id);
1328
+ return { ok: true };
1329
+ },
1330
+ }),
1331
+ },
1332
+
1189
1333
  unsplash: {
1190
1334
  search: defineAction({
1191
1335
  input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
@@ -278,6 +278,12 @@ export default function EmailSettings() {
278
278
 
279
279
  return (
280
280
  <div className="ems">
281
+ {data.linkError && !savedOff && (
282
+ <p className="notice warn" role="status">
283
+ {data.linkError} A connection test can succeed before this is set.{' '}
284
+ <a href="/admin/settings#site">Open site settings</a>
285
+ </p>
286
+ )}
281
287
  <div className="seg ems-tabs" role="tablist" aria-label="Email settings">
282
288
  {TABS.map((t, i) => (
283
289
  <button key={t.id} ref={(el) => { tabRefs.current[i] = el; }} type="button" role="tab" id={id(`tab-${t.id}`)}
@@ -9,6 +9,7 @@ import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Oc
9
9
  import { describeRepeat } from '../lib/calendar/rrule';
10
10
  import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
11
  import { siteLocale } from '../lib/dates';
12
+ import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
12
13
 
13
14
  interface Props {
14
15
  event: CalEvent;
@@ -20,7 +21,7 @@ interface Props {
20
21
  origin: string;
21
22
  preview?: boolean;
22
23
  // RSVP state when the event takes RSVPs (null otherwise)
23
- rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean } | null;
24
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
24
25
  }
25
26
  const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
26
27
  const tz = eventTimeZone(e, settings);
@@ -38,6 +39,12 @@ const mapHref = e.location.url
38
39
  : e.location.address
39
40
  ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([e.location.name, e.location.address].filter(Boolean).join(', '))}`
40
41
  : '';
42
+ // static map (made when the event was saved; see lib/maps/service.ts)
43
+ const loc = e.location;
44
+ const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
45
+ const directions = showMap ? safeHref(directionsUrl(loc)) : '';
46
+ const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
47
+ const mapLabel = loc.name || loc.address || 'the location';
41
48
  const fieldRows = CALENDAR.eventFields.filter((fd) => e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
42
49
  const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
43
50
  const I = {
@@ -95,6 +102,15 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
95
102
  </div>
96
103
  </div>
97
104
  )}
105
+ {showMap && (
106
+ <figure class="pc-ev-map">
107
+ <a href={directions || undefined} target="_blank" rel="noopener" aria-label={`Directions to ${mapLabel}`}>
108
+ <img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
109
+ {loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
110
+ </a>
111
+ {mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
112
+ </figure>
113
+ )}
98
114
  {repeats && (
99
115
  <div class="pc-ev-fact">
100
116
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
@@ -114,15 +130,19 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
114
130
  <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
115
131
  <summary class="pc-ev-cta">Save my seat</summary>
116
132
  <form method="post">
133
+ <Fragment set:html={rsvp.formHtml ?? ''}/>
134
+ {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
135
+ {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
117
136
  {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
118
- <label class="pc-bk-field"><span>Your name</span><input name="name" required autocomplete="name" /></label>
119
- <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required autocomplete="email" /></label>
137
+ <label class="pc-bk-field"><span>Your name</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
138
+ <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
120
139
  {rsvp.maxSeats > 1 && (
121
140
  <label class="pc-bk-field"><span>Seats</span>
122
- <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1}>{i + 1}</option>)}</select>
141
+ <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
123
142
  </label>
124
143
  )}
125
- <button class="pc-bk-submit" type="submit">{e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat'}</button>
144
+ <Fragment set:html={rsvp.fieldsHtml ?? ''}/>
145
+ <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat')}</button>
126
146
  </form>
127
147
  </details>
128
148
  ) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
@@ -0,0 +1,156 @@
1
+ ---
2
+ // Booking lengths as Hours + Minutes. "One length" is a single pair; "Several
3
+ // lengths" is a list of pairs people pick from (add/remove rows, plus a few
4
+ // common presets). Each block carries data-mode so the page can show/disable
5
+ // it with the mode switch. Rows are raw text so a failed save shows what was typed.
6
+ import type { DurationRow } from '../../lib/booking/forms';
7
+ import { describeLength } from '../../lib/booking/availability';
8
+ interface Props { single: DurationRow; singleError?: string; several: DurationRow[]; severalError?: string }
9
+ const { single, singleError = '', several, severalError = '' } = Astro.props;
10
+ const PRESETS = [30, 60, 90, 120, 150, 180, 240];
11
+ const rows = several.length ? several : [{ h: '1', m: '0' }];
12
+ ---
13
+
14
+ <div class="dur" data-mode="slots" data-dur-single>
15
+ <fieldset aria-describedby={singleError ? 'dur-one-err' : undefined}>
16
+ <legend>Length</legend>
17
+ <div class="dur-row">
18
+ <label class="dur-part"><input type="number" name="durH" min="0" max="24" step="1" inputmode="numeric" value={single.h} aria-invalid={singleError ? 'true' : undefined} /> <span>hours</span></label>
19
+ <label class="dur-part"><input type="number" name="durM" min="0" max="59" step="1" inputmode="numeric" value={single.m} aria-invalid={singleError ? 'true' : undefined} /> <span>min</span></label>
20
+ </div>
21
+ {singleError && <p class="dur-err" id="dur-one-err" role="alert">{singleError}</p>}
22
+ <div class="dur-presets" role="group" aria-label="Common lengths">
23
+ {PRESETS.map((n) => <button type="button" class="btn sm" data-preset={n}>{describeLength(n)}</button>)}
24
+ </div>
25
+ </fieldset>
26
+ </div>
27
+
28
+ <div class="dur" data-mode="range" data-dur-several>
29
+ <fieldset aria-describedby={`dur-many-hint${severalError ? ' dur-many-err' : ''}`}>
30
+ <legend>Lengths people can pick</legend>
31
+ <ol class="dur-list" data-dur-list>
32
+ {rows.map((r, i) => (
33
+ <li class="dur-item">
34
+ <div class="dur-row">
35
+ <label class="dur-part"><input type="number" name="lenH" min="0" max="24" step="1" inputmode="numeric" value={r.h} aria-label={`Length ${i + 1}, hours`} aria-invalid={r.error ? 'true' : undefined} aria-describedby={r.error ? `dur-row-err-${i}` : undefined} /> <span aria-hidden="true">hours</span></label>
36
+ <label class="dur-part"><input type="number" name="lenM" min="0" max="59" step="1" inputmode="numeric" value={r.m} aria-label={`Length ${i + 1}, minutes`} aria-invalid={r.error ? 'true' : undefined} aria-describedby={r.error ? `dur-row-err-${i}` : undefined} /> <span aria-hidden="true">min</span></label>
37
+ <button type="button" class="icon-btn" data-dur-remove aria-label={`Remove length ${i + 1}`} title="Remove" disabled={rows.length === 1}>×</button>
38
+ </div>
39
+ {r.error && <p class="dur-err" id={`dur-row-err-${i}`}>{r.error}</p>}
40
+ </li>
41
+ ))}
42
+ </ol>
43
+ <template data-dur-row>
44
+ <li class="dur-item">
45
+ <div class="dur-row">
46
+ <label class="dur-part"><input type="number" name="lenH" min="0" max="24" step="1" inputmode="numeric" /> <span aria-hidden="true">hours</span></label>
47
+ <label class="dur-part"><input type="number" name="lenM" min="0" max="59" step="1" inputmode="numeric" /> <span aria-hidden="true">min</span></label>
48
+ <button type="button" class="icon-btn" data-dur-remove title="Remove">×</button>
49
+ </div>
50
+ </li>
51
+ </template>
52
+ {severalError && <p class="dur-err" id="dur-many-err" role="alert">{severalError}</p>}
53
+ <div class="dur-presets" role="group" aria-label="Add a common length">
54
+ <button type="button" class="btn sm" data-dur-add>+ Add a length</button>
55
+ {PRESETS.map((n) => <button type="button" class="btn sm ghost" data-preset={n}>+ {describeLength(n)}</button>)}
56
+ </div>
57
+ <p class="hint" id="dur-many-hint">People pick one of these when they book. Duplicates are merged and the list is sorted when you save.</p>
58
+ </fieldset>
59
+ </div>
60
+
61
+ <style>
62
+ .dur { margin-bottom: 14px; }
63
+ .dur fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
64
+ .dur legend { padding: 0; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
65
+ .dur-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
66
+ .dur-part { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
67
+ .dur-part input { width: 84px; }
68
+ .dur [aria-invalid='true'] { border-color: var(--red); }
69
+ .dur-err { color: var(--red); font-size: 12.5px; margin: 5px 0 0; }
70
+ .dur-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
71
+ .dur-item .icon-btn:disabled { opacity: 0.35; cursor: default; }
72
+ .dur-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
73
+ .dur .hint { margin: 8px 0 0; }
74
+ @media (max-width: 480px) {
75
+ .dur-part input { width: 72px; }
76
+ }
77
+ </style>
78
+
79
+ <script>
80
+ const minutesOf = (h: string, m: string) => (Number(h) || 0) * 60 + (Number(m) || 0);
81
+ const clearError = (scope: Element) => {
82
+ scope.querySelectorAll('[aria-invalid]').forEach((i) => i.removeAttribute('aria-invalid'));
83
+ scope.querySelectorAll('.dur-err').forEach((p) => p.remove());
84
+ };
85
+
86
+ document.querySelectorAll<HTMLElement>('[data-dur-single]').forEach((root) => {
87
+ const [h, m] = root.querySelectorAll<HTMLInputElement>('input[type=number]');
88
+ root.addEventListener('input', () => clearError(root));
89
+ root.addEventListener('click', (e) => {
90
+ const b = (e.target as Element).closest<HTMLElement>('[data-preset]');
91
+ if (!b) return;
92
+ const n = Number(b.dataset.preset);
93
+ h.value = String(Math.floor(n / 60));
94
+ m.value = String(n % 60);
95
+ clearError(root);
96
+ h.dispatchEvent(new Event('change', { bubbles: true }));
97
+ });
98
+ });
99
+
100
+ document.querySelectorAll<HTMLElement>('[data-dur-several]').forEach((root) => {
101
+ const list = root.querySelector<HTMLOListElement>('[data-dur-list]')!;
102
+ const tpl = root.querySelector<HTMLTemplateElement>('template[data-dur-row]')!;
103
+ const addBtn = root.querySelector<HTMLButtonElement>('[data-dur-add]')!;
104
+ const items = () => [...list.querySelectorAll<HTMLLIElement>(':scope > li')];
105
+ const parts = (li: Element) => li.querySelectorAll<HTMLInputElement>('input[type=number]');
106
+ const renumber = () => {
107
+ const all = items();
108
+ all.forEach((li, i) => {
109
+ const [h, m] = parts(li);
110
+ h.setAttribute('aria-label', `Length ${i + 1}, hours`);
111
+ m.setAttribute('aria-label', `Length ${i + 1}, minutes`);
112
+ const x = li.querySelector<HTMLButtonElement>('[data-dur-remove]')!;
113
+ x.setAttribute('aria-label', `Remove length ${i + 1}`);
114
+ x.disabled = all.length === 1;
115
+ });
116
+ };
117
+ const add = (n: number) => {
118
+ const li = tpl.content.firstElementChild!.cloneNode(true) as HTMLLIElement;
119
+ const [h, m] = parts(li);
120
+ h.value = String(Math.floor(n / 60));
121
+ m.value = String(n % 60);
122
+ list.append(li);
123
+ renumber();
124
+ return li;
125
+ };
126
+ list.addEventListener('input', (e) => { const li = (e.target as Element).closest('li'); if (li) clearError(li); });
127
+ root.addEventListener('click', (e) => {
128
+ const t = e.target as Element;
129
+ if (t.closest('[data-dur-add]')) {
130
+ const last = items().at(-1);
131
+ const prev = last ? minutesOf(parts(last)[0].value, parts(last)[1].value) : 0;
132
+ // a new row starts an hour past the last one (or at 1 h)
133
+ add(prev ? Math.min(1440, prev + 60) : 60).querySelector('input')!.focus();
134
+ return;
135
+ }
136
+ const preset = t.closest<HTMLElement>('[data-preset]');
137
+ if (preset) {
138
+ const n = Number(preset.dataset.preset);
139
+ const hit = items().find((li) => minutesOf(parts(li)[0].value, parts(li)[1].value) === n);
140
+ (hit ?? add(n)).querySelector('input')!.focus();
141
+ return;
142
+ }
143
+ const rm = t.closest<HTMLButtonElement>('[data-dur-remove]');
144
+ if (rm && !rm.disabled) {
145
+ const all = items();
146
+ const i = all.indexOf(rm.closest('li')!);
147
+ all[i].remove();
148
+ renumber();
149
+ const next = items()[Math.min(i, items().length - 1)];
150
+ (next?.querySelector<HTMLButtonElement>('[data-dur-remove]:not(:disabled)') ?? addBtn).focus();
151
+ const msg = root.querySelector('#dur-many-err');
152
+ if (msg && !list.querySelector('[aria-invalid]')) msg.remove();
153
+ }
154
+ });
155
+ });
156
+ </script>
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { bookingsEnabled } from '../../lib/calendar/config';
2
3
  // Shared header for the Bookings home: Requests (what came in) and Rooms &
3
4
  // people (what can be booked) are two tabs of one place, with the same ⚙ and
4
5
  // "New ▾" as the Schedule. Extra page actions go in the default slot.
@@ -22,7 +23,7 @@ const gear = 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.
22
23
  <div class="page-actions">
23
24
  <slot />
24
25
  <a class="btn ico" href="/admin/calendar/settings#bookings" title="Calendar settings" aria-label="Calendar settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={gear} /></svg></a>
25
- <details class="newmenu">
26
+ {bookingsEnabled() && <details class="newmenu">
26
27
  <summary class="btn primary">New <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 9l6 6 6-6" /></svg></summary>
27
28
  <div class="newmenu-pop" role="menu">
28
29
  {inPlace
@@ -35,12 +36,12 @@ const gear = 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.
35
36
  ? <button type="button" role="menuitem" data-modal="new-resource">Room or person</button>
36
37
  : <a role="menuitem" href="/admin/calendar/resources?new=1">Room or person</a>}
37
38
  </div>
38
- </details>
39
+ </details>}
39
40
  </div>
40
41
  </div>
41
42
  <nav class="ptabs" aria-label="Bookings">
42
43
  <a href="/admin/calendar/bookings" aria-current={active === 'requests' ? 'page' : undefined}>Requests{pending > 0 && <small class="attn">{pending} {pending === 1 ? 'needs' : 'need'} approval</small>}</a>
43
- <a href="/admin/calendar/resources" aria-current={active === 'rooms' ? 'page' : undefined}>Rooms &amp; people<small>{rooms}</small></a>
44
+ {bookingsEnabled() && <a href="/admin/calendar/resources" aria-current={active === 'rooms' ? 'page' : undefined}>Rooms &amp; people<small>{rooms}</small></a>}
44
45
  </nav>
45
46
 
46
47
  <script>
@@ -78,8 +78,8 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
78
78
  };
79
79
  const label = (r: RoomRow) => {
80
80
  if (!r.conflicts.length) return { text: 'Free', cls: 'free' };
81
- if (r.dates > 1) return { text: `Booked on ${r.datesWithBookings} of ${r.dates} dates`, cls: 'busy' };
82
- return { text: `${r.conflicts.length} booking${r.conflicts.length === 1 ? '' : 's'}`, cls: 'busy' };
81
+ if (r.dates > 1) return { text: `Busy on ${r.datesWithBookings} of ${r.dates} dates`, cls: 'busy' };
82
+ return { text: r.conflicts.length === 1 ? 'Busy' : `Busy · ${r.conflicts.length} bookings`, cls: 'busy' };
83
83
  };
84
84
  const conflicted = data.rooms.filter((r) => selected.has(r.id) && r.conflicts.length);
85
85
  const draft = status === 'draft';
@@ -95,16 +95,21 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
95
95
  }
96
96
 
97
97
  return (
98
- <section className="pe-sb-section pe-ev rh">
99
- <h3 className="pe-sb-title">Rooms</h3>
100
- <p className="pe-ev-note">Rooms and people this event uses can't be booked while it happens.</p>
101
- <div className="rh-list">
98
+ <section className="rh" aria-labelledby="rh-title">
99
+ <div className="rh-head">
100
+ <b id="rh-title">Hold rooms &amp; people</b>
101
+ <small>can’t be booked while this runs</small>
102
+ </div>
103
+ <div className="rh-table" role="group" aria-labelledby="rh-title">
104
+ <div className="rh-tr rh-th" aria-hidden="true"><span /><span>Name</span><span>Capacity</span><span>Status</span></div>
102
105
  {data.rooms.map((r) => {
103
106
  const l = label(r);
107
+ const kind = r.kind.trim().toLowerCase();
104
108
  return (
105
- <label key={r.id} className={`rh-room${selected.has(r.id) ? ' on' : ''}`}>
109
+ <label key={r.id} className={`rh-tr rh-room${selected.has(r.id) ? ' on' : ''}`}>
106
110
  <input type="checkbox" checked={selected.has(r.id)} onChange={() => toggle(r)} />
107
- <span className="rh-name"><b>{r.name}</b><small>{r.kind}{r.capacity ? ` · up to ${r.capacity}` : ''}</small></span>
111
+ <span className="rh-name"><b>{r.name}</b>{kind && kind !== 'room' && <small>· {kind}</small>}</span>
112
+ <span className="rh-cap">{r.capacity ? <><span className="rh-cap-l">up to </span>{r.capacity}</> : '—'}</span>
108
113
  <span className={`rh-st ${l.cls}`}>{l.text}</span>
109
114
  </label>
110
115
  );