@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
@@ -2,6 +2,13 @@ import { useEffect, useState } from "react";
2
2
  import { toast } from "sonner";
3
3
  import { Plus, Save, Trash2, Copy } from "lucide-react";
4
4
 
5
+ import {
6
+ markSaved,
7
+ mergeServerProfiles,
8
+ newRow,
9
+ type DraftRow,
10
+ } from "./tracking-drafts";
11
+
5
12
  export type InjectionScope = "global" | "locale" | "domain";
6
13
 
7
14
  /** One injection profile row, structurally matching the host's own type. */
@@ -27,7 +34,17 @@ export type TrackingDeps = {
27
34
  profiles: InjectionProfileLike[];
28
35
  domains: { domain: string }[];
29
36
  locales: readonly string[];
30
- saveInjection: (input: { data: Omit<InjectionProfileLike, "id"> & { id?: string } }) => Promise<unknown>;
37
+ /**
38
+ * Persist one profile and return the row's server id.
39
+ *
40
+ * The id is the contract, not a convenience: a profile created here has none
41
+ * until the server assigns one, and a save that comes back without it leaves
42
+ * the row unable to say which profile it is. Saving again would then insert a
43
+ * second copy rather than update the first.
44
+ */
45
+ saveInjection: (
46
+ input: { data: Omit<InjectionProfileLike, "id"> & { id?: string } },
47
+ ) => Promise<{ id: string }>;
31
48
  deleteInjection: (input: { data: { id: string } }) => Promise<unknown>;
32
49
  refresh: () => Promise<unknown> | void;
33
50
  };
@@ -103,20 +120,26 @@ function Code({
103
120
  export function TrackingView({ deps }: { deps: TrackingDeps }) {
104
121
  const { profiles, domains, locales, saveInjection: save, deleteInjection: remove, refresh } = deps;
105
122
 
106
- const [drafts, setDrafts] = useState<InjectionProfileLike[]>([]);
123
+ const [rows, setRows] = useState<DraftRow<InjectionProfileLike>[]>([]);
107
124
  const [busy, setBusy] = useState<string | null>(null);
108
125
 
126
+ // Fold the server list into the rows being edited rather than replacing
127
+ // them. Replacing is what discarded unsaved work every time an unrelated
128
+ // save, refetch or parent render produced a new list.
109
129
  useEffect(() => {
110
- setDrafts(profiles);
130
+ setRows((current) => mergeServerProfiles(current, profiles));
111
131
  }, [profiles]);
112
132
 
113
133
  const patch = (i: number, next: Partial<InjectionProfileLike>) =>
114
- setDrafts((d) => d.map((p, idx) => (idx === i ? { ...p, ...next } : p)));
134
+ setRows((d) =>
135
+ d.map((row, idx) => (idx === i ? { ...row, value: { ...row.value, ...next } } : row)),
136
+ );
115
137
 
116
- const onSave = async (p: InjectionProfileLike) => {
117
- setBusy(p.id || p.name);
138
+ const onSave = async (row: DraftRow<InjectionProfileLike>) => {
139
+ const p = row.value;
140
+ setBusy(row.key);
118
141
  try {
119
- await save({
142
+ const result = await save({
120
143
  data: {
121
144
  id: p.id || undefined,
122
145
  name: p.name,
@@ -134,6 +157,17 @@ export function TrackingView({ deps }: { deps: TrackingDeps }) {
134
157
  sort_order: p.sort_order,
135
158
  },
136
159
  });
160
+ // Adopt the id a newly created profile was just given, so the reload
161
+ // recognises this row instead of listing the same profile twice.
162
+ const savedId = result?.id;
163
+ if (!p.id && !savedId) {
164
+ // Without an id this row cannot be updated, only inserted again. It
165
+ // stays dirty and unsaved so the next attempt is a retry rather than a
166
+ // duplicate profile nobody asked for.
167
+ toast.error("Saved without an id — reload and try again");
168
+ return;
169
+ }
170
+ setRows((d) => markSaved(d, row.key, p, savedId));
137
171
  toast.success("Saved — live within 30 seconds");
138
172
  void refresh();
139
173
  } catch (e) {
@@ -143,14 +177,16 @@ export function TrackingView({ deps }: { deps: TrackingDeps }) {
143
177
  }
144
178
  };
145
179
 
146
- const onDelete = async (p: InjectionProfileLike, i: number) => {
180
+ const onDelete = async (row: DraftRow<InjectionProfileLike>) => {
181
+ const p = row.value;
147
182
  if (!p.id) {
148
- setDrafts((d) => d.filter((_, idx) => idx !== i));
183
+ setRows((d) => d.filter((x) => x.key !== row.key));
149
184
  return;
150
185
  }
151
186
  if (!confirm(`Delete "${p.name}"?`)) return;
152
187
  try {
153
188
  await remove({ data: { id: p.id } });
189
+ setRows((d) => d.filter((x) => x.key !== row.key));
154
190
  toast.success("Deleted");
155
191
  void refresh();
156
192
  } catch (e) {
@@ -169,233 +205,236 @@ export function TrackingView({ deps }: { deps: TrackingDeps }) {
169
205
  </p>
170
206
 
171
207
  <div className="mt-8 space-y-8">
172
- {drafts.map((p, i) => (
173
- <section key={p.id || `new-${i}`} className="border border-ink/10 bg-white">
174
- <header className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 px-5 py-4">
175
- <div className="flex min-w-0 flex-wrap items-center gap-3">
176
- <input
177
- value={p.name}
178
- onChange={(e) => patch(i, { name: e.target.value })}
179
- className="border border-transparent px-1 py-1 font-serif text-lg text-ink hover:border-ink/15 focus:border-sea focus:outline-none"
180
- />
181
- <label className="inline-flex items-center gap-2 text-sm text-ink">
208
+ {rows.map((row, i) => {
209
+ const p = row.value;
210
+ return (
211
+ <section key={row.key} className="border border-ink/10 bg-white">
212
+ <header className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 px-5 py-4">
213
+ <div className="flex min-w-0 flex-wrap items-center gap-3">
182
214
  <input
183
- type="checkbox"
184
- checked={p.enabled}
185
- onChange={(e) => patch(i, { enabled: e.target.checked })}
215
+ value={p.name}
216
+ onChange={(e) => patch(i, { name: e.target.value })}
217
+ className="border border-transparent px-1 py-1 font-serif text-lg text-ink hover:border-ink/15 focus:border-sea focus:outline-none"
186
218
  />
187
- Enabled
188
- </label>
189
- </div>
190
- <div className="flex items-center gap-2">
191
- <button
192
- onClick={() =>
193
- setDrafts((d) => [
194
- ...d,
195
- { ...p, id: "", name: `${p.name} (copy)`, enabled: false },
196
- ])
197
- }
198
- className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
199
- >
200
- <Copy className="size-3.5" /> Duplicate
201
- </button>
202
- <button
203
- onClick={() => onDelete(p, i)}
204
- className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
205
- >
206
- <Trash2 className="size-3.5" /> Delete
207
- </button>
208
- <button
209
- onClick={() => onSave(p)}
210
- disabled={busy !== null}
211
- className="inline-flex items-center gap-2 bg-ink px-4 py-1.5 text-sm text-white disabled:opacity-40"
212
- >
213
- <Save className="size-3.5" /> Save
214
- </button>
215
- </div>
216
- </header>
217
-
218
- <div className="grid gap-6 p-5 lg:grid-cols-2">
219
- <div className="space-y-4">
220
- <div className="grid gap-3 sm:grid-cols-2">
221
- <label className="block">
222
- <Label>Applies to</Label>
223
- <select
224
- value={p.scope_type}
225
- onChange={(e) =>
226
- patch(i, {
227
- scope_type: e.target.value as InjectionScope,
228
- scope_value: "",
229
- })
230
- }
231
- className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
232
- >
233
- <option value="global">All sites</option>
234
- <option value="locale">One language</option>
235
- <option value="domain">One domain</option>
236
- </select>
219
+ <label className="inline-flex items-center gap-2 text-sm text-ink">
220
+ <input
221
+ type="checkbox"
222
+ checked={p.enabled}
223
+ onChange={(e) => patch(i, { enabled: e.target.checked })}
224
+ />
225
+ Enabled
237
226
  </label>
238
- {p.scope_type === "locale" ? (
227
+ </div>
228
+ <div className="flex items-center gap-2">
229
+ <button
230
+ onClick={() =>
231
+ setRows((d) => [
232
+ ...d,
233
+ newRow({ ...p, id: "", name: `${p.name} (copy)`, enabled: false }),
234
+ ])
235
+ }
236
+ className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
237
+ >
238
+ <Copy className="size-3.5" /> Duplicate
239
+ </button>
240
+ <button
241
+ onClick={() => onDelete(row)}
242
+ className="inline-flex items-center gap-1 border border-ink/20 px-3 py-1.5 text-sm text-red-600 hover:border-red-400"
243
+ >
244
+ <Trash2 className="size-3.5" /> Delete
245
+ </button>
246
+ <button
247
+ onClick={() => onSave(row)}
248
+ disabled={busy !== null}
249
+ className="inline-flex items-center gap-2 bg-ink px-4 py-1.5 text-sm text-white disabled:opacity-40"
250
+ >
251
+ <Save className="size-3.5" /> Save
252
+ </button>
253
+ </div>
254
+ </header>
255
+
256
+ <div className="grid gap-6 p-5 lg:grid-cols-2">
257
+ <div className="space-y-4">
258
+ <div className="grid gap-3 sm:grid-cols-2">
239
259
  <label className="block">
240
- <Label>Language</Label>
260
+ <Label>Applies to</Label>
241
261
  <select
242
- value={p.scope_value}
243
- onChange={(e) => patch(i, { scope_value: e.target.value })}
262
+ value={p.scope_type}
263
+ onChange={(e) =>
264
+ patch(i, {
265
+ scope_type: e.target.value as InjectionScope,
266
+ scope_value: "",
267
+ })
268
+ }
244
269
  className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
245
270
  >
246
- <option value="">Select…</option>
247
- {locales.map((l) => (
248
- <option key={l} value={l}>
249
- {l.toUpperCase()}
250
- </option>
251
- ))}
271
+ <option value="global">All sites</option>
272
+ <option value="locale">One language</option>
273
+ <option value="domain">One domain</option>
252
274
  </select>
253
275
  </label>
254
- ) : null}
255
- {p.scope_type === "domain" ? (
276
+ {p.scope_type === "locale" ? (
277
+ <label className="block">
278
+ <Label>Language</Label>
279
+ <select
280
+ value={p.scope_value}
281
+ onChange={(e) => patch(i, { scope_value: e.target.value })}
282
+ className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
283
+ >
284
+ <option value="">Select…</option>
285
+ {locales.map((l) => (
286
+ <option key={l} value={l}>
287
+ {l.toUpperCase()}
288
+ </option>
289
+ ))}
290
+ </select>
291
+ </label>
292
+ ) : null}
293
+ {p.scope_type === "domain" ? (
294
+ <label className="block">
295
+ <Label>Domain</Label>
296
+ <input
297
+ value={p.scope_value}
298
+ onChange={(e) => patch(i, { scope_value: e.target.value })}
299
+ list={`domains-${i}`}
300
+ placeholder="dev.voyara.se"
301
+ className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
302
+ />
303
+ <datalist id={`domains-${i}`}>
304
+ {domains.map((d) => (
305
+ <option key={d.domain} value={d.domain} />
306
+ ))}
307
+ </datalist>
308
+ </label>
309
+ ) : null}
310
+ </div>
311
+
312
+ <div className="grid gap-3 sm:grid-cols-2">
313
+ <label className="block">
314
+ <Label>Google Tag Manager ID</Label>
315
+ <Input
316
+ value={p.gtm_id}
317
+ onChange={(v) => patch(i, { gtm_id: v })}
318
+ placeholder="GTM-XXXXXXX"
319
+ />
320
+ </label>
256
321
  <label className="block">
257
- <Label>Domain</Label>
258
- <input
259
- value={p.scope_value}
260
- onChange={(e) => patch(i, { scope_value: e.target.value })}
261
- list={`domains-${i}`}
262
- placeholder="dev.voyara.se"
263
- className="w-full border border-ink/20 px-3 py-2 text-sm text-ink focus:outline-none focus:border-sea"
322
+ <Label>Google Analytics 4 ID</Label>
323
+ <Input
324
+ value={p.ga4_id}
325
+ onChange={(v) => patch(i, { ga4_id: v })}
326
+ placeholder="G-XXXXXXXXXX"
327
+ />
328
+ </label>
329
+ <label className="block">
330
+ <Label>Meta (Facebook / Instagram) Pixel</Label>
331
+ <Input
332
+ value={p.meta_pixel_id}
333
+ onChange={(v) => patch(i, { meta_pixel_id: v })}
334
+ placeholder="1234567890"
335
+ />
336
+ </label>
337
+ <label className="block">
338
+ <Label>TikTok Pixel</Label>
339
+ <Input
340
+ value={p.tiktok_pixel_id}
341
+ onChange={(v) => patch(i, { tiktok_pixel_id: v })}
342
+ placeholder="CXXXXXXXXXXXXXXXX"
264
343
  />
265
- <datalist id={`domains-${i}`}>
266
- {domains.map((d) => (
267
- <option key={d.domain} value={d.domain} />
268
- ))}
269
- </datalist>
270
344
  </label>
271
- ) : null}
345
+ </div>
346
+
347
+ <div>
348
+ <Label>Extra meta tags (verification codes)</Label>
349
+ <div className="space-y-2">
350
+ {p.extra_meta.map((m, mi) => (
351
+ <div key={mi} className="flex gap-2">
352
+ <input
353
+ value={m.name}
354
+ onChange={(e) =>
355
+ patch(i, {
356
+ extra_meta: p.extra_meta.map((x, xi) =>
357
+ xi === mi ? { ...x, name: e.target.value } : x,
358
+ ),
359
+ })
360
+ }
361
+ placeholder="google-site-verification"
362
+ className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
363
+ />
364
+ <input
365
+ value={m.content}
366
+ onChange={(e) =>
367
+ patch(i, {
368
+ extra_meta: p.extra_meta.map((x, xi) =>
369
+ xi === mi ? { ...x, content: e.target.value } : x,
370
+ ),
371
+ })
372
+ }
373
+ placeholder="value"
374
+ className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
375
+ />
376
+ <button
377
+ onClick={() =>
378
+ patch(i, {
379
+ extra_meta: p.extra_meta.filter((_, xi) => xi !== mi),
380
+ })
381
+ }
382
+ className="p-2 text-red-500 hover:text-red-700"
383
+ >
384
+ <Trash2 className="size-3.5" />
385
+ </button>
386
+ </div>
387
+ ))}
388
+ <button
389
+ onClick={() =>
390
+ patch(i, { extra_meta: [...p.extra_meta, { name: "", content: "" }] })
391
+ }
392
+ className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
393
+ >
394
+ <Plus className="size-4" /> Add meta tag
395
+ </button>
396
+ </div>
397
+ </div>
272
398
  </div>
273
399
 
274
- <div className="grid gap-3 sm:grid-cols-2">
400
+ <div className="space-y-4">
275
401
  <label className="block">
276
- <Label>Google Tag Manager ID</Label>
277
- <Input
278
- value={p.gtm_id}
279
- onChange={(v) => patch(i, { gtm_id: v })}
280
- placeholder="GTM-XXXXXXX"
402
+ <Label>Inside &lt;head&gt;</Label>
403
+ <Code
404
+ value={p.head_html}
405
+ onChange={(v) => patch(i, { head_html: v })}
406
+ placeholder="<!-- scripts, meta tags, verification -->"
281
407
  />
282
408
  </label>
283
409
  <label className="block">
284
- <Label>Google Analytics 4 ID</Label>
285
- <Input
286
- value={p.ga4_id}
287
- onChange={(v) => patch(i, { ga4_id: v })}
288
- placeholder="G-XXXXXXXXXX"
410
+ <Label>After &lt;body&gt; tag</Label>
411
+ <Code
412
+ value={p.body_start_html}
413
+ onChange={(v) => patch(i, { body_start_html: v })}
414
+ placeholder="<!-- noscript pixels, chat widgets -->"
289
415
  />
290
416
  </label>
291
417
  <label className="block">
292
- <Label>Meta (Facebook / Instagram) Pixel</Label>
293
- <Input
294
- value={p.meta_pixel_id}
295
- onChange={(v) => patch(i, { meta_pixel_id: v })}
296
- placeholder="1234567890"
418
+ <Label>Before &lt;/body&gt; tag (footer)</Label>
419
+ <Code
420
+ value={p.body_end_html}
421
+ onChange={(v) => patch(i, { body_end_html: v })}
422
+ placeholder="<!-- deferred scripts -->"
297
423
  />
298
424
  </label>
299
- <label className="block">
300
- <Label>TikTok Pixel</Label>
301
- <Input
302
- value={p.tiktok_pixel_id}
303
- onChange={(v) => patch(i, { tiktok_pixel_id: v })}
304
- placeholder="CXXXXXXXXXXXXXXXX"
305
- />
306
- </label>
307
- </div>
308
-
309
- <div>
310
- <Label>Extra meta tags (verification codes)</Label>
311
- <div className="space-y-2">
312
- {p.extra_meta.map((m, mi) => (
313
- <div key={mi} className="flex gap-2">
314
- <input
315
- value={m.name}
316
- onChange={(e) =>
317
- patch(i, {
318
- extra_meta: p.extra_meta.map((x, xi) =>
319
- xi === mi ? { ...x, name: e.target.value } : x,
320
- ),
321
- })
322
- }
323
- placeholder="google-site-verification"
324
- className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
325
- />
326
- <input
327
- value={m.content}
328
- onChange={(e) =>
329
- patch(i, {
330
- extra_meta: p.extra_meta.map((x, xi) =>
331
- xi === mi ? { ...x, content: e.target.value } : x,
332
- ),
333
- })
334
- }
335
- placeholder="value"
336
- className="w-1/2 border border-ink/20 px-3 py-2 text-sm focus:outline-none focus:border-sea"
337
- />
338
- <button
339
- onClick={() =>
340
- patch(i, {
341
- extra_meta: p.extra_meta.filter((_, xi) => xi !== mi),
342
- })
343
- }
344
- className="p-2 text-red-500 hover:text-red-700"
345
- >
346
- <Trash2 className="size-3.5" />
347
- </button>
348
- </div>
349
- ))}
350
- <button
351
- onClick={() =>
352
- patch(i, { extra_meta: [...p.extra_meta, { name: "", content: "" }] })
353
- }
354
- className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm text-ink hover:border-sea"
355
- >
356
- <Plus className="size-4" /> Add meta tag
357
- </button>
358
- </div>
425
+ <p className="text-xs text-ink/50">
426
+ Tracking IDs above generate their official snippets automatically — you only need
427
+ raw code for anything not covered.
428
+ </p>
359
429
  </div>
360
430
  </div>
361
-
362
- <div className="space-y-4">
363
- <label className="block">
364
- <Label>Inside &lt;head&gt;</Label>
365
- <Code
366
- value={p.head_html}
367
- onChange={(v) => patch(i, { head_html: v })}
368
- placeholder="<!-- scripts, meta tags, verification -->"
369
- />
370
- </label>
371
- <label className="block">
372
- <Label>After &lt;body&gt; tag</Label>
373
- <Code
374
- value={p.body_start_html}
375
- onChange={(v) => patch(i, { body_start_html: v })}
376
- placeholder="<!-- noscript pixels, chat widgets -->"
377
- />
378
- </label>
379
- <label className="block">
380
- <Label>Before &lt;/body&gt; tag (footer)</Label>
381
- <Code
382
- value={p.body_end_html}
383
- onChange={(v) => patch(i, { body_end_html: v })}
384
- placeholder="<!-- deferred scripts -->"
385
- />
386
- </label>
387
- <p className="text-xs text-ink/50">
388
- Tracking IDs above generate their official snippets automatically — you only need
389
- raw code for anything not covered.
390
- </p>
391
- </div>
392
- </div>
393
- </section>
394
- ))}
431
+ </section>
432
+ );
433
+ })}
395
434
  </div>
396
435
 
397
436
  <button
398
- onClick={() => setDrafts((d) => [...d, blankProfile()])}
437
+ onClick={() => setRows((d) => [...d, newRow(blankProfile())])}
399
438
  className="mt-8 inline-flex items-center gap-2 border border-ink/20 px-5 py-2.5 text-sm text-ink hover:border-sea"
400
439
  >
401
440
  <Plus className="size-4" /> New profile
@@ -31,14 +31,16 @@ export type FormVersionRow = {
31
31
 
32
32
  export function VersionHistory({
33
33
  formKey,
34
+ editSessionId,
34
35
  listVersions,
35
36
  restoreVersion,
36
37
  renderPreview,
37
38
  }: {
38
39
  formKey: string;
40
+ editSessionId?: string;
39
41
  listVersions: (input: { data: { formKey: string } }) => Promise<FormVersionRow[]>;
40
42
  restoreVersion: (input: {
41
- data: { versionId: string };
43
+ data: { versionId: string; editSessionId?: string };
42
44
  }) => Promise<{ carriedFields: string[] }>;
43
45
  /** Host renders the preview so the platform stays free of site form UI. */
44
46
  renderPreview: (version: FormVersionRow) => React.ReactNode;
@@ -60,7 +62,7 @@ export function VersionHistory({
60
62
  if (!window.confirm(`Roll this form back to version ${no}? Submissions are untouched.`)) return;
61
63
  setBusy(true);
62
64
  try {
63
- const res = await restore({ data: { versionId: id } });
65
+ const res = await restore({ data: { versionId: id, editSessionId } });
64
66
  await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
65
67
  await qc.invalidateQueries({ queryKey: ["form-versions", formKey] });
66
68
  toast.success(
@@ -60,11 +60,20 @@ import type {
60
60
  } from "../../blocks/types";
61
61
  import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
62
62
  import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
63
- import { InspectorSection, LocalizedField, TextField, MediaField, MediaMultiField } from "./fields";
63
+ import {
64
+ CtaHint,
65
+ InspectorSection,
66
+ LocalizedField,
67
+ TextField,
68
+ MediaField,
69
+ MediaMultiField,
70
+ } from "./fields";
64
71
  import { RichTextEditor } from "../../host";
72
+ import { useEditLockBlocked } from "../EditLock";
65
73
  import { Plus, Trash2, ArrowUp, ArrowDown } from "lucide-react";
66
74
  import type { LocalizedString, Locale } from "../../i18n";
67
75
  import { SUPPORTED_LOCALES } from "../../i18n";
76
+ import { normalizeRevealMode, type RevealMode } from "../../ui/Reveal";
68
77
 
69
78
  export type BlockPatchProps = {
70
79
  block: BlockInstance;
@@ -684,6 +693,7 @@ function ProseInspector({ block, patch }: BlockPatchProps) {
684
693
  function RichTextInspector({ block, patch }: BlockPatchProps) {
685
694
  const p = (block.props ?? {}) as RichTextProps;
686
695
  const update = (next: RichTextProps) => patch(next as unknown as BlockProps);
696
+ const blocked = useEditLockBlocked();
687
697
  const [loc, setLoc] = useState<Locale>(SUPPORTED_LOCALES[0]);
688
698
  const html = ((p.html ?? {}) as Record<string, string>)[loc] ?? "";
689
699
 
@@ -735,6 +745,7 @@ function RichTextInspector({ block, patch }: BlockPatchProps) {
735
745
  <RichTextEditor
736
746
  key={loc}
737
747
  value={html}
748
+ readOnly={blocked}
738
749
  onChange={(next) =>
739
750
  update({
740
751
  ...p,
@@ -771,6 +782,14 @@ function RichTextInspector({ block, patch }: BlockPatchProps) {
771
782
  { value: "1", label: "One column" },
772
783
  { value: "2", label: "Two columns (desktop)" },
773
784
  ], (v) => update({ ...p, columns: Number(v) as 1 | 2 }))}
785
+ {select("Appearance", normalizeRevealMode(p.reveal), [
786
+ { value: "immediate", label: "Immediate" },
787
+ { value: "fade", label: "Fade in" },
788
+ ] as { value: RevealMode; label: string }[], (v) => update({ ...p, reveal: v }))}
789
+ <p className="text-[0.7rem] text-ink/50">
790
+ Fade in reveals the block once, as it scrolls into view. Readers who ask their
791
+ system for reduced motion always see it immediately.
792
+ </p>
774
793
  <label className="flex items-center gap-2 text-sm text-ink">
775
794
  <input
776
795
  type="checkbox"
@@ -848,6 +867,7 @@ function HeroFullInspector({ block, patch }: BlockPatchProps) {
848
867
  onChange={(v) => update({ ...p, primaryCta: { ...p.primaryCta, href: v } })}
849
868
  placeholder="/boka"
850
869
  />
870
+ <CtaHint label={p.primaryCta?.label} href={p.primaryCta?.href} />
851
871
  <LocalizedField
852
872
  label="Secondary label"
853
873
  value={p.secondaryCta?.label}
@@ -858,6 +878,7 @@ function HeroFullInspector({ block, patch }: BlockPatchProps) {
858
878
  value={p.secondaryCta?.href}
859
879
  onChange={(v) => update({ ...p, secondaryCta: { ...p.secondaryCta, href: v } })}
860
880
  />
881
+ <CtaHint label={p.secondaryCta?.label} href={p.secondaryCta?.href} />
861
882
  </InspectorSection>
862
883
 
863
884
  <InspectorSection title="Background media">
@@ -4,6 +4,7 @@ import { toast } from "sonner";
4
4
  import { X, Image as ImageIcon, Upload, Loader2 } from "lucide-react";
5
5
 
6
6
  import type { LocalizedString } from "../../i18n";
7
+ import { hasAnyText } from "../../blocks/optional-content";
7
8
  import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n";
8
9
 
9
10
 
@@ -17,6 +18,26 @@ import {
17
18
 
18
19
  type LocValue = Partial<LocalizedString> | undefined;
19
20
 
21
+ /**
22
+ * Explains, next to a pair of call-to-action fields, why a half-filled button
23
+ * does not appear on the page.
24
+ *
25
+ * The renderer requires both a label and a link — half of one is not a button
26
+ * — and an editor who has typed only a link has no other way to find that out.
27
+ */
28
+ export function CtaHint({ label, href }: { label: LocValue; href?: string | null }) {
29
+ const hasLabel = hasAnyText(label);
30
+ const hasHref = typeof href === "string" && href.trim() !== "";
31
+ const missing = hasLabel && !hasHref ? "link" : hasHref && !hasLabel ? "label" : null;
32
+ return (
33
+ <p className={`text-[0.7rem] ${missing ? "text-amber-700" : "text-ink/50"}`}>
34
+ {missing
35
+ ? `Hidden on the page — add a ${missing} to show this button.`
36
+ : "A button appears only when it has both a label and a link."}
37
+ </p>
38
+ );
39
+ }
40
+
20
41
  export function LocalizedField({
21
42
  label,
22
43
  value,