@acms-subir/core 0.3.52 → 0.3.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +18 -1
- package/docs/calendar.md +20 -2
- package/docs/mobile.md +1 -1
- package/docs/server.md +61 -1
- package/integration.mjs +11 -5
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/email/service.ts +12 -2
- package/src/lib/email/templates.ts +12 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +7 -0
- package/src/lib/repo/types.ts +6 -1
- package/src/lib/schema.ts +47 -0
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +53 -0
- package/src/styles/forms.css +8 -0
- package/src/virtual.d.ts +1 -1
|
@@ -2,18 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { actions, isActionError } from 'astro:actions';
|
|
4
4
|
import type { FormDefinition, SavedForm } from '../../lib/forms/schema';
|
|
5
|
-
import FormBuilder from './FormBuilder';
|
|
5
|
+
import FormBuilder, { type SiteLanguage } from './FormBuilder';
|
|
6
6
|
import { Menu } from './Inspector';
|
|
7
7
|
import { definitionIssue } from './meta';
|
|
8
8
|
|
|
9
9
|
type SaveState = 'saved' | 'dirty' | 'saving' | 'error' | 'invalid' | 'conflict';
|
|
10
|
-
type Props = { form: Pick<SavedForm, 'id' | 'name' | 'kind' | 'purpose' | 'status' | 'revision'> & { hasDraft: boolean }; definition: FormDefinition; responses?: number
|
|
10
|
+
type Props = { form: Pick<SavedForm, 'id' | 'name' | 'kind' | 'purpose' | 'status' | 'revision'> & { hasDraft: boolean }; definition: FormDefinition; responses?: number;
|
|
11
|
+
/** the site's other languages and its main one (none: no language switcher) */
|
|
12
|
+
languages?: SiteLanguage[]; mainLanguage?: SiteLanguage;
|
|
13
|
+
};
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* The Build page island: the builder plus autosave (1.2s after the last
|
|
14
17
|
* change), Publish, and the header actions (portaled into FormHeader's slot).
|
|
15
18
|
*/
|
|
16
|
-
export default function FormBuildPage({ form, definition, responses = 0 }: Props) {
|
|
19
|
+
export default function FormBuildPage({ form, definition, responses = 0, languages = [], mainLanguage }: Props) {
|
|
17
20
|
const [value, setValue] = useState(definition);
|
|
18
21
|
const [name, setName] = useState(form.name);
|
|
19
22
|
const [status, setStatus] = useState(form.status);
|
|
@@ -30,6 +33,8 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
30
33
|
const inflight = useRef<Promise<boolean> | null>(null);
|
|
31
34
|
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
32
35
|
const template = form.kind === 'template';
|
|
36
|
+
// language names for "In Español, …" issues (fixed for the page's life)
|
|
37
|
+
const names = useRef(Object.fromEntries(languages.map(l => [l.code, l.name]))).current;
|
|
33
38
|
|
|
34
39
|
useEffect(() => { setSlot(document.getElementById('fb-actions')); }, []);
|
|
35
40
|
|
|
@@ -38,7 +43,7 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
38
43
|
while (inflight.current) await inflight.current;
|
|
39
44
|
const snapshot = latest.current, json = JSON.stringify(snapshot);
|
|
40
45
|
if (op === 'save' && json === saved.current) { setState('saved'); return true; }
|
|
41
|
-
const issue = definitionIssue(snapshot.value) || (!snapshot.name.trim() ? 'The form needs a name.' : '');
|
|
46
|
+
const issue = definitionIssue(snapshot.value, names) || (!snapshot.name.trim() ? 'The form needs a name.' : '');
|
|
42
47
|
if (issue) { setState('invalid'); setMessage(issue); return false; }
|
|
43
48
|
setState('saving');
|
|
44
49
|
const run = (async () => {
|
|
@@ -138,7 +143,8 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
138
143
|
? form.purpose === 'general' && <button type="button" className="btn primary" disabled={busy || state === 'conflict'} onClick={useTemplate}>Use template</button>
|
|
139
144
|
: <button type="button" className="btn primary" disabled={busy || !canPublish} onClick={publish}>{live ? 'Publish changes' : 'Publish'}</button>}
|
|
140
145
|
<Menu items={[
|
|
141
|
-
...(!template && live ? [{ label: 'Open public form ↗', run: () => window.open(`/forms/${form.id}`, '_blank', 'noopener') }
|
|
146
|
+
...(!template && live ? [{ label: 'Open public form ↗', run: () => window.open(`/forms/${form.id}`, '_blank', 'noopener') },
|
|
147
|
+
...languages.map(l => ({ label: `Open in ${l.name} ↗`, run: () => window.open(`/${l.code}/forms/${form.id}`, '_blank', 'noopener') }))] : []),
|
|
142
148
|
{ label: template ? 'Duplicate template' : 'Save as new template', disabled: busy, run: saveAsTemplate },
|
|
143
149
|
...(status !== 'archived' ? [{ label: template ? 'Archive template' : 'Archive form', danger: true, run: archive }] : []),
|
|
144
150
|
]} />
|
|
@@ -149,7 +155,7 @@ export default function FormBuildPage({ form, definition, responses = 0 }: Props
|
|
|
149
155
|
<>
|
|
150
156
|
{slot && createPortal(headerActions, slot)}
|
|
151
157
|
<FormBuilder value={value} onChange={setValue} mode={form.purpose === 'event' ? 'event' : 'standalone'} name={name} onNameChange={setName}
|
|
152
|
-
responses={responses} preview={preview} onPreviewChange={setPreview} />
|
|
158
|
+
responses={responses} preview={preview} onPreviewChange={setPreview} languages={languages} mainLanguage={mainLanguage} />
|
|
153
159
|
</>
|
|
154
160
|
);
|
|
155
161
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { arrayMove } from '@dnd-kit/sortable';
|
|
3
|
-
import { starter, type FormDefinition, type FormField } from '../../lib/forms/schema';
|
|
3
|
+
import { starter, type FieldWords, type FormDefinition, type FormField, type FormWords } from '../../lib/forms/schema';
|
|
4
|
+
import { choicesIn, localizeForm } from '../../lib/forms/localize';
|
|
5
|
+
import { formStrings } from '../../lib/forms/words';
|
|
6
|
+
import { rsvpStrings } from '../../lib/calendar/rsvp-words';
|
|
4
7
|
import { Canvas, Preview } from './Canvas';
|
|
5
8
|
import { Inspector } from './Inspector';
|
|
6
9
|
import { InsertMenu } from './InsertMenu';
|
|
7
10
|
import { Outline } from './Outline';
|
|
8
|
-
import { DEFAULT_EVENT_SYSTEM, META, clean, keyFrom, newField, switchType, type FieldType, type Selection, type SystemField } from './meta';
|
|
11
|
+
import { DEFAULT_EVENT_SYSTEM, META, clean, keyFrom, newField, rememberChoices, switchType, withFieldWords, withFormWords, type FieldType, type Language, type RememberedChoices, type Selection, type SystemField } from './meta';
|
|
9
12
|
import { useFormHistory } from './useFormHistory';
|
|
10
13
|
|
|
14
|
+
/** A site language: its code and its name in itself ("Español"). */
|
|
15
|
+
export type SiteLanguage = { code: string; name: string };
|
|
16
|
+
const ENGLISH: SiteLanguage = { code: 'en', name: 'English' };
|
|
17
|
+
|
|
11
18
|
export type FormBuilderProps = {
|
|
12
19
|
value: FormDefinition;
|
|
13
20
|
onChange: (v: FormDefinition) => void;
|
|
@@ -24,6 +31,9 @@ export type FormBuilderProps = {
|
|
|
24
31
|
/** Controlled preview (the Build page drives it from its header). */
|
|
25
32
|
preview?: boolean;
|
|
26
33
|
onPreviewChange?: (on: boolean) => void;
|
|
34
|
+
/** The site's other languages (lib/i18n.ts extraLocales): a switcher to write the form's words in each. None: no switcher. */
|
|
35
|
+
languages?: SiteLanguage[];
|
|
36
|
+
mainLanguage?: SiteLanguage;
|
|
27
37
|
};
|
|
28
38
|
type Layout = 'wide' | 'mid' | 'phone';
|
|
29
39
|
|
|
@@ -32,12 +42,19 @@ type Layout = 'wide' | 'mid' | 'phone';
|
|
|
32
42
|
* canvas + inspector (outline in a drawer) under 1100px of its own width, and
|
|
33
43
|
* to a question list with a bottom-sheet inspector under 760px.
|
|
34
44
|
*/
|
|
35
|
-
export default function FormBuilder({ value, onChange, event = false, mode, systemFields, name, onNameChange, responses = 0, preview: previewProp, onPreviewChange }: FormBuilderProps) {
|
|
45
|
+
export default function FormBuilder({ value, onChange, event = false, mode, systemFields, name, onNameChange, responses = 0, preview: previewProp, onPreviewChange, languages = [], mainLanguage }: FormBuilderProps) {
|
|
36
46
|
const isEvent = mode ? mode === 'event' : event;
|
|
37
47
|
const system = systemFields ?? (isEvent ? DEFAULT_EVENT_SYSTEM : []);
|
|
38
48
|
const history = useFormHistory(value, onChange);
|
|
39
49
|
const { commit } = history;
|
|
40
50
|
const valueRef = useRef(value); valueRef.current = value;
|
|
51
|
+
// '' = the main language; another one writes only that language's words
|
|
52
|
+
// (the questions, their choices and settings stay shared)
|
|
53
|
+
const [langChoice, setLang] = useState('');
|
|
54
|
+
const main = mainLanguage ?? ENGLISH;
|
|
55
|
+
const current = languages.find(l => l.code === langChoice);
|
|
56
|
+
const lang = current?.code ?? '';
|
|
57
|
+
const language: Language = { code: lang, name: current?.name ?? main.name, main: main.name, names: Object.fromEntries([main, ...languages].map(l => [l.code, l.name])) };
|
|
41
58
|
const [selection, setSelection] = useState<Selection>(null);
|
|
42
59
|
const [inserting, setInserting] = useState<{ after: string | null } | null>(null);
|
|
43
60
|
const [drawer, setDrawer] = useState(false);
|
|
@@ -52,7 +69,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
52
69
|
// Keys of questions that already existed when there were responses stay fixed; label edits keep new questions' keys in sync until a key is typed by hand.
|
|
53
70
|
const lockedKeys = useMemo(() => new Set(responses > 0 ? value.fields.map(f => f.id) : []), [responses]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
54
71
|
const autoKeys = useRef(new Set<string>());
|
|
55
|
-
const optionMemory = useRef(new Map<string,
|
|
72
|
+
const optionMemory = useRef(new Map<string, RememberedChoices>());
|
|
56
73
|
|
|
57
74
|
useLayoutEffect(() => {
|
|
58
75
|
const el = rootRef.current; if (!el) return;
|
|
@@ -72,6 +89,13 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
72
89
|
const patchField = useCallback((id: string, next: Partial<FormField>, key: string) => {
|
|
73
90
|
setFields(valueRef.current.fields.map(f => f.id === id ? clean({ ...f, ...next }) : f), key);
|
|
74
91
|
}, [setFields]);
|
|
92
|
+
// the words of the language being written
|
|
93
|
+
const patchWords = useCallback((next: FormWords, key: string) => {
|
|
94
|
+
if (lang) commit({ ...valueRef.current, ...withFormWords(valueRef.current, lang, next) }, key);
|
|
95
|
+
}, [commit, lang]);
|
|
96
|
+
const patchFieldWords = useCallback((id: string, next: FieldWords, key: string) => {
|
|
97
|
+
if (lang) setFields(valueRef.current.fields.map(f => f.id === id ? withFieldWords(f, lang, next) : f), key);
|
|
98
|
+
}, [setFields, lang]);
|
|
75
99
|
const select = (s: Selection) => { setSelection(s); setDrawer(false); if (layout === 'phone' && s) setSheet(true); };
|
|
76
100
|
|
|
77
101
|
// Focus after React has rendered the moved/new question.
|
|
@@ -128,7 +152,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
128
152
|
};
|
|
129
153
|
const setType = (id: string, type: FieldType) => {
|
|
130
154
|
const f = valueRef.current.fields.find(x => x.id === id); if (!f) return;
|
|
131
|
-
if (f.options.length) optionMemory.current.set(id, f
|
|
155
|
+
if (f.options.length) optionMemory.current.set(id, rememberChoices(f));
|
|
132
156
|
setFields(valueRef.current.fields.map(x => x.id === id ? switchType(x, type, optionMemory.current.get(id)) : x));
|
|
133
157
|
};
|
|
134
158
|
const onLabelDone = (id: string) => {
|
|
@@ -183,23 +207,46 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
183
207
|
return () => document.removeEventListener('keydown', onDoc);
|
|
184
208
|
}, []);
|
|
185
209
|
const openInsert = (after: string | null = selection?.kind === 'field' ? selection.id : null) => { setDrawer(false); setInserting({ after }); };
|
|
210
|
+
// another language: the list and preview show the form as its visitors see
|
|
211
|
+
// it; the canvas holds its own words ('' where none yet) over the main ones
|
|
212
|
+
const shown = lang ? localizeForm(value, lang) : value;
|
|
213
|
+
const t = (lang && value.translations?.[lang]) || {};
|
|
214
|
+
const words = lang ? {
|
|
215
|
+
...value, title: t.title ?? '', intro: t.intro ?? '', submitLabel: t.submitLabel ?? '', confirmation: t.confirmation ?? '',
|
|
216
|
+
fields: value.fields.map((f) => {
|
|
217
|
+
const w = f.translations?.[lang];
|
|
218
|
+
return { ...f, label: w?.label ?? '', help: w?.help ?? '', options: choicesIn(f, lang), placeholder: w?.placeholder?.trim() || f.placeholder };
|
|
219
|
+
}),
|
|
220
|
+
} : value;
|
|
221
|
+
const canvasEdits = lang ? {
|
|
222
|
+
patch: (next: Partial<FormDefinition>, key: string) => patchWords(next, `${key}:${lang}`),
|
|
223
|
+
patchField: (id: string, next: Partial<FormField>, key: string) => patchFieldWords(id, { ...(next.label !== undefined ? { label: next.label } : {}), ...(next.help !== undefined ? { help: next.help } : {}) }, `${key}:${lang}`),
|
|
224
|
+
onLabelDone: () => {},
|
|
225
|
+
source: value, words: formStrings(lang), submitFallback: isEvent ? rsvpStrings(lang).confirmSeat : formStrings(lang).submit,
|
|
226
|
+
} : { patch, patchField, onLabelDone };
|
|
227
|
+
const langSwitch = languages.length > 0 && (
|
|
228
|
+
<div className="fb-seg fb-langs" role="group" aria-label="Language">
|
|
229
|
+
{[main, ...languages].map(l => <button key={l.code} type="button" aria-pressed={l.code === main.code ? !lang : lang === l.code} onClick={() => setLang(l.code === main.code ? '' : l.code)}>{l.name}</button>)}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
186
232
|
const outline = (
|
|
187
|
-
<Outline fields={fields} system={system} selection={selection} onSelect={s => {
|
|
233
|
+
<Outline fields={shown.fields} system={system} selection={selection} onSelect={s => {
|
|
188
234
|
select(s);
|
|
189
235
|
// Picking from the list brings the question into view on the canvas.
|
|
190
236
|
if (s?.kind === 'field' && layout !== 'phone') requestAnimationFrame(() => rootRef.current?.querySelector(`[data-item="${CSS.escape(s.id)}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
|
191
237
|
}} phone={layout === 'phone'}
|
|
192
238
|
onReorder={(from, to) => setFields(arrayMove(valueRef.current.fields, from, to))} onAdd={() => openInsert()} actions={actions}
|
|
193
|
-
labels={{ title:
|
|
239
|
+
labels={{ title: shown.title, submit: shown.submitLabel || (isEvent ? 'Confirm my seat' : 'Submit') }} />
|
|
194
240
|
);
|
|
195
241
|
const inspector = (
|
|
196
242
|
<Inspector value={value} selection={selection} system={system} event={isEvent} phone={layout === 'phone'} name={name} onNameChange={onNameChange}
|
|
197
|
-
lockedKeys={lockedKeys} patch={patch} patchField={patchField} setType={setType} onLabelDone={onLabelDone} setKey={setKey} actions={actions} onAdd={() => openInsert()}
|
|
243
|
+
lockedKeys={lockedKeys} patch={patch} patchField={patchField} setType={setType} onLabelDone={onLabelDone} setKey={setKey} actions={actions} onAdd={() => openInsert()}
|
|
244
|
+
language={language} patchWords={patchWords} patchFieldWords={patchFieldWords} />
|
|
198
245
|
);
|
|
199
246
|
const canvas = preview
|
|
200
|
-
? <Preview value={
|
|
201
|
-
: <Canvas value={
|
|
202
|
-
|
|
247
|
+
? <Preview value={shown} system={system} event={isEvent} device={layout === 'phone' ? 'phone' : device} lang={lang || main.code} />
|
|
248
|
+
: <Canvas value={words} system={system} selection={selection} event={isEvent} onSelect={select} {...canvasEdits}
|
|
249
|
+
onInsertAfter={after => openInsert(after)} onDuplicate={actions.duplicate} onRemove={actions.remove} />;
|
|
203
250
|
|
|
204
251
|
const undoRedo = <>
|
|
205
252
|
<button type="button" className="fb-icon" aria-label="Undo" title="Undo (⌘Z)" disabled={!history.canUndo} onClick={history.undo}>
|
|
@@ -225,6 +272,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
225
272
|
<button type="button" aria-pressed={preview} onClick={() => setPreview(true)}>Preview</button>
|
|
226
273
|
</div>
|
|
227
274
|
)}
|
|
275
|
+
{langSwitch}
|
|
228
276
|
<span className="fb-bar-sp" />
|
|
229
277
|
{preview && layout !== 'phone' && (
|
|
230
278
|
<div className="fb-seg" role="group" aria-label="Preview width">
|
|
@@ -249,6 +297,7 @@ export default function FormBuilder({ value, onChange, event = false, mode, syst
|
|
|
249
297
|
</>)}
|
|
250
298
|
<div className="fb-canvas" onClick={e => { if (e.target === e.currentTarget) setSelection(null); }}>
|
|
251
299
|
{!showBar && <div className="fb-float">{undoRedo}</div>}
|
|
300
|
+
{!showBar && langSwitch && <div className="fb-float fb-float-l">{langSwitch}</div>}
|
|
252
301
|
{canvas}
|
|
253
302
|
</div>
|
|
254
303
|
{!preview && <aside className="fb-insp" aria-label="Question settings">{inspector}</aside>}
|
|
@@ -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/api.ts
CHANGED
|
@@ -8,6 +8,9 @@ export class ApiError extends Error {
|
|
|
8
8
|
/** retryAfter: seconds a 429 should wait (mobile API Retry-After; default 60) */
|
|
9
9
|
constructor(public status: number, message: string, public retryAfter?: number) { super(message); }
|
|
10
10
|
}
|
|
11
|
+
/** An ApiError with a `code` saying why, for pages that word errors themselves (the member pages' words look them up). */
|
|
12
|
+
export const coded = (status: number, code: string, message: string, retryAfter?: number): ApiError & { code: string } =>
|
|
13
|
+
Object.assign(new ApiError(status, message, retryAfter), { code });
|
|
11
14
|
export const json = (body: unknown, status = 200) => Response.json(body, {
|
|
12
15
|
status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
|
|
13
16
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { SavedForm, FormSubmission } from '../forms/schema';
|
|
3
3
|
import { AnnouncementRecord } from '../announcements/types';
|
|
4
|
-
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place } from '../schema';
|
|
4
|
+
import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking, Place, RequestRecord } from '../schema';
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
6
|
|
|
7
7
|
// backups from before booking approval modes: a link that didn't confirm on
|
|
@@ -29,6 +29,7 @@ export const BackupContent = z.object({
|
|
|
29
29
|
// optional (no version bump): older cores restore such a backup and skip them
|
|
30
30
|
announcements: z.array(AnnouncementRecord).optional(),
|
|
31
31
|
screens: z.array(ScreenRecord).optional(),
|
|
32
|
+
eventRequests: z.array(RequestRecord).optional(),
|
|
32
33
|
}).superRefine((data, ctx) => {
|
|
33
34
|
if (data.version >= 6 && (!data.forms || !data.formSubmissions)) ctx.addIssue({ code: 'custom', message: 'Version 6 requires forms and their submissions.' });
|
|
34
35
|
if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
|
|
@@ -48,12 +49,13 @@ export async function exportContent(repo: ContentRepository, now = Date.now()) {
|
|
|
48
49
|
repo.listPlaces(), repo.forms.list(), repo.forms.submissions(undefined, -1),
|
|
49
50
|
]);
|
|
50
51
|
const [announcements, screens] = await Promise.all([repo.announcements.list(-1), repo.screens.list()]);
|
|
52
|
+
const eventRequests = await repo.requests.list({ order: 'oldest', limit: -1 });
|
|
51
53
|
return {
|
|
52
54
|
format: 'acms-backup' as const, version: 6, exportedAt: new Date(now).toISOString(),
|
|
53
55
|
settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false }, monitoring: { ...settings.monitoring, pushUrl: '' } },
|
|
54
56
|
pages, posts, media, links, tags, navItems, users, redirects,
|
|
55
57
|
calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
|
|
56
|
-
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements,
|
|
58
|
+
resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState, places, forms, formSubmissions, announcements, eventRequests,
|
|
57
59
|
screens: screens.map(s => ({ ...s, key: '', lastSeenAt: null })),
|
|
58
60
|
};
|
|
59
61
|
}
|
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('#')),
|