@acms-subir/core 0.3.53 → 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.
@@ -10,6 +10,7 @@ import type { EmailStore } from '../email/store';
10
10
  import type { RsvpStaffNotice } from '../email/types';
11
11
  import type { PushStore } from '../push/store';
12
12
  import type { AnnouncementStore } from '../announcements/store';
13
+ import type { RequestStore } from '../calendar/request-store';
13
14
  import type { Announcement } from '../announcements/types';
14
15
  import type { EditorStore } from '../editor/store';
15
16
  import type { PermissionStore } from '../permissions/store';
@@ -19,7 +20,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
19
20
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
20
21
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
21
22
 
22
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest } from '../schema';
23
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest, RequestRecord } from '../schema';
23
24
 
24
25
  export interface Stats {
25
26
  publishedPages: number;
@@ -82,6 +83,8 @@ export interface ContentRepository {
82
83
  readonly live: LiveStore;
83
84
  readonly push: PushStore;
84
85
  readonly announcements: AnnouncementStore;
86
+ // calendar requests on their own rows (lib/calendar/request-store.ts)
87
+ readonly requests: RequestStore;
85
88
  readonly editor: EditorStore;
86
89
  readonly permissions: PermissionStore;
87
90
  readonly screens: ScreenStore;
@@ -372,6 +375,8 @@ export interface ContentRepository {
372
375
  // saved TV screens, without their keys (each gets a new one); a backup
373
376
  // without them leaves the current ones
374
377
  screens?: Screen[];
378
+ // added with calendar requests; a backup without them leaves the current ones
379
+ eventRequests?: RequestRecord[];
375
380
  }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
376
381
 
377
382
  // imports (lib/import/apply.ts drives these). writeImported is the
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,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) => (
@@ -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>
@@ -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
  }
@@ -3,22 +3,24 @@ import Layout from '../_Layout.astro';
3
3
  import EventForm from '../_EventForm.astro';
4
4
  import { getRepo } from '../../../lib/repo';
5
5
  import { currentUser } from '../../../server';
6
- import { ApiError } from '../../../lib/api';
6
+ import { coded } from '../../../lib/api';
7
7
  import { readForm } from '../../../lib/forms/http';
8
8
  import { MEMBERS, memberEventsPath, bodyToText, isHost, updateEventRequest, withdrawEventRequest } from '../../../lib/calendar/requests';
9
9
  import { eventFormValues, eventRequestInput, memberEventFields, readEventForm } from '../../../lib/calendar/member-form';
10
10
  import { mergeEventDraft } from '../../../lib/drafts';
11
- import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
11
+ import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
12
+ import { fill } from '../../../lib/live/words';
12
13
 
13
14
  // Edit a request, or suggest changes to a live event the member hosts: the
14
15
  // form starts from the event with any changes already waiting merged in.
15
16
  // A live event stays as it is until the team approves (updateEventRequest).
16
17
  if (!MEMBERS.enabled) return notFound();
17
18
  const user = await currentUser(Astro);
18
- if (!user) return signInRedirect(Astro.url);
19
+ if (!user) return signInRedirect(Astro.url, Astro.locals);
19
20
  keepPrivate(Astro.response.headers);
20
21
  const repo = await getRepo(Astro.locals);
21
- const home = memberEventsPath();
22
+ const { w, lang, here } = memberLanguage(Astro, await repo.getSettings());
23
+ const home = here(memberEventsPath());
22
24
  const hooks = { url: Astro.url, locals: Astro.locals };
23
25
  const id = Astro.params.id ?? '';
24
26
  const event = await repo.getEvent(id);
@@ -40,12 +42,12 @@ if (Astro.request.method === 'POST') {
40
42
  await withdrawEventRequest(repo, user, id, hooks);
41
43
  return redirectTo(`${home}?withdrawn=1`);
42
44
  }
43
- if (op !== 'update') throw new ApiError(400, 'That action isn’t available here.');
45
+ if (op !== 'update') throw coded(400, 'unavailable', 'That action isn’t available here.');
44
46
  values = readEventForm(form, fields);
45
47
  await updateEventRequest(repo, user, id, eventRequestInput(values), hooks);
46
48
  return redirectTo(`${home}?${live ? 'changes' : 'updated'}=1`);
47
49
  } catch (err) {
48
- const f = failure(err);
50
+ const f = failure(err, w, lang);
49
51
  error = f.message;
50
52
  Astro.response.status = f.status;
51
53
  }
@@ -53,34 +55,33 @@ if (Astro.request.method === 'POST') {
53
55
 
54
56
  const r = event.request;
55
57
  const canRequest = user.can('events.request');
56
- const heading = live ? 'Suggest changes' : 'Edit your request';
57
- const lede = live
58
- ? `“${event.title}” stays as it is on the site until the team approves your changes.`
59
- : r?.state === 'declined' ? 'Make your changes and send it again.'
60
- : r ? 'It’s waiting for approval. Saving sends the updated request.'
61
- : 'It isn’t on the calendar yet. Send it to the team when it’s ready.';
58
+ const heading = live ? w.suggestTitle : w.editTitle;
59
+ const lede = live ? fill(w.suggestLede, { title: event.title })
60
+ : r?.state === 'declined' ? w.declinedLede
61
+ : r ? w.waitingLede
62
+ : w.draftLede;
62
63
  const waiting = live && r?.kind === 'changes' && r.state === 'pending';
63
64
  const reply = r?.state === 'declined' ? r.reply : '';
64
65
  const canWithdraw = canRequest && r && !(r.kind === 'changes' && r.state === 'declined');
65
66
  ---
66
67
 
67
- <Layout title={heading} user={user}>
68
+ <Layout title={heading} user={user} w={w}>
68
69
  <div>
69
- <a class="pc-mem-back" href={home}>← My events</a>
70
+ <a class="pc-mem-back" href={home}>{w.back}</a>
70
71
  <div class="pc-mem-head"><h1>{heading}</h1></div>
71
72
  <p class="pc-mem-lede">{lede}</p>
72
73
  </div>
73
- {waiting && <p class="pc-mem-reply">The changes you sent are waiting for approval; they’re filled in below. Sending again replaces them.</p>}
74
+ {waiting && <p class="pc-mem-reply">{w.changesWaitingNote}</p>}
74
75
  {r?.state === 'declined' && (
75
- <p class="pc-mem-reply"><strong>{r.kind === 'changes' ? 'The team declined your last changes.' : 'The team declined this request.'}</strong>{reply && <> They said: {reply}</>}</p>
76
+ <p class="pc-mem-reply"><strong>{r.kind === 'changes' ? w.declinedChanges : w.declinedRequest}</strong>{reply && <> {w.theySaid} {reply}</>}</p>
76
77
  )}
77
78
  {canRequest
78
- ? <EventForm values={values} calendars={calendars} fields={fields} op="update" submitLabel={live ? 'Send changes for approval' : 'Submit for approval'} error={error} />
79
- : <p class="pc-bk-error" role="alert">Your role doesn’t include “Request events”. Ask the site’s team if you need it.</p>}
79
+ ? <EventForm values={values} calendars={calendars} fields={fields} op="update" submitLabel={live ? w.sendChanges : w.submit} error={error} w={w} lang={lang} />
80
+ : <p class="pc-bk-error" role="alert">{w.noRequestRole}</p>}
80
81
  {canWithdraw && (
81
- <form method="post" onsubmit={r!.kind === 'new' ? "return confirm('Withdraw this request?')" : "return confirm('Withdraw these changes?')"}>
82
+ <form method="post" onsubmit={'return confirm(' + JSON.stringify(r!.kind === 'new' ? w.confirmWithdrawRequest : w.confirmWithdrawChanges) + ')'}>
82
83
  <input type="hidden" name="op" value="withdraw" />
83
- <button class="pc-mem-btn is-danger" type="submit">{r!.kind === 'new' ? 'Withdraw request' : 'Withdraw changes'}</button>
84
+ <button class="pc-mem-btn is-danger" type="submit">{r!.kind === 'new' ? w.withdrawRequest : w.withdrawChanges}</button>
84
85
  </form>
85
86
  )}
86
87
  </Layout>