@acms-subir/core 0.3.52 → 0.3.54

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 (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +18 -1
  3. package/docs/calendar.md +20 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/server.md +61 -1
  6. package/integration.mjs +11 -5
  7. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  8. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  9. package/package.json +1 -1
  10. package/src/components/ThemeSections.astro +2 -2
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/lib/api.ts +3 -0
  22. package/src/lib/backup/content.ts +4 -2
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/calendar/host-requests.ts +247 -0
  25. package/src/lib/calendar/member-form.ts +17 -11
  26. package/src/lib/calendar/member-words.ts +473 -0
  27. package/src/lib/calendar/members-config.ts +2 -2
  28. package/src/lib/calendar/moves.ts +81 -33
  29. package/src/lib/calendar/request-migration.ts +17 -0
  30. package/src/lib/calendar/request-store.ts +104 -0
  31. package/src/lib/calendar/requests.ts +24 -24
  32. package/src/lib/dynamicBlocks.ts +4 -2
  33. package/src/lib/email/service.ts +12 -2
  34. package/src/lib/email/templates.ts +12 -2
  35. package/src/lib/forms/fields.ts +14 -9
  36. package/src/lib/forms/localize.ts +68 -0
  37. package/src/lib/forms/render.ts +8 -2
  38. package/src/lib/forms/schema.ts +31 -2
  39. package/src/lib/forms/service.ts +9 -1
  40. package/src/lib/forms/validate.ts +16 -4
  41. package/src/lib/forms/words.ts +32 -0
  42. package/src/lib/mobile/forms.ts +45 -13
  43. package/src/lib/mobile/openapi.ts +3 -3
  44. package/src/lib/mobile/rsvp.ts +4 -2
  45. package/src/lib/mobile/schemas.ts +3 -1
  46. package/src/lib/repo/migrations.ts +4 -0
  47. package/src/lib/repo/sql.ts +7 -0
  48. package/src/lib/repo/types.ts +6 -1
  49. package/src/lib/schema.ts +47 -0
  50. package/src/pages/[...slug].astro +1 -1
  51. package/src/pages/admin/calendar/events/index.astro +82 -6
  52. package/src/pages/admin/forms/[id].astro +6 -0
  53. package/src/pages/admin/index.astro +6 -2
  54. package/src/pages/blog/[slug].astro +1 -1
  55. package/src/pages/events/[slug].astro +9 -3
  56. package/src/pages/forms/[id].astro +22 -4
  57. package/src/pages/index.astro +1 -1
  58. package/src/pages/members/_EventForm.astro +33 -27
  59. package/src/pages/members/_Layout.astro +25 -5
  60. package/src/pages/members/_http.ts +29 -6
  61. package/src/pages/members/events/[id].astro +21 -20
  62. package/src/pages/members/events/index.astro +295 -101
  63. package/src/pages/members/events/new.astro +11 -10
  64. package/src/server/index.ts +53 -0
  65. package/src/styles/forms.css +8 -0
  66. package/src/virtual.d.ts +1 -1
package/src/lib/schema.ts CHANGED
@@ -395,6 +395,53 @@ export const EventRequest = z.object({
395
395
  });
396
396
  export type EventRequest = z.infer<typeof EventRequest>;
397
397
 
398
+ // A request about the calendar kept on its own row (table event_requests,
399
+ // lib/calendar/request-store.ts), unlike the member requests above that
400
+ // stage an event draft:
401
+ // - 'new': a new event from anyone, without an account (source 'app' or 'web').
402
+ // Approving makes a draft event and stores its id in `eventId`.
403
+ // - 'change': anyone asks for a change to an event (`eventId`, one date in
404
+ // `key` or '' for the whole event); `note` says what should change.
405
+ // - 'move' / 'from-date': a host's move of one date, or a change from a date
406
+ // on (C3/C4); `data` holds the move.
407
+ // `userId` is the member who asked; `requester` is the person without an
408
+ // account (never both). `note` is their own words to staff, `reply` staff's
409
+ // last answer. Decided rows (and withdrawn ones, which set reviewedAt too)
410
+ // are deleted some days after `reviewedAt` (CalendarSettings.requests).
411
+ export const RequestKind = z.enum(['new', 'change', 'move', 'from-date']);
412
+ export type RequestKind = z.infer<typeof RequestKind>;
413
+ export const RequestState = z.enum(['pending', 'approved', 'declined', 'withdrawn']);
414
+ export type RequestState = z.infer<typeof RequestState>;
415
+ export const RequestSource = z.enum(['member', 'web', 'app']);
416
+ export type RequestSource = z.infer<typeof RequestSource>;
417
+ export const Requester = z.object({
418
+ name: z.string(),
419
+ email: z.string(),
420
+ phone: z.string().default(''),
421
+ // the language they asked in, for replies ('' = the main language)
422
+ locale: z.string().default(''),
423
+ });
424
+ export type Requester = z.infer<typeof Requester>;
425
+ export const RequestRecord = z.object({
426
+ id: z.string(),
427
+ kind: RequestKind,
428
+ state: RequestState.default('pending'),
429
+ source: RequestSource,
430
+ userId: z.string().default(''),
431
+ requester: Requester.nullable().default(null),
432
+ eventId: z.string().default(''),
433
+ // an occurrence key (same format as exdates) when it's about one date
434
+ key: z.string().default(''),
435
+ data: z.record(z.any()).default({}),
436
+ note: z.string().default(''),
437
+ reply: z.string().default(''),
438
+ reviewedBy: z.string().default(''),
439
+ reviewedAt: z.number().default(0),
440
+ createdAt: z.number(),
441
+ updatedAt: z.number(),
442
+ });
443
+ export type RequestRecord = z.infer<typeof RequestRecord>;
444
+
398
445
  export const CalEvent = z.object({
399
446
  id: z.string(),
400
447
  slug: z.string(),
@@ -22,7 +22,7 @@ const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astr
22
22
  if (!page || (page.status !== 'published' && !isAdmin)) {
23
23
  return new Response(null, { status: 404, statusText: 'Not found' });
24
24
  }
25
- const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
25
+ const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo, locale)) : '';
26
26
  // ?designPreview=1 (admin-only) renders the staged Design-screen changes
27
27
  const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
28
28
  const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
@@ -22,6 +22,7 @@ import { invalidateRedirectCache } from '../../../../middleware';
22
22
  import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
23
23
  import type { Permission } from '../../../../lib/permissions/catalog';
24
24
  import { approveEventRequest, declineEventRequest, pendingRequests } from '../../../../lib/calendar/requests';
25
+ import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
25
26
  import { mergeEventDraft } from '../../../../lib/drafts';
26
27
  import { ApiError } from '../../../../lib/api';
27
28
 
@@ -72,7 +73,7 @@ if (Astro.request.method === 'POST') {
72
73
  // create, copy and import make drafts; bulk status changes that touch live events need Publish
73
74
  let needs: Permission = 'content.edit';
74
75
  // answering members' event requests needs Publish (approving puts them live)
75
- if (op === 'approve' || op === 'decline') needs = 'content.publish';
76
+ if (op === 'approve' || op === 'decline' || op === 'approve-date' || op === 'decline-date') needs = 'content.publish';
76
77
  if (op === 'bulk') {
77
78
  const to = String(form.get('to'));
78
79
  const before = await Promise.all(form.getAll('ids').map(String).filter(Boolean).map((id) => repo.getEvent(id)));
@@ -81,7 +82,7 @@ if (Astro.request.method === 'POST') {
81
82
  const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
82
83
  if (denied) return denied;
83
84
 
84
- if (op === 'approve' || op === 'decline') {
85
+ if (op === 'approve' || op === 'decline' || op === 'approve-date' || op === 'decline-date') {
85
86
  const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
86
87
  const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
87
88
  const hooks = { url: Astro.url, locals: Astro.locals };
@@ -93,6 +94,17 @@ if (Astro.request.method === 'POST') {
93
94
  return Astro.redirect(`/admin/calendar/events?${p}`);
94
95
  };
95
96
  try {
97
+ // a host's date move (host-requests.ts): `id` is the request's
98
+ if (op === 'approve-date') {
99
+ const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
100
+ const n = moved.clashes.length;
101
+ const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
102
+ return toRequests('Approved. “' + moved.event.title + '” has the date moved' + shared + '.');
103
+ }
104
+ if (op === 'decline-date') {
105
+ await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
106
+ return toRequests('Declined. The date stays as it was.');
107
+ }
96
108
  const before = await repo.getEvent(id);
97
109
  if (op === 'approve') {
98
110
  const e = await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
@@ -182,8 +194,9 @@ if (Astro.request.method === 'POST') {
182
194
  }
183
195
  }
184
196
 
185
- const [events, calendars, requests, users, access] = await Promise.all([
197
+ const [events, calendars, requests, users, access, waitingDates] = await Promise.all([
186
198
  repo.listEvents(), repo.listCalendars(), pendingRequests(repo), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo),
199
+ repo.requests.list({ kinds: ['move', 'from-date'], states: ['pending'], order: 'oldest', limit: -1 }),
187
200
  ]);
188
201
  const canApprove = access?.can('content.publish') ?? false;
189
202
  const calById = new Map(calendars.map((c) => [c.id, c]));
@@ -201,7 +214,7 @@ const counts = {
201
214
  upcoming: rows.filter(isUpcoming).length,
202
215
  past: rows.filter((r) => !isUpcoming(r)).length,
203
216
  drafts: rows.filter((r) => r.e.status === 'draft').length,
204
- requests: requests.length,
217
+ requests: requests.length + waitingDates.length,
205
218
  all: rows.length,
206
219
  };
207
220
  const selected = rows
@@ -278,6 +291,26 @@ const requestRows = scope !== 'requests' ? [] : requests
278
291
  };
279
292
  });
280
293
 
294
+ // hosts' date moves waiting (scope=requests): what moves where, and what
295
+ // else holds its rooms then (C2 clashes; names are fine for staff)
296
+ const eventById = new Map(events.map((e) => [e.id, e]));
297
+ const roomNames = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
298
+ const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
299
+ .filter((r) => eventById.has(r.eventId))
300
+ .filter((r) => { const e = eventById.get(r.eventId)!; return (!calFilter || e.calendarIds.includes(calFilter)) && (!q || `${e.title} ${e.slug}`.toLowerCase().includes(q)); })
301
+ .map(async (r) => {
302
+ const e = eventById.get(r.eventId)!;
303
+ const d = r.data as MoveRequestData;
304
+ const host = nameOf.get(r.userId) ?? 'a host';
305
+ const clashes = await dateRequestClashes(repo, r);
306
+ const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
307
+ return {
308
+ id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
309
+ ...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
310
+ line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
311
+ };
312
+ }));
313
+
281
314
  // feed calendars: the next 60 days as read-only rows (upcoming/all scopes)
282
315
  const feedErrors: string[] = [];
283
316
  if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
@@ -464,8 +497,51 @@ const subBits = [
464
497
  ))}
465
498
  </section>
466
499
  )}
467
- {requestRows.length > 0 && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
468
- {requestRows.length === 0 && (
500
+ {dateRows.length > 0 && (
501
+ <section class="card bk-group">
502
+ <div class="bk-dayh"><b>Date changes from hosts</b><span>{dateRows.length}</span></div>
503
+ {dateRows.map((r) => (
504
+ <div class="bk-row ev-req">
505
+ <a class="bk-when" href={r.href}>{r.from || 'No date'}<small>{r.to ? '→ ' + r.to : ''}</small></a>
506
+ <a class="bk-who" href={r.href}>
507
+ <b>{r.title}</b>
508
+ <small>{r.line}</small>
509
+ {r.note && <small class="ev-req-note">“{r.note}”</small>}
510
+ {r.publicNote && <small>Note on the date: {r.publicNote}</small>}
511
+ {r.clash && <small class="ev-req-clash">{r.clash}</small>}
512
+ </a>
513
+ <span class="bk-st is-pending">{r.back ? 'Move back' : 'Date move'}</span>
514
+ <div class="bk-acts">
515
+ <a class="btn sm ghost" href={r.href}>Open</a>
516
+ {canApprove && (
517
+ <>
518
+ <button class="btn sm ghost" type="button" data-modal={`decline-date-${r.id}`}>Decline…</button>
519
+ <form method="post">
520
+ <input type="hidden" name="op" value="approve-date" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
521
+ <button class="btn sm" type="submit">Approve</button>
522
+ </form>
523
+ </>
524
+ )}
525
+ </div>
526
+ </div>
527
+ ))}
528
+ </section>
529
+ )}
530
+ {(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
531
+ {canApprove && dateRows.map((r) => (
532
+ <Modal id={`decline-date-${r.id}`} title="Decline this date change">
533
+ <form method="post">
534
+ <input type="hidden" name="op" value="decline-date" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
535
+ <p class="hint">The date stays as it is, and {r.host} hears that it wasn’t approved.</p>
536
+ <label class="field"><span>Note to {r.host} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="That room is booked for the youth night. Could the week after work?"></textarea></label>
537
+ <div class="modal-foot">
538
+ <button class="btn ghost" type="button" data-close>Cancel</button>
539
+ <button class="btn primary" type="submit">Decline</button>
540
+ </div>
541
+ </form>
542
+ </Modal>
543
+ ))}
544
+ {requestRows.length === 0 && dateRows.length === 0 && (
469
545
  <p class="hint ev-req-empty">{counts.requests ? 'No requests match the current filters.' : 'Nothing waiting. When members ask for events or suggest changes, they show up here.'}</p>
470
546
  )}
471
547
  {canApprove && requestRows.map((r) => (
@@ -3,14 +3,18 @@ import Admin from '../../../layouts/Admin.astro';
3
3
  import FormHeader from '../../../components/forms/FormHeader.astro';
4
4
  import FormBuildPage from '../../../components/forms/FormBuildPage';
5
5
  import { getRepo } from '../../../lib/repo';
6
+ import { I18N, extraLocales, localeName } from '../../../lib/i18n';
6
7
  import '../../../styles/forms.css';
7
8
  // Build tab. Edits autosave as a draft through the `forms.save` action;
8
9
  // "Publish changes" makes them live. See components/forms/FormBuildPage.tsx.
10
+ // With more than one site language, a switcher writes the form's words in each.
9
11
  const repo = await getRepo(Astro.locals);
10
12
  const form = await repo.forms.get(Astro.params.id || '');
11
13
  if (!form) return new Response(null, { status: 404 });
12
14
  Astro.response.headers.set('Cache-Control', 'private, no-store');
13
15
  const responses = form.kind === 'form' && form.purpose === 'general' ? (await repo.forms.submissions(form.id, 1)).length : 0;
16
+ const languages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
17
+ const mainLanguage = { code: I18N.defaultLocale || 'en', name: localeName(I18N.defaultLocale || 'en', true) };
14
18
  ---
15
19
  <Admin title={form.name} section="forms" wide>
16
20
  <FormHeader form={form} active="build">
@@ -23,5 +27,7 @@ const responses = form.kind === 'form' && form.purpose === 'general' ? (await re
23
27
  form={{ id: form.id, name: form.name, kind: form.kind, purpose: form.purpose, status: form.status, revision: form.revision, hasDraft: Boolean(form.draft) }}
24
28
  definition={form.draft ?? form.definition}
25
29
  responses={responses}
30
+ languages={languages}
31
+ mainLanguage={mainLanguage}
26
32
  />
27
33
  </Admin>
@@ -29,9 +29,13 @@ const access = await accessFor(Astro.request, Astro.cookies, repo);
29
29
  const eventRequests = CALENDAR.enabled && access?.can('content.publish') ? await pendingRequests(repo) : [];
30
30
  const newRequests = eventRequests.filter((e) => e.request?.kind === 'new').length;
31
31
  const changeRequests = eventRequests.length - newRequests;
32
+ // hosts' date moves waiting (lib/calendar/host-requests.ts)
33
+ const dateRequests = CALENDAR.enabled && access?.can('content.publish') ? await repo.requests.count({ kinds: ['move', 'from-date'], states: ['pending'] }) : 0;
34
+ const waitingRequests = eventRequests.length + dateRequests;
32
35
  const requestDetail = [
33
36
  newRequests ? `${newRequests} new event${newRequests === 1 ? '' : 's'}` : '',
34
37
  changeRequests ? `changes to ${changeRequests} event${changeRequests === 1 ? '' : 's'}` : '',
38
+ dateRequests ? `${dateRequests} date change${dateRequests === 1 ? '' : 's'}` : '',
35
39
  ].filter(Boolean).join(' · ');
36
40
  const me = sessionUserId && sessionUserId !== OWNER_ENV ? users.find((u) => u.id === sessionUserId) : null;
37
41
  const firstName = me?.name.split(/\s+/)[0] ?? '';
@@ -167,10 +171,10 @@ const tiles = [
167
171
  </div>
168
172
  )}
169
173
 
170
- {eventRequests.length > 0 && (
174
+ {waitingRequests > 0 && (
171
175
  <div class="notice">
172
176
  <div class="grow-text">
173
- <b>{eventRequests.length} event request{eventRequests.length === 1 ? '' : 's'} waiting</b>
177
+ <b>{waitingRequests} event request{waitingRequests === 1 ? '' : 's'} waiting</b>
174
178
  <span class="hint">{requestDetail}</span>
175
179
  </div>
176
180
  <a class="btn sm" href="/admin/calendar/events?scope=requests">Review</a>
@@ -63,7 +63,7 @@ const po = design.post;
63
63
  const PostTemplate = (post.template ? design.theme.postTemplates[post.template] : null) as any;
64
64
  const tagSlug = (name: string) =>
65
65
  tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
66
- const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo));
66
+ const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo, locale));
67
67
  const fmt = (ts: number | null) => formatDate(ts, settings);
68
68
  const words = (post.bodyHtml || post.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
69
69
  const readMins = Math.max(1, Math.round(words / 220));
@@ -3,6 +3,8 @@ import { ApiError } from '../../lib/api';
3
3
  import { starter } from '../../lib/forms/schema';
4
4
  import { formVersion } from '../../lib/forms/service';
5
5
  import { renderFields, hiddenFields } from '../../lib/forms/render';
6
+ import { localizeForm } from '../../lib/forms/localize';
7
+ import { formStrings } from '../../lib/forms/words';
6
8
  import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
7
9
  import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
8
10
  import { I18N, localePath, requestLocale } from '../../lib/i18n';
@@ -74,22 +76,26 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
74
76
  }
75
77
  }
76
78
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
79
+ // the questions in the page's language (main-language words where the form has none)
80
+ const fieldsHtml = renderFields(localizeForm(definition, isExtraLocale(locale) ? locale : ''), formValues, 'event-registration', formStrings((I18N.enabled ? locale : '') || siteLang(settings)));
77
81
  const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
78
82
  // the event's RSVP deadline for this date (null: open until it ends)
79
83
  const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
80
84
  // waitlist: a full date offers "Join the waitlist" (the same form, posting waitlist=1)
81
85
  const rsvp = event.rsvp.enabled
82
- ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
86
+ ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml, values: coreValues,
83
87
  open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt, waitlist: waitlistOn(event) }
84
88
  : null;
85
89
  const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
86
- const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
90
+ const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo, locale));
87
91
  const upcoming = (await upcomingEvents(repo, settings, now, { limit: 4 })).filter((u) => u.event.id !== event.id).slice(0, 3);
88
92
  const pageUrl = origin + eventUrl(event.slug);
89
93
  const jsonLd = eventJsonLd(event, occurrence, settings, origin, pageUrl);
90
94
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
95
  const Template = resolveDesign(settings).theme.EventTemplate as any;
92
- const props = { event, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
96
+ // templates read the form's title, introduction and button from the event: in the page's language too
97
+ const shownEvent = event.rsvp.form && isExtraLocale(locale) ? { ...event, rsvp: { ...event.rsvp, form: localizeForm(event.rsvp.form, locale) } } : event;
98
+ const props = { event: shownEvent, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
93
99
  ---
94
100
 
95
101
  <Site
@@ -5,12 +5,24 @@ import { renderForm } from '../../lib/forms/render';
5
5
  import { collectResponse } from '../../lib/forms/service';
6
6
  import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
7
7
  import { saveResponse } from '../../lib/forms/alerts';
8
+ import { localizeForm } from '../../lib/forms/localize';
9
+ import { formStrings, thanksText } from '../../lib/forms/words';
8
10
  import { ApiError } from '../../lib/api';
11
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
12
+ import { isExtraLocale } from '../../lib/translations';
13
+ import { siteLang } from '../../lib/dates';
9
14
  const repo = await getRepo(Astro.locals);
10
15
  const form = await repo.forms.get(Astro.params.id || '');
11
16
  if (!form || form.status !== 'published' || form.kind !== 'form' || form.purpose !== 'general') return new Response(null, { status: 404 });
12
17
  Astro.response.headers.set('Cache-Control', 'private, no-store');
13
18
  const embed = Astro.url.searchParams.has('embed');
19
+ // /es/forms/<id>: the form's Spanish words (main-language ones where it has
20
+ // none) and the page's own words in Spanish; answers are stored the same
21
+ const locale = requestLocale(Astro.url, Astro.locals);
22
+ const formLocale = isExtraLocale(locale) ? locale : '';
23
+ const lang = (I18N.enabled ? locale : '') || siteLang(await repo.getSettings());
24
+ const shown = localizeForm(form.definition, formLocale);
25
+ const path = localePath(`/forms/${encodeURIComponent(form.id)}`, locale);
14
26
  let error = '', success = false, values: Record<string,string> = {};
15
27
  let requestId: string = crypto.randomUUID();
16
28
  if (Astro.request.method === 'POST') {
@@ -18,11 +30,17 @@ if (Astro.request.method === 'POST') {
18
30
  const data = await readForm(Astro.request);
19
31
  requestId = await submissionGuard(repo, data, Astro.clientAddress, `form:${form.id}`);
20
32
  values = readAnswers(data, form.definition);
21
- const response = await collectResponse(form.definition, values, String(data.get('formVersion') || ''));
22
- await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
33
+ // a resubmit of a saved response (refresh, double click) thanks them again,
34
+ // also after the form changed since; the same id with other answers is a 409
35
+ const prior = await repo.forms.receipt(form.id, requestId);
36
+ if (!prior || JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition)) {
37
+ const response = await collectResponse(form.definition, values, prior ? undefined : String(data.get('formVersion') || ''));
38
+ await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
39
+ }
23
40
  success = true;
24
41
  } catch (e) { error = e instanceof ApiError ? e.message : 'Could not save your response. Please try again.'; Astro.response.status = e instanceof ApiError ? e.status : 500; }
25
42
  }
26
- const html = success ? '' : await renderForm(form.id, form.definition, values, error, requestId, embed);
43
+ const w = formStrings(lang);
44
+ const html = success ? '' : await renderForm(form.id, form.definition, values, error, requestId, embed, { locale: formLocale, lang, action: path });
27
45
  ---
28
- <Site title={form.definition.title || form.name} noindex bare={embed}>{success ? <section class="pc-simple-form"><h1>{form.definition.title || form.name}</h1><p role="status">{form.definition.confirmation || 'Thank you. Your response has been received.'}</p><a href={Astro.url.pathname + (embed ? '?embed=1' : '')}>Send another response</a></section> : <div set:html={html}/>}</Site>
46
+ <Site title={shown.title || form.name} noindex bare={embed}>{success ? <section class="pc-simple-form"><h1>{shown.title || form.name}</h1><p role="status">{thanksText(shown.confirmation, w)}</p><a href={path + (embed ? '?embed=1' : '')}>{w.sendAnother}</a></section> : <div set:html={html}/>}</Site>
@@ -46,7 +46,7 @@ const [posts, tags] = HomeTemplate
46
46
  : [[], []];
47
47
  // expand dynamic blocks (post lists) in the page body for BOTH paths — a theme
48
48
  // template injects the body as its hero, so it must receive expanded HTML too
49
- const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo) : '';
49
+ const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo, locale) : '';
50
50
  const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
51
51
  ---
52
52
 
@@ -1,9 +1,12 @@
1
1
  ---
2
2
  import type { EventFieldConfig } from '../../lib/calendar/config';
3
3
  import { MEMBER_FORM_LIMITS as L, repeatChoices, type MemberEventForm } from '../../lib/calendar/member-form';
4
+ import { memberStrings, type MemberStrings } from '../../lib/calendar/member-words';
5
+ import { fill } from '../../lib/live/words';
4
6
 
5
7
  // The event request form (new requests and suggested changes). Plain HTML:
6
- // it posts to the page it's on, with `op` saying what to do.
8
+ // it posts to the page it's on, with `op` saying what to do. `w` and `lang`
9
+ // word it in the page's language.
7
10
  interface Props {
8
11
  values: MemberEventForm;
9
12
  /** the calendars members may choose (native, public); none hides the choice */
@@ -13,9 +16,12 @@ interface Props {
13
16
  op: 'submit' | 'update';
14
17
  submitLabel: string;
15
18
  error?: string;
19
+ w?: MemberStrings;
20
+ lang?: string;
16
21
  }
17
- const { values: v, calendars, fields, op, submitLabel, error = '' } = Astro.props;
18
- const repeat = repeatChoices(v.repeat, v.date);
22
+ const { values: v, calendars, fields, op, submitLabel, error = '', w = memberStrings('en'), lang } = Astro.props;
23
+ const f = w.form;
24
+ const repeat = repeatChoices(v.repeat, v.date, lang);
19
25
  const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' : t === 'url' ? 'url' : 'text');
20
26
  ---
21
27
 
@@ -23,25 +29,25 @@ const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' :
23
29
  {error && <p class="pc-bk-error" role="alert">{error}</p>}
24
30
  <input type="hidden" name="op" value={op} />
25
31
  <label class="pc-bk-field">
26
- <span>Title</span>
32
+ <span>{f.title}</span>
27
33
  <input name="title" required maxlength={L.title} value={v.title} />
28
34
  </label>
29
35
  <fieldset class="pc-mem-when">
30
- <legend>When</legend>
36
+ <legend>{f.when}</legend>
31
37
  <div class="pc-mem-row">
32
- <label class="pc-bk-field"><span>Date</span><input type="date" name="date" required value={v.date} /></label>
33
- <label class="pc-bk-field pc-mem-time"><span>Starts</span><input type="time" name="startTime" value={v.startTime} /></label>
34
- <label class="pc-bk-field pc-mem-time"><span>Ends <em>(optional)</em></span><input type="time" name="endTime" value={v.endTime} /></label>
38
+ <label class="pc-bk-field"><span>{f.date}</span><input type="date" name="date" required value={v.date} /></label>
39
+ <label class="pc-bk-field pc-mem-time"><span>{f.starts}</span><input type="time" name="startTime" value={v.startTime} /></label>
40
+ <label class="pc-bk-field pc-mem-time"><span>{f.ends} <em>{f.optional}</em></span><input type="time" name="endTime" value={v.endTime} /></label>
35
41
  </div>
36
- <label class="pc-bk-check"><input type="checkbox" name="allDay" value="1" checked={v.allDay} /> <span>All day <small>No start or end time</small></span></label>
42
+ <label class="pc-bk-check"><input type="checkbox" name="allDay" value="1" checked={v.allDay} /> <span>{f.allDay} <small>{f.allDayHint}</small></span></label>
37
43
  {v.days > 0 && (
38
44
  <>
39
45
  <input type="hidden" name="days" value={String(v.days)} />
40
- <p class="pc-mem-hint">This event runs over {v.days + 1} days. Changing the date moves all of them.</p>
46
+ <p class="pc-mem-hint">{fill(f.multiDay, { days: String(v.days + 1) })}</p>
41
47
  </>
42
48
  )}
43
49
  <label class="pc-bk-field">
44
- <span>Repeats</span>
50
+ <span>{f.repeats}</span>
45
51
  <select name="repeat">
46
52
  {repeat.options.map((o) => <option value={o.value} selected={o.value === repeat.value}>{o.label}</option>)}
47
53
  </select>
@@ -49,40 +55,40 @@ const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' :
49
55
  </label>
50
56
  </fieldset>
51
57
  <label class="pc-bk-field">
52
- <span>Where</span>
53
- <input name="where" maxlength={L.where} value={v.where} placeholder="A room, an address, or “Online”" />
58
+ <span>{f.where}</span>
59
+ <input name="where" maxlength={L.where} value={v.where} placeholder={f.wherePlaceholder} />
54
60
  </label>
55
61
  <label class="pc-bk-field">
56
- <span>Description</span>
62
+ <span>{f.description}</span>
57
63
  <textarea name="description" rows="6" maxlength={L.description}>{v.description}</textarea>
58
- <small>Plain text. Leave a blank line between paragraphs.</small>
64
+ <small>{f.descriptionHint}</small>
59
65
  </label>
60
66
  {calendars.length > 0 && (
61
67
  <label class="pc-bk-field">
62
- <span>Calendar</span>
68
+ <span>{f.calendar}</span>
63
69
  <select name="calendarId">
64
- <option value="">Not sure (the team will choose)</option>
70
+ <option value="">{f.notSure}</option>
65
71
  {calendars.map((c) => <option value={c.id} selected={c.id === v.calendarId}>{c.name}</option>)}
66
72
  </select>
67
73
  </label>
68
74
  )}
69
- {fields.map((f) => (
75
+ {fields.map((fd) => (
70
76
  <label class="pc-bk-field">
71
- <span>{f.label}</span>
72
- {f.type === 'textarea' ? (
73
- <textarea name={`field_${f.key}`} rows="3" maxlength={L.field}>{v.fields[f.key] ?? ''}</textarea>
74
- ) : f.type === 'select' ? (
75
- <select name={`field_${f.key}`}>
76
- <option value="">Choose…</option>
77
- {f.options.map((o) => <option value={o} selected={o === v.fields[f.key]}>{o}</option>)}
77
+ <span>{fd.label}</span>
78
+ {fd.type === 'textarea' ? (
79
+ <textarea name={`field_${fd.key}`} rows="3" maxlength={L.field}>{v.fields[fd.key] ?? ''}</textarea>
80
+ ) : fd.type === 'select' ? (
81
+ <select name={`field_${fd.key}`}>
82
+ <option value="">{f.choose}</option>
83
+ {fd.options.map((o) => <option value={o} selected={o === v.fields[fd.key]}>{o}</option>)}
78
84
  </select>
79
85
  ) : (
80
- <input type={fieldType(f.type)} name={`field_${f.key}`} maxlength={f.type === 'number' ? undefined : L.field} step={f.type === 'number' ? 'any' : undefined} value={v.fields[f.key] ?? ''} />
86
+ <input type={fieldType(fd.type)} name={`field_${fd.key}`} maxlength={fd.type === 'number' ? undefined : L.field} step={fd.type === 'number' ? 'any' : undefined} value={v.fields[fd.key] ?? ''} />
81
87
  )}
82
88
  </label>
83
89
  ))}
84
90
  <label class="pc-bk-field">
85
- <span>Note for the team <em>(only staff see this)</em></span>
91
+ <span>{f.note} <em>{f.staffOnly}</em></span>
86
92
  <textarea name="note" rows="3" maxlength={L.note}>{v.note}</textarea>
87
93
  </label>
88
94
  <button class="pc-bk-submit" type="submit">{submitLabel}</button>
@@ -1,23 +1,27 @@
1
1
  ---
2
2
  import Site from '../../layouts/Site.astro';
3
+ import { fill } from '../../lib/live/words';
4
+ import { memberStrings, type MemberStrings } from '../../lib/calendar/member-words';
3
5
 
4
6
  // The frame of the built-in member pages: the site's own layout and theme,
5
7
  // never indexed, plus who's signed in. `.pc-mem-*` classes are theme hooks;
6
- // form controls reuse the booking pages' `.pc-bk-*` ones.
8
+ // form controls reuse the booking pages' `.pc-bk-*` ones. `w`: the page's
9
+ // words (memberLanguage in _http.ts), English by default.
7
10
  interface Props {
8
11
  title: string;
9
12
  user: { name: string; email: string; staff: boolean };
13
+ w?: MemberStrings;
10
14
  }
11
- const { title, user } = Astro.props;
15
+ const { title, user, w = memberStrings('en') } = Astro.props;
12
16
  ---
13
17
 
14
18
  <Site title={title} noindex staffPreview>
15
19
  <div class="pc-mem">
16
20
  <div class="pc-mem-top">
17
- <span>Signed in as {user.name || user.email}</span>
21
+ <span>{fill(w.signedInAs, { name: user.name || user.email })}</span>
18
22
  <span class="pc-mem-top-links">
19
- {user.staff && <a href="/admin">Open the admin</a>}
20
- <form method="post" action="/admin/logout"><button type="submit">Sign out</button></form>
23
+ {user.staff && <a href="/admin">{w.openAdmin}</a>}
24
+ <form method="post" action="/admin/logout"><button type="submit">{w.signOut}</button></form>
21
25
  </span>
22
26
  </div>
23
27
  <slot />
@@ -48,6 +52,7 @@ const { title, user } = Astro.props;
48
52
  .pc-mem-state::before { content: ''; width: 7px; height: 7px; border-radius: 2px; background: currentColor; }
49
53
  .pc-mem-state.is-pending { color: var(--warning); }
50
54
  .pc-mem-state.is-declined { color: var(--danger); }
55
+ .pc-mem-state.is-approved { color: var(--success); }
51
56
  .pc-mem-reply { margin: 10px 0 0; padding: 9px 12px; background: var(--soft); border-radius: var(--r-sm); font-size: 14px; white-space: pre-line; }
52
57
  .pc-mem > .pc-mem-reply { margin: 0; }
53
58
  .pc-mem-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
@@ -73,5 +78,20 @@ const { title, user } = Astro.props;
73
78
  .pc-mem-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
74
79
  .pc-mem-when:has(input[name='allDay']:checked) .pc-mem-time { opacity: 0.45; }
75
80
  .pc-mem-hint { color: var(--muted); font-size: 13px; margin: 0; }
81
+ /* one date's controls on its row (Move…, then Cancel); an open form takes the line under it */
82
+ .pc-mem-dates li { flex-wrap: wrap; justify-content: flex-start; column-gap: 6px; }
83
+ .pc-mem-dates li > span:first-child { flex: 1 1 16rem; }
84
+ .pc-mem-date-acts { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; order: 3; }
85
+ .pc-mem-dates em.is-pending { color: var(--warning); font-weight: 500; }
86
+ .pc-mem-move { order: 2; }
87
+ .pc-mem-move[open], .pc-mem-clash { order: 4; flex-basis: 100%; }
88
+ .pc-mem-move > summary { list-style: none; cursor: pointer; display: inline-flex; }
89
+ .pc-mem-move > summary::-webkit-details-marker { display: none; }
90
+ .pc-mem-move[open] > summary { margin-bottom: 8px; }
91
+ .pc-mem-move form { display: grid; gap: 12px; padding: 12px; background: var(--soft); border-radius: var(--r-sm); margin-bottom: 6px; }
92
+ .pc-mem-move fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; }
93
+ .pc-mem-move fieldset > legend { padding: 0; margin-bottom: 6px; font-size: 13px; font-weight: 600; }
94
+ .pc-mem-clash { display: grid; gap: 10px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--line)); border-radius: var(--r-sm); background: var(--panel); }
95
+ .pc-mem-clash p { margin: 0; font-size: 14px; }
76
96
  }
77
97
  </style>
@@ -1,4 +1,10 @@
1
1
  import { ApiError } from '../../lib/api';
2
+ import type { AstroGlobal } from 'astro';
3
+ import type { Settings } from '../../lib/schema';
4
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
5
+ import { isExtraLocale } from '../../lib/translations';
6
+ import { siteLang, siteLocale } from '../../lib/dates';
7
+ import { memberError, memberStrings, type MemberStrings } from '../../lib/calendar/member-words';
2
8
 
3
9
  // Shared by the built-in "My events" pages: they're personal, so nothing
4
10
  // they answer (redirects included) may be cached or indexed.
@@ -12,14 +18,31 @@ export function keepPrivate(headers: Headers) {
12
18
  /** 303 after a POST (the browser GETs the list), 302 otherwise */
13
19
  export const redirectTo = (location: string, status = 303) => new Response(null, { status, headers: { Location: location, ...NO_STORE } });
14
20
 
15
- /** signed out: sign in, then come back here */
16
- export const signInRedirect = (url: URL) => redirectTo(`/admin/login?return=${encodeURIComponent(url.pathname + url.search)}`, 302);
21
+ /** signed out: sign in, then come back here (the address they used: /es/… is rewritten to these pages) */
22
+ export const signInRedirect = (url: URL, locals?: unknown) => {
23
+ const path = (locals as { acmsPath?: string } | undefined)?.acmsPath ?? url.pathname;
24
+ return redirectTo(`/admin/login?return=${encodeURIComponent(path + url.search)}`, 302);
25
+ };
26
+
27
+ /**
28
+ * The page's language (/es/members/events is these pages in Spanish): its
29
+ * words, links that keep the visitor in it, and the locale dates are written
30
+ * in (the site's own locale setting for the main language).
31
+ */
32
+ export function memberLanguage(Astro: Pick<AstroGlobal, 'url' | 'locals'>, settings: Pick<Settings, 'locale'>): { locale: string; lang: string; w: MemberStrings; here: (path: string) => string; dateLocale: string } {
33
+ const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
34
+ const lang = locale || siteLang(settings);
35
+ return { locale, lang, w: memberStrings(lang), here: (path: string) => localePath(path, locale), dateLocale: isExtraLocale(locale) ? locale : siteLocale(settings) };
36
+ }
17
37
 
18
38
  export const notFound = () => new Response(null, { status: 404, statusText: 'Not found', headers: NO_STORE });
19
39
 
20
- /** an error from requests.ts → a message safe to show, and the status to answer with */
21
- export function failure(err: unknown): { message: string; status: number } {
22
- if (err instanceof ApiError) return { message: err.message, status: err.status };
40
+ /** an error from requests.ts (or moves.ts) → a message safe to show, in the page's words when given, and the status to answer with */
41
+ export function failure(err: unknown, w?: MemberStrings, lang?: string): { message: string; status: number; code: string } {
42
+ if (err instanceof ApiError) {
43
+ const code = String((err as { code?: unknown }).code ?? '');
44
+ return { message: w ? memberError(w, err, lang) : err.message, status: err.status, code };
45
+ }
23
46
  console.error(err);
24
- return { message: 'Something went wrong. Please try again.', status: 500 };
47
+ return { message: w?.somethingWrong ?? 'Something went wrong. Please try again.', status: 500, code: '' };
25
48
  }