@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10

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 (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -0,0 +1,240 @@
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
+ AdminNavigationPort,
11
+ AdminScreen,
12
+ FormDirectoryPort,
13
+ HostFormField,
14
+ HostFormSchema,
15
+ HostIntegrationTarget,
16
+ HostPage,
17
+ OptInDestinationPort,
18
+ PageDirectoryPort,
19
+ } from "@bygghammar/lightweight-popup/adapters";
20
+ import { fieldTypes } from "../forms/field-sets";
21
+ import { backofficePaths } from "../backoffice/routes/host";
22
+ import { DEFAULT_LOCALE, t } from "../i18n";
23
+ import { backofficeHost } from "../backoffice/routes/host";
24
+
25
+ type UnknownRecord = Record<string, unknown>;
26
+
27
+ function asRecords(value: unknown): UnknownRecord[] {
28
+ return Array.isArray(value)
29
+ ? (value.filter(
30
+ (row) => typeof row === "object" && row !== null,
31
+ ) as UnknownRecord[])
32
+ : [];
33
+ }
34
+
35
+ function text(row: UnknownRecord, ...keys: string[]): string | undefined {
36
+ for (const key of keys) {
37
+ const value = row[key];
38
+ if (typeof value === "string" && value.trim() !== "") return value;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ /**
44
+ * The site's forms, from the backoffice host's own `listForms`.
45
+ *
46
+ * Only the key and a display name cross the boundary. The full row carries
47
+ * private settings — notification recipients, webhook secrets — that a popup
48
+ * builder has no business seeing.
49
+ */
50
+ /**
51
+ * Field keys that are never a visitor's answer.
52
+ *
53
+ * The spam trap is the one that matters: it is a real field on a real form, and
54
+ * forwarding it would send a receiver evidence of the trap rather than anything
55
+ * anyone typed. The rest are identifiers and bookkeeping the form system owns.
56
+ */
57
+ const NEVER_SENDABLE: ReadonlySet<string> = new Set([
58
+ "hp",
59
+ "honeypot",
60
+ "spam_honeypot",
61
+ "id",
62
+ "form_key",
63
+ "created_at",
64
+ "updated_at",
65
+ "ip",
66
+ "ip_address",
67
+ "user_agent",
68
+ "referrer",
69
+ "locale",
70
+ "client_token",
71
+ "submission_id",
72
+ ]);
73
+
74
+ /** The human name for a field type, or nothing rather than a raw enum value. */
75
+ function typeLabel(type: unknown): string | undefined {
76
+ if (typeof type !== "string") return undefined;
77
+ return fieldTypes().find((entry) => entry.value === type)?.label;
78
+ }
79
+
80
+ /**
81
+ * One stored field definition, as the popup builder needs to show it.
82
+ *
83
+ * Returns `null` for anything that is not a real, current answer: a field the
84
+ * editor disabled, one it removed (the key survives as a tombstone so old
85
+ * submissions still resolve a label), and anything on the list above.
86
+ */
87
+ function toHostField(field: UnknownRecord): HostFormField | null {
88
+ const key = text(field, "key");
89
+ if (key === undefined) return null;
90
+ if (field["enabled"] === false) return null;
91
+ if (field["removed"] === true) return null;
92
+
93
+ const label = t(
94
+ (field["label"] ?? null) as never,
95
+ DEFAULT_LOCALE,
96
+ );
97
+ const type = typeLabel(field["type"]);
98
+ return {
99
+ key,
100
+ label: label.trim() === "" ? key : label,
101
+ ...(type === undefined ? {} : { type }),
102
+ sendable: !NEVER_SENDABLE.has(key),
103
+ };
104
+ }
105
+
106
+ export function cmsFormDirectory(): FormDirectoryPort {
107
+ const rows = async (): Promise<UnknownRecord[]> =>
108
+ asRecords(await backofficeHost().fns.listForms());
109
+
110
+ return {
111
+ listForms: async () => {
112
+ return (await rows()).flatMap((row) => {
113
+ const key = text(row, "key");
114
+ if (key === undefined) return [];
115
+ return [{ key, name: text(row, "name") ?? key }];
116
+ });
117
+ },
118
+
119
+ /*
120
+ * The same listing, read for its fields. No second host contract and no
121
+ * second query: `listForms` already returns each form's merged field
122
+ * definitions, which is what the forms screen itself renders from.
123
+ */
124
+ describeForms: async (keys): Promise<readonly HostFormSchema[]> => {
125
+ const wanted = new Set(keys);
126
+ return (await rows()).flatMap((row) => {
127
+ const key = text(row, "key");
128
+ if (key === undefined || !wanted.has(key)) return [];
129
+ const fields = asRecords(row["fields"])
130
+ .map(toHostField)
131
+ .filter((field): field is HostFormField => field !== null);
132
+ return [{ key, name: text(row, "name") ?? key, fields }];
133
+ });
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The site's pages, for targeting.
140
+ *
141
+ * The popup builder shows an editor their pages by name and stores the
142
+ * address, so both halves have to be right. The name is the CMS's own page
143
+ * name. The address is the one a visitor would type — which is not the page's
144
+ * key: this platform's routing turns a key into a path through the site's slug
145
+ * table, and the home page is the case that proves it, since its key is
146
+ * `home` and its address is `/`. Targeting `/home` would match nothing, and
147
+ * nothing would say why.
148
+ *
149
+ * So a caller passes `resolvePath` — the routing engine the backoffice already
150
+ * uses for "view this page" links. Without one, the key is the best guess
151
+ * available, which is what this did before and is right for a host whose keys
152
+ * are paths.
153
+ *
154
+ * Returns an empty list rather than throwing when the host has no page module
155
+ * registered: a missing picker is a smaller problem than a backoffice screen
156
+ * that will not render.
157
+ */
158
+ export function cmsPageDirectory(
159
+ resolvePath?: (pageKey: string) => string,
160
+ ): PageDirectoryPort {
161
+ return {
162
+ listPages: async (): Promise<readonly HostPage[]> => {
163
+ // `pages.fns.listPages` is the declared home for this. The top-level
164
+ // lookup stays for a site that registered it there before the contract
165
+ // named it, and a host with neither simply has no picker.
166
+ const host = backofficeHost();
167
+ const listPages =
168
+ host.pages?.fns.listPages ??
169
+ (host.fns as unknown as Record<string, unknown>)["listPages"];
170
+ if (typeof listPages !== "function") return [];
171
+ const rows = asRecords(await (listPages as () => Promise<unknown>)());
172
+ return rows.flatMap((row) => {
173
+ const key = text(row, "path", "slug", "key");
174
+ if (key === undefined) return [];
175
+ const resolved = resolvePath === undefined ? undefined : resolvePath(key);
176
+ const path =
177
+ resolved !== undefined && resolved.startsWith("/")
178
+ ? resolved
179
+ : key.startsWith("/")
180
+ ? key
181
+ : `/${key}`;
182
+ return [{ path, title: text(row, "title", "name") ?? path }];
183
+ });
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * The site's opt-in destinations, for the popup editor's picker.
190
+ *
191
+ * Only an id, a name and a warning cross the boundary. The endpoint and the
192
+ * signing secret stay on the server: a popup's configuration is readable by
193
+ * anyone who can read active popups, so anything more would be published.
194
+ *
195
+ * A host with no destination integration gets an empty list rather than an
196
+ * error, and the popup editor then shows no picker at all.
197
+ */
198
+ export function cmsOptInDestinations(): OptInDestinationPort {
199
+ return {
200
+ listDestinations: async (): Promise<readonly HostIntegrationTarget[]> => {
201
+ const host = backofficeHost() as unknown as {
202
+ fns: Record<string, unknown>;
203
+ };
204
+ const list = host.fns["listWebhookDestinations"];
205
+ if (typeof list !== "function") return [];
206
+
207
+ const rows = asRecords(await (list as () => Promise<unknown>)());
208
+ return rows.flatMap((row) => {
209
+ const id = text(row, "id");
210
+ if (id === undefined) return [];
211
+ const name = text(row, "name") ?? id;
212
+ // A destination whose secret is missing from this environment will fail
213
+ // at delivery time. Saying so in the picker beats a silent failure.
214
+ const configured = row["secretConfigured"];
215
+ const warning =
216
+ configured === false
217
+ ? "its signing secret is not set in this environment"
218
+ : undefined;
219
+ return [{ id, name, ...(warning === undefined ? {} : { warning }) }];
220
+ });
221
+ },
222
+ };
223
+ }
224
+
225
+
226
+ /**
227
+ * Where this site mounted the popup screens.
228
+ *
229
+ * Derived from the backoffice base the site configured, like every other
230
+ * backoffice path — so a site that moves its backoffice moves these with it,
231
+ * and the popup package never learns either address.
232
+ */
233
+ export function cmsPopupNavigation(current: AdminScreen): AdminNavigationPort {
234
+ const paths = backofficePaths();
235
+ return {
236
+ popupsHref: paths.popups,
237
+ destinationsHref: paths.popupDestinations,
238
+ current,
239
+ };
240
+ }
@@ -0,0 +1,147 @@
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
+ import { cmsPopupNavigation } from "./cms-bridges";
27
+
28
+ /* eslint-disable @typescript-eslint/no-explicit-any */
29
+ type Fn = (...args: any[]) => Promise<any>;
30
+ /* eslint-enable @typescript-eslint/no-explicit-any */
31
+
32
+ function hostFn(name: string): Fn | undefined {
33
+ const host = backofficeHost() as unknown as { fns: Record<string, unknown> };
34
+ const fn = host.fns[name];
35
+ return typeof fn === "function" ? (fn as Fn) : undefined;
36
+ }
37
+
38
+ /**
39
+ * A host that has not registered these functions gets an empty, honest
40
+ * screen rather than a crash: the site simply has no delivery integration.
41
+ */
42
+ export function cmsDestinationAdmin(): OptInDestinationAdminPort {
43
+ return {
44
+ list: async (): Promise<readonly DestinationRecord[]> => {
45
+ const fn = hostFn("listWebhookDestinations");
46
+ if (fn === undefined) return [];
47
+ return (await fn()) as DestinationRecord[];
48
+ },
49
+
50
+ save: async (draft: DestinationDraft) => {
51
+ const fn = hostFn("saveWebhookDestination");
52
+ if (fn === undefined)
53
+ return {
54
+ ok: false as const,
55
+ error: "This site cannot save destinations.",
56
+ };
57
+ return (await fn({ data: draft })) as
58
+ { ok: true; id: string } | { ok: false; error: string };
59
+ },
60
+
61
+ setEnabled: async (id: string, enabled: boolean): Promise<void> => {
62
+ // Enabling is an ordinary save of one field, so there is no second
63
+ // server contract to keep in step with the first.
64
+ const list = hostFn("listWebhookDestinations");
65
+ const save = hostFn("saveWebhookDestination");
66
+ if (list === undefined || save === undefined) return;
67
+ const rows = (await list()) as DestinationRecord[];
68
+ const row = rows.find((candidate) => candidate.id === id);
69
+ if (row === undefined) return;
70
+ await save({
71
+ data: {
72
+ id,
73
+ name: row.name,
74
+ // Empty means "keep the stored endpoint": the display value has its
75
+ // query string removed and must never be written back as the real one.
76
+ endpoint: "",
77
+ secretRefName: row.secretRefName ?? "",
78
+ fields: [...row.fields],
79
+ isActive: enabled,
80
+ description: row.description,
81
+ },
82
+ });
83
+ },
84
+
85
+ remove: async (id: string): Promise<void> => {
86
+ const fn = hostFn("deleteWebhookDestination");
87
+ if (fn === undefined) return;
88
+ await fn({ data: { id } });
89
+ },
90
+
91
+ sendTest: async (id: string): Promise<TestDeliveryOutcome> => {
92
+ const fn = hostFn("sendTestWebhook");
93
+ if (fn === undefined) return { ok: false, category: "config-error" };
94
+ const result = (await fn({ data: { destinationId: id } })) as {
95
+ ok?: unknown;
96
+ category?: unknown;
97
+ };
98
+ if (result.ok === true) return { ok: true };
99
+ /*
100
+ * A host that names no category has told us nothing about where this
101
+ * failed. Defaulting to "http-error" claimed the endpoint refused the
102
+ * request — which sent administrators to debug a receiver that had never
103
+ * been contacted, because a missing signing secret arrives this way.
104
+ * "config-error" is the honest answer to "something went wrong here".
105
+ */
106
+ return {
107
+ ok: false,
108
+ category:
109
+ typeof result.category === "string" && result.category !== ""
110
+ ? result.category
111
+ : "config-error",
112
+ };
113
+ },
114
+
115
+ listDeliveries: async (): Promise<readonly DeliveryRecord[]> => {
116
+ const fn = hostFn("listWebhookDeliveries");
117
+ if (fn === undefined) return [];
118
+ const rows = (await fn({ data: {} })) as Record<string, unknown>[];
119
+ return rows.map((row) => ({
120
+ id: String(row["id"]),
121
+ popupKey: String(row["popup_key"] ?? ""),
122
+ status: String(row["status"] ?? "pending"),
123
+ attempts: Number(row["attempts"] ?? 0),
124
+ httpStatus:
125
+ typeof row["last_status"] === "number" ? row["last_status"] : null,
126
+ errorCategory:
127
+ typeof row["last_error_category"] === "string"
128
+ ? row["last_error_category"]
129
+ : null,
130
+ isTest: row["is_test"] === true,
131
+ createdAt: String(row["created_at"] ?? ""),
132
+ }));
133
+ },
134
+ };
135
+ }
136
+
137
+ /** The screen, ready to mount on a route. */
138
+ export function PopupDestinationsView(): ReactNode {
139
+ return (
140
+ <DestinationsAdmin
141
+ destinations={cmsDestinationAdmin()}
142
+ navigation={cmsPopupNavigation("destinations")}
143
+ />
144
+ );
145
+ }
146
+
147
+ export default PopupDestinationsView;
@@ -0,0 +1,91 @@
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
+ * Paths of the site's own that no popup may appear on.
24
+ *
25
+ * The backoffice is added for every site already, from the configured
26
+ * `backofficePath` — this is for the rest: a checkout, a members' area, a
27
+ * page a site simply wants left alone. Patterns are the popup package's:
28
+ * `/checkout` for one page, `/checkout/**` for everything beneath it.
29
+ */
30
+ readonly excludedPaths?: readonly string[];
31
+ };
32
+
33
+ /**
34
+ * Repositories the backoffice needs, in addition to the public ones.
35
+ *
36
+ * Form and page discovery are optional here because the CMS supplies them
37
+ * itself — a host only overrides them if its content model differs from the
38
+ * platform's.
39
+ */
40
+ export type PopupAdminHost = {
41
+ readonly popups: PopupAdminRepository;
42
+ readonly statistics: PopupStatisticsRepository;
43
+ readonly formDirectory?: FormDirectoryPort;
44
+ readonly pages?: PageDirectoryPort;
45
+ };
46
+
47
+ let publicHost: PopupPublicHost | null = null;
48
+ let adminHost: PopupAdminHost | null = null;
49
+
50
+ /**
51
+ * Register the site's public popup repositories.
52
+ *
53
+ * Called from a site's public bundle. Keeping this separate from the admin
54
+ * registration is what lets the public site avoid importing anything the
55
+ * backoffice needs.
56
+ */
57
+ export function registerPopupPublicHost(
58
+ host: PopupPublicHost,
59
+ ): PopupPublicHost {
60
+ publicHost = host;
61
+ return host;
62
+ }
63
+
64
+ export function registerPopupAdminHost(host: PopupAdminHost): PopupAdminHost {
65
+ adminHost = host;
66
+ return host;
67
+ }
68
+
69
+ export function popupPublicHost(): PopupPublicHost {
70
+ if (publicHost === null) {
71
+ throw new Error(
72
+ "@bygghammar/cms-platform: the Popups module is enabled but no public host is registered. Call registerPopupPublicHost() during site startup.",
73
+ );
74
+ }
75
+ return publicHost;
76
+ }
77
+
78
+ export function popupAdminHost(): PopupAdminHost {
79
+ if (adminHost === null) {
80
+ throw new Error(
81
+ "@bygghammar/cms-platform: the Popups backoffice is mounted but no admin host is registered. Call registerPopupAdminHost() from the backoffice entry.",
82
+ );
83
+ }
84
+ return adminHost;
85
+ }
86
+
87
+ /** Test helper. */
88
+ export function clearPopupHosts(): void {
89
+ publicHost = null;
90
+ adminHost = null;
91
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The CMS media library, offered to the popup builder.
3
+ *
4
+ * This is the whole bridge: the popup package asks a host to render a picker,
5
+ * and the host renders the same `MediaPickerDialog` every other CMS screen
6
+ * uses. Nothing is reimplemented — the library's browsing, searching and
7
+ * uploading all come along, because it is literally the same component.
8
+ *
9
+ * What crosses the boundary is a URL, a filename and (when the library knows
10
+ * one) alt text. The popup package never learns which storage provider the
11
+ * site uses, and never uploads anything itself.
12
+ */
13
+ import type { ReactNode } from "react";
14
+
15
+ import type {
16
+ HostMediaItem,
17
+ MediaPickerPort,
18
+ } from "@bygghammar/lightweight-popup/adapters";
19
+
20
+ import { MediaPickerDialog } from "../backoffice/inspectors/fields";
21
+
22
+ /* eslint-disable @typescript-eslint/no-explicit-any */
23
+ /**
24
+ * What the picker hands back.
25
+ *
26
+ * Structural rather than the library's own type: the shape has grown fields
27
+ * over time and a popup needs three of them.
28
+ */
29
+ type PickedItem = {
30
+ url?: unknown;
31
+ name?: unknown;
32
+ path?: unknown;
33
+ alt?: unknown;
34
+ } & Record<string, any>;
35
+ /* eslint-enable @typescript-eslint/no-explicit-any */
36
+
37
+ function text(value: unknown): string | undefined {
38
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
39
+ }
40
+
41
+ /**
42
+ * Turn one library item into the neutral shape the popup package stores.
43
+ *
44
+ * `url` is the stable public address the media library already serves to the
45
+ * site — the same value every other block stores — so a popup configured in
46
+ * one environment keeps working in the next. A signed or expiring link would
47
+ * render today and 404 later, which the package's own reference check refuses
48
+ * anyway.
49
+ */
50
+ export function toHostMediaItem(item: PickedItem): HostMediaItem | null {
51
+ const src = text(item.url) ?? text(item.path);
52
+ if (src === undefined) return null;
53
+ const name = text(item.name);
54
+ const alt = text(item.alt);
55
+ return {
56
+ src,
57
+ ...(name === undefined ? {} : { name }),
58
+ ...(alt === undefined ? {} : { alt }),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The site's media library as a popup media port.
64
+ *
65
+ * Images only: a popup's picture is a picture, and offering video here would
66
+ * put a file in an `<img>` that cannot render it.
67
+ */
68
+ export function cmsMediaPicker(): MediaPickerPort {
69
+ return {
70
+ renderPicker: ({ onPick, onClose }): ReactNode => (
71
+ <MediaPickerDialog
72
+ accept="image"
73
+ onPick={(item) => {
74
+ const media = toHostMediaItem(item as PickedItem);
75
+ // A library row with no address is not something to hand on; closing
76
+ // is honest, and the editor keeps whatever they had.
77
+ if (media !== null) onPick(media);
78
+ onClose();
79
+ }}
80
+ onClose={onClose}
81
+ />
82
+ ),
83
+ };
84
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Registers the Popups module's loaders.
3
+ *
4
+ * Deliberately tiny and free of popup imports: a site calls this once, and
5
+ * nothing about the popup runtime — not the package, not its CSS, not the
6
+ * builder — is evaluated until the module is both enabled and hydrated. The
7
+ * `import()` calls below are ordinary local imports the host's bundler turns
8
+ * into separate chunks; nothing is fetched from a remote origin.
9
+ *
10
+ * An explicit function rather than a module-scope side effect: a host whose
11
+ * `sideEffects` field is an allowlist tree-shakes a bare
12
+ * `import "…/popups/register"` away, taking the registration with it, and the
13
+ * build still succeeds. That failure is silent — no popup ever appears.
14
+ */
15
+ import {
16
+ registerModuleAdmin,
17
+ registerModuleRuntime,
18
+ } from "../modules/public-runtime";
19
+
20
+ export function registerPopupModule(): void {
21
+ registerModuleRuntime("popups", () => import("./runtime"));
22
+ registerModuleAdmin("popups", () => import("./admin"));
23
+ }