@actuate-media/cms-admin 0.76.7 → 0.78.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 +38 -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.d.ts +2 -0
- package/dist/__tests__/views/form-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-editor.render.test.js +146 -0
- package/dist/__tests__/views/form-editor.render.test.js.map +1 -0
- 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/actuate-admin.css +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/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/lib/forms-service.d.ts +29 -3
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +37 -1
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/FormEditor.d.ts.map +1 -1
- package/dist/views/FormEditor.js +211 -116
- 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/package.json +9 -9
- package/src/__tests__/components/attribution-rows.test.ts +93 -0
- package/src/__tests__/views/form-editor.render.test.tsx +186 -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/components/forms/ExportMenu.tsx +70 -0
- package/src/components/forms/FormEntryDetailPane.tsx +82 -8
- package/src/components/forms/WebhooksPanel.tsx +5 -1
- package/src/lib/forms-service.ts +61 -2
- package/src/views/FormEditor.tsx +655 -510
- package/src/views/FormEntries.tsx +7 -46
- package/src/views/FormSubmissions.tsx +6 -46
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export dropdown shared by the per-form Submissions view and the All
|
|
5
|
+
* Entries inbox. Downloads the *server-side* export (every page, decrypted
|
|
6
|
+
* values, attribution columns — up to 5,000 rows), not just the rows
|
|
7
|
+
* currently rendered, in CSV or Excel format.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
11
|
+
import { ChevronDown, Download, FileSpreadsheet, FileText } from 'lucide-react'
|
|
12
|
+
import { toast } from 'sonner'
|
|
13
|
+
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
14
|
+
import { downloadEntriesExport, type EntriesExportFormat } from '../../lib/forms-service.js'
|
|
15
|
+
import { btnSecondary } from './primitives.js'
|
|
16
|
+
|
|
17
|
+
export interface ExportMenuProps {
|
|
18
|
+
/** Scope the export to one form; omit to export entries across all forms. */
|
|
19
|
+
formId?: string
|
|
20
|
+
/** Disable the trigger (e.g. no entries to export). */
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const itemClass =
|
|
25
|
+
'text-card-foreground data-highlighted:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none'
|
|
26
|
+
|
|
27
|
+
export function ExportMenu({ formId, disabled }: ExportMenuProps) {
|
|
28
|
+
const [busy, setBusy] = useState(false)
|
|
29
|
+
|
|
30
|
+
const run = async (format: EntriesExportFormat) => {
|
|
31
|
+
setBusy(true)
|
|
32
|
+
try {
|
|
33
|
+
const res = await downloadEntriesExport({ formId, format })
|
|
34
|
+
if (res.error) toast.error(res.error)
|
|
35
|
+
} finally {
|
|
36
|
+
setBusy(false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<DropdownMenu.Root>
|
|
42
|
+
<DropdownMenu.Trigger asChild>
|
|
43
|
+
<button type="button" className={btnSecondary} disabled={disabled || busy}>
|
|
44
|
+
<Download className="h-4 w-4" aria-hidden />
|
|
45
|
+
{busy ? 'Exporting…' : 'Export'}
|
|
46
|
+
<ChevronDown className="h-4 w-4" aria-hidden />
|
|
47
|
+
</button>
|
|
48
|
+
</DropdownMenu.Trigger>
|
|
49
|
+
<DropdownMenu.Portal container={adminPortalContainer()}>
|
|
50
|
+
<DropdownMenu.Content
|
|
51
|
+
align="end"
|
|
52
|
+
sideOffset={4}
|
|
53
|
+
className="border-border bg-card z-50 min-w-52 rounded-lg border p-1 shadow-md"
|
|
54
|
+
>
|
|
55
|
+
<DropdownMenu.Label className="text-muted-foreground px-2 py-1.5 text-xs">
|
|
56
|
+
Exports all submissions with attribution
|
|
57
|
+
</DropdownMenu.Label>
|
|
58
|
+
<DropdownMenu.Item onSelect={() => void run('csv')} className={itemClass}>
|
|
59
|
+
<FileText size={14} aria-hidden />
|
|
60
|
+
CSV (.csv)
|
|
61
|
+
</DropdownMenu.Item>
|
|
62
|
+
<DropdownMenu.Item onSelect={() => void run('xlsx')} className={itemClass}>
|
|
63
|
+
<FileSpreadsheet size={14} aria-hidden />
|
|
64
|
+
Excel (.xlsx)
|
|
65
|
+
</DropdownMenu.Item>
|
|
66
|
+
</DropdownMenu.Content>
|
|
67
|
+
</DropdownMenu.Portal>
|
|
68
|
+
</DropdownMenu.Root>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -58,14 +58,88 @@ interface AttrRow {
|
|
|
58
58
|
value: string | null | undefined
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
const CHANNEL_LABELS: Record<string, string> = {
|
|
62
|
+
paid: 'Paid',
|
|
63
|
+
search_organic: 'Organic search',
|
|
64
|
+
social: 'Social',
|
|
65
|
+
email: 'Email',
|
|
66
|
+
ai: 'AI',
|
|
67
|
+
referral: 'Referral',
|
|
68
|
+
direct: 'Direct',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CLICK_ID_LABELS: Record<string, string> = {
|
|
72
|
+
gclid: 'GCLID (Google Ads)',
|
|
73
|
+
fbclid: 'FBCLID (Meta)',
|
|
74
|
+
msclkid: 'MSCLKID (Microsoft)',
|
|
75
|
+
ttclid: 'TTCLID (TikTok)',
|
|
76
|
+
li_fat_id: 'LinkedIn click ID',
|
|
77
|
+
dclid: 'DCLID (Display)',
|
|
78
|
+
twclid: 'TWCLID (X Ads)',
|
|
79
|
+
rdt_cid: 'Reddit click ID',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEVICE_LABELS: Record<string, string> = {
|
|
83
|
+
desktop: 'Desktop',
|
|
84
|
+
tablet: 'Tablet',
|
|
85
|
+
mobile: 'Mobile',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function attrString(blob: Record<string, unknown>, key: string): string {
|
|
89
|
+
const v = blob[key]
|
|
90
|
+
return typeof v === 'string' ? v : ''
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the attribution rows for an entry. Prefers the full client-captured
|
|
95
|
+
* blob (channel, click IDs, first/last touch) and falls back to the
|
|
96
|
+
* denormalized UTM columns for entries captured before attribution shipped.
|
|
97
|
+
* Exported for tests.
|
|
98
|
+
*/
|
|
99
|
+
export function buildAttributionRows(entry: FormSubmission): AttrRow[] {
|
|
100
|
+
const blob =
|
|
101
|
+
entry.attribution && typeof entry.attribution === 'object' && !Array.isArray(entry.attribution)
|
|
102
|
+
? entry.attribution
|
|
103
|
+
: {}
|
|
104
|
+
|
|
105
|
+
const channel = attrString(blob, 'channel')
|
|
106
|
+
const referrerName = attrString(blob, 'referrerName')
|
|
107
|
+
const channelLabel = channel
|
|
108
|
+
? `${CHANNEL_LABELS[channel] ?? channel}${referrerName ? ` · ${referrerName}` : ''}`
|
|
109
|
+
: ''
|
|
110
|
+
|
|
111
|
+
const rows: AttrRow[] = [
|
|
112
|
+
{ label: 'Channel', value: channelLabel },
|
|
113
|
+
{ label: 'Source', value: attrString(blob, 'source') || entry.utmSource },
|
|
114
|
+
{ label: 'Medium', value: attrString(blob, 'medium') || entry.utmMedium },
|
|
115
|
+
{ label: 'Campaign', value: attrString(blob, 'campaign') || entry.utmCampaign },
|
|
116
|
+
{ label: 'Term', value: attrString(blob, 'term') },
|
|
117
|
+
{ label: 'Content', value: attrString(blob, 'content') },
|
|
118
|
+
{ label: 'Landing page', value: attrString(blob, 'landingPage') },
|
|
66
119
|
{ label: 'Page', value: entry.pageUrl },
|
|
67
|
-
{ label: 'Referrer', value: entry.referrer },
|
|
68
|
-
|
|
120
|
+
{ label: 'Referrer', value: attrString(blob, 'referrer') || entry.referrer },
|
|
121
|
+
{
|
|
122
|
+
label: 'Device',
|
|
123
|
+
value: DEVICE_LABELS[attrString(blob, 'deviceType')] ?? '',
|
|
124
|
+
},
|
|
125
|
+
{ label: 'First touch', value: formatDateTimeOrEmpty(attrString(blob, 'firstTouchAt')) },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
const clickIds = blob.clickIds
|
|
129
|
+
if (clickIds && typeof clickIds === 'object' && !Array.isArray(clickIds)) {
|
|
130
|
+
for (const [key, label] of Object.entries(CLICK_ID_LABELS)) {
|
|
131
|
+
const value = (clickIds as Record<string, unknown>)[key]
|
|
132
|
+
if (typeof value === 'string' && value) rows.push({ label, value })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return rows.filter((r) => r.value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatDateTimeOrEmpty(iso: string): string {
|
|
140
|
+
if (!iso) return ''
|
|
141
|
+
const formatted = formatDateTime(iso)
|
|
142
|
+
return formatted === '—' ? '' : formatted
|
|
69
143
|
}
|
|
70
144
|
|
|
71
145
|
export interface FormEntryDetailPaneProps {
|
|
@@ -155,7 +229,7 @@ export function FormEntryDetailPane({
|
|
|
155
229
|
|
|
156
230
|
const entry = detail?.entry
|
|
157
231
|
const senderName = entry?.senderName || 'Anonymous'
|
|
158
|
-
const attrRows = entry ?
|
|
232
|
+
const attrRows = entry ? buildAttributionRows(entry) : []
|
|
159
233
|
|
|
160
234
|
return (
|
|
161
235
|
<>
|
|
@@ -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
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
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,
|
|
17
17
|
FormField,
|
|
18
|
+
FormAnalyticsConfig,
|
|
18
19
|
FormNotificationSettings,
|
|
19
20
|
FormEmbedSettings,
|
|
20
21
|
FormWebhookConfig,
|
|
@@ -35,6 +36,7 @@ export type {
|
|
|
35
36
|
FormFieldType,
|
|
36
37
|
FormFieldOption,
|
|
37
38
|
FieldValidationRules,
|
|
39
|
+
FormAnalyticsConfig,
|
|
38
40
|
FormNotificationSettings,
|
|
39
41
|
FormEmbedSettings,
|
|
40
42
|
FormWebhookConfig,
|
|
@@ -132,6 +134,9 @@ export interface CreateFormPayload {
|
|
|
132
134
|
status?: FormStatus
|
|
133
135
|
template?: string
|
|
134
136
|
fields?: FormField[]
|
|
137
|
+
successMessage?: string
|
|
138
|
+
redirectUrl?: string
|
|
139
|
+
analytics?: FormAnalyticsConfig
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
export async function createForm(
|
|
@@ -144,9 +149,19 @@ export async function createForm(
|
|
|
144
149
|
return { data: res.data, error: res.error }
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
/** Patch shape accepted by `PATCH /forms/:id` (cms-core `UpdateFormInput`). */
|
|
153
|
+
export interface UpdateFormPayload {
|
|
154
|
+
name?: string
|
|
155
|
+
description?: string
|
|
156
|
+
status?: FormStatus
|
|
157
|
+
successMessage?: string
|
|
158
|
+
redirectUrl?: string
|
|
159
|
+
analytics?: FormAnalyticsConfig
|
|
160
|
+
}
|
|
161
|
+
|
|
147
162
|
export async function updateForm(
|
|
148
163
|
id: string,
|
|
149
|
-
payload:
|
|
164
|
+
payload: UpdateFormPayload,
|
|
150
165
|
): Promise<{ data?: FormDefinition; error?: string }> {
|
|
151
166
|
const res = await cmsApi<FormDefinition>(`/forms/${encodeURIComponent(id)}`, {
|
|
152
167
|
method: 'PATCH',
|
|
@@ -422,3 +437,47 @@ export async function bulkUpdateEntries(
|
|
|
422
437
|
})
|
|
423
438
|
return { data: res.data, error: res.error }
|
|
424
439
|
}
|
|
440
|
+
|
|
441
|
+
export type EntriesExportFormat = 'csv' | 'xlsx'
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Download the full server-side entries export (all pages, decrypted values,
|
|
445
|
+
* attribution columns — bounded server-side to 5,000 rows). Scoped to one
|
|
446
|
+
* form when `formId` is set, otherwise exports across all forms. Uses raw
|
|
447
|
+
* `fetch` because the response is a file, not the JSON envelope `cmsApi`
|
|
448
|
+
* unwraps.
|
|
449
|
+
*/
|
|
450
|
+
export async function downloadEntriesExport(
|
|
451
|
+
options: {
|
|
452
|
+
formId?: string
|
|
453
|
+
format?: EntriesExportFormat
|
|
454
|
+
} = {},
|
|
455
|
+
): Promise<{ error?: string }> {
|
|
456
|
+
const format: EntriesExportFormat = options.format ?? 'csv'
|
|
457
|
+
const qs = new URLSearchParams({ format })
|
|
458
|
+
if (options.formId) qs.set('formId', options.formId)
|
|
459
|
+
|
|
460
|
+
let res: Response
|
|
461
|
+
try {
|
|
462
|
+
res = await fetch(`${getApiBase()}/forms/entries/export?${qs.toString()}`, {
|
|
463
|
+
credentials: 'include',
|
|
464
|
+
})
|
|
465
|
+
} catch (err) {
|
|
466
|
+
return { error: err instanceof Error ? err.message : 'Network error' }
|
|
467
|
+
}
|
|
468
|
+
if (!res.ok) {
|
|
469
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
470
|
+
return { error: body.error || `Export failed (${res.status})` }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const blob = await res.blob()
|
|
474
|
+
const disposition = res.headers.get('content-disposition') ?? ''
|
|
475
|
+
const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `entries.${format}`
|
|
476
|
+
const url = URL.createObjectURL(blob)
|
|
477
|
+
const a = document.createElement('a')
|
|
478
|
+
a.href = url
|
|
479
|
+
a.download = filename
|
|
480
|
+
a.click()
|
|
481
|
+
URL.revokeObjectURL(url)
|
|
482
|
+
return {}
|
|
483
|
+
}
|