@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +38 -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.d.ts +2 -0
  7. package/dist/__tests__/views/form-editor.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/views/form-editor.render.test.js +146 -0
  9. package/dist/__tests__/views/form-editor.render.test.js.map +1 -0
  10. package/dist/__tests__/views/form-entries.render.test.js +20 -0
  11. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  12. package/dist/__tests__/views/form-submissions.render.test.js +12 -0
  13. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/forms/ExportMenu.d.ts +8 -0
  16. package/dist/components/forms/ExportMenu.d.ts.map +1 -0
  17. package/dist/components/forms/ExportMenu.js +32 -0
  18. package/dist/components/forms/ExportMenu.js.map +1 -0
  19. package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
  20. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
  21. package/dist/components/forms/FormEntryDetailPane.js +74 -8
  22. package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
  23. package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
  24. package/dist/components/forms/WebhooksPanel.js +1 -1
  25. package/dist/components/forms/WebhooksPanel.js.map +1 -1
  26. package/dist/lib/forms-service.d.ts +29 -3
  27. package/dist/lib/forms-service.d.ts.map +1 -1
  28. package/dist/lib/forms-service.js +37 -1
  29. package/dist/lib/forms-service.js.map +1 -1
  30. package/dist/views/FormEditor.d.ts.map +1 -1
  31. package/dist/views/FormEditor.js +211 -116
  32. package/dist/views/FormEditor.js.map +1 -1
  33. package/dist/views/FormEntries.d.ts.map +1 -1
  34. package/dist/views/FormEntries.js +8 -36
  35. package/dist/views/FormEntries.js.map +1 -1
  36. package/dist/views/FormSubmissions.d.ts.map +1 -1
  37. package/dist/views/FormSubmissions.js +7 -36
  38. package/dist/views/FormSubmissions.js.map +1 -1
  39. package/package.json +9 -9
  40. package/src/__tests__/components/attribution-rows.test.ts +93 -0
  41. package/src/__tests__/views/form-editor.render.test.tsx +186 -0
  42. package/src/__tests__/views/form-entries.render.test.tsx +29 -0
  43. package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
  44. package/src/components/forms/ExportMenu.tsx +70 -0
  45. package/src/components/forms/FormEntryDetailPane.tsx +82 -8
  46. package/src/components/forms/WebhooksPanel.tsx +5 -1
  47. package/src/lib/forms-service.ts +61 -2
  48. package/src/views/FormEditor.tsx +655 -510
  49. package/src/views/FormEntries.tsx +7 -46
  50. 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
- function attributionRows(entry: FormSubmission): AttrRow[] {
62
- return [
63
- { label: 'Source', value: entry.utmSource },
64
- { label: 'Medium', value: entry.utmMedium },
65
- { label: 'Campaign', value: entry.utmCampaign },
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
- ].filter((r) => r.value)
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 ? attributionRows(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
@@ -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: Partial<Pick<FormDefinition, 'name' | 'slug' | 'description' | 'status'>>,
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
+ }