@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
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.10",
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": [
@@ -30,6 +30,7 @@
30
30
  "./blocks": "./src/blocks/index.ts",
31
31
  "./blocks/types": "./src/blocks/types.ts",
32
32
  "./blocks/countdown-typography": "./src/blocks/countdown-typography.ts",
33
+ "./blocks/optional-content": "./src/blocks/optional-content.ts",
33
34
  "./blocks/icons": "./src/blocks/icons.ts",
34
35
  "./blocks/block-props-context": "./src/blocks/block-props-context.tsx",
35
36
  "./blocks/components/*": "./src/blocks/components/*.tsx",
@@ -49,6 +50,8 @@
49
50
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
50
51
  "./navigation": "./src/navigation/index.ts",
51
52
  "./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
53
+ "./forms/success": "./src/forms/success-listeners.ts",
54
+ "./forms/submission-result": "./src/forms/submission-result.ts",
52
55
  "./forms/conditions": "./src/forms/conditions.ts",
53
56
  "./forms/field-sets": "./src/forms/field-sets.ts",
54
57
  "./forms/form-audit": "./src/forms/form-audit.ts",
@@ -64,6 +67,7 @@
64
67
  "./media/storage": "./src/media/storage.ts",
65
68
  "./media/usage-types": "./src/media/usage-types.ts",
66
69
  "./cms/version-diff": "./src/cms/version-diff.ts",
70
+ "./cms/edit-lock": "./src/cms/edit-lock.ts",
67
71
  "./cms/shared-draft-store": "./src/cms/shared-draft-store.ts",
68
72
  "./http/cache-policy": "./src/http/cache-policy.ts",
69
73
  "./http/compression": "./src/http/compression.ts",
@@ -150,6 +154,9 @@
150
154
  "./server/media-library-core": "./src/server/media-library-core.ts",
151
155
  "./server/admin-core": "./src/server/admin-core.ts",
152
156
  "./server/pages-core": "./src/server/pages-core.ts",
157
+ "./server/edit-locks-core": "./src/server/edit-locks-core.ts",
158
+ "./server/edit-lock-adapter": "./src/server/edit-lock-adapter.ts",
159
+ "./server/edit-lock-supabase-adapter": "./src/server/edit-lock-supabase-adapter.ts",
153
160
  "./server/published-cache": "./src/server/published-cache.ts",
154
161
  "./server/resolve-globals": "./src/server/resolve-globals.ts",
155
162
  "./server/content-sections-core": "./src/server/content-sections-core.ts",
@@ -169,6 +176,7 @@
169
176
  "./backoffice/routes/media-forms": "./src/backoffice/routes/media-forms.tsx",
170
177
  "./backoffice/inspectors": "./src/backoffice/inspectors/index.tsx",
171
178
  "./backoffice/PageBuilderView": "./src/backoffice/PageBuilderView.tsx",
179
+ "./backoffice/EditLock": "./src/backoffice/EditLock.tsx",
172
180
  "./backoffice/SectionEditorView": "./src/backoffice/SectionEditorView.tsx",
173
181
  "./backoffice/DashboardView": "./src/backoffice/DashboardView.tsx",
174
182
  "./backoffice/ResetPasswordView": "./src/backoffice/ResetPasswordView.tsx",
@@ -179,6 +187,12 @@
179
187
  "./site/font-bundle": "./src/site/font-bundle.ts",
180
188
  "./admin/version-core": "./src/admin/version-core.ts",
181
189
  "./config/package-owned-modules": "./src/config/package-owned-modules.ts",
190
+ "./modules": "./src/modules/index.ts",
191
+ "./popups/host": "./src/popups/host.ts",
192
+ "./popups/cms-bridges": "./src/popups/cms-bridges.ts",
193
+ "./popups/register": "./src/popups/register.ts",
194
+ "./popups/runtime": "./src/popups/runtime.tsx",
195
+ "./popups/admin": "./src/popups/admin.tsx",
182
196
  "./server/schema-drift": "./src/server/schema-drift.ts",
183
197
  "./server/media-focus-core": "./src/server/media-focus-core.ts",
184
198
  "./server/media-meta-core": "./src/server/media-meta-core.ts",
@@ -213,6 +227,8 @@
213
227
  "./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx",
214
228
  "./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
215
229
  "./site/site-url": "./src/site/site-url.ts",
230
+ "./site/host": "./src/site/host.ts",
231
+ "./site/locale-domains": "./src/site/locale-domains.ts",
216
232
  "./site/SiteHeader": "./src/site/SiteHeader.tsx",
217
233
  "./site/SiteFooter": "./src/site/SiteFooter.tsx",
218
234
  "./server/schemas": "./src/server/schemas.ts",
@@ -222,7 +238,11 @@
222
238
  "./site/routes/section-preview": "./src/site/routes/section-preview.tsx",
223
239
  "./media/priority": "./src/media/priority.tsx",
224
240
  "./media/preload": "./src/media/preload.ts",
225
- "./ui/NearViewport": "./src/ui/NearViewport.tsx"
241
+ "./ui/NearViewport": "./src/ui/NearViewport.tsx",
242
+ "./server/webhook-url-guard": "./src/server/webhook-url-guard.ts",
243
+ "./server/popup-optin-webhook-core": "./src/server/popup-optin-webhook-core.ts",
244
+ "./server/popup-optin-trigger": "./src/server/popup-optin-trigger.ts",
245
+ "./popups/destinations": "./src/popups/destinations.tsx"
226
246
  },
227
247
  "dependencies": {
228
248
  "fflate": "^0.8.3"
@@ -250,7 +270,8 @@
250
270
  "@dnd-kit/core": ">=6",
251
271
  "@dnd-kit/sortable": ">=3",
252
272
  "@dnd-kit/utilities": ">=3",
253
- "zod": ">=3"
273
+ "zod": ">=3",
274
+ "@bygghammar/lightweight-popup": ">=0.2.0-m7.8"
254
275
  },
255
276
  "peerDependenciesMeta": {
256
277
  "@supabase/supabase-js": {
@@ -294,6 +315,9 @@
294
315
  },
295
316
  "@tiptap/extension-table": {
296
317
  "optional": true
318
+ },
319
+ "@bygghammar/lightweight-popup": {
320
+ "optional": true
297
321
  }
298
322
  },
299
323
  "bin": {
@@ -312,6 +336,7 @@
312
336
  "check": "bun run typecheck && bun run test && bun run verify:pack"
313
337
  },
314
338
  "devDependencies": {
339
+ "@bygghammar/lightweight-popup": "0.2.0-m7.8",
315
340
  "@dnd-kit/core": "^6.3.1",
316
341
  "@dnd-kit/sortable": "^10.0.0",
317
342
  "@dnd-kit/utilities": "^3.2.2",
@@ -319,6 +344,8 @@
319
344
  "@tanstack/react-query": "^5.101.1",
320
345
  "@tanstack/react-router": "1.170.18",
321
346
  "@tanstack/react-start": "1.168.32",
347
+ "@testing-library/dom": "^10.4.0",
348
+ "@testing-library/react": "^16.3.0",
322
349
  "@tiptap/extension-highlight": "^3.29.2",
323
350
  "@tiptap/extension-image": "^3.29.2",
324
351
  "@tiptap/extension-subscript": "^3.29.2",
@@ -333,6 +360,7 @@
333
360
  "@types/node": "^22.10.2",
334
361
  "@types/react": "19.2.17",
335
362
  "@types/react-dom": "19.2.3",
363
+ "jsdom": "^26.1.0",
336
364
  "lucide-react": "^0.575.0",
337
365
  "react": "^19.2.0",
338
366
  "react-dom": "^19.2.0",
@@ -131,10 +131,11 @@ helper comes from \`@bygghammar/cms-platform\`.
131
131
  Next steps:
132
132
 
133
133
  1. \`npm install @bygghammar/cms-platform\`
134
- 2. Apply the canonical schema: \`npx cms-platform-migrations sync\`
135
- 3. Fill in \`src/lib/backoffice/host.tsx\` with this site's server functions.
136
- 4. Add the thin route wrappers under \`src/routes/${backofficePath.replace("/", "")}.*.tsx\`.
137
- 5. Set \`VITE_PUBLIC_SITE_URL\` per environment; never store it in the database.
134
+ 2. Copy the canonical migrations: \`npx cms-platform-migrations\`
135
+ 3. Apply \`supabase/migrations\` with the site's normal staging-first migration workflow.
136
+ 4. Fill in \`src/lib/backoffice/host.tsx\` with this site's server functions.
137
+ 5. Add the thin route wrappers under \`src/routes/${backofficePath.replace("/", "")}.*.tsx\`.
138
+ 6. Set \`VITE_PUBLIC_SITE_URL\` per environment; never store it in the database.
138
139
  `;
139
140
 
140
141
  const existing = existsSync(root) ? await readdir(root) : [];
@@ -8,9 +8,9 @@
8
8
  *
9
9
  * Usage: cms-platform-migrations [targetDir] [--check]
10
10
  * targetDir defaults to ./supabase/migrations
11
- * --check exits non-zero when files are missing instead of copying them
11
+ * --check exits non-zero when files are missing or their contents differ
12
12
  */
13
- import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
 
@@ -32,18 +32,39 @@ const canonical = readdirSync(source)
32
32
 
33
33
  const present = new Set(existsSync(target) ? readdirSync(target) : []);
34
34
  const missing = canonical.filter((f) => !present.has(f));
35
+ const different = canonical.filter(
36
+ (f) =>
37
+ present.has(f) &&
38
+ !readFileSync(join(source, f)).equals(readFileSync(join(target, f))),
39
+ );
35
40
 
36
- if (missing.length === 0) {
41
+ if (missing.length === 0 && different.length === 0) {
37
42
  console.log(`Up to date: ${canonical.length} canonical migrations already present.`);
38
43
  process.exit(0);
39
44
  }
40
45
 
41
46
  if (checkOnly) {
42
- console.error(`Missing ${missing.length} canonical migrations:`);
43
- for (const f of missing) console.error(` ${f}`);
47
+ if (missing.length > 0) {
48
+ console.error(`Missing ${missing.length} canonical migrations:`);
49
+ for (const f of missing) console.error(` ${f}`);
50
+ }
51
+ if (different.length > 0) {
52
+ console.error(`Different contents for ${different.length} canonical migrations:`);
53
+ for (const f of different) console.error(` ${f}`);
54
+ }
44
55
  process.exit(1);
45
56
  }
46
57
 
47
58
  mkdirSync(target, { recursive: true });
48
59
  for (const f of missing) copyFileSync(join(source, f), join(target, f));
49
- console.log(`Copied ${missing.length} migration(s) into ${target}. Apply them with your migration tooling.`);
60
+ if (missing.length > 0) {
61
+ console.log(`Copied ${missing.length} migration(s) into ${target}. Apply them with your migration tooling.`);
62
+ }
63
+ if (different.length > 0) {
64
+ console.error(
65
+ "Existing migrations with different contents were not overwritten. " +
66
+ "If a timestamp has been applied, add a new corrective migration; otherwise replace the local copy explicitly.",
67
+ );
68
+ for (const f of different) console.error(` ${f}`);
69
+ process.exit(1);
70
+ }
@@ -1,6 +1,9 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { toast } from "sonner";
3
3
 
4
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
5
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
6
+
4
7
  /** One editable company field. */
5
8
  export type ContactField = { key: string; label: string; type?: string };
6
9
 
@@ -26,7 +29,9 @@ export type ContactDetailsDeps = {
26
29
  loading?: boolean;
27
30
  /** Fields to render; defaults to `CONTACT_FIELDS`. */
28
31
  fields?: ContactField[];
29
- saveSection: (input: { data: { key: string; data: unknown } }) => Promise<unknown>;
32
+ saveSection: (input: {
33
+ data: { key: string; data: unknown; editSessionId?: string };
34
+ }) => Promise<unknown>;
30
35
  refresh: () => Promise<unknown> | void;
31
36
  };
32
37
 
@@ -44,6 +49,11 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
44
49
  const [form, setForm] = useState<Record<string, string>>(defaults);
45
50
  const [saving, setSaving] = useState(false);
46
51
 
52
+ const lock = useEditLock({
53
+ resourceType: EDIT_LOCK_TYPES.contentSection,
54
+ resourceId: "company",
55
+ });
56
+
47
57
  useEffect(() => {
48
58
  if (stored && typeof stored === "object") setForm({ ...defaults, ...stored });
49
59
  }, [stored, defaults]);
@@ -53,7 +63,9 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
53
63
  async function onSave() {
54
64
  setSaving(true);
55
65
  try {
56
- await saveSection({ data: { key: "company", data: form } });
66
+ await saveSection({
67
+ data: { key: "company", data: form, editSessionId: lock.sessionId },
68
+ });
57
69
  await refresh();
58
70
  toast.success("Contact details saved");
59
71
  } catch (e) {
@@ -73,7 +85,9 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
73
85
  for search engines.
74
86
  </p>
75
87
 
76
- <div className="mt-10 grid gap-5 sm:grid-cols-2">
88
+ <EditLockBanner lock={lock} className="mt-8" />
89
+
90
+ <EditLockFence lock={lock} className="mt-10 grid gap-5 sm:grid-cols-2">
77
91
  {fields.map((f) => (
78
92
  <label key={f.key} className="block">
79
93
  <span className={lbl}>{f.label}</span>
@@ -85,13 +99,13 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
85
99
  />
86
100
  </label>
87
101
  ))}
88
- </div>
102
+ </EditLockFence>
89
103
 
90
104
  <div className="mt-10 flex items-center gap-4">
91
105
  <button
92
106
  type="button"
93
107
  onClick={onSave}
94
- disabled={saving || !!loading}
108
+ disabled={saving || !!loading || lock.blocked}
95
109
  className="bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper disabled:opacity-50"
96
110
  >
97
111
  {saving ? "Saving..." : "Save details"}
@@ -2,6 +2,16 @@ import { useState } from "react";
2
2
  import { toast } from "sonner";
3
3
  import { Trash2, Plus, Check, X } from "lucide-react";
4
4
 
5
+ import {
6
+ adminHostImpact,
7
+ adminHostImpactMessage,
8
+ domainChangeProblemMessage,
9
+ validateDomainChange,
10
+ type AdminDomainChange,
11
+ type DomainChangeProblem,
12
+ type EffectiveAdminHost,
13
+ } from "../site/locale-domains";
14
+
5
15
  /** One language-to-domain mapping. */
6
16
  export type LocaleDomainRow = {
7
17
  locale: string;
@@ -15,15 +25,58 @@ export type DomainsDeps = {
15
25
  domains: LocaleDomainRow[];
16
26
  locales: readonly string[];
17
27
  defaultLocale: string;
18
- /** Path back to the backoffice home, e.g. `/kontoret`. */
28
+ /** Path back to the Admin overview, e.g. `/kontoret/admin`. */
19
29
  backPath: string;
20
30
  /** Example domain shown as the input placeholder. */
21
31
  domainPlaceholder?: string;
32
+ /**
33
+ * The hostname that actually serves the backoffice, and why.
34
+ *
35
+ * Answered by the server, because the deciding input is `SITE_URL` — a value
36
+ * the browser cannot read, and one a screen that guesses it would state to
37
+ * an administrator as fact about their own install. Undefined when the host
38
+ * registered no way to ask: the screen then says nothing about the hostname
39
+ * instead of inventing an answer, and everything else still works.
40
+ */
41
+ adminHost?: EffectiveAdminHost;
42
+ /** Path the backoffice is served under, for the admin-domain explanation. */
43
+ backofficePath?: string;
22
44
  saveDomain: (input: { data: LocaleDomainRow }) => Promise<unknown>;
23
45
  deleteDomain: (input: { data: { locale: string } }) => Promise<unknown>;
24
46
  refresh: () => Promise<unknown> | void;
25
47
  };
26
48
 
49
+ /**
50
+ * Why the backoffice is reached on the hostname it is reached on.
51
+ *
52
+ * Worth spelling out because the Admin checkbox looks like the whole answer
53
+ * and is not: on a self-hosted install `SITE_URL` outranks it, which is also
54
+ * what makes a bad row recoverable.
55
+ *
56
+ * The fallbacks say "no usable row" rather than "no row" because a row flagged
57
+ * before the domain validator existed can hold a value that is not a hostname.
58
+ * Such a row is skipped, so the backoffice falls back exactly as if nothing
59
+ * were flagged — and telling the administrator nothing is flagged, while the
60
+ * checkbox they can see is ticked, reads as a bug in the screen rather than as
61
+ * the reason to go and fix the row.
62
+ */
63
+ function adminHostExplanation(info: EffectiveAdminHost): string {
64
+ switch (info.source) {
65
+ case "site-url":
66
+ return "is set by SITE_URL on this deployment, which overrides the Admin checkbox below. Change SITE_URL to move the backoffice.";
67
+ case "database":
68
+ return "is the domain flagged as the admin domain below.";
69
+ case "request-host":
70
+ return "is the address you opened, kept as-is because no usable row is flagged as the admin domain. Flag one below to designate it.";
71
+ case "default-mapping":
72
+ return "is the default domain, used because no usable row is flagged as the admin domain.";
73
+ case "first-mapping":
74
+ return "is the first configured domain, because no usable row is flagged as the admin domain and none is marked default.";
75
+ case "localhost":
76
+ return "is the local address, because nothing is configured yet.";
77
+ }
78
+ }
79
+
27
80
  /** Host-agnostic languages & domains screen. */
28
81
  export function DomainsView({ deps }: { deps: DomainsDeps }) {
29
82
  const {
@@ -31,43 +84,80 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
31
84
  locales,
32
85
  defaultLocale: DEFAULT_LOCALE,
33
86
  backPath,
34
- domainPlaceholder = "www.example.com",
87
+ domainPlaceholder = "example.com",
88
+ adminHost,
89
+ backofficePath = "/kontoret",
35
90
  saveDomain: save,
36
91
  deleteDomain: del,
37
92
  refresh,
38
93
  } = deps;
39
94
  const [busy, setBusy] = useState(false);
40
95
  const [editing, setEditing] = useState<LocaleDomainRow | null>(null);
96
+ const [problem, setProblem] = useState<DomainChangeProblem | null>(null);
41
97
  const [draft, setDraft] = useState<Partial<LocaleDomainRow>>({
42
- locale: DEFAULT_LOCALE,
43
98
  domain: "",
44
99
  isDefault: false,
45
100
  isAdminDomain: false,
46
101
  });
47
102
 
103
+ // One row per language, so adding can only offer the languages that have
104
+ // none. Offering all of them made "Add" an upsert: picking a configured
105
+ // language and saving replaced that mapping's domain without ever saying so.
106
+ const configured = new Set(domains.map((d) => d.locale));
107
+ const available = locales.filter((l) => !configured.has(l));
108
+ const suggested = available.includes(DEFAULT_LOCALE) ? DEFAULT_LOCALE : available[0];
109
+ const addLocale = draft.locale && available.includes(draft.locale) ? draft.locale : suggested;
110
+ const locale = editing ? editing.locale : addLocale;
111
+ const canAdd = available.length > 0;
112
+
48
113
  const reset = () => {
49
114
  setEditing(null);
50
- setDraft({
51
- locale: DEFAULT_LOCALE,
52
- domain: "",
53
- isDefault: false,
54
- isAdminDomain: false,
115
+ setProblem(null);
116
+ setDraft({ domain: "", isDefault: false, isAdminDomain: false });
117
+ };
118
+
119
+ /**
120
+ * Whether a change may go ahead, having said so when it may not.
121
+ *
122
+ * Two different things, deliberately kept apart. A change the server will
123
+ * refuse is refused here — stated as an error, with no dialog implying it
124
+ * could proceed. A change that merely moves the backoffice to another
125
+ * hostname is valid, and gets a confirmation naming both hostnames instead.
126
+ */
127
+ const allow = (change: AdminDomainChange): boolean => {
128
+ const blocked = validateDomainChange(domains, change, {
129
+ siteUrlAuthoritative: adminHost?.siteUrlAuthoritative === true,
130
+ });
131
+ setProblem(blocked);
132
+ if (blocked) {
133
+ toast.error(domainChangeProblemMessage(blocked));
134
+ return false;
135
+ }
136
+
137
+ const impact = adminHostImpact(domains, change, {
138
+ // With SITE_URL in charge no row change can move the backoffice, so this
139
+ // resolves to no impact and nothing is asked.
140
+ forcedHost: adminHost?.siteUrlAuthoritative ? adminHost.host : null,
141
+ requestHost: typeof window === "undefined" ? null : window.location.host,
142
+ defaultLocale: DEFAULT_LOCALE,
55
143
  });
144
+ if (!impact) return true;
145
+ return confirm(adminHostImpactMessage(impact, { authoritative: adminHost !== undefined }));
56
146
  };
57
147
 
58
148
  const submit = async (e: React.FormEvent) => {
59
149
  e.preventDefault();
60
- if (!draft.locale || !draft.domain) return;
150
+ if (!locale || !draft.domain) return;
151
+ const row: LocaleDomainRow = {
152
+ locale,
153
+ domain: draft.domain,
154
+ isDefault: !!draft.isDefault,
155
+ isAdminDomain: !!draft.isAdminDomain,
156
+ };
157
+ if (!allow({ kind: "save", ...row })) return;
61
158
  setBusy(true);
62
159
  try {
63
- await save({
64
- data: {
65
- locale: draft.locale,
66
- domain: draft.domain,
67
- isDefault: !!draft.isDefault,
68
- isAdminDomain: !!draft.isAdminDomain,
69
- },
70
- });
160
+ await save({ data: row });
71
161
  await refresh();
72
162
  toast.success("Domain saved");
73
163
  reset();
@@ -78,11 +168,12 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
78
168
  }
79
169
  };
80
170
 
81
- const remove = async (locale: string) => {
171
+ const remove = async (target: string) => {
172
+ if (!allow({ kind: "delete", locale: target })) return;
82
173
  if (!confirm("Remove this domain mapping?")) return;
83
174
  setBusy(true);
84
175
  try {
85
- await del({ data: { locale } });
176
+ await del({ data: { locale: target } });
86
177
  await refresh();
87
178
  toast.success("Domain removed");
88
179
  } catch (err) {
@@ -94,9 +185,11 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
94
185
 
95
186
  const startEdit = (d: LocaleDomainRow) => {
96
187
  setEditing(d);
188
+ setProblem(null);
97
189
  setDraft({ ...d });
98
190
  };
99
191
 
192
+
100
193
  return (
101
194
  <main className="p-6 md:p-10 w-full">
102
195
  <div className="flex items-center justify-between">
@@ -114,9 +207,21 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
114
207
 
115
208
  <p className="mt-4 max-w-2xl text-sm text-ink/70">
116
209
  Map each language to its own domain. Mark one domain as the default (fallback) and one as
117
- the admin domain where the backoffice is only available.
210
+ the domain the backoffice is served from.
118
211
  </p>
119
212
 
213
+ {adminHost && (
214
+ <div className="mt-6 max-w-2xl border border-ink/10 bg-ink/[0.02] px-4 py-3 text-sm text-ink/70">
215
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">
216
+ Backoffice hostname
217
+ </span>
218
+ <p className="mt-1">
219
+ <code className="font-mono text-ink">{adminHost.host}</code>{" "}
220
+ {adminHostExplanation(adminHost)}
221
+ </p>
222
+ </div>
223
+ )}
224
+
120
225
  <section className="mt-10">
121
226
  <h2 className="font-serif text-xl">Current domains</h2>
122
227
  <div className="mt-4 overflow-x-auto border border-ink/10">
@@ -186,20 +291,33 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
186
291
 
187
292
  <section className="mt-12 max-w-xl">
188
293
  <h2 className="font-serif text-xl">{editing ? "Edit domain" : "Add domain"}</h2>
294
+ {!editing && !canAdd ? (
295
+ <p className="mt-4 border border-ink/10 bg-ink/[0.02] px-4 py-3 text-sm text-ink/70">
296
+ Every language already has a domain. Use Edit on a row above to change one — adding
297
+ would only overwrite an existing mapping.
298
+ </p>
299
+ ) : (
189
300
  <form onSubmit={submit} className="mt-4 space-y-4">
190
301
  <label className="block">
191
302
  <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Language</span>
192
303
  <select
193
- value={draft.locale}
304
+ value={locale}
305
+ disabled={editing !== null}
194
306
  onChange={(e) => setDraft((d) => ({ ...d, locale: e.target.value }))}
195
- className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none"
307
+ className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none disabled:cursor-not-allowed disabled:bg-ink/5 disabled:text-ink/60"
196
308
  >
197
- {locales.map((l) => (
309
+ {(editing ? [editing.locale] : available).map((l) => (
198
310
  <option key={l} value={l}>
199
311
  {l.toUpperCase()}
200
312
  </option>
201
313
  ))}
202
314
  </select>
315
+ {editing !== null && (
316
+ <span className="mt-1 block text-xs text-ink/50">
317
+ The language identifies the mapping, so it cannot be changed here. To move this
318
+ domain to another language, remove this row and add it again.
319
+ </span>
320
+ )}
203
321
  </label>
204
322
 
205
323
  <label className="block">
@@ -213,7 +331,9 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
213
331
  className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink placeholder:text-ink/40 focus:border-sea focus:outline-none"
214
332
  />
215
333
  <span className="mt-1 block text-xs text-ink/50">
216
- Enter the domain without https:// and without trailing slash.
334
+ Enter the hostname only — no https://, no path, no trailing slash. Whether you
335
+ include <code>www.</code> is kept as typed and used in generated links; requests
336
+ arriving on the other form still reach this language, so one row covers both.
217
337
  </span>
218
338
  </label>
219
339
 
@@ -234,10 +354,16 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
234
354
  onChange={(e) => setDraft((d) => ({ ...d, isAdminDomain: e.target.checked }))}
235
355
  className="size-4 border-ink/30 text-sea focus:ring-sea"
236
356
  />
237
- Admin domain (/kontoret only accessible here)
357
+ Admin domain ({backofficePath} is served here)
238
358
  </label>
239
359
  </div>
240
360
 
361
+ {problem && (
362
+ <p role="alert" className="border border-red-600/30 bg-red-50 px-4 py-3 text-sm text-red-700">
363
+ {domainChangeProblemMessage(problem)}
364
+ </p>
365
+ )}
366
+
241
367
  <div className="flex items-center gap-3 pt-4">
242
368
  <button
243
369
  type="submit"
@@ -258,6 +384,7 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
258
384
  )}
259
385
  </div>
260
386
  </form>
387
+ )}
261
388
  </section>
262
389
  </main>
263
390
  );