@bygghammar/cms-platform 0.52.0 → 0.54.0

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 CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.54.0
4
+
5
+ - `forms/FormRenderer`: the whole public managed-form runtime (validation,
6
+ conditional fields, honeypot, partial drafts, thank-you/redirect) now lives in
7
+ the package, together with `ManagedForm` which resolves a form by key.
8
+ - `blocks/components/FormBlock`: the form section block is package-owned and is
9
+ registered in `SHARED_BLOCK_DEFS` as `form`, so a fresh site gets it for free.
10
+ - `host`: new site-adapter contract (`registerSiteAdapters`,
11
+ `usePublicFormFns`, `useTripSource`) so a site hands its own server functions
12
+ and central trip source to package-owned public components.
13
+ - `blocks/types`: added `FormProps`.
14
+
15
+ ## 0.53.0
16
+
17
+ - `admin/describe-log`: one place that turns action codes and JSON meta into
18
+ ordinary English sentences for the Audit, Activity and Email log views.
19
+ - `server/audit-core`: `auditAs` lets any staff-guarded core log through the
20
+ caller's own client, and every entry now stores its sentence in
21
+ `meta.summary` at write time.
22
+ - `server/email-log-core`: `logEmail` records every send attempt — form
23
+ notifications, auto-replies, provider tests and account emails — with a
24
+ purpose and a sentence. Form delivery and the backoffice test send both go
25
+ through it.
26
+ - Content records, language domains, redirects, site settings, image focus
27
+ points, image text and submission/delivery actions are now audited.
28
+ - `AuditView`/`EmailLogsView`: rows lead with the sentence; codes stay in the
29
+ expanded detail only.
30
+
3
31
  ## 0.52.0
4
32
 
5
33
  - `IndexingStatusCard`: read-only search-engine indexing status (from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -48,6 +48,7 @@
48
48
  "./trips": "./src/trips/index.ts",
49
49
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
50
50
  "./navigation": "./src/navigation/index.ts",
51
+ "./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
51
52
  "./forms/conditions": "./src/forms/conditions.ts",
52
53
  "./forms/field-sets": "./src/forms/field-sets.ts",
53
54
  "./forms/form-audit": "./src/forms/form-audit.ts",
@@ -141,7 +142,9 @@
141
142
  "./backoffice/BlocksLibraryView": "./src/backoffice/BlocksLibraryView.tsx",
142
143
  "./backoffice/SharedContentListView": "./src/backoffice/SharedContentListView.tsx",
143
144
  "./backoffice/seo/SeoListView": "./src/backoffice/seo/SeoListView.tsx",
145
+ "./admin/describe-log": "./src/admin/describe-log.ts",
144
146
  "./server/audit-core": "./src/server/audit-core.ts",
147
+ "./server/email-log-core": "./src/server/email-log-core.ts",
145
148
  "./server/review-image-validate": "./src/server/review-image-validate.ts",
146
149
  "./server/media-library-core": "./src/server/media-library-core.ts",
147
150
  "./server/admin-core": "./src/server/admin-core.ts",
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Plain-English descriptions for audit and email log rows.
3
+ *
4
+ * Everything an editor sees in the Activity, Audit and Email log views is
5
+ * written here in ordinary sentences — never raw action codes, table names or
6
+ * JSON. The same module runs on the server, where each log row gets its sentence
7
+ * stored in `meta.summary` at write time, so the wording survives even if the
8
+ * label tables later change.
9
+ */
10
+
11
+ export const ACTION_LABELS: Record<string, string> = {
12
+ "user.create": "Created user",
13
+ "user.delete": "Deleted user",
14
+ "user.password_reset": "Set a new password",
15
+ "user.send_reset": "Sent a password reset email",
16
+ "user.set_roles": "Changed user roles",
17
+ "user.invite": "Invited user",
18
+ "role.grant": "Granted role",
19
+ "role.revoke": "Revoked role",
20
+ "role.update": "Updated role",
21
+ "email.settings.update": "Updated email settings",
22
+ "email_settings.update": "Updated email settings",
23
+ "email.settings.test": "Sent test email",
24
+ "email_provider.create": "Added email provider",
25
+ "email_provider.update": "Updated email provider",
26
+ "email_provider.delete": "Deleted email provider",
27
+ "email_provider.test_send": "Sent a test email",
28
+ "content.section.save": "Saved content",
29
+ "content.section.delete": "Deleted content",
30
+ "site_settings.update": "Updated site settings",
31
+ "site_settings.clear": "Reset a site setting to the code default",
32
+ "page.create": "Created page",
33
+ "page.update": "Updated page",
34
+ "page.delete": "Deleted page",
35
+ "page.publish": "Published page",
36
+ "page.unpublish": "Unpublished page",
37
+ "page.version.create": "Saved page version",
38
+ "page.version.restore": "Restored page version",
39
+ "template.create": "Created block template",
40
+ "template.update": "Updated block template",
41
+ "template.delete": "Deleted block template",
42
+ "global.section.save": "Saved global block",
43
+ "global.section.delete": "Deleted global block",
44
+ "domain.create": "Added language domain",
45
+ "domain.update": "Updated language domain",
46
+ "domain.save": "Saved language domain",
47
+ "domain.delete": "Removed language domain",
48
+ "seo.update": "Updated SEO settings",
49
+ "seo.delete": "Removed SEO settings",
50
+ "redirect.save": "Saved redirect",
51
+ "redirect.delete": "Deleted redirect",
52
+ "media.upload": "Uploaded media",
53
+ "media.delete": "Deleted media",
54
+ "media.import": "Imported external media",
55
+ "media.replace": "Replaced an image",
56
+ "media.focus.set": "Set an image focus point",
57
+ "media.focus.clear": "Cleared an image focus point",
58
+ "media.meta.set": "Updated image text",
59
+ "media.meta.clear": "Cleared image text",
60
+ "submission.partial.delete": "Deleted saved partial submissions",
61
+ "delivery.resolve": "Marked delivery attempts as resolved",
62
+ "translate.run": "Ran auto-translation",
63
+ "form.create": "Created form",
64
+ "form.update": "Updated form",
65
+ "form.duplicate": "Duplicated form",
66
+ "form.soft_delete": "Moved form to bin",
67
+ "form.restore": "Restored form",
68
+ "form.purge": "Permanently deleted form",
69
+ "form.defaults.update": "Updated shared form defaults",
70
+ "submission.status": "Changed a submission status",
71
+ "submission.purge": "Removed old submissions",
72
+ "submission.sheet_push": "Sent submissions to the spreadsheet",
73
+ "submission.delivery_retry": "Retried a failed delivery",
74
+ "submission.delivery_resolve": "Marked a delivery problem as resolved",
75
+ "submission.partials_delete": "Deleted saved drafts",
76
+ "backup.export": "Exported a backup",
77
+ "backup.import": "Restored from a backup",
78
+ "auth.login": "Signed in",
79
+ "auth.logout": "Signed out",
80
+ };
81
+
82
+ export const ENTITY_LABELS: Record<string, string> = {
83
+ user: "user",
84
+ "auth.users": "user",
85
+ user_role: "role",
86
+ user_roles: "roles",
87
+ content_section: "content record",
88
+ content_sections: "content record",
89
+ site_settings: "site settings",
90
+ page: "page",
91
+ page_version: "page version",
92
+ page_publication: "publication",
93
+ block_template: "template",
94
+ global_section: "global block",
95
+ locale_domain: "language domain",
96
+ locale_domains: "language domain",
97
+ page_seo: "SEO settings",
98
+ email_settings: "email settings",
99
+ email_providers: "email provider",
100
+ media: "media file",
101
+ media_focus: "image focus point",
102
+ media_meta: "image text",
103
+ redirect: "redirect",
104
+ redirects: "redirect",
105
+ forms: "form",
106
+ form_submissions: "submission",
107
+ form_partials: "saved draft",
108
+ form_delivery_attempts: "delivery attempt",
109
+ translations: "translations",
110
+ backup: "backup",
111
+ };
112
+
113
+ export function humanizeAction(action: string | null | undefined): string {
114
+ if (!action) return "—";
115
+ const label = ACTION_LABELS[action];
116
+ if (label) return label;
117
+ return action
118
+ .replace(/[._-]+/g, " ")
119
+ .replace(/\s+/g, " ")
120
+ .trim()
121
+ .replace(/^./, (c) => c.toUpperCase());
122
+ }
123
+
124
+ export function humanizeEntity(entity: string | null | undefined): string {
125
+ if (!entity) return "—";
126
+ const label = ENTITY_LABELS[entity];
127
+ if (label) return label.replace(/^./, (c) => c.toUpperCase());
128
+ return entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase());
129
+ }
130
+
131
+ /** Lower-case noun for use inside a sentence, e.g. "the page". */
132
+ export function entityNoun(entity: string | null | undefined): string {
133
+ if (!entity) return "record";
134
+ return ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").toLowerCase();
135
+ }
136
+
137
+ const isUuid = (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(v);
138
+
139
+ function metaString(meta: Record<string, unknown>, keys: string[]): string | null {
140
+ for (const key of keys) {
141
+ const value = meta[key];
142
+ if (typeof value === "string" && value.trim()) return value.trim();
143
+ if (typeof value === "number") return String(value);
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * One sentence describing what happened, in ordinary English.
150
+ *
151
+ * Uses whatever human-friendly handle the meta payload carries (name, key,
152
+ * email, path) and only falls back to an identifier when nothing better exists.
153
+ */
154
+ export function describeAudit(input: {
155
+ action: string;
156
+ entity?: string | null;
157
+ entityId?: string | null;
158
+ meta?: Record<string, unknown> | null;
159
+ }): string {
160
+ const meta = (input.meta ?? {}) as Record<string, unknown>;
161
+ const existing = metaString(meta, ["summary"]);
162
+ if (existing) return existing;
163
+
164
+ const verb = humanizeAction(input.action);
165
+ const noun = entityNoun(input.entity);
166
+ const handle =
167
+ metaString(meta, ["name", "title", "email", "key", "path", "from_path", "domain", "locale"]) ??
168
+ (input.entityId && !isUuid(input.entityId) ? input.entityId : null);
169
+
170
+ const firstWord = noun.split(" ")[0] ?? "";
171
+ const alreadyNamesEntity = firstWord ? verb.toLowerCase().includes(firstWord) : false;
172
+ const subject = alreadyNamesEntity ? "" : ` the ${noun}`;
173
+ const named = handle ? ` “${handle}”` : "";
174
+
175
+ let sentence = `${verb}${subject}${named}`.replace(/\s+/g, " ").trim();
176
+
177
+ const extras: string[] = [];
178
+ const roles = meta["roles"];
179
+ if (Array.isArray(roles) && roles.length) extras.push(`roles: ${roles.join(", ")}`);
180
+ const to = metaString(meta, ["to_path"]);
181
+ if (to && input.action.startsWith("redirect")) extras.push(`now points to ${to}`);
182
+ const count = meta["count"] ?? meta["deleted"] ?? meta["updated"];
183
+ if (typeof count === "number") extras.push(`${count} item${count === 1 ? "" : "s"}`);
184
+ const status = metaString(meta, ["status"]);
185
+ if (status && input.action.startsWith("submission.status")) extras.push(`set to ${status}`);
186
+ const reason = metaString(meta, ["reason", "note"]);
187
+ if (reason) extras.push(reason);
188
+
189
+ if (extras.length) sentence += ` — ${extras.join("; ")}`;
190
+ if (!/[.!?]$/.test(sentence)) sentence += ".";
191
+ return sentence;
192
+ }
193
+
194
+ /** One sentence describing an email attempt. */
195
+ export function describeEmail(input: {
196
+ status: string;
197
+ to: string;
198
+ subject?: string | null;
199
+ provider?: string | null;
200
+ purpose?: string | null;
201
+ error?: string | null;
202
+ }): string {
203
+ const purposeLabel = humanizeEmailPurpose(input.purpose);
204
+ const lead = purposeLabel ? `${purposeLabel} email` : "Email";
205
+ const subject = input.subject ? ` “${input.subject}”` : "";
206
+ const via = input.provider ? ` via ${input.provider}` : "";
207
+ const ok = input.status === "sent" || input.status === "delivered";
208
+ const verb = ok ? "sent to" : "could not be sent to";
209
+ const tail = !ok && input.error ? ` — ${input.error}` : "";
210
+ return `${lead}${subject} ${verb} ${input.to}${via}${tail}.`;
211
+ }
212
+
213
+ /** Friendly names for the purposes app emails are sent for. */
214
+ export const EMAIL_PURPOSE_LABELS: Record<string, string> = {
215
+ form_notification: "Form notification",
216
+ form_autoreply: "Form auto-reply",
217
+ form_test: "Form test",
218
+ provider_test: "Provider test",
219
+ password_reset: "Password reset",
220
+ user_invite: "New account",
221
+ };
222
+
223
+ export function humanizeEmailPurpose(purpose: string | null | undefined): string | null {
224
+ if (!purpose) return null;
225
+ return (
226
+ EMAIL_PURPOSE_LABELS[purpose] ??
227
+ purpose.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
228
+ );
229
+ }
@@ -1,83 +1,18 @@
1
1
  // Turn machine action codes and JSON meta into human-readable strings for the
2
- // backoffice Audit and Email log views.
2
+ // backoffice Audit and Email log views. All wording lives in `describe-log`.
3
3
 
4
- const ACTION_LABELS: Record<string, string> = {
5
- "user.create": "Created user",
6
- "user.delete": "Deleted user",
7
- "user.password_reset": "Reset password",
8
- "user.invite": "Invited user",
9
- "role.grant": "Granted role",
10
- "role.revoke": "Revoked role",
11
- "role.update": "Updated role",
12
- "email.settings.update": "Updated email settings",
13
- "email.settings.test": "Sent test email",
14
- "content.section.save": "Saved section",
15
- "content.section.delete": "Deleted section",
16
- "page.create": "Created page",
17
- "page.update": "Updated page",
18
- "page.delete": "Deleted page",
19
- "page.publish": "Published page",
20
- "page.unpublish": "Unpublished page",
21
- "page.version.create": "Saved page version",
22
- "page.version.restore": "Restored page version",
23
- "template.create": "Created block template",
24
- "template.update": "Updated block template",
25
- "template.delete": "Deleted block template",
26
- "global.section.save": "Saved global block",
27
- "global.section.delete": "Deleted global block",
28
- "domain.create": "Added locale domain",
29
- "domain.update": "Updated locale domain",
30
- "domain.delete": "Removed locale domain",
31
- "seo.update": "Updated SEO",
32
- "media.upload": "Uploaded media",
33
- "media.delete": "Deleted media",
34
- "media.import": "Imported external media",
35
- "translate.run": "Ran auto-translation",
36
- "form.create": "Created form",
37
- "form.update": "Updated form",
38
- "form.duplicate": "Duplicated form",
39
- "form.soft_delete": "Moved form to bin",
40
- "form.restore": "Restored form",
41
- "form.purge": "Permanently deleted form",
42
- "form.defaults.update": "Updated shared form defaults",
43
- "auth.login": "Signed in",
44
- "auth.logout": "Signed out",
45
- };
4
+ export {
5
+ ACTION_LABELS,
6
+ ENTITY_LABELS,
7
+ describeAudit,
8
+ describeEmail,
9
+ entityNoun,
10
+ humanizeAction,
11
+ humanizeEmailPurpose,
12
+ humanizeEntity,
13
+ } from "./describe-log";
46
14
 
47
- const ENTITY_LABELS: Record<string, string> = {
48
- user: "User",
49
- user_role: "Role",
50
- content_section: "Section",
51
- page: "Page",
52
- page_version: "Page version",
53
- page_publication: "Publication",
54
- block_template: "Template",
55
- global_section: "Global block",
56
- locale_domain: "Domain",
57
- page_seo: "SEO",
58
- email_settings: "Email settings",
59
- media: "Media",
60
- redirect: "Redirect",
61
- forms: "Form",
62
- };
63
-
64
- export function humanizeAction(action: string | null | undefined): string {
65
- if (!action) return "—";
66
- if (ACTION_LABELS[action]) return ACTION_LABELS[action];
67
- // Fallback: turn "some.snake_case.action" into "Some snake case action".
68
- return action
69
- .replace(/[._-]+/g, " ")
70
- .replace(/\s+/g, " ")
71
- .trim()
72
- .replace(/^./, (c) => c.toUpperCase());
73
- }
74
-
75
- export function humanizeEntity(entity: string | null | undefined): string {
76
- if (!entity) return "—";
77
- return (
78
- ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
79
- );
80
- }
15
+ import { describeAudit, humanizeEntity } from "./describe-log";
81
16
 
82
17
  function shortId(id: string): string {
83
18
  if (id.length <= 12) return id;
@@ -105,6 +40,7 @@ export function summarizeMeta(meta: unknown): string {
105
40
  if (!Object.keys(m).length) return "";
106
41
 
107
42
  const parts: string[] = [];
43
+ // `summary` is rendered on its own as the row's sentence — never repeat it here.
108
44
  const push = (label: string, value: unknown) => {
109
45
  if (value == null || value === "") return;
110
46
  if (typeof value === "object") {
@@ -120,7 +56,6 @@ export function summarizeMeta(meta: unknown): string {
120
56
 
121
57
  // Common well-known fields, in a nice order
122
58
  const known = [
123
- ["summary", "Summary"],
124
59
  ["email", "Email"],
125
60
  ["role", "Role"],
126
61
  ["name", "Name"],
@@ -146,7 +81,7 @@ export function summarizeMeta(meta: unknown): string {
146
81
  }
147
82
  // Any remaining keys, up to 4 total extra fields
148
83
  const extras = Object.keys(m)
149
- .filter((k) => !used.has(k))
84
+ .filter((k) => !used.has(k) && k !== "summary")
150
85
  .slice(0, 4);
151
86
  for (const k of extras) {
152
87
  push(
@@ -215,3 +150,28 @@ export function emailStatusTone(status: string | null | undefined): {
215
150
  }
216
151
  );
217
152
  }
153
+
154
+
155
+ /**
156
+ * The plain-English sentence for one audit row: the stored `meta.summary`
157
+ * written at the time of the action, or a sentence derived from the row.
158
+ */
159
+ export function auditSentence(row: {
160
+ action: string;
161
+ entity?: string | null;
162
+ entity_id?: string | null;
163
+ meta?: Record<string, unknown> | null;
164
+ }): string {
165
+ return describeAudit({
166
+ action: row.action,
167
+ entity: row.entity ?? null,
168
+ entityId: row.entity_id ?? null,
169
+ meta: row.meta ?? null,
170
+ });
171
+ }
172
+
173
+ /** The plain-English sentence stored with an email log row, when present. */
174
+ export function emailSentence(row: { meta?: Record<string, unknown> | null }): string {
175
+ const summary = row.meta?.["summary"];
176
+ return typeof summary === "string" ? summary : "";
177
+ }
@@ -1,6 +1,7 @@
1
1
  import { Fragment, useMemo, useState } from "react";
2
2
 
3
3
  import {
4
+ auditSentence,
4
5
  formatAbsoluteTime,
5
6
  formatEntityRef,
6
7
  formatRelativeTime,
@@ -35,6 +36,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
35
36
  r.actor_email,
36
37
  r.action,
37
38
  humanizeAction(r.action),
39
+ auditSentence(r),
38
40
  r.entity,
39
41
  r.entity_id,
40
42
  JSON.stringify(r.meta ?? {}),
@@ -77,6 +79,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
77
79
  </thead>
78
80
  <tbody>
79
81
  {filtered.map((r) => {
82
+ const sentence = auditSentence(r);
80
83
  const summary = summarizeMeta(r.meta as never);
81
84
  const isOpen = expanded === r.id;
82
85
  return (
@@ -100,7 +103,7 @@ export function AuditView({ rows }: { rows: AuditLogRow[] }) {
100
103
  )}
101
104
  </td>
102
105
  <td className="p-3">
103
- <div className="text-ink">{humanizeAction(r.action)}</div>
106
+ <div className="text-ink">{sentence}</div>
104
107
  {summary && (
105
108
  <div className="text-[0.7rem] text-ink/60 mt-1 line-clamp-2">{summary}</div>
106
109
  )}
@@ -1,6 +1,12 @@
1
1
  import { Fragment, useMemo, useState } from "react";
2
2
 
3
- import { emailStatusTone, formatAbsoluteTime, formatRelativeTime } from "../admin/format-log";
3
+ import {
4
+ emailSentence,
5
+ emailStatusTone,
6
+ formatAbsoluteTime,
7
+ formatRelativeTime,
8
+ humanizeEmailPurpose,
9
+ } from "../admin/format-log";
4
10
 
5
11
  /** One outgoing email attempt. */
6
12
  export type EmailLogRow = {
@@ -33,7 +39,17 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
33
39
  return rows.filter((r) => {
34
40
  if (status !== "all" && r.status !== status) return false;
35
41
  if (!s) return true;
36
- const hay = [r.to_address, r.from_address, r.subject, r.provider, r.error]
42
+ const hay = [
43
+ r.to_address,
44
+ r.from_address,
45
+ r.subject,
46
+ r.provider,
47
+ r.error,
48
+ emailSentence(r),
49
+ humanizeEmailPurpose(
50
+ typeof r.meta?.["purpose"] === "string" ? (r.meta["purpose"] as string) : null,
51
+ ),
52
+ ]
37
53
  .filter(Boolean)
38
54
  .join(" ")
39
55
  .toLowerCase();
@@ -88,6 +104,10 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
88
104
  <tbody>
89
105
  {filtered.map((r) => {
90
106
  const tone = emailStatusTone(r.status);
107
+ const purpose = humanizeEmailPurpose(
108
+ typeof r.meta?.["purpose"] === "string" ? (r.meta["purpose"] as string) : null,
109
+ );
110
+ const sentence = emailSentence(r);
91
111
  const isOpen = expanded === r.id;
92
112
  return (
93
113
  <Fragment key={r.id}>
@@ -109,6 +129,11 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
109
129
  </td>
110
130
  <td className="p-3">
111
131
  <div className="text-ink">{r.subject ?? "(no subject)"}</div>
132
+ {purpose && (
133
+ <div className="text-[0.65rem] uppercase tracking-[0.15em] text-ink/50 mt-1">
134
+ {purpose}
135
+ </div>
136
+ )}
112
137
  {r.body_preview && (
113
138
  <div className="text-[0.7rem] text-ink/50 mt-1 line-clamp-1">
114
139
  {r.body_preview}
@@ -133,6 +158,7 @@ export function EmailLogsView({ rows }: { rows: EmailLogRow[] }) {
133
158
  {isOpen && (
134
159
  <tr className="bg-ink/[0.02] border-t border-ink/10">
135
160
  <td colSpan={6} className="p-4 space-y-3 text-xs">
161
+ {sentence && <p className="text-ink/80">{sentence}</p>}
136
162
  <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
137
163
  <DetailRow label="To" value={r.to_address} />
138
164
  <DetailRow label="From" value={r.from_address ?? "—"} />
@@ -0,0 +1,51 @@
1
+ import { useT } from "../../i18n";
2
+ import type { LocalizedString } from "../../i18n";
3
+ import { ManagedForm } from "../../forms/FormRenderer";
4
+ import type { FormProps } from "../types";
5
+
6
+ const TONES: Record<string, string> = {
7
+ white: "bg-white text-ink",
8
+ paper: "bg-paper text-ink",
9
+ ink: "bg-ink text-paper",
10
+ };
11
+
12
+ /**
13
+ * Section wrapper around a managed form: optional eyebrow/title/lead plus the
14
+ * form itself, resolved by key from the Forms module.
15
+ */
16
+ export function FormBlock({ props }: { props: FormProps }) {
17
+ const { t } = useT();
18
+ const tone = props.tone ?? "paper";
19
+ const dark = tone === "ink";
20
+ const formKey = props.formKey?.trim();
21
+
22
+ return (
23
+ <section id={props.anchor || undefined} className={`${TONES[tone]} py-20 md:py-28`}>
24
+ <div className="mx-auto w-full max-w-3xl px-6">
25
+ {props.eyebrow ? (
26
+ <div className="text-[0.7rem] uppercase tracking-[0.28em] opacity-60">
27
+ {t(props.eyebrow as LocalizedString)}
28
+ </div>
29
+ ) : null}
30
+ {props.title ? (
31
+ <h2 className="mt-4 font-serif text-3xl md:text-4xl whitespace-pre-line">
32
+ {t(props.title as LocalizedString)}
33
+ </h2>
34
+ ) : null}
35
+ {props.lead ? (
36
+ <p className="mt-4 opacity-75 whitespace-pre-line">{t(props.lead as LocalizedString)}</p>
37
+ ) : null}
38
+ <div className="mt-10">
39
+ {formKey ? (
40
+ <ManagedForm formKey={formKey} dark={dark} />
41
+ ) : (
42
+ <p className="text-sm opacity-60 whitespace-pre-line">No form selected.</p>
43
+ )}
44
+ </div>
45
+ </div>
46
+ </section>
47
+ );
48
+ }
49
+
50
+ export type { FormProps };
51
+ export { FormRenderer, ManagedForm } from "../../forms/FormRenderer";
@@ -12,6 +12,7 @@ import { ImageMarqueeBlock } from "./components/ImageMarqueeBlock";
12
12
  import { LinkTilesBlock } from "./components/LinkTilesBlock";
13
13
  import { CabinTabsBlock } from "./components/CabinTabsBlock";
14
14
  import { PriceTableBlock } from "./components/PriceTableBlock";
15
+ import { FormBlock } from "./components/FormBlock";
15
16
  import type {
16
17
  BlockProps,
17
18
  FaqProps,
@@ -27,6 +28,7 @@ import type {
27
28
  LinkTilesProps,
28
29
  CabinTabsProps,
29
30
  PriceTableProps,
31
+ FormProps,
30
32
  } from "./types";
31
33
  import { BlockPropsProvider } from "./block-props-context";
32
34
 
@@ -309,6 +311,19 @@ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
309
311
  } satisfies PriceTableProps as unknown as BlockProps,
310
312
  hasInlineContent: true,
311
313
  },
314
+ form: {
315
+ type: "form",
316
+ label: "Form",
317
+ description:
318
+ "Renders a managed form from Backoffice \u2192 Forms. All forms share the same settings, notifications and submissions inbox.",
319
+ category: "content",
320
+ component: ({ props }) => <FormBlock props={props as FormProps} />,
321
+ defaultProps: {
322
+ formKey: "",
323
+ tone: "paper",
324
+ } satisfies FormProps as unknown as BlockProps,
325
+ hasInlineContent: true,
326
+ },
312
327
  };
313
328
 
314
329
  /**
@@ -423,3 +423,13 @@ export type PriceTableProps = {
423
423
 
424
424
  groups?: PriceGroup[];
425
425
  };
426
+
427
+ /** Props for the shared managed-form section block. */
428
+ export type FormProps = {
429
+ formKey?: string;
430
+ eyebrow?: Partial<LocalizedString>;
431
+ title?: Partial<LocalizedString>;
432
+ lead?: Partial<LocalizedString>;
433
+ tone?: "white" | "paper" | "ink";
434
+ anchor?: string;
435
+ };