@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.
- package/CHANGELOG.md +35 -0
- package/dist/__tests__/components/attribution-rows.test.d.ts +2 -0
- package/dist/__tests__/components/attribution-rows.test.d.ts.map +1 -0
- package/dist/__tests__/components/attribution-rows.test.js +82 -0
- package/dist/__tests__/components/attribution-rows.test.js.map +1 -0
- package/dist/__tests__/views/form-editor.render.test.js +1 -0
- package/dist/__tests__/views/form-editor.render.test.js.map +1 -1
- package/dist/__tests__/views/form-entries.render.test.js +20 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.js +12 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +2 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +2 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/components/forms/ExportMenu.d.ts +8 -0
- package/dist/components/forms/ExportMenu.d.ts.map +1 -0
- package/dist/components/forms/ExportMenu.js +32 -0
- package/dist/components/forms/ExportMenu.js.map +1 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
- package/dist/components/forms/FormEntryDetailPane.js +74 -8
- package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.js +1 -1
- package/dist/components/forms/FormSchemaPane.js.map +1 -1
- package/dist/components/forms/NotificationsPanel.d.ts +4 -1
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
- package/dist/components/forms/NotificationsPanel.js +66 -12
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/ShortcodeGlossary.d.ts +5 -0
- package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -0
- package/dist/components/forms/ShortcodeGlossary.js +62 -0
- package/dist/components/forms/ShortcodeGlossary.js.map +1 -0
- package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
- package/dist/components/forms/WebhooksPanel.js +1 -1
- package/dist/components/forms/WebhooksPanel.js.map +1 -1
- package/dist/components/ui/ReauthDialog.d.ts +17 -0
- package/dist/components/ui/ReauthDialog.d.ts.map +1 -0
- package/dist/components/ui/ReauthDialog.js +34 -0
- package/dist/components/ui/ReauthDialog.js.map +1 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/lib/forms-service.d.ts +23 -0
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +42 -1
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +4 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +1 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +22 -23
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/FormEditor.js +1 -1
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/FormEntries.d.ts.map +1 -1
- package/dist/views/FormEntries.js +8 -36
- package/dist/views/FormEntries.js.map +1 -1
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +7 -36
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +3 -2
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +2 -30
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/attribution-rows.test.ts +93 -0
- package/src/__tests__/views/form-editor.render.test.tsx +1 -0
- package/src/__tests__/views/form-entries.render.test.tsx +29 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
- package/src/__tests__/views/forms-list.render.test.tsx +2 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +2 -0
- package/src/components/forms/ExportMenu.tsx +70 -0
- package/src/components/forms/FormEntryDetailPane.tsx +82 -8
- package/src/components/forms/FormSchemaPane.tsx +1 -1
- package/src/components/forms/NotificationsPanel.tsx +150 -20
- package/src/components/forms/ShortcodeGlossary.tsx +146 -0
- package/src/components/forms/WebhooksPanel.tsx +5 -1
- package/src/components/ui/ReauthDialog.tsx +93 -0
- package/src/components/ui/index.ts +2 -0
- package/src/lib/forms-service.ts +57 -1
- package/src/lib/seo-service.ts +5 -0
- package/src/views/ApiKeys.tsx +48 -41
- package/src/views/FormEditor.tsx +1 -1
- package/src/views/FormEntries.tsx +7 -46
- package/src/views/FormSubmissions.tsx +6 -46
- package/src/views/seo/RedirectsTab.tsx +3 -1
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
212
|
+
placeholder="New submission: {{formName}} — {{siteName}}"
|
|
124
213
|
className={inputCls}
|
|
125
214
|
/>
|
|
126
215
|
</Field>
|
|
127
216
|
|
|
128
|
-
<Field
|
|
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-
|
|
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
|
|
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'
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -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,
|