@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.90.0-m7.9",
3
+ "version": "0.91.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": [
@@ -203,6 +203,7 @@
203
203
  "./media/LazyVideo": "./src/media/LazyVideo.tsx",
204
204
  "./media/use-media-upload": "./src/media/use-media-upload.ts",
205
205
  "./cms/page-draft-store": "./src/cms/page-draft-store.ts",
206
+ "./cms/page-publication": "./src/cms/page-publication.ts",
206
207
  "./site/routes": "./src/site/routes.ts",
207
208
  "./site/PageRenderer": "./src/site/PageRenderer.tsx",
208
209
  "./server/env": "./src/server/env.ts",
@@ -227,6 +228,8 @@
227
228
  "./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx",
228
229
  "./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
229
230
  "./site/site-url": "./src/site/site-url.ts",
231
+ "./site/host": "./src/site/host.ts",
232
+ "./site/locale-domains": "./src/site/locale-domains.ts",
230
233
  "./site/SiteHeader": "./src/site/SiteHeader.tsx",
231
234
  "./site/SiteFooter": "./src/site/SiteFooter.tsx",
232
235
  "./server/schemas": "./src/server/schemas.ts",
@@ -331,6 +334,7 @@
331
334
  "test": "vitest run",
332
335
  "test:watch": "vitest",
333
336
  "verify:pack": "node ops/verify-pack.mjs",
337
+ "test:migrations": "node ops/migration-smoke.mjs",
334
338
  "check": "bun run typecheck && bun run test && bun run verify:pack"
335
339
  },
336
340
  "devDependencies": {
@@ -2,6 +2,16 @@ import { useState } from "react";
2
2
  import { toast } from "sonner";
3
3
  import { Trash2, Plus, Check, X } from "lucide-react";
4
4
 
5
+ import {
6
+ adminHostImpact,
7
+ adminHostImpactMessage,
8
+ domainChangeProblemMessage,
9
+ validateDomainChange,
10
+ type AdminDomainChange,
11
+ type DomainChangeProblem,
12
+ type EffectiveAdminHost,
13
+ } from "../site/locale-domains";
14
+
5
15
  /** One language-to-domain mapping. */
6
16
  export type LocaleDomainRow = {
7
17
  locale: string;
@@ -15,15 +25,58 @@ export type DomainsDeps = {
15
25
  domains: LocaleDomainRow[];
16
26
  locales: readonly string[];
17
27
  defaultLocale: string;
18
- /** Path back to the backoffice home, e.g. `/kontoret`. */
28
+ /** Path back to the Admin overview, e.g. `/kontoret/admin`. */
19
29
  backPath: string;
20
30
  /** Example domain shown as the input placeholder. */
21
31
  domainPlaceholder?: string;
32
+ /**
33
+ * The hostname that actually serves the backoffice, and why.
34
+ *
35
+ * Answered by the server, because the deciding input is `SITE_URL` — a value
36
+ * the browser cannot read, and one a screen that guesses it would state to
37
+ * an administrator as fact about their own install. Undefined when the host
38
+ * registered no way to ask: the screen then says nothing about the hostname
39
+ * instead of inventing an answer, and everything else still works.
40
+ */
41
+ adminHost?: EffectiveAdminHost;
42
+ /** Path the backoffice is served under, for the admin-domain explanation. */
43
+ backofficePath?: string;
22
44
  saveDomain: (input: { data: LocaleDomainRow }) => Promise<unknown>;
23
45
  deleteDomain: (input: { data: { locale: string } }) => Promise<unknown>;
24
46
  refresh: () => Promise<unknown> | void;
25
47
  };
26
48
 
49
+ /**
50
+ * Why the backoffice is reached on the hostname it is reached on.
51
+ *
52
+ * Worth spelling out because the Admin checkbox looks like the whole answer
53
+ * and is not: on a self-hosted install `SITE_URL` outranks it, which is also
54
+ * what makes a bad row recoverable.
55
+ *
56
+ * The fallbacks say "no usable row" rather than "no row" because a row flagged
57
+ * before the domain validator existed can hold a value that is not a hostname.
58
+ * Such a row is skipped, so the backoffice falls back exactly as if nothing
59
+ * were flagged — and telling the administrator nothing is flagged, while the
60
+ * checkbox they can see is ticked, reads as a bug in the screen rather than as
61
+ * the reason to go and fix the row.
62
+ */
63
+ function adminHostExplanation(info: EffectiveAdminHost): string {
64
+ switch (info.source) {
65
+ case "site-url":
66
+ return "is set by SITE_URL on this deployment, which overrides the Admin checkbox below. Change SITE_URL to move the backoffice.";
67
+ case "database":
68
+ return "is the domain flagged as the admin domain below.";
69
+ case "request-host":
70
+ return "is the address you opened, kept as-is because no usable row is flagged as the admin domain. Flag one below to designate it.";
71
+ case "default-mapping":
72
+ return "is the default domain, used because no usable row is flagged as the admin domain.";
73
+ case "first-mapping":
74
+ return "is the first configured domain, because no usable row is flagged as the admin domain and none is marked default.";
75
+ case "localhost":
76
+ return "is the local address, because nothing is configured yet.";
77
+ }
78
+ }
79
+
27
80
  /** Host-agnostic languages & domains screen. */
28
81
  export function DomainsView({ deps }: { deps: DomainsDeps }) {
29
82
  const {
@@ -31,43 +84,80 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
31
84
  locales,
32
85
  defaultLocale: DEFAULT_LOCALE,
33
86
  backPath,
34
- domainPlaceholder = "www.example.com",
87
+ domainPlaceholder = "example.com",
88
+ adminHost,
89
+ backofficePath = "/kontoret",
35
90
  saveDomain: save,
36
91
  deleteDomain: del,
37
92
  refresh,
38
93
  } = deps;
39
94
  const [busy, setBusy] = useState(false);
40
95
  const [editing, setEditing] = useState<LocaleDomainRow | null>(null);
96
+ const [problem, setProblem] = useState<DomainChangeProblem | null>(null);
41
97
  const [draft, setDraft] = useState<Partial<LocaleDomainRow>>({
42
- locale: DEFAULT_LOCALE,
43
98
  domain: "",
44
99
  isDefault: false,
45
100
  isAdminDomain: false,
46
101
  });
47
102
 
103
+ // One row per language, so adding can only offer the languages that have
104
+ // none. Offering all of them made "Add" an upsert: picking a configured
105
+ // language and saving replaced that mapping's domain without ever saying so.
106
+ const configured = new Set(domains.map((d) => d.locale));
107
+ const available = locales.filter((l) => !configured.has(l));
108
+ const suggested = available.includes(DEFAULT_LOCALE) ? DEFAULT_LOCALE : available[0];
109
+ const addLocale = draft.locale && available.includes(draft.locale) ? draft.locale : suggested;
110
+ const locale = editing ? editing.locale : addLocale;
111
+ const canAdd = available.length > 0;
112
+
48
113
  const reset = () => {
49
114
  setEditing(null);
50
- setDraft({
51
- locale: DEFAULT_LOCALE,
52
- domain: "",
53
- isDefault: false,
54
- isAdminDomain: false,
115
+ setProblem(null);
116
+ setDraft({ domain: "", isDefault: false, isAdminDomain: false });
117
+ };
118
+
119
+ /**
120
+ * Whether a change may go ahead, having said so when it may not.
121
+ *
122
+ * Two different things, deliberately kept apart. A change the server will
123
+ * refuse is refused here — stated as an error, with no dialog implying it
124
+ * could proceed. A change that merely moves the backoffice to another
125
+ * hostname is valid, and gets a confirmation naming both hostnames instead.
126
+ */
127
+ const allow = (change: AdminDomainChange): boolean => {
128
+ const blocked = validateDomainChange(domains, change, {
129
+ siteUrlAuthoritative: adminHost?.siteUrlAuthoritative === true,
130
+ });
131
+ setProblem(blocked);
132
+ if (blocked) {
133
+ toast.error(domainChangeProblemMessage(blocked));
134
+ return false;
135
+ }
136
+
137
+ const impact = adminHostImpact(domains, change, {
138
+ // With SITE_URL in charge no row change can move the backoffice, so this
139
+ // resolves to no impact and nothing is asked.
140
+ forcedHost: adminHost?.siteUrlAuthoritative ? adminHost.host : null,
141
+ requestHost: typeof window === "undefined" ? null : window.location.host,
142
+ defaultLocale: DEFAULT_LOCALE,
55
143
  });
144
+ if (!impact) return true;
145
+ return confirm(adminHostImpactMessage(impact, { authoritative: adminHost !== undefined }));
56
146
  };
57
147
 
58
148
  const submit = async (e: React.FormEvent) => {
59
149
  e.preventDefault();
60
- if (!draft.locale || !draft.domain) return;
150
+ if (!locale || !draft.domain) return;
151
+ const row: LocaleDomainRow = {
152
+ locale,
153
+ domain: draft.domain,
154
+ isDefault: !!draft.isDefault,
155
+ isAdminDomain: !!draft.isAdminDomain,
156
+ };
157
+ if (!allow({ kind: "save", ...row })) return;
61
158
  setBusy(true);
62
159
  try {
63
- await save({
64
- data: {
65
- locale: draft.locale,
66
- domain: draft.domain,
67
- isDefault: !!draft.isDefault,
68
- isAdminDomain: !!draft.isAdminDomain,
69
- },
70
- });
160
+ await save({ data: row });
71
161
  await refresh();
72
162
  toast.success("Domain saved");
73
163
  reset();
@@ -78,11 +168,12 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
78
168
  }
79
169
  };
80
170
 
81
- const remove = async (locale: string) => {
171
+ const remove = async (target: string) => {
172
+ if (!allow({ kind: "delete", locale: target })) return;
82
173
  if (!confirm("Remove this domain mapping?")) return;
83
174
  setBusy(true);
84
175
  try {
85
- await del({ data: { locale } });
176
+ await del({ data: { locale: target } });
86
177
  await refresh();
87
178
  toast.success("Domain removed");
88
179
  } catch (err) {
@@ -94,9 +185,11 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
94
185
 
95
186
  const startEdit = (d: LocaleDomainRow) => {
96
187
  setEditing(d);
188
+ setProblem(null);
97
189
  setDraft({ ...d });
98
190
  };
99
191
 
192
+
100
193
  return (
101
194
  <main className="p-6 md:p-10 w-full">
102
195
  <div className="flex items-center justify-between">
@@ -114,9 +207,21 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
114
207
 
115
208
  <p className="mt-4 max-w-2xl text-sm text-ink/70">
116
209
  Map each language to its own domain. Mark one domain as the default (fallback) and one as
117
- the admin domain where the backoffice is only available.
210
+ the domain the backoffice is served from.
118
211
  </p>
119
212
 
213
+ {adminHost && (
214
+ <div className="mt-6 max-w-2xl border border-ink/10 bg-ink/[0.02] px-4 py-3 text-sm text-ink/70">
215
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">
216
+ Backoffice hostname
217
+ </span>
218
+ <p className="mt-1">
219
+ <code className="font-mono text-ink">{adminHost.host}</code>{" "}
220
+ {adminHostExplanation(adminHost)}
221
+ </p>
222
+ </div>
223
+ )}
224
+
120
225
  <section className="mt-10">
121
226
  <h2 className="font-serif text-xl">Current domains</h2>
122
227
  <div className="mt-4 overflow-x-auto border border-ink/10">
@@ -186,20 +291,33 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
186
291
 
187
292
  <section className="mt-12 max-w-xl">
188
293
  <h2 className="font-serif text-xl">{editing ? "Edit domain" : "Add domain"}</h2>
294
+ {!editing && !canAdd ? (
295
+ <p className="mt-4 border border-ink/10 bg-ink/[0.02] px-4 py-3 text-sm text-ink/70">
296
+ Every language already has a domain. Use Edit on a row above to change one — adding
297
+ would only overwrite an existing mapping.
298
+ </p>
299
+ ) : (
189
300
  <form onSubmit={submit} className="mt-4 space-y-4">
190
301
  <label className="block">
191
302
  <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Language</span>
192
303
  <select
193
- value={draft.locale}
304
+ value={locale}
305
+ disabled={editing !== null}
194
306
  onChange={(e) => setDraft((d) => ({ ...d, locale: e.target.value }))}
195
- className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none"
307
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none disabled:cursor-not-allowed disabled:bg-ink/5 disabled:text-ink/60"
196
308
  >
197
- {locales.map((l) => (
309
+ {(editing ? [editing.locale] : available).map((l) => (
198
310
  <option key={l} value={l}>
199
311
  {l.toUpperCase()}
200
312
  </option>
201
313
  ))}
202
314
  </select>
315
+ {editing !== null && (
316
+ <span className="mt-1 block text-xs text-ink/50">
317
+ The language identifies the mapping, so it cannot be changed here. To move this
318
+ domain to another language, remove this row and add it again.
319
+ </span>
320
+ )}
203
321
  </label>
204
322
 
205
323
  <label className="block">
@@ -213,7 +331,9 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
213
331
  className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink placeholder:text-ink/40 focus:border-sea focus:outline-none"
214
332
  />
215
333
  <span className="mt-1 block text-xs text-ink/50">
216
- Enter the domain without https:// and without trailing slash.
334
+ Enter the hostname only — no https://, no path, no trailing slash. Whether you
335
+ include <code>www.</code> is kept as typed and used in generated links; requests
336
+ arriving on the other form still reach this language, so one row covers both.
217
337
  </span>
218
338
  </label>
219
339
 
@@ -234,10 +354,16 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
234
354
  onChange={(e) => setDraft((d) => ({ ...d, isAdminDomain: e.target.checked }))}
235
355
  className="size-4 border-ink/30 text-sea focus:ring-sea"
236
356
  />
237
- Admin domain (/kontoret only accessible here)
357
+ Admin domain ({backofficePath} is served here)
238
358
  </label>
239
359
  </div>
240
360
 
361
+ {problem && (
362
+ <p role="alert" className="border border-red-600/30 bg-red-50 px-4 py-3 text-sm text-red-700">
363
+ {domainChangeProblemMessage(problem)}
364
+ </p>
365
+ )}
366
+
241
367
  <div className="flex items-center gap-3 pt-4">
242
368
  <button
243
369
  type="submit"
@@ -258,6 +384,7 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
258
384
  )}
259
385
  </div>
260
386
  </form>
387
+ )}
261
388
  </section>
262
389
  </main>
263
390
  );
@@ -15,6 +15,7 @@ import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields
15
15
  import { RuleSimulator } from "./forms/RuleSimulator";
16
16
  import { ConditionBuilder } from "./forms/ConditionBuilder";
17
17
  import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
18
+ import { GoogleSheetsHelp, type TestSheetFn } from "./forms/GoogleSheetsHelp";
18
19
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
19
20
  import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
20
21
 
@@ -60,6 +61,8 @@ export type FormEditorDeps = {
60
61
  testWebhook: (input: {
61
62
  data: { formKey: string; url?: string | undefined; secret?: string | undefined };
62
63
  }) => Promise<{ ok: boolean; status?: number | null; error?: string | null }>;
64
+ /** Verifies the Google Sheets connection. Absent on hosts that skip it. */
65
+ testSheetConnection?: TestSheetFn;
63
66
  /** Host router link. */
64
67
  LinkComponent: (props: {
65
68
  to: string;
@@ -134,6 +137,7 @@ export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
134
137
  duplicateForm,
135
138
  syncSubmissions,
136
139
  testWebhook,
140
+ testSheetConnection,
137
141
  LinkComponent: Link,
138
142
  FieldEditorComponent: FieldEditor,
139
143
  FormPreviewComponent: FormPreview,
@@ -774,6 +778,12 @@ export function FormEditorView({ deps }: { deps: FormEditorDeps }) {
774
778
  <p className="text-xs text-ink/45">
775
779
  Submissions are always stored first — a Sheets outage never loses data.
776
780
  </p>
781
+ <GoogleSheetsHelp
782
+ testSheet={testSheetConnection}
783
+ formKey={form.key}
784
+ spreadsheetId={priv.sheetId ?? d?.sheet_id ?? ""}
785
+ tab={priv.sheetTab ?? d?.sheet_tab ?? ""}
786
+ />
777
787
  </InspectorSection>
778
788
  </div>
779
789
 
@@ -5,6 +5,7 @@ import { ArrowLeft, Save } from "lucide-react";
5
5
  import type { FormDefaults } from "../forms/field-sets";
6
6
  import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields";
7
7
  import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
8
+ import { GoogleSheetsHelp, type TestSheetFn } from "./forms/GoogleSheetsHelp";
8
9
  import { POLICY_WORDS, normalizePolicyUrl, renderLinkedText } from "../forms/rich-text";
9
10
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
10
11
  import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
@@ -19,6 +20,8 @@ export type FormSettingsDeps = {
19
20
  purgeOldSubmissions: (input: {
20
21
  data: { dryRun: boolean };
21
22
  }) => Promise<{ matched: number; deleted: number; cutoff: string }>;
23
+ /** Verifies the Google Sheets connection. Absent on hosts that skip it. */
24
+ testSheetConnection?: TestSheetFn;
22
25
  /** Locale previewed under the consent text; defaults to the first key present. */
23
26
  previewLocale?: string;
24
27
  /** Host router link back to the forms index. */
@@ -440,6 +443,11 @@ export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
440
443
  Live sync requires the Google Sheets connection. CSV export always works from the
441
444
  submissions inbox.
442
445
  </p>
446
+ <GoogleSheetsHelp
447
+ testSheet={deps.testSheetConnection}
448
+ spreadsheetId={s.sheet_id}
449
+ tab={s.sheet_tab}
450
+ />
443
451
  </InspectorSection>
444
452
  </div>
445
453
  </EditLockFence>