@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,7 @@
9
9
  import type { ReactNode } from "react";
10
10
 
11
11
  import type { SiteConfig } from "../../config/site-config";
12
+ import type { SecretsLocation } from "../../server/provider-secrets";
12
13
 
13
14
  /* eslint-disable @typescript-eslint/no-explicit-any */
14
15
  /** Media field component as the reviews editor consumes it. */
@@ -34,6 +35,8 @@ export type BackofficeHost = {
34
35
  company: Record<string, string>;
35
36
  /** Locale used to preview form copy in the backoffice. */
36
37
  previewLocale?: string;
38
+ /** Where this installation configures provider and system-mail secrets. */
39
+ secretsLocation?: SecretsLocation;
37
40
  /** Server functions, grouped per module. */
38
41
  fns: {
39
42
  // admin
@@ -78,6 +81,31 @@ export type BackofficeHost = {
78
81
  // locale domains
79
82
  saveLocaleDomain: HostServerFn;
80
83
  deleteLocaleDomain: HostServerFn;
84
+ /*
85
+ * Which hostname actually serves the backoffice, and why.
86
+ *
87
+ * `SITE_URL` is server information: it cannot be read from the browser,
88
+ * and a value baked into this registry would be a guess presented to an
89
+ * administrator as fact about their own install. So the answer is fetched
90
+ * from the server instead, and returns the package's `EffectiveAdminHost`.
91
+ *
92
+ * Optional: a host that has not registered it simply gets no statement
93
+ * about the backoffice hostname on the domains screen. The editor works
94
+ * either way — this only ever adds an explanation, never a permission.
95
+ */
96
+ getEffectiveAdminHost?: HostServerFn;
97
+ /*
98
+ * Popup opt-in destinations, for the popup editor's picker.
99
+ *
100
+ * Optional: a site with no delivery integration simply does not register
101
+ * it, and the popup editor then shows no picker at all rather than an
102
+ * empty control. Returns names and ids — never an endpoint or a secret.
103
+ */
104
+ listWebhookDestinations?: HostServerFn;
105
+ saveWebhookDestination?: HostServerFn;
106
+ deleteWebhookDestination?: HostServerFn;
107
+ sendTestWebhook?: HostServerFn;
108
+ listWebhookDeliveries?: HostServerFn;
81
109
  };
82
110
  /** Site-specific example values shown as input placeholders. */
83
111
  placeholders?: {
@@ -105,7 +133,10 @@ export type BackofficeHost = {
105
133
  /** Editor components a site wires with its own locales, media and forms. */
106
134
  components: {
107
135
  /** Recursive shared-content editor. */
108
- SectionForm: (props: { value: unknown; onChange: (next: unknown) => void }) => ReactNode;
136
+ SectionForm: (props: {
137
+ value: unknown;
138
+ onChange: (next: unknown) => void;
139
+ }) => ReactNode;
109
140
  /** Single media field used by the reviews editor. */
110
141
  MediaField: MediaFieldComponent;
111
142
  /** Review portrait uploader. */
@@ -193,6 +224,15 @@ export type BackofficeHost = {
193
224
  listPagePublications: HostServerFn;
194
225
  cancelPagePublication: HostServerFn;
195
226
  renamePage: HostServerFn;
227
+ /**
228
+ * The site's pages, listed.
229
+ *
230
+ * Optional because the builder screens do not need it — they open one
231
+ * page by key. What needs it is anything offering a choice of pages,
232
+ * and the popup page picker is the first: without this the picker has
233
+ * nothing to show and falls back to typing addresses, silently.
234
+ */
235
+ listPages?: HostServerFn;
196
236
  };
197
237
  /** Human labels for the locale switch, keyed by locale. */
198
238
  localeLabels?: Record<string, string>;
@@ -200,7 +240,10 @@ export type BackofficeHost = {
200
240
  * React hook returning a resolver for a page's public URL. Implemented as a
201
241
  * hook so a site can read its slug overrides and locale domains.
202
242
  */
203
- usePageUrl: () => (pageKey: string, locale: string) => { path: string; host: string };
243
+ usePageUrl: () => (
244
+ pageKey: string,
245
+ locale: string,
246
+ ) => { path: string; host: string };
204
247
  /** Persists unsaved shared-content drafts; returns the keys it saved. */
205
248
  flushSharedDrafts?: () => Promise<string[]>;
206
249
  /** Renders the block canvas for the builder. */
@@ -211,7 +254,12 @@ export type BackofficeHost = {
211
254
  blocks: any[];
212
255
  setBlocks: (next: any[]) => void;
213
256
  globals: Record<string, any[]>;
214
- globalSections: { key: string; name: string; kind: "block" | "section"; blocks: any[] }[];
257
+ globalSections: {
258
+ key: string;
259
+ name: string;
260
+ kind: "block" | "section";
261
+ blocks: any[];
262
+ }[];
215
263
  locale: string;
216
264
  setLocale: (next: string) => void;
217
265
  setDirty: (next: boolean) => void;
@@ -248,7 +296,6 @@ export type BackofficeHost = {
248
296
  /* eslint-enable @typescript-eslint/no-explicit-any */
249
297
  };
250
298
 
251
-
252
299
  let current: BackofficeHost | null = null;
253
300
 
254
301
  /** Registers the host adapters. Call once, from a module the routes import. */
@@ -283,6 +330,7 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
283
330
  email: `${base}/admin/email`,
284
331
  contact: `${base}/admin/contact`,
285
332
  tracking: `${base}/admin/tracking`,
333
+ domains: `${base}/admin/domains`,
286
334
  redirects: `${base}/admin/redirects`,
287
335
  sitemap: `${base}/admin/sitemap`,
288
336
  navigation: `${base}/admin/navigation`,
@@ -295,13 +343,16 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
295
343
  formDeliveries: `${base}/forms/deliveries`,
296
344
  pagesBuilder: `${base}/pages-builder`,
297
345
  resetPassword: `${base}/reset-password`,
346
+ popups: `${base}/popups`,
347
+ popupDestinations: `${base}/popups/destinations`,
298
348
  };
299
349
  }
300
350
 
301
351
  /** Reads a required optional host group, failing loudly when it is missing. */
302
- export function requireHostGroup<K extends "media" | "forms" | "pages" | "sections" | "dashboard" | "authAdapters">(
303
- key: K,
304
- ): NonNullable<BackofficeHost[K]> {
352
+ export function requireHostGroup<
353
+ K extends
354
+ "media" | "forms" | "pages" | "sections" | "dashboard" | "authAdapters",
355
+ >(key: K): NonNullable<BackofficeHost[K]> {
305
356
  const group = backofficeHost()[key];
306
357
  if (!group) {
307
358
  throw new Error(
@@ -18,6 +18,7 @@ import {
18
18
  Menu,
19
19
  Package,
20
20
  ScrollText,
21
+ Languages,
21
22
  Shield,
22
23
  Signpost,
23
24
  SlidersHorizontal,
@@ -25,8 +26,10 @@ import {
25
26
  Users,
26
27
  } from "lucide-react";
27
28
  import { useQuery, useQueryClient } from "@tanstack/react-query";
29
+ import { useCallback, useRef } from "react";
28
30
  import { useServerFn } from "@tanstack/react-start";
29
31
 
32
+ import { MISSING_CREATED_ID } from "../../tracking/injections";
30
33
  import type { SiteSettingOverrides } from "../../config/site-config";
31
34
  import { AuditView } from "../AuditView";
32
35
  import { BackupView } from "../BackupView";
@@ -42,10 +45,16 @@ import { RedirectsView } from "../RedirectsView";
42
45
  import { RolesView } from "../RolesView";
43
46
  import { SitemapView, type SitemapCheck } from "../SitemapView";
44
47
  import { SiteSettingsView } from "../SiteSettingsView";
45
- import { TrackingView } from "../TrackingView";
48
+ import { TrackingView, type TrackingDeps } from "../TrackingView";
46
49
  import { UsersView } from "../UsersView";
47
50
  import { VersionView } from "../VersionView";
48
- import { useFeatureGate } from "../shell/use-resolved-config";
51
+ import { parseEffectiveAdminHost } from "../../site/locale-domains";
52
+ import {
53
+ useFeatureEnabled,
54
+ useFeatureGate,
55
+ useModuleGate,
56
+ useMyRoles,
57
+ } from "../shell/use-resolved-config";
49
58
  import { backofficeHost, backofficePaths } from "./host";
50
59
 
51
60
  export * from "./host";
@@ -200,13 +209,54 @@ function TrackingRouteInner() {
200
209
  const q = useQuery({ queryKey: ["admin", "injections"], queryFn: () => list() });
201
210
  const domainsQ = useQuery({ queryKey: ["admin", "locale-domains"], queryFn: () => domains() });
202
211
 
212
+ /*
213
+ * A create whose answer carried no id, if one has happened.
214
+ *
215
+ * The insert may well have succeeded — what is missing is the identity of
216
+ * the row it wrote, not evidence that it wrote none. Sending the same draft
217
+ * again would therefore be as likely to create a second profile as to retry
218
+ * the first, and the screen has no way to tell which. So no further create
219
+ * is attempted at all until a reload has reconciled the list with what the
220
+ * server actually holds. Updates carry their own id and are unaffected.
221
+ */
222
+ const createUnresolved = useRef(false);
223
+
224
+ const saveInjection = useCallback<TrackingDeps["saveInjection"]>(
225
+ async (input) => {
226
+ const isCreate = !input.data.id;
227
+ if (isCreate && createUnresolved.current) throw new Error(MISSING_CREATED_ID);
228
+ let result: { id?: unknown } | null | undefined;
229
+ try {
230
+ result = (await save(input as never)) as { id?: unknown } | null | undefined;
231
+ } catch (err) {
232
+ /*
233
+ * The server refuses an insert it cannot identify, and that refusal is
234
+ * indeterminate in exactly the way the check below is: the row may have
235
+ * landed. Any other failure is a plain no, so only this one closes
236
+ * creates — a rejected create is otherwise still worth another try.
237
+ */
238
+ if (isCreate && err instanceof Error && err.message === MISSING_CREATED_ID) {
239
+ createUnresolved.current = true;
240
+ }
241
+ throw err;
242
+ }
243
+ const id = typeof result?.id === "string" ? result.id.trim() : "";
244
+ if (isCreate && !id) {
245
+ createUnresolved.current = true;
246
+ throw new Error(MISSING_CREATED_ID);
247
+ }
248
+ return { id };
249
+ },
250
+ [save],
251
+ );
252
+
203
253
  return (
204
254
  <TrackingView
205
255
  deps={{
206
256
  profiles: q.data ?? [],
207
257
  domains: domainsQ.data ?? [],
208
258
  locales: locales as never,
209
- saveInjection: (input) => save(input as never),
259
+ saveInjection,
210
260
  deleteInjection: (input) => remove(input),
211
261
  refresh: () => q.refetch(),
212
262
  }}
@@ -308,9 +358,20 @@ export function AdminLayoutRoute() {
308
358
  export function AdminOverviewRoute() {
309
359
  const host = backofficeHost();
310
360
  const paths = backofficePaths(host);
311
- const fetchRoles = useServerFn(host.fns.getMyRoles);
312
- const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
313
- const isSuper = ((rolesQ.data ?? []) as string[]).includes("superadmin");
361
+ const { roles } = useMyRoles();
362
+ const isSuper = roles.includes("superadmin");
363
+ // Both screens write through server functions that ask `has_role(user,
364
+ // 'admin')`, so a card offered to anyone else is an invitation to a screen
365
+ // whose every save comes back refused. The role is read from the list the
366
+ // host returns; holding `superadmin` is not the same grant.
367
+ const isAdmin = roles.includes("admin");
368
+ // The live flags, not the compiled ones. Both route gates already read the
369
+ // live value, so a card driven by the compiled default disagrees with the
370
+ // screen behind it: an override that switched a module on leaves its card
371
+ // missing, and one that switched a module off leaves a card that opens
372
+ // straight into a no-access screen.
373
+ const domainsEnabled = useFeatureEnabled("domains") === true;
374
+ const trackingEnabled = useFeatureEnabled("tracking") === true;
314
375
 
315
376
  const cards = [
316
377
  {
@@ -360,7 +421,7 @@ export function AdminOverviewRoute() {
360
421
  body: "Company address, org. number, email and phone.",
361
422
  icon: Building2,
362
423
  },
363
- ...(host.siteConfig.features.tracking
424
+ ...(isAdmin && trackingEnabled
364
425
  ? [
365
426
  {
366
427
  to: paths.tracking,
@@ -370,6 +431,16 @@ export function AdminOverviewRoute() {
370
431
  },
371
432
  ]
372
433
  : []),
434
+ ...(isAdmin && domainsEnabled
435
+ ? [
436
+ {
437
+ to: paths.domains,
438
+ title: "Languages & domains",
439
+ body: "One domain per language, the default fallback and the hostname the backoffice is served from.",
440
+ icon: Languages,
441
+ },
442
+ ]
443
+ : []),
373
444
  {
374
445
  to: paths.redirects,
375
446
  title: "Redirects",
@@ -532,6 +603,7 @@ function EmailSettingsRouteInner() {
532
603
  fromEmail: host.placeholders?.fromEmail ?? "",
533
604
  notifyTo: host.placeholders?.notifyTo ?? "",
534
605
  },
606
+ secretsLocation: host.secretsLocation,
535
607
  }}
536
608
  />
537
609
  );
@@ -559,6 +631,15 @@ export function NavigationRoute() {
559
631
  );
560
632
  }
561
633
 
634
+ /**
635
+ * Stand-in for the optional effective-admin-host function.
636
+ *
637
+ * `useServerFn` is a hook and cannot be called conditionally, so an
638
+ * unregistered function still needs something to hand it. This one is never
639
+ * invoked: the query that would call it stays disabled.
640
+ */
641
+ const noEffectiveAdminHost = async () => null;
642
+
562
643
  /** Per-locale domain mapping. */
563
644
  function DomainsRouteInner() {
564
645
  const host = backofficeHost();
@@ -567,19 +648,40 @@ function DomainsRouteInner() {
567
648
  const list = useServerFn(host.fns.getLocaleDomains);
568
649
  const save = useServerFn(host.fns.saveLocaleDomain);
569
650
  const del = useServerFn(host.fns.deleteLocaleDomain);
570
- const q = useQuery({ queryKey: ["locale-domains"], queryFn: () => list() });
651
+ const q = useQuery({ queryKey: ["admin", "locale-domains"], queryFn: () => list() });
652
+
653
+ // Where the backoffice is actually served is server knowledge — `SITE_URL`
654
+ // and the request that arrived. A host that has not registered the function
655
+ // gets no statement about it at all, which is the honest answer: the editor
656
+ // keeps working, it just stops explaining a hostname it cannot know.
657
+ const getAdminHost = useServerFn(host.fns.getEffectiveAdminHost ?? noEffectiveAdminHost);
658
+ const adminHostQuery = useQuery({
659
+ queryKey: ["admin", "effective-admin-host"],
660
+ queryFn: () => getAdminHost(),
661
+ enabled: Boolean(host.fns.getEffectiveAdminHost),
662
+ });
663
+
664
+ const domains = q.data ?? [];
571
665
 
572
666
  return (
573
667
  <DomainsView
574
668
  deps={{
575
- domains: q.data ?? [],
669
+ domains,
576
670
  locales: host.siteConfig.locales,
577
671
  defaultLocale: host.siteConfig.defaultLocale,
578
- backPath: paths.base,
672
+ backPath: paths.admin,
579
673
  domainPlaceholder: host.placeholders?.domain ?? "",
674
+ adminHost: parseEffectiveAdminHost(adminHostQuery.data) ?? undefined,
675
+ backofficePath: paths.base,
580
676
  saveDomain: (input) => save(input as never),
581
677
  deleteDomain: (input) => del(input as never),
582
- refresh: () => qc.invalidateQueries({ queryKey: ["locale-domains"] }),
678
+ // A saved or deleted mapping can move the backoffice itself, and the
679
+ // banner above the table states where that is. Refreshing only the
680
+ // table leaves it stating the hostname from before the change.
681
+ refresh: async () => {
682
+ await qc.invalidateQueries({ queryKey: ["admin", "locale-domains"] });
683
+ await qc.invalidateQueries({ queryKey: ["admin", "effective-admin-host"] });
684
+ },
583
685
  }}
584
686
  />
585
687
  );
@@ -658,16 +760,16 @@ export function ReviewsRoute() {
658
760
  return <ReviewsRouteInner />;
659
761
  }
660
762
 
661
- /** DomainsRoute guarded by the "domains" module switch. */
763
+ /** DomainsRoute guarded by the "domains" module switch and its admin role. */
662
764
  export function DomainsRoute() {
663
- const gate = useFeatureGate("domains", "Domains");
765
+ const gate = useModuleGate("domains", "Languages & domains");
664
766
  if (gate) return <>{gate}</>;
665
767
  return <DomainsRouteInner />;
666
768
  }
667
769
 
668
- /** TrackingRoute guarded by the "tracking" module switch. */
770
+ /** TrackingRoute guarded by the "tracking" module switch and its admin role. */
669
771
  export function TrackingRoute() {
670
- const gate = useFeatureGate("tracking", "Code & tracking");
772
+ const gate = useModuleGate("tracking", "Code & tracking");
671
773
  if (gate) return <>{gate}</>;
672
774
  return <TrackingRouteInner />;
673
775
  }
@@ -107,8 +107,10 @@ export function PageBuilderRoute() {
107
107
  publicHost: (locale) => resolveUrl(key, locale).host,
108
108
  versions: versionsQ.data as any,
109
109
  publications: pubsQ.data as any,
110
- save: async ({ pageId: id, blocks, publish, note }) => {
111
- const res = await save({ data: { pageId: id, blocks, publish, note } });
110
+ save: async ({ pageId: id, blocks, publish, note, editSessionId }) => {
111
+ const res = await save({
112
+ data: { pageId: id, blocks, publish, note, editSessionId },
113
+ });
112
114
  return res as { versionId: string };
113
115
  },
114
116
  flushSharedDrafts: pages.flushSharedDrafts
@@ -120,8 +122,10 @@ export function PageBuilderRoute() {
120
122
  }
121
123
  : undefined,
122
124
  schedule: (input) => schedule({ data: input }),
123
- cancelPublication: (publicationId) => cancelPub({ data: { publicationId } }),
124
- restoreVersion: (versionId) => restore({ data: { versionId } }),
125
+ cancelPublication: (publicationId, editSessionId) =>
126
+ cancelPub({ data: { publicationId, editSessionId } }),
127
+ restoreVersion: (versionId, editSessionId) =>
128
+ restore({ data: { versionId, editSessionId } }),
125
129
  rename: (input) => rename({ data: input }),
126
130
  refetchPage: () => void pageQ.refetch(),
127
131
  refetchVersions: () => void versionsQ.refetch(),
@@ -190,8 +194,8 @@ export function SectionEditorRoute() {
190
194
  meta,
191
195
  value,
192
196
  codeDefault,
193
- save: async (next) => {
194
- await save({ data: { key: sectionKey, data: next } });
197
+ save: async (next, editSessionId) => {
198
+ await save({ data: { key: sectionKey, data: next, editSessionId } });
195
199
  await queryClient.invalidateQueries({ queryKey: host.content.overridesQueryKey });
196
200
  },
197
201
  goToList: () => navigate({ to: paths.sections as any }),