@acms-subir/core 0.3.52 → 0.3.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/docs/mobile.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
- package/package.json +1 -1
- package/src/components/ThemeSections.astro +2 -2
- package/src/components/editor/PostEditor.tsx +2 -0
- package/src/components/editor/event/EventSheet.tsx +6 -2
- package/src/components/editor/event/RegistrationCard.tsx +3 -3
- package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
- package/src/components/forms/Canvas.tsx +31 -19
- package/src/components/forms/FormBuildPage.tsx +12 -6
- package/src/components/forms/FormBuilder.tsx +60 -11
- package/src/components/forms/Inspector.tsx +116 -8
- package/src/components/forms/TypeIcon.tsx +1 -0
- package/src/components/forms/meta.ts +71 -9
- package/src/lib/calendar/api.ts +3 -1
- package/src/lib/dynamicBlocks.ts +4 -2
- package/src/lib/forms/fields.ts +14 -9
- package/src/lib/forms/localize.ts +68 -0
- package/src/lib/forms/render.ts +8 -2
- package/src/lib/forms/schema.ts +31 -2
- package/src/lib/forms/service.ts +9 -1
- package/src/lib/forms/validate.ts +16 -4
- package/src/lib/forms/words.ts +32 -0
- package/src/lib/mobile/forms.ts +45 -13
- package/src/lib/mobile/openapi.ts +3 -3
- package/src/lib/mobile/rsvp.ts +4 -2
- package/src/lib/mobile/schemas.ts +3 -1
- package/src/pages/[...slug].astro +1 -1
- package/src/pages/admin/forms/[id].astro +6 -0
- package/src/pages/blog/[slug].astro +1 -1
- package/src/pages/events/[slug].astro +9 -3
- package/src/pages/forms/[id].astro +22 -4
- package/src/pages/index.astro +1 -1
- package/src/styles/forms.css +8 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure answer reading + validation, shared by the server (collectResponse,
|
|
2
2
|
// readAnswers) and the builder's preview. No server imports here.
|
|
3
3
|
import { answerFields, hasOther, type FormDefinition, type FormField } from './schema';
|
|
4
|
+
import { mainChoice } from './localize';
|
|
4
5
|
|
|
5
6
|
/** Value of the "Other" radio/checkbox; its text arrives as `o_<field id>`. */
|
|
6
7
|
export const OTHER = '__other__';
|
|
@@ -28,12 +29,19 @@ export function answersFrom(getAll: (name: string) => unknown[], definition: Pic
|
|
|
28
29
|
return answers;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
/**
|
|
32
|
+
/** HH:mm, 24-hour (an hour without its leading zero is fine: "9:05" is stored "09:05"). */
|
|
33
|
+
const TIME = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validates one answer and returns its stored form. Throws AnswerError with a
|
|
37
|
+
* visitor-facing message. A choice may come in any of the form's languages;
|
|
38
|
+
* it's stored as the main-language choice.
|
|
39
|
+
*/
|
|
32
40
|
export function checkAnswer(f: FormField, raw: unknown): string {
|
|
33
41
|
if (typeof raw !== 'string' || raw.length > 2000) throw new AnswerError(`“${f.label}” is too long.`);
|
|
34
42
|
if (f.type === 'heading') return '';
|
|
35
43
|
if (f.type === 'checkboxes') {
|
|
36
|
-
const items = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
44
|
+
const items = raw.split('\n').map(s => s.trim()).filter(Boolean).map(s => mainChoice(f, s) ?? s);
|
|
37
45
|
const extra = items.filter(i => !f.options.includes(i));
|
|
38
46
|
if (f.required && !items.length) throw new AnswerError(`Please answer “${f.label}”.`);
|
|
39
47
|
if (new Set(items).size !== items.length || extra.length > (hasOther(f) ? 1 : 0) || extra.some(e => e.length > 200)) throw invalid(f);
|
|
@@ -48,8 +56,12 @@ export function checkAnswer(f: FormField, raw: unknown): string {
|
|
|
48
56
|
if (f.type === 'email') valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
49
57
|
if (f.type === 'number') valid = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value) && Number.isFinite(Number(value));
|
|
50
58
|
if (f.type === 'date') valid = /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
51
|
-
if (f.type === '
|
|
52
|
-
if (f.type === '
|
|
59
|
+
if (f.type === 'time') { const m = TIME.exec(value); valid = Boolean(m); if (m) value = `${m[1].padStart(2, '0')}:${m[2]}`; }
|
|
60
|
+
if (f.type === 'select' || f.type === 'radio') {
|
|
61
|
+
const main = mainChoice(f, value);
|
|
62
|
+
if (main !== undefined) value = main;
|
|
63
|
+
else valid = f.type === 'radio' && hasOther(f) && value.length <= 200 && !value.includes('\n');
|
|
64
|
+
}
|
|
53
65
|
if (agree) valid = value === 'yes';
|
|
54
66
|
if (f.type === 'phone') valid = value.length <= 40 && /^[+\d().\s-]+$/.test(value);
|
|
55
67
|
if (f.type === 'url') {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The public form's own words, around the ones its author wrote: the button
|
|
2
|
+
// and the thank-you when the form has none, the dropdown's empty choice,
|
|
3
|
+
// "Other" with its text box, and "Send another response". In the page's
|
|
4
|
+
// language (/forms/<id> and /es/forms/<id>, an event's RSVP questions); the
|
|
5
|
+
// author's own words come from the form and its translations. {label}: the
|
|
6
|
+
// question (live/words.ts `fill`). Other languages fall back to English.
|
|
7
|
+
import { DEFAULT_THANKS } from './schema';
|
|
8
|
+
|
|
9
|
+
export interface FormStrings {
|
|
10
|
+
submit: string;
|
|
11
|
+
thanks: string;
|
|
12
|
+
choose: string;
|
|
13
|
+
other: string;
|
|
14
|
+
typeAnswer: string;
|
|
15
|
+
/** read out for the "Other" text box */
|
|
16
|
+
otherAnswer: string;
|
|
17
|
+
sendAnother: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STRINGS: Record<string, FormStrings> = {
|
|
21
|
+
en: { submit: 'Submit', thanks: 'Thank you. Your response has been received.', choose: 'Choose…', other: 'Other', typeAnswer: 'Type your answer', otherAnswer: 'Other answer for {label}', sendAnother: 'Send another response' },
|
|
22
|
+
es: { submit: 'Enviar', thanks: 'Gracias. Recibimos tu respuesta.', choose: 'Elige…', other: 'Otro', typeAnswer: 'Escribe tu respuesta', otherAnswer: 'Otra respuesta para {label}', sendAnother: 'Enviar otra respuesta' },
|
|
23
|
+
fr: { submit: 'Envoyer', thanks: 'Merci. Votre réponse a bien été reçue.', choose: 'Choisir…', other: 'Autre', typeAnswer: 'Saisissez votre réponse', otherAnswer: 'Autre réponse pour {label}', sendAnother: 'Envoyer une autre réponse' },
|
|
24
|
+
de: { submit: 'Senden', thanks: 'Danke. Ihre Antwort ist eingegangen.', choose: 'Auswählen…', other: 'Andere', typeAnswer: 'Ihre Antwort', otherAnswer: 'Andere Antwort für {label}', sendAnother: 'Weitere Antwort senden' },
|
|
25
|
+
pt: { submit: 'Enviar', thanks: 'Obrigado. Recebemos sua resposta.', choose: 'Escolha…', other: 'Outro', typeAnswer: 'Digite sua resposta', otherAnswer: 'Outra resposta para {label}', sendAnother: 'Enviar outra resposta' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const formStrings = (locale: string | null | undefined): FormStrings =>
|
|
29
|
+
STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
|
|
30
|
+
|
|
31
|
+
/** The form's thank-you, or the page's own words when it has none (or still has the default one). */
|
|
32
|
+
export const thanksText = (confirmation: string, w: FormStrings) => (confirmation.trim() && confirmation !== DEFAULT_THANKS ? confirmation : w.thanks);
|
package/src/lib/mobile/forms.ts
CHANGED
|
@@ -3,17 +3,24 @@ import type { ContentRepository } from '../repo/types';
|
|
|
3
3
|
import type { FormDefinition, SavedForm } from '../forms/schema';
|
|
4
4
|
import { collectResponse, formVersion } from '../forms/service';
|
|
5
5
|
import { saveResponse } from '../forms/alerts';
|
|
6
|
+
import { localizeForm } from '../forms/localize';
|
|
7
|
+
import { formStrings, thanksText } from '../forms/words';
|
|
6
8
|
import { ApiError, readJson } from '../api';
|
|
7
9
|
import { sha256Hex } from '../api-keys';
|
|
10
|
+
import { I18N } from '../i18n';
|
|
11
|
+
import { isExtraLocale } from '../translations';
|
|
12
|
+
import { siteLang } from '../dates';
|
|
8
13
|
import { queryParams } from './http';
|
|
9
14
|
import { requireAccess } from './access';
|
|
15
|
+
import { localeOnlyQuery, mobileLocale } from './locale';
|
|
10
16
|
import type { PublicContext } from './route';
|
|
11
17
|
|
|
12
|
-
// Published general forms for apps: read the questions
|
|
13
|
-
//
|
|
14
|
-
// limits: an Idempotency-Key per
|
|
15
|
-
// responses per address per form per
|
|
16
|
-
// from Admin → Mobile app so one form
|
|
18
|
+
// Published general forms for apps: read the questions (in the app's
|
|
19
|
+
// language with ?locale=), send answers. The same validation and storage as
|
|
20
|
+
// the web form (/forms/<id>), plus app limits: an Idempotency-Key per
|
|
21
|
+
// response (retries never duplicate), 10 responses per address per form per
|
|
22
|
+
// minute, and a per-form daily ceiling from Admin → Mobile app so one form
|
|
23
|
+
// can't be flooded.
|
|
17
24
|
|
|
18
25
|
const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
19
26
|
|
|
@@ -25,10 +32,15 @@ async function publishedForm(repo: ContentRepository, id: string): Promise<Saved
|
|
|
25
32
|
return form;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A form's questions for apps, in `locale` (the main-language words where it
|
|
37
|
+
* has none; never the translations themselves); `version` is the one
|
|
38
|
+
* collectResponse accepts (also for event RSVPs), the same in every language.
|
|
39
|
+
*/
|
|
40
|
+
export async function formJson(definition: FormDefinition, locale?: string | null) {
|
|
41
|
+
const d = localizeForm(definition, isExtraLocale(locale) ? locale : '');
|
|
30
42
|
return {
|
|
31
|
-
version: await formVersion(
|
|
43
|
+
version: await formVersion(definition),
|
|
32
44
|
title: d.title, intro: d.intro, submitLabel: d.submitLabel, confirmation: d.confirmation,
|
|
33
45
|
fields: d.fields.map((f) => ({
|
|
34
46
|
id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
|
|
@@ -37,12 +49,26 @@ export async function formJson(d: FormDefinition) {
|
|
|
37
49
|
};
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/** the language a request asks for (`?locale=`), or the site's main one */
|
|
53
|
+
function formLocale(url: URL, read: boolean) {
|
|
54
|
+
if (read) return mobileLocale(localeOnlyQuery.parse(queryParams(url)).locale);
|
|
55
|
+
// sending answers never fails over the language: one the site doesn't
|
|
56
|
+
// have gets the main language's thank-you (the response is the same)
|
|
57
|
+
const locale = url.searchParams.get('locale');
|
|
58
|
+
return locale && I18N.locales.includes(locale) ? locale : I18N.defaultLocale || null;
|
|
59
|
+
}
|
|
60
|
+
// the button and thank-you when the form has none, in the request's language (else the site's)
|
|
61
|
+
async function ownWords(repo: ContentRepository, locale: string | null) {
|
|
62
|
+
return formStrings(locale ?? siteLang(await repo.getSettings()));
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
41
|
-
|
|
66
|
+
const locale = formLocale(url, true);
|
|
42
67
|
await requireAccess(repo, 'forms');
|
|
43
68
|
const form = await publishedForm(repo, id);
|
|
44
|
-
const
|
|
45
|
-
|
|
69
|
+
const json = await formJson(form.definition, locale);
|
|
70
|
+
const w = await ownWords(repo, locale);
|
|
71
|
+
return { id: form.id, ...json, title: json.title || form.name, submitLabel: json.submitLabel || w.submit, confirmation: thanksText(json.confirmation, w) };
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
// field id → answer; checkbox groups send a list. "Other" answers send the typed text.
|
|
@@ -60,6 +86,7 @@ export const formResponseInput = z.object({
|
|
|
60
86
|
}).strict();
|
|
61
87
|
|
|
62
88
|
export async function submitFormResponse(c: PublicContext) {
|
|
89
|
+
const locale = formLocale(c.url, false);
|
|
63
90
|
const settings = await requireAccess(c.repo, 'forms');
|
|
64
91
|
const id = c.params.id ?? '';
|
|
65
92
|
const key = c.request.headers.get('idempotency-key') ?? '';
|
|
@@ -77,12 +104,17 @@ export async function submitFormResponse(c: PublicContext) {
|
|
|
77
104
|
const unchanged = JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition);
|
|
78
105
|
if (unchanged && JSON.stringify(await collectResponse(form.definition, answers, body.version)) !== prior)
|
|
79
106
|
throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
|
|
80
|
-
return
|
|
107
|
+
return receipt(c.repo, form, locale);
|
|
81
108
|
}
|
|
82
109
|
const response = await collectResponse(form.definition, answers, body.version);
|
|
83
110
|
// only valid new responses count toward the daily ceiling
|
|
84
111
|
const cap = settings.mobileAccess.responsesPerFormDay;
|
|
85
112
|
if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw new ApiError(429, 'This form has had a lot of responses today. Please try again tomorrow.', 86_400);
|
|
86
113
|
await saveResponse(c.repo, form, response, key.toLowerCase(), { via: 'app', locals: c.locals });
|
|
87
|
-
return
|
|
114
|
+
return receipt(c.repo, form, locale);
|
|
115
|
+
}
|
|
116
|
+
// the thank-you in the app's language (?locale=), as the web form shows it
|
|
117
|
+
async function receipt(repo: ContentRepository, form: SavedForm, locale: string | null) {
|
|
118
|
+
const d = localizeForm(form.definition, isExtraLocale(locale) ? locale : '');
|
|
119
|
+
return { received: true as const, confirmation: thanksText(d.confirmation, await ownWords(repo, locale)) };
|
|
88
120
|
}
|
|
@@ -61,7 +61,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
61
61
|
['/announcements/{id}', 'One announcement that is up, in the requested language; requires announcements', localeOnlyQuery, ref('AnnouncementDetail')],
|
|
62
62
|
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
63
63
|
['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
|
|
64
|
-
['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)',
|
|
64
|
+
['/forms/{id}', 'A published form’s questions, in the requested language (locale: its labels, help, choices, button and thank-you there; words it has no translation for stay in the main language; version is the same in every language); 410 when it stopped accepting responses (owner switch: forms)', localeOnlyQuery, ref('Form')],
|
|
65
65
|
['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
|
|
66
66
|
];
|
|
67
67
|
for (const [path, summary, query, schema, cache = true] of reads) {
|
|
@@ -114,8 +114,8 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
114
114
|
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
115
115
|
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
116
116
|
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
117
|
-
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409. Leave website empty (spam trap).' }),
|
|
118
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
117
|
+
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409 (a translation alone doesn’t change it). A choice may be sent as shown in the requested language or in the main one; it is stored as the main-language choice. A time answer is HH:mm (24-hour). locale: the confirmation in that language (one the site doesn’t have gets the main language’s). Leave website empty (spam trap).' }),
|
|
118
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'locale', in: 'query', required: false, schema: { type: 'string', minLength: 1, maxLength: 35 } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
119
119
|
const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
|
|
120
120
|
paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
|
|
121
121
|
description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
|
package/src/lib/mobile/rsvp.ts
CHANGED
|
@@ -61,7 +61,9 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
61
61
|
const closed = rsvpClosed(o, settings);
|
|
62
62
|
const deadline = rsvp.enabled ? rsvpDeadline(o, settings) : null;
|
|
63
63
|
const reason = closed ?? (left === 0 ? 'full' as const : null);
|
|
64
|
-
const
|
|
64
|
+
const questions = rsvpQuestions(o.event);
|
|
65
|
+
// the questions in the app's language, where the form has its words
|
|
66
|
+
const form = questions ? await formJson(questions, locale) : null;
|
|
65
67
|
const title = localizeDoc(o.event, locale ?? undefined).title;
|
|
66
68
|
// the built-in button speaks the app's language (the site's own without languages set up)
|
|
67
69
|
let submitLabel = form?.submitLabel ?? '';
|
|
@@ -79,6 +81,6 @@ export async function getRsvpStatus(repo: ContentRepository, url: URL, slug: str
|
|
|
79
81
|
maxSeats: Math.min(rsvp.maxSeats || 1, left ?? Infinity), autoConfirm: rsvp.autoConfirm,
|
|
80
82
|
// a full date takes places in line: how many people wait, for how many seats, and the most one may ask for
|
|
81
83
|
waitlist: { enabled: waitlistOn(o.event), waiting: line.length, seats: line.reduce((n, b) => n + b.seats, 0), maxSeats: rsvp.maxSeats || 1 },
|
|
82
|
-
form: form ? { ...
|
|
84
|
+
form: form ? { ...form, submitLabel } : null,
|
|
83
85
|
};
|
|
84
86
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAvatarFit, LinksAvatarScale } from '../schema';
|
|
3
|
+
import { FIELD_TYPES } from '../forms/schema';
|
|
3
4
|
|
|
4
5
|
const str = z.string(), bool = z.boolean(), strings = z.array(str);
|
|
5
6
|
const date = str.datetime(), locale = str.nullable();
|
|
@@ -73,7 +74,8 @@ export const Links = z.object({
|
|
|
73
74
|
export const Form = z.object({
|
|
74
75
|
id: str, version: str, title: str, intro: str, submitLabel: str, confirmation: str,
|
|
75
76
|
fields: z.array(z.object({
|
|
76
|
-
|
|
77
|
+
// time: 24-hour HH:mm. An app that doesn't know a type can ask it as short text; the site checks the answer
|
|
78
|
+
id: str, type: z.enum(FIELD_TYPES),
|
|
77
79
|
label: str, required: bool, help: str, options: strings, placeholder: str, width: z.enum(['full', 'half']), allowOther: bool,
|
|
78
80
|
})),
|
|
79
81
|
});
|
|
@@ -22,7 +22,7 @@ const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astr
|
|
|
22
22
|
if (!page || (page.status !== 'published' && !isAdmin)) {
|
|
23
23
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
24
24
|
}
|
|
25
|
-
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
|
25
|
+
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo, locale)) : '';
|
|
26
26
|
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
27
27
|
const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
28
28
|
const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
|
|
@@ -3,14 +3,18 @@ import Admin from '../../../layouts/Admin.astro';
|
|
|
3
3
|
import FormHeader from '../../../components/forms/FormHeader.astro';
|
|
4
4
|
import FormBuildPage from '../../../components/forms/FormBuildPage';
|
|
5
5
|
import { getRepo } from '../../../lib/repo';
|
|
6
|
+
import { I18N, extraLocales, localeName } from '../../../lib/i18n';
|
|
6
7
|
import '../../../styles/forms.css';
|
|
7
8
|
// Build tab. Edits autosave as a draft through the `forms.save` action;
|
|
8
9
|
// "Publish changes" makes them live. See components/forms/FormBuildPage.tsx.
|
|
10
|
+
// With more than one site language, a switcher writes the form's words in each.
|
|
9
11
|
const repo = await getRepo(Astro.locals);
|
|
10
12
|
const form = await repo.forms.get(Astro.params.id || '');
|
|
11
13
|
if (!form) return new Response(null, { status: 404 });
|
|
12
14
|
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
13
15
|
const responses = form.kind === 'form' && form.purpose === 'general' ? (await repo.forms.submissions(form.id, 1)).length : 0;
|
|
16
|
+
const languages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
|
|
17
|
+
const mainLanguage = { code: I18N.defaultLocale || 'en', name: localeName(I18N.defaultLocale || 'en', true) };
|
|
14
18
|
---
|
|
15
19
|
<Admin title={form.name} section="forms" wide>
|
|
16
20
|
<FormHeader form={form} active="build">
|
|
@@ -23,5 +27,7 @@ const responses = form.kind === 'form' && form.purpose === 'general' ? (await re
|
|
|
23
27
|
form={{ id: form.id, name: form.name, kind: form.kind, purpose: form.purpose, status: form.status, revision: form.revision, hasDraft: Boolean(form.draft) }}
|
|
24
28
|
definition={form.draft ?? form.definition}
|
|
25
29
|
responses={responses}
|
|
30
|
+
languages={languages}
|
|
31
|
+
mainLanguage={mainLanguage}
|
|
26
32
|
/>
|
|
27
33
|
</Admin>
|
|
@@ -63,7 +63,7 @@ const po = design.post;
|
|
|
63
63
|
const PostTemplate = (post.template ? design.theme.postTemplates[post.template] : null) as any;
|
|
64
64
|
const tagSlug = (name: string) =>
|
|
65
65
|
tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
|
|
66
|
-
const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo));
|
|
66
|
+
const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo, locale));
|
|
67
67
|
const fmt = (ts: number | null) => formatDate(ts, settings);
|
|
68
68
|
const words = (post.bodyHtml || post.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
|
|
69
69
|
const readMins = Math.max(1, Math.round(words / 220));
|
|
@@ -3,6 +3,8 @@ import { ApiError } from '../../lib/api';
|
|
|
3
3
|
import { starter } from '../../lib/forms/schema';
|
|
4
4
|
import { formVersion } from '../../lib/forms/service';
|
|
5
5
|
import { renderFields, hiddenFields } from '../../lib/forms/render';
|
|
6
|
+
import { localizeForm } from '../../lib/forms/localize';
|
|
7
|
+
import { formStrings } from '../../lib/forms/words';
|
|
6
8
|
import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
|
|
7
9
|
import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
|
|
8
10
|
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
@@ -74,22 +76,26 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
|
|
79
|
+
// the questions in the page's language (main-language words where the form has none)
|
|
80
|
+
const fieldsHtml = renderFields(localizeForm(definition, isExtraLocale(locale) ? locale : ''), formValues, 'event-registration', formStrings((I18N.enabled ? locale : '') || siteLang(settings)));
|
|
77
81
|
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
78
82
|
// the event's RSVP deadline for this date (null: open until it ends)
|
|
79
83
|
const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
|
|
80
84
|
// waitlist: a full date offers "Join the waitlist" (the same form, posting waitlist=1)
|
|
81
85
|
const rsvp = event.rsvp.enabled
|
|
82
|
-
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml
|
|
86
|
+
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml, values: coreValues,
|
|
83
87
|
open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt, waitlist: waitlistOn(event) }
|
|
84
88
|
: null;
|
|
85
89
|
const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
|
|
86
|
-
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
|
|
90
|
+
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo, locale));
|
|
87
91
|
const upcoming = (await upcomingEvents(repo, settings, now, { limit: 4 })).filter((u) => u.event.id !== event.id).slice(0, 3);
|
|
88
92
|
const pageUrl = origin + eventUrl(event.slug);
|
|
89
93
|
const jsonLd = eventJsonLd(event, occurrence, settings, origin, pageUrl);
|
|
90
94
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
95
|
const Template = resolveDesign(settings).theme.EventTemplate as any;
|
|
92
|
-
|
|
96
|
+
// templates read the form's title, introduction and button from the event: in the page's language too
|
|
97
|
+
const shownEvent = event.rsvp.form && isExtraLocale(locale) ? { ...event, rsvp: { ...event.rsvp, form: localizeForm(event.rsvp.form, locale) } } : event;
|
|
98
|
+
const props = { event: shownEvent, occurrence, settings, calendars, html, upcoming, origin, preview: event.status !== 'published', rsvp };
|
|
93
99
|
---
|
|
94
100
|
|
|
95
101
|
<Site
|
|
@@ -5,12 +5,24 @@ import { renderForm } from '../../lib/forms/render';
|
|
|
5
5
|
import { collectResponse } from '../../lib/forms/service';
|
|
6
6
|
import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
|
|
7
7
|
import { saveResponse } from '../../lib/forms/alerts';
|
|
8
|
+
import { localizeForm } from '../../lib/forms/localize';
|
|
9
|
+
import { formStrings, thanksText } from '../../lib/forms/words';
|
|
8
10
|
import { ApiError } from '../../lib/api';
|
|
11
|
+
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
12
|
+
import { isExtraLocale } from '../../lib/translations';
|
|
13
|
+
import { siteLang } from '../../lib/dates';
|
|
9
14
|
const repo = await getRepo(Astro.locals);
|
|
10
15
|
const form = await repo.forms.get(Astro.params.id || '');
|
|
11
16
|
if (!form || form.status !== 'published' || form.kind !== 'form' || form.purpose !== 'general') return new Response(null, { status: 404 });
|
|
12
17
|
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
13
18
|
const embed = Astro.url.searchParams.has('embed');
|
|
19
|
+
// /es/forms/<id>: the form's Spanish words (main-language ones where it has
|
|
20
|
+
// none) and the page's own words in Spanish; answers are stored the same
|
|
21
|
+
const locale = requestLocale(Astro.url, Astro.locals);
|
|
22
|
+
const formLocale = isExtraLocale(locale) ? locale : '';
|
|
23
|
+
const lang = (I18N.enabled ? locale : '') || siteLang(await repo.getSettings());
|
|
24
|
+
const shown = localizeForm(form.definition, formLocale);
|
|
25
|
+
const path = localePath(`/forms/${encodeURIComponent(form.id)}`, locale);
|
|
14
26
|
let error = '', success = false, values: Record<string,string> = {};
|
|
15
27
|
let requestId: string = crypto.randomUUID();
|
|
16
28
|
if (Astro.request.method === 'POST') {
|
|
@@ -18,11 +30,17 @@ if (Astro.request.method === 'POST') {
|
|
|
18
30
|
const data = await readForm(Astro.request);
|
|
19
31
|
requestId = await submissionGuard(repo, data, Astro.clientAddress, `form:${form.id}`);
|
|
20
32
|
values = readAnswers(data, form.definition);
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
// a resubmit of a saved response (refresh, double click) thanks them again,
|
|
34
|
+
// also after the form changed since; the same id with other answers is a 409
|
|
35
|
+
const prior = await repo.forms.receipt(form.id, requestId);
|
|
36
|
+
if (!prior || JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition)) {
|
|
37
|
+
const response = await collectResponse(form.definition, values, prior ? undefined : String(data.get('formVersion') || ''));
|
|
38
|
+
await saveResponse(repo, form, response, requestId, { via: 'web', locals: Astro.locals });
|
|
39
|
+
}
|
|
23
40
|
success = true;
|
|
24
41
|
} catch (e) { error = e instanceof ApiError ? e.message : 'Could not save your response. Please try again.'; Astro.response.status = e instanceof ApiError ? e.status : 500; }
|
|
25
42
|
}
|
|
26
|
-
const
|
|
43
|
+
const w = formStrings(lang);
|
|
44
|
+
const html = success ? '' : await renderForm(form.id, form.definition, values, error, requestId, embed, { locale: formLocale, lang, action: path });
|
|
27
45
|
---
|
|
28
|
-
<Site title={
|
|
46
|
+
<Site title={shown.title || form.name} noindex bare={embed}>{success ? <section class="pc-simple-form"><h1>{shown.title || form.name}</h1><p role="status">{thanksText(shown.confirmation, w)}</p><a href={path + (embed ? '?embed=1' : '')}>{w.sendAnother}</a></section> : <div set:html={html}/>}</Site>
|
package/src/pages/index.astro
CHANGED
|
@@ -46,7 +46,7 @@ const [posts, tags] = HomeTemplate
|
|
|
46
46
|
: [[], []];
|
|
47
47
|
// expand dynamic blocks (post lists) in the page body for BOTH paths — a theme
|
|
48
48
|
// template injects the body as its hero, so it must receive expanded HTML too
|
|
49
|
-
const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo) : '';
|
|
49
|
+
const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo, locale) : '';
|
|
50
50
|
const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
|
|
51
51
|
---
|
|
52
52
|
|
package/src/styles/forms.css
CHANGED
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
.fb.is-preview .fb-canvas { border: 0; }
|
|
82
82
|
.fb-wide .fb-canvas:has(> .fb-float) { padding-top: 48px; }
|
|
83
83
|
.fb-float { position: absolute; top: 8px; right: 10px; z-index: 3; display: flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--panel-2) 85%, transparent); }
|
|
84
|
+
.fb-float-l { right: auto; left: 10px; }
|
|
84
85
|
.fb-paper {
|
|
85
86
|
/* the public form's tokens, mapped onto the admin palette */
|
|
86
87
|
--ink: var(--text); --line-2: var(--line-strong); --btn: var(--text); --btn-ink: var(--bg); --accent-text: var(--accent-deep);
|
|
@@ -195,6 +196,13 @@
|
|
|
195
196
|
.fb-choice.dragging input { box-shadow: var(--shadow-pop); }
|
|
196
197
|
.fb-choice input { min-height: 34px; }
|
|
197
198
|
.fb-choice-add { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
|
|
199
|
+
/* another language: the main language's words above each box, choices side by side */
|
|
200
|
+
.fb-src { display: block; padding: 6px 9px; border-radius: var(--r-sm); background: var(--panel-2); color: var(--text-2); font-size: 12.5px; line-height: 1.45; white-space: pre-line; overflow-wrap: anywhere; max-height: 7.5em; overflow: auto; }
|
|
201
|
+
.fb-src > span { display: block; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 1px; }
|
|
202
|
+
.fb-tr-choices { display: grid; gap: 6px; }
|
|
203
|
+
.fb-tr-choice { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 8px; align-items: center; }
|
|
204
|
+
.fb-tr-choice > span { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
205
|
+
.fb-tr-choice input { min-height: 34px; }
|
|
198
206
|
|
|
199
207
|
/* menus */
|
|
200
208
|
.fb-more, .fb-q-more { position: relative; flex: none; }
|