@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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy public-site contributions from enabled modules.
|
|
3
|
+
*
|
|
4
|
+
* A module registers a *loader*, never a component. Two consequences that are
|
|
5
|
+
* the whole point:
|
|
6
|
+
*
|
|
7
|
+
* 1. Defining a module costs nothing. Registration does not import the module's
|
|
8
|
+
* implementation, so a site that has popups switched off never evaluates the
|
|
9
|
+
* popup runtime and never asks the browser for its chunk.
|
|
10
|
+
* 2. Loading happens after hydration, on the client only. The server renders
|
|
11
|
+
* nothing here and so does the first client render, so there is nothing for
|
|
12
|
+
* hydration to disagree about.
|
|
13
|
+
*
|
|
14
|
+
* The loaders are ordinary local `import()` calls that the host's bundler turns
|
|
15
|
+
* into chunks. Nothing is ever fetched from a remote origin.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useState } from "react";
|
|
18
|
+
import type { ComponentType, ReactNode } from "react";
|
|
19
|
+
import type { CmsModuleKey, SiteFeatureFlags } from "../config/modules";
|
|
20
|
+
import { CMS_MODULE_LIST } from "../config/modules";
|
|
21
|
+
import { siteConfig } from "../config/site-config";
|
|
22
|
+
|
|
23
|
+
/** What a loader must resolve to: a module with a default-exported component. */
|
|
24
|
+
export type ModuleComponentLoader = () => Promise<{ default: ComponentType }>;
|
|
25
|
+
|
|
26
|
+
const publicLoaders = new Map<CmsModuleKey, ModuleComponentLoader>();
|
|
27
|
+
const adminLoaders = new Map<CmsModuleKey, ModuleComponentLoader>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a module's public component loader.
|
|
31
|
+
*
|
|
32
|
+
* Idempotent by key: bundlers evaluate a module once, but a site may call this
|
|
33
|
+
* from more than one entry, and mounting the same runtime twice would double
|
|
34
|
+
* every impression it records.
|
|
35
|
+
*/
|
|
36
|
+
export function registerModuleRuntime(key: CmsModuleKey, load: ModuleComponentLoader): void {
|
|
37
|
+
const declared = CMS_MODULE_LIST.find((module) => module.key === key);
|
|
38
|
+
if (declared?.hasPublicRuntime !== true) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`@bygghammar/cms-platform: module "${key}" did not declare hasPublicRuntime, so it cannot contribute to the public site.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
publicLoaders.set(key, load);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Register a module's backoffice screen loader. */
|
|
47
|
+
export function registerModuleAdmin(key: CmsModuleKey, load: ModuleComponentLoader): void {
|
|
48
|
+
adminLoaders.set(key, load);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Test helper. Not part of the site-facing contract. */
|
|
52
|
+
export function clearModuleRuntimes(): void {
|
|
53
|
+
publicLoaders.clear();
|
|
54
|
+
adminLoaders.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Which registered public runtimes the given feature set enables, in table order. */
|
|
58
|
+
export function enabledModuleRuntimes(
|
|
59
|
+
features: SiteFeatureFlags,
|
|
60
|
+
): readonly { key: CmsModuleKey; load: ModuleComponentLoader }[] {
|
|
61
|
+
return CMS_MODULE_LIST.flatMap((module) => {
|
|
62
|
+
if (features[module.key] !== true) return [];
|
|
63
|
+
const load = publicLoaders.get(module.key);
|
|
64
|
+
return load === undefined ? [] : [{ key: module.key, load }];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function moduleAdminLoader(key: CmsModuleKey): ModuleComponentLoader | undefined {
|
|
69
|
+
return adminLoaders.get(key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Loads one module's component after hydration and renders it.
|
|
74
|
+
*
|
|
75
|
+
* The loader runs in an effect, never during render, so it cannot execute on
|
|
76
|
+
* the server. `cancelled` guards a module that unmounts before its chunk
|
|
77
|
+
* arrives, which is ordinary on a fast navigation.
|
|
78
|
+
*/
|
|
79
|
+
function LazyModule({ load }: { load: ModuleComponentLoader }): ReactNode {
|
|
80
|
+
// Null until the chunk arrives. That is what makes this safe under SSR and
|
|
81
|
+
// hydration alike: the server renders nothing, the first client pass renders
|
|
82
|
+
// nothing, and the component only appears once an effect has resolved it. No
|
|
83
|
+
// separate "hydrated" flag is needed — the effect is the gate.
|
|
84
|
+
const [Component, setComponent] = useState<ComponentType | null>(null);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
load().then(
|
|
89
|
+
(module) => {
|
|
90
|
+
if (!cancelled) setComponent(() => module.default);
|
|
91
|
+
},
|
|
92
|
+
() => {
|
|
93
|
+
// A module that fails to load must not take the site down with it.
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, [load]);
|
|
100
|
+
|
|
101
|
+
return Component === null ? null : <Component />;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type CmsModuleRuntimesProps = {
|
|
105
|
+
/**
|
|
106
|
+
* Resolved feature flags. Defaults to the registered site config, which is
|
|
107
|
+
* the code-level answer — deployable and reviewable. A host that already has
|
|
108
|
+
* database overrides in hand may pass them instead; the public root
|
|
109
|
+
* deliberately does not fetch them itself, because a blocking read there
|
|
110
|
+
* would sit on the server-render path of every page.
|
|
111
|
+
*/
|
|
112
|
+
readonly features?: SiteFeatureFlags;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export function CmsModuleRuntimes({ features }: CmsModuleRuntimesProps): ReactNode {
|
|
116
|
+
let resolved: SiteFeatureFlags | null = null;
|
|
117
|
+
try {
|
|
118
|
+
resolved = features ?? siteConfig().features;
|
|
119
|
+
} catch {
|
|
120
|
+
// No site config registered yet: render nothing rather than throwing from
|
|
121
|
+
// a site's root layout.
|
|
122
|
+
resolved = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (resolved === null) return null;
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
{enabledModuleRuntimes(resolved).map(({ key, load }) => (
|
|
130
|
+
<LazyModule key={key} load={load} />
|
|
131
|
+
))}
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type CmsModuleAdminProps = {
|
|
137
|
+
readonly moduleKey: CmsModuleKey;
|
|
138
|
+
/** Rendered while the chunk is in flight. */
|
|
139
|
+
readonly fallback?: ReactNode;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Mounts a module's backoffice screen, loading its chunk on first visit.
|
|
144
|
+
*
|
|
145
|
+
* Kept separate from the public runtime registry so the two are independently
|
|
146
|
+
* lazy: a public page never asks for the admin chunk, and an administrator
|
|
147
|
+
* opening the screen never pulls in anything the public site uses.
|
|
148
|
+
*/
|
|
149
|
+
export function CmsModuleAdmin({ moduleKey, fallback = null }: CmsModuleAdminProps): ReactNode {
|
|
150
|
+
const load = moduleAdminLoader(moduleKey);
|
|
151
|
+
if (load === undefined) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="p-8 text-sm text-ink/50">
|
|
154
|
+
This module has no backoffice screen registered.
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return <LazyModule load={load} />;
|
|
159
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Popups backoffice screen.
|
|
3
|
+
*
|
|
4
|
+
* Its own module with its own subpath export, not part of `backoffice/routes`:
|
|
5
|
+
* that barrel eagerly imports every admin view, so listing popups there would
|
|
6
|
+
* pull the builder and the statistics screen into any chunk that touches the
|
|
7
|
+
* backoffice. The module host loads this lazily on first visit, and a public
|
|
8
|
+
* bundle never reaches it.
|
|
9
|
+
*
|
|
10
|
+
* The screen itself belongs to the popup package. What this file adds is the
|
|
11
|
+
* CMS half: the site's forms and pages, discovered through the platform's own
|
|
12
|
+
* host contract rather than reimplemented per site.
|
|
13
|
+
*/
|
|
14
|
+
import { useMemo } from "react";
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { PopupAdminApp } from "@bygghammar/lightweight-popup/admin";
|
|
17
|
+
// The backoffice styling ships with the popup package but must be imported by
|
|
18
|
+
// whoever mounts the screen. Importing it HERE — inside the lazily loaded
|
|
19
|
+
// admin module — makes the consumer's bundler attach it to the admin chunk, so
|
|
20
|
+
// a public bundle never carries a byte of it.
|
|
21
|
+
import "@bygghammar/lightweight-popup/admin.css";
|
|
22
|
+
import type { AdminAdapters } from "@bygghammar/lightweight-popup/adapters";
|
|
23
|
+
|
|
24
|
+
import { useFeatureGate } from "../backoffice/shell/use-resolved-config";
|
|
25
|
+
import { cmsMediaPicker } from "./media-bridge";
|
|
26
|
+
import { ManagedForm } from "../forms/FormRenderer";
|
|
27
|
+
import {
|
|
28
|
+
cmsFormDirectory,
|
|
29
|
+
cmsOptInDestinations,
|
|
30
|
+
cmsPageDirectory,
|
|
31
|
+
} from "./cms-bridges";
|
|
32
|
+
import { popupAdminHost } from "./host";
|
|
33
|
+
|
|
34
|
+
function PopupsRouteInner(): ReactNode {
|
|
35
|
+
const host = popupAdminHost();
|
|
36
|
+
|
|
37
|
+
const adapters = useMemo<AdminAdapters>(
|
|
38
|
+
() => ({
|
|
39
|
+
popups: host.popups,
|
|
40
|
+
statistics: host.statistics,
|
|
41
|
+
// The CMS knows its own forms and pages; a host only overrides these when
|
|
42
|
+
// its content model differs from the platform's.
|
|
43
|
+
formDirectory: host.formDirectory ?? cmsFormDirectory(),
|
|
44
|
+
pages: host.pages ?? cmsPageDirectory(),
|
|
45
|
+
// Only offered when the site actually has destinations: the popup editor
|
|
46
|
+
// shows no picker at all otherwise, rather than an empty control.
|
|
47
|
+
optInDestinations: cmsOptInDestinations(),
|
|
48
|
+
// THE line that makes "Choose from media library" appear. Without it the
|
|
49
|
+
// popup editor falls back to typing an address, which is what an
|
|
50
|
+
// administrator was left with before.
|
|
51
|
+
media: cmsMediaPicker(),
|
|
52
|
+
forms: {
|
|
53
|
+
// The builder preview renders the site's real form, so what an editor
|
|
54
|
+
// checks is what a visitor will see.
|
|
55
|
+
renderForm: ({ formKey }) => <ManagedForm formKey={formKey} />,
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
[host],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return <PopupAdminApp adapters={adapters} />;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** PopupsRoute guarded by the "popups" module switch. */
|
|
65
|
+
export function PopupsRoute(): ReactNode {
|
|
66
|
+
const gate = useFeatureGate("popups", "Popups");
|
|
67
|
+
if (gate) return <>{gate}</>;
|
|
68
|
+
return <PopupsRouteInner />;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Default export so the module host can load this screen lazily.
|
|
72
|
+
export default PopupsRoute;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS-side discovery for the popup builder.
|
|
3
|
+
*
|
|
4
|
+
* The popup package has no idea what a form or a page is beyond a key and a
|
|
5
|
+
* path. These adapt what the CMS already knows into those neutral shapes, so
|
|
6
|
+
* every site using this platform gets working pickers without writing any of
|
|
7
|
+
* this itself.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
FormDirectoryPort,
|
|
11
|
+
HostFormField,
|
|
12
|
+
HostFormSchema,
|
|
13
|
+
HostIntegrationTarget,
|
|
14
|
+
HostPage,
|
|
15
|
+
OptInDestinationPort,
|
|
16
|
+
PageDirectoryPort,
|
|
17
|
+
} from "@bygghammar/lightweight-popup/adapters";
|
|
18
|
+
import { fieldTypes } from "../forms/field-sets";
|
|
19
|
+
import { DEFAULT_LOCALE, t } from "../i18n";
|
|
20
|
+
import { backofficeHost } from "../backoffice/routes/host";
|
|
21
|
+
|
|
22
|
+
type UnknownRecord = Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
function asRecords(value: unknown): UnknownRecord[] {
|
|
25
|
+
return Array.isArray(value)
|
|
26
|
+
? (value.filter(
|
|
27
|
+
(row) => typeof row === "object" && row !== null,
|
|
28
|
+
) as UnknownRecord[])
|
|
29
|
+
: [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function text(row: UnknownRecord, ...keys: string[]): string | undefined {
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const value = row[key];
|
|
35
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The site's forms, from the backoffice host's own `listForms`.
|
|
42
|
+
*
|
|
43
|
+
* Only the key and a display name cross the boundary. The full row carries
|
|
44
|
+
* private settings — notification recipients, webhook secrets — that a popup
|
|
45
|
+
* builder has no business seeing.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Field keys that are never a visitor's answer.
|
|
49
|
+
*
|
|
50
|
+
* The spam trap is the one that matters: it is a real field on a real form, and
|
|
51
|
+
* forwarding it would send a receiver evidence of the trap rather than anything
|
|
52
|
+
* anyone typed. The rest are identifiers and bookkeeping the form system owns.
|
|
53
|
+
*/
|
|
54
|
+
const NEVER_SENDABLE: ReadonlySet<string> = new Set([
|
|
55
|
+
"hp",
|
|
56
|
+
"honeypot",
|
|
57
|
+
"spam_honeypot",
|
|
58
|
+
"id",
|
|
59
|
+
"form_key",
|
|
60
|
+
"created_at",
|
|
61
|
+
"updated_at",
|
|
62
|
+
"ip",
|
|
63
|
+
"ip_address",
|
|
64
|
+
"user_agent",
|
|
65
|
+
"referrer",
|
|
66
|
+
"locale",
|
|
67
|
+
"client_token",
|
|
68
|
+
"submission_id",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** The human name for a field type, or nothing rather than a raw enum value. */
|
|
72
|
+
function typeLabel(type: unknown): string | undefined {
|
|
73
|
+
if (typeof type !== "string") return undefined;
|
|
74
|
+
return fieldTypes().find((entry) => entry.value === type)?.label;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One stored field definition, as the popup builder needs to show it.
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` for anything that is not a real, current answer: a field the
|
|
81
|
+
* editor disabled, one it removed (the key survives as a tombstone so old
|
|
82
|
+
* submissions still resolve a label), and anything on the list above.
|
|
83
|
+
*/
|
|
84
|
+
function toHostField(field: UnknownRecord): HostFormField | null {
|
|
85
|
+
const key = text(field, "key");
|
|
86
|
+
if (key === undefined) return null;
|
|
87
|
+
if (field["enabled"] === false) return null;
|
|
88
|
+
if (field["removed"] === true) return null;
|
|
89
|
+
|
|
90
|
+
const label = t(
|
|
91
|
+
(field["label"] ?? null) as never,
|
|
92
|
+
DEFAULT_LOCALE,
|
|
93
|
+
);
|
|
94
|
+
const type = typeLabel(field["type"]);
|
|
95
|
+
return {
|
|
96
|
+
key,
|
|
97
|
+
label: label.trim() === "" ? key : label,
|
|
98
|
+
...(type === undefined ? {} : { type }),
|
|
99
|
+
sendable: !NEVER_SENDABLE.has(key),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function cmsFormDirectory(): FormDirectoryPort {
|
|
104
|
+
const rows = async (): Promise<UnknownRecord[]> =>
|
|
105
|
+
asRecords(await backofficeHost().fns.listForms());
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
listForms: async () => {
|
|
109
|
+
return (await rows()).flatMap((row) => {
|
|
110
|
+
const key = text(row, "key");
|
|
111
|
+
if (key === undefined) return [];
|
|
112
|
+
return [{ key, name: text(row, "name") ?? key }];
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* The same listing, read for its fields. No second host contract and no
|
|
118
|
+
* second query: `listForms` already returns each form's merged field
|
|
119
|
+
* definitions, which is what the forms screen itself renders from.
|
|
120
|
+
*/
|
|
121
|
+
describeForms: async (keys): Promise<readonly HostFormSchema[]> => {
|
|
122
|
+
const wanted = new Set(keys);
|
|
123
|
+
return (await rows()).flatMap((row) => {
|
|
124
|
+
const key = text(row, "key");
|
|
125
|
+
if (key === undefined || !wanted.has(key)) return [];
|
|
126
|
+
const fields = asRecords(row["fields"])
|
|
127
|
+
.map(toHostField)
|
|
128
|
+
.filter((field): field is HostFormField => field !== null);
|
|
129
|
+
return [{ key, name: text(row, "name") ?? key, fields }];
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The site's pages, for targeting.
|
|
137
|
+
*
|
|
138
|
+
* Returns an empty list rather than throwing when the host has no page module
|
|
139
|
+
* registered: a missing picker is a smaller problem than a backoffice screen
|
|
140
|
+
* that will not render.
|
|
141
|
+
*/
|
|
142
|
+
export function cmsPageDirectory(): PageDirectoryPort {
|
|
143
|
+
return {
|
|
144
|
+
listPages: async (): Promise<readonly HostPage[]> => {
|
|
145
|
+
// Read through an index rather than a named property: the host contract
|
|
146
|
+
// is a closed type that does not currently declare a page listing, and a
|
|
147
|
+
// site is free to supply one. A host without it simply has no picker.
|
|
148
|
+
const host = backofficeHost() as unknown as {
|
|
149
|
+
pages?: { fns?: Record<string, unknown> };
|
|
150
|
+
fns: Record<string, unknown>;
|
|
151
|
+
};
|
|
152
|
+
const listPages = host.pages?.fns?.["listPages"] ?? host.fns["listPages"];
|
|
153
|
+
if (typeof listPages !== "function") return [];
|
|
154
|
+
const rows = asRecords(await (listPages as () => Promise<unknown>)());
|
|
155
|
+
return rows.flatMap((row) => {
|
|
156
|
+
const key = text(row, "path", "slug", "key");
|
|
157
|
+
if (key === undefined) return [];
|
|
158
|
+
const path = key.startsWith("/") ? key : `/${key}`;
|
|
159
|
+
return [{ path, title: text(row, "title", "name") ?? path }];
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The site's opt-in destinations, for the popup editor's picker.
|
|
167
|
+
*
|
|
168
|
+
* Only an id, a name and a warning cross the boundary. The endpoint and the
|
|
169
|
+
* signing secret stay on the server: a popup's configuration is readable by
|
|
170
|
+
* anyone who can read active popups, so anything more would be published.
|
|
171
|
+
*
|
|
172
|
+
* A host with no destination integration gets an empty list rather than an
|
|
173
|
+
* error, and the popup editor then shows no picker at all.
|
|
174
|
+
*/
|
|
175
|
+
export function cmsOptInDestinations(): OptInDestinationPort {
|
|
176
|
+
return {
|
|
177
|
+
listDestinations: async (): Promise<readonly HostIntegrationTarget[]> => {
|
|
178
|
+
const host = backofficeHost() as unknown as {
|
|
179
|
+
fns: Record<string, unknown>;
|
|
180
|
+
};
|
|
181
|
+
const list = host.fns["listWebhookDestinations"];
|
|
182
|
+
if (typeof list !== "function") return [];
|
|
183
|
+
|
|
184
|
+
const rows = asRecords(await (list as () => Promise<unknown>)());
|
|
185
|
+
return rows.flatMap((row) => {
|
|
186
|
+
const id = text(row, "id");
|
|
187
|
+
if (id === undefined) return [];
|
|
188
|
+
const name = text(row, "name") ?? id;
|
|
189
|
+
// A destination whose secret is missing from this environment will fail
|
|
190
|
+
// at delivery time. Saying so in the picker beats a silent failure.
|
|
191
|
+
const configured = row["secretConfigured"];
|
|
192
|
+
const warning =
|
|
193
|
+
configured === false
|
|
194
|
+
? "its signing secret is not set in this environment"
|
|
195
|
+
: undefined;
|
|
196
|
+
return [{ id, name, ...(warning === undefined ? {} : { warning }) }];
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The opt-in destinations screen, bound to the host's server functions.
|
|
3
|
+
*
|
|
4
|
+
* The screen itself belongs to the popup package and is provider-neutral;
|
|
5
|
+
* this is the thin adapter that turns its port into the host's own calls.
|
|
6
|
+
* Every one of those calls is staff-guarded on the server — nothing here
|
|
7
|
+
* grants access, it only routes.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
|
|
11
|
+
import { DestinationsAdmin } from "@bygghammar/lightweight-popup/admin";
|
|
12
|
+
// The same stylesheet the popup builder imports. This screen is a sibling
|
|
13
|
+
// route, not a child of it, so it loads on its own and has to ask for its own
|
|
14
|
+
// styling — without this it renders as unstyled text, which is exactly what it
|
|
15
|
+
// did the first time anyone could reach it.
|
|
16
|
+
import "@bygghammar/lightweight-popup/admin.css";
|
|
17
|
+
import type {
|
|
18
|
+
DeliveryRecord,
|
|
19
|
+
DestinationDraft,
|
|
20
|
+
DestinationRecord,
|
|
21
|
+
OptInDestinationAdminPort,
|
|
22
|
+
TestDeliveryOutcome,
|
|
23
|
+
} from "@bygghammar/lightweight-popup/adapters";
|
|
24
|
+
|
|
25
|
+
import { backofficeHost } from "../backoffice/routes/host";
|
|
26
|
+
|
|
27
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
28
|
+
type Fn = (...args: any[]) => Promise<any>;
|
|
29
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
30
|
+
|
|
31
|
+
function hostFn(name: string): Fn | undefined {
|
|
32
|
+
const host = backofficeHost() as unknown as { fns: Record<string, unknown> };
|
|
33
|
+
const fn = host.fns[name];
|
|
34
|
+
return typeof fn === "function" ? (fn as Fn) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A host that has not registered these functions gets an empty, honest
|
|
39
|
+
* screen rather than a crash: the site simply has no delivery integration.
|
|
40
|
+
*/
|
|
41
|
+
export function cmsDestinationAdmin(): OptInDestinationAdminPort {
|
|
42
|
+
return {
|
|
43
|
+
list: async (): Promise<readonly DestinationRecord[]> => {
|
|
44
|
+
const fn = hostFn("listWebhookDestinations");
|
|
45
|
+
if (fn === undefined) return [];
|
|
46
|
+
return (await fn()) as DestinationRecord[];
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
save: async (draft: DestinationDraft) => {
|
|
50
|
+
const fn = hostFn("saveWebhookDestination");
|
|
51
|
+
if (fn === undefined)
|
|
52
|
+
return {
|
|
53
|
+
ok: false as const,
|
|
54
|
+
error: "This site cannot save destinations.",
|
|
55
|
+
};
|
|
56
|
+
return (await fn({ data: draft })) as
|
|
57
|
+
{ ok: true; id: string } | { ok: false; error: string };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
setEnabled: async (id: string, enabled: boolean): Promise<void> => {
|
|
61
|
+
// Enabling is an ordinary save of one field, so there is no second
|
|
62
|
+
// server contract to keep in step with the first.
|
|
63
|
+
const list = hostFn("listWebhookDestinations");
|
|
64
|
+
const save = hostFn("saveWebhookDestination");
|
|
65
|
+
if (list === undefined || save === undefined) return;
|
|
66
|
+
const rows = (await list()) as DestinationRecord[];
|
|
67
|
+
const row = rows.find((candidate) => candidate.id === id);
|
|
68
|
+
if (row === undefined) return;
|
|
69
|
+
await save({
|
|
70
|
+
data: {
|
|
71
|
+
id,
|
|
72
|
+
name: row.name,
|
|
73
|
+
// Empty means "keep the stored endpoint": the display value has its
|
|
74
|
+
// query string removed and must never be written back as the real one.
|
|
75
|
+
endpoint: "",
|
|
76
|
+
secretRefName: row.secretRefName ?? "",
|
|
77
|
+
fields: [...row.fields],
|
|
78
|
+
isActive: enabled,
|
|
79
|
+
description: row.description,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
remove: async (id: string): Promise<void> => {
|
|
85
|
+
const fn = hostFn("deleteWebhookDestination");
|
|
86
|
+
if (fn === undefined) return;
|
|
87
|
+
await fn({ data: { id } });
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
sendTest: async (id: string): Promise<TestDeliveryOutcome> => {
|
|
91
|
+
const fn = hostFn("sendTestWebhook");
|
|
92
|
+
if (fn === undefined) return { ok: false, category: "config-error" };
|
|
93
|
+
const result = (await fn({ data: { destinationId: id } })) as {
|
|
94
|
+
ok?: unknown;
|
|
95
|
+
category?: unknown;
|
|
96
|
+
};
|
|
97
|
+
if (result.ok === true) return { ok: true };
|
|
98
|
+
/*
|
|
99
|
+
* A host that names no category has told us nothing about where this
|
|
100
|
+
* failed. Defaulting to "http-error" claimed the endpoint refused the
|
|
101
|
+
* request — which sent administrators to debug a receiver that had never
|
|
102
|
+
* been contacted, because a missing signing secret arrives this way.
|
|
103
|
+
* "config-error" is the honest answer to "something went wrong here".
|
|
104
|
+
*/
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
category:
|
|
108
|
+
typeof result.category === "string" && result.category !== ""
|
|
109
|
+
? result.category
|
|
110
|
+
: "config-error",
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
listDeliveries: async (): Promise<readonly DeliveryRecord[]> => {
|
|
115
|
+
const fn = hostFn("listWebhookDeliveries");
|
|
116
|
+
if (fn === undefined) return [];
|
|
117
|
+
const rows = (await fn({ data: {} })) as Record<string, unknown>[];
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
id: String(row["id"]),
|
|
120
|
+
popupKey: String(row["popup_key"] ?? ""),
|
|
121
|
+
status: String(row["status"] ?? "pending"),
|
|
122
|
+
attempts: Number(row["attempts"] ?? 0),
|
|
123
|
+
httpStatus:
|
|
124
|
+
typeof row["last_status"] === "number" ? row["last_status"] : null,
|
|
125
|
+
errorCategory:
|
|
126
|
+
typeof row["last_error_category"] === "string"
|
|
127
|
+
? row["last_error_category"]
|
|
128
|
+
: null,
|
|
129
|
+
isTest: row["is_test"] === true,
|
|
130
|
+
createdAt: String(row["created_at"] ?? ""),
|
|
131
|
+
}));
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The screen, ready to mount on a route. */
|
|
137
|
+
export function PopupDestinationsView(): ReactNode {
|
|
138
|
+
return <DestinationsAdmin destinations={cmsDestinationAdmin()} />;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default PopupDestinationsView;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a site must supply for the Popups module.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small and provider-neutral: the CMS integration knows nothing
|
|
5
|
+
* about Supabase, PostgREST, RLS or a connection string. A site registers
|
|
6
|
+
* repositories that speak the popup package's own contracts, and everything
|
|
7
|
+
* provider-specific stays on the site's side of this boundary.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
AnalyticsEventRepository,
|
|
11
|
+
FormDirectoryPort,
|
|
12
|
+
PageDirectoryPort,
|
|
13
|
+
PopupAdminRepository,
|
|
14
|
+
PopupConfigRepository,
|
|
15
|
+
PopupStatisticsRepository,
|
|
16
|
+
} from "@bygghammar/lightweight-popup/adapters";
|
|
17
|
+
|
|
18
|
+
/** Repositories the public runtime needs. Read and append only. */
|
|
19
|
+
export type PopupPublicHost = {
|
|
20
|
+
readonly configs: PopupConfigRepository;
|
|
21
|
+
readonly analytics: AnalyticsEventRepository;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Repositories the backoffice needs, in addition to the public ones.
|
|
26
|
+
*
|
|
27
|
+
* Form and page discovery are optional here because the CMS supplies them
|
|
28
|
+
* itself — a host only overrides them if its content model differs from the
|
|
29
|
+
* platform's.
|
|
30
|
+
*/
|
|
31
|
+
export type PopupAdminHost = {
|
|
32
|
+
readonly popups: PopupAdminRepository;
|
|
33
|
+
readonly statistics: PopupStatisticsRepository;
|
|
34
|
+
readonly formDirectory?: FormDirectoryPort;
|
|
35
|
+
readonly pages?: PageDirectoryPort;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let publicHost: PopupPublicHost | null = null;
|
|
39
|
+
let adminHost: PopupAdminHost | null = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register the site's public popup repositories.
|
|
43
|
+
*
|
|
44
|
+
* Called from a site's public bundle. Keeping this separate from the admin
|
|
45
|
+
* registration is what lets the public site avoid importing anything the
|
|
46
|
+
* backoffice needs.
|
|
47
|
+
*/
|
|
48
|
+
export function registerPopupPublicHost(
|
|
49
|
+
host: PopupPublicHost,
|
|
50
|
+
): PopupPublicHost {
|
|
51
|
+
publicHost = host;
|
|
52
|
+
return host;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerPopupAdminHost(host: PopupAdminHost): PopupAdminHost {
|
|
56
|
+
adminHost = host;
|
|
57
|
+
return host;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function popupPublicHost(): PopupPublicHost {
|
|
61
|
+
if (publicHost === null) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"@bygghammar/cms-platform: the Popups module is enabled but no public host is registered. Call registerPopupPublicHost() during site startup.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return publicHost;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function popupAdminHost(): PopupAdminHost {
|
|
70
|
+
if (adminHost === null) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"@bygghammar/cms-platform: the Popups backoffice is mounted but no admin host is registered. Call registerPopupAdminHost() from the backoffice entry.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return adminHost;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Test helper. */
|
|
79
|
+
export function clearPopupHosts(): void {
|
|
80
|
+
publicHost = null;
|
|
81
|
+
adminHost = null;
|
|
82
|
+
}
|