@acms-subir/core 0.3.51 → 0.3.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/cli/migrations.generated.mjs +16 -1
  3. package/docs/calendar.md +24 -10
  4. package/docs/mobile.md +1 -1
  5. package/integration.mjs +5 -0
  6. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  7. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  8. package/package.json +1 -1
  9. package/src/components/ThemeSections.astro +2 -2
  10. package/src/components/display/ScreensManager.tsx +481 -0
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/layouts/Admin.astro +2 -0
  22. package/src/lib/backup/content.ts +10 -1
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/display/board.ts +4 -3
  25. package/src/lib/display/screens.ts +111 -0
  26. package/src/lib/display/store.ts +113 -0
  27. package/src/lib/display/words.ts +16 -6
  28. package/src/lib/dynamicBlocks.ts +4 -2
  29. package/src/lib/forms/fields.ts +14 -9
  30. package/src/lib/forms/localize.ts +68 -0
  31. package/src/lib/forms/render.ts +8 -2
  32. package/src/lib/forms/schema.ts +31 -2
  33. package/src/lib/forms/service.ts +9 -1
  34. package/src/lib/forms/validate.ts +16 -4
  35. package/src/lib/forms/words.ts +32 -0
  36. package/src/lib/mobile/forms.ts +45 -13
  37. package/src/lib/mobile/openapi.ts +3 -3
  38. package/src/lib/mobile/rsvp.ts +4 -2
  39. package/src/lib/mobile/schemas.ts +3 -1
  40. package/src/lib/repo/migrations.ts +3 -0
  41. package/src/lib/repo/sql.ts +5 -0
  42. package/src/lib/repo/types.ts +5 -0
  43. package/src/pages/[...slug].astro +1 -1
  44. package/src/pages/admin/calendar/screens/index.astro +24 -0
  45. package/src/pages/admin/calendar/screens/preview.astro +36 -0
  46. package/src/pages/admin/forms/[id].astro +6 -0
  47. package/src/pages/api/calendar/screens.ts +30 -0
  48. package/src/pages/blog/[slug].astro +1 -1
  49. package/src/pages/display/s/[key].astro +48 -0
  50. package/src/pages/events/[slug].astro +9 -3
  51. package/src/pages/forms/[id].astro +22 -4
  52. package/src/pages/index.astro +1 -1
  53. package/src/styles/display.css +2 -1
  54. package/src/styles/forms.css +8 -0
  55. package/src/styles/screens-admin.css +53 -0
@@ -2,6 +2,8 @@ import { Fragment, useId, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { escapeForm, renderControl, renderField, renderFields } from '../../lib/forms/fields';
3
3
  import { isHalf, type FormDefinition, type FormField } from '../../lib/forms/schema';
4
4
  import { answersFrom, firstError } from '../../lib/forms/validate';
5
+ import { formStrings, thanksText, type FormStrings } from '../../lib/forms/words';
6
+ import { rsvpStrings } from '../../lib/calendar/rsvp-words';
5
7
  import { META, type Selection, type SystemField } from './meta';
6
8
 
7
9
  /** Plain-text contentEditable that doesn't fight the caret: the DOM is only rewritten when the value changes from outside. */
@@ -37,20 +39,22 @@ type CanvasProps = {
37
39
  onSelect: (s: Selection) => void; patch: (next: Partial<FormDefinition>, key: string) => void;
38
40
  patchField: (id: string, next: Partial<FormField>, key: string) => void; onLabelDone: (id: string) => void;
39
41
  onInsertAfter: (id: string | null) => void; onDuplicate: (id: string) => void; onRemove: (id: string) => void;
42
+ /** writing another language: `value` holds its words ('' = not written yet), `source` the main form, whose words show where it has none */
43
+ source?: FormDefinition; words?: FormStrings; submitFallback?: string;
40
44
  };
41
45
 
42
46
  /** The form drawn with the public form markup/classes; labels, help, title, intro and the button edit in place. */
43
47
  export function Canvas(p: CanvasProps) {
44
- const { value, system, selection, event, onSelect } = p;
48
+ const { value, system, selection, event, onSelect, source } = p;
45
49
  const prefix = useId().replace(/:/g, '');
46
50
  const sel = (s: Selection) => JSON.stringify(s) === JSON.stringify(selection);
47
- const submitFallback = event ? 'Confirm my seat' : 'Submit';
51
+ const submitFallback = p.submitFallback ?? (event ? 'Confirm my seat' : 'Submit');
48
52
 
49
53
  if (selection?.kind === 'thanks') return (
50
54
  <div className="fb-paper fb-paper-done">
51
55
  <p className="fb-paper-tag">After someone submits</p>
52
- <h2 className="fb-title">{value.title || 'Thank you'}</h2>
53
- <p className={`fb-thanks on`}><Inline value={value.confirmation} onChange={v => p.patch({ confirmation: v }, 'confirmation')} placeholder={event ? 'Uses the booking confirmation message' : 'Thank you. Your response has been received.'} max={1000} multiline label="Thank-you message" /></p>
56
+ <h2 className="fb-title">{value.title || source?.title || 'Thank you'}</h2>
57
+ <p className={`fb-thanks on`}><Inline value={value.confirmation} onChange={v => p.patch({ confirmation: v }, 'confirmation')} placeholder={event ? source?.confirmation || 'Uses the booking confirmation message' : thanksText(source?.confirmation ?? '', p.words ?? formStrings('en'))} max={1000} multiline label="Thank-you message" /></p>
54
58
  <button type="button" className="fb-link" onClick={() => onSelect(null)}>← Back to the questions</button>
55
59
  </div>
56
60
  );
@@ -66,8 +70,8 @@ export function Canvas(p: CanvasProps) {
66
70
  return (
67
71
  <div className="fb-paper" onClick={e => { if (e.target === e.currentTarget) onSelect(null); }}>
68
72
  <div className={`fb-item fb-head${sel({ kind: 'form' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'form' })}>
69
- <h2 className="fb-title"><Inline value={value.title} onChange={v => p.patch({ title: v }, 'title')} placeholder="Form title" max={120} label="Form title" /></h2>
70
- <p className="fb-intro"><Inline value={value.intro} onChange={v => p.patch({ intro: v }, 'intro')} placeholder="Add an introduction (optional)" max={1000} multiline label="Introduction" /></p>
73
+ <h2 className="fb-title"><Inline value={value.title} onChange={v => p.patch({ title: v }, 'title')} placeholder={source?.title || 'Form title'} max={120} label="Form title" /></h2>
74
+ <p className="fb-intro"><Inline value={value.intro} onChange={v => p.patch({ intro: v }, 'intro')} placeholder={source?.intro || 'Add an introduction (optional)'} max={1000} multiline label="Introduction" /></p>
71
75
  </div>
72
76
  {system.map((f, i) => (
73
77
  <div key={`sys-${i}`} className={`fb-item fb-sys${sel({ kind: 'system', index: i }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'system', index: i })} title="Always asked">
@@ -81,27 +85,30 @@ export function Canvas(p: CanvasProps) {
81
85
  </button>
82
86
  )}
83
87
  <div className={`fb-item fb-submit-wrap${sel({ kind: 'submit' }) ? ' on' : ''}`} onClick={() => onSelect({ kind: 'submit' })}>
84
- <div className="pc-bk-submit fb-submit"><Inline value={value.submitLabel} onChange={v => p.patch({ submitLabel: v }, 'submit')} placeholder={submitFallback} max={80} label="Submit button label" /></div>
88
+ <div className="pc-bk-submit fb-submit"><Inline value={value.submitLabel} onChange={v => p.patch({ submitLabel: v }, 'submit')} placeholder={source?.submitLabel || submitFallback} max={80} label="Submit button label" /></div>
85
89
  </div>
86
90
  </div>
87
91
  );
88
92
  }
89
93
 
90
- function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInsertAfter, onDuplicate, onRemove }: CanvasProps & { field: FormField; prefix: string; on: boolean }) {
94
+ function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInsertAfter, onDuplicate, onRemove, source, words }: CanvasProps & { field: FormField; prefix: string; on: boolean }) {
91
95
  const id = `${prefix}-${f.id}`;
92
- const label = <Inline value={f.label} onChange={v => patchField(f.id, { label: v }, `label:${f.id}`)} onDone={() => onLabelDone(f.id)} placeholder={META[f.type].fresh} max={200} label={`${META[f.type].label} label`} />;
96
+ // the main-language question while writing another language
97
+ const main = source?.fields.find(x => x.id === f.id);
98
+ const hasHelp = Boolean(f.help || main?.help || on);
99
+ const label = <Inline value={f.label} onChange={v => patchField(f.id, { label: v }, `label:${f.id}`)} onDone={() => onLabelDone(f.id)} placeholder={main?.label || META[f.type].fresh} max={200} label={`${META[f.type].label} label`} />;
93
100
  const req = f.required ? <span className="fb-req-mark" aria-hidden="true"> *</span> : null;
94
- const help = (f.help || on) ? <small className="fb-help"><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder={f.type === 'heading' ? 'Add a description (optional)' : 'Add help text (optional)'} max={300} label="Help text" /></small> : null;
95
- const control = <div className="fb-control" inert dangerouslySetInnerHTML={{ __html: renderControl(f, '', id) }} />;
101
+ const help = hasHelp ? <small className="fb-help"><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder={main?.help || (f.type === 'heading' ? 'Add a description (optional)' : 'Add help text (optional)')} max={300} label="Help text" /></small> : null;
102
+ const control = <div className="fb-control" inert dangerouslySetInnerHTML={{ __html: renderControl(f, '', id, words) }} />;
96
103
  const body = f.type === 'heading'
97
- ? <div className="pc-form-heading"><h3>{label}</h3>{(f.help || on) && <p><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder="Add a description (optional)" max={300} label="Section description" /></p>}</div>
104
+ ? <div className="pc-form-heading"><h3>{label}</h3>{hasHelp && <p><Inline value={f.help} onChange={v => patchField(f.id, { help: v }, `help:${f.id}`)} placeholder={main?.help || 'Add a description (optional)'} max={300} label="Section description" /></p>}</div>
98
105
  : f.type === 'checkbox' || f.type === 'consent'
99
106
  ? <div className="pc-bk-check"><input type="checkbox" tabIndex={-1} aria-hidden="true" readOnly checked={false} /><span>{label}{req}{help}</span></div>
100
107
  : f.type === 'radio' || f.type === 'checkboxes'
101
108
  ? <div className="pc-bk-field pc-bk-choices"><div className="fb-legend">{label}{req}</div>{control}{help}</div>
102
109
  : <div className="pc-bk-field"><span>{label}{req}</span>{control}{help}</div>;
103
110
  return (
104
- <div className={`fb-item${on ? ' on' : ''}${isHalf(f) ? ' is-half' : ''}`} data-item={f.id} tabIndex={-1} aria-label={`${META[f.type].label}: ${f.label}`}
111
+ <div className={`fb-item${on ? ' on' : ''}${isHalf(f) ? ' is-half' : ''}`} data-item={f.id} tabIndex={-1} aria-label={`${META[f.type].label}: ${f.label || main?.label || ''}`}
105
112
  onClick={e => { onSelect({ kind: 'field', id: f.id }); if (!(e.target as HTMLElement).closest('.fb-inline, button')) e.currentTarget.focus({ preventScroll: true }); }}>
106
113
  {body}
107
114
  {on && (
@@ -119,13 +126,18 @@ function Item({ field: f, prefix, on, onSelect, patchField, onLabelDone, onInser
119
126
  );
120
127
  }
121
128
 
122
- /** Interactive preview: type, tick and press the button to check validation. Nothing is sent. */
123
- export function Preview({ value, system, event, device }: { value: FormDefinition; system: SystemField[]; event: boolean; device: 'desktop' | 'phone' }) {
129
+ /**
130
+ * Interactive preview: type, tick and press the button to check validation.
131
+ * Nothing is sent. lang: the language previewed (`value` is already in it),
132
+ * for the form's own words around the questions.
133
+ */
134
+ export function Preview({ value, system, event, device, lang = '' }: { value: FormDefinition; system: SystemField[]; event: boolean; device: 'desktop' | 'phone'; lang?: string }) {
124
135
  const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
125
136
  const formRef = useRef<HTMLFormElement>(null);
126
137
  const e = escapeForm;
127
- const sys = system.map((f, i) => renderField({ id: `sys${i}`, type: f.type, label: f.label, required: f.type !== 'select', help: '', options: f.type === 'select' ? ['1', '2'] : [] }, '', 'pv-sys')).join('');
128
- const html = `${value.title ? `<h2>${e(value.title)}</h2>` : ''}${value.intro ? `<p class="fb-pv-intro">${e(value.intro)}</p>` : ''}${sys}${renderFields(value, {}, 'pv')}`;
138
+ const w = formStrings(lang), rsvp = rsvpStrings(lang);
139
+ const sys = system.map((f, i) => renderField({ id: `sys${i}`, type: f.type, label: f.label, required: f.type !== 'select', help: '', options: f.type === 'select' ? ['1', '2'] : [] }, '', 'pv-sys', w)).join('');
140
+ const html = `${value.title ? `<h2>${e(value.title)}</h2>` : ''}${value.intro ? `<p class="fb-pv-intro">${e(value.intro)}</p>` : ''}${sys}${renderFields(value, {}, 'pv', w)}`;
129
141
  const key = JSON.stringify(value);
130
142
  return (
131
143
  <div className={`fb-pv-frame ${device}`}>
@@ -136,13 +148,13 @@ export function Preview({ value, system, event, device }: { value: FormDefinitio
136
148
  const data = new FormData(ev.currentTarget);
137
149
  try {
138
150
  const error = firstError(value, answersFrom(name => data.getAll(name), value));
139
- setResult(error ? { ok: false, text: error } : { ok: true, text: value.confirmation || (event ? 'Your seat is saved.' : 'Thank you. Your response has been received.') });
151
+ setResult(error ? { ok: false, text: error } : { ok: true, text: event ? value.confirmation || 'Your seat is saved.' : thanksText(value.confirmation, w) });
140
152
  } catch (err) { setResult({ ok: false, text: err instanceof Error ? err.message : 'Invalid answer.' }); }
141
153
  }}
142
154
  >
143
155
  <div dangerouslySetInnerHTML={{ __html: html }} />
144
156
  {result && <p className={`fb-pv-result ${result.ok ? 'ok' : 'bad'}`} role="status">{result.ok ? <><b>Looks good.</b> Visitors would see: “{result.text}” Nothing was sent.</> : result.text}</p>}
145
- <button type="submit" className="pc-bk-submit">{value.submitLabel || (event ? 'Confirm my seat' : 'Submit')}</button>
157
+ <button type="submit" className="pc-bk-submit">{value.submitLabel || (event ? rsvp.confirmSeat : w.submit)}</button>
146
158
  </form>
147
159
  </div>
148
160
  );
@@ -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, 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.options);
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: value.title, submit: value.submitLabel || (isEvent ? 'Confirm my seat' : 'Submit') }} />
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={value} system={system} event={isEvent} device={layout === 'phone' ? 'phone' : device} />
201
- : <Canvas value={value} system={system} selection={selection} event={isEvent} onSelect={select} patch={patch} patchField={patchField}
202
- onLabelDone={onLabelDone} onInsertAfter={after => openInsert(after)} onDuplicate={actions.duplicate} onRemove={actions.remove} />;
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
- function Text({ label, hint, value, onChange, onBlur, max, rows, placeholder }: { label: string; hint?: string; value: string; onChange: (v: string) => void; onBlur?: () => void; max: number; rows?: number; placeholder?: string }) {
225
+ // source: the main language's words while writing another one, shown above the box
226
+ function Text({ label, hint, value, onChange, onBlur, max, rows, placeholder, source, sourceName }: { label: string; hint?: string; value: string; onChange: (v: string) => void; onBlur?: () => void; max: number; rows?: number; placeholder?: string; source?: string; sourceName?: string }) {
129
227
  return (
130
228
  <label className="fb-f"><span className="fb-lab">{label}{hint && <small> · {hint}</small>}</span>
229
+ {source?.trim() && <span className="fb-src"><span>{sourceName}</span>{source}</span>}
131
230
  {rows ? <textarea rows={rows} maxLength={max} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} onBlur={onBlur} />
132
231
  : <input maxLength={max} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)} onBlur={onBlur} />}
133
232
  </label>
@@ -160,8 +259,13 @@ export function Menu({ items }: { items: { label: string; run: () => void; disab
160
259
  );
161
260
  }
162
261
 
163
- /** Choice list: drag or ⌥↑↓ to reorder, Enter adds below, Backspace on an empty choice removes it, pasting several lines adds many. */
164
- function Choices({ options, onChange }: { options: string[]; onChange: (options: string[], key: string) => void }) {
262
+ /**
263
+ * Choice list: drag or ⌥↑↓ to reorder, Enter adds below, Backspace on an
264
+ * empty choice removes it, pasting several lines adds many. `map` (new
265
+ * position → old, -1 for a new choice) comes with adds, removals and moves,
266
+ * so translated choices can follow theirs.
267
+ */
268
+ function Choices({ options, onChange }: { options: string[]; onChange: (options: string[], key: string, map?: number[]) => void }) {
165
269
  const ids = useRef<string[]>([]);
166
270
  // Stable ids for drag + focus while texts change.
167
271
  while (ids.current.length < options.length) ids.current.push(Math.random().toString(36).slice(2));
@@ -170,7 +274,11 @@ function Choices({ options, onChange }: { options: string[]; onChange: (options:
170
274
  const focusAt = (i: number) => requestAnimationFrame(() => listRef.current?.querySelectorAll<HTMLInputElement>('input')[i]?.focus());
171
275
  const dndId = useId();
172
276
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
173
- const set = (next: string[], key = 'choices', idList?: string[]) => { if (idList) ids.current = idList; onChange(next.slice(0, 50), key); };
277
+ const set = (next: string[], key = 'choices', idList?: string[]) => {
278
+ const before = ids.current;
279
+ if (idList) ids.current = idList;
280
+ onChange(next.slice(0, 50), key, idList ? idList.slice(0, 50).map(id => before.indexOf(id)) : undefined);
281
+ };
174
282
  const insertAt = (i: number, values: string[]) => {
175
283
  const next = [...options]; next.splice(i, 0, ...values);
176
284
  const nextIds = [...ids.current]; nextIds.splice(i, 0, ...values.map(() => Math.random().toString(36).slice(2)));
@@ -12,6 +12,7 @@ const PATHS: Record<FieldType | 'title' | 'submit' | 'thanks', string> = {
12
12
  url: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1',
13
13
  number: 'M5 9h14M5 15h14M10 4 8 20M16 4l-2 16',
14
14
  date: 'M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2ZM3 10h18M8 3v4M16 3v4',
15
+ time: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM12 7v5l3.5 2',
15
16
  consent: 'M5 12.5 9.5 17 19 7',
16
17
  checkbox: 'M5 12.5 9.5 17 19 7',
17
18
  heading: 'M6 4v16M18 4v16M6 12h12',