@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.
- package/CHANGELOG.md +16 -0
- package/dist/__tests__/lib/page-editor-service.test.js +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +21 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -1
- package/dist/components/forms/FormSchemaDrawer.js +6 -3
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -1
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
- package/dist/components/forms/NotificationsPanel.js +2 -3
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/WebhooksPanel.d.ts +5 -0
- package/dist/components/forms/WebhooksPanel.d.ts.map +1 -0
- package/dist/components/forms/WebhooksPanel.js +129 -0
- package/dist/components/forms/WebhooksPanel.js.map +1 -0
- package/dist/lib/forms-service.d.ts +40 -2
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +24 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +4 -0
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts +9 -0
- package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/WidgetEmbedSection.js +18 -0
- package/dist/views/page-editor/sections/WidgetEmbedSection.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +22 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useSeoSettings.d.ts +8 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -1
- package/dist/views/settings/useSeoSettings.js +61 -1
- package/dist/views/settings/useSeoSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/page-editor-service.test.ts +1 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +31 -0
- package/src/components/forms/FormSchemaDrawer.tsx +9 -2
- package/src/components/forms/NotificationsPanel.tsx +5 -4
- package/src/components/forms/WebhooksPanel.tsx +311 -0
- package/src/lib/forms-service.ts +81 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +4 -0
- package/src/views/page-editor/sections/WidgetEmbedSection.tsx +32 -0
- package/src/views/settings/SeoSettingsTab.tsx +145 -0
- 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).
|
|
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
|
-
|
|
183
|
-
|
|
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
|
+
}
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|