@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.
- package/CHANGELOG.md +17 -0
- package/docs/mobile.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/calendar/api.ts +3 -1
- 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/pages/[...slug].astro +1 -1
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/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
|
-
|
|
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
|
-
/**
|
|
164
|
-
|
|
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[]) => {
|
|
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
|
-
/**
|
|
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/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/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
|
}
|
|
@@ -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
|
+
|
package/src/lib/forms/render.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/forms/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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';
|
package/src/lib/forms/service.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|