@acms-subir/core 0.3.51 → 0.3.53
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 +33 -0
- package/cli/migrations.generated.mjs +16 -1
- package/docs/calendar.md +24 -10
- package/docs/mobile.md +1 -1
- package/integration.mjs +5 -0
- 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/display/ScreensManager.tsx +481 -0
- 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/layouts/Admin.astro +2 -0
- package/src/lib/backup/content.ts +10 -1
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/display/board.ts +4 -3
- package/src/lib/display/screens.ts +111 -0
- package/src/lib/display/store.ts +113 -0
- package/src/lib/display/words.ts +16 -6
- package/src/lib/dynamicBlocks.ts +4 -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 +3 -0
- package/src/lib/repo/sql.ts +5 -0
- package/src/lib/repo/types.ts +5 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/screens/index.astro +24 -0
- package/src/pages/admin/calendar/screens/preview.astro +36 -0
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/api/calendar/screens.ts +30 -0
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/display/s/[key].astro +48 -0
- 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/styles/display.css +2 -1
- package/src/styles/forms.css +8 -0
- package/src/styles/screens-admin.css +53 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Question types, defaults and small pure helpers shared by the builder panes.
|
|
2
|
-
import { CHOICE_TYPES, FIELD_ID, type FormDefinition, type FormField } from '../../lib/forms/schema';
|
|
2
|
+
import { CHOICE_TYPES, FIELD_ID, type FieldWords, type FormDefinition, type FormField, type FormWords } from '../../lib/forms/schema';
|
|
3
|
+
import { choiceClash } from '../../lib/forms/localize';
|
|
3
4
|
|
|
4
5
|
export type FieldType = FormField['type'];
|
|
5
6
|
export type Group = 'Basics' | 'Contact' | 'Other';
|
|
6
7
|
export type Selection = { kind: 'field'; id: string } | { kind: 'form' } | { kind: 'submit' } | { kind: 'thanks' } | { kind: 'system'; index: number } | null;
|
|
7
8
|
export type SystemField = { label: string; type: FieldType; note?: string };
|
|
9
|
+
/** The language being written (code '' = the main one) and its name, the main one's, and every language's by code. */
|
|
10
|
+
export type Language = { code: string; name: string; main: string; names: Record<string, string> };
|
|
8
11
|
|
|
9
12
|
export const META: Record<FieldType, { label: string; group: Group; desc: string; fresh: string }> = {
|
|
10
13
|
text: { label: 'Short text', group: 'Basics', desc: 'A single line, like a name or a title.', fresh: 'Short answer' },
|
|
@@ -17,12 +20,13 @@ export const META: Record<FieldType, { label: string; group: Group; desc: string
|
|
|
17
20
|
url: { label: 'Website', group: 'Contact', desc: 'A web address. “example.org” is accepted too.', fresh: 'Website' },
|
|
18
21
|
number: { label: 'Number', group: 'Other', desc: 'Any number, like a count or an age.', fresh: 'Number' },
|
|
19
22
|
date: { label: 'Date', group: 'Other', desc: 'A calendar date picker.', fresh: 'Date' },
|
|
23
|
+
time: { label: 'Time', group: 'Other', desc: 'A time of day, like 6:30 PM. Stored as 18:30.', fresh: 'Time' },
|
|
20
24
|
consent: { label: 'Agree checkbox', group: 'Other', desc: 'One box to tick. When required, people must tick it to send.', fresh: 'I agree to the terms' },
|
|
21
25
|
heading: { label: 'Section heading', group: 'Other', desc: 'A title and description that splits a long form into parts. Not a question.', fresh: 'New section' },
|
|
22
26
|
checkbox: { label: 'Checkbox', group: 'Other', desc: 'One box to tick.', fresh: 'Checkbox' },
|
|
23
27
|
};
|
|
24
28
|
/** Insert menu order (`checkbox` stays valid for old data but isn't offered). */
|
|
25
|
-
export const INSERT: FieldType[] = ['text', 'textarea', 'radio', 'checkboxes', 'select', 'email', 'phone', 'url', 'number', 'date', 'consent', 'heading'];
|
|
29
|
+
export const INSERT: FieldType[] = ['text', 'textarea', 'radio', 'checkboxes', 'select', 'email', 'phone', 'url', 'number', 'date', 'time', 'consent', 'heading'];
|
|
26
30
|
export const TYPE_CHOICES: FieldType[] = [...INSERT];
|
|
27
31
|
|
|
28
32
|
export const isChoice = (t: FieldType) => CHOICE_TYPES.includes(t);
|
|
@@ -46,21 +50,77 @@ export function clean(f: FormField): FormField {
|
|
|
46
50
|
if (f.placeholder && takesPlaceholder(f)) out.placeholder = f.placeholder;
|
|
47
51
|
if (f.width === 'half' && f.type !== 'heading') out.width = 'half';
|
|
48
52
|
if (f.allowOther && canOther(f.type)) out.allowOther = true;
|
|
53
|
+
const translations = cleanFieldWords(out, f.translations);
|
|
54
|
+
if (translations) out.translations = translations;
|
|
49
55
|
return out;
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
const blank = (v: string | undefined) => !v || !v.trim();
|
|
59
|
+
// Another language's words for a question: blank ones (and languages left
|
|
60
|
+
// with none) are dropped, and so are words its type doesn't show. Choices
|
|
61
|
+
// line up with the main ones by position.
|
|
62
|
+
function cleanFieldWords(f: FormField, translations: FormField['translations']): FormField['translations'] {
|
|
63
|
+
const out: NonNullable<FormField['translations']> = {};
|
|
64
|
+
for (const [locale, t] of Object.entries(translations ?? {})) {
|
|
65
|
+
const w: FieldWords = {};
|
|
66
|
+
if (!blank(t.label)) w.label = t.label;
|
|
67
|
+
if (!blank(t.help)) w.help = t.help;
|
|
68
|
+
if (!blank(t.placeholder) && takesPlaceholder(f)) w.placeholder = t.placeholder;
|
|
69
|
+
if (isChoice(f.type) && t.options?.some(o => !blank(o))) {
|
|
70
|
+
const options = f.options.map((_, i) => t.options?.[i] ?? '');
|
|
71
|
+
while (blank(options[options.length - 1])) options.pop();
|
|
72
|
+
w.options = options;
|
|
73
|
+
}
|
|
74
|
+
if (Object.keys(w).length) out[locale] = w;
|
|
75
|
+
}
|
|
76
|
+
return Object.keys(out).length ? out : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The question with some of its words in `locale` changed. */
|
|
80
|
+
export const withFieldWords = (f: FormField, locale: string, next: FieldWords): FormField =>
|
|
81
|
+
clean({ ...f, translations: { ...f.translations, [locale]: { ...f.translations?.[locale], ...next } } });
|
|
82
|
+
|
|
83
|
+
/** The form's title, introduction, button or thank-you in `locale` changed (blank ones dropped). */
|
|
84
|
+
export function withFormWords(d: FormDefinition, locale: string, next: FormWords): Pick<FormDefinition, 'translations'> {
|
|
85
|
+
const all = { ...d.translations, [locale]: { ...d.translations?.[locale], ...next } };
|
|
86
|
+
const out: NonNullable<FormDefinition['translations']> = {};
|
|
87
|
+
for (const [l, t] of Object.entries(all)) {
|
|
88
|
+
const w = Object.fromEntries(Object.entries(t).filter(([, v]) => !blank(v)));
|
|
89
|
+
if (Object.keys(w).length) out[l] = w;
|
|
90
|
+
}
|
|
91
|
+
return { translations: Object.keys(out).length ? out : undefined };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Each language's choices follow their main choice when choices are added, removed or moved (map: new position → old one, -1 for a new choice). */
|
|
95
|
+
export function remapChoices(translations: FormField['translations'], map: number[]): FormField['translations'] {
|
|
96
|
+
if (!translations) return translations;
|
|
97
|
+
return Object.fromEntries(Object.entries(translations).map(([l, t]) => [l, t.options ? { ...t, options: map.map(i => (i >= 0 ? t.options?.[i] ?? '' : '')) } : t]));
|
|
98
|
+
}
|
|
99
|
+
|
|
52
100
|
export function newField(type: FieldType, taken: string[]): FormField {
|
|
53
101
|
const label = META[type].fresh;
|
|
54
102
|
return clean({ id: keyFrom(label, taken), type, label, required: false, help: '', options: isChoice(type) ? ['Option 1', 'Option 2'] : [] });
|
|
55
103
|
}
|
|
56
104
|
|
|
57
|
-
/**
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
|
|
105
|
+
/** Choices kept while a question is some other type: the main ones and each language's. */
|
|
106
|
+
export interface RememberedChoices { options: string[]; words: Record<string, string[]> }
|
|
107
|
+
export const rememberChoices = (f: FormField): RememberedChoices => ({
|
|
108
|
+
options: f.options, words: Object.fromEntries(Object.entries(f.translations ?? {}).flatMap(([l, t]) => (t.options?.length ? [[l, t.options]] : []))),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Switching type keeps the label/help; choice lists (and their translations) survive between choice types. */
|
|
112
|
+
export function switchType(f: FormField, type: FieldType, remembered?: RememberedChoices): FormField {
|
|
113
|
+
const restore = isChoice(type) && !f.options.length && !!remembered?.options.length;
|
|
114
|
+
const options = isChoice(type) ? (f.options.length ? f.options : restore ? remembered!.options : ['Option 1', 'Option 2']) : [];
|
|
115
|
+
const translations = restore
|
|
116
|
+
? Object.fromEntries([...new Set([...Object.keys(f.translations ?? {}), ...Object.keys(remembered!.words)])]
|
|
117
|
+
.map((l) => [l, { ...(f.translations?.[l] ?? {}), ...(remembered!.words[l] ? { options: remembered!.words[l] } : {}) }]))
|
|
118
|
+
: f.translations;
|
|
119
|
+
return clean({ ...f, type, options, translations });
|
|
61
120
|
}
|
|
62
121
|
|
|
63
|
-
|
|
122
|
+
/** names: language names by locale, for a translated choice that reads the same as another one */
|
|
123
|
+
export function fieldIssue(f: FormField, all: FormField[], names: Record<string, string> = {}): string {
|
|
64
124
|
if (!f.label.trim()) return 'Needs a label.';
|
|
65
125
|
if (!FIELD_ID.test(f.id)) return 'Answer key must start with a letter and use letters, numbers, - or _.';
|
|
66
126
|
if (all.filter(x => x.id === f.id).length > 1) return 'Answer key is used twice.';
|
|
@@ -68,12 +128,14 @@ export function fieldIssue(f: FormField, all: FormField[]): string {
|
|
|
68
128
|
if (!f.options.length) return 'Add at least one choice.';
|
|
69
129
|
if (f.options.some(o => !o.trim())) return 'A choice is empty.';
|
|
70
130
|
if (new Set(f.options.map(o => o.trim())).size !== f.options.length) return 'Two choices are the same.';
|
|
131
|
+
const clash = choiceClash(f);
|
|
132
|
+
if (clash) return `In ${names[clash.locale] ?? clash.locale.toUpperCase()}, “${clash.text.trim()}” is used for two choices.`;
|
|
71
133
|
}
|
|
72
134
|
return '';
|
|
73
135
|
}
|
|
74
136
|
|
|
75
|
-
export function definitionIssue(d: FormDefinition): string {
|
|
76
|
-
for (const f of d.fields) { const issue = fieldIssue(f, d.fields); if (issue) return `“${f.label || META[f.type].label}”: ${issue}`; }
|
|
137
|
+
export function definitionIssue(d: FormDefinition, names: Record<string, string> = {}): string {
|
|
138
|
+
for (const f of d.fields) { const issue = fieldIssue(f, d.fields, names); if (issue) return `“${f.label || META[f.type].label}”: ${issue}`; }
|
|
77
139
|
return '';
|
|
78
140
|
}
|
|
79
141
|
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -128,6 +128,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
128
128
|
...(CALENDAR.enabled && can('schedule.bookings')
|
|
129
129
|
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
130
130
|
: []),
|
|
131
|
+
// TV screens: saved display links (lib/display), with private details behind their key
|
|
132
|
+
...(can('schedule.manage') ? [{ key: 'calendar:screens', label: 'Screens', href: '/admin/calendar/screens', icon: icons.monitor, count: null }] : []),
|
|
131
133
|
],
|
|
132
134
|
}]
|
|
133
135
|
: []),
|
|
@@ -9,6 +9,13 @@ import type { ContentRepository } from '../repo/types';
|
|
|
9
9
|
const legacyApproval = (l: unknown) =>
|
|
10
10
|
l && typeof l === 'object' && !('approval' in l) && (l as { autoConfirm?: boolean }).autoConfirm === false ? { ...l, approval: 'manual' } : l;
|
|
11
11
|
|
|
12
|
+
// a saved TV screen (lib/display/store.ts). Its key never goes into a
|
|
13
|
+
// backup: a restored screen gets a new one
|
|
14
|
+
export const ScreenRecord = z.object({
|
|
15
|
+
id: z.string().min(1), name: z.string().max(200).default(''), params: z.string().max(4000).default(''), locale: z.string().max(20).default(''),
|
|
16
|
+
privateDetails: z.boolean().default(false), key: z.string().default(''), lastSeenAt: z.number().nullable().default(null),
|
|
17
|
+
order: z.number().default(0), createdAt: z.number(), updatedAt: z.number(),
|
|
18
|
+
});
|
|
12
19
|
export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
|
|
13
20
|
export type BookingRequest = z.infer<typeof BookingRequest>;
|
|
14
21
|
export const BackupContent = z.object({
|
|
@@ -21,6 +28,7 @@ export const BackupContent = z.object({
|
|
|
21
28
|
forms: z.array(SavedForm).optional(), formSubmissions: z.array(FormSubmission).optional(),
|
|
22
29
|
// optional (no version bump): older cores restore such a backup and skip them
|
|
23
30
|
announcements: z.array(AnnouncementRecord).optional(),
|
|
31
|
+
screens: z.array(ScreenRecord).optional(),
|
|
24
32
|
}).superRefine((data, ctx) => {
|
|
25
33
|
if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
|
|
26
34
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
@@ -39,12 +47,13 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
|
39
47
|
repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
|
|
40
48
|
repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
|
|
41
49
|
]);
|
|
42
|
-
const announcements = await repo.announcements.list(-1);
|
|
50
|
+
const [announcements, screens] = await Promise.all([repo.announcements.list(-1), repo.screens.list()]);
|
|
43
51
|
return {
|
|
44
52
|
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
45
53
|
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
|
|
46
54
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
47
55
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
48
56
|
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements,
|
|
57
|
+
screens: screens.map(s => ({ ...s, key: '', lastSeenAt: null })),
|
|
49
58
|
};
|
|
50
59
|
}
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
|
|
7
7
|
import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
|
|
8
8
|
import { describeRepeat } from './rrule';
|
|
9
|
+
import { localizeForm } from '../forms/localize';
|
|
9
10
|
|
|
10
11
|
export interface EventJson {
|
|
11
12
|
id: string;
|
|
@@ -90,7 +91,8 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
90
91
|
movedDates: movedDatesToJson(e, opts.includePrivate),
|
|
91
92
|
location: e.location,
|
|
92
93
|
cta: e.cta,
|
|
93
|
-
|
|
94
|
+
// public: the RSVP form in the main language, without its translations (GET /events/{slug}/rsvp?locale= has it in a language)
|
|
95
|
+
rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => (rsvp.form ? { ...rsvp, form: localizeForm(rsvp.form, '') } : rsvp))(e.rsvp),
|
|
94
96
|
image: e.cover ? abs(origin, e.cover) : '',
|
|
95
97
|
imageAlt: e.coverAlt,
|
|
96
98
|
tags: e.tags.filter((t) => !t.startsWith('#')),
|
package/src/lib/display/board.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { addDays, dayOfWeek, monthWeeks, startOfWeek, type Iso } from '../calend
|
|
|
15
15
|
import { siteLang, siteLocale } from '../dates';
|
|
16
16
|
import { DEFAULT_HOURS, MIN_AUTO_HOURS, type DisplaySettings, type HourRange } from './settings';
|
|
17
17
|
import { renderBoard, type DisplayItem, type DisplayModel, type DisplayRoom } from './render';
|
|
18
|
-
import { displayStrings } from './words';
|
|
18
|
+
import { displayStrings, isBookedWord } from './words';
|
|
19
19
|
import { qrSvg } from './http';
|
|
20
20
|
|
|
21
21
|
export const GOOGLE_BOOKING_TAG = '#google-booking';
|
|
@@ -200,8 +200,9 @@ function displayItem(v: ViewEvent, i: number, c: ItemContext): DisplayItem {
|
|
|
200
200
|
key: `${ev?.id ?? v.ref ?? 'x'}:${v.start}:${i}`,
|
|
201
201
|
kind: ev ? 'event' : v.rooms ? 'booking' : 'feed',
|
|
202
202
|
title: booked ? c.words.booked : v.title,
|
|
203
|
-
// under "Booked", a private screen also names the appointment
|
|
204
|
-
|
|
203
|
+
// under "Booked", a private screen also names the appointment (unless
|
|
204
|
+
// its title is just "Booked", as the Google sync makes them)
|
|
205
|
+
detail: booked && c.privateDetails && !isBookedWord(v.title) ? v.title : '',
|
|
205
206
|
start: v.start, end: v.end, allDay: v.allDay,
|
|
206
207
|
startDay, endDay,
|
|
207
208
|
startMin: v.allDay ? 0 : s.min,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Saved screens as the TV and the admin see them: opening one by its key
|
|
2
|
+
// (rate-limited for wrong keys), and the admin state and actions behind
|
|
3
|
+
// Admin → Calendar → Screens (/api/calendar/screens).
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { ContentRepository } from '../repo/types';
|
|
6
|
+
import { ApiError } from '../api';
|
|
7
|
+
import { sha256Hex } from '../api-keys';
|
|
8
|
+
import { I18N, localeName } from '../i18n';
|
|
9
|
+
import { siteLang } from '../dates';
|
|
10
|
+
import { roomColor } from '../schema';
|
|
11
|
+
import { safeColor } from '../calendar/events';
|
|
12
|
+
import { canonicalDisplayQuery } from './settings';
|
|
13
|
+
import type { Screen, ScreenInput } from './store';
|
|
14
|
+
|
|
15
|
+
// Wrong keys, per client address: past this many in the window, further
|
|
16
|
+
// wrong keys from that address are refused without a lookup. A right key
|
|
17
|
+
// always opens its screen, so nobody can lock a church's TVs out by sending
|
|
18
|
+
// bad keys "from" its address (proxies can claim any address). Keys are
|
|
19
|
+
// 256-bit, so guessing gets nowhere either way.
|
|
20
|
+
export const MISS_LIMIT = 60;
|
|
21
|
+
export const MISS_WINDOW = 15 * 60_000;
|
|
22
|
+
const missKey = async (address: string) => `display:miss:${await sha256Hex(address || 'unknown')}`;
|
|
23
|
+
|
|
24
|
+
export type OpenedScreen = { status: 'ok'; screen: Screen } | { status: 'missing' } | { status: 'limited' };
|
|
25
|
+
|
|
26
|
+
/** The screen at /display/s/<key>, counting a wrong key against the caller's address. */
|
|
27
|
+
export async function openScreen(repo: ContentRepository, key: string, address: string): Promise<OpenedScreen> {
|
|
28
|
+
const screen = await repo.screens.byKey(key);
|
|
29
|
+
if (screen) return { status: 'ok', screen };
|
|
30
|
+
const limitKey = await missKey(address);
|
|
31
|
+
if (await repo.email.rateLimited(limitKey, MISS_LIMIT)) return { status: 'limited' };
|
|
32
|
+
await repo.email.rateHit(limitKey, MISS_WINDOW);
|
|
33
|
+
return { status: 'missing' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const screenPath = (s: Pick<Screen, 'key'>) => `/display/s/${s.key}`;
|
|
37
|
+
|
|
38
|
+
// ---- admin -----------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const siteLanguages = (mainLang: string) => {
|
|
41
|
+
const codes = I18N.enabled ? I18N.locales : [mainLang];
|
|
42
|
+
return codes.map((code) => ({ code, label: localeName(code, true), main: I18N.enabled ? code === I18N.defaultLocale : true }));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the Screens editor needs: the screens, and what a screen can pick
|
|
47
|
+
* from. `bookings`: the person may see booker names (Manage bookings), which
|
|
48
|
+
* private details show.
|
|
49
|
+
*/
|
|
50
|
+
export async function screensState(repo: ContentRepository, origin: string, opts: { bookings: boolean }) {
|
|
51
|
+
const [screens, site, calendars, resources, events] = await Promise.all([
|
|
52
|
+
repo.screens.list(), repo.getSettings(), repo.listCalendars(), repo.listResources(), repo.listPublishedEvents(),
|
|
53
|
+
]);
|
|
54
|
+
const tags = [...new Set(events.flatMap((e) => e.tags.map((t) => t.trim()).filter(Boolean)))].sort((a, b) => a.localeCompare(b));
|
|
55
|
+
return {
|
|
56
|
+
now: Date.now(),
|
|
57
|
+
origin,
|
|
58
|
+
canPrivate: opts.bookings,
|
|
59
|
+
weekStart: site.calendar.weekStart,
|
|
60
|
+
timezone: site.timezone || 'UTC',
|
|
61
|
+
languages: siteLanguages(siteLang(site)),
|
|
62
|
+
screens: screens.map((s) => ({ ...s, url: s.key ? origin + screenPath(s) : '' })),
|
|
63
|
+
calendars: calendars.map((c) => ({ slug: c.slug, name: c.name, color: safeColor(c.color), visibility: c.visibility, kind: c.kind })),
|
|
64
|
+
// public: named on public events, so quick links can show and filter by it
|
|
65
|
+
rooms: resources.map((r) => ({ slug: r.slug, name: r.name, kind: r.kind, color: roomColor(r), public: r.showOnEvents })),
|
|
66
|
+
tags,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ScreenFields = {
|
|
71
|
+
name: z.string().trim().min(1, 'Give the screen a name.').max(120),
|
|
72
|
+
params: z.string().max(4000),
|
|
73
|
+
locale: z.string().max(20).default(''),
|
|
74
|
+
privateDetails: z.boolean().default(false),
|
|
75
|
+
};
|
|
76
|
+
export const ScreenAction = z.discriminatedUnion('action', [
|
|
77
|
+
z.object({ action: z.literal('create'), ...ScreenFields }),
|
|
78
|
+
z.object({ action: z.literal('save'), id: z.string().min(1), ...ScreenFields }),
|
|
79
|
+
z.object({ action: z.literal('rotate'), id: z.string().min(1) }),
|
|
80
|
+
z.object({ action: z.literal('delete'), id: z.string().min(1) }),
|
|
81
|
+
z.object({ action: z.literal('reorder'), ids: z.array(z.string()).max(500) }),
|
|
82
|
+
]);
|
|
83
|
+
export type ScreenAction = z.infer<typeof ScreenAction>;
|
|
84
|
+
|
|
85
|
+
function screenInput(a: { name: string; params: string; locale: string; privateDetails: boolean }): ScreenInput {
|
|
86
|
+
// only a configured extra language; the main one is stored as ''
|
|
87
|
+
const locale = I18N.enabled && a.locale !== I18N.defaultLocale && I18N.locales.includes(a.locale) ? a.locale : '';
|
|
88
|
+
return { name: a.name, params: canonicalDisplayQuery(a.params.replace(/^\?/, '')), locale, privateDetails: a.privateDetails };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function screenAction(repo: ContentRepository, a: ScreenAction, opts: { bookings: boolean }): Promise<{ screen?: Screen; ok?: true }> {
|
|
92
|
+
// private details show booker names: only for people who may see bookings
|
|
93
|
+
if ((a.action === 'create' || a.action === 'save') && a.privateDetails && !opts.bookings) {
|
|
94
|
+
throw new ApiError(403, 'Showing private calendars and room bookings needs the “Manage bookings” permission.');
|
|
95
|
+
}
|
|
96
|
+
const found = (s: Screen | null) => {
|
|
97
|
+
if (!s) throw new ApiError(404, 'That screen was removed. Reload the page.');
|
|
98
|
+
return { screen: s };
|
|
99
|
+
};
|
|
100
|
+
switch (a.action) {
|
|
101
|
+
case 'create': return { screen: await repo.screens.create(screenInput(a)) };
|
|
102
|
+
case 'save': return found(await repo.screens.update(a.id, screenInput(a)));
|
|
103
|
+
case 'rotate': return found(await repo.screens.rotate(a.id));
|
|
104
|
+
case 'delete':
|
|
105
|
+
if (!(await repo.screens.delete(a.id))) throw new ApiError(404, 'That screen was removed. Reload the page.');
|
|
106
|
+
return { ok: true };
|
|
107
|
+
case 'reorder':
|
|
108
|
+
await repo.screens.reorder(a.ids);
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Saved TV screens (Admin → Calendar → Screens). Each one is a name, the
|
|
2
|
+
// display settings as their canonical query (lib/display/settings.ts, the
|
|
3
|
+
// same text a quick link carries), a language, the private-details switch and
|
|
4
|
+
// a long random key: the screen's whole address is /display/s/<key>. The key
|
|
5
|
+
// is kept as is, like a private calendar's feed token, so staff can copy the
|
|
6
|
+
// link again; rotating it stops the old one at once. last_seen_at is the last
|
|
7
|
+
// time the screen fetched its events. Shared by the D1 and libSQL adapters.
|
|
8
|
+
import type { SqlDriver, SqlStatement } from '../repo/sql';
|
|
9
|
+
|
|
10
|
+
export interface Screen {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** the display settings, as their canonical query ("view=rooms&hours=8-21") */
|
|
14
|
+
params: string;
|
|
15
|
+
/** the display's language ('' = the site's main language) */
|
|
16
|
+
locale: string;
|
|
17
|
+
/** shows private calendars it names and room bookings (titles, rooms, hosts, the name on a booking) */
|
|
18
|
+
privateDetails: boolean;
|
|
19
|
+
/** the secret in the screen's address; '' = no link until it's rotated */
|
|
20
|
+
key: string;
|
|
21
|
+
/** when the screen last fetched its events (ms; null = never) */
|
|
22
|
+
lastSeenAt: number | null;
|
|
23
|
+
order: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ScreenInput = Pick<Screen, 'name' | 'params' | 'locale' | 'privateDetails'>;
|
|
29
|
+
|
|
30
|
+
// v67: Admin → Calendar → Screens. Not public (each screen is reached by its
|
|
31
|
+
// own key, never cached), so no content-version trigger.
|
|
32
|
+
export const SCREENS_MIGRATION = {
|
|
33
|
+
version: 67,
|
|
34
|
+
statements: [
|
|
35
|
+
`CREATE TABLE display_screens (
|
|
36
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', params TEXT NOT NULL DEFAULT '', locale TEXT NOT NULL DEFAULT '',
|
|
37
|
+
private_details INTEGER NOT NULL DEFAULT 0, screen_key TEXT NOT NULL DEFAULT '', last_seen_at INTEGER,
|
|
38
|
+
ord INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
39
|
+
)`,
|
|
40
|
+
`CREATE UNIQUE INDEX display_screens_key ON display_screens (screen_key) WHERE screen_key <> ''`,
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 32 random bytes, base64url (43 characters) */
|
|
45
|
+
export function screenKey(): string {
|
|
46
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
47
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
48
|
+
}
|
|
49
|
+
export const isScreenKey = (s: string) => /^[A-Za-z0-9_-]{43}$/.test(s);
|
|
50
|
+
|
|
51
|
+
type Row = Record<string, unknown>;
|
|
52
|
+
const screen = (r: Row): Screen => ({
|
|
53
|
+
id: String(r.id), name: String(r.name ?? ''), params: String(r.params ?? ''), locale: String(r.locale ?? ''),
|
|
54
|
+
privateDetails: Number(r.private_details) === 1, key: String(r.screen_key ?? ''),
|
|
55
|
+
lastSeenAt: r.last_seen_at == null ? null : Number(r.last_seen_at),
|
|
56
|
+
order: Number(r.ord ?? 0), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** The INSERT for one screen (create and backup restore). */
|
|
60
|
+
export function screenInsert(s: Screen): SqlStatement {
|
|
61
|
+
return {
|
|
62
|
+
sql: `INSERT INTO display_screens (id, name, params, locale, private_details, screen_key, last_seen_at, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
63
|
+
args: [s.id, s.name, s.params, s.locale, s.privateDetails ? 1 : 0, s.key, s.lastSeenAt, s.order, s.createdAt, s.updatedAt],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// a screen fetching every few minutes writes its check-in at most once a minute
|
|
68
|
+
const CHECK_IN_EVERY = 60_000;
|
|
69
|
+
|
|
70
|
+
export class ScreenStore {
|
|
71
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
72
|
+
private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
|
|
73
|
+
|
|
74
|
+
async list(): Promise<Screen[]> {
|
|
75
|
+
return (await this.all('SELECT * FROM display_screens ORDER BY ord, name COLLATE NOCASE, created_at')).map(screen);
|
|
76
|
+
}
|
|
77
|
+
async get(id: string): Promise<Screen | null> {
|
|
78
|
+
const r = await this.all('SELECT * FROM display_screens WHERE id = ?', [id]);
|
|
79
|
+
return r[0] ? screen(r[0]) : null;
|
|
80
|
+
}
|
|
81
|
+
/** the screen with this key (null for a wrong, rotated or empty key) */
|
|
82
|
+
async byKey(key: string): Promise<Screen | null> {
|
|
83
|
+
if (!isScreenKey(key)) return null;
|
|
84
|
+
const r = await this.all('SELECT * FROM display_screens WHERE screen_key = ?', [key]);
|
|
85
|
+
return r[0] ? screen(r[0]) : null;
|
|
86
|
+
}
|
|
87
|
+
async create(input: ScreenInput, now = Date.now()): Promise<Screen> {
|
|
88
|
+
const next = await this.all('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM display_screens');
|
|
89
|
+
const s: Screen = { id: crypto.randomUUID(), ...input, key: screenKey(), lastSeenAt: null, order: Number(next[0]?.n ?? 0), createdAt: now, updatedAt: now };
|
|
90
|
+
const insert = screenInsert(s);
|
|
91
|
+
return screen((await this.all(`${insert.sql} RETURNING *`, insert.args))[0]);
|
|
92
|
+
}
|
|
93
|
+
async update(id: string, input: ScreenInput, now = Date.now()): Promise<Screen | null> {
|
|
94
|
+
const r = await this.all('UPDATE display_screens SET name = ?, params = ?, locale = ?, private_details = ?, updated_at = ? WHERE id = ? RETURNING *',
|
|
95
|
+
[input.name, input.params, input.locale, input.privateDetails ? 1 : 0, now, id]);
|
|
96
|
+
return r[0] ? screen(r[0]) : null;
|
|
97
|
+
}
|
|
98
|
+
/** a new key: the old address stops working at once */
|
|
99
|
+
async rotate(id: string, now = Date.now()): Promise<Screen | null> {
|
|
100
|
+
const r = await this.all('UPDATE display_screens SET screen_key = ?, updated_at = ? WHERE id = ? RETURNING *', [screenKey(), now, id]);
|
|
101
|
+
return r[0] ? screen(r[0]) : null;
|
|
102
|
+
}
|
|
103
|
+
async delete(id: string): Promise<boolean> {
|
|
104
|
+
return (await this.all('DELETE FROM display_screens WHERE id = ? RETURNING id', [id])).length > 0;
|
|
105
|
+
}
|
|
106
|
+
async reorder(ids: string[]): Promise<void> {
|
|
107
|
+
await (await this.driver()).batch(ids.map((id, i) => ({ sql: 'UPDATE display_screens SET ord = ? WHERE id = ?', args: [i, id] })));
|
|
108
|
+
}
|
|
109
|
+
/** the screen fetched its events (written at most once a minute) */
|
|
110
|
+
async checkIn(id: string, now = Date.now()): Promise<void> {
|
|
111
|
+
await (await this.driver()).run('UPDATE display_screens SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at <= ?)', [now, id, now - CHECK_IN_EVERY]);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/lib/display/words.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Words of the TV display (/display/…). Dates, weekdays and times come from
|
|
2
2
|
// Intl in the display's language; these are the rest. Plain strings with
|
|
3
|
-
// {slots} filled by `fill`. Other languages fall back to English.
|
|
3
|
+
// {slots} filled by `fill`. Other languages fall back to English. "Moved"
|
|
4
|
+
// and "Cancelled" are the event pages' own words (event-words.ts), so a TV
|
|
5
|
+
// says what the site and its emails say.
|
|
6
|
+
import { eventStrings } from '../calendar/event-words';
|
|
4
7
|
export interface DisplayStrings {
|
|
5
8
|
thisWeek: string;
|
|
6
9
|
/** a rolling week (start=today) or list: "Next 7 days" */
|
|
@@ -16,11 +19,13 @@ export interface DisplayStrings {
|
|
|
16
19
|
nothingNext: string;
|
|
17
20
|
noEvents: string;
|
|
18
21
|
more: string;
|
|
22
|
+
/** from eventStrings: the event pages' word */
|
|
19
23
|
moved: string;
|
|
20
24
|
/** a moved date's old time */
|
|
21
25
|
was: string;
|
|
22
26
|
/** appointments from Google booking pages (#google-booking) */
|
|
23
27
|
booked: string;
|
|
28
|
+
/** from eventStrings: the event pages' word */
|
|
24
29
|
cancelled: string;
|
|
25
30
|
scan: string;
|
|
26
31
|
tomorrow: string;
|
|
@@ -28,25 +33,30 @@ export interface DisplayStrings {
|
|
|
28
33
|
notFoundNote: string;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
const STRINGS: Record<string, DisplayStrings
|
|
36
|
+
const STRINGS: Record<string, Omit<DisplayStrings, 'moved' | 'cancelled'>> = {
|
|
32
37
|
en: {
|
|
33
38
|
thisWeek: 'This week', nextDays: 'Next {n} days', today: 'Today', upcoming: 'Coming up', allDay: 'All day',
|
|
34
39
|
updated: 'Updated {time}', now: 'Now', next: 'Next', nothingNow: 'Nothing right now', nothingNext: 'Nothing else today',
|
|
35
|
-
noEvents: 'No events', more: '+{n} more',
|
|
40
|
+
noEvents: 'No events', more: '+{n} more', was: 'was {time}', booked: 'Booked',
|
|
36
41
|
scan: 'Scan for the calendar', tomorrow: 'Tomorrow',
|
|
37
42
|
notFound: 'This screen isn’t available', notFoundNote: 'Its link may have changed. Copy the new one from Admin → Calendar → Screens.',
|
|
38
43
|
},
|
|
39
44
|
es: {
|
|
40
45
|
thisWeek: 'Esta semana', nextDays: 'Próximos {n} días', today: 'Hoy', upcoming: 'Próximamente', allDay: 'Todo el día',
|
|
41
46
|
updated: 'Actualizado {time}', now: 'Ahora', next: 'Después', nothingNow: 'Nada en este momento', nothingNext: 'Nada más hoy',
|
|
42
|
-
noEvents: 'Sin eventos', more: '+{n} más',
|
|
47
|
+
noEvents: 'Sin eventos', more: '+{n} más', was: 'antes {time}', booked: 'Reservado',
|
|
43
48
|
scan: 'Escanea para ver el calendario', tomorrow: 'Mañana',
|
|
44
49
|
notFound: 'Esta pantalla no está disponible', notFoundNote: 'Puede que su enlace haya cambiado. Copia el nuevo en Admin → Calendario → Pantallas.',
|
|
45
50
|
},
|
|
46
51
|
};
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
/** "Booked" in every language: the Google sync titles its appointments with it */
|
|
54
|
+
export const isBookedWord = (title: string) => Object.values(STRINGS).some((s) => s.booked.toLowerCase() === title.trim().toLowerCase());
|
|
55
|
+
|
|
56
|
+
export const displayStrings = (lang: string | null | undefined): DisplayStrings => {
|
|
57
|
+
const ew = eventStrings(lang);
|
|
58
|
+
return { ...(STRINGS[(lang || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en), moved: ew.moved, cancelled: ew.cancelled };
|
|
59
|
+
};
|
|
50
60
|
|
|
51
61
|
export const fill = (s: string, slots: Record<string, string | number>) =>
|
|
52
62
|
s.replace(/\{(\w+)\}/g, (m, k: string) => (k in slots ? String(slots[k]) : m));
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { formatDate } from './dates';
|
|
|
5
5
|
import { CONTENT, collectionUrl } from './content';
|
|
6
6
|
import { renderCalendarBlock, isNativeSource } from './calendar/render';
|
|
7
7
|
import { renderEventCards, type EventCardAttrs } from './calendar/cards';
|
|
8
|
+
import { localePath } from './i18n';
|
|
8
9
|
|
|
9
10
|
// Dynamic blocks: bodyHtml is a save-time cache, so cards whose content must
|
|
10
11
|
// stay fresh (post lists, calendars) serialize as placeholder divs and get
|
|
@@ -131,7 +132,8 @@ function eventCardAttrs(tag: string): EventCardAttrs {
|
|
|
131
132
|
};
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
// locale: the page's language; a form block opens the form in it (/es/forms/<id>)
|
|
136
|
+
export async function expandDynamicBlocks(html: string, repo: ContentRepository, locale = ''): Promise<string> {
|
|
135
137
|
html = stampHeadingAnchors(html);
|
|
136
138
|
const hasForms = html.includes('data-dyn="form"');
|
|
137
139
|
const hasPostList = html.includes('data-dyn="post-list"');
|
|
@@ -168,7 +170,7 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
168
170
|
// A same-origin frame gives each visitor an uncached form/receipt even
|
|
169
171
|
// when the containing content page is served from a shared cache.
|
|
170
172
|
calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
|
|
171
|
-
? `<iframe class="pc-form-embed" src="
|
|
173
|
+
? `<iframe class="pc-form-embed" src="${localePath(`/forms/${encodeURIComponent(form.id)}`, locale)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
|
|
172
174
|
}));
|
|
173
175
|
}
|
|
174
176
|
const anchored = html;
|
package/src/lib/forms/fields.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { hasOther, isHalf, type FormDefinition, type FormField } from './schema';
|
|
2
2
|
import { OTHER } from './validate';
|
|
3
|
+
import { formStrings, type FormStrings } from './words';
|
|
4
|
+
import { fill } from '../live/words';
|
|
3
5
|
export const escapeForm = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
4
6
|
const e = escapeForm;
|
|
5
|
-
const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date' };
|
|
7
|
+
const inputType: Partial<Record<FormField['type'], string>> = { phone: 'tel', email: 'email', number: 'number', date: 'date', time: 'time' };
|
|
6
8
|
// Websites use a text box so "example.org" can be typed; the server adds https://.
|
|
7
9
|
const autocomplete: Partial<Record<FormField['type'], string>> = { email: 'email', phone: 'tel', url: 'url' };
|
|
10
|
+
// Questions arrive in the page's language already (localize.ts localizeForm);
|
|
11
|
+
// `w` is the form's own words around them (words.ts), English by default.
|
|
12
|
+
const english = formStrings('en');
|
|
8
13
|
|
|
9
14
|
/** The answer control alone (input, select, textarea or option list). The builder canvas reuses it under editable labels. */
|
|
10
|
-
export function renderControl(f: FormField, value: string, id: string): string {
|
|
15
|
+
export function renderControl(f: FormField, value: string, id: string, w: FormStrings = english): string {
|
|
11
16
|
const describe = f.help ? ` aria-describedby="${e(id)}-help"` : '';
|
|
12
17
|
const attrs = `id="${e(id)}" name="q_${e(f.id)}"${f.required ? ' required' : ''}${describe}`;
|
|
13
18
|
const ph = f.placeholder ? ` placeholder="${e(f.placeholder)}"` : '';
|
|
@@ -17,31 +22,31 @@ export function renderControl(f: FormField, value: string, id: string): string {
|
|
|
17
22
|
// A required checkbox group can't be enforced natively; the server checks it.
|
|
18
23
|
const req = radio && f.required ? ' required' : '';
|
|
19
24
|
const option = (o: string, i: number) => `<label class="pc-bk-option"><input type="${radio ? 'radio' : 'checkbox'}" id="${e(id)}-${i}" name="q_${e(f.id)}" value="${e(o)}"${req}${chosen.includes(o) ? ' checked' : ''}/><span>${e(o)}</span></label>`;
|
|
20
|
-
const other = hasOther(f) ? `<label class="pc-bk-option pc-bk-other"><input type="${radio ? 'radio' : 'checkbox'}" name="q_${e(f.id)}" value="${OTHER}"${req}${extra ? ' checked' : ''}/><span
|
|
25
|
+
const other = hasOther(f) ? `<label class="pc-bk-option pc-bk-other"><input type="${radio ? 'radio' : 'checkbox'}" name="q_${e(f.id)}" value="${OTHER}"${req}${extra ? ' checked' : ''}/><span>${e(w.other)}</span><input type="text" name="o_${e(f.id)}" maxlength="200" aria-label="${e(fill(w.otherAnswer, { label: f.label }))}" placeholder="${e(f.placeholder || w.typeAnswer)}" value="${e(extra)}"/></label>` : '';
|
|
21
26
|
return `<div class="pc-bk-options">${f.options.map(option).join('')}${other}</div>`;
|
|
22
27
|
}
|
|
23
28
|
if (f.type === 'textarea') return `<textarea ${attrs}${ph} maxlength="2000" rows="3">${e(value)}</textarea>`;
|
|
24
|
-
if (f.type === 'select') return `<select ${attrs}><option value="">${e(f.placeholder ||
|
|
29
|
+
if (f.type === 'select') return `<select ${attrs}><option value="">${e(f.placeholder || w.choose)}</option>${f.options.map(o => `<option value="${e(o)}" ${o === value ? 'selected' : ''}>${e(o)}</option>`).join('')}</select>`;
|
|
25
30
|
const ac = (autocomplete[f.type] ? ` autocomplete="${autocomplete[f.type]}"` : '') + (f.type === 'url' ? ' inputmode="url" spellcheck="false"' : '');
|
|
26
31
|
return `<input ${attrs}${ph}${ac} type="${inputType[f.type] ?? 'text'}"${f.type === 'number' ? ' step="any"' : ''} maxlength="${f.type === 'phone' ? 40 : 2000}" value="${e(value)}"/>`;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
export function renderField(f: FormField, value: string, prefix: string): string {
|
|
34
|
+
export function renderField(f: FormField, value: string, prefix: string, w: FormStrings = english): string {
|
|
30
35
|
const id = `${prefix}-${f.id}`, half = isHalf(f) ? ' is-half' : '';
|
|
31
36
|
const help = f.help ? `<small id="${e(id)}-help">${e(f.help)}</small>` : '';
|
|
32
37
|
const label = `${e(f.label)}${f.required ? ' *' : ''}`;
|
|
33
38
|
if (f.type === 'heading') return `<div class="pc-form-heading" id="${e(id)}"><h3>${e(f.label)}</h3>${f.help ? `<p>${e(f.help)}</p>` : ''}</div>`;
|
|
34
39
|
if (f.type === 'checkbox' || f.type === 'consent') return `<label class="pc-bk-check${half}" for="${e(id)}"><input type="checkbox" id="${e(id)}" name="q_${e(f.id)}" ${f.required ? 'required' : ''} ${f.help ? `aria-describedby="${e(id)}-help"` : ''} value="yes" ${value === 'yes' ? 'checked' : ''}/><span>${label}${help}</span></label>`;
|
|
35
|
-
if (f.type === 'radio' || f.type === 'checkboxes') return `<fieldset class="pc-bk-field pc-bk-choices${half}"${f.help ? ` aria-describedby="${e(id)}-help"` : ''}><legend>${label}</legend>${renderControl(f, value, id)}${help}</fieldset>`;
|
|
36
|
-
return `<label class="pc-bk-field${half}" for="${e(id)}"><span>${label}</span>${renderControl(f, value, id)}${help}</label>`;
|
|
40
|
+
if (f.type === 'radio' || f.type === 'checkboxes') return `<fieldset class="pc-bk-field pc-bk-choices${half}"${f.help ? ` aria-describedby="${e(id)}-help"` : ''}><legend>${label}</legend>${renderControl(f, value, id, w)}${help}</fieldset>`;
|
|
41
|
+
return `<label class="pc-bk-field${half}" for="${e(id)}"><span>${label}</span>${renderControl(f, value, id, w)}${help}</label>`;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** Two consecutive half-width questions share a `.pc-form-row` (side by side when there's room). */
|
|
40
|
-
export function renderFields(definition: FormDefinition, values: Record<string, string> = {}, prefix = 'form'): string {
|
|
45
|
+
export function renderFields(definition: FormDefinition, values: Record<string, string> = {}, prefix = 'form', w: FormStrings = english): string {
|
|
41
46
|
const out: string[] = []; let row: string[] = [];
|
|
42
47
|
const flush = () => { if (row.length) out.push(`<div class="pc-form-row">${row.join('')}</div>`); row = []; };
|
|
43
48
|
for (const f of definition.fields) {
|
|
44
|
-
const html = renderField(f, values[f.id] ?? '', prefix);
|
|
49
|
+
const html = renderField(f, values[f.id] ?? '', prefix, w);
|
|
45
50
|
if (isHalf(f)) { row.push(html); if (row.length === 2) flush(); }
|
|
46
51
|
else { flush(); out.push(html); }
|
|
47
52
|
}
|