@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.54
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 031e463: Hosts move single dates of their events from My events, and My events speaks the visitor's language.
|
|
8
|
+
|
|
9
|
+
- **Move… on each date.** Each upcoming date of a repeating event a host runs now has **Move…**, next to Cancel and Restore. It takes the date, the start and end, the date's rooms and a note for people who come. Hosts pick from the event's own rooms and the ones marked **Anyone can check if it’s free**. A date the host moved also gets **Move back**. People who signed up are emailed the new time, as with staff moves.
|
|
10
|
+
- **`acms({ members: { hostMoves } })`.** With `'direct'` (the default), a move applies at once while the rooms are free, and staff get the "Date moved" email. If a room is taken, the host sees which one and can **Send as a request** instead. With `'approve'`, every host move is a request.
|
|
11
|
+
- **Requests.** Host requests wait in Schedule → List → **Requests** under "Date changes from hosts", with what else holds the room at the new time. **Approve** makes the move (a clash only warns) and records it as the host's. **Decline…** keeps the date and sends the host your note. Staff get a "Date move to review" email, and the host hears the answer. The requests are stored on their own rows, in a new `event_requests` table (migration 68). Requests from people without an account will share that table.
|
|
12
|
+
- **Server API.** `@acms-subir/core/server` adds `moveHostedDate`, `moveHostedDateBack`, `requestDateMove`, `listMyDateRequests`, `withdrawDateRequest`, `approveDateRequest` and `declineDateRequest`. Member errors now carry a `code`.
|
|
13
|
+
- **My events under `/es`.** The pages, buttons, notices, the request form and their errors follow the address, so `/es/members/events` is in Spanish, including the event's translated title and the dates. The words exist in English, Spanish, French, German and Portuguese.
|
|
14
|
+
|
|
15
|
+
## 0.3.53
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 1b8201d: Forms get a **Time** question and can speak every language your site has.
|
|
20
|
+
|
|
21
|
+
**Time questions.** Add one from the builder's question menu (next to Date). Visitors pick a time with their browser's time picker; it's saved as a 24-hour time like `18:30` and shows that way in Responses, CSV exports and alert emails. Required works like any other question, and anything that isn't a real time is refused. Event registration forms can ask it too.
|
|
22
|
+
|
|
23
|
+
**Forms in your site's other languages.** On a site with more than one language, the form builder now has a language switcher (English · Español …) above the canvas, and so does an event's registration form. Pick a language to write its words: the title, introduction, button and thank-you message, and each question's label, help text, placeholder and choices. You type straight onto the form, with the main language showing in grey where you haven't written anything yet, and the side panel shows the main language's words above each box. Anything left blank shows the main language. The questions themselves (types, order, choices, Required and the other settings) stay the same in every language, and when you add, remove or reorder choices, their translations move with them. Translation changes to a published form go live with **Publish changes**, and the menu opens the live form in each language.
|
|
24
|
+
|
|
25
|
+
- `/es/forms/<id>` shows the form in Spanish, and a Form block on a Spanish page, post, event or home page opens it in Spanish. Event pages in another language show the registration questions in that language too.
|
|
26
|
+
- The form's own words around your questions ("Submit", "Choose…", "Other", "Send another response" and the default thank-you) follow the page's language in English, Spanish, French, German and Portuguese. Error messages are still in English.
|
|
27
|
+
- Answers are always saved with the main-language choice, so Responses, exports and alerts read the same whatever language someone answered in. A translated choice can't read the same as a different choice, so every answer maps back to exactly one.
|
|
28
|
+
- Rewording a translated label, help text or title doesn't change a form's version, so someone halfway through the form, on the website or in an app, can still send it. Rewording a translated choice does, so an answer in the old words is sent back for a reload instead of being stored as the wrong choice. Sending the same website response again (a refresh after sending) shows the thank-you again and stores it once.
|
|
29
|
+
|
|
30
|
+
**API:** `GET /api/v1/forms/{id}` takes an optional `locale` and returns the form's words in that language (the main language's where it has none); `version` is the same in every language. `POST /api/v1/forms/{id}/responses` accepts a choice in the language it was shown in or in the main one, and an optional `?locale=` for the receipt's thank-you. `GET /api/v1/events/{slug}/rsvp?locale=` returns the registration questions in that language, and RSVPs may send their choices the same way. Field `type` can now be `time` (24-hour `HH:mm`). Stored forms gain optional `translations` on the form and on each question; nothing changes for forms without them, and no migration is needed.
|
|
31
|
+
|
|
3
32
|
## 0.3.52
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
|
@@ -447,6 +447,20 @@ var SCREENS_MIGRATION = {
|
|
|
447
447
|
]
|
|
448
448
|
};
|
|
449
449
|
|
|
450
|
+
// src/lib/calendar/request-migration.ts
|
|
451
|
+
var REQUEST_TABLE = [
|
|
452
|
+
`CREATE TABLE event_requests (
|
|
453
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, state TEXT NOT NULL, source TEXT NOT NULL,
|
|
454
|
+
user_id TEXT NOT NULL DEFAULT '', requester TEXT,
|
|
455
|
+
event_id TEXT NOT NULL DEFAULT '', occurrence_key TEXT NOT NULL DEFAULT '',
|
|
456
|
+
data TEXT NOT NULL DEFAULT '{}', note TEXT NOT NULL DEFAULT '', reply TEXT NOT NULL DEFAULT '',
|
|
457
|
+
reviewed_by TEXT NOT NULL DEFAULT '', reviewed_at INTEGER NOT NULL DEFAULT 0,
|
|
458
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
459
|
+
)`,
|
|
460
|
+
`CREATE INDEX event_requests_state ON event_requests (state, created_at)`,
|
|
461
|
+
`CREATE INDEX event_requests_event ON event_requests (event_id)`
|
|
462
|
+
];
|
|
463
|
+
|
|
450
464
|
// src/lib/repo/migrations.ts
|
|
451
465
|
var CONTENT_VERSION_COLUMNS = {
|
|
452
466
|
settings: [
|
|
@@ -1281,7 +1295,10 @@ var MIGRATIONS = [
|
|
|
1281
1295
|
// row, so the triggers on events (content version, event_changes) see a move
|
|
1282
1296
|
MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2]),
|
|
1283
1297
|
// saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
|
|
1284
|
-
SCREENS_MIGRATION
|
|
1298
|
+
SCREENS_MIGRATION,
|
|
1299
|
+
// calendar requests on their own rows (RequestRecord): new events and
|
|
1300
|
+
// changes asked for without an account, and hosts' date moves
|
|
1301
|
+
{ version: 68, statements: REQUEST_TABLE }
|
|
1285
1302
|
];
|
|
1286
1303
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1287
1304
|
...CONTENT_VERSION_COLUMNS,
|
package/docs/calendar.md
CHANGED
|
@@ -99,8 +99,9 @@ and `moveOccurrenceBack(repo, who, eventId, key, ctx, { quiet? })` check who is
|
|
|
99
99
|
- **`GOOGLE_SYNC`** is a calendar sync acting with staff rights. A clash only warns,
|
|
100
100
|
since the change already happened in Google.
|
|
101
101
|
- **A host of the event** may move its dates while the rooms are free; a taken room
|
|
102
|
-
refuses the move (409).
|
|
103
|
-
|
|
102
|
+
refuses the move (409). They pick rooms among the event's own and the rooms marked
|
|
103
|
+
**Anyone can check if it’s free** (403 `room-not-allowed` otherwise). Staff
|
|
104
|
+
(`members.notify`, else the owners) are emailed about each move a host makes.
|
|
104
105
|
- Anyone else gets 404, and nobody signed in gets 401.
|
|
105
106
|
|
|
106
107
|
`end` defaults to the date's own length. `roomIds`, `location` and `note` replace the
|
|
@@ -114,6 +115,22 @@ write, webhook, push or email. A sync can retry, or re-apply what it already app
|
|
|
114
115
|
safely. Cancelling a date that's already cancelled, or restoring one that isn't, is a
|
|
115
116
|
no-op too, even after the date has passed.
|
|
116
117
|
|
|
118
|
+
### Hosts on My events
|
|
119
|
+
|
|
120
|
+
Hosts move dates from **My events** (`/members/events`): each upcoming date has
|
|
121
|
+
**Move…** next to **Cancel**, and a date they moved has **Move back**.
|
|
122
|
+
`acms({ members: { hostMoves } })` decides how a host's move applies:
|
|
123
|
+
|
|
124
|
+
- `'direct'` (the default): at once while the date's rooms are free, as above. When a
|
|
125
|
+
room is taken the host sees which one and can **Send as a request** instead.
|
|
126
|
+
- `'approve'`: every host move, and every move back, is a request.
|
|
127
|
+
|
|
128
|
+
Requests wait in Schedule → List → **Requests** under "Date changes from hosts", with
|
|
129
|
+
what else holds the rooms at the new time. **Approve** makes the move as you (a clash
|
|
130
|
+
only warns) and records it as the host's. **Decline…** keeps the date and sends the host
|
|
131
|
+
your note. Both answers email the host. The server API is `moveHostedDate`,
|
|
132
|
+
`requestDateMove` and `approveDateRequest` in `npx acms docs server`.
|
|
133
|
+
|
|
117
134
|
Errors carry a `status`, a message safe to show people, and a `code`:
|
|
118
135
|
|
|
119
136
|
| status | code | when |
|
|
@@ -127,6 +144,7 @@ Errors carry a `status`, a message safe to show people, and a `code`:
|
|
|
127
144
|
| 409 | `cancelled-event` | the whole event is cancelled |
|
|
128
145
|
| 409 | `cancelled-date` | the date is cancelled (restore it first) |
|
|
129
146
|
| 409 | `room-taken` | a host moving into a taken room |
|
|
147
|
+
| 403 | `room-not-allowed` | a host picking a room the event doesn't use and nobody may request |
|
|
130
148
|
| 401, 404 | | who is acting |
|
|
131
149
|
|
|
132
150
|
## Rooms
|
package/docs/mobile.md
CHANGED
|
@@ -124,7 +124,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
124
124
|
| `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
|
|
125
125
|
| `useConfirmBooking(token)` | an RSVP on a waitlist: `waitlisted` shows "#3 in line · 2 seats" (`waitlistLineText(booking, words, locale)`) and can leave (`useCancelBooking`); `offered` holds a spot until `booking.holdUntil` (`holdText`: "We're holding it until Fri, Oct 24 at 9 PM") and `can.confirm` takes it. `isReleased(error)`: the hold ran out (410) and the spot went to the next person |
|
|
126
126
|
| `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
|
|
127
|
-
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms
|
|
127
|
+
| `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms (in the app's language where the form has its words) and sending answers (choices as shown; `time` answers are 24-hour `HH:mm`, and `answerProblems` checks them); check `features.links` / `features.forms`. Ask a question type you don't know as short text |
|
|
128
128
|
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
129
129
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
130
130
|
| `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
|
package/docs/server.md
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
eventRoomClashes, roomBusy,
|
|
17
17
|
listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
|
|
18
18
|
withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
|
|
19
|
+
moveHostedDate, moveHostedDateBack, requestDateMove, listMyDateRequests, withdrawDateRequest,
|
|
20
|
+
approveDateRequest, declineDateRequest,
|
|
19
21
|
} from '@acms-subir/core/server';
|
|
20
22
|
```
|
|
21
23
|
|
|
@@ -49,11 +51,23 @@ approveEventRequest(repo: Repo, who: ServerUser | null, eventId: string, ctx: Wr
|
|
|
49
51
|
declineEventRequest(repo: Repo, who: ServerUser | null, eventId: string, reply: string, ctx: WriteContext, seen?: number): Promise<CalEvent>
|
|
50
52
|
setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, cancelled: boolean, ctx: WriteContext): Promise<CalEvent>
|
|
51
53
|
|
|
54
|
+
// hosts moving dates (acms({ members: { hostMoves } }))
|
|
55
|
+
moveHostedDate(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput, ctx: WriteContext): Promise<HostMoveResult>
|
|
56
|
+
moveHostedDateBack(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<HostMoveResult>
|
|
57
|
+
requestDateMove(repo: Repo, who: ServerUser | null, eventId: string, occurrenceKey: string, input: MoveInput | null, message: string, ctx: WriteContext): Promise<RequestRecord>
|
|
58
|
+
listMyDateRequests(repo: Repo, who: ServerUser | null, eventId?: string): Promise<RequestRecord[]>
|
|
59
|
+
withdrawDateRequest(repo: Repo, who: ServerUser | null, requestId: string): Promise<void>
|
|
60
|
+
approveDateRequest(repo: Repo, who: ServerUser | null, requestId: string, ctx: WriteContext, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }>
|
|
61
|
+
declineDateRequest(repo: Repo, who: ServerUser | null, requestId: string, reply: string, ctx: WriteContext, seen?: number): Promise<RequestRecord>
|
|
62
|
+
|
|
52
63
|
type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
|
|
53
64
|
interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
|
|
54
65
|
interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[]; moved?: { from: { start: number; end: number }; note: string; location?: EventLocation } }
|
|
55
66
|
interface MoveInput { start: string; end?: string; roomIds?: string[]; location?: Partial<EventLocation> | null; note?: string }
|
|
56
67
|
interface MoveResult { event: CalEvent; clashes: BusySlot[]; changed: boolean }
|
|
68
|
+
type HostMoveResult = { moved: MoveResult } | { request: RequestRecord }
|
|
69
|
+
interface RequestRecord { id: string; kind: 'new' | 'change' | 'move' | 'from-date'; state: 'pending' | 'approved' | 'declined' | 'withdrawn'; source: 'member' | 'web' | 'app'; userId: string; requester: { name: string; email: string; phone: string; locale: string } | null; eventId: string; key: string; data: Record<string, unknown>; note: string; reply: string; reviewedBy: string; reviewedAt: number; createdAt: number; updatedAt: number }
|
|
70
|
+
interface MoveRequestData { start: string; end: string; roomIds?: string[]; note: string; back?: boolean }
|
|
57
71
|
interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
|
|
58
72
|
interface EventClash { hold: Hold; other: Hold }
|
|
59
73
|
interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
|
|
@@ -145,6 +159,9 @@ error.
|
|
|
145
159
|
emailed. `opts.quiet` skips the app notice and that staff email.
|
|
146
160
|
- **`moveOccurrenceBack(...)`**: puts it back where the series has it. Idempotent.
|
|
147
161
|
|
|
162
|
+
A host picks rooms only among the ones the event already uses and the rooms marked
|
|
163
|
+
**Anyone can check if it’s free** (403, code `room-not-allowed`, otherwise).
|
|
164
|
+
|
|
148
165
|
Moving a date to where it already is returns `changed: false` without writing or
|
|
149
166
|
sending anything, so retries are safe. Errors carry `status` and `code` (`one-off`,
|
|
150
167
|
`not-a-date`, `started`, `past`, `bad-time`, `cancelled-date`, `room-taken`…; the
|
|
@@ -300,6 +317,42 @@ The functions take the person acting (`currentUser(ctx)`) and check them on ever
|
|
|
300
317
|
(`true`) or restores (`false`) one date of an event they host, at once. No permission
|
|
301
318
|
needed beyond being in `hostIds`. Same rules and errors as `cancelOccurrence`.
|
|
302
319
|
|
|
320
|
+
### Hosts moving dates
|
|
321
|
+
|
|
322
|
+
A host moves one date of an event they host from My events, or from your own page with
|
|
323
|
+
these. Being in `hostIds` is enough; no permission is needed. Staff who host an event
|
|
324
|
+
always move at once.
|
|
325
|
+
|
|
326
|
+
- **`moveHostedDate(repo, who, eventId, key, input, ctx)`**: with
|
|
327
|
+
`acms({ members: { hostMoves: 'direct' } })` (the default) the move applies at once
|
|
328
|
+
while the date's rooms are free: `{ moved }`, and staff get the "Date moved" email. A
|
|
329
|
+
taken room answers 409 with code `room-taken`, and the same move can then go to staff
|
|
330
|
+
with `requestDateMove`. With `hostMoves: 'approve'` every host move becomes a request:
|
|
331
|
+
`{ request }`.
|
|
332
|
+
- **`moveHostedDateBack(repo, who, eventId, key, ctx)`**: puts a moved date back, at once
|
|
333
|
+
or as a request, the same way.
|
|
334
|
+
- **`requestDateMove(repo, who, eventId, key, input, message, ctx)`**: asks staff to move
|
|
335
|
+
the date (`input`), or to move it back (`null`). It's checked like the move itself, but
|
|
336
|
+
a taken room is allowed, since staff decide. `message` is for staff only. Asking again
|
|
337
|
+
about the same date replaces the waiting request. Staff (`members.notify`, else the
|
|
338
|
+
owners) get a "Date move to review" email.
|
|
339
|
+
- **`listMyDateRequests(repo, who, eventId?)`**, **`withdrawDateRequest(repo, who, requestId)`**:
|
|
340
|
+
the host's own requests, newest first, and taking one back.
|
|
341
|
+
- **`approveDateRequest(repo, who, requestId, ctx, seen?)`**: needs **Publish content**.
|
|
342
|
+
The move runs as the approver, so room clashes are warnings in `moved.clashes`. It is
|
|
343
|
+
recorded as the host's move (`movedDates[key].by`), so they can move it back later. The
|
|
344
|
+
host is emailed. Pass `seen`, the request's `updatedAt` you showed, to get 409 if the
|
|
345
|
+
host changed it since.
|
|
346
|
+
- **`declineDateRequest(repo, who, requestId, reply, ctx, seen?)`**: the date stays as it
|
|
347
|
+
is, and the host is emailed `reply`.
|
|
348
|
+
|
|
349
|
+
These requests are `RequestRecord`s with `kind: 'move'` and `data: MoveRequestData`,
|
|
350
|
+
stored on their own rows (the same table as requests from people without an account).
|
|
351
|
+
Approved, declined and withdrawn ones stay listed until they're deleted after the
|
|
352
|
+
retention period. Errors carry a `code` as well as a `status`, so your page can word
|
|
353
|
+
them in its own language. The built-in pages use the codes in
|
|
354
|
+
`lib/calendar/member-words.ts` (English, Spanish, French, German and Portuguese).
|
|
355
|
+
|
|
303
356
|
`EventRequestInput` is what a member may fill in. Staff can change anything else in the
|
|
304
357
|
admin.
|
|
305
358
|
|
|
@@ -340,7 +393,14 @@ With the calendar on, `acms({ members })` adds "My events" in the site's theme:
|
|
|
340
393
|
| `/members/events/new` | the request form |
|
|
341
394
|
| `/members/events/[id]` | one of their events and its request |
|
|
342
395
|
|
|
343
|
-
`/members` itself redirects to `/members/events`.
|
|
396
|
+
`/members` itself redirects to `/members/events`. With more than one site language the
|
|
397
|
+
pages follow the address, like the rest of the site: `/es/members/events` is My events in
|
|
398
|
+
Spanish (words, dates and the event's translated title). Its buttons and errors exist in
|
|
399
|
+
English, Spanish, French, German and Portuguese; other languages show English.
|
|
400
|
+
|
|
401
|
+
Each upcoming date of a repeating event a host runs has **Cancel** (or **Restore**),
|
|
402
|
+
**Move…** (date, times, rooms and a note for people who come) and, for a date they
|
|
403
|
+
moved, **Move back**. A taken room shows the clash with **Send as a request**.
|
|
344
404
|
|
|
345
405
|
`members: { base: '/club' }` moves them. A file at the same path in your `src/pages/`
|
|
346
406
|
replaces that one page. `members: false` removes them all; the functions above keep
|
package/integration.mjs
CHANGED
|
@@ -428,10 +428,13 @@ const unknownKeys = (label, value, known) => {
|
|
|
428
428
|
const isSitePath = (p) => typeof p === 'string' && p.startsWith('/') && !p.startsWith('//') && !/[\\\s\u0000-\u001f]/.test(p);
|
|
429
429
|
const isAdminPath = (p) => /^\/(admin|api|_actions)(\/|$|\?|#)/.test(p);
|
|
430
430
|
function resolveMembers(members, calendar) {
|
|
431
|
-
if (members === false) return { enabled: false, base: '/members', notify: [] };
|
|
431
|
+
if (members === false) return { enabled: false, base: '/members', notify: [], hostMoves: 'direct' };
|
|
432
432
|
if (members === undefined || members === true) members = {};
|
|
433
433
|
if (!members || typeof members !== 'object' || Array.isArray(members)) throw new Error('acms: members must be an object (or false to turn off the built-in member pages)');
|
|
434
|
-
unknownKeys('members', members, ['base', 'notify']);
|
|
434
|
+
unknownKeys('members', members, ['base', 'notify', 'hostMoves']);
|
|
435
|
+
// a host moving one date: at once while its rooms are free, or always as a request for staff
|
|
436
|
+
const hostMoves = members.hostMoves ?? 'direct';
|
|
437
|
+
if (hostMoves !== 'direct' && hostMoves !== 'approve') throw new Error(`acms: members.hostMoves must be 'direct' or 'approve' — got ${JSON.stringify(hostMoves)}`);
|
|
435
438
|
// who hears about new event requests (default: the owners)
|
|
436
439
|
const notify = members.notify ?? [];
|
|
437
440
|
if (!Array.isArray(notify) || notify.length > 20 || notify.some((e) => typeof e !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e))) {
|
|
@@ -443,7 +446,7 @@ function resolveMembers(members, calendar) {
|
|
|
443
446
|
}
|
|
444
447
|
if (RESERVED_BASES.has(base) || base === '/account') throw new Error(`acms: members.base "${base}" collides with a built-in route — pick another path`);
|
|
445
448
|
// the built-in pages are "My events": they need the calendar
|
|
446
|
-
return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()) };
|
|
449
|
+
return { enabled: calendar.enabled, base, notify: notify.map((e) => e.trim().toLowerCase()), hostMoves };
|
|
447
450
|
}
|
|
448
451
|
function resolveAnnouncements(announcements, reserved) {
|
|
449
452
|
if (announcements === undefined || announcements === false) return { enabled: false, base: '/announcements', pages: true, topics: {} };
|
|
@@ -689,13 +692,16 @@ function acmsVitePlugin(userConfig) {
|
|
|
689
692
|
* 'Cancel any class', defaults: ['administrator'] }`. They show on Team →
|
|
690
693
|
* Roles under "This site" and answer `currentUser().can(key)`. Owners hold
|
|
691
694
|
* every permission; `defaults` lists the other roles that start with it.
|
|
692
|
-
* @param {false | {base?: string, notify?: string[]}} [options.members]
|
|
695
|
+
* @param {false | {base?: string, notify?: string[], hostMoves?: 'direct' | 'approve'}} [options.members]
|
|
693
696
|
* Built-in "My events" pages for signed-in members (default base
|
|
694
697
|
* '/members', needs the calendar): the events they host, their event
|
|
695
698
|
* requests and a request form. `false` turns the pages off (the server API
|
|
696
699
|
* in @acms-subir/core/server still works, so a site can build its own); a
|
|
697
700
|
* site file at the same path replaces any one page. `notify` lists who is
|
|
698
|
-
* emailed about new event requests (default: the owners).
|
|
701
|
+
* emailed about new event requests (default: the owners). `hostMoves`:
|
|
702
|
+
* 'direct' (default) lets a host move one date at once while its rooms are
|
|
703
|
+
* free (a taken room can be sent as a request instead); 'approve' sends
|
|
704
|
+
* every host move to staff as a request.
|
|
699
705
|
* @param {boolean | {base?: string, pages?: boolean, topics?: {event?: string, registration?: string, help?: string, info?: string}}} [options.announcements]
|
|
700
706
|
* Announcements (off by default): Admin → Announcements stores messages for
|
|
701
707
|
* members (event, registration, help needed, good to know, urgent) in each
|
|
@@ -32,7 +32,7 @@ const tokens = {
|
|
|
32
32
|
|
|
33
33
|
type Answers = Record<string, string | string[]>;
|
|
34
34
|
const OTHER = '__other__';
|
|
35
|
-
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
35
|
+
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url', time: 'numbers-and-punctuation' };
|
|
36
36
|
// "Other" choices send the typed text instead
|
|
37
37
|
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
38
38
|
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
@@ -269,7 +269,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
269
269
|
}
|
|
270
270
|
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
271
271
|
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
272
|
-
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
272
|
+
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : f.type === 'time' ? words.timePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
273
273
|
});
|
|
274
274
|
};
|
|
275
275
|
|
|
@@ -32,7 +32,7 @@ const tokens = {
|
|
|
32
32
|
|
|
33
33
|
type Answers = Record<string, string | string[]>;
|
|
34
34
|
const OTHER = '__other__';
|
|
35
|
-
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url' };
|
|
35
|
+
const keyboards: Partial<Record<FormField['type'], KeyboardTypeOptions>> = { email: 'email-address', phone: 'phone-pad', number: 'numeric', url: 'url', time: 'numbers-and-punctuation' };
|
|
36
36
|
// "Other" choices send the typed text instead
|
|
37
37
|
const withOther = (f: FormField, value: string | string[] | undefined, other: string): string | string[] => {
|
|
38
38
|
if (f.type === 'checkboxes') { const list = Array.isArray(value) ? value : []; return list.includes(OTHER) ? [...list.filter((v) => v !== OTHER), other.trim()] : list; }
|
|
@@ -269,7 +269,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
|
|
|
269
269
|
}
|
|
270
270
|
return text(f.label, String(values[f.id] ?? ''), (v) => set(f.id, v), {
|
|
271
271
|
required: f.required, help: f.help, problem, multiline: f.type === 'textarea', keyboard: keyboards[f.type],
|
|
272
|
-
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
272
|
+
placeholder: f.placeholder || (f.type === 'date' ? words.datePlaceholder : f.type === 'time' ? words.timePlaceholder : undefined), auto: f.type === 'email' ? 'email' : f.type === 'phone' ? 'tel' : undefined,
|
|
273
273
|
});
|
|
274
274
|
};
|
|
275
275
|
|
package/package.json
CHANGED
|
@@ -19,11 +19,11 @@ const { page, settings } = Astro.props;
|
|
|
19
19
|
const theme = getTheme(settings.activeTheme);
|
|
20
20
|
const layout = settings.sectionPages[page.id];
|
|
21
21
|
const repo = await getRepo(Astro.locals);
|
|
22
|
-
const
|
|
22
|
+
const locale = Astro.props.locale ?? requestLocale(Astro.url, Astro.locals);
|
|
23
|
+
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo, locale)) : '';
|
|
23
24
|
// admin design preview: sections carry their ids so the Design screen can
|
|
24
25
|
// open the one you click (see Site.astro's preview script)
|
|
25
26
|
const editable = Astro.props.editable ?? (await resolvePreview(Astro.url, Astro.cookies, Astro.locals)).role === 'admin';
|
|
26
|
-
const locale = Astro.props.locale ?? requestLocale(Astro.url, Astro.locals);
|
|
27
27
|
const now = Date.now();
|
|
28
28
|
const nowWall = wallNow(settings.timezone || 'UTC', now);
|
|
29
29
|
// another language: dates/times format in it (components read settings.locale)
|
|
@@ -1162,6 +1162,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
|
|
|
1162
1162
|
eventId={docId}
|
|
1163
1163
|
title={meta.title}
|
|
1164
1164
|
status={meta.status}
|
|
1165
|
+
languages={languages}
|
|
1166
|
+
mainLanguage={mainLanguage}
|
|
1165
1167
|
tags={<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />}
|
|
1166
1168
|
/>
|
|
1167
1169
|
</div>
|
|
@@ -13,6 +13,7 @@ import { RegistrationCard } from './RegistrationCard';
|
|
|
13
13
|
import { WhenCard } from './WhenCard';
|
|
14
14
|
import { SingleDates } from './SingleDates';
|
|
15
15
|
import { registrationMode } from './registration';
|
|
16
|
+
import type { SiteLanguage } from '../../forms/FormBuilder';
|
|
16
17
|
|
|
17
18
|
// Every event setting, on the page above the event's own content, as one
|
|
18
19
|
// settings column: When · Where · CTA · Organize · Status, with a
|
|
@@ -31,6 +32,9 @@ interface Props {
|
|
|
31
32
|
title: string;
|
|
32
33
|
status: string;
|
|
33
34
|
tags?: ReactNode;
|
|
35
|
+
/** the site's other languages and its main one, for the registration form's words in each */
|
|
36
|
+
languages?: SiteLanguage[];
|
|
37
|
+
mainLanguage?: SiteLanguage;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
const SECTIONS = [
|
|
@@ -43,7 +47,7 @@ const SECTIONS = [
|
|
|
43
47
|
|
|
44
48
|
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
45
49
|
|
|
46
|
-
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags }: Props) {
|
|
50
|
+
export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags, languages, mainLanguage }: Props) {
|
|
47
51
|
const [places, setPlaces] = useState<Place[]>([]);
|
|
48
52
|
const [current, setCurrent] = useState<string>(SECTIONS[0].id);
|
|
49
53
|
const root = useRef<HTMLDivElement>(null);
|
|
@@ -129,7 +133,7 @@ export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, ma
|
|
|
129
133
|
onLocation={(name) => onChange({ location: { ...meta.location, name, placeId: '' } })}
|
|
130
134
|
/>
|
|
131
135
|
</Section>
|
|
132
|
-
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} />
|
|
136
|
+
<RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} languages={languages} mainLanguage={mainLanguage} />
|
|
133
137
|
<OrganizeCard meta={meta} onChange={onChange} calendars={calendars} fields={fields} tags={tags} />
|
|
134
138
|
<StatusSection meta={meta} onChange={onChange} />
|
|
135
139
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
-
import { starter } from '../../forms/FormBuilder';
|
|
2
|
+
import { starter, type SiteLanguage } from '../../forms/FormBuilder';
|
|
3
3
|
import type { EventCta } from '../../../lib/schema';
|
|
4
4
|
import type { EventMeta } from '../EventPanel';
|
|
5
5
|
import { Section } from './Section';
|
|
@@ -13,7 +13,7 @@ const MODES: { id: RegistrationMode; label: string; sub: string }[] = [
|
|
|
13
13
|
{ id: 'rsvp', label: 'Built-in form', sub: 'Collect RSVPs here' },
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
-
export function RegistrationCard({ meta, onChange, eventId, title = '' }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void }) {
|
|
16
|
+
export function RegistrationCard({ meta, onChange, eventId, title = '', languages, mainLanguage }: { eventId?: string; title?: string; meta: EventMeta; onChange: (patch: Partial<EventMeta>) => void; languages?: SiteLanguage[]; mainLanguage?: SiteLanguage }) {
|
|
17
17
|
// "Link button" with nothing typed yet is still link mode, so keep the
|
|
18
18
|
// choice locally and only fall back to the derived one on first render
|
|
19
19
|
const [mode, setMode] = useState<RegistrationMode>(() => registrationMode(meta));
|
|
@@ -94,7 +94,7 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
94
94
|
<button type="button" className="pe-btn sm" aria-haspopup="dialog" onClick={() => setEditing(true)}>Edit form</button>
|
|
95
95
|
</div>
|
|
96
96
|
</div>
|
|
97
|
-
{editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
|
|
97
|
+
{editing && <RegistrationFormEditor form={form} maxSeats={meta.rsvp.maxSeats} eventTitle={title} languages={languages} mainLanguage={mainLanguage} onClose={() => setEditing(false)} onChange={(f) => onChange({ rsvp: { ...meta.rsvp, form: f } })} />}
|
|
98
98
|
</div>
|
|
99
99
|
)}
|
|
100
100
|
</Section>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { actions } from 'astro:actions';
|
|
3
|
-
import FormBuilder, { starter } from '../../forms/FormBuilder';
|
|
3
|
+
import FormBuilder, { starter, type SiteLanguage } from '../../forms/FormBuilder';
|
|
4
4
|
import type { SystemField } from '../../forms/meta';
|
|
5
5
|
import type { FormDefinition, SavedForm } from '../../../lib/forms/schema';
|
|
6
6
|
import '../../../styles/forms.css';
|
|
@@ -10,12 +10,15 @@ type Choice = { id: string; name: string; note: string; definition: () => FormDe
|
|
|
10
10
|
// The event's own registration questions in the full forms builder, on top
|
|
11
11
|
// of the event editor. Edits go straight into the event's meta (its own copy);
|
|
12
12
|
// the event's Update/autosave persists them, so "Done" only closes.
|
|
13
|
-
export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle }: {
|
|
13
|
+
export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, eventTitle, languages, mainLanguage }: {
|
|
14
14
|
form: FormDefinition;
|
|
15
15
|
onChange: (form: FormDefinition) => void;
|
|
16
16
|
onClose: () => void;
|
|
17
17
|
maxSeats: number;
|
|
18
18
|
eventTitle: string;
|
|
19
|
+
/** the site's other languages: the builder's switcher writes the questions' words in each */
|
|
20
|
+
languages?: SiteLanguage[];
|
|
21
|
+
mainLanguage?: SiteLanguage;
|
|
19
22
|
}) {
|
|
20
23
|
const ref = useRef<HTMLDialogElement>(null);
|
|
21
24
|
const [templates, setTemplates] = useState<SavedForm[]>([]);
|
|
@@ -135,7 +138,7 @@ export function RegistrationFormEditor({ form, onChange, onClose, maxSeats, even
|
|
|
135
138
|
</div>
|
|
136
139
|
)}
|
|
137
140
|
<div className="pe-evr-body">
|
|
138
|
-
<FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} />
|
|
141
|
+
<FormBuilder value={form} onChange={onChange} mode="event" systemFields={system} languages={languages} mainLanguage={mainLanguage} />
|
|
139
142
|
</div>
|
|
140
143
|
</dialog>
|
|
141
144
|
);
|
|
@@ -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' : '
|
|
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=
|
|
70
|
-
<p className="fb-intro"><Inline value={value.intro} onChange={v => p.patch({ intro: v }, 'intro')} placeholder=
|
|
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
|
-
|
|
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 =
|
|
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>{
|
|
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
|
-
/**
|
|
123
|
-
|
|
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
|
|
128
|
-
const
|
|
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 ||
|
|
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 ?
|
|
157
|
+
<button type="submit" className="pc-bk-submit">{value.submitLabel || (event ? rsvp.confirmSeat : w.submit)}</button>
|
|
146
158
|
</form>
|
|
147
159
|
</div>
|
|
148
160
|
);
|