@actuate-media/cms-admin 0.77.0 → 0.79.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 (92) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/__tests__/components/attribution-rows.test.d.ts +2 -0
  3. package/dist/__tests__/components/attribution-rows.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/attribution-rows.test.js +82 -0
  5. package/dist/__tests__/components/attribution-rows.test.js.map +1 -0
  6. package/dist/__tests__/views/form-editor.render.test.js +1 -0
  7. package/dist/__tests__/views/form-editor.render.test.js.map +1 -1
  8. package/dist/__tests__/views/form-entries.render.test.js +20 -0
  9. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  10. package/dist/__tests__/views/form-submissions.render.test.js +12 -0
  11. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  12. package/dist/__tests__/views/forms-list.render.test.js +2 -0
  13. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  14. package/dist/__tests__/views/redirects-pane.render.test.js +2 -0
  15. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  16. package/dist/components/forms/ExportMenu.d.ts +8 -0
  17. package/dist/components/forms/ExportMenu.d.ts.map +1 -0
  18. package/dist/components/forms/ExportMenu.js +32 -0
  19. package/dist/components/forms/ExportMenu.js.map +1 -0
  20. package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
  21. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
  22. package/dist/components/forms/FormEntryDetailPane.js +74 -8
  23. package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
  24. package/dist/components/forms/FormSchemaPane.js +1 -1
  25. package/dist/components/forms/FormSchemaPane.js.map +1 -1
  26. package/dist/components/forms/NotificationsPanel.d.ts +4 -1
  27. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
  28. package/dist/components/forms/NotificationsPanel.js +66 -12
  29. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  30. package/dist/components/forms/ShortcodeGlossary.d.ts +5 -0
  31. package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -0
  32. package/dist/components/forms/ShortcodeGlossary.js +62 -0
  33. package/dist/components/forms/ShortcodeGlossary.js.map +1 -0
  34. package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
  35. package/dist/components/forms/WebhooksPanel.js +1 -1
  36. package/dist/components/forms/WebhooksPanel.js.map +1 -1
  37. package/dist/components/ui/ReauthDialog.d.ts +17 -0
  38. package/dist/components/ui/ReauthDialog.d.ts.map +1 -0
  39. package/dist/components/ui/ReauthDialog.js +34 -0
  40. package/dist/components/ui/ReauthDialog.js.map +1 -0
  41. package/dist/components/ui/index.d.ts +2 -0
  42. package/dist/components/ui/index.d.ts.map +1 -1
  43. package/dist/components/ui/index.js +1 -0
  44. package/dist/components/ui/index.js.map +1 -1
  45. package/dist/lib/forms-service.d.ts +23 -0
  46. package/dist/lib/forms-service.d.ts.map +1 -1
  47. package/dist/lib/forms-service.js +42 -1
  48. package/dist/lib/forms-service.js.map +1 -1
  49. package/dist/lib/seo-service.d.ts +4 -0
  50. package/dist/lib/seo-service.d.ts.map +1 -1
  51. package/dist/lib/seo-service.js +1 -0
  52. package/dist/lib/seo-service.js.map +1 -1
  53. package/dist/views/ApiKeys.d.ts.map +1 -1
  54. package/dist/views/ApiKeys.js +22 -23
  55. package/dist/views/ApiKeys.js.map +1 -1
  56. package/dist/views/FormEditor.js +1 -1
  57. package/dist/views/FormEditor.js.map +1 -1
  58. package/dist/views/FormEntries.d.ts.map +1 -1
  59. package/dist/views/FormEntries.js +8 -36
  60. package/dist/views/FormEntries.js.map +1 -1
  61. package/dist/views/FormSubmissions.d.ts.map +1 -1
  62. package/dist/views/FormSubmissions.js +7 -36
  63. package/dist/views/FormSubmissions.js.map +1 -1
  64. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  65. package/dist/views/seo/RedirectsTab.js +3 -2
  66. package/dist/views/seo/RedirectsTab.js.map +1 -1
  67. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
  68. package/dist/views/users/UserDetailDrawer.js +2 -30
  69. package/dist/views/users/UserDetailDrawer.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/__tests__/components/attribution-rows.test.ts +93 -0
  72. package/src/__tests__/views/form-editor.render.test.tsx +1 -0
  73. package/src/__tests__/views/form-entries.render.test.tsx +29 -0
  74. package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
  75. package/src/__tests__/views/forms-list.render.test.tsx +2 -0
  76. package/src/__tests__/views/redirects-pane.render.test.tsx +2 -0
  77. package/src/components/forms/ExportMenu.tsx +70 -0
  78. package/src/components/forms/FormEntryDetailPane.tsx +82 -8
  79. package/src/components/forms/FormSchemaPane.tsx +1 -1
  80. package/src/components/forms/NotificationsPanel.tsx +150 -20
  81. package/src/components/forms/ShortcodeGlossary.tsx +146 -0
  82. package/src/components/forms/WebhooksPanel.tsx +5 -1
  83. package/src/components/ui/ReauthDialog.tsx +93 -0
  84. package/src/components/ui/index.ts +2 -0
  85. package/src/lib/forms-service.ts +57 -1
  86. package/src/lib/seo-service.ts +5 -0
  87. package/src/views/ApiKeys.tsx +48 -41
  88. package/src/views/FormEditor.tsx +1 -1
  89. package/src/views/FormEntries.tsx +7 -46
  90. package/src/views/FormSubmissions.tsx +6 -46
  91. package/src/views/seo/RedirectsTab.tsx +3 -1
  92. package/src/views/users/UserDetailDrawer.tsx +1 -76
@@ -4,42 +4,72 @@
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
6
  * autoresponder). Email delivery uses the configured email adapter when enabled.
7
+ *
8
+ * Leave subject/message blank to use the built-in template (all fields +
9
+ * attribution). Shortcodes are listed in ShortcodeGlossary for copy/paste.
7
10
  */
8
- import { useEffect, useState } from 'react'
11
+ import { useEffect, useMemo, useState } from 'react'
9
12
  import { Toggle } from '../ui/Toggle.js'
10
13
  import { toast } from 'sonner'
11
14
  import {
15
+ fetchFormById,
12
16
  fetchNotificationSettings,
13
17
  updateNotificationSettings,
18
+ testNotificationSettings,
19
+ type FormField,
14
20
  type FormNotificationSettings,
15
21
  } from '../../lib/forms-service.js'
16
22
  import { emitFormsChanged } from '../../lib/forms-events.js'
17
- import { FormsErrorState, FormsLoading, btnPrimary } from './primitives.js'
23
+ import { FormsErrorState, FormsLoading, btnPrimary, btnSecondary } from './primitives.js'
24
+ import { ShortcodeGlossary } from './ShortcodeGlossary.js'
18
25
 
19
26
  const inputCls =
20
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'
21
28
 
29
+ const selectCls =
30
+ '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'
31
+
22
32
  export function NotificationsPanel({
23
33
  formId,
34
+ fields: fieldsProp,
24
35
  onChanged,
25
36
  }: {
26
37
  formId: string
38
+ /** Optional schema fields for reply-to picker + shortcode glossary. */
39
+ fields?: FormField[]
27
40
  /** Called after a successful save so the list can refresh the notify state. */
28
41
  onChanged?: () => void
29
42
  }) {
30
43
  const [settings, setSettings] = useState<FormNotificationSettings | null>(null)
44
+ const [fields, setFields] = useState<FormField[]>(fieldsProp ?? [])
31
45
  const [loading, setLoading] = useState(true)
32
46
  const [error, setError] = useState<string | null>(null)
33
47
  const [saving, setSaving] = useState(false)
48
+ const [testing, setTesting] = useState(false)
49
+
50
+ useEffect(() => {
51
+ if (fieldsProp) setFields(fieldsProp)
52
+ }, [fieldsProp])
34
53
 
35
54
  useEffect(() => {
36
55
  let active = true
37
56
  setLoading(true)
38
57
  setError(null)
39
- fetchNotificationSettings(formId)
40
- .then((s) => {
41
- if (active) setSettings(s)
42
- })
58
+
59
+ const load = async () => {
60
+ const settingsPromise = fetchNotificationSettings(formId)
61
+ const fieldsPromise =
62
+ fieldsProp && fieldsProp.length > 0
63
+ ? Promise.resolve(fieldsProp)
64
+ : fetchFormById(formId).then((f) => f?.fields ?? [])
65
+
66
+ const [s, f] = await Promise.all([settingsPromise, fieldsPromise])
67
+ if (!active) return
68
+ setSettings(s)
69
+ setFields(f)
70
+ }
71
+
72
+ load()
43
73
  .catch((e: unknown) => {
44
74
  if (active)
45
75
  setError(e instanceof Error ? e.message : 'Failed to load notification settings')
@@ -47,10 +77,13 @@ export function NotificationsPanel({
47
77
  .finally(() => {
48
78
  if (active) setLoading(false)
49
79
  })
80
+
50
81
  return () => {
51
82
  active = false
52
83
  }
53
- }, [formId])
84
+ }, [formId, fieldsProp])
85
+
86
+ const emailFields = useMemo(() => fields.filter((f) => f.type === 'email'), [fields])
54
87
 
55
88
  function patch(p: Partial<FormNotificationSettings>) {
56
89
  setSettings((cur) => (cur ? { ...cur, ...p } : cur))
@@ -71,10 +104,48 @@ export function NotificationsPanel({
71
104
  onChanged?.()
72
105
  }
73
106
 
107
+ async function sendTest() {
108
+ if (!settings) return
109
+ if (!settings.enabled || settings.recipients.length === 0) {
110
+ toast.error('Enable notifications and add at least one recipient before sending a test.')
111
+ return
112
+ }
113
+ setTesting(true)
114
+ // Persist first so the test uses what the admin currently sees.
115
+ const saved = await updateNotificationSettings(formId, settings)
116
+ if (saved.error) {
117
+ setTesting(false)
118
+ toast.error(saved.error)
119
+ return
120
+ }
121
+ if (saved.data) setSettings(saved.data)
122
+
123
+ const res = await testNotificationSettings(formId)
124
+ setTesting(false)
125
+ if (res.error) {
126
+ toast.error(res.error)
127
+ return
128
+ }
129
+ const notified = res.data?.notified ?? 0
130
+ if (notified > 0) {
131
+ toast.success(`Test email sent to ${settings.recipients[0]}.`)
132
+ emitFormsChanged()
133
+ onChanged?.()
134
+ return
135
+ }
136
+ toast.error(
137
+ res.data?.skippedReason === 'no-email-adapter'
138
+ ? 'No email provider configured. Set up Email in Settings or RESEND_API_KEY / SMTP.'
139
+ : 'Test email was not sent.',
140
+ )
141
+ }
142
+
74
143
  if (loading) return <FormsLoading label="Loading notification settings…" />
75
144
  if (error) return <FormsErrorState message={error} />
76
145
  if (!settings) return null
77
146
 
147
+ const missingRecipients = settings.enabled && settings.recipients.length === 0
148
+
78
149
  return (
79
150
  <div className="space-y-4">
80
151
  <ToggleRow
@@ -84,6 +155,12 @@ export function NotificationsPanel({
84
155
  onChange={(v) => patch({ enabled: v })}
85
156
  />
86
157
 
158
+ {missingRecipients && (
159
+ <p className="text-destructive text-sm" role="status">
160
+ Notifications are on, but no recipients are set — admin emails will not send.
161
+ </p>
162
+ )}
163
+
87
164
  <fieldset
88
165
  disabled={!settings.enabled}
89
166
  className="space-y-4 transition-opacity disabled:opacity-50"
@@ -105,41 +182,65 @@ export function NotificationsPanel({
105
182
  />
106
183
  </Field>
107
184
 
108
- <Field label="Reply-to field key" hint="Field whose value is used as the reply-to address">
109
- <input
110
- type="text"
185
+ <Field
186
+ label="Reply-To"
187
+ hint="Defaults to the submitter’s email. Override with a specific email field if needed."
188
+ >
189
+ <select
111
190
  value={settings.replyToFieldKey ?? ''}
112
191
  onChange={(e) => patch({ replyToFieldKey: e.target.value || null })}
113
- placeholder="email"
114
- className={inputCls}
115
- />
192
+ className={selectCls}
193
+ aria-label="Reply-To field"
194
+ >
195
+ <option value="">Submitter email (auto)</option>
196
+ {emailFields.map((f) => (
197
+ <option key={f.id || f.key} value={f.key}>
198
+ {`${f.label || f.key} ({{${f.key}}})`}
199
+ </option>
200
+ ))}
201
+ </select>
116
202
  </Field>
117
203
 
118
- <Field label="Subject template">
204
+ <Field
205
+ label="Subject template"
206
+ hint="Leave blank for “New submission: {form} — {website}”. Use shortcodes below to customize."
207
+ >
119
208
  <input
120
209
  type="text"
121
210
  value={settings.subjectTemplate ?? ''}
122
211
  onChange={(e) => patch({ subjectTemplate: e.target.value })}
123
- placeholder="New submission from {{name}}"
212
+ placeholder="New submission: {{formName}} {{siteName}}"
124
213
  className={inputCls}
125
214
  />
126
215
  </Field>
127
216
 
128
- <Field label="Message template">
217
+ <Field
218
+ label="Message template"
219
+ hint="Leave blank for the built-in intro. Field values and attribution are appended below when enabled."
220
+ >
129
221
  <textarea
130
222
  rows={3}
131
223
  value={settings.messageTemplate ?? ''}
132
224
  onChange={(e) => patch({ messageTemplate: e.target.value })}
225
+ placeholder="Optional custom intro — leave blank for the default template"
133
226
  className={inputCls}
134
227
  />
135
228
  </Field>
136
229
 
230
+ <ShortcodeGlossary fields={fields} />
231
+
137
232
  <ToggleRow
138
233
  label="Include all fields"
139
234
  help="Append every submitted field to the notification body."
140
235
  checked={settings.includeFields ?? true}
141
236
  onChange={(v) => patch({ includeFields: v })}
142
237
  />
238
+ <ToggleRow
239
+ label="Include attribution"
240
+ help="Append marketing attribution (source, medium, channel, landing page, referrer) when captured."
241
+ checked={settings.includeAttribution ?? true}
242
+ onChange={(v) => patch({ includeAttribution: v })}
243
+ />
143
244
  <ToggleRow
144
245
  label="Exclude sensitive fields"
145
246
  help="Omit fields marked as sensitive/PII from the email."
@@ -156,11 +257,30 @@ export function NotificationsPanel({
156
257
  />
157
258
  {settings.autoresponderEnabled && (
158
259
  <div className="mt-3 space-y-3">
260
+ <Field
261
+ label="Auto-response email field"
262
+ hint="Defaults to the submitter’s email when left on Auto."
263
+ >
264
+ <select
265
+ value={settings.autoresponderEmailFieldKey ?? ''}
266
+ onChange={(e) => patch({ autoresponderEmailFieldKey: e.target.value || null })}
267
+ className={selectCls}
268
+ aria-label="Auto-response email field"
269
+ >
270
+ <option value="">Submitter email (auto)</option>
271
+ {emailFields.map((f) => (
272
+ <option key={f.id || f.key} value={f.key}>
273
+ {f.label || f.key}
274
+ </option>
275
+ ))}
276
+ </select>
277
+ </Field>
159
278
  <Field label="Auto-response subject">
160
279
  <input
161
280
  type="text"
162
281
  value={settings.autoresponderSubject ?? ''}
163
282
  onChange={(e) => patch({ autoresponderSubject: e.target.value })}
283
+ placeholder="We received your submission"
164
284
  className={inputCls}
165
285
  />
166
286
  </Field>
@@ -169,6 +289,7 @@ export function NotificationsPanel({
169
289
  rows={3}
170
290
  value={settings.autoresponderBody ?? ''}
171
291
  onChange={(e) => patch({ autoresponderBody: e.target.value })}
292
+ placeholder="Thanks, {{senderName}}! We received your submission…"
172
293
  className={inputCls}
173
294
  />
174
295
  </Field>
@@ -180,12 +301,21 @@ export function NotificationsPanel({
180
301
  <p className="text-muted-foreground text-xs">
181
302
  For CRM integrations (Zapier, HubSpot via Zapier, etc.), use the{' '}
182
303
  <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).
304
+ notifications send when recipients are configured and your email provider is set up in{' '}
305
+ <span className="font-medium">Settings → Email</span> (or via{' '}
306
+ <span className="font-mono">RESEND_API_KEY</span> / SMTP).
185
307
  </p>
186
308
 
187
- <div className="flex justify-end">
188
- <button className={btnPrimary} onClick={save} disabled={saving}>
309
+ <div className="flex flex-wrap items-center justify-end gap-2">
310
+ <button
311
+ type="button"
312
+ className={btnSecondary}
313
+ onClick={() => void sendTest()}
314
+ disabled={testing || saving || !settings.enabled || settings.recipients.length === 0}
315
+ >
316
+ {testing ? 'Sending test…' : 'Send test'}
317
+ </button>
318
+ <button type="button" className={btnPrimary} onClick={() => void save()} disabled={saving}>
189
319
  {saving ? 'Saving…' : 'Save notifications'}
190
320
  </button>
191
321
  </div>
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Copy-paste glossary of notification shortcodes (built-ins + form field keys).
5
+ * Used by the Notifications panel so admins never have to guess `{{token}}` names.
6
+ *
7
+ * Built-in keys must stay in sync with `FORM_NOTIFICATION_SHORTCODES` in
8
+ * `packages/cms-core/src/forms/notify.ts`.
9
+ */
10
+ import { useState } from 'react'
11
+ import { Check, Copy } from 'lucide-react'
12
+ import { toast } from 'sonner'
13
+ import type { FormField } from '../../lib/forms-service.js'
14
+
15
+ /** Mirrors cms-core `FORM_NOTIFICATION_SHORTCODES`. */
16
+ const BUILTIN_SHORTCODES = [
17
+ { key: 'formName', description: 'Form name' },
18
+ { key: 'siteName', description: 'Website name (SEO settings)' },
19
+ { key: 'siteUrl', description: 'Website URL (SEO settings)' },
20
+ { key: 'entryNumber', description: 'Submission number (e.g. #00042)' },
21
+ { key: 'senderName', description: 'Derived submitter name' },
22
+ { key: 'senderEmail', description: 'Derived submitter email' },
23
+ { key: 'submittedAt', description: 'Submission timestamp (ISO)' },
24
+ ] as const
25
+
26
+ function token(key: string): string {
27
+ return `{{${key}}}`
28
+ }
29
+
30
+ async function copyText(value: string, label: string) {
31
+ try {
32
+ await navigator.clipboard.writeText(value)
33
+ toast.success(`Copied ${label}`)
34
+ return true
35
+ } catch {
36
+ toast.error('Copy failed — your browser blocked clipboard access.')
37
+ return false
38
+ }
39
+ }
40
+
41
+ export function ShortcodeGlossary({ fields = [] }: { fields?: FormField[] }) {
42
+ const fieldTokens = fields.filter(
43
+ (f) => f.type !== 'honeypot' && f.type !== 'divider' && f.type !== 'richtext',
44
+ )
45
+
46
+ const allTokens = [
47
+ ...BUILTIN_SHORTCODES.map((s) => token(s.key)),
48
+ ...fieldTokens.map((f) => token(f.key)),
49
+ ].join('\n')
50
+
51
+ return (
52
+ <div className="border-border bg-muted/40 space-y-3 rounded-md border p-3">
53
+ <div className="flex items-start justify-between gap-3">
54
+ <div className="min-w-0">
55
+ <p className="text-foreground text-sm font-medium">Shortcode glossary</p>
56
+ <p className="text-muted-foreground text-xs">
57
+ Paste these into subject or message templates. Leave templates blank to use the built-in
58
+ email (all fields + attribution).
59
+ </p>
60
+ </div>
61
+ <CopyButton
62
+ label="Copy all"
63
+ ariaLabel="Copy all shortcodes"
64
+ value={allTokens}
65
+ successLabel="all shortcodes"
66
+ />
67
+ </div>
68
+
69
+ <ShortcodeGroup title="Built-in">
70
+ {BUILTIN_SHORTCODES.map((s) => (
71
+ <ShortcodeRow key={s.key} code={token(s.key)} description={s.description} />
72
+ ))}
73
+ </ShortcodeGroup>
74
+
75
+ {fieldTokens.length > 0 && (
76
+ <ShortcodeGroup title="Form fields">
77
+ {fieldTokens.map((f) => (
78
+ <ShortcodeRow key={f.id || f.key} code={token(f.key)} description={f.label || f.key} />
79
+ ))}
80
+ </ShortcodeGroup>
81
+ )}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ function ShortcodeGroup({ title, children }: { title: string; children: React.ReactNode }) {
87
+ return (
88
+ <div>
89
+ <p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
90
+ {title}
91
+ </p>
92
+ <ul className="border-border divide-border bg-background divide-y overflow-hidden rounded-md border">
93
+ {children}
94
+ </ul>
95
+ </div>
96
+ )
97
+ }
98
+
99
+ function ShortcodeRow({ code, description }: { code: string; description: string }) {
100
+ return (
101
+ <li className="flex items-center justify-between gap-2 px-2.5 py-1.5">
102
+ <div className="min-w-0">
103
+ <code className="text-foreground font-mono text-xs">{code}</code>
104
+ <span className="text-muted-foreground ml-2 text-xs">{description}</span>
105
+ </div>
106
+ <CopyButton label="Copy" ariaLabel={`Copy ${code}`} value={code} successLabel={code} />
107
+ </li>
108
+ )
109
+ }
110
+
111
+ function CopyButton({
112
+ label,
113
+ ariaLabel,
114
+ value,
115
+ successLabel,
116
+ }: {
117
+ label: string
118
+ ariaLabel: string
119
+ value: string
120
+ successLabel: string
121
+ }) {
122
+ const [copied, setCopied] = useState(false)
123
+
124
+ async function onCopy() {
125
+ const ok = await copyText(value, successLabel)
126
+ if (!ok) return
127
+ setCopied(true)
128
+ setTimeout(() => setCopied(false), 1500)
129
+ }
130
+
131
+ return (
132
+ <button
133
+ type="button"
134
+ onClick={() => void onCopy()}
135
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none"
136
+ aria-label={ariaLabel}
137
+ >
138
+ {copied ? (
139
+ <Check className="h-3.5 w-3.5" aria-hidden />
140
+ ) : (
141
+ <Copy className="h-3.5 w-3.5" aria-hidden />
142
+ )}
143
+ {copied ? 'Copied' : label}
144
+ </button>
145
+ )
146
+ }
@@ -155,7 +155,11 @@ export function WebhooksPanel({ formId, formSlug }: { formId: string; formSlug:
155
155
  <li>
156
156
  Map fields in your action step — submission values arrive under{' '}
157
157
  <span className="font-mono text-xs">data.email</span>,{' '}
158
- <span className="font-mono text-xs">data.name</span>, etc.
158
+ <span className="font-mono text-xs">data.name</span>, etc. Marketing attribution
159
+ (when captured) arrives under{' '}
160
+ <span className="font-mono text-xs">attribution.source</span>,{' '}
161
+ <span className="font-mono text-xs">attribution.medium</span>,{' '}
162
+ <span className="font-mono text-xs">attribution.channel</span>, etc.
159
163
  </li>
160
164
  <li>
161
165
  Use event <span className="font-mono text-xs">form.submitted</span> only (spam hits
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Modal } from './Modal.js'
5
+ import { Button } from './Button.js'
6
+
7
+ export interface ReauthDialogProps {
8
+ open: boolean
9
+ onClose: () => void
10
+ /** Called with the entered password. Resolve on success; throw or return without closing on failure. */
11
+ onSubmit: (password: string) => Promise<void>
12
+ title?: string
13
+ description?: string
14
+ confirmLabel?: string
15
+ /** When true, the confirm button uses the destructive (danger) variant. */
16
+ destructive?: boolean
17
+ }
18
+
19
+ /**
20
+ * Password re-auth modal for sensitive admin actions. Prefer this over
21
+ * `window.prompt()` so the flow stays in-theme and accessible.
22
+ */
23
+ export function ReauthDialog({
24
+ open,
25
+ onClose,
26
+ onSubmit,
27
+ title = "Confirm it's you",
28
+ description = 'This is a sensitive action. Re-enter your password to continue.',
29
+ confirmLabel = 'Confirm',
30
+ destructive = false,
31
+ }: ReauthDialogProps) {
32
+ const [password, setPassword] = useState('')
33
+ const [busy, setBusy] = useState(false)
34
+
35
+ const resetAndClose = () => {
36
+ setPassword('')
37
+ onClose()
38
+ }
39
+
40
+ const submit = async () => {
41
+ if (!password || busy) return
42
+ setBusy(true)
43
+ try {
44
+ await onSubmit(password)
45
+ setPassword('')
46
+ } finally {
47
+ setBusy(false)
48
+ }
49
+ }
50
+
51
+ return (
52
+ <Modal
53
+ open={open}
54
+ onClose={resetAndClose}
55
+ title={title}
56
+ actions={
57
+ <>
58
+ <Button variant="ghost" onClick={resetAndClose} disabled={busy}>
59
+ Cancel
60
+ </Button>
61
+ <Button
62
+ variant={destructive ? 'danger' : 'primary'}
63
+ loading={busy}
64
+ disabled={!password || busy}
65
+ onClick={submit}
66
+ >
67
+ {confirmLabel}
68
+ </Button>
69
+ </>
70
+ }
71
+ >
72
+ <p className="text-muted-foreground mb-3 text-sm">{description}</p>
73
+ <label
74
+ htmlFor="reauth-password"
75
+ className="text-card-foreground mb-1 block text-sm font-medium"
76
+ >
77
+ Your password
78
+ </label>
79
+ <input
80
+ id="reauth-password"
81
+ type="password"
82
+ value={password}
83
+ onChange={(e) => setPassword(e.target.value)}
84
+ onKeyDown={(e) => {
85
+ if (e.key === 'Enter') void submit()
86
+ }}
87
+ autoComplete="current-password"
88
+ autoFocus
89
+ className="border-border bg-input-background text-foreground focus:ring-brand w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
90
+ />
91
+ </Modal>
92
+ )
93
+ }
@@ -28,6 +28,8 @@ export { Modal } from './Modal.js'
28
28
  export type { ModalProps } from './Modal.js'
29
29
  export { ConfirmDialog } from './ConfirmDialog.js'
30
30
  export type { ConfirmDialogProps } from './ConfirmDialog.js'
31
+ export { ReauthDialog } from './ReauthDialog.js'
32
+ export type { ReauthDialogProps } from './ReauthDialog.js'
31
33
  export { DataTable } from './DataTable.js'
32
34
  export type { DataTableProps } from './DataTable.js'
33
35
  export { Pagination } from './Pagination.js'
@@ -10,7 +10,7 @@
10
10
  * `packages/cms-core/src/api/handlers.ts` (PR 2). Domain types are re-used
11
11
  * from `@actuate-media/cms-core` so the admin can never drift from the server.
12
12
  */
13
- import { cmsApi } from './api.js'
13
+ import { cmsApi, getApiBase } from './api.js'
14
14
  import type {
15
15
  FormDefinition,
16
16
  FormSchemaVersion,
@@ -251,6 +251,18 @@ export async function setNotifyEnabled(
251
251
  return { data: res.data, error: res.error }
252
252
  }
253
253
 
254
+ /** Send a sample notification email to the first configured recipient. */
255
+ export async function testNotificationSettings(id: string): Promise<{
256
+ data?: { notified: number; autoresponded: boolean; skippedReason?: string }
257
+ error?: string
258
+ }> {
259
+ const res = await cmsApi<{ notified: number; autoresponded: boolean; skippedReason?: string }>(
260
+ `/forms/${encodeURIComponent(id)}/notifications/test`,
261
+ { method: 'POST', body: '{}' },
262
+ )
263
+ return { data: res.data, error: res.error }
264
+ }
265
+
254
266
  // ─── Embed ───────────────────────────────────────────────────────────────
255
267
 
256
268
  export async function fetchEmbedSettings(id: string): Promise<FormEmbedSettings> {
@@ -437,3 +449,47 @@ export async function bulkUpdateEntries(
437
449
  })
438
450
  return { data: res.data, error: res.error }
439
451
  }
452
+
453
+ export type EntriesExportFormat = 'csv' | 'xlsx'
454
+
455
+ /**
456
+ * Download the full server-side entries export (all pages, decrypted values,
457
+ * attribution columns — bounded server-side to 5,000 rows). Scoped to one
458
+ * form when `formId` is set, otherwise exports across all forms. Uses raw
459
+ * `fetch` because the response is a file, not the JSON envelope `cmsApi`
460
+ * unwraps.
461
+ */
462
+ export async function downloadEntriesExport(
463
+ options: {
464
+ formId?: string
465
+ format?: EntriesExportFormat
466
+ } = {},
467
+ ): Promise<{ error?: string }> {
468
+ const format: EntriesExportFormat = options.format ?? 'csv'
469
+ const qs = new URLSearchParams({ format })
470
+ if (options.formId) qs.set('formId', options.formId)
471
+
472
+ let res: Response
473
+ try {
474
+ res = await fetch(`${getApiBase()}/forms/entries/export?${qs.toString()}`, {
475
+ credentials: 'include',
476
+ })
477
+ } catch (err) {
478
+ return { error: err instanceof Error ? err.message : 'Network error' }
479
+ }
480
+ if (!res.ok) {
481
+ const body = (await res.json().catch(() => ({}))) as { error?: string }
482
+ return { error: body.error || `Export failed (${res.status})` }
483
+ }
484
+
485
+ const blob = await res.blob()
486
+ const disposition = res.headers.get('content-disposition') ?? ''
487
+ const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `entries.${format}`
488
+ const url = URL.createObjectURL(blob)
489
+ const a = document.createElement('a')
490
+ a.href = url
491
+ a.download = filename
492
+ a.click()
493
+ URL.revokeObjectURL(url)
494
+ return {}
495
+ }
@@ -776,6 +776,9 @@ export interface RedirectRule {
776
776
  preserveQuery: boolean
777
777
  hits: number
778
778
  lastHitAt: string | null
779
+ /** True when lastHitAt is null — never recorded a public hit. */
780
+ neverHit: boolean
781
+ /** True when last hit was more than 60 days ago (never-hit is not stale). */
779
782
  stale: boolean
780
783
  source: string
781
784
  notes: string | null
@@ -786,6 +789,7 @@ export interface RedirectRule {
786
789
  export interface RedirectOverview {
787
790
  activeRedirects: number
788
791
  staleRedirects: number
792
+ neverHitRedirects: number
789
793
  notFoundCount: number
790
794
  notFoundHits: number
791
795
  chainsAndLoops: number
@@ -825,6 +829,7 @@ export async function fetchRedirectOverview(): Promise<RedirectOverview> {
825
829
  res.data ?? {
826
830
  activeRedirects: 0,
827
831
  staleRedirects: 0,
832
+ neverHitRedirects: 0,
828
833
  notFoundCount: 0,
829
834
  notFoundHits: 0,
830
835
  chainsAndLoops: 0,