@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.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +18 -1
- package/docs/calendar.md +20 -2
- package/docs/mobile.md +1 -1
- package/docs/server.md +61 -1
- package/integration.mjs +11 -5
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/email/service.ts +12 -2
- package/src/lib/email/templates.ts +12 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +7 -0
- package/src/lib/repo/types.ts +6 -1
- package/src/lib/schema.ts +47 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +53 -0
- package/src/styles/forms.css +8 -0
- 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
|
-
{
|
|
468
|
-
|
|
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
|
-
{
|
|
174
|
+
{waitingRequests > 0 && (
|
|
171
175
|
<div class="notice">
|
|
172
176
|
<div class="grow-text">
|
|
173
|
-
<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
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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={
|
|
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>
|
package/src/pages/index.astro
CHANGED
|
@@ -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
|
|
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>
|
|
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>
|
|
36
|
+
<legend>{f.when}</legend>
|
|
31
37
|
<div class="pc-mem-row">
|
|
32
|
-
<label class="pc-bk-field"><span>
|
|
33
|
-
<label class="pc-bk-field pc-mem-time"><span>
|
|
34
|
-
<label class="pc-bk-field pc-mem-time"><span>
|
|
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>
|
|
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">
|
|
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>
|
|
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>
|
|
53
|
-
<input name="where" maxlength={L.where} value={v.where} placeholder=
|
|
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>
|
|
62
|
+
<span>{f.description}</span>
|
|
57
63
|
<textarea name="description" rows="6" maxlength={L.description}>{v.description}</textarea>
|
|
58
|
-
<small>
|
|
64
|
+
<small>{f.descriptionHint}</small>
|
|
59
65
|
</label>
|
|
60
66
|
{calendars.length > 0 && (
|
|
61
67
|
<label class="pc-bk-field">
|
|
62
|
-
<span>
|
|
68
|
+
<span>{f.calendar}</span>
|
|
63
69
|
<select name="calendarId">
|
|
64
|
-
<option value="">
|
|
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((
|
|
75
|
+
{fields.map((fd) => (
|
|
70
76
|
<label class="pc-bk-field">
|
|
71
|
-
<span>{
|
|
72
|
-
{
|
|
73
|
-
<textarea name={`field_${
|
|
74
|
-
) :
|
|
75
|
-
<select name={`field_${
|
|
76
|
-
<option value="">
|
|
77
|
-
{
|
|
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(
|
|
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>
|
|
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>
|
|
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">
|
|
20
|
-
<form method="post" action="/admin/logout"><button type="submit">
|
|
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) =>
|
|
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)
|
|
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
|
}
|