@actuate-media/cms-admin 0.37.0 → 0.37.2

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 (44) hide show
  1. package/dist/__tests__/layout/sidebar-brand.render.test.d.ts +2 -0
  2. package/dist/__tests__/layout/sidebar-brand.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/layout/sidebar-brand.render.test.js +44 -0
  4. package/dist/__tests__/layout/sidebar-brand.render.test.js.map +1 -0
  5. package/dist/__tests__/lib/page-editor-service.test.js +1 -0
  6. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  7. package/dist/__tests__/views/login-branding.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/login-branding.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/login-branding.render.test.js +80 -0
  10. package/dist/__tests__/views/login-branding.render.test.js.map +1 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js +6 -0
  12. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -1
  13. package/dist/actuate-admin.css +1 -1
  14. package/dist/components/LogoPlaceholder.d.ts +17 -0
  15. package/dist/components/LogoPlaceholder.d.ts.map +1 -0
  16. package/dist/components/LogoPlaceholder.js +13 -0
  17. package/dist/components/LogoPlaceholder.js.map +1 -0
  18. package/dist/components/forms/EmbedPanel.d.ts.map +1 -1
  19. package/dist/components/forms/EmbedPanel.js +8 -11
  20. package/dist/components/forms/EmbedPanel.js.map +1 -1
  21. package/dist/components/ui/Badge.d.ts +1 -1
  22. package/dist/components/ui/Button.d.ts +1 -1
  23. package/dist/components/ui/Card.d.ts +2 -2
  24. package/dist/components/ui/Input.d.ts +1 -1
  25. package/dist/components/ui/Select.d.ts +1 -1
  26. package/dist/layout/Sidebar.d.ts.map +1 -1
  27. package/dist/layout/Sidebar.js +13 -10
  28. package/dist/layout/Sidebar.js.map +1 -1
  29. package/dist/views/Login.d.ts.map +1 -1
  30. package/dist/views/Login.js +66 -8
  31. package/dist/views/Login.js.map +1 -1
  32. package/dist/views/page-editor/section-icons.d.ts.map +1 -1
  33. package/dist/views/page-editor/section-icons.js +2 -1
  34. package/dist/views/page-editor/section-icons.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/__tests__/layout/sidebar-brand.render.test.tsx +58 -0
  37. package/src/__tests__/lib/page-editor-service.test.ts +1 -0
  38. package/src/__tests__/views/login-branding.render.test.tsx +96 -0
  39. package/src/__tests__/views/login-mfa.render.test.tsx +7 -0
  40. package/src/components/LogoPlaceholder.tsx +55 -0
  41. package/src/components/forms/EmbedPanel.tsx +17 -14
  42. package/src/layout/Sidebar.tsx +14 -24
  43. package/src/views/Login.tsx +70 -9
  44. package/src/views/page-editor/section-icons.ts +2 -0
@@ -0,0 +1,96 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+
5
+ /**
6
+ * The login screen resolves its logo to match the admin sidebar:
7
+ * 1. the brand uploaded in Settings → Appearance (cached admin brand, then
8
+ * the unauthenticated `/public/brand` endpoint for fresh browsers),
9
+ * 2. the static `config.admin.branding.logo`,
10
+ * 3. the bundled Actuate wordmark,
11
+ * and `branding.logo === null` hides the logo entirely (whitelabel opt-out).
12
+ *
13
+ * `cmsApi` is mocked because the component fetches `/public/brand` on mount.
14
+ */
15
+ const cmsApiMock = vi.fn()
16
+ vi.mock('../../lib/api.js', () => ({
17
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
18
+ }))
19
+
20
+ import { Login } from '../../views/Login.js'
21
+
22
+ const onLogin = vi.fn(async () => ({ success: true }))
23
+
24
+ beforeEach(() => {
25
+ cmsApiMock.mockReset()
26
+ // Default: no public brand configured.
27
+ cmsApiMock.mockResolvedValue({ data: {}, status: 200 })
28
+ })
29
+
30
+ describe('Login — logo resolution', () => {
31
+ it('renders the bundled Actuate logo when nothing is branded', async () => {
32
+ render(<Login onLogin={onLogin} />)
33
+ // Bundled SVG exposes role="img" + aria-label="Actuate Media".
34
+ expect(screen.getByRole('img', { name: 'Actuate Media' })).toBeTruthy()
35
+ // No <img> logo element.
36
+ expect(screen.queryByRole('img', { name: 'Actuate CMS' })).toBeNull()
37
+ await waitFor(() => expect(cmsApiMock).toHaveBeenCalledWith('/public/brand'))
38
+ })
39
+
40
+ it('shows the neutral logo placeholder when branded but no logo is set', async () => {
41
+ render(<Login onLogin={onLogin} branding={{ name: 'Acme Co' }} />)
42
+ // Placeholder slot, not the bundled Actuate wordmark.
43
+ expect(screen.getByRole('img', { name: 'Logo placeholder' })).toBeTruthy()
44
+ expect(screen.queryByRole('img', { name: 'Actuate Media' })).toBeNull()
45
+ // The brand name still renders as the headline.
46
+ expect(screen.getByRole('heading', { name: 'Acme Co' })).toBeTruthy()
47
+ })
48
+
49
+ it('shows the placeholder for a fresh browser when /public/brand reports a branded site (initials) but no logo', async () => {
50
+ // No config name, no cached admin brand — only the public brand initials
51
+ // signal a branded install. The logo-less fallback must be the placeholder,
52
+ // not the Actuate wordmark.
53
+ cmsApiMock.mockResolvedValue({
54
+ data: { public: { primary: null, initials: 'AC' } },
55
+ status: 200,
56
+ })
57
+
58
+ render(<Login onLogin={onLogin} />)
59
+
60
+ await waitFor(() => {
61
+ expect(screen.getByRole('img', { name: 'Logo placeholder' })).toBeTruthy()
62
+ })
63
+ expect(screen.queryByRole('img', { name: 'Actuate Media' })).toBeNull()
64
+ })
65
+
66
+ it('uses the static config logo when no appearance brand exists', async () => {
67
+ render(
68
+ <Login onLogin={onLogin} branding={{ logo: 'https://cdn.example.com/config-logo.png' }} />,
69
+ )
70
+ const img = (await screen.findByAltText('Actuate CMS')) as HTMLImageElement
71
+ expect(img.getAttribute('src')).toBe('https://cdn.example.com/config-logo.png')
72
+ })
73
+
74
+ it('prefers the uploaded appearance logo from /public/brand over the config logo', async () => {
75
+ cmsApiMock.mockResolvedValue({
76
+ data: { public: { primary: { url: 'https://cdn.example.com/appearance-logo.svg' } } },
77
+ status: 200,
78
+ })
79
+
80
+ render(
81
+ <Login onLogin={onLogin} branding={{ logo: 'https://cdn.example.com/config-logo.png' }} />,
82
+ )
83
+
84
+ await waitFor(() => {
85
+ const img = screen.getByAltText('Actuate CMS') as HTMLImageElement
86
+ expect(img.getAttribute('src')).toBe('https://cdn.example.com/appearance-logo.svg')
87
+ })
88
+ })
89
+
90
+ it('hides the logo entirely when branding.logo is null (whitelabel) and skips the fetch', async () => {
91
+ render(<Login onLogin={onLogin} branding={{ logo: null }} />)
92
+ expect(screen.queryByRole('img', { name: 'Actuate Media' })).toBeNull()
93
+ expect(screen.queryByAltText('Actuate CMS')).toBeNull()
94
+ expect(cmsApiMock).not.toHaveBeenCalled()
95
+ })
96
+ })
@@ -2,6 +2,13 @@
2
2
  import { describe, expect, it, vi } from 'vitest'
3
3
  import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
4
 
5
+ // The login screen fetches `/public/brand` on mount to mirror the sidebar
6
+ // logo. These MFA tests don't care about branding — stub the network so the
7
+ // real `cmsApi` (which reads localStorage) doesn't run in this environment.
8
+ vi.mock('../../lib/api.js', () => ({
9
+ cmsApi: vi.fn(async () => ({ data: {}, status: 200 })),
10
+ }))
11
+
5
12
  import { Login } from '../../views/Login.js'
6
13
 
7
14
  /**
@@ -0,0 +1,55 @@
1
+ 'use client'
2
+
3
+ import { ImagePlus } from 'lucide-react'
4
+
5
+ /**
6
+ * Neutral "logo goes here" placeholder shown when no brand logo has been
7
+ * uploaded or configured. Deliberately reads as an empty slot — a dashed
8
+ * outline with a muted image icon — rather than a filled initials badge, which
9
+ * looks like a user avatar (the real account avatar already lives top-right).
10
+ *
11
+ * It is automatically superseded the moment a logo is set in Settings →
12
+ * Appearance, since every logo branch takes precedence over this fallback.
13
+ */
14
+ export interface LogoPlaceholderProps {
15
+ /** Color treatment so the slot blends with its surface. */
16
+ tone: 'sidebar' | 'login'
17
+ /** Brand/site name rendered beside the slot (expanded sidebar only). */
18
+ label?: string
19
+ }
20
+
21
+ export function LogoPlaceholder({ tone, label }: LogoPlaceholderProps) {
22
+ if (tone === 'login') {
23
+ return (
24
+ <span
25
+ role="img"
26
+ aria-label="Logo placeholder"
27
+ title="Add a logo in Settings → Appearance"
28
+ className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-lg border border-dashed border-gray-300 text-gray-400"
29
+ >
30
+ <ImagePlus className="h-6 w-6" aria-hidden="true" />
31
+ </span>
32
+ )
33
+ }
34
+
35
+ const slot = (
36
+ <span
37
+ className="border-sidebar-border text-sidebar-foreground/50 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-dashed"
38
+ role={label ? undefined : 'img'}
39
+ aria-hidden={label ? true : undefined}
40
+ aria-label={label ? undefined : 'Logo placeholder'}
41
+ title="Add a logo in Settings → Appearance"
42
+ >
43
+ <ImagePlus size={16} aria-hidden="true" />
44
+ </span>
45
+ )
46
+
47
+ if (!label) return slot
48
+
49
+ return (
50
+ <div className="flex min-w-0 items-center gap-2.5">
51
+ {slot}
52
+ <span className="text-sidebar-foreground truncate text-sm font-medium">{label}</span>
53
+ </div>
54
+ )
55
+ }
@@ -1,11 +1,10 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * Embed tab of the Form Schema drawer. Shows the public submission/schema
5
- * endpoints and copy-paste embed snippets, and lets the editor restrict which
6
- * origins may host the embedded form. Snippets are derived from the form slug;
7
- * the live public endpoints land in a later phase (PR 5) but the paths shown
8
- * here are the committed contract.
4
+ * Embed tab of the Form Schema drawer. Shows the live public schema/submission
5
+ * endpoints and a ready-to-paste `<ActuateForm>` snippet, and lets the editor
6
+ * restrict which origins may host the embedded form. Endpoints are derived from
7
+ * the form slug and match the public API mounted at `/api/cms`.
9
8
  */
10
9
  import { Check, Copy } from 'lucide-react'
11
10
  import { useEffect, useState } from 'react'
@@ -60,11 +59,9 @@ export function EmbedPanel({
60
59
  }, [formId])
61
60
 
62
61
  const origin = siteOrigin()
63
- const submitUrl = `${origin}/api/forms/${slug}/submit`
64
- const schemaUrl = `${origin}/api/forms/${slug}/schema`
65
- const embedUrl = `${origin}/forms/embed/${slug}`
66
- const iframeSnippet = `<iframe src="${embedUrl}" title="${slug}" style="width:100%;border:0;" loading="lazy"></iframe>`
67
- const scriptSnippet = `<div data-actuate-form="${slug}"></div>\n<script src="${origin}/api/forms/embed.js" async></script>`
62
+ const schemaUrl = `${origin}/api/cms/public/forms/${slug}`
63
+ const submitUrl = `${origin}/api/cms/public/forms/${slug}/submit`
64
+ const componentSnippet = `import { ActuateForm } from '@actuate-media/sections-react'\n\n<ActuateForm slug="${slug}" />`
68
65
 
69
66
  async function saveDomains() {
70
67
  setSaving(true)
@@ -94,10 +91,16 @@ export function EmbedPanel({
94
91
  </p>
95
92
  )}
96
93
 
97
- <CopyField label="Public submission endpoint" value={submitUrl} />
98
- <CopyField label="Schema endpoint" value={schemaUrl} />
99
- <CopyField label="Iframe embed" value={iframeSnippet} multiline />
100
- <CopyField label="Script embed" value={scriptSnippet} multiline />
94
+ <CopyField label="React component" value={componentSnippet} multiline />
95
+ <p className="text-muted-foreground -mt-2 text-xs">
96
+ Renders, validates, and submits this form (with spam protection) using{' '}
97
+ <span className="font-mono">@actuate-media/sections-react</span>. Or add a{' '}
98
+ <span className="font-medium">Form</span> section to any page and set its slug to{' '}
99
+ <span className="font-mono">{slug}</span>.
100
+ </p>
101
+
102
+ <CopyField label="Schema endpoint (GET)" value={schemaUrl} />
103
+ <CopyField label="Submission endpoint (POST)" value={submitUrl} />
101
104
 
102
105
  <div>
103
106
  <label className="text-foreground mb-1 block text-sm font-medium">
@@ -34,6 +34,7 @@ import {
34
34
  } from 'lucide-react'
35
35
  import type { LucideIcon } from 'lucide-react'
36
36
  import { ActuateBrandLogo } from '../assets/actuate-logo.js'
37
+ import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
37
38
 
38
39
  /**
39
40
  * Compact mark used in the collapsed sidebar — just the "C" symbol from the
@@ -76,28 +77,17 @@ const ICON_MAP: Record<string, LucideIcon> = {
76
77
  Layers: Layers,
77
78
  }
78
79
 
79
- /** Square site-initials badge — the final fallback before the Actuate mark. */
80
- function InitialsBadge({ initials, size }: { initials: string; size: 'sm' | 'lg' }) {
81
- return (
82
- <span
83
- className={`bg-sidebar-primary text-sidebar-primary-foreground inline-flex shrink-0 items-center justify-center rounded-md font-medium ${
84
- size === 'lg' ? 'h-8 w-8 text-sm' : 'h-8 w-8 text-sm'
85
- }`}
86
- aria-hidden="true"
87
- >
88
- {initials}
89
- </span>
90
- )
91
- }
92
-
93
80
  /**
94
81
  * Sidebar brand lockup.
95
82
  *
96
83
  * Resolution order follows the Appearance spec, sourcing from the live
97
84
  * appearance context (DB-backed brand, cached in localStorage) and falling
98
- * back to legacy `config.admin.branding`, then site initials, then the bundled
99
- * Actuate mark/wordmark for un-branded installs. Dark-mode uses the dark logo
100
- * when one is configured. Logos are never stretched/cropped (object-contain).
85
+ * back to legacy `config.admin.branding`, then a neutral logo placeholder for
86
+ * branded-but-logo-less installs, then the bundled Actuate mark/wordmark for
87
+ * un-branded installs. The placeholder replaces the old initials badge, which
88
+ * read as a user avatar (the real account avatar already sits top-right).
89
+ * Dark-mode uses the dark logo when one is configured. Logos are never
90
+ * stretched/cropped (object-contain).
101
91
  */
102
92
  function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean }) {
103
93
  const { adminBrand, resolvedTheme } = useTheme()
@@ -117,7 +107,7 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
117
107
  if (mark) return <img src={mark} alt={alt} className="h-8 w-8 object-contain" />
118
108
  if (primary) return <img src={primary} alt={alt} className="h-8 w-8 object-contain" />
119
109
  if (configLogo) return <img src={configLogo} alt={alt} className="h-8 w-8 object-contain" />
120
- if (hasInitials) return <InitialsBadge initials={initials} size="sm" />
110
+ if (hasInitials) return <LogoPlaceholder tone="sidebar" />
121
111
  return <ActuateMark className="h-8 w-8" />
122
112
  }
123
113
 
@@ -143,14 +133,10 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
143
133
  )
144
134
  }
145
135
 
146
- if (mark || hasInitials) {
136
+ if (mark) {
147
137
  return (
148
138
  <div className="flex min-w-0 items-center gap-2.5">
149
- {mark ? (
150
- <img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
151
- ) : (
152
- <InitialsBadge initials={initials} size="lg" />
153
- )}
139
+ <img src={mark} alt={alt} className="h-8 w-8 shrink-0 object-contain" />
154
140
  <span className="text-sidebar-foreground truncate text-sm font-medium">
155
141
  {brandName ?? alt}
156
142
  </span>
@@ -158,6 +144,10 @@ function BrandLogo({ config, collapsed }: { config?: any; collapsed: boolean })
158
144
  )
159
145
  }
160
146
 
147
+ if (hasInitials) {
148
+ return <LogoPlaceholder tone="sidebar" label={brandName ?? alt} />
149
+ }
150
+
161
151
  if (brandName) {
162
152
  return (
163
153
  <div className="flex min-w-0 items-center gap-2.5">
@@ -1,8 +1,11 @@
1
1
  'use client'
2
2
 
3
- import { useState, type FormEvent } from 'react'
3
+ import { useState, useEffect, useRef, type FormEvent } from 'react'
4
4
  import { Eye, EyeOff, AlertTriangle, Loader2 } from 'lucide-react'
5
5
  import { ActuateBrandLogo } from '../assets/actuate-logo.js'
6
+ import { LogoPlaceholder } from '../components/LogoPlaceholder.js'
7
+ import { useTheme } from '../components/ThemeProvider.js'
8
+ import { cmsApi } from '../lib/api.js'
6
9
 
7
10
  export interface CaptchaConfig {
8
11
  provider: 'recaptcha' | 'turnstile' | 'none'
@@ -227,8 +230,45 @@ export function Login({
227
230
  const [totpError, setTotpError] = useState('')
228
231
  const [verifying, setVerifying] = useState(false)
229
232
 
233
+ // Brand logo, sourced to match the admin sidebar (see resolution note below).
234
+ // `adminBrand` is the ThemeProvider cache the sidebar paints from (hydrated
235
+ // from localStorage on mount); `publicLogo` is fetched only when that cache
236
+ // is empty (a fresh browser that hasn't loaded the admin yet).
237
+ const { adminBrand } = useTheme()
238
+ const [publicLogo, setPublicLogo] = useState<string | null>(null)
239
+ // Site initials resolved from `/public/brand`. Used only to detect whether
240
+ // the install is "branded" on a fresh browser (no cache, no config name), so
241
+ // the logo-less fallback can pick the placeholder over the Actuate wordmark.
242
+ const [publicInitials, setPublicInitials] = useState<string | null>(null)
243
+ const publicBrandFetched = useRef(false)
244
+
230
245
  useCaptchaScript(captchaConfig?.provider, captchaConfig?.siteKey)
231
246
 
247
+ useEffect(() => {
248
+ // The cached admin brand (exactly what the sidebar last rendered) wins and
249
+ // needs no network. Only reach for the unauthenticated `/public/brand`
250
+ // endpoint on a fresh browser with no cache, and never when the integrator
251
+ // opted out of a logo entirely (`branding.logo === null`).
252
+ if (adminBrand.primaryUrl || branding?.logo === null) return
253
+ if (publicBrandFetched.current) return
254
+ publicBrandFetched.current = true
255
+
256
+ let cancelled = false
257
+ void (async () => {
258
+ const res = await cmsApi<{
259
+ public?: { primary?: { url?: string } | null; initials?: string } | null
260
+ }>('/public/brand')
261
+ if (cancelled) return
262
+ const url = res.data?.public?.primary?.url
263
+ if (url) setPublicLogo(url)
264
+ const initials = res.data?.public?.initials
265
+ if (initials) setPublicInitials(initials)
266
+ })()
267
+ return () => {
268
+ cancelled = true
269
+ }
270
+ }, [adminBrand.primaryUrl, branding?.logo])
271
+
232
272
  const canSubmit = email.trim() && password && !submitting
233
273
 
234
274
  const handleSubmit = async (e: FormEvent) => {
@@ -308,27 +348,48 @@ export function Login({
308
348
  const enabledProviders =
309
349
  oauthProviders?.filter((p) => ['google', 'github', 'microsoft'].includes(p)) ?? []
310
350
 
311
- // Branding fallbacks: when an integrator doesn't pass `branding`, render the
312
- // Actuate Media wordmark inline (transparent SVG, no surrounding pill needed).
313
- // When `branding.logo` is explicitly `null`, render no logo at all (just the
314
- // headline + tagline) so whitelabel deployments can hide it entirely.
315
- const customLogo = branding?.logo
351
+ // Logo resolution mirrors the admin sidebar so the login screen shows the
352
+ // same brand mark a user sees once signed in. Precedence (best available
353
+ // pre-auth):
354
+ // 1. the logo uploaded in Settings Appearance read from the
355
+ // ThemeProvider cache the sidebar paints from, then (fresh browsers with
356
+ // no cache) the unauthenticated `/public/brand` endpoint;
357
+ // 2. the static `config.admin.branding.logo` URL;
358
+ // 3. a neutral logo placeholder when the install is branded (a site/brand
359
+ // name is set) but no logo has been uploaded yet — matching the sidebar;
360
+ // 4. the bundled Actuate wordmark for a fully un-branded install.
361
+ // `branding.logo === null` still hides the logo entirely (whitelabel opt-out).
362
+ const configLogo = branding?.logo
363
+ const hideLogo = configLogo === null
364
+ const appearanceLogo = adminBrand.primaryUrl ?? publicLogo
365
+ const effectiveLogo = appearanceLogo ?? (typeof configLogo === 'string' ? configLogo : null)
316
366
  const brandName = branding?.name ?? 'Actuate CMS'
317
367
  const brandTagline = branding?.tagline ?? 'Sign in to your account'
318
- const showLogo = customLogo !== null
368
+ const showLogo = !hideLogo
369
+ // "Branded" = the operator has set a brand identity. Mirrors the sidebar's
370
+ // `hasInitials` signal (initials are derived from the site title) plus an
371
+ // explicit `branding.name` and, for fresh browsers, the initials returned by
372
+ // `/public/brand`. Drives the placeholder vs. Actuate-wordmark fallback so a
373
+ // configured client install never shows Actuate branding. `'A'` is the
374
+ // unset-title default, so it counts as un-branded.
375
+ const hasInitials = (value: string | null | undefined) => Boolean(value) && value !== 'A'
376
+ const isBranded =
377
+ Boolean(branding?.name) || hasInitials(adminBrand.initials) || hasInitials(publicInitials)
319
378
 
320
379
  return (
321
380
  <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
322
381
  <div className="w-full max-w-md">
323
382
  <div className="mb-8 text-center">
324
383
  {showLogo &&
325
- (customLogo ? (
384
+ (effectiveLogo ? (
326
385
  <img
327
- src={customLogo}
386
+ src={effectiveLogo}
328
387
  alt={brandName}
329
388
  className="mx-auto mb-4 max-h-16 w-auto object-contain"
330
389
  draggable={false}
331
390
  />
391
+ ) : isBranded ? (
392
+ <LogoPlaceholder tone="login" />
332
393
  ) : (
333
394
  <ActuateBrandLogo className="mx-auto mb-4 h-14 w-auto" />
334
395
  ))}
@@ -5,6 +5,7 @@ import {
5
5
  LayoutGrid,
6
6
  LayoutTemplate,
7
7
  type LucideIcon,
8
+ Mail,
8
9
  Megaphone,
9
10
  PanelsTopLeft,
10
11
  Quote,
@@ -18,6 +19,7 @@ const SECTION_ICONS: Record<string, LucideIcon> = {
18
19
  'align-left': AlignLeft,
19
20
  'panels-top-left': PanelsTopLeft,
20
21
  'bar-chart-3': BarChart3,
22
+ mail: Mail,
21
23
  megaphone: Megaphone,
22
24
  text: Type,
23
25
  quote: Quote,