@bygghammar/cms-platform 0.80.0 → 0.90.0-m7.1
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 +251 -0
- package/docs/POPUPS.md +102 -0
- package/package.json +23 -3
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/routes/host.ts +30 -7
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/config/modules.ts +184 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +40 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +10 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/popups/admin.tsx +72 -0
- package/src/popups/cms-bridges.ts +200 -0
- package/src/popups/destinations.tsx +141 -0
- package/src/popups/host.ts +82 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +94 -0
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/types/css-modules.d.ts +6 -0
|
@@ -8,22 +8,23 @@
|
|
|
8
8
|
import type { LucideProps } from "lucide-react";
|
|
9
9
|
import {
|
|
10
10
|
Blocks,
|
|
11
|
-
Calendar,
|
|
12
|
-
ClipboardList,
|
|
13
11
|
FileText,
|
|
14
|
-
Globe,
|
|
15
|
-
GraduationCap,
|
|
16
12
|
ImageIcon,
|
|
17
13
|
Layers,
|
|
18
14
|
LayoutDashboard,
|
|
19
15
|
LayoutList,
|
|
20
|
-
MessageSquareQuote,
|
|
21
16
|
ShieldCheck,
|
|
22
17
|
} from "lucide-react";
|
|
23
18
|
|
|
24
19
|
import type { SiteConfig } from "../../config/site-config";
|
|
20
|
+
import { CMS_MODULE_LIST, type CmsModuleKey } from "../../config/modules";
|
|
25
21
|
import type { BackofficeNavigationItem } from "../../navigation";
|
|
26
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Sidebar keys: the always-present screens plus every module that declares a
|
|
25
|
+
* nav entry. Module keys are no longer repeated here — a module contributing a
|
|
26
|
+
* sidebar item is a fact about the module, kept in one place.
|
|
27
|
+
*/
|
|
27
28
|
export type BackofficeNavKey =
|
|
28
29
|
| "dashboard"
|
|
29
30
|
| "pages"
|
|
@@ -32,12 +33,8 @@ export type BackofficeNavKey =
|
|
|
32
33
|
| "sections"
|
|
33
34
|
| "media"
|
|
34
35
|
| "seo"
|
|
35
|
-
| "
|
|
36
|
-
|
|
|
37
|
-
| "courses"
|
|
38
|
-
| "forms"
|
|
39
|
-
| "reviews"
|
|
40
|
-
| "admin";
|
|
36
|
+
| "admin"
|
|
37
|
+
| CmsModuleKey;
|
|
41
38
|
|
|
42
39
|
export type BackofficeNavItem = BackofficeNavigationItem<LucideProps>;
|
|
43
40
|
|
|
@@ -51,7 +48,8 @@ type Definition = {
|
|
|
51
48
|
feature?: keyof SiteConfig["features"];
|
|
52
49
|
};
|
|
53
50
|
|
|
54
|
-
|
|
51
|
+
/** Screens every site has, regardless of which modules are on. */
|
|
52
|
+
const CORE: readonly Definition[] = [
|
|
55
53
|
{ key: "dashboard", path: "", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
|
56
54
|
{ key: "pages", path: "/pages-builder", label: "Page builder", icon: Blocks },
|
|
57
55
|
{ key: "global-sections", path: "/global-sections", label: "Global Sections", icon: Layers },
|
|
@@ -59,17 +57,28 @@ const DEFINITIONS: readonly Definition[] = [
|
|
|
59
57
|
{ key: "sections", path: "/sections", label: "Shared content", icon: LayoutList },
|
|
60
58
|
{ key: "media", path: "/media", label: "Media", icon: ImageIcon },
|
|
61
59
|
{ key: "seo", path: "/seo", label: "SEO", icon: FileText },
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Entries contributed by modules, each gated on its own flag. */
|
|
63
|
+
const MODULE_ITEMS: readonly Definition[] = CMS_MODULE_LIST.flatMap((module) =>
|
|
64
|
+
module.nav === undefined
|
|
65
|
+
? []
|
|
66
|
+
: [
|
|
67
|
+
{
|
|
68
|
+
key: module.key,
|
|
69
|
+
path: module.nav.path,
|
|
70
|
+
label: module.nav.label,
|
|
71
|
+
icon: module.nav.icon,
|
|
72
|
+
...(module.nav.exact === undefined ? {} : { exact: module.nav.exact }),
|
|
73
|
+
feature: module.key,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Admin stays last: it is the catch-all for everything a module did not claim.
|
|
79
|
+
const DEFINITIONS: readonly Definition[] = [
|
|
80
|
+
...CORE,
|
|
81
|
+
...MODULE_ITEMS,
|
|
73
82
|
{ key: "admin", path: "/admin", label: "Admin", icon: ShieldCheck },
|
|
74
83
|
];
|
|
75
84
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS module table.
|
|
3
|
+
*
|
|
4
|
+
* "Modules" were previously described in four separate places that had to agree
|
|
5
|
+
* by hand: the `SiteFeatureFlags` type, the defaults object, the label map on
|
|
6
|
+
* the Modules screen, and the sidebar definitions. Adding one meant editing all
|
|
7
|
+
* four and hoping none was missed.
|
|
8
|
+
*
|
|
9
|
+
* This is the single description each of those now derives from. It is
|
|
10
|
+
* deliberately NOT a plugin registry: the key space stays a closed, typed union
|
|
11
|
+
* so an unknown value in `site_settings` is still rejected rather than trusted.
|
|
12
|
+
* What it buys is that a new first-party module is one entry here plus one line
|
|
13
|
+
* in the flag type, instead of five edits across the package.
|
|
14
|
+
*/
|
|
15
|
+
import type { ComponentType } from "react";
|
|
16
|
+
import type { LucideProps } from "lucide-react";
|
|
17
|
+
import {
|
|
18
|
+
Blocks,
|
|
19
|
+
Calendar,
|
|
20
|
+
ClipboardList,
|
|
21
|
+
Globe,
|
|
22
|
+
GraduationCap,
|
|
23
|
+
MessageSquare,
|
|
24
|
+
MessageSquareQuote,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every module a site can switch on.
|
|
29
|
+
*
|
|
30
|
+
* Adding a key here is the one type-level edit a new module needs; the compiler
|
|
31
|
+
* then requires the matching table entry below.
|
|
32
|
+
*/
|
|
33
|
+
export type SiteFeatureFlags = {
|
|
34
|
+
/** Trips / departures module (`/kontoret/trips`). */
|
|
35
|
+
trips: boolean;
|
|
36
|
+
/** Courses module (`/kontoret/kurser`). */
|
|
37
|
+
courses: boolean;
|
|
38
|
+
/** Reviews module (`/kontoret/recensioner`). */
|
|
39
|
+
reviews: boolean;
|
|
40
|
+
/** Transactional email + provider settings screens. */
|
|
41
|
+
email: boolean;
|
|
42
|
+
/** Head/body code injection and analytics pixels. */
|
|
43
|
+
tracking: boolean;
|
|
44
|
+
/** Backup & restore tooling (superadmin only). */
|
|
45
|
+
backup: boolean;
|
|
46
|
+
/** Custom domain management screen. */
|
|
47
|
+
domains: boolean;
|
|
48
|
+
/** Public forms module. */
|
|
49
|
+
forms: boolean;
|
|
50
|
+
/** Popups module: public popup runtime + backoffice administration. */
|
|
51
|
+
popups: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CmsModuleKey = keyof SiteFeatureFlags;
|
|
55
|
+
|
|
56
|
+
/** A sidebar entry a module contributes when it is enabled. */
|
|
57
|
+
export type CmsModuleNav = {
|
|
58
|
+
/** Appended to the site's configured backoffice path. Never hardcode `/kontoret`. */
|
|
59
|
+
readonly path: string;
|
|
60
|
+
readonly label: string;
|
|
61
|
+
readonly icon: ComponentType<LucideProps>;
|
|
62
|
+
readonly exact?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type CmsModuleDefinition = {
|
|
66
|
+
readonly key: CmsModuleKey;
|
|
67
|
+
/** Shown on the Modules screen. */
|
|
68
|
+
readonly label: string;
|
|
69
|
+
/** Whether a site that says nothing gets this module. */
|
|
70
|
+
readonly defaultEnabled: boolean;
|
|
71
|
+
/** One line of help on the Modules screen. */
|
|
72
|
+
readonly description?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Sidebar contribution. Absent for modules with no screen of their own —
|
|
75
|
+
* `email`, `tracking` and `backup` live under Admin.
|
|
76
|
+
*/
|
|
77
|
+
readonly nav?: CmsModuleNav;
|
|
78
|
+
/**
|
|
79
|
+
* Minimum role for the module's own screens. `superadmin` is used by modules
|
|
80
|
+
* that can destroy or exfiltrate data.
|
|
81
|
+
*/
|
|
82
|
+
readonly requiresRole?: "staff" | "superadmin";
|
|
83
|
+
/**
|
|
84
|
+
* True when the module contributes a component to the public site. The public
|
|
85
|
+
* runtime host renders only the modules that both declare this and are
|
|
86
|
+
* enabled, so a disabled module mounts nothing.
|
|
87
|
+
*/
|
|
88
|
+
readonly hasPublicRuntime?: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every module, in sidebar order.
|
|
93
|
+
*
|
|
94
|
+
* `satisfies` pins the array to the flag union, so a key added to
|
|
95
|
+
* `SiteFeatureFlags` without an entry here — or an entry whose key is not a
|
|
96
|
+
* flag — is a compile error rather than a module that silently never appears.
|
|
97
|
+
*/
|
|
98
|
+
export const CMS_MODULES = [
|
|
99
|
+
{
|
|
100
|
+
key: "trips",
|
|
101
|
+
label: "Trips & departures",
|
|
102
|
+
defaultEnabled: false,
|
|
103
|
+
nav: { path: "/trips", label: "Trips", icon: Calendar },
|
|
104
|
+
requiresRole: "staff",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: "courses",
|
|
108
|
+
label: "Courses",
|
|
109
|
+
defaultEnabled: false,
|
|
110
|
+
nav: { path: "/kurser", label: "Courses", icon: GraduationCap },
|
|
111
|
+
requiresRole: "staff",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: "reviews",
|
|
115
|
+
label: "Reviews",
|
|
116
|
+
defaultEnabled: false,
|
|
117
|
+
nav: { path: "/recensioner", label: "Reviews", icon: MessageSquareQuote },
|
|
118
|
+
requiresRole: "staff",
|
|
119
|
+
},
|
|
120
|
+
{ key: "email", label: "Transactional email", defaultEnabled: true, requiresRole: "staff" },
|
|
121
|
+
{ key: "tracking", label: "Code & tracking", defaultEnabled: true, requiresRole: "staff" },
|
|
122
|
+
{ key: "backup", label: "Backup & restore", defaultEnabled: true, requiresRole: "superadmin" },
|
|
123
|
+
{
|
|
124
|
+
key: "domains",
|
|
125
|
+
label: "Custom domains",
|
|
126
|
+
defaultEnabled: false,
|
|
127
|
+
nav: { path: "/domains", label: "Domains", icon: Globe },
|
|
128
|
+
requiresRole: "staff",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
key: "forms",
|
|
132
|
+
label: "Forms",
|
|
133
|
+
defaultEnabled: true,
|
|
134
|
+
nav: { path: "/forms", label: "Forms", icon: ClipboardList },
|
|
135
|
+
requiresRole: "staff",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: "popups",
|
|
139
|
+
label: "Popups",
|
|
140
|
+
defaultEnabled: false,
|
|
141
|
+
description: "Timed popups on the public site, with their own statistics.",
|
|
142
|
+
nav: { path: "/popups", label: "Popups", icon: MessageSquare },
|
|
143
|
+
requiresRole: "staff",
|
|
144
|
+
hasPublicRuntime: true,
|
|
145
|
+
},
|
|
146
|
+
] as const satisfies readonly CmsModuleDefinition[];
|
|
147
|
+
|
|
148
|
+
/** Compile-time proof that every flag has exactly one module entry. */
|
|
149
|
+
type DeclaredKeys = (typeof CMS_MODULES)[number]["key"];
|
|
150
|
+
type MissingModule = Exclude<CmsModuleKey, DeclaredKeys>;
|
|
151
|
+
type AssertNever<T extends never> = T;
|
|
152
|
+
type _EveryFlagIsDeclared = AssertNever<MissingModule>;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The table widened to the declaration type.
|
|
156
|
+
*
|
|
157
|
+
* `CMS_MODULES` is `as const` so the exhaustiveness check above can see the
|
|
158
|
+
* literal keys; consumers want the declared shape, where every optional field
|
|
159
|
+
* exists whether or not a particular entry uses it.
|
|
160
|
+
*/
|
|
161
|
+
export const CMS_MODULE_LIST: readonly CmsModuleDefinition[] = CMS_MODULES;
|
|
162
|
+
|
|
163
|
+
export const CMS_MODULE_KEYS: readonly CmsModuleKey[] = CMS_MODULES.map((m) => m.key);
|
|
164
|
+
|
|
165
|
+
export function findCmsModule(key: CmsModuleKey): CmsModuleDefinition | undefined {
|
|
166
|
+
return CMS_MODULES.find((module) => module.key === key);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The defaults a site starts from, derived so the two can never disagree. */
|
|
170
|
+
export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = Object.fromEntries(
|
|
171
|
+
CMS_MODULES.map((module) => [module.key, module.defaultEnabled]),
|
|
172
|
+
) as SiteFeatureFlags;
|
|
173
|
+
|
|
174
|
+
/** Labels for the Modules screen, derived from the same table. */
|
|
175
|
+
export const CMS_MODULE_LABELS: Readonly<Record<CmsModuleKey, string>> = Object.fromEntries(
|
|
176
|
+
CMS_MODULES.map((module) => [module.key, module.label]),
|
|
177
|
+
) as Record<CmsModuleKey, string>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Blocks that unnecessary icon imports are not the reason a public bundle grows:
|
|
181
|
+
* this module table is imported by the public runtime host as well, so keep it
|
|
182
|
+
* free of anything heavier than the icon components already used by the sidebar.
|
|
183
|
+
*/
|
|
184
|
+
export type { LucideProps };
|
|
@@ -8,36 +8,12 @@
|
|
|
8
8
|
* path or feature set.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** Reviews module (`/kontoret/recensioner`). */
|
|
18
|
-
reviews: boolean;
|
|
19
|
-
/** Transactional email + provider settings screens. */
|
|
20
|
-
email: boolean;
|
|
21
|
-
/** Head/body code injection and analytics pixels. */
|
|
22
|
-
tracking: boolean;
|
|
23
|
-
/** Backup & restore tooling (superadmin only). */
|
|
24
|
-
backup: boolean;
|
|
25
|
-
/** Custom domain management screen. */
|
|
26
|
-
domains: boolean;
|
|
27
|
-
/** Public forms module. */
|
|
28
|
-
forms: boolean;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = {
|
|
32
|
-
trips: false,
|
|
33
|
-
courses: false,
|
|
34
|
-
reviews: false,
|
|
35
|
-
email: true,
|
|
36
|
-
tracking: true,
|
|
37
|
-
backup: true,
|
|
38
|
-
domains: false,
|
|
39
|
-
forms: true,
|
|
40
|
-
};
|
|
11
|
+
// Modules — their keys, defaults and labels — are described once in
|
|
12
|
+
// `./modules`. Re-exported here so every existing import keeps working.
|
|
13
|
+
export type { SiteFeatureFlags } from "./modules";
|
|
14
|
+
export { DEFAULT_FEATURE_FLAGS } from "./modules";
|
|
15
|
+
import type { SiteFeatureFlags } from "./modules";
|
|
16
|
+
import { CMS_MODULE_KEYS, DEFAULT_FEATURE_FLAGS } from "./modules";
|
|
41
17
|
|
|
42
18
|
/** Official company / contact details. Locale-neutral by design. */
|
|
43
19
|
export type SiteCompany = {
|
|
@@ -254,7 +230,9 @@ export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
|
|
|
254
230
|
}
|
|
255
231
|
if (isRecord(raw["features"])) {
|
|
256
232
|
const features: Partial<SiteFeatureFlags> = {};
|
|
257
|
-
|
|
233
|
+
// Still a closed allowlist: an unknown key in the database is dropped on
|
|
234
|
+
// read and on write. Modules made the list derived, not arbitrary.
|
|
235
|
+
for (const key of CMS_MODULE_KEYS) {
|
|
258
236
|
const value = (raw["features"] as Record<string, unknown>)[key];
|
|
259
237
|
if (typeof value === "boolean") features[key] = value;
|
|
260
238
|
}
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
} from "./conditions";
|
|
15
15
|
import { renderLinkedText } from "./rich-text";
|
|
16
16
|
import { useHostPublicFormFns, useHostTripSource } from "../host";
|
|
17
|
+
import { emitFormSubmissionSuccess } from "./success-listeners";
|
|
18
|
+
import { isStoredSubmission } from "./submission-result";
|
|
19
|
+
import type { FormSubmissionSuccess } from "./submission-result";
|
|
17
20
|
|
|
18
21
|
const TOKEN_PREFIX = "form-draft-token:";
|
|
19
22
|
|
|
@@ -45,11 +48,18 @@ export function FormRenderer({
|
|
|
45
48
|
dark = false,
|
|
46
49
|
className,
|
|
47
50
|
preview = false,
|
|
51
|
+
onSuccess,
|
|
48
52
|
}: {
|
|
49
53
|
form: PublicForm;
|
|
50
54
|
dark?: boolean;
|
|
51
55
|
className?: string;
|
|
52
56
|
preview?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Called only for a submission the host actually stored. A honeypot
|
|
59
|
+
* rejection — `{ ok: true, skipped: true }` — never reaches it, and neither
|
|
60
|
+
* does preview mode, which contacts no server at all.
|
|
61
|
+
*/
|
|
62
|
+
onSuccess?: (event: FormSubmissionSuccess) => void;
|
|
53
63
|
}) {
|
|
54
64
|
const { t } = useT();
|
|
55
65
|
const locale = useLocale();
|
|
@@ -216,16 +226,32 @@ export function FormRenderer({
|
|
|
216
226
|
try {
|
|
217
227
|
// Send only the answers to fields the visitor actually saw.
|
|
218
228
|
const visible = Object.fromEntries(fields.map((f) => [f.key, values[f.key] ?? ""]));
|
|
219
|
-
|
|
229
|
+
const pagePath = typeof window !== "undefined" ? window.location.pathname : undefined;
|
|
230
|
+
const result = await send({
|
|
220
231
|
key: form.key,
|
|
221
232
|
locale,
|
|
222
|
-
pagePath
|
|
233
|
+
pagePath,
|
|
223
234
|
consent,
|
|
224
235
|
elapsedMs: Date.now() - mountedAt.current,
|
|
225
236
|
hp,
|
|
226
237
|
clientToken: form.savePartials ? draftToken(form.key) : undefined,
|
|
227
238
|
values: visible,
|
|
228
239
|
});
|
|
240
|
+
|
|
241
|
+
// Announced before the redirect below, not after: assigning
|
|
242
|
+
// window.location.href tears the page down, and a listener that has not
|
|
243
|
+
// run by then never will. Only a stored submission counts — a honeypot
|
|
244
|
+
// rejection answers ok:true and must not look like a conversion.
|
|
245
|
+
if (isStoredSubmission(result)) {
|
|
246
|
+
const event: FormSubmissionSuccess = {
|
|
247
|
+
formKey: form.key,
|
|
248
|
+
locale,
|
|
249
|
+
...(pagePath === undefined ? {} : { pagePath }),
|
|
250
|
+
};
|
|
251
|
+
emitFormSubmissionSuccess(event);
|
|
252
|
+
onSuccess?.(event);
|
|
253
|
+
}
|
|
254
|
+
|
|
229
255
|
if (form.thankyouMode === "redirect" && form.thankyouRedirect) {
|
|
230
256
|
window.location.href = form.thankyouRedirect;
|
|
231
257
|
return;
|
|
@@ -412,10 +438,13 @@ export function ManagedForm({
|
|
|
412
438
|
formKey,
|
|
413
439
|
dark = false,
|
|
414
440
|
className,
|
|
441
|
+
onSuccess,
|
|
415
442
|
}: {
|
|
416
443
|
formKey: string;
|
|
417
444
|
dark?: boolean;
|
|
418
445
|
className?: string;
|
|
446
|
+
/** Forwarded to `FormRenderer`; fires only for a stored submission. */
|
|
447
|
+
onSuccess?: (event: FormSubmissionSuccess) => void;
|
|
419
448
|
}) {
|
|
420
449
|
const { getPublicForm } = useHostPublicFormFns();
|
|
421
450
|
const { data: form, isLoading } = useQuery({
|
|
@@ -432,7 +461,15 @@ export function ManagedForm({
|
|
|
432
461
|
);
|
|
433
462
|
}
|
|
434
463
|
if (!form) return null;
|
|
435
|
-
return
|
|
464
|
+
return (
|
|
465
|
+
<FormRenderer
|
|
466
|
+
form={form}
|
|
467
|
+
dark={dark}
|
|
468
|
+
className={className}
|
|
469
|
+
{...(onSuccess === undefined ? {} : { onSuccess })}
|
|
470
|
+
/>
|
|
471
|
+
);
|
|
436
472
|
}
|
|
437
473
|
|
|
438
474
|
export type { PublicForm };
|
|
475
|
+
export type { FormSubmissionSuccess };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host's submit endpoint answered.
|
|
3
|
+
*
|
|
4
|
+
* The distinction that matters is `skipped`. A site's spam protection may
|
|
5
|
+
* accept a request and deliberately store nothing — a honeypot hit answers
|
|
6
|
+
* `{ ok: true, skipped: true }`. Nothing was saved, no notification was sent,
|
|
7
|
+
* and no person is behind it. Treating that as a completed submission is how a
|
|
8
|
+
* bot inflates every conversion figure a site reports.
|
|
9
|
+
*/
|
|
10
|
+
export type FormSubmitResult = {
|
|
11
|
+
readonly ok: boolean;
|
|
12
|
+
/** True when the request was accepted but deliberately not stored. */
|
|
13
|
+
readonly skipped?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Details handed to a success listener. Never carries what the visitor typed. */
|
|
17
|
+
export type FormSubmissionSuccess = {
|
|
18
|
+
readonly formKey: string;
|
|
19
|
+
readonly locale: string;
|
|
20
|
+
/** The page the form was submitted from, when the host knows it. */
|
|
21
|
+
readonly pagePath?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* True only for a submission the host actually stored.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately conservative: an endpoint that answers with something this
|
|
28
|
+
* cannot read is treated as *not* a confirmed success, because over-counting a
|
|
29
|
+
* conversion is worse than under-counting one.
|
|
30
|
+
*/
|
|
31
|
+
export function isStoredSubmission(result: unknown): boolean {
|
|
32
|
+
if (typeof result !== "object" || result === null) return false;
|
|
33
|
+
const { ok, skipped } = result as FormSubmitResult;
|
|
34
|
+
return ok === true && skipped !== true;
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic notification seam for genuinely successful form submissions.
|
|
3
|
+
*
|
|
4
|
+
* The form system knows nothing about who is listening. Any CMS module — popups
|
|
5
|
+
* today, something else later — subscribes here, and the form runtime keeps
|
|
6
|
+
* working with no listener at all. That is the whole point: `FormRenderer` must
|
|
7
|
+
* never import a module in order to tell it something happened.
|
|
8
|
+
*/
|
|
9
|
+
import type { FormSubmissionSuccess } from "./submission-result";
|
|
10
|
+
|
|
11
|
+
type Listener = (event: FormSubmissionSuccess) => void;
|
|
12
|
+
|
|
13
|
+
const listeners = new Set<Listener>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Subscribe to stored submissions. Returns an unsubscribe function.
|
|
17
|
+
*
|
|
18
|
+
* Only genuine submissions arrive here — a honeypot rejection never does.
|
|
19
|
+
*/
|
|
20
|
+
export function onFormSubmissionSuccess(listener: Listener): () => void {
|
|
21
|
+
listeners.add(listener);
|
|
22
|
+
return () => {
|
|
23
|
+
listeners.delete(listener);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Notify every listener.
|
|
29
|
+
*
|
|
30
|
+
* Each is isolated: a listener that throws must not break the visitor's
|
|
31
|
+
* submission, which has already succeeded by the time this runs.
|
|
32
|
+
*/
|
|
33
|
+
export function emitFormSubmissionSuccess(event: FormSubmissionSuccess): void {
|
|
34
|
+
for (const listener of [...listeners]) {
|
|
35
|
+
try {
|
|
36
|
+
listener(event);
|
|
37
|
+
} catch {
|
|
38
|
+
/* A subscriber must never break a form. */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test helper: drops every subscription. */
|
|
44
|
+
export function clearFormSubmissionListeners(): void {
|
|
45
|
+
listeners.clear();
|
|
46
|
+
}
|
package/src/host/index.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentType, ImgHTMLAttributes } from "react";
|
|
2
2
|
import type { UseQueryOptions } from "@tanstack/react-query";
|
|
3
3
|
import type { LocalizedString } from "../i18n/types";
|
|
4
|
+
import type { FormSubmitResult } from "../forms/submission-result";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Host component contract.
|
|
@@ -191,7 +192,15 @@ export type HostSavePartialInput = {
|
|
|
191
192
|
*/
|
|
192
193
|
export type HostPublicFormFns = {
|
|
193
194
|
getPublicForm: (key: string) => Promise<unknown>;
|
|
194
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Stores a submission.
|
|
197
|
+
*
|
|
198
|
+
* The result is read, not discarded: `skipped: true` means the request was
|
|
199
|
+
* accepted and deliberately not stored (a honeypot hit), which must never be
|
|
200
|
+
* reported as a completed submission. A host that answers with something
|
|
201
|
+
* else is treated as unconfirmed rather than successful.
|
|
202
|
+
*/
|
|
203
|
+
submitForm: (input: HostSubmitFormInput) => Promise<FormSubmitResult | unknown>;
|
|
195
204
|
savePartialSubmission: (input: HostSavePartialInput) => Promise<unknown>;
|
|
196
205
|
};
|
|
197
206
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CmsModuleAdmin,
|
|
3
|
+
CmsModuleRuntimes,
|
|
4
|
+
clearModuleRuntimes,
|
|
5
|
+
enabledModuleRuntimes,
|
|
6
|
+
moduleAdminLoader,
|
|
7
|
+
registerModuleAdmin,
|
|
8
|
+
registerModuleRuntime,
|
|
9
|
+
} from "./public-runtime";
|
|
10
|
+
export type {
|
|
11
|
+
CmsModuleAdminProps,
|
|
12
|
+
CmsModuleRuntimesProps,
|
|
13
|
+
ModuleComponentLoader,
|
|
14
|
+
} from "./public-runtime";
|
|
15
|
+
export {
|
|
16
|
+
CMS_MODULES,
|
|
17
|
+
CMS_MODULE_KEYS,
|
|
18
|
+
CMS_MODULE_LABELS,
|
|
19
|
+
CMS_MODULE_LIST,
|
|
20
|
+
DEFAULT_FEATURE_FLAGS,
|
|
21
|
+
findCmsModule,
|
|
22
|
+
} from "../config/modules";
|
|
23
|
+
export type {
|
|
24
|
+
CmsModuleDefinition,
|
|
25
|
+
CmsModuleKey,
|
|
26
|
+
CmsModuleNav,
|
|
27
|
+
SiteFeatureFlags,
|
|
28
|
+
} from "../config/modules";
|