@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
@@ -9,6 +9,8 @@
9
9
  import { useEffect, useMemo, useState, type ReactNode } from "react";
10
10
  import { Download, ImageIcon, Images, RotateCcw, Save, Sparkles, X } from "lucide-react";
11
11
 
12
+ import { EDIT_LOCK_TYPES } from "../../cms/edit-lock";
13
+ import { EditLockBanner, EditLockFence, useEditLock } from "../EditLock";
12
14
  import {
13
15
  mergeMediaMetaIntoForm,
14
16
  pageSeoPreviewCanonical,
@@ -27,6 +29,13 @@ export type PageSeoPanelViewProps = {
27
29
  defaultLocale: string;
28
30
  /** True when the page exists only in the CMS (no code registry entry). */
29
31
  isCmsPage?: boolean;
32
+ /**
33
+ * Page this record belongs to. Supplying it puts the screen under the shared
34
+ * editing lock, so two administrators cannot overwrite each other's SEO. A
35
+ * host that omits it keeps the previous behaviour (server-side enforcement
36
+ * still applies whenever the host passes the session id on to the save).
37
+ */
38
+ pageKey?: string | null;
30
39
  /** Current registry route for a locale, shown in the preview panel. */
31
40
  registryRoute: (locale: string) => string;
32
41
  /** Public origin used to build the canonical preview URL. */
@@ -35,10 +44,14 @@ export type PageSeoPanelViewProps = {
35
44
  pageImages: readonly PageSeoImageCandidate[];
36
45
  /** Saved media-library metadata for an image URL, or null when none exists. */
37
46
  lookupMeta: (src: string) => PageSeoMediaMeta | null;
38
- /** Persist the form. Return false when saving failed (host shows the error). */
39
- onSave: (form: PageSeoFormShape) => Promise<boolean>;
47
+ /**
48
+ * Persist the form. Return false when saving failed (host shows the error).
49
+ * The editing-session id is passed through so the server can tell this
50
+ * editor's own save apart from someone else's.
51
+ */
52
+ onSave: (form: PageSeoFormShape, editSessionId?: string) => Promise<boolean>;
40
53
  /** Remove the override; only enabled when `canReset`. */
41
- onReset: () => Promise<void>;
54
+ onReset: (editSessionId?: string) => Promise<void>;
42
55
  canReset?: boolean;
43
56
  renderMediaPicker: (args: { onPick: (url: string) => void; onClose: () => void }) => ReactNode;
44
57
  renderPageImagePicker: (args: {
@@ -58,6 +71,7 @@ export function PageSeoPanelView({
58
71
  locales,
59
72
  defaultLocale,
60
73
  isCmsPage,
74
+ pageKey,
61
75
  registryRoute,
62
76
  originForLocale,
63
77
  pageImages,
@@ -76,6 +90,7 @@ export function PageSeoPanelView({
76
90
  const [picking, setPicking] = useState(false);
77
91
  const [pickingOnPage, setPickingOnPage] = useState(false);
78
92
  const [form, setForm] = useState<PageSeoFormShape>(initialForm);
93
+ const lock = useEditLock({ resourceType: EDIT_LOCK_TYPES.pageSeo, resourceId: pageKey });
79
94
 
80
95
  useEffect(() => {
81
96
  setForm(initialForm);
@@ -130,7 +145,7 @@ export function PageSeoPanelView({
130
145
  const handleSave = async () => {
131
146
  setBusy(true);
132
147
  try {
133
- if (await onSave(form)) setDirty(false);
148
+ if (await onSave(form, lock.sessionId)) setDirty(false);
134
149
  } finally {
135
150
  setBusy(false);
136
151
  }
@@ -145,7 +160,7 @@ export function PageSeoPanelView({
145
160
  if (!ask("Remove your changes and revert to the defaults from the codebase?")) return;
146
161
  setBusy(true);
147
162
  try {
148
- await onReset();
163
+ await onReset(lock.sessionId);
149
164
  } finally {
150
165
  setBusy(false);
151
166
  }
@@ -163,213 +178,215 @@ export function PageSeoPanelView({
163
178
 
164
179
  return (
165
180
  <div>
166
- {isCmsPage && (
167
- <p className="mb-4 text-xs text-ink/55">
168
- This page was created in the page builder. Its URL and metadata are stored in the CMS —
169
- set the slug below (empty = site root).
170
- </p>
171
- )}
172
- {locales.length > 1 && (
173
- <div className="flex gap-2 border-b border-ink/10">
174
- {locales.map((l) => (
175
- <button
176
- key={l}
177
- onClick={() => setActiveLocale(l)}
178
- className={[
179
- "px-4 py-2 text-xs uppercase tracking-[0.22em] border-b-2 -mb-px",
180
- activeLocale === l
181
- ? "border-ink text-ink"
182
- : "border-transparent text-ink/50 hover:text-ink",
183
- ].join(" ")}
184
- >
185
- {l}
186
- </button>
187
- ))}
188
- </div>
189
- )}
190
-
191
- <div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
192
- <Field label="Slug" hint={`Empty = root for ${activeLocale}. E.g. "boka"`}>
193
- <input
194
- value={form.slugs[activeLocale] ?? ""}
195
- onChange={(e) => updateLocalized("slugs", activeLocale, e.target.value)}
196
- className="seo-input"
197
- />
198
- </Field>
199
- <Field label="Robots" hint="E.g. noindex,nofollow — empty = default">
200
- <input
201
- value={form.seo.robots}
202
- onChange={(e) => updateString("robots", e.target.value)}
203
- className="seo-input"
204
- />
205
- </Field>
206
- <Field label="Title" full>
207
- <input
208
- value={form.seo.title[activeLocale] ?? ""}
209
- onChange={(e) => updateLocalized("title", activeLocale, e.target.value)}
210
- className="seo-input"
211
- />
212
- </Field>
213
- <Field label="Meta description" full>
214
- <textarea
215
- rows={3}
216
- value={form.seo.description[activeLocale] ?? ""}
217
- onChange={(e) => updateLocalized("description", activeLocale, e.target.value)}
218
- className="seo-input"
219
- />
220
- </Field>
221
- <Field label="OG title" hint="Share title (fallback: title)">
222
- <input
223
- value={form.seo.ogTitle[activeLocale] ?? ""}
224
- onChange={(e) => updateLocalized("ogTitle", activeLocale, e.target.value)}
225
- className="seo-input"
226
- />
227
- </Field>
228
- <Field label="OG description" hint="Share text (fallback: meta description)">
229
- <input
230
- value={form.seo.ogDescription[activeLocale] ?? ""}
231
- onChange={(e) => updateLocalized("ogDescription", activeLocale, e.target.value)}
232
- className="seo-input"
233
- />
234
- </Field>
235
- <Field label="OG image" hint="Pick from the media library or paste an absolute https URL">
236
- <div className="space-y-2">
237
- <div className="flex gap-2">
238
- <input
239
- value={form.seo.ogImageSrc}
240
- onChange={(e) => updateString("ogImageSrc", e.target.value)}
241
- className="seo-input"
242
- placeholder="https://…"
243
- />
181
+ <EditLockBanner lock={lock} className="mb-6" />
182
+ <EditLockFence lock={lock}>
183
+ {isCmsPage && (
184
+ <p className="mb-4 text-xs text-ink/55">
185
+ This page was created in the page builder. Its URL and metadata are stored in the CMS —
186
+ set the slug below (empty = site root).
187
+ </p>
188
+ )}
189
+ {locales.length > 1 && (
190
+ <div className="flex gap-2 border-b border-ink/10">
191
+ {locales.map((l) => (
244
192
  <button
245
- type="button"
246
- onClick={() => setPicking(true)}
247
- className="inline-flex shrink-0 items-center gap-1.5 border border-ink/20 px-3 text-[0.7rem] uppercase tracking-widest hover:border-sea"
193
+ key={l}
194
+ onClick={() => setActiveLocale(l)}
195
+ className={[
196
+ "px-4 py-2 text-xs uppercase tracking-[0.22em] border-b-2 -mb-px",
197
+ activeLocale === l
198
+ ? "border-ink text-ink"
199
+ : "border-transparent text-ink/50 hover:text-ink",
200
+ ].join(" ")}
248
201
  >
249
- <ImageIcon className="size-3.5" />
250
- Choose
202
+ {l}
251
203
  </button>
252
- {form.seo.ogImageSrc ? (
204
+ ))}
205
+ </div>
206
+ )}
207
+
208
+ <div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
209
+ <Field label="Slug" hint={`Empty = root for ${activeLocale}. E.g. "boka"`}>
210
+ <input
211
+ value={form.slugs[activeLocale] ?? ""}
212
+ onChange={(e) => updateLocalized("slugs", activeLocale, e.target.value)}
213
+ className="seo-input"
214
+ />
215
+ </Field>
216
+ <Field label="Robots" hint="E.g. noindex,nofollow — empty = default">
217
+ <input
218
+ value={form.seo.robots}
219
+ onChange={(e) => updateString("robots", e.target.value)}
220
+ className="seo-input"
221
+ />
222
+ </Field>
223
+ <Field label="Title" full>
224
+ <input
225
+ value={form.seo.title[activeLocale] ?? ""}
226
+ onChange={(e) => updateLocalized("title", activeLocale, e.target.value)}
227
+ className="seo-input"
228
+ />
229
+ </Field>
230
+ <Field label="Meta description" full>
231
+ <textarea
232
+ rows={3}
233
+ value={form.seo.description[activeLocale] ?? ""}
234
+ onChange={(e) => updateLocalized("description", activeLocale, e.target.value)}
235
+ className="seo-input"
236
+ />
237
+ </Field>
238
+ <Field label="OG title" hint="Share title (fallback: title)">
239
+ <input
240
+ value={form.seo.ogTitle[activeLocale] ?? ""}
241
+ onChange={(e) => updateLocalized("ogTitle", activeLocale, e.target.value)}
242
+ className="seo-input"
243
+ />
244
+ </Field>
245
+ <Field label="OG description" hint="Share text (fallback: meta description)">
246
+ <input
247
+ value={form.seo.ogDescription[activeLocale] ?? ""}
248
+ onChange={(e) => updateLocalized("ogDescription", activeLocale, e.target.value)}
249
+ className="seo-input"
250
+ />
251
+ </Field>
252
+ <Field label="OG image" hint="Pick from the media library or paste an absolute https URL">
253
+ <div className="space-y-2">
254
+ <div className="flex gap-2">
255
+ <input
256
+ value={form.seo.ogImageSrc}
257
+ onChange={(e) => updateString("ogImageSrc", e.target.value)}
258
+ className="seo-input"
259
+ placeholder="https://…"
260
+ />
253
261
  <button
254
262
  type="button"
255
- onClick={() => updateString("ogImageSrc", "")}
256
- className="inline-flex shrink-0 items-center border border-ink/20 px-2 text-ink/60 hover:border-sea"
257
- title="Clear"
263
+ onClick={() => setPicking(true)}
264
+ className="inline-flex shrink-0 items-center gap-1.5 border border-ink/20 px-3 text-[0.7rem] uppercase tracking-widest hover:border-sea"
258
265
  >
259
- <X className="size-3.5" />
266
+ <ImageIcon className="size-3.5" />
267
+ Choose
268
+ </button>
269
+ {form.seo.ogImageSrc ? (
270
+ <button
271
+ type="button"
272
+ onClick={() => updateString("ogImageSrc", "")}
273
+ className="inline-flex shrink-0 items-center border border-ink/20 px-2 text-ink/60 hover:border-sea"
274
+ title="Clear"
275
+ >
276
+ <X className="size-3.5" />
277
+ </button>
278
+ ) : null}
279
+ </div>
280
+ <div className="flex flex-wrap gap-2">
281
+ <button
282
+ type="button"
283
+ disabled={!heroImage}
284
+ onClick={() => heroImage && onPickMedia(heroImage.url)}
285
+ className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
286
+ title={heroImage?.url}
287
+ >
288
+ <Sparkles className="size-3.5" />
289
+ Use hero image
260
290
  </button>
261
- ) : null}
262
- </div>
263
- <div className="flex flex-wrap gap-2">
264
- <button
265
- type="button"
266
- disabled={!heroImage}
267
- onClick={() => heroImage && onPickMedia(heroImage.url)}
268
- className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
269
- title={heroImage?.url}
270
- >
271
- <Sparkles className="size-3.5" />
272
- Use hero image
273
- </button>
274
- <button
275
- type="button"
276
- disabled={pageImages.length === 0}
277
- onClick={() => setPickingOnPage(true)}
278
- className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
279
- >
280
- <Images className="size-3.5" />
281
- Pick from this page ({pageImages.length})
282
- </button>
283
- </div>
284
- {form.seo.ogImageSrc ? (
285
- <div className="flex items-center gap-3">
286
- <img
287
- src={form.seo.ogImageSrc}
288
- alt=""
289
- className="h-16 w-28 border border-ink/10 object-cover"
290
- />
291
291
  <button
292
292
  type="button"
293
- onClick={() => {
294
- const ok = importFromMedia(form.seo.ogImageSrc, true);
295
- (ok ? notify.success : notify.info)(
296
- ok
297
- ? "Imported alt, OG title and description from the media library"
298
- : "This image has no metadata saved in the media library yet",
299
- );
300
- }}
301
- className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea"
293
+ disabled={pageImages.length === 0}
294
+ onClick={() => setPickingOnPage(true)}
295
+ className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
302
296
  >
303
- <Download className="size-3.5" />
304
- Import image metadata
297
+ <Images className="size-3.5" />
298
+ Pick from this page ({pageImages.length})
305
299
  </button>
306
300
  </div>
307
- ) : null}
308
- </div>
309
- </Field>
310
- <Field label={`OG image alt (${activeLocale})`}>
311
- <input
312
- value={form.seo.ogImageAlt[activeLocale] ?? ""}
313
- onChange={(e) => updateLocalized("ogImageAlt", activeLocale, e.target.value)}
314
- className="seo-input"
315
- />
316
- </Field>
317
- <Field label="Keywords" full hint="Comma-separated list">
318
- <input
319
- value={form.seo.keywords[activeLocale] ?? ""}
320
- onChange={(e) => updateLocalized("keywords", activeLocale, e.target.value)}
321
- className="seo-input"
322
- />
323
- </Field>
324
- </div>
301
+ {form.seo.ogImageSrc ? (
302
+ <div className="flex items-center gap-3">
303
+ <img
304
+ src={form.seo.ogImageSrc}
305
+ alt=""
306
+ className="h-16 w-28 border border-ink/10 object-cover"
307
+ />
308
+ <button
309
+ type="button"
310
+ onClick={() => {
311
+ const ok = importFromMedia(form.seo.ogImageSrc, true);
312
+ (ok ? notify.success : notify.info)(
313
+ ok
314
+ ? "Imported alt, OG title and description from the media library"
315
+ : "This image has no metadata saved in the media library yet",
316
+ );
317
+ }}
318
+ className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea"
319
+ >
320
+ <Download className="size-3.5" />
321
+ Import image metadata
322
+ </button>
323
+ </div>
324
+ ) : null}
325
+ </div>
326
+ </Field>
327
+ <Field label={`OG image alt (${activeLocale})`}>
328
+ <input
329
+ value={form.seo.ogImageAlt[activeLocale] ?? ""}
330
+ onChange={(e) => updateLocalized("ogImageAlt", activeLocale, e.target.value)}
331
+ className="seo-input"
332
+ />
333
+ </Field>
334
+ <Field label="Keywords" full hint="Comma-separated list">
335
+ <input
336
+ value={form.seo.keywords[activeLocale] ?? ""}
337
+ onChange={(e) => updateLocalized("keywords", activeLocale, e.target.value)}
338
+ className="seo-input"
339
+ />
340
+ </Field>
341
+ </div>
325
342
 
326
- <div className="mt-10 border border-ink/10 bg-white p-6">
327
- <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Preview</div>
328
- <dl className="mt-4 grid grid-cols-1 md:grid-cols-[140px_1fr] gap-x-6 gap-y-2 text-sm">
329
- <dt className="text-ink/50">Canonical</dt>
330
- <dd className="font-mono text-ink/80 break-all">{previewCanonical}</dd>
331
- <dt className="text-ink/50">Route</dt>
332
- <dd className="font-mono text-ink/80">
333
- {previewPath} (current registry: {registryRoute(activeLocale)})
334
- </dd>
335
- <dt className="text-ink/50">&lt;title&gt;</dt>
336
- <dd className="text-ink/80">
337
- {form.seo.title[activeLocale] || <em className="text-ink/40">empty</em>}
338
- </dd>
339
- <dt className="text-ink/50">description</dt>
340
- <dd className="text-ink/80">
341
- {form.seo.description[activeLocale] || <em className="text-ink/40">empty</em>}
342
- </dd>
343
- </dl>
344
- </div>
343
+ <div className="mt-10 border border-ink/10 bg-white p-6">
344
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Preview</div>
345
+ <dl className="mt-4 grid grid-cols-1 md:grid-cols-[140px_1fr] gap-x-6 gap-y-2 text-sm">
346
+ <dt className="text-ink/50">Canonical</dt>
347
+ <dd className="font-mono text-ink/80 break-all">{previewCanonical}</dd>
348
+ <dt className="text-ink/50">Route</dt>
349
+ <dd className="font-mono text-ink/80">
350
+ {previewPath} (current registry: {registryRoute(activeLocale)})
351
+ </dd>
352
+ <dt className="text-ink/50">&lt;title&gt;</dt>
353
+ <dd className="text-ink/80">
354
+ {form.seo.title[activeLocale] || <em className="text-ink/40">empty</em>}
355
+ </dd>
356
+ <dt className="text-ink/50">description</dt>
357
+ <dd className="text-ink/80">
358
+ {form.seo.description[activeLocale] || <em className="text-ink/40">empty</em>}
359
+ </dd>
360
+ </dl>
361
+ </div>
345
362
 
346
- <div className="mt-8 flex flex-wrap items-center gap-3">
347
- <button
348
- onClick={handleSave}
349
- disabled={busy || !dirty}
350
- className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
351
- >
352
- <Save className="size-4" />
353
- {busy ? "Saving…" : "Save"}
354
- </button>
355
- <button
356
- onClick={handleReset}
357
- disabled={busy || !canReset}
358
- className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink disabled:opacity-40"
359
- >
360
- <RotateCcw className="size-4" />
361
- Reset to default
362
- </button>
363
- {dirty && (
364
- <span className="text-xs text-sea uppercase tracking-[0.22em]">unsaved changes</span>
365
- )}
366
- </div>
363
+ <div className="mt-8 flex flex-wrap items-center gap-3">
364
+ <button
365
+ onClick={handleSave}
366
+ disabled={busy || !dirty || lock.blocked}
367
+ className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
368
+ >
369
+ <Save className="size-4" />
370
+ {busy ? "Saving…" : "Save"}
371
+ </button>
372
+ <button
373
+ onClick={handleReset}
374
+ disabled={busy || !canReset || lock.blocked}
375
+ className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink disabled:opacity-40"
376
+ >
377
+ <RotateCcw className="size-4" />
378
+ Reset to default
379
+ </button>
380
+ {dirty && (
381
+ <span className="text-xs text-sea uppercase tracking-[0.22em]">unsaved changes</span>
382
+ )}
383
+ </div>
367
384
 
368
- {picking && renderMediaPicker({ onPick: onPickMedia, onClose: () => setPicking(false) })}
369
- {pickingOnPage &&
370
- renderPageImagePicker({ onPick: onPickMedia, onClose: () => setPickingOnPage(false) })}
385
+ {picking && renderMediaPicker({ onPick: onPickMedia, onClose: () => setPicking(false) })}
386
+ {pickingOnPage &&
387
+ renderPageImagePicker({ onPick: onPickMedia, onClose: () => setPickingOnPage(false) })}
371
388
 
372
- <style>{`
389
+ <style>{`
373
390
  .seo-input {
374
391
  width: 100%;
375
392
  background: white;
@@ -380,7 +397,8 @@ export function PageSeoPanelView({
380
397
  color: var(--ink);
381
398
  }
382
399
  .seo-input:focus { border-color: var(--sea); }
383
- `}</style>
400
+ `}</style>
401
+ </EditLockFence>
384
402
  </div>
385
403
  );
386
404
  }
@@ -3,25 +3,25 @@
3
3
  *
4
4
  * Returns the host the backoffice should be served from, or null when the
5
5
  * current host is fine (local dev, Lovable preview, or already the admin host).
6
+ *
7
+ * This is canonical-host routing, not an authorization boundary — roles are.
8
+ * So every uncertain case returns null and stays put: a missing, blank or
9
+ * malformed admin domain must not bounce an administrator to a hostname that
10
+ * serves nothing, which is a lockout with no way back in.
6
11
  */
7
- const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
12
+ import { isValidCanonicalDomain, normalizeCanonicalDomain } from "../../site/host";
8
13
 
9
- function normalizeHost(value: string): string {
10
- return value
11
- .replace(/^https?:\/\//, "")
12
- .replace(/\/+$/, "")
13
- .replace(/^www\./, "")
14
- .toLowerCase();
15
- }
14
+ const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
16
15
 
17
16
  /** True for hosts where the admin-domain guard must not apply. */
18
17
  export function isDevelopmentHost(host: string): boolean {
19
- const h = normalizeHost(host);
18
+ const h = normalizeCanonicalDomain(host);
20
19
  return (
21
20
  DEV_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix)) ||
22
21
  h === "localhost" ||
23
- h.startsWith("localhost:") ||
24
- h.startsWith("127.0.0.1")
22
+ h.endsWith(".localhost") ||
23
+ h === "127.0.0.1" ||
24
+ h === "[::1]"
25
25
  );
26
26
  }
27
27
 
@@ -31,8 +31,21 @@ export function backofficeRedirectHost(
31
31
  adminDomain: string | null | undefined,
32
32
  ): string | null {
33
33
  if (!currentHost || isDevelopmentHost(currentHost)) return null;
34
- const current = normalizeHost(currentHost);
35
- const admin = normalizeHost(adminDomain ?? "");
36
- if (!admin || current === admin) return null;
34
+
35
+ // The stored form is the destination — `www.` included when configured —
36
+ // and the comparison is against that exact hostname, not against an alias
37
+ // key. Apex and `www.` are aliases for *finding* the row; they are two
38
+ // different addresses to serve from, and the configured one is the answer.
39
+ // Folding them together here is what left `voyara.se` sitting on a
40
+ // deployment that had chosen `www.voyara.se`, cookies and all.
41
+ // Validated before normalizing, not after: normalizing strips the scheme, so
42
+ // `ftp://voyara.se` checked afterwards is the valid `voyara.se`.
43
+ const admin = isValidCanonicalDomain(adminDomain) ? normalizeCanonicalDomain(adminDomain ?? "") : "";
44
+ if (!admin) return null;
45
+ // A development host is never a destination either. `localhost` is what an
46
+ // unconfigured install resolves to, and sending a public request there is
47
+ // the lockout this guard exists to avoid.
48
+ if (isDevelopmentHost(admin)) return null;
49
+ if (normalizeCanonicalDomain(currentHost) === admin) return null;
37
50
  return admin;
38
51
  }
@@ -9,9 +9,14 @@ export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "
9
9
  export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
10
10
  export {
11
11
  FeatureDisabledNotice,
12
+ ModuleRoleNotice,
13
+ MY_ROLES_QUERY_KEY,
12
14
  SITE_SETTINGS_QUERY_KEY,
13
15
  useBackofficeNav,
14
16
  useFeatureEnabled,
15
17
  useFeatureGate,
18
+ useModuleGate,
19
+ useModuleRoleAllowed,
20
+ useMyRoles,
16
21
  useResolvedSiteConfig,
17
22
  } from "./use-resolved-config";
@@ -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
- | "domains"
36
- | "trips"
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
- const DEFINITIONS: readonly Definition[] = [
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
- { key: "domains", path: "/domains", label: "Domains", icon: Globe, feature: "domains" },
63
- { key: "trips", path: "/trips", label: "Trips", icon: Calendar, feature: "trips" },
64
- { key: "courses", path: "/kurser", label: "Courses", icon: GraduationCap, feature: "courses" },
65
- { key: "forms", path: "/forms", label: "Forms", icon: ClipboardList, feature: "forms" },
66
- {
67
- key: "reviews",
68
- path: "/recensioner",
69
- label: "Reviews",
70
- icon: MessageSquareQuote,
71
- feature: "reviews",
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