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

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 CHANGED
@@ -8,12 +8,222 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.90.0-m7.2
12
+
13
+ - `backofficePaths` gains `popups` and `popupDestinations`, and both popup
14
+ mount points supply them to the package's navigation. The destinations screen
15
+ was reachable only by typing its address.
16
+ - Requires `@bygghammar/lightweight-popup@>=0.2.0-m7.2`, which is where the
17
+ navigation itself lives.
18
+
19
+ ## 0.90.0-m7.1
20
+
21
+ First version published to npm rather than vendored as a tarball. Same code as
22
+ `0.80.2-popup.15`, renumbered for a real prerelease: the minor moves because the
23
+ popup integration gained form-field discovery, and the `m7` prerelease says this
24
+ is still waiting on staging acceptance. Published under the `m7` dist-tag, so
25
+ `latest` keeps pointing at 0.80.1.
26
+
27
+ - The optional `@bygghammar/lightweight-popup` peer range now names a
28
+ prerelease. `>=0.0.0` does not accept `0.2.0-m7.1` — a prerelease only
29
+ satisfies a range that mentions one at the same version — so the peer would
30
+ have read as unmet on every install.
31
+
32
+ ## 0.80.2-popup.15
33
+
34
+ - `cmsFormDirectory` can describe a form's fields, so the popup builder offers
35
+ real fields to tick instead of asking an administrator to type column names.
36
+ Built from the listing the forms screen already reads — no second host
37
+ contract and no second query.
38
+ - Disabled fields, removed ones and the spam trap never reach the selector; a
39
+ field's own label is shown while its stable key is what gets stored.
40
+ - Vendors popup 0.1.0-dev.9.
41
+
42
+ ## 0.80.2-popup.14
43
+
44
+ - A test delivery that failed before any request was made is no longer reported
45
+ as "The endpoint refused it". The bridge defaulted an absent category to
46
+ `http-error`; a missing signing secret arrives exactly that way, so the screen
47
+ sent administrators to debug a receiver that had never been contacted. An
48
+ unnamed failure is now `config-error`.
49
+
50
+ ## 0.80.2-popup.13
51
+
52
+ - The destinations screen imports the popup admin stylesheet. It is a sibling
53
+ route rather than a child of the builder, so it loads on its own and nothing
54
+ else pulled the styling in — the first time anyone could reach it, twelve
55
+ `lwp-` elements rendered as plain text.
56
+ - A test now requires every module that mounts one of the package's admin
57
+ screens to import that stylesheet beside it.
58
+
59
+ ## 0.80.2-popup.12
60
+
61
+ - Documents the popup bridges in `docs/POPUPS.md`: which port each CMS system
62
+ fills, why an unfilled port is not an integration, what crosses the media
63
+ boundary, and which webhook responsibility sits in which layer.
64
+
65
+ ## 0.80.2-popup.11
66
+
67
+ - A destinations screen for popup opt-in webhooks: `popups/destinations` binds
68
+ the package's provider-neutral `DestinationsAdmin` to the host's own server
69
+ functions. A site that registers none gets an honest empty screen.
70
+ - The backoffice host contract gains the remaining optional webhook slots
71
+ (save, delete, send test, list deliveries).
72
+ - Vendors popup 0.1.0-dev.5.
73
+
74
+ ## 0.80.2-popup.10
75
+
76
+ - The popup builder now gets the site's media library. `cmsMediaPicker()`
77
+ renders the same `MediaPickerDialog` every other backoffice screen uses, and
78
+ is passed into the popup admin adapters — the line whose absence left
79
+ administrators typing storage URLs by hand.
80
+ - Only a stable public address, a filename and any stored alt text cross into
81
+ the popup package. Uploading, listing and deleting stay with the library.
82
+ - Vendors popup 0.1.0-dev.4.
83
+
84
+ ## 0.80.2-popup.9
85
+
86
+ - The backoffice host contract gains an optional , so
87
+ a site can offer opt-in destinations to the popup editor. A site without one
88
+ registers nothing and the picker does not appear.
89
+
90
+ ## 0.80.2-popup.8
91
+
92
+ - Vendors popup 0.1.0-dev.3, which adds the opt-in destination picker this
93
+ version bridges to.
94
+
95
+ ## 0.80.2-popup.7
96
+
97
+ - Server-only popup opt-in webhooks. `server/popup-optin-webhook-core` signs a
98
+ versioned payload with HMAC-SHA256 over a timestamped string, re-validates
99
+ the endpoint on every redirect hop, bounds the response it will read, and
100
+ categorises failures without ever echoing the receiver's own words.
101
+ - `server/webhook-url-guard` refuses anything that is not public https:
102
+ loopback, private, link-local, carrier-grade NAT, unique-local, multicast and
103
+ cloud-metadata addresses, including the IPv4 forms hidden inside IPv6, plus
104
+ credentials embedded in the URL.
105
+ - `server/popup-optin-trigger` is the single predicate deciding what counts as
106
+ a genuine opt-in. The honeypot's `{ok: true, skipped: true}` is not one, and
107
+ neither is a Yes click, a dismissal, a preview or a test-mode popup.
108
+ - CMS bridges for the popup editor's opt-in destination picker and media
109
+ picker. Only an identifier, a name and a warning cross the boundary — never
110
+ an endpoint or a secret.
111
+ - Vendors popup 0.1.0-dev.2 (image layouts, four presentation formats).
112
+
113
+ ## 0.80.2-popup.6
114
+
115
+ - Vendors popup development build 0.0.0-dev.11: two fixes found by using the
116
+ redesigned backoffice in a browser — a false "unsaved changes" state
117
+ immediately after saving, and bottom-anchored presentations rendering at the
118
+ top of the builder's preview frame.
119
+
120
+ ## 0.80.2-popup.5
121
+
122
+ - Vendors popup development build 0.0.0-dev.10: the design and usability
123
+ sprint over the popup backoffice and the public popup. No CMS-side code
124
+ changes were needed — the module host, the form and page bridges and the
125
+ lazy admin/runtime split all carry the new screen unchanged, which is the
126
+ layering working as intended.
127
+
128
+ ## 0.80.2-popup.4
129
+
130
+ - The popup backoffice screen imports `@bygghammar/lightweight-popup/admin.css`
131
+ itself, from inside the lazily loaded admin module. The screen was rendering
132
+ unstyled because nothing imported the stylesheet; importing it here lets the
133
+ consumer's bundler attach it to the admin chunk, keeping every byte of it out
134
+ of public bundles.
135
+ - Vendored popup development build 0.0.0-dev.9: live step switching in the
136
+ builder, draft-until-valid-save creation, strict schema validation in the
137
+ reference storage adapter.
138
+
139
+ ## 0.80.2-popup.3
140
+
141
+ Not yet published. Local development artifact for the Popups module only.
142
+
143
+ ### Changed
144
+
145
+ - Rebuilt against the popup package's `0.0.0-dev.8`, which default-exports the
146
+ administration app so the module host's lazy loader resolves to it.
147
+
148
+ ## 0.80.2-popup.2
149
+
150
+ Not yet published. Local development artifact for the Popups module only.
151
+
152
+ ### Added
153
+
154
+ - The Popups backoffice now mounts the popup package's full administration
155
+ screen — list, builder, rules, appearance, statistics, import and export —
156
+ instead of a read-only summary.
157
+ - `./popups/cms-bridges`: form and page discovery adapted from the platform's
158
+ own host contract, so every site gets working pickers without writing them.
159
+ Only a key and a display name cross into the builder; the full form row
160
+ carries private settings a popup builder has no business seeing. A host with
161
+ no page listing gets no picker rather than a screen that fails to render.
162
+
163
+ ## 0.80.2-popup.1
164
+
165
+ Not yet published. Local development artifact for the Popups module only.
166
+
167
+ ### Added
168
+
169
+ - **CMS module table** (`config/modules`, exported as `./modules`). Modules were
170
+ described in four places that had to agree by hand — the flag type, the
171
+ defaults, the Modules screen labels and the sidebar definitions. All four now
172
+ derive from one table, so a new first-party module is one entry plus one key
173
+ in `SiteFeatureFlags`. The key space stays a closed typed union and the
174
+ settings parser still drops anything it does not recognise, on read and on
175
+ write. Every pre-existing module keeps its default, label, path and sidebar
176
+ position.
177
+ - **Lazy public module runtimes** (`CmsModuleRuntimes`, `registerModuleRuntime`)
178
+ and **lazy backoffice screens** (`CmsModuleAdmin`, `registerModuleAdmin`). A
179
+ module registers loader functions rather than components, so defining one
180
+ costs nothing: a site with the module off never evaluates its code and the
181
+ browser never requests its chunk. Loading happens in an effect, which is
182
+ itself the hydration gate — the server renders nothing, the first client pass
183
+ renders nothing. Public and admin loaders live in separate registries.
184
+ - **Generic successful-submission seam for forms** (`./forms/success`,
185
+ `./forms/submission-result`). `FormRenderer` reads the submit result instead
186
+ of assuming success, and announces only submissions the host actually stored:
187
+ a honeypot answers `{ ok: true, skipped: true }` after storing nothing, and
188
+ counting that is how a bot inflates every conversion figure a site reports.
189
+ The event is emitted *before* a thank-you redirect assigns
190
+ `window.location.href`, because that tears the page down. Subscribers are
191
+ isolated, and the form system names no module. `ManagedForm` and
192
+ `FormRenderer` also take an optional `onSuccess`.
193
+ - **Popups module** (`popups: false` by default), split across `./popups/host`,
194
+ `./popups/register`, `./popups/runtime` and `./popups/admin`. The public
195
+ bridge imports only the popup package's runtime entry, and the admin screen
196
+ stays out of the eagerly-imported backoffice barrel.
197
+ `@bygghammar/lightweight-popup` is an optional peer dependency.
198
+
199
+ ### Changed
200
+
201
+ - `DEFAULT_FEATURE_FLAGS`, the Modules screen and the backoffice sidebar now
202
+ derive from the module table.
203
+ - The lazy module host uses its component-loading effect as the hydration gate;
204
+ an earlier separate `hydrated` flag could be deleted without a single test
205
+ noticing, which meant it was guarding nothing.
206
+
11
207
  ## 0.80.1
12
208
 
13
209
  - Release pipeline verification: first version published from the canonical
14
210
  `byggis79/cms-platform` repository via the tag-triggered GitHub Actions
15
211
  release workflow. No source or behaviour changes.
16
212
 
213
+ ### Changed
214
+
215
+ - Module public runtimes and backoffice screens are now **lazily loaded**.
216
+ A module registers loader functions (`() => import(...)`) instead of
217
+ components, through `@bygghammar/cms-platform/popups/register` for popups.
218
+ Defining a module therefore costs nothing: a site with the module switched off
219
+ never evaluates its code and the browser never requests its chunk. Loading
220
+ happens in an effect after hydration, so nothing runs during SSR and the first
221
+ client render matches the server's markup.
222
+ - The public and admin loaders live in separate registries, so a public page
223
+ never pulls the backoffice screen and vice versa. `CmsModuleAdmin` mounts a
224
+ module's screen, loading it on first visit.
225
+
226
+
17
227
  ## 0.80.0
18
228
 
19
229
  - Search engines status card: the help text now explicitly tells admins which
@@ -32,6 +242,49 @@ from the published npm tarballs.
32
242
  screens instead of its own inline card, so the status and help text stay
33
243
  identical in both places.
34
244
 
245
+ ### Fixed
246
+
247
+ - The Popups module now registers through an exported `registerPopupModule()`
248
+ instead of a module-scope side effect. A host whose `package.json`
249
+ `sideEffects` field is an allowlist tree-shakes a bare
250
+ `import "@bygghammar/cms-platform/popups/runtime"` away, taking the
251
+ registration and the entire popup runtime with it — a silent failure where the
252
+ build succeeds and no popup ever appears.
253
+
254
+ ### Added
255
+
256
+ - **CMS module table** (`config/modules`, exported as `./modules`). Modules were
257
+ previously described in four places that had to agree by hand — the flag type,
258
+ the defaults, the Modules screen labels and the sidebar definitions. All four
259
+ now derive from one table, so a new first-party module is one entry plus one
260
+ key in `SiteFeatureFlags`. The key space stays a closed typed union, and the
261
+ settings parser still drops anything it does not recognise.
262
+ - **Public module runtimes** (`CmsModuleRuntimes`, `registerModuleRuntime`). A
263
+ site renders the host once in its root layout; enabled modules that registered
264
+ a component are mounted. Registration happens by import, so a module a site
265
+ never imports is never bundled, and a module whose flag is off never runs.
266
+ - **Generic successful-submission seam for forms** (`./forms/success`,
267
+ `./forms/submission-result`). `FormRenderer` now reads the submit result
268
+ instead of assuming success, and announces only submissions the host actually
269
+ stored: a honeypot answer of `{ ok: true, skipped: true }` stored nothing and
270
+ must never be counted. The event is emitted *before* a thank-you redirect
271
+ assigns `window.location.href`, because that tears the page down. Subscribers
272
+ are isolated — one that throws cannot break a form — and the form system names
273
+ no module, so it keeps working with none subscribed. `ManagedForm` and
274
+ `FormRenderer` also take an optional `onSuccess`.
275
+ - **Popups module** (`popups: false` by default), with its CMS integration split
276
+ across `./popups/host`, `./popups/runtime` (public) and `./popups/admin`. The
277
+ public bridge imports only the popup package's runtime entry, so a site with
278
+ popups on never pulls the builder, the statistics screen or the validator into
279
+ its public bundle. `@bygghammar/lightweight-popup` is an optional peer
280
+ dependency: sites without the module need not install it.
281
+
282
+ ### Changed
283
+
284
+ - `DEFAULT_FEATURE_FLAGS`, the Modules screen and the backoffice sidebar are now
285
+ derived from the module table. Every existing module keeps its previous
286
+ default, label, path and sidebar position.
287
+
35
288
  ## 0.78.0
36
289
 
37
290
  - Countdown block: the unit tiles now always stay on a single row on mobile.
package/docs/POPUPS.md ADDED
@@ -0,0 +1,102 @@
1
+ # Popups: what this package supplies
2
+
3
+ `@bygghammar/lightweight-popup` is deliberately host-agnostic. It knows nothing
4
+ about Supabase, about this CMS, or about any particular site. Everything it
5
+ needs from the outside arrives through ports.
6
+
7
+ This package is the adapter that fills those ports with the CMS's own systems,
8
+ so a site gets a working popup builder by registering one module instead of
9
+ reimplementing a media library, a form renderer and a page picker.
10
+
11
+ ## The bridges
12
+
13
+ | Port | Filled with | File |
14
+ | --- | --- | --- |
15
+ | `MediaPickerPort` | The CMS media library dialog | `src/popups/media-bridge.tsx` |
16
+ | `FormRenderPort` | The CMS form renderer | `src/popups/cms-bridges.ts` |
17
+ | `PagePickerPort` | The CMS page list | `src/popups/cms-bridges.ts` |
18
+ | `OptInDestinationAdminPort` | The host's webhook server functions | `src/popups/destinations.tsx` |
19
+
20
+ All of them are assembled in `src/popups/admin.tsx`. **An unfilled port is not
21
+ an integration.** `cmsMediaPicker` existed for weeks and was never placed on the
22
+ adapters object, so `adapters.media` was permanently `undefined`, the builder
23
+ took its no-picker fallback branch, and every administrator pasted storage URLs
24
+ by hand. The failure was invisible because the fallback is a legitimate state
25
+ for a host without a media library.
26
+
27
+ `tests/popup-media-bridge.test.ts` now asserts against the source of
28
+ `admin.tsx` that the key is present. Deleting the line fails the suite.
29
+
30
+ ## The media bridge
31
+
32
+ `MediaPickerPort` is a render seam:
33
+
34
+ ```ts
35
+ renderPicker(props: { onPick(item): void; onClose(): void }): ReactNode
36
+ ```
37
+
38
+ The bridge returns the CMS's own `MediaPickerDialog` with `accept="image"`. Not
39
+ a wrapper around it and not a reimplementation — the same component every other
40
+ inspector uses, so browsing, searching, folders and **upload** all come along
41
+ for free. The popup package never uploads anything and never learns which
42
+ storage provider the site uses.
43
+
44
+ What crosses the boundary is `{ src, name?, alt? }`: the stable public URL the
45
+ library already serves to the site, the filename to show, and the library's own
46
+ description when it has one. `toHostMediaItem` returns `null` for a row with no
47
+ address rather than passing an empty string on.
48
+
49
+ The URL must be the **public** one. A signed or expiring link renders in the
50
+ builder and 404s a month later on the live site, which is why the popup package
51
+ refuses those shapes itself in `checkMediaReference`.
52
+
53
+ ## The webhook layers
54
+
55
+ Opt-in delivery is split across all three layers, and the split is the security
56
+ model rather than a tidiness preference.
57
+
58
+ | Layer | Has |
59
+ | --- | --- |
60
+ | lightweight-popup | The destinations screen and the delivery log. No fetch, no crypto, no database |
61
+ | **this package** | `webhook-url-guard.ts`, `popup-optin-webhook-core.ts`, `popup-optin-trigger.ts` — validation, signing, the retry schedule, the genuine-opt-in predicate, field selection |
62
+ | The site | Tables, server functions, `process.env` secret resolution, routing |
63
+
64
+ Everything here is pure and host-agnostic: it is handed a row and a secret and
65
+ returns an outcome. It never reads an environment variable and never opens a
66
+ database connection, so the same code is testable without either.
67
+
68
+ `src/server/*` is server-only by convention **and** by reachability: nothing in
69
+ `src/backoffice` or `src/popups` imports it, which is what keeps delivery code
70
+ out of every client bundle. The host's own chunk-boundary test proves that on
71
+ the built assets.
72
+
73
+ ### The one rule that matters
74
+
75
+ **Delivery is triggered from the stored submission, on the server.** The
76
+ browser contributes a popup key and nothing else. There is no parameter that
77
+ carries a payload, an endpoint or a recipient, so a crafted request cannot make
78
+ the server send anything anywhere.
79
+
80
+ `shouldDeliverOptIn` is the only predicate. A honeypot's
81
+ `{ ok: true, skipped: true }` — the deliberate lie the form system tells a bot —
82
+ is **not** an opt-in. Neither is a "yes" click, a preview, or a test-mode popup.
83
+
84
+ ### Secrets
85
+
86
+ A destination stores the **name** of an environment variable, never a value.
87
+ The host resolves it at delivery time. The admin port's `DestinationRecord`
88
+ carries `secretConfigured: boolean` and an `endpointDisplay` with the query
89
+ string removed; there is no field on it that could hold a credential, and the
90
+ form that creates one has nowhere to type a secret.
91
+
92
+ ## Registering it on a site
93
+
94
+ ```ts
95
+ import { registerPopupModule } from "@bygghammar/cms-platform/popups";
96
+ ```
97
+
98
+ The host supplies its `fns` object. The webhook slots on it —
99
+ `listWebhookDestinations`, `saveWebhookDestination`, `deleteWebhookDestination`,
100
+ `sendTestWebhook`, `listWebhookDeliveries` — are **optional**. A site with no
101
+ webhook tables simply omits them and the destinations screen is not offered;
102
+ nothing breaks and no dead menu item appears.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.80.1",
3
+ "version": "0.90.0-m7.2",
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": [
@@ -49,6 +49,8 @@
49
49
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
50
50
  "./navigation": "./src/navigation/index.ts",
51
51
  "./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
52
+ "./forms/success": "./src/forms/success-listeners.ts",
53
+ "./forms/submission-result": "./src/forms/submission-result.ts",
52
54
  "./forms/conditions": "./src/forms/conditions.ts",
53
55
  "./forms/field-sets": "./src/forms/field-sets.ts",
54
56
  "./forms/form-audit": "./src/forms/form-audit.ts",
@@ -179,6 +181,12 @@
179
181
  "./site/font-bundle": "./src/site/font-bundle.ts",
180
182
  "./admin/version-core": "./src/admin/version-core.ts",
181
183
  "./config/package-owned-modules": "./src/config/package-owned-modules.ts",
184
+ "./modules": "./src/modules/index.ts",
185
+ "./popups/host": "./src/popups/host.ts",
186
+ "./popups/cms-bridges": "./src/popups/cms-bridges.ts",
187
+ "./popups/register": "./src/popups/register.ts",
188
+ "./popups/runtime": "./src/popups/runtime.tsx",
189
+ "./popups/admin": "./src/popups/admin.tsx",
182
190
  "./server/schema-drift": "./src/server/schema-drift.ts",
183
191
  "./server/media-focus-core": "./src/server/media-focus-core.ts",
184
192
  "./server/media-meta-core": "./src/server/media-meta-core.ts",
@@ -222,7 +230,11 @@
222
230
  "./site/routes/section-preview": "./src/site/routes/section-preview.tsx",
223
231
  "./media/priority": "./src/media/priority.tsx",
224
232
  "./media/preload": "./src/media/preload.ts",
225
- "./ui/NearViewport": "./src/ui/NearViewport.tsx"
233
+ "./ui/NearViewport": "./src/ui/NearViewport.tsx",
234
+ "./server/webhook-url-guard": "./src/server/webhook-url-guard.ts",
235
+ "./server/popup-optin-webhook-core": "./src/server/popup-optin-webhook-core.ts",
236
+ "./server/popup-optin-trigger": "./src/server/popup-optin-trigger.ts",
237
+ "./popups/destinations": "./src/popups/destinations.tsx"
226
238
  },
227
239
  "dependencies": {
228
240
  "fflate": "^0.8.3"
@@ -250,7 +262,8 @@
250
262
  "@dnd-kit/core": ">=6",
251
263
  "@dnd-kit/sortable": ">=3",
252
264
  "@dnd-kit/utilities": ">=3",
253
- "zod": ">=3"
265
+ "zod": ">=3",
266
+ "@bygghammar/lightweight-popup": ">=0.2.0-m7.2"
254
267
  },
255
268
  "peerDependenciesMeta": {
256
269
  "@supabase/supabase-js": {
@@ -294,6 +307,9 @@
294
307
  },
295
308
  "@tiptap/extension-table": {
296
309
  "optional": true
310
+ },
311
+ "@bygghammar/lightweight-popup": {
312
+ "optional": true
297
313
  }
298
314
  },
299
315
  "bin": {
@@ -312,6 +328,7 @@
312
328
  "check": "bun run typecheck && bun run test && bun run verify:pack"
313
329
  },
314
330
  "devDependencies": {
331
+ "@bygghammar/lightweight-popup": "file:./vendor/bygghammar-lightweight-popup-0.2.0-m7.2.tgz",
315
332
  "@dnd-kit/core": "^6.3.1",
316
333
  "@dnd-kit/sortable": "^10.0.0",
317
334
  "@dnd-kit/utilities": "^3.2.2",
@@ -319,6 +336,8 @@
319
336
  "@tanstack/react-query": "^5.101.1",
320
337
  "@tanstack/react-router": "1.170.18",
321
338
  "@tanstack/react-start": "1.168.32",
339
+ "@testing-library/dom": "^10.4.0",
340
+ "@testing-library/react": "^16.3.0",
322
341
  "@tiptap/extension-highlight": "^3.29.2",
323
342
  "@tiptap/extension-image": "^3.29.2",
324
343
  "@tiptap/extension-subscript": "^3.29.2",
@@ -333,6 +352,7 @@
333
352
  "@types/node": "^22.10.2",
334
353
  "@types/react": "19.2.17",
335
354
  "@types/react-dom": "19.2.3",
355
+ "jsdom": "^26.1.0",
336
356
  "lucide-react": "^0.575.0",
337
357
  "react": "^19.2.0",
338
358
  "react-dom": "^19.2.0",
@@ -11,6 +11,7 @@ import {
11
11
  type SiteFeatureFlags,
12
12
  type SiteSettingOverrides,
13
13
  } from "../config/site-config";
14
+ import { CMS_MODULE_LIST, CMS_MODULE_LABELS } from "../config/modules";
14
15
  import type { IndexingPolicy } from "../site/indexing";
15
16
  import { IndexingStatusCard } from "./IndexingStatusCard";
16
17
 
@@ -48,16 +49,9 @@ export type SiteSettingsDeps = {
48
49
  MediaField?: (props: any) => ReactNode;
49
50
  };
50
51
 
51
- const FEATURE_LABELS: Record<keyof SiteFeatureFlags, string> = {
52
- trips: "Trips & departures",
53
- courses: "Courses",
54
- reviews: "Reviews",
55
- email: "Transactional email",
56
- tracking: "Code & tracking",
57
- backup: "Backup & restore",
58
- domains: "Custom domains",
59
- forms: "Forms",
60
- };
52
+ // Labels and order come from the module table, so a new module appears here
53
+ // without a second list to keep in step.
54
+ const FEATURE_LABELS = CMS_MODULE_LABELS;
61
55
 
62
56
  const COMPANY_LABELS: Record<keyof typeof EMPTY_COMPANY, string> = {
63
57
  legalName: "Legal name",
@@ -275,14 +269,19 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
275
269
  busy={busy === "features"}
276
270
  />
277
271
  <div className="mt-4 grid gap-3 md:grid-cols-2">
278
- {(Object.keys(DEFAULT_FEATURE_FLAGS) as (keyof SiteFeatureFlags)[]).map((flag) => (
279
- <label key={flag} className="flex items-center gap-3 text-sm text-ink">
272
+ {CMS_MODULE_LIST.map((module) => (
273
+ <label key={module.key} className="flex items-center gap-3 text-sm text-ink">
280
274
  <input
281
275
  type="checkbox"
282
- checked={features[flag]}
283
- onChange={(e) => setFeatures({ ...features, [flag]: e.target.checked })}
276
+ checked={features[module.key]}
277
+ onChange={(e) => setFeatures({ ...features, [module.key]: e.target.checked })}
284
278
  />
285
- {FEATURE_LABELS[flag]}
279
+ <span>
280
+ {FEATURE_LABELS[module.key]}
281
+ {module.description ? (
282
+ <span className="block text-xs text-ink/60">{module.description}</span>
283
+ ) : null}
284
+ </span>
286
285
  </label>
287
286
  ))}
288
287
  </div>
@@ -78,6 +78,18 @@ export type BackofficeHost = {
78
78
  // locale domains
79
79
  saveLocaleDomain: HostServerFn;
80
80
  deleteLocaleDomain: HostServerFn;
81
+ /*
82
+ * Popup opt-in destinations, for the popup editor's picker.
83
+ *
84
+ * Optional: a site with no delivery integration simply does not register
85
+ * it, and the popup editor then shows no picker at all rather than an
86
+ * empty control. Returns names and ids — never an endpoint or a secret.
87
+ */
88
+ listWebhookDestinations?: HostServerFn;
89
+ saveWebhookDestination?: HostServerFn;
90
+ deleteWebhookDestination?: HostServerFn;
91
+ sendTestWebhook?: HostServerFn;
92
+ listWebhookDeliveries?: HostServerFn;
81
93
  };
82
94
  /** Site-specific example values shown as input placeholders. */
83
95
  placeholders?: {
@@ -105,7 +117,10 @@ export type BackofficeHost = {
105
117
  /** Editor components a site wires with its own locales, media and forms. */
106
118
  components: {
107
119
  /** Recursive shared-content editor. */
108
- SectionForm: (props: { value: unknown; onChange: (next: unknown) => void }) => ReactNode;
120
+ SectionForm: (props: {
121
+ value: unknown;
122
+ onChange: (next: unknown) => void;
123
+ }) => ReactNode;
109
124
  /** Single media field used by the reviews editor. */
110
125
  MediaField: MediaFieldComponent;
111
126
  /** Review portrait uploader. */
@@ -200,7 +215,10 @@ export type BackofficeHost = {
200
215
  * React hook returning a resolver for a page's public URL. Implemented as a
201
216
  * hook so a site can read its slug overrides and locale domains.
202
217
  */
203
- usePageUrl: () => (pageKey: string, locale: string) => { path: string; host: string };
218
+ usePageUrl: () => (
219
+ pageKey: string,
220
+ locale: string,
221
+ ) => { path: string; host: string };
204
222
  /** Persists unsaved shared-content drafts; returns the keys it saved. */
205
223
  flushSharedDrafts?: () => Promise<string[]>;
206
224
  /** Renders the block canvas for the builder. */
@@ -211,7 +229,12 @@ export type BackofficeHost = {
211
229
  blocks: any[];
212
230
  setBlocks: (next: any[]) => void;
213
231
  globals: Record<string, any[]>;
214
- globalSections: { key: string; name: string; kind: "block" | "section"; blocks: any[] }[];
232
+ globalSections: {
233
+ key: string;
234
+ name: string;
235
+ kind: "block" | "section";
236
+ blocks: any[];
237
+ }[];
215
238
  locale: string;
216
239
  setLocale: (next: string) => void;
217
240
  setDirty: (next: boolean) => void;
@@ -248,7 +271,6 @@ export type BackofficeHost = {
248
271
  /* eslint-enable @typescript-eslint/no-explicit-any */
249
272
  };
250
273
 
251
-
252
274
  let current: BackofficeHost | null = null;
253
275
 
254
276
  /** Registers the host adapters. Call once, from a module the routes import. */
@@ -295,13 +317,16 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
295
317
  formDeliveries: `${base}/forms/deliveries`,
296
318
  pagesBuilder: `${base}/pages-builder`,
297
319
  resetPassword: `${base}/reset-password`,
320
+ popups: `${base}/popups`,
321
+ popupDestinations: `${base}/popups/destinations`,
298
322
  };
299
323
  }
300
324
 
301
325
  /** Reads a required optional host group, failing loudly when it is missing. */
302
- export function requireHostGroup<K extends "media" | "forms" | "pages" | "sections" | "dashboard" | "authAdapters">(
303
- key: K,
304
- ): NonNullable<BackofficeHost[K]> {
326
+ export function requireHostGroup<
327
+ K extends
328
+ "media" | "forms" | "pages" | "sections" | "dashboard" | "authAdapters",
329
+ >(key: K): NonNullable<BackofficeHost[K]> {
305
330
  const group = backofficeHost()[key];
306
331
  if (!group) {
307
332
  throw new Error(