@acms-subir/core 0.3.52 → 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 (36) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/docs/mobile.md +1 -1
  3. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  4. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  5. package/package.json +1 -1
  6. package/src/components/ThemeSections.astro +2 -2
  7. package/src/components/editor/PostEditor.tsx +2 -0
  8. package/src/components/editor/event/EventSheet.tsx +6 -2
  9. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  10. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  11. package/src/components/forms/Canvas.tsx +31 -19
  12. package/src/components/forms/FormBuildPage.tsx +12 -6
  13. package/src/components/forms/FormBuilder.tsx +60 -11
  14. package/src/components/forms/Inspector.tsx +116 -8
  15. package/src/components/forms/TypeIcon.tsx +1 -0
  16. package/src/components/forms/meta.ts +71 -9
  17. package/src/lib/calendar/api.ts +3 -1
  18. package/src/lib/dynamicBlocks.ts +4 -2
  19. package/src/lib/forms/fields.ts +14 -9
  20. package/src/lib/forms/localize.ts +68 -0
  21. package/src/lib/forms/render.ts +8 -2
  22. package/src/lib/forms/schema.ts +31 -2
  23. package/src/lib/forms/service.ts +9 -1
  24. package/src/lib/forms/validate.ts +16 -4
  25. package/src/lib/forms/words.ts +32 -0
  26. package/src/lib/mobile/forms.ts +45 -13
  27. package/src/lib/mobile/openapi.ts +3 -3
  28. package/src/lib/mobile/rsvp.ts +4 -2
  29. package/src/lib/mobile/schemas.ts +3 -1
  30. package/src/pages/[...slug].astro +1 -1
  31. package/src/pages/admin/forms/[id].astro +6 -0
  32. package/src/pages/blog/[slug].astro +1 -1
  33. package/src/pages/events/[slug].astro +9 -3
  34. package/src/pages/forms/[id].astro +22 -4
  35. package/src/pages/index.astro +1 -1
  36. package/src/styles/forms.css +8 -0
@@ -1,9 +1,11 @@
1
1
  import { useEffect, useId, useRef, useState } from 'react';
2
2
  import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
3
3
  import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
4
- import { FIELD_ID, type FormDefinition, type FormField } from '../../lib/forms/schema';
4
+ import { FIELD_ID, type FieldWords, type FormDefinition, type FormField, type FormWords } from '../../lib/forms/schema';
5
+ import { formStrings, thanksText } from '../../lib/forms/words';
6
+ import { rsvpStrings } from '../../lib/calendar/rsvp-words';
5
7
  import { verticalOnly } from './dnd';
6
- import { META, TYPE_CHOICES, canOther, fieldIssue, isChoice, takesPlaceholder, type FieldType, type Selection, type SystemField } from './meta';
8
+ import { META, TYPE_CHOICES, canOther, fieldIssue, isChoice, remapChoices, takesPlaceholder, type FieldType, type Language, type Selection, type SystemField } from './meta';
7
9
 
8
10
  type Props = {
9
11
  value: FormDefinition; selection: Selection; system: SystemField[]; event: boolean; phone: boolean;
@@ -13,11 +15,15 @@ type Props = {
13
15
  setType: (id: string, type: FieldType) => void; onLabelDone: (id: string) => void; setKey: (id: string, key: string) => void;
14
16
  actions: { move: (id: string, dir: -1 | 1) => void; duplicate: (id: string) => void; remove: (id: string) => void };
15
17
  onAdd: () => void;
18
+ language: Language;
19
+ patchWords: (next: FormWords, key: string) => void;
20
+ patchFieldWords: (id: string, next: FieldWords, key: string) => void;
16
21
  };
17
22
 
18
23
  /** Settings for whatever is selected: a question, the title/intro, the button, the thank-you message or a locked system question. */
19
24
  export function Inspector(p: Props) {
20
25
  const { value, selection } = p;
26
+ if (p.language.code) return <WordsInspector {...p} />;
21
27
  if (selection?.kind === 'field') {
22
28
  const field = value.fields.find(f => f.id === selection.id);
23
29
  if (field) return <FieldSettings {...p} field={field} />;
@@ -68,7 +74,7 @@ export function Inspector(p: Props) {
68
74
  function FieldSettings(p: Props & { field: FormField }) {
69
75
  const { field: f, value, patchField } = p;
70
76
  const index = value.fields.findIndex(x => x.id === f.id);
71
- const issue = fieldIssue(f, value.fields);
77
+ const issue = fieldIssue(f, value.fields, p.language.names);
72
78
  const [key, setKey] = useState(f.id);
73
79
  useEffect(() => setKey(f.id), [f.id]);
74
80
  const keyError = key === f.id ? '' : !FIELD_ID.test(key) ? 'Start with a letter; use letters, numbers, - or _.' : value.fields.some(x => x.id === key) ? 'Another question uses this key.' : '';
@@ -93,7 +99,7 @@ function FieldSettings(p: Props & { field: FormField }) {
93
99
  <Text label={f.type === 'heading' ? 'Heading' : 'Label'} value={f.label} max={200} onChange={v => patchField(f.id, { label: v }, `label:${f.id}`)} onBlur={() => p.onLabelDone(f.id)} />
94
100
  <Text label={f.type === 'heading' ? 'Description' : 'Help text'} value={f.help} max={300} rows={2} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} />
95
101
  {takesPlaceholder(f) && <Text label={canOther(f.type) ? '“Other” placeholder' : f.type === 'select' ? 'Empty choice text' : 'Placeholder'} value={f.placeholder ?? ''} max={120} placeholder={f.type === 'select' ? 'Choose…' : canOther(f.type) ? 'Type your answer' : ''} onChange={v => patchField(f.id, { placeholder: v }, `ph:${f.id}`)} />}
96
- {isChoice(f.type) && <Choices options={f.options} onChange={(options, key) => patchField(f.id, { options }, key)} />}
102
+ {isChoice(f.type) && <Choices options={f.options} onChange={(options, key, map) => patchField(f.id, { options, ...(map && f.translations ? { translations: remapChoices(f.translations, map) } : {}) }, key)} />}
97
103
  {f.type !== 'heading' && (
98
104
  <div className="fb-opts">
99
105
  <Switch label="Required" hint={f.type === 'consent' || f.type === 'checkbox' ? 'People must tick it to send' : f.type === 'checkboxes' ? 'At least one choice' : undefined} on={f.required} onChange={v => patchField(f.id, { required: v }, `req:${f.id}`)} />
@@ -121,13 +127,106 @@ function FieldSettings(p: Props & { field: FormField }) {
121
127
  );
122
128
  }
123
129
 
130
+ /**
131
+ * Another language's words for whatever is selected, each under the main
132
+ * language's. Blank ones show the main language's. The questions themselves
133
+ * (types, choices, settings) are the same in every language.
134
+ */
135
+ function WordsInspector(p: Props) {
136
+ const { value, selection, language } = p;
137
+ const t = value.translations?.[language.code] ?? {};
138
+ const own = formStrings(language.code), rsvp = rsvpStrings(language.code);
139
+ const src = { sourceName: language.main };
140
+ const blank = <p className="fb-hint">Blank ones show the {language.main}.</p>;
141
+ if (selection?.kind === 'field') {
142
+ const field = value.fields.find(f => f.id === selection.id);
143
+ if (field) return <QuestionWords {...p} field={field} />;
144
+ }
145
+ if (selection?.kind === 'form') return (
146
+ <div className="fb-insp-body">
147
+ <Head title="Title & introduction" sub={language.name} />
148
+ <Text label="Form title" value={t.title ?? ''} max={120} source={value.title} {...src} onChange={v => p.patchWords({ title: v }, `title:${language.code}`)} />
149
+ <Text label="Introduction" value={t.intro ?? ''} max={1000} rows={4} source={value.intro} {...src} onChange={v => p.patchWords({ intro: v }, `intro:${language.code}`)} />
150
+ {blank}
151
+ </div>
152
+ );
153
+ if (selection?.kind === 'submit') return (
154
+ <div className="fb-insp-body">
155
+ <Head title="Submit button" sub={language.name} />
156
+ <Text label="Button label" value={t.submitLabel ?? ''} max={80} source={value.submitLabel} {...src}
157
+ placeholder={value.submitLabel ? '' : p.event ? rsvp.confirmSeat + ' / ' + rsvp.requestSeat : own.submit} onChange={v => p.patchWords({ submitLabel: v }, `submit:${language.code}`)} />
158
+ {blank}
159
+ </div>
160
+ );
161
+ if (selection?.kind === 'thanks') return (
162
+ <div className="fb-insp-body">
163
+ <Head title="Thank-you message" sub={language.name} />
164
+ <Text label="Shown after someone submits" value={t.confirmation ?? ''} max={1000} rows={5} source={value.confirmation} {...src}
165
+ placeholder={p.event ? 'Uses the booking confirmation message' : thanksText(value.confirmation, own) === own.thanks ? own.thanks : ''} onChange={v => p.patchWords({ confirmation: v }, `confirmation:${language.code}`)} />
166
+ {blank}
167
+ </div>
168
+ );
169
+ if (selection?.kind === 'system') {
170
+ const f = p.system[selection.index];
171
+ if (f) return (
172
+ <div className="fb-insp-body">
173
+ <Head title={f.label} sub={META[f.type].label} />
174
+ <p className="fb-note"><b>Always asked.</b> Its words are already in each visitor’s language.</p>
175
+ </div>
176
+ );
177
+ }
178
+ return (
179
+ <div className="fb-insp-body fb-insp-none">
180
+ <b>Writing in {language.name}</b>
181
+ <p>Click a question, the title or the button to write its words in {language.name}. Blank ones show the {language.main}.</p>
182
+ <p>Questions, choices and settings are the same in every language. Switch to {language.main} to change them.</p>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function QuestionWords(p: Props & { field: FormField }) {
188
+ const { field: f, language } = p;
189
+ const w = f.translations?.[language.code] ?? {};
190
+ const issue = fieldIssue(f, p.value.fields, language.names);
191
+ const own = formStrings(language.code);
192
+ const set = (next: FieldWords, key: string) => p.patchFieldWords(f.id, next, `${key}:${language.code}:${f.id}`);
193
+ const src = { sourceName: language.main };
194
+ return (
195
+ <div className="fb-insp-body">
196
+ <Head title={w.label?.trim() || f.label || META[f.type].label} sub={`${META[f.type].label} · ${language.name}`} />
197
+ {issue && <p className="fb-warn" role="alert">{issue}</p>}
198
+ <Text label={f.type === 'heading' ? 'Heading' : 'Label'} value={w.label ?? ''} max={200} source={f.label} {...src} onChange={v => set({ label: v }, 'label')} />
199
+ <Text label={f.type === 'heading' ? 'Description' : 'Help text'} value={w.help ?? ''} max={300} rows={2} source={f.help} {...src} onChange={v => set({ help: v }, 'help')} />
200
+ {takesPlaceholder(f) && <Text label={canOther(f.type) ? '“Other” placeholder' : f.type === 'select' ? 'Empty choice text' : 'Placeholder'} value={w.placeholder ?? ''} max={120} source={f.placeholder} {...src}
201
+ placeholder={f.placeholder ? '' : f.type === 'select' ? own.choose : canOther(f.type) ? own.typeAnswer : ''} onChange={v => set({ placeholder: v }, 'ph')} />}
202
+ {isChoice(f.type) && (
203
+ <div className="fb-f"><span className="fb-lab">Choices <small>· {language.main} on the left</small></span>
204
+ <div className="fb-tr-choices">
205
+ {f.options.map((o, i) => (
206
+ <label key={i} className="fb-tr-choice">
207
+ <span title={o}>{o}</span>
208
+ <input value={w.options?.[i] ?? ''} maxLength={200} placeholder={o} aria-label={`${o} in ${language.name}`}
209
+ onChange={e => set({ options: f.options.map((_, j) => (j === i ? e.target.value.replace(/[\r\n]+/g, ' ') : w.options?.[j] ?? '')) }, `choice-${i}`)} />
210
+ </label>
211
+ ))}
212
+ </div>
213
+ {f.allowOther && canOther(f.type) && <small className="fb-hint">“{own.other}” is added in {language.name} for you.</small>}
214
+ </div>
215
+ )}
216
+ <p className="fb-hint">Blank ones show the {language.main}. Answers are saved in {language.main}, so they read the same whatever language people answer in.</p>
217
+ </div>
218
+ );
219
+ }
220
+
124
221
  function Head({ title, sub, children }: { title: string; sub?: string; children?: React.ReactNode }) {
125
222
  return <div className="fb-insp-h"><div><b>{title}</b>{sub && <small>{sub}</small>}</div>{children}</div>;
126
223
  }
127
224
 
128
- function Text({ label, hint, value, onChange, onBlur, max, rows, placeholder }: { label: string; hint?: string; value: string; onChange: (v: string) => void; onBlur?: () => void; max: number; rows?: number; placeholder?: string }) {
225
+ // source: the main language's words while writing another one, shown above the box
226
+ function Text({ label, hint, value, onChange, onBlur, max, rows, placeholder, source, sourceName }: { label: string; hint?: string; value: string; onChange: (v: string) => void; onBlur?: () => void; max: number; rows?: number; placeholder?: string; source?: string; sourceName?: string }) {
129
227
  return (
130
228
  <label className="fb-f"><span className="fb-lab">{label}{hint && <small> · {hint}</small>}</span>
229
+ {source?.trim() && <span className="fb-src"><span>{sourceName}</span>{source}</span>}
131
230
  {rows ? <textarea rows={rows} maxLength={max} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
132
231
  : <input maxLength={max} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} onBlur={onBlur} />}
133
232
  </label>
@@ -160,8 +259,13 @@ export function Menu({ items }: { items: { label: string; run: () => void; disab
160
259
  );
161
260
  }
162
261
 
163
- /** Choice list: drag or ⌥↑↓ to reorder, Enter adds below, Backspace on an empty choice removes it, pasting several lines adds many. */
164
- function Choices({ options, onChange }: { options: string[]; onChange: (options: string[], key: string) => void }) {
262
+ /**
263
+ * Choice list: drag or ⌥↑↓ to reorder, Enter adds below, Backspace on an
264
+ * empty choice removes it, pasting several lines adds many. `map` (new
265
+ * position → old, -1 for a new choice) comes with adds, removals and moves,
266
+ * so translated choices can follow theirs.
267
+ */
268
+ function Choices({ options, onChange }: { options: string[]; onChange: (options: string[], key: string, map?: number[]) => void }) {
165
269
  const ids = useRef<string[]>([]);
166
270
  // Stable ids for drag + focus while texts change.
167
271
  while (ids.current.length < options.length) ids.current.push(Math.random().toString(36).slice(2));
@@ -170,7 +274,11 @@ function Choices({ options, onChange }: { options: string[]; onChange: (options:
170
274
  const focusAt = (i: number) => requestAnimationFrame(() => listRef.current?.querySelectorAll<HTMLInputElement>('input')[i]?.focus());
171
275
  const dndId = useId();
172
276
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
173
- const set = (next: string[], key = 'choices', idList?: string[]) => { if (idList) ids.current = idList; onChange(next.slice(0, 50), key); };
277
+ const set = (next: string[], key = 'choices', idList?: string[]) => {
278
+ const before = ids.current;
279
+ if (idList) ids.current = idList;
280
+ onChange(next.slice(0, 50), key, idList ? idList.slice(0, 50).map(id => before.indexOf(id)) : undefined);
281
+ };
174
282
  const insertAt = (i: number, values: string[]) => {
175
283
  const next = [...options]; next.splice(i, 0, ...values);
176
284
  const nextIds = [...ids.current]; nextIds.splice(i, 0, ...values.map(() => Math.random().toString(36).slice(2)));
@@ -12,6 +12,7 @@ const PATHS: Record<FieldType | 'title' | 'submit' | 'thanks', string> = {
12
12
  url: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
13
13
  number: 'M5 9h14M5 15h14M10 4 8 20M16 4l-2 16',
14
14
  date: 'M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM3 10h18M8 3v4M16 3v4',
15
+ time: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM12 7v5l3.5 2',
15
16
  consent: 'M5 12.5 9.5 17 19 7',
16
17
  checkbox: 'M5 12.5 9.5 17 19 7',
17
18
  heading: 'M6 4v16M18 4v16M6 12h12',
@@ -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
- /** Switching type keeps the label/help; choice lists survive between choice types. */
58
- export function switchType(f: FormField, type: FieldType, remembered?: string[]): FormField {
59
- const options = isChoice(type) ? (f.options.length ? f.options : remembered?.length ? remembered : ['Option 1', 'Option 2']) : [];
60
- return clean({ ...f, type, options });
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
- export function fieldIssue(f: FormField, all: FormField[]): string {
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
 
@@ -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
- rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
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('#')),
@@ -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
- export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
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="/forms/${encodeURIComponent(form.id)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
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;
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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>Other</span><input type="text" name="o_${e(f.id)}" maxlength="200" aria-label="Other answer for ${e(f.label)}" placeholder="${e(f.placeholder || 'Type your answer')}" value="${e(extra)}"/></label>` : '';
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 || 'Choose…')}</option>${f.options.map(o => `<option value="${e(o)}" ${o === value ? 'selected' : ''}>${e(o)}</option>`).join('')}</select>`;
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
  }
@@ -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);