@actuate-media/cms-admin 0.62.0 → 0.63.1

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +2 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useAppearanceBootstrap.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useAppearanceBootstrap.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useAppearanceBootstrap.test.js +55 -0
  8. package/dist/__tests__/hooks/useAppearanceBootstrap.test.js.map +1 -0
  9. package/dist/__tests__/lib/appearance-api.test.d.ts +2 -0
  10. package/dist/__tests__/lib/appearance-api.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/appearance-api.test.js +37 -0
  12. package/dist/__tests__/lib/appearance-api.test.js.map +1 -0
  13. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -1
  14. package/dist/components/forms/FormSchemaDrawer.js +6 -3
  15. package/dist/components/forms/FormSchemaDrawer.js.map +1 -1
  16. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
  17. package/dist/components/forms/NotificationsPanel.js +2 -3
  18. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  19. package/dist/components/forms/WebhooksPanel.d.ts +5 -0
  20. package/dist/components/forms/WebhooksPanel.d.ts.map +1 -0
  21. package/dist/components/forms/WebhooksPanel.js +129 -0
  22. package/dist/components/forms/WebhooksPanel.js.map +1 -0
  23. package/dist/hooks/useAppearanceBootstrap.d.ts +8 -0
  24. package/dist/hooks/useAppearanceBootstrap.d.ts.map +1 -0
  25. package/dist/hooks/useAppearanceBootstrap.js +36 -0
  26. package/dist/hooks/useAppearanceBootstrap.js.map +1 -0
  27. package/dist/lib/appearance-api.d.ts +33 -0
  28. package/dist/lib/appearance-api.d.ts.map +1 -0
  29. package/dist/lib/appearance-api.js +42 -0
  30. package/dist/lib/appearance-api.js.map +1 -0
  31. package/dist/lib/forms-service.d.ts +40 -2
  32. package/dist/lib/forms-service.d.ts.map +1 -1
  33. package/dist/lib/forms-service.js +24 -0
  34. package/dist/lib/forms-service.js.map +1 -1
  35. package/dist/views/settings/useAppearanceSettings.d.ts +3 -9
  36. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -1
  37. package/dist/views/settings/useAppearanceSettings.js +6 -28
  38. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/AdminRoot.tsx +2 -0
  41. package/src/__tests__/hooks/useAppearanceBootstrap.test.ts +69 -0
  42. package/src/__tests__/lib/appearance-api.test.ts +41 -0
  43. package/src/components/forms/FormSchemaDrawer.tsx +9 -2
  44. package/src/components/forms/NotificationsPanel.tsx +5 -4
  45. package/src/components/forms/WebhooksPanel.tsx +311 -0
  46. package/src/hooks/useAppearanceBootstrap.ts +40 -0
  47. package/src/lib/appearance-api.ts +84 -0
  48. package/src/lib/forms-service.ts +81 -0
  49. package/src/views/settings/useAppearanceSettings.ts +14 -58
@@ -2,8 +2,9 @@
2
2
 
3
3
  /**
4
4
  * Form Schema drawer — the right-side panel opened from the Forms list "Schema"
5
- * action. Three tabs: Fields (versioned schema editor), Embed (public snippets
6
- * + domain restrictions), Notifications (per-form notification settings).
5
+ * action. Four tabs: Fields (versioned schema editor), Embed (public snippets
6
+ * + domain restrictions), Notifications (email settings), Integrations (CRM
7
+ * webhooks / Zapier).
7
8
  *
8
9
  * Saving the Fields tab creates a new schema version server-side; existing
9
10
  * submissions keep referencing the version they were captured against, so
@@ -20,12 +21,14 @@ import { Drawer } from './Drawer.js'
20
21
  import { FieldsPanel } from './FieldsPanel.js'
21
22
  import { EmbedPanel } from './EmbedPanel.js'
22
23
  import { NotificationsPanel } from './NotificationsPanel.js'
24
+ import { WebhooksPanel } from './WebhooksPanel.js'
23
25
  import { FormsErrorState, FormsLoading, btnPrimary, btnSecondary } from './primitives.js'
24
26
 
25
27
  const TABS = [
26
28
  { id: 'fields', label: 'Fields' },
27
29
  { id: 'embed', label: 'Embed' },
28
30
  { id: 'notifications', label: 'Notifications' },
31
+ { id: 'integrations', label: 'Integrations' },
29
32
  ] as const
30
33
 
31
34
  export function FormSchemaDrawer({
@@ -164,6 +167,10 @@ export function FormSchemaDrawer({
164
167
  <Tabs.Content value="notifications" tabIndex={-1}>
165
168
  {formId && <NotificationsPanel formId={formId} onChanged={onSaved} />}
166
169
  </Tabs.Content>
170
+
171
+ <Tabs.Content value="integrations" tabIndex={-1}>
172
+ {formId && <WebhooksPanel formId={formId} formSlug={form.slug} />}
173
+ </Tabs.Content>
167
174
  </Tabs.Root>
168
175
  </Drawer>
169
176
 
@@ -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
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ import { useTheme } from '../components/ThemeProvider.js'
6
+ import { appearanceApiToSnapshot, type AppearanceApiResponse } from '../lib/appearance-api.js'
7
+ import { cmsApi } from '../lib/api.js'
8
+
9
+ /**
10
+ * Hydrate ThemeProvider from the DB-backed appearance settings as soon as the
11
+ * admin shell mounts with a session. Without this, the sidebar only picks up
12
+ * uploaded admin logos after visiting Settings → Appearance (which calls
13
+ * applyAppearance on tab load).
14
+ */
15
+ export function useAppearanceBootstrap(session: unknown) {
16
+ const { applyAppearance } = useTheme()
17
+ const active = Boolean(session)
18
+
19
+ useEffect(() => {
20
+ if (!active) return
21
+
22
+ let cancelled = false
23
+
24
+ const hydrate = async () => {
25
+ try {
26
+ const res = await cmsApi<AppearanceApiResponse>('/appearance')
27
+ if (cancelled || res.error || !res.data) return
28
+ applyAppearance(appearanceApiToSnapshot(res.data))
29
+ } catch {
30
+ /* keep localStorage / config fallbacks */
31
+ }
32
+ }
33
+
34
+ void hydrate()
35
+
36
+ return () => {
37
+ cancelled = true
38
+ }
39
+ }, [active, applyAppearance])
40
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ classifyLogoShape,
3
+ resolveAdminBrand,
4
+ type BrandAssetLike,
5
+ type BrandSettings,
6
+ type ColorMode,
7
+ type LogoShape,
8
+ type SidebarDensity,
9
+ } from '@actuate-media/cms-core/appearance'
10
+
11
+ import type { AdminBrandCache, AppearanceSnapshot } from '../components/ThemeProvider.js'
12
+
13
+ /** GET /appearance response shape (admin Appearance tab + boot hydration). */
14
+ export interface AppearanceApiResponse {
15
+ appearance: {
16
+ adminColorModeDefault: ColorMode
17
+ adminAccentColor: string
18
+ sidebarDensity: SidebarDensity
19
+ contentFont: string
20
+ reduceMotion: boolean
21
+ }
22
+ brand: BrandSettings
23
+ resolved: { admin: { initials: string } }
24
+ assets: BrandAssetLike[]
25
+ siteTitle: string | null
26
+ }
27
+
28
+ export interface AssetPreview {
29
+ id: string
30
+ url: string
31
+ mimeType: string
32
+ width?: number
33
+ height?: number
34
+ shape: LogoShape
35
+ }
36
+
37
+ export function parseAppearanceAssets(assets: BrandAssetLike[]): Record<string, AssetPreview> {
38
+ const next: Record<string, AssetPreview> = {}
39
+ for (const a of assets ?? []) {
40
+ next[a.id] = {
41
+ id: a.id,
42
+ url: a.publicUrl,
43
+ mimeType: a.mimeType,
44
+ width: a.width ?? undefined,
45
+ height: a.height ?? undefined,
46
+ shape: classifyLogoShape(a.width ?? undefined, a.height ?? undefined),
47
+ }
48
+ }
49
+ return next
50
+ }
51
+
52
+ export function adminBrandToCache(
53
+ brand: BrandSettings,
54
+ assets: Record<string, AssetPreview>,
55
+ siteTitle: string | null,
56
+ ): AdminBrandCache {
57
+ const map = new Map<string, BrandAssetLike>(
58
+ Object.values(assets).map((a) => [
59
+ a.id,
60
+ { id: a.id, publicUrl: a.url, mimeType: a.mimeType, width: a.width, height: a.height },
61
+ ]),
62
+ )
63
+ const resolved = resolveAdminBrand(brand, map, siteTitle)
64
+ return {
65
+ primaryUrl: resolved.primary?.url ?? null,
66
+ markUrl: resolved.mark?.url ?? null,
67
+ darkUrl: resolved.dark?.url ?? null,
68
+ initials: resolved.initials,
69
+ }
70
+ }
71
+
72
+ /** Map a stored appearance payload into the ThemeProvider snapshot. */
73
+ export function appearanceApiToSnapshot(data: AppearanceApiResponse): AppearanceSnapshot {
74
+ const brand: BrandSettings = { ...data.brand, mode: data.brand?.mode ?? 'inheritPublicBrand' }
75
+ const assets = parseAppearanceAssets(data.assets)
76
+ return {
77
+ theme: data.appearance.adminColorModeDefault,
78
+ accent: data.appearance.adminAccentColor,
79
+ density: data.appearance.sidebarDensity,
80
+ font: data.appearance.contentFont,
81
+ reduceMotion: data.appearance.reduceMotion,
82
+ adminBrand: adminBrandToCache(brand, assets, data.siteTitle ?? null),
83
+ }
84
+ }
@@ -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`. */