@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cli/migrations.generated.mjs +16 -1
  3. package/docs/calendar.md +24 -10
  4. package/docs/mobile.md +1 -1
  5. package/integration.mjs +5 -0
  6. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  7. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  8. package/package.json +1 -1
  9. package/src/components/ThemeSections.astro +2 -2
  10. package/src/components/display/ScreensManager.tsx +481 -0
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/layouts/Admin.astro +2 -0
  22. package/src/lib/backup/content.ts +10 -1
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/display/board.ts +4 -3
  25. package/src/lib/display/screens.ts +111 -0
  26. package/src/lib/display/store.ts +113 -0
  27. package/src/lib/display/words.ts +16 -6
  28. package/src/lib/dynamicBlocks.ts +4 -2
  29. package/src/lib/forms/fields.ts +14 -9
  30. package/src/lib/forms/localize.ts +68 -0
  31. package/src/lib/forms/render.ts +8 -2
  32. package/src/lib/forms/schema.ts +31 -2
  33. package/src/lib/forms/service.ts +9 -1
  34. package/src/lib/forms/validate.ts +16 -4
  35. package/src/lib/forms/words.ts +32 -0
  36. package/src/lib/mobile/forms.ts +45 -13
  37. package/src/lib/mobile/openapi.ts +3 -3
  38. package/src/lib/mobile/rsvp.ts +4 -2
  39. package/src/lib/mobile/schemas.ts +3 -1
  40. package/src/lib/repo/migrations.ts +3 -0
  41. package/src/lib/repo/sql.ts +5 -0
  42. package/src/lib/repo/types.ts +5 -0
  43. package/src/pages/[...slug].astro +1 -1
  44. package/src/pages/admin/calendar/screens/index.astro +24 -0
  45. package/src/pages/admin/calendar/screens/preview.astro +36 -0
  46. package/src/pages/admin/forms/[id].astro +6 -0
  47. package/src/pages/api/calendar/screens.ts +30 -0
  48. package/src/pages/blog/[slug].astro +1 -1
  49. package/src/pages/display/s/[key].astro +48 -0
  50. package/src/pages/events/[slug].astro +9 -3
  51. package/src/pages/forms/[id].astro +22 -4
  52. package/src/pages/index.astro +1 -1
  53. package/src/styles/display.css +2 -1
  54. package/src/styles/forms.css +8 -0
  55. package/src/styles/screens-admin.css +53 -0
@@ -0,0 +1,68 @@
1
+ // A form in one of the site's other languages (schema.ts `translations`):
2
+ // the translated words laid over the main ones, word by word, so anything
3
+ // not translated yet stays in the main language. Pure (the builder uses it
4
+ // too); the caller picks the locale.
5
+ import type { FormDefinition, FormField } from './schema';
6
+
7
+ type Choices = Pick<FormField, 'options' | 'translations'>;
8
+ const pick = (v: string | undefined, fallback: string) => (v && v.trim() ? v : fallback);
9
+
10
+ /** A question's choices in one language, in the main order; untranslated ones stay as they are. */
11
+ export const choicesIn = (f: Choices, locale: string | null | undefined): string[] => {
12
+ const t = locale ? f.translations?.[locale]?.options : undefined;
13
+ return f.options.map((o, i) => t?.[i]?.trim() || o);
14
+ };
15
+
16
+ /** The main-language choice an answer stands for: itself, or the choice a translation of it sits in for. Undefined when it's neither. */
17
+ export function mainChoice(f: Choices, value: string): string | undefined {
18
+ if (f.options.includes(value)) return value;
19
+ for (const t of Object.values(f.translations ?? {})) {
20
+ const i = t.options?.findIndex(o => o.trim() === value) ?? -1;
21
+ if (i >= 0 && i < f.options.length) return f.options[i];
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /** A language in which a choice reads the same as a different choice (in any language), with that text; null when every text means one choice. */
27
+ export function choiceClash(f: Choices): { locale: string; text: string } | null {
28
+ const seen = new Map(f.options.map((o, i) => [o.trim(), i]));
29
+ for (const locale of Object.keys(f.translations ?? {})) {
30
+ for (const [i, text] of choicesIn(f, locale).entries()) {
31
+ const at = seen.get(text.trim());
32
+ if (at !== undefined && at !== i) return { locale, text };
33
+ seen.set(text.trim(), i);
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /** The form as visitors in `locale` see it (main-language words where it has none), without any translations. */
40
+ export function localizeForm(d: FormDefinition, locale: string | null | undefined): FormDefinition {
41
+ const t = locale ? d.translations?.[locale] : undefined;
42
+ const { translations: _, ...rest } = d;
43
+ return {
44
+ ...rest,
45
+ title: pick(t?.title, d.title), intro: pick(t?.intro, d.intro),
46
+ submitLabel: pick(t?.submitLabel, d.submitLabel), confirmation: pick(t?.confirmation, d.confirmation),
47
+ fields: d.fields.map((f) => {
48
+ const w = locale ? f.translations?.[locale] : undefined;
49
+ const { translations: _, ...field } = f;
50
+ const placeholder = pick(w?.placeholder, f.placeholder ?? '');
51
+ return { ...field, label: pick(w?.label, f.label), help: pick(w?.help, f.help), options: choicesIn(f, locale), ...(placeholder ? { placeholder } : {}) };
52
+ }),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * What a form's version is made of (service.ts formVersion): the questions,
58
+ * and the choices in each language (an answer names one), but none of the
59
+ * other words. A form without translated choices hashes as it always did.
60
+ */
61
+ export function withoutWords(d: FormDefinition): FormDefinition {
62
+ const { translations: _, ...rest } = d;
63
+ return { ...rest, fields: d.fields.map(({ translations, ...f }) => {
64
+ const choices = Object.entries(translations ?? {}).filter(([, t]) => t.options?.some((o) => o.trim())).map(([l, t]) => [l, t.options!] as const);
65
+ return choices.length ? { ...f, choiceWords: Object.fromEntries(choices) } as FormField : f;
66
+ }) };
67
+ }
68
+
@@ -1,11 +1,17 @@
1
1
  import type { FormDefinition } from './schema';
2
2
  import { formVersion } from './service';
3
3
  import { escapeForm, renderFields } from './fields';
4
+ import { localizeForm } from './localize';
5
+ import { formStrings } from './words';
4
6
  export { escapeForm, renderFields } from './fields';
5
7
  export function hiddenFields(version: string, requestId: string) {
6
8
  return `<input type="hidden" name="formVersion" value="${escapeForm(version)}"><input type="hidden" name="requestId" value="${escapeForm(requestId)}"><div class="pc-form-trap" aria-hidden="true"><label>Leave empty<input name="website" tabindex="-1" autocomplete="off"></label></div>`;
7
9
  }
8
- export async function renderForm(id: string, definition: FormDefinition, values: Record<string, string> = {}, error = '', requestId: string = crypto.randomUUID(), embed = false) {
10
+ // locale: the form's language (its translations, '' = the main one); lang:
11
+ // the language of the page's own words; action: where it posts (/es/forms/<id>)
12
+ export async function renderForm(id: string, definition: FormDefinition, values: Record<string, string> = {}, error = '', requestId: string = crypto.randomUUID(), embed = false, o: { locale?: string; lang?: string; action?: string } = {}) {
9
13
  const e = escapeForm;
10
- return `<section class="pc-simple-form">${definition.title ? `<h2>${e(definition.title)}</h2>` : ''}${definition.intro ? `<p>${e(definition.intro)}</p>` : ''}<form method="post" action="/forms/${e(id)}${embed ? '?embed=1' : ''}">${error ? `<p class="pc-bk-error" role="alert">${e(error)}</p>` : ''}${hiddenFields(await formVersion(definition), requestId)}${renderFields(definition, values, `f-${id}`)}<button type="submit" class="pc-bk-submit">${e(definition.submitLabel || 'Submit')}</button></form></section>`;
14
+ const d = localizeForm(definition, o.locale), w = formStrings(o.lang);
15
+ const action = (o.action ?? `/forms/${encodeURIComponent(id)}`) + (embed ? '?embed=1' : '');
16
+ return `<section class="pc-simple-form">${d.title ? `<h2>${e(d.title)}</h2>` : ''}${d.intro ? `<p>${e(d.intro)}</p>` : ''}<form method="post" action="${e(action)}">${error ? `<p class="pc-bk-error" role="alert">${e(error)}</p>` : ''}${hiddenFields(await formVersion(definition), requestId)}${renderFields(d, values, `f-${id}`, w)}<button type="submit" class="pc-bk-submit">${e(d.submitLabel || w.submit)}</button></form></section>`;
11
17
  }
@@ -1,11 +1,34 @@
1
1
  import { z } from 'zod';
2
+ import { choiceClash } from './localize';
2
3
 
3
4
  // `checkbox` and `consent` both mean "must tick to agree" (consent is what the
4
5
  // builder inserts; checkbox stays valid for existing data). `heading` has no
5
6
  // answer: it is a section title (label) with an optional description (help).
6
- export const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading'] as const;
7
+ // `time` is stored as 24-hour HH:mm, like `date` as YYYY-MM-DD.
8
+ export const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading', 'time'] as const;
7
9
  export const CHOICE_TYPES: readonly string[] = ['select', 'radio', 'checkboxes'];
8
10
  export const FIELD_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,79}$/;
11
+ // The site's other languages (lib/i18n.ts), by locale like DocTranslations:
12
+ // question: translations: { es: { label, help, placeholder, options } }
13
+ // form: translations: { es: { title, intro, submitLabel, confirmation } }
14
+ // Every word is optional; a blank or missing one shows the main language's
15
+ // (lib/forms/localize.ts). A translated choice is the choice at the same
16
+ // position, and answers always store the main-language one, so Responses,
17
+ // CSV and alerts read the same whatever language someone answered in.
18
+ const byLocale = <T extends z.ZodTypeAny>(words: T) =>
19
+ z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), words).refine(r => Object.keys(r).length <= 20, 'Too many languages.');
20
+ export const FieldWords = z.object({
21
+ label: z.string().trim().max(200).optional(), help: z.string().max(300).optional(),
22
+ placeholder: z.string().max(120).optional(),
23
+ // by position; '' = this choice isn't translated yet
24
+ options: z.array(z.string().trim().max(200).regex(/^[^\r\n]*$/, 'Each choice must be one line.')).max(50).optional(),
25
+ });
26
+ export type FieldWords = z.infer<typeof FieldWords>;
27
+ export const FormWords = z.object({
28
+ title: z.string().max(120).optional(), intro: z.string().max(1000).optional(),
29
+ submitLabel: z.string().max(80).optional(), confirmation: z.string().max(1000).optional(),
30
+ });
31
+ export type FormWords = z.infer<typeof FormWords>;
9
32
  export const FormField = z.object({
10
33
  id: z.string().regex(FIELD_ID),
11
34
  type: z.enum(FIELD_TYPES), label: z.string().trim().min(1).max(200),
@@ -18,15 +41,21 @@ export const FormField = z.object({
18
41
  placeholder: z.string().max(120).optional(),
19
42
  width: z.enum(['full', 'half']).optional(),
20
43
  allowOther: z.boolean().optional(),
44
+ translations: byLocale(FieldWords).optional(),
21
45
  });
22
46
  export type FormField = z.infer<typeof FormField>;
47
+ /** a new form's thank-you; untouched, it shows in the page's language (words.ts thanksText) */
48
+ export const DEFAULT_THANKS = 'Thank you. Your response has been received.';
23
49
  export const FormDefinition = z.object({
24
50
  title: z.string().max(120).default(''), intro: z.string().max(1000).default(''),
25
- submitLabel: z.string().max(80).default(''), confirmation: z.string().max(1000).default('Thank you. Your response has been received.'),
51
+ submitLabel: z.string().max(80).default(''), confirmation: z.string().max(1000).default(DEFAULT_THANKS),
26
52
  fields: z.array(FormField).max(30).default([]),
53
+ translations: byLocale(FormWords).optional(),
27
54
  }).superRefine((d, ctx) => {
28
55
  if (new Set(d.fields.map(f => f.id)).size !== d.fields.length) ctx.addIssue({ code: 'custom', message: 'Field IDs must be unique.' });
29
56
  for (const f of d.fields) if (CHOICE_TYPES.includes(f.type) && (!f.options.length || new Set(f.options).size !== f.options.length)) ctx.addIssue({ code: 'custom', message: `“${f.label}” needs unique choices.` });
57
+ // one text, one choice: a translated answer must map back to a single choice
58
+ for (const f of d.fields) if (CHOICE_TYPES.includes(f.type) && choiceClash(f)) ctx.addIssue({ code: 'custom', message: `“${f.label}” has a translated choice that reads the same as another choice.` });
30
59
  });
31
60
  export type FormDefinition = z.infer<typeof FormDefinition>;
32
61
  export const isHalf = (f: Pick<FormField, 'width' | 'type'>) => f.width === 'half' && f.type !== 'heading';
@@ -2,8 +2,16 @@ import { ApiError } from '../api';
2
2
  import { sha256Hex } from '../api-keys';
3
3
  import { FormDefinition, answerFields, type FormResponse } from './schema';
4
4
  import { AnswerError, checkAnswer } from './validate';
5
+ import { withoutWords } from './localize';
5
6
 
6
- export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.stringify(FormDefinition.parse(definition)));
7
+ // The version is the questions and their choices in each language, not the
8
+ // other translated words: rewording a label, help or title doesn't change
9
+ // what's asked, so a page or app that loaded the form before still sends its
10
+ // answers (no 409). A choice is answered in any language and stored as the
11
+ // main-language one, so rewording a translated choice does change the
12
+ // version: an answer in the old words gets the 409 and a reload, never a
13
+ // wrong answer. Definitions without translated choices hash as before.
14
+ export const formVersion = (definition: FormDefinition) => sha256Hex(JSON.stringify(withoutWords(FormDefinition.parse(definition))));
7
15
  export async function collectResponse(definition: FormDefinition, input: Record<string, string>, version?: string): Promise<FormResponse> {
8
16
  definition = FormDefinition.parse(definition);
9
17
  const current = await formVersion(definition);
@@ -1,6 +1,7 @@
1
1
  // Pure answer reading + validation, shared by the server (collectResponse,
2
2
  // readAnswers) and the builder's preview. No server imports here.
3
3
  import { answerFields, hasOther, type FormDefinition, type FormField } from './schema';
4
+ import { mainChoice } from './localize';
4
5
 
5
6
  /** Value of the "Other" radio/checkbox; its text arrives as `o_<field id>`. */
6
7
  export const OTHER = '__other__';
@@ -28,12 +29,19 @@ export function answersFrom(getAll: (name: string) => unknown[], definition: Pic
28
29
  return answers;
29
30
  }
30
31
 
31
- /** Validates one answer and returns its stored form. Throws AnswerError with a visitor-facing message. */
32
+ /** HH:mm, 24-hour (an hour without its leading zero is fine: "9:05" is stored "09:05"). */
33
+ const TIME = /^([01]?\d|2[0-3]):([0-5]\d)$/;
34
+
35
+ /**
36
+ * Validates one answer and returns its stored form. Throws AnswerError with a
37
+ * visitor-facing message. A choice may come in any of the form's languages;
38
+ * it's stored as the main-language choice.
39
+ */
32
40
  export function checkAnswer(f: FormField, raw: unknown): string {
33
41
  if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError(`“${f.label}” is too long.`);
34
42
  if (f.type === 'heading') return '';
35
43
  if (f.type === 'checkboxes') {
36
- const items = raw.split('\n').map(s => s.trim()).filter(Boolean);
44
+ const items = raw.split('\n').map(s => s.trim()).filter(Boolean).map(s => mainChoice(f, s) ?? s);
37
45
  const extra = items.filter(i => !f.options.includes(i));
38
46
  if (f.required && !items.length) throw new AnswerError(`Please answer “${f.label}”.`);
39
47
  if (new Set(items).size !== items.length || extra.length > (hasOther(f) ? 1 : 0) || extra.some(e => e.length > 200)) throw invalid(f);
@@ -48,8 +56,12 @@ export function checkAnswer(f: FormField, raw: unknown): string {
48
56
  if (f.type === 'email') valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
49
57
  if (f.type === 'number') valid = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value) && Number.isFinite(Number(value));
50
58
  if (f.type === 'date') valid = /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
51
- if (f.type === 'select') valid = f.options.includes(value);
52
- if (f.type === 'radio') valid = f.options.includes(value) || (hasOther(f) && value.length <= 200 && !value.includes('\n'));
59
+ if (f.type === 'time') { const m = TIME.exec(value); valid = Boolean(m); if (m) value = `${m[1].padStart(2, '0')}:${m[2]}`; }
60
+ if (f.type === 'select' || f.type === 'radio') {
61
+ const main = mainChoice(f, value);
62
+ if (main !== undefined) value = main;
63
+ else valid = f.type === 'radio' && hasOther(f) && value.length <= 200 && !value.includes('\n');
64
+ }
53
65
  if (agree) valid = value === 'yes';
54
66
  if (f.type === 'phone') valid = value.length <= 40 && /^[+\d().\s-]+$/.test(value);
55
67
  if (f.type === 'url') {
@@ -0,0 +1,32 @@
1
+ // The public form's own words, around the ones its author wrote: the button
2
+ // and the thank-you when the form has none, the dropdown's empty choice,
3
+ // "Other" with its text box, and "Send another response". In the page's
4
+ // language (/forms/<id> and /es/forms/<id>, an event's RSVP questions); the
5
+ // author's own words come from the form and its translations. {label}: the
6
+ // question (live/words.ts `fill`). Other languages fall back to English.
7
+ import { DEFAULT_THANKS } from './schema';
8
+
9
+ export interface FormStrings {
10
+ submit: string;
11
+ thanks: string;
12
+ choose: string;
13
+ other: string;
14
+ typeAnswer: string;
15
+ /** read out for the "Other" text box */
16
+ otherAnswer: string;
17
+ sendAnother: string;
18
+ }
19
+
20
+ const STRINGS: Record<string, FormStrings> = {
21
+ en: { submit: 'Submit', thanks: 'Thank you. Your response has been received.', choose: 'Choose…', other: 'Other', typeAnswer: 'Type your answer', otherAnswer: 'Other answer for {label}', sendAnother: 'Send another response' },
22
+ es: { submit: 'Enviar', thanks: 'Gracias. Recibimos tu respuesta.', choose: 'Elige…', other: 'Otro', typeAnswer: 'Escribe tu respuesta', otherAnswer: 'Otra respuesta para {label}', sendAnother: 'Enviar otra respuesta' },
23
+ fr: { submit: 'Envoyer', thanks: 'Merci. Votre réponse a bien été reçue.', choose: 'Choisir…', other: 'Autre', typeAnswer: 'Saisissez votre réponse', otherAnswer: 'Autre réponse pour {label}', sendAnother: 'Envoyer une autre réponse' },
24
+ de: { submit: 'Senden', thanks: 'Danke. Ihre Antwort ist eingegangen.', choose: 'Auswählen…', other: 'Andere', typeAnswer: 'Ihre Antwort', otherAnswer: 'Andere Antwort für {label}', sendAnother: 'Weitere Antwort senden' },
25
+ pt: { submit: 'Enviar', thanks: 'Obrigado. Recebemos sua resposta.', choose: 'Escolha…', other: 'Outro', typeAnswer: 'Digite sua resposta', otherAnswer: 'Outra resposta para {label}', sendAnother: 'Enviar outra resposta' },
26
+ };
27
+
28
+ export const formStrings = (locale: string | null | undefined): FormStrings =>
29
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
30
+
31
+ /** The form's thank-you, or the page's own words when it has none (or still has the default one). */
32
+ export const thanksText = (confirmation: string, w: FormStrings) => (confirmation.trim() && confirmation !== DEFAULT_THANKS ? confirmation : w.thanks);
@@ -3,17 +3,24 @@ import type { ContentRepository } from '../repo/types';
3
3
  import type { FormDefinition, SavedForm } from '../forms/schema';
4
4
  import { collectResponse, formVersion } from '../forms/service';
5
5
  import { saveResponse } from '../forms/alerts';
6
+ import { localizeForm } from '../forms/localize';
7
+ import { formStrings, thanksText } from '../forms/words';
6
8
  import { ApiError, readJson } from '../api';
7
9
  import { sha256Hex } from '../api-keys';
10
+ import { I18N } from '../i18n';
11
+ import { isExtraLocale } from '../translations';
12
+ import { siteLang } from '../dates';
8
13
  import { queryParams } from './http';
9
14
  import { requireAccess } from './access';
15
+ import { localeOnlyQuery, mobileLocale } from './locale';
10
16
  import type { PublicContext } from './route';
11
17
 
12
- // Published general forms for apps: read the questions, send answers. The
13
- // same validation and storage as the web form (/forms/<id>), plus app
14
- // limits: an Idempotency-Key per response (retries never duplicate), 10
15
- // responses per address per form per minute, and a per-form daily ceiling
16
- // from Admin → Mobile app so one form can't be flooded.
18
+ // Published general forms for apps: read the questions (in the app's
19
+ // language with ?locale=), send answers. The same validation and storage as
20
+ // the web form (/forms/<id>), plus app limits: an Idempotency-Key per
21
+ // response (retries never duplicate), 10 responses per address per form per
22
+ // minute, and a per-form daily ceiling from Admin → Mobile app so one form
23
+ // can't be flooded.
17
24
 
18
25
  const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19
26
 
@@ -25,10 +32,15 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
25
32
  return form;
26
33
  }
27
34
 
28
- /** A form's questions for apps; `version` is the one collectResponse accepts (also for event RSVPs). */
29
- export async function formJson(d: FormDefinition) {
35
+ /**
36
+ * A form's questions for apps, in `locale` (the main-language words where it
37
+ * has none; never the translations themselves); `version` is the one
38
+ * collectResponse accepts (also for event RSVPs), the same in every language.
39
+ */
40
+ export async function formJson(definition: FormDefinition, locale?: string | null) {
41
+ const d = localizeForm(definition, isExtraLocale(locale) ? locale : '');
30
42
  return {
31
- version: await formVersion(d),
43
+ version: await formVersion(definition),
32
44
  title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
33
45
  fields: d.fields.map((f) => ({
34
46
  id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
@@ -37,12 +49,26 @@ export async function formJson(d: FormDefinition) {
37
49
  };
38
50
  }
39
51
 
52
+ /** the language a request asks for (`?locale=`), or the site's main one */
53
+ function formLocale(url: URL, read: boolean) {
54
+ if (read) return mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
55
+ // sending answers never fails over the language: one the site doesn't
56
+ // have gets the main language's thank-you (the response is the same)
57
+ const locale = url.searchParams.get('locale');
58
+ return locale && I18N.locales.includes(locale) ? locale : I18N.defaultLocale || null;
59
+ }
60
+ // the button and thank-you when the form has none, in the request's language (else the site's)
61
+ async function ownWords(repo: ContentRepository, locale: string | null) {
62
+ return formStrings(locale ?? siteLang(await repo.getSettings()));
63
+ }
64
+
40
65
  export async function getForm(repo: ContentRepository, url: URL, id: string) {
41
- z.object({}).strict().parse(queryParams(url));
66
+ const locale = formLocale(url, true);
42
67
  await requireAccess(repo, 'forms');
43
68
  const form = await publishedForm(repo, id);
44
- const d = form.definition;
45
- return { id: form.id, ...await formJson(d), title: d.title || form.name, submitLabel: d.submitLabel || 'Submit' };
69
+ const json = await formJson(form.definition, locale);
70
+ const w = await ownWords(repo, locale);
71
+ return { id: form.id, ...json, title: json.title || form.name, submitLabel: json.submitLabel || w.submit, confirmation: thanksText(json.confirmation, w) };
46
72
  }
47
73
 
48
74
  // field id → answer; checkbox groups send a list. "Other" answers send the typed text.
@@ -60,6 +86,7 @@ export const formResponseInput = z.object({
60
86
  }).strict();
61
87
 
62
88
  export async function submitFormResponse(c: PublicContext) {
89
+ const locale = formLocale(c.url, false);
63
90
  const settings = await requireAccess(c.repo, 'forms');
64
91
  const id = c.params.id ?? '';
65
92
  const key = c.request.headers.get('idempotency-key') ?? '';
@@ -77,12 +104,17 @@ export async function submitFormResponse(c: PublicContext) {
77
104
  const unchanged = JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition);
78
105
  if (unchanged && JSON.stringify(await collectResponse(form.definition, answers, body.version)) !== prior)
79
106
  throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
80
- return { received: true as const, confirmation: form.definition.confirmation };
107
+ return receipt(c.repo, form, locale);
81
108
  }
82
109
  const response = await collectResponse(form.definition, answers, body.version);
83
110
  // only valid new responses count toward the daily ceiling
84
111
  const cap = settings.mobileAccess.responsesPerFormDay;
85
112
  if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw new ApiError(429, 'This form has had a lot of responses today. Please try again tomorrow.', 86_400);
86
113
  await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
87
- return { received: true as const, confirmation: form.definition.confirmation };
114
+ return receipt(c.repo, form, locale);
115
+ }
116
+ // the thank-you in the app's language (?locale=), as the web form shows it
117
+ async function receipt(repo: ContentRepository, form: SavedForm, locale: string | null) {
118
+ const d = localizeForm(form.definition, isExtraLocale(locale) ? locale : '');
119
+ return { received: true as const, confirmation: thanksText(d.confirmation, await ownWords(repo, locale)) };
88
120
  }
@@ -61,7 +61,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
61
61
  ['/announcements/{id}', 'One announcement that is up, in the requested language; requires announcements', localeOnlyQuery, ref('AnnouncementDetail')],
62
62
  ['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
63
63
  ['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
64
- ['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
64
+ ['/forms/{id}', 'A published form’s questions, in the requested language (locale: its labels, help, choices, button and thank-you there; words it has no translation for stay in the main language; version is the same in every language); 410 when it stopped accepting responses (owner switch: forms)', localeOnlyQuery, ref('Form')],
65
65
  ['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
66
66
  ];
67
67
  for (const [path, summary, query, schema, cache = true] of reads) {
@@ -114,8 +114,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
114
114
  paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
115
115
  responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
116
116
  paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
117
- description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409. Leave website empty (spam trap).' }),
118
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
117
+ description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation in that language (one the site doesn’t have gets the main language’s). Leave website empty (spam trap).' }),
118
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'locale', in: 'query', required: false, schema: { type: 'string', minLength: 1, maxLength: 35 } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
119
119
  const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
120
120
  paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
121
121
  description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
@@ -61,7 +61,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
61
61
  const closed = rsvpClosed(o, settings);
62
62
  const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
63
63
  const reason = closed ?? (left === 0 ? 'full' as const : null);
64
- const form = rsvpQuestions(o.event);
64
+ const questions = rsvpQuestions(o.event);
65
+ // the questions in the app's language, where the form has its words
66
+ const form = questions ? await formJson(questions, locale) : null;
65
67
  const title = localizeDoc(o.event, locale ?? undefined).title;
66
68
  // the built-in button speaks the app's language (the site's own without languages set up)
67
69
  let submitLabel = form?.submitLabel ?? '';
@@ -79,6 +81,6 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
79
81
  maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
80
82
  // a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
81
83
  waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
82
- form: form ? { ...await formJson(form), submitLabel } : null,
84
+ form: form ? { ...form, submitLabel } : null,
83
85
  };
84
86
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale } from '../schema';
3
+ import { FIELD_TYPES } from '../forms/schema';
3
4
 
4
5
  const str = z.string(), bool = z.boolean(), strings = z.array(str);
5
6
  const date = str.datetime(), locale = str.nullable();
@@ -73,7 +74,8 @@ export const Links = z.object({
73
74
  export const Form = z.object({
74
75
  id: str, version: str, title: str, intro: str, submitLabel: str, confirmation: str,
75
76
  fields: z.array(z.object({
76
- id: str, type: z.enum(['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading']),
77
+ // time: 24-hour HH:mm. An app that doesn't know a type can ask it as short text; the site checks the answer
78
+ id: str, type: z.enum(FIELD_TYPES),
77
79
  label: str, required: bool, help: str, options: strings, placeholder: str, width: z.enum(['full', 'half']), allowOther: bool,
78
80
  })),
79
81
  });
@@ -10,6 +10,7 @@ import { PLACES_MIGRATION } from '../maps/migration';
10
10
  import { ROLES_MIGRATION } from '../permissions/migration';
11
11
  import { SYNC_MIGRATION } from '../sync/migration';
12
12
  import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
13
+ import { SCREENS_MIGRATION } from '../display/store';
13
14
  // Versioned schema migrations shared by the two SQLite-dialect adapters
14
15
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
15
16
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -727,6 +728,8 @@ export const MIGRATIONS: Migration[] = [
727
728
  // single dates of a series moved (CalEvent.movedDates): a column on the
728
729
  // row, so the triggers on events (content version, event_changes) see a move
729
730
  MOVED_DATES_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'])[2]),
731
+ // saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
732
+ SCREENS_MIGRATION,
730
733
  ];
731
734
 
732
735
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -6,6 +6,7 @@ import { GuestStore } from '../mobile/guest-store';
6
6
  import { LiveStore } from '../live/store';
7
7
  import { PushStore } from '../push/store';
8
8
  import { AnnouncementStore, announcementInsert } from '../announcements/store';
9
+ import { ScreenStore, screenInsert, screenKey } from '../display/store';
9
10
  import { EditorStore } from '../editor/store';
10
11
  import { PermissionStore } from '../permissions/store';
11
12
  import { BackupStore } from '../backup/store';
@@ -296,6 +297,7 @@ export class SqlRepository implements ContentRepository {
296
297
  readonly editor: EditorStore;
297
298
  readonly backups: BackupStore;
298
299
  readonly permissions: PermissionStore;
300
+ readonly screens: ScreenStore;
299
301
 
300
302
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
301
303
  this.ready = this.init(driver);
@@ -310,6 +312,7 @@ export class SqlRepository implements ContentRepository {
310
312
  this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
311
313
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
312
314
  this.permissions = new PermissionStore(async () => { await this.ready; return this.driver; });
315
+ this.screens = new ScreenStore(async () => { await this.ready; return this.driver; });
313
316
  }
314
317
 
315
318
  private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
@@ -1938,6 +1941,8 @@ export class SqlRepository implements ContentRepository {
1938
1941
  // announcements (a backup made before them leaves the current ones). Restored
1939
1942
  // rows never push again here: the push ledger keeps announcement:<id>
1940
1943
  ...(content.announcements ? [{ sql: 'DELETE FROM announcements' }, ...content.announcements.map(announcementInsert)] : []),
1944
+ // screens come back with new keys (backups never hold them): each TV needs its new link
1945
+ ...(content.screens ? [{ sql: 'DELETE FROM display_screens' }, ...content.screens.map((s) => screenInsert({ ...s, key: screenKey(), lastSeenAt: null }))] : []),
1941
1946
  ...(content.forms ?? []).map(formInsert),
1942
1947
  ...(content.formSubmissions ?? []).map(submissionInsert),
1943
1948
  ...(content.resources ?? []).map((r) => this.resourceInsert(r)),
@@ -13,6 +13,7 @@ import type { AnnouncementStore } from '../announcements/store';
13
13
  import type { Announcement } from '../announcements/types';
14
14
  import type { EditorStore } from '../editor/store';
15
15
  import type { PermissionStore } from '../permissions/store';
16
+ import type { Screen, ScreenStore } from '../display/store';
16
17
  export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
17
18
  export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
18
19
  export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
@@ -83,6 +84,7 @@ export interface ContentRepository {
83
84
  readonly announcements: AnnouncementStore;
84
85
  readonly editor: EditorStore;
85
86
  readonly permissions: PermissionStore;
87
+ readonly screens: ScreenStore;
86
88
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
87
89
  listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
88
90
  exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
@@ -367,6 +369,9 @@ export interface ContentRepository {
367
369
  formSubmissions?: FormSubmission[];
368
370
  // added with announcements; a backup without them leaves the current ones
369
371
  announcements?: Announcement[];
372
+ // saved TV screens, without their keys (each gets a new one); a backup
373
+ // without them leaves the current ones
374
+ screens?: Screen[];
370
375
  }, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
371
376
 
372
377
  // imports (lib/import/apply.ts drives these). writeImported is the
@@ -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;
@@ -0,0 +1,24 @@
1
+ ---
2
+ import Admin from '../../../../layouts/Admin.astro';
3
+ import ScreensManager from '../../../../components/display/ScreensManager';
4
+ import { getRepo } from '../../../../lib/repo';
5
+ import { CALENDAR } from '../../../../lib/calendar/config';
6
+ import { forbiddenUnless } from '../../../../lib/permissions/access';
7
+
8
+ // Admin → Calendar → Screens: saved TV screens and quick links
9
+ // (lib/display). A screen's link can show private calendars, so it takes the
10
+ // same permission as calendars and rooms.
11
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
12
+ const repo = await getRepo(Astro.locals);
13
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
14
+ if (denied) return denied;
15
+ ---
16
+ <Admin title="Screens" section="calendar:screens" wide>
17
+ <div class="page-head">
18
+ <div>
19
+ <h1>Screens</h1>
20
+ <p class="sub">Full-screen calendars for TVs. A change here reaches the TV within 5 minutes.</p>
21
+ </div>
22
+ </div>
23
+ <ScreensManager client:only="react" />
24
+ </Admin>
@@ -0,0 +1,36 @@
1
+ ---
2
+ import DisplayPage from '../../../../components/display/DisplayPage.astro';
3
+ import { getRepo } from '../../../../lib/repo';
4
+ import { CALENDAR } from '../../../../lib/calendar/config';
5
+ import { I18N, localePath } from '../../../../lib/i18n';
6
+ import { siteOrigin } from '../../../../lib/seo';
7
+ import { siteLang } from '../../../../lib/dates';
8
+ import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
9
+ import { readDisplaySettings } from '../../../../lib/display/settings';
10
+ import { loadDisplayBoard } from '../../../../lib/display/board';
11
+
12
+ // The Screens editor's live preview (a 16:9 frame beside the settings): the
13
+ // board as the TV will show it, from settings not saved yet. ?lang= picks the
14
+ // language, ?private=1 shows what a screen with private details shows (for
15
+ // people who may see bookings). It never refreshes, keeps no offline copy
16
+ // and counts as no screen's check-in.
17
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
18
+ const repo = await getRepo(Astro.locals);
19
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'schedule.manage');
20
+ if (denied) return denied;
21
+ const site = await repo.getSettings();
22
+ const q = Astro.url.searchParams;
23
+ const canPrivate = Boolean((await accessFor(Astro.request, Astro.cookies, repo))?.can('schedule.bookings'));
24
+ const settings = readDisplaySettings(q);
25
+ const wanted = q.get('lang') ?? '';
26
+ const lang = I18N.enabled && I18N.locales.includes(wanted) ? wanted : siteLang(site);
27
+ const board = await loadDisplayBoard(repo, site, {
28
+ settings, privateDetails: q.get('private') === '1' && canPrivate, lang,
29
+ phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
30
+ });
31
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
32
+ Astro.response.headers.set('Content-Security-Policy', "frame-ancestors 'self'");
33
+ Astro.response.headers.set('Referrer-Policy', 'no-referrer');
34
+ ---
35
+
36
+ <DisplayPage site={site} board={board} settings={settings} title="Screen preview" lang={lang} live={false} />
@@ -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>