@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0

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 (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. package/src/ui/MobileCarousel.tsx +289 -0
@@ -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