@actuate-media/cms-admin 0.61.0 → 0.63.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 (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/lib/page-editor-service.test.js +1 -0
  3. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  4. package/dist/__tests__/views/seo-settings.render.test.js +21 -0
  5. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  6. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -1
  7. package/dist/components/forms/FormSchemaDrawer.js +6 -3
  8. package/dist/components/forms/FormSchemaDrawer.js.map +1 -1
  9. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
  10. package/dist/components/forms/NotificationsPanel.js +2 -3
  11. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  12. package/dist/components/forms/WebhooksPanel.d.ts +5 -0
  13. package/dist/components/forms/WebhooksPanel.d.ts.map +1 -0
  14. package/dist/components/forms/WebhooksPanel.js +129 -0
  15. package/dist/components/forms/WebhooksPanel.js.map +1 -0
  16. package/dist/lib/forms-service.d.ts +40 -2
  17. package/dist/lib/forms-service.d.ts.map +1 -1
  18. package/dist/lib/forms-service.js +24 -0
  19. package/dist/lib/forms-service.js.map +1 -1
  20. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  21. package/dist/views/page-editor/sections/SectionRenderer.js +4 -0
  22. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  23. package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts +9 -0
  24. package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts.map +1 -0
  25. package/dist/views/page-editor/sections/WidgetEmbedSection.js +18 -0
  26. package/dist/views/page-editor/sections/WidgetEmbedSection.js.map +1 -0
  27. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  28. package/dist/views/settings/SeoSettingsTab.js +22 -1
  29. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  30. package/dist/views/settings/useSeoSettings.d.ts +8 -0
  31. package/dist/views/settings/useSeoSettings.d.ts.map +1 -1
  32. package/dist/views/settings/useSeoSettings.js +61 -1
  33. package/dist/views/settings/useSeoSettings.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/lib/page-editor-service.test.ts +1 -0
  36. package/src/__tests__/views/seo-settings.render.test.tsx +31 -0
  37. package/src/components/forms/FormSchemaDrawer.tsx +9 -2
  38. package/src/components/forms/NotificationsPanel.tsx +5 -4
  39. package/src/components/forms/WebhooksPanel.tsx +311 -0
  40. package/src/lib/forms-service.ts +81 -0
  41. package/src/views/page-editor/sections/SectionRenderer.tsx +4 -0
  42. package/src/views/page-editor/sections/WidgetEmbedSection.tsx +32 -0
  43. package/src/views/settings/SeoSettingsTab.tsx +145 -0
  44. package/src/views/settings/useSeoSettings.ts +74 -1
@@ -3,8 +3,7 @@
3
3
  /**
4
4
  * Notifications tab of the Form Schema drawer. Persists per-form notification
5
5
  * settings (recipients, reply-to, subject/message templates, field inclusion,
6
- * autoresponder). Actual email/webhook delivery + the "send test" action are
7
- * wired in a later phase (PR 6); this panel owns the configuration only.
6
+ * autoresponder). Email delivery uses the configured email adapter when enabled.
8
7
  */
9
8
  import { useEffect, useState } from 'react'
10
9
  import * as Switch from '@radix-ui/react-switch'
@@ -179,8 +178,10 @@ export function NotificationsPanel({
179
178
  </fieldset>
180
179
 
181
180
  <p className="text-muted-foreground text-xs">
182
- Email &amp; webhook delivery (including the test action) is wired in an upcoming release.
183
- Settings saved here apply automatically once delivery is enabled.
181
+ For CRM integrations (Zapier, HubSpot via Zapier, etc.), use the{' '}
182
+ <span className="font-medium">Integrations</span> tab to add a signed webhook. Email
183
+ notifications send when recipients are configured and your email provider is set up (
184
+ <span className="font-mono">RESEND_API_KEY</span> or SMTP).
184
185
  </p>
185
186
 
186
187
  <div className="flex justify-end">
@@ -0,0 +1,311 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Integrations tab of the Form Schema drawer. Manages signed outbound webhooks
5
+ * (Zapier Catch Hook, Make, custom CRM endpoints) and guides editors through
6
+ * the default Zapier → Webhooks by Zapier → Catch Hook setup.
7
+ */
8
+ import * as Switch from '@radix-ui/react-switch'
9
+ import { ExternalLink, Loader2, Plus, Trash2, Zap } from 'lucide-react'
10
+ import { useEffect, useState } from 'react'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ createFormWebhook,
14
+ deleteFormWebhook,
15
+ fetchFormWebhooks,
16
+ testFormWebhook,
17
+ updateFormWebhook,
18
+ type FormWebhookConfig,
19
+ } from '../../lib/forms-service.js'
20
+ import { ConfirmDialog } from '../ui/ConfirmDialog.js'
21
+ import { FormsErrorState, FormsLoading, btnPrimary, btnSecondary } from './primitives.js'
22
+
23
+ const ZAPIER_CATCH_HOOK_DOCS = 'https://zapier.com/apps/webhook/integrations'
24
+ const FORMS_INTEGRATIONS_DOCS = 'https://actuatecms.dev/docs/forms-and-integrations'
25
+
26
+ const inputCls =
27
+ 'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
28
+
29
+ export function WebhooksPanel({ formId, formSlug }: { formId: string; formSlug: string }) {
30
+ const [webhooks, setWebhooks] = useState<FormWebhookConfig[]>([])
31
+ const [loading, setLoading] = useState(true)
32
+ const [error, setError] = useState<string | null>(null)
33
+ const [url, setUrl] = useState('')
34
+ const [creating, setCreating] = useState(false)
35
+ const [createdSecret, setCreatedSecret] = useState<string | null>(null)
36
+ const [pendingDelete, setPendingDelete] = useState<FormWebhookConfig | null>(null)
37
+ const [testingId, setTestingId] = useState<string | null>(null)
38
+
39
+ async function reload(): Promise<void> {
40
+ setLoading(true)
41
+ setError(null)
42
+ try {
43
+ setWebhooks(await fetchFormWebhooks(formId))
44
+ } catch (e: unknown) {
45
+ setError(e instanceof Error ? e.message : 'Failed to load webhooks')
46
+ } finally {
47
+ setLoading(false)
48
+ }
49
+ }
50
+
51
+ useEffect(() => {
52
+ let active = true
53
+ setLoading(true)
54
+ setError(null)
55
+ fetchFormWebhooks(formId)
56
+ .then((rows) => {
57
+ if (active) setWebhooks(rows)
58
+ })
59
+ .catch((e: unknown) => {
60
+ if (active) setError(e instanceof Error ? e.message : 'Failed to load webhooks')
61
+ })
62
+ .finally(() => {
63
+ if (active) setLoading(false)
64
+ })
65
+ return () => {
66
+ active = false
67
+ }
68
+ }, [formId])
69
+
70
+ async function handleCreate(e: React.FormEvent): Promise<void> {
71
+ e.preventDefault()
72
+ const trimmed = url.trim()
73
+ if (!trimmed) {
74
+ toast.error('Paste your Catch Hook URL first.')
75
+ return
76
+ }
77
+ setCreating(true)
78
+ const res = await createFormWebhook(formId, {
79
+ url: trimmed,
80
+ events: ['form.submitted'],
81
+ enabled: true,
82
+ })
83
+ setCreating(false)
84
+ if (res.error) {
85
+ toast.error(res.error)
86
+ return
87
+ }
88
+ if (res.data?.secret) setCreatedSecret(res.data.secret)
89
+ setUrl('')
90
+ toast.success('Webhook connected.')
91
+ await reload()
92
+ }
93
+
94
+ async function toggleEnabled(webhook: FormWebhookConfig, enabled: boolean): Promise<void> {
95
+ const res = await updateFormWebhook(formId, webhook.id, { enabled })
96
+ if (res.error) {
97
+ toast.error(res.error)
98
+ return
99
+ }
100
+ setWebhooks((rows) => rows.map((row) => (row.id === webhook.id ? { ...row, enabled } : row)))
101
+ }
102
+
103
+ async function handleTest(webhookId: string): Promise<void> {
104
+ setTestingId(webhookId)
105
+ const res = await testFormWebhook(formId, webhookId)
106
+ setTestingId(null)
107
+ if (res.error) {
108
+ toast.error(res.error)
109
+ return
110
+ }
111
+ if (res.data?.ok) {
112
+ toast.success(`Test delivery succeeded (HTTP ${res.data.status}).`)
113
+ } else {
114
+ toast.error(res.data?.error || `Test failed (HTTP ${res.data?.status ?? 'unknown'}).`)
115
+ }
116
+ await reload()
117
+ }
118
+
119
+ async function confirmDelete(): Promise<void> {
120
+ if (!pendingDelete) return
121
+ const res = await deleteFormWebhook(formId, pendingDelete.id)
122
+ setPendingDelete(null)
123
+ if (res.error) {
124
+ toast.error(res.error)
125
+ return
126
+ }
127
+ toast.success('Webhook removed.')
128
+ await reload()
129
+ }
130
+
131
+ if (loading) return <FormsLoading label="Loading integrations…" />
132
+ if (error) return <FormsErrorState message={error} onRetry={() => void reload()} />
133
+
134
+ return (
135
+ <div className="space-y-5">
136
+ <section
137
+ className="border-border bg-muted/40 space-y-3 rounded-lg border p-4"
138
+ aria-labelledby="zapier-helper-title"
139
+ >
140
+ <div className="flex items-start gap-3">
141
+ <span className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-md">
142
+ <Zap size={18} aria-hidden />
143
+ </span>
144
+ <div className="min-w-0 space-y-2">
145
+ <h3 id="zapier-helper-title" className="text-foreground text-sm font-medium">
146
+ Connect to Zapier (recommended)
147
+ </h3>
148
+ <ol className="text-muted-foreground list-decimal space-y-1 pl-4 text-sm">
149
+ <li>
150
+ In Zapier, create a Zap with trigger{' '}
151
+ <span className="text-foreground font-medium">Webhooks by Zapier → Catch Hook</span>
152
+ .
153
+ </li>
154
+ <li>Copy the hook URL Zapier gives you and paste it below.</li>
155
+ <li>
156
+ Map fields in your action step — submission values arrive under{' '}
157
+ <span className="font-mono text-xs">data.email</span>,{' '}
158
+ <span className="font-mono text-xs">data.name</span>, etc.
159
+ </li>
160
+ <li>
161
+ Use event <span className="font-mono text-xs">form.submitted</span> only (spam hits
162
+ emit <span className="font-mono text-xs">form.spam_detected</span> separately).
163
+ </li>
164
+ </ol>
165
+ <p className="text-muted-foreground text-xs">
166
+ HubSpot, Salesforce, Slack, and Google Sheets work through Zapier actions — no native
167
+ Actuate app required for v1.
168
+ </p>
169
+ <div className="flex flex-wrap gap-3 pt-1">
170
+ <a
171
+ href={ZAPIER_CATCH_HOOK_DOCS}
172
+ target="_blank"
173
+ rel="noopener noreferrer"
174
+ className="text-primary inline-flex items-center gap-1 text-sm font-medium hover:underline"
175
+ >
176
+ Open Zapier Webhooks
177
+ <ExternalLink size={14} aria-hidden />
178
+ </a>
179
+ <a
180
+ href={FORMS_INTEGRATIONS_DOCS}
181
+ target="_blank"
182
+ rel="noopener noreferrer"
183
+ className="text-primary inline-flex items-center gap-1 text-sm font-medium hover:underline"
184
+ >
185
+ Full integration guide
186
+ <ExternalLink size={14} aria-hidden />
187
+ </a>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </section>
192
+
193
+ <form onSubmit={handleCreate} className="space-y-3">
194
+ <label className="block">
195
+ <span className="text-foreground mb-1 block text-sm font-medium">Webhook URL</span>
196
+ <input
197
+ type="url"
198
+ value={url}
199
+ onChange={(e) => setUrl(e.target.value)}
200
+ placeholder="https://hooks.zapier.com/hooks/catch/…"
201
+ className={inputCls}
202
+ autoComplete="off"
203
+ />
204
+ <span className="text-muted-foreground mt-1 block text-xs">
205
+ Paste your Catch Hook URL for form <span className="font-mono">{formSlug}</span>.
206
+ Payloads are HMAC-signed via <span className="font-mono">X-Actuate-Signature</span>.
207
+ </span>
208
+ </label>
209
+ <button
210
+ type="submit"
211
+ className={`${btnPrimary} inline-flex items-center`}
212
+ disabled={creating}
213
+ >
214
+ {creating ? (
215
+ <>
216
+ <Loader2 className="mr-2 inline h-4 w-4 animate-spin" aria-hidden />
217
+ Connecting…
218
+ </>
219
+ ) : (
220
+ <>
221
+ <Plus className="mr-1 inline h-4 w-4" aria-hidden />
222
+ Add webhook
223
+ </>
224
+ )}
225
+ </button>
226
+ </form>
227
+
228
+ {createdSecret && (
229
+ <div
230
+ className="border-success/30 bg-success/10 text-foreground space-y-2 rounded-md border px-3 py-3 text-sm"
231
+ role="status"
232
+ >
233
+ <p className="font-medium">Signing secret (shown once)</p>
234
+ <p className="text-muted-foreground text-xs">
235
+ Store this if your CRM verifies HMAC signatures. You cannot retrieve it again after
236
+ closing this panel.
237
+ </p>
238
+ <pre className="border-border bg-background overflow-x-auto rounded border px-2 py-1.5 font-mono text-xs">
239
+ {createdSecret}
240
+ </pre>
241
+ <button type="button" className={btnSecondary} onClick={() => setCreatedSecret(null)}>
242
+ Dismiss
243
+ </button>
244
+ </div>
245
+ )}
246
+
247
+ {webhooks.length === 0 ? (
248
+ <p className="text-muted-foreground text-sm">
249
+ No webhooks yet. Add a Catch Hook URL to send each submission to your CRM.
250
+ </p>
251
+ ) : (
252
+ <ul className="space-y-3" aria-label="Configured webhooks">
253
+ {webhooks.map((webhook) => (
254
+ <li
255
+ key={webhook.id}
256
+ className="border-border flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between"
257
+ >
258
+ <div className="min-w-0">
259
+ <p className="text-foreground truncate font-mono text-xs">{webhook.url}</p>
260
+ <p className="text-muted-foreground mt-1 text-xs">
261
+ Events: {webhook.events.join(', ') || 'form.submitted'}
262
+ {webhook.lastDeliveryAt
263
+ ? ` · Last delivery: ${webhook.lastDeliveryStatus ?? 'unknown'}`
264
+ : ''}
265
+ </p>
266
+ </div>
267
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
268
+ <Switch.Root
269
+ checked={webhook.enabled}
270
+ onCheckedChange={(v) => void toggleEnabled(webhook, v)}
271
+ aria-label={`Enable webhook ${webhook.url}`}
272
+ className="bg-destructive data-[state=checked]:bg-success focus-visible:ring-ring relative h-[18px] w-8 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
273
+ >
274
+ <Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
275
+ </Switch.Root>
276
+ <button
277
+ type="button"
278
+ className={btnSecondary}
279
+ disabled={testingId === webhook.id}
280
+ onClick={() => void handleTest(webhook.id)}
281
+ >
282
+ {testingId === webhook.id ? 'Testing…' : 'Send test'}
283
+ </button>
284
+ <button
285
+ type="button"
286
+ className="text-destructive hover:bg-destructive/10 focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none"
287
+ aria-label="Remove webhook"
288
+ onClick={() => setPendingDelete(webhook)}
289
+ >
290
+ <Trash2 size={16} aria-hidden />
291
+ Remove
292
+ </button>
293
+ </div>
294
+ </li>
295
+ ))}
296
+ </ul>
297
+ )}
298
+
299
+ <ConfirmDialog
300
+ open={!!pendingDelete}
301
+ onClose={() => setPendingDelete(null)}
302
+ onConfirm={() => void confirmDelete()}
303
+ title="Remove webhook?"
304
+ description="New submissions will no longer be sent to this URL. Existing Zapier Zaps are not deleted — disable them in Zapier if needed."
305
+ confirmLabel="Remove"
306
+ cancelLabel="Keep webhook"
307
+ destructive
308
+ />
309
+ </div>
310
+ )
311
+ }
@@ -17,6 +17,8 @@ import type {
17
17
  FormField,
18
18
  FormNotificationSettings,
19
19
  FormEmbedSettings,
20
+ FormWebhookConfig,
21
+ FormWebhookEvent,
20
22
  FormStatus,
21
23
  FormStats,
22
24
  FormsSidebarCount,
@@ -35,6 +37,8 @@ export type {
35
37
  FieldValidationRules,
36
38
  FormNotificationSettings,
37
39
  FormEmbedSettings,
40
+ FormWebhookConfig,
41
+ FormWebhookEvent,
38
42
  FormStatus,
39
43
  FormStats,
40
44
  FormsSidebarCount,
@@ -251,6 +255,83 @@ export async function updateEmbedSettings(
251
255
  return { data: res.data, error: res.error }
252
256
  }
253
257
 
258
+ // ─── Webhooks (CRM / Zapier) ───────────────────────────────────────────────
259
+
260
+ export interface WebhookTestResult {
261
+ ok: boolean
262
+ status: number
263
+ attempts: number
264
+ error?: string
265
+ }
266
+
267
+ export interface CreateFormWebhookPayload {
268
+ url: string
269
+ events?: FormWebhookEvent[]
270
+ enabled?: boolean
271
+ retryCount?: number
272
+ }
273
+
274
+ export interface UpdateFormWebhookPayload {
275
+ url?: string
276
+ events?: FormWebhookEvent[]
277
+ enabled?: boolean
278
+ retryCount?: number
279
+ }
280
+
281
+ /** Plaintext secret is returned only on create — store it before closing the dialog. */
282
+ export type CreatedFormWebhook = FormWebhookConfig & { secret: string }
283
+
284
+ export async function fetchFormWebhooks(formId: string): Promise<FormWebhookConfig[]> {
285
+ const res = await cmsApi<FormWebhookConfig[]>(`/forms/${encodeURIComponent(formId)}/webhooks`)
286
+ throwIfError(res)
287
+ return res.data ?? []
288
+ }
289
+
290
+ export async function createFormWebhook(
291
+ formId: string,
292
+ payload: CreateFormWebhookPayload,
293
+ ): Promise<{ data?: CreatedFormWebhook; error?: string }> {
294
+ const res = await cmsApi<CreatedFormWebhook>(`/forms/${encodeURIComponent(formId)}/webhooks`, {
295
+ method: 'POST',
296
+ body: JSON.stringify(payload),
297
+ })
298
+ return { data: res.data, error: res.error }
299
+ }
300
+
301
+ export async function updateFormWebhook(
302
+ formId: string,
303
+ webhookId: string,
304
+ payload: UpdateFormWebhookPayload,
305
+ ): Promise<{ data?: FormWebhookConfig; error?: string }> {
306
+ const res = await cmsApi<FormWebhookConfig>(
307
+ `/forms/${encodeURIComponent(formId)}/webhooks/${encodeURIComponent(webhookId)}`,
308
+ { method: 'PATCH', body: JSON.stringify(payload) },
309
+ )
310
+ return { data: res.data, error: res.error }
311
+ }
312
+
313
+ export async function deleteFormWebhook(
314
+ formId: string,
315
+ webhookId: string,
316
+ ): Promise<{ error?: string }> {
317
+ const res = await cmsApi(
318
+ `/forms/${encodeURIComponent(formId)}/webhooks/${encodeURIComponent(webhookId)}`,
319
+ { method: 'DELETE' },
320
+ )
321
+ return { error: res.error }
322
+ }
323
+
324
+ export async function testFormWebhook(
325
+ formId: string,
326
+ webhookId: string,
327
+ ): Promise<{ data?: WebhookTestResult; error?: string }> {
328
+ const res = await cmsApi<WebhookTestResult>(
329
+ `/forms/${encodeURIComponent(formId)}/webhooks/${encodeURIComponent(webhookId)}/test`,
330
+ { method: 'POST' },
331
+ )
332
+ return { data: res.data, error: res.error }
333
+ }
334
+
254
335
  // ─── Entries (submissions) ─────────────────────────────────────────────────
255
336
 
256
337
  /** A page of submissions as returned by `GET /forms/entries`. */
@@ -17,6 +17,7 @@ import { AuthorBioSection } from './AuthorBioSection.js'
17
17
  import { RelatedPostsSection } from './RelatedPostsSection.js'
18
18
  import { GallerySection } from './GallerySection.js'
19
19
  import { EmbedSection } from './EmbedSection.js'
20
+ import { WidgetEmbedSection } from './WidgetEmbedSection.js'
20
21
 
21
22
  /**
22
23
  * Section types with a real canvas body component below. Anything else —
@@ -36,6 +37,7 @@ const CANVAS_RENDERED_TYPES: ReadonlySet<string> = new Set([
36
37
  'related-posts',
37
38
  'gallery',
38
39
  'embed',
40
+ 'widget-embed',
39
41
  ])
40
42
 
41
43
  /** True when the canvas renders this type for real (not as a placeholder). */
@@ -70,6 +72,8 @@ function SectionBody({ section, context }: { section: PageSection; context?: Pos
70
72
  return <GallerySection section={section} />
71
73
  case 'embed':
72
74
  return <EmbedSection section={section} />
75
+ case 'widget-embed':
76
+ return <WidgetEmbedSection section={section} />
73
77
  default: {
74
78
  // Custom (config-declared) and unmanaged types: use a consumer-supplied
75
79
  // canvas renderer when one is registered (see admin-renderers.tsx).
@@ -0,0 +1,32 @@
1
+ import { Puzzle } from 'lucide-react'
2
+ import { parseWidgetEmbed, WIDGET_PROVIDER_LABEL } from '@actuate-media/cms-core/page-sections'
3
+ import type { PageSection } from '../../../lib/page-editor-service.js'
4
+ import { str } from './parts.js'
5
+
6
+ /**
7
+ * Canvas body for the `widget-embed` section. Shows a reserved-height placeholder
8
+ * instead of loading third-party scripts inside the editor.
9
+ */
10
+ export function WidgetEmbedSection({ section }: { section: PageSection }) {
11
+ const c = section.content
12
+ const parsed = parseWidgetEmbed(c)
13
+ const title = str(c, 'title')
14
+ const minHeight = parsed?.minHeightPx ?? 280
15
+
16
+ return (
17
+ <section className="mx-auto max-w-3xl px-6" aria-label={title || 'Third-party widget'}>
18
+ <div
19
+ className="bg-muted text-muted-foreground relative flex w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-xl"
20
+ style={{ minHeight: `${minHeight}px` }}
21
+ >
22
+ <Puzzle className="h-8 w-8" aria-hidden />
23
+ <p className="text-foreground text-sm font-medium">
24
+ {parsed
25
+ ? `${WIDGET_PROVIDER_LABEL[parsed.provider]} widget`
26
+ : 'Configure widget provider + ids'}
27
+ </p>
28
+ {title && <p className="text-muted-foreground px-4 text-center text-xs">{title}</p>}
29
+ </div>
30
+ </section>
31
+ )
32
+ }
@@ -126,6 +126,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
126
126
  <SettingsValidationSummary issues={seo.issues} />
127
127
 
128
128
  <MetaDefaultsCard seo={seo} canEdit={canEdit} />
129
+ <OrganizationCard seo={seo} canEdit={canEdit} />
129
130
  <SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
130
131
  <SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
131
132
 
@@ -409,6 +410,150 @@ function SeoPreviewCard({
409
410
  )
410
411
  }
411
412
 
413
+ // ---------------------------------------------------------------------------
414
+ // Organization (Schema.org)
415
+ // ---------------------------------------------------------------------------
416
+
417
+ function OrganizationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
418
+ const nameId = useId()
419
+ const urlId = useId()
420
+ const sameAsId = useId()
421
+ const [pickerOpen, setPickerOpen] = useState(false)
422
+
423
+ const { form, setField, issues, sources, siteName, siteUrl } = seo
424
+ const nameIssue = issues.find((i) => i.field === 'organizationName')
425
+ const urlIssue = issues.find((i) => i.field === 'organizationUrl')
426
+ const logoIssue = issues.find((i) => i.field === 'organizationLogo')
427
+ const sameAsIssue = issues.find((i) => i.field === 'organizationSameAs')
428
+
429
+ const nameEditable = sources.organizationName?.editable !== false && canEdit
430
+ const urlEditable = sources.organizationUrl?.editable !== false && canEdit
431
+ const logoEditable = sources.organizationLogo?.editable !== false && canEdit
432
+ const sameAsEditable = sources.organizationSameAs?.editable !== false && canEdit
433
+
434
+ return (
435
+ <SettingsCard
436
+ title="Organization"
437
+ description="Schema.org Organization data injected sitewide in JSON-LD. Logo and social profiles should use absolute production URLs."
438
+ >
439
+ <div className="space-y-4">
440
+ <div>
441
+ <label htmlFor={nameId} className={LABEL_CLASS}>
442
+ Organization name
443
+ <SettingsSourceBadge meta={sources.organizationName} />
444
+ </label>
445
+ <input
446
+ id={nameId}
447
+ type="text"
448
+ value={form.organizationName}
449
+ disabled={!nameEditable}
450
+ placeholder={siteName || 'Your organization'}
451
+ onChange={(e) => setField('organizationName', e.target.value)}
452
+ className={INPUT_CLASS}
453
+ />
454
+ <p className="text-muted-foreground mt-1 text-sm">Falls back to site name when empty.</p>
455
+ {nameIssue && <p className="text-destructive mt-1 text-sm">{nameIssue.message}</p>}
456
+ </div>
457
+
458
+ <div>
459
+ <label htmlFor={urlId} className={LABEL_CLASS}>
460
+ Organization URL
461
+ <SettingsSourceBadge meta={sources.organizationUrl} />
462
+ </label>
463
+ <input
464
+ id={urlId}
465
+ type="url"
466
+ value={form.organizationUrl}
467
+ disabled={!urlEditable}
468
+ placeholder={siteUrl || 'https://example.com'}
469
+ aria-invalid={urlIssue?.severity === 'error'}
470
+ onChange={(e) => setField('organizationUrl', e.target.value)}
471
+ className={INPUT_CLASS}
472
+ />
473
+ <p className="text-muted-foreground mt-1 text-sm">
474
+ Canonical organization homepage. Falls back to site URL when empty.
475
+ </p>
476
+ {urlIssue && <p className="text-destructive mt-1 text-sm">{urlIssue.message}</p>}
477
+ </div>
478
+
479
+ <div>
480
+ <span className={LABEL_CLASS}>
481
+ Organization logo
482
+ <SettingsSourceBadge meta={sources.organizationLogo} />
483
+ </span>
484
+ <div className="flex items-center gap-3">
485
+ <div className="border-border bg-muted flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border">
486
+ {form.organizationLogo ? (
487
+ // eslint-disable-next-line @next/next/no-img-element
488
+ <img
489
+ src={form.organizationLogo}
490
+ alt="Organization logo preview"
491
+ className="h-full w-full object-contain p-1"
492
+ />
493
+ ) : (
494
+ <ImageIcon size={20} className="text-muted-foreground" aria-hidden="true" />
495
+ )}
496
+ </div>
497
+ <div className="flex flex-col gap-2">
498
+ <div className="flex items-center gap-2">
499
+ <button
500
+ type="button"
501
+ onClick={() => setPickerOpen(true)}
502
+ disabled={!logoEditable}
503
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
504
+ >
505
+ <Upload size={14} aria-hidden="true" />
506
+ {form.organizationLogo ? 'Replace logo' : 'Choose logo'}
507
+ </button>
508
+ {form.organizationLogo && logoEditable && (
509
+ <button
510
+ type="button"
511
+ onClick={() => setField('organizationLogo', '')}
512
+ className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors"
513
+ >
514
+ <X size={14} aria-hidden="true" />
515
+ Remove
516
+ </button>
517
+ )}
518
+ </div>
519
+ <p className="text-muted-foreground text-sm">Square PNG or SVG recommended.</p>
520
+ </div>
521
+ </div>
522
+ {logoIssue && <p className="text-destructive mt-1 text-sm">{logoIssue.message}</p>}
523
+ </div>
524
+
525
+ <div>
526
+ <label htmlFor={sameAsId} className={LABEL_CLASS}>
527
+ Social profiles (sameAs)
528
+ <SettingsSourceBadge meta={sources.organizationSameAs} />
529
+ </label>
530
+ <textarea
531
+ id={sameAsId}
532
+ rows={4}
533
+ value={form.organizationSameAs}
534
+ disabled={!sameAsEditable}
535
+ placeholder={'https://linkedin.com/company/your-brand\nhttps://twitter.com/your-brand'}
536
+ aria-invalid={sameAsIssue?.severity === 'error'}
537
+ onChange={(e) => setField('organizationSameAs', e.target.value)}
538
+ className={`${INPUT_CLASS} resize-y font-mono text-sm`}
539
+ />
540
+ <p className="text-muted-foreground mt-1 text-sm">
541
+ One HTTPS profile URL per line (LinkedIn, X, Facebook, YouTube, etc.).
542
+ </p>
543
+ {sameAsIssue && <p className="text-destructive mt-1 text-sm">{sameAsIssue.message}</p>}
544
+ </div>
545
+ </div>
546
+
547
+ <MediaPickerModal
548
+ open={pickerOpen}
549
+ onClose={() => setPickerOpen(false)}
550
+ accept="image/*"
551
+ onSelect={(url) => setField('organizationLogo', url)}
552
+ />
553
+ </SettingsCard>
554
+ )
555
+ }
556
+
412
557
  // ---------------------------------------------------------------------------
413
558
  // Search Engine Verification
414
559
  // ---------------------------------------------------------------------------