@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.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- 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.
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
|
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={
|
|
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
|
-
{
|
|
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
|
|
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 (
|
|
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>
|