@actuate-media/cms-admin 0.22.0 → 0.24.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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
- package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
- package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +112 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.js +110 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/EntriesTable.d.ts +28 -0
- package/dist/components/forms/EntriesTable.d.ts.map +1 -0
- package/dist/components/forms/EntriesTable.js +26 -0
- package/dist/components/forms/EntriesTable.js.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.js +145 -0
- package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +1 -1
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/primitives.d.ts +15 -0
- package/dist/components/forms/primitives.d.ts.map +1 -1
- package/dist/components/forms/primitives.js +36 -1
- package/dist/components/forms/primitives.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +44 -21
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-service.d.ts +36 -2
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +66 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/FormEntries.d.ts +5 -0
- package/dist/views/FormEntries.d.ts.map +1 -0
- package/dist/views/FormEntries.js +211 -0
- package/dist/views/FormEntries.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +204 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +26 -10
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
- package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
- package/src/__tests__/views/form-entries.render.test.tsx +133 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/components/forms/EntriesTable.tsx +197 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/NotificationsPanel.tsx +1 -1
- package/src/components/forms/primitives.tsx +101 -1
- package/src/layout/Sidebar.tsx +75 -17
- package/src/lib/forms-service.ts +99 -0
- package/src/views/FormEntries.tsx +449 -0
- package/src/views/FormSubmissions.tsx +391 -401
- package/src/views/Forms.tsx +145 -107
|
@@ -1,460 +1,450 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Monitor,
|
|
16
|
-
Tablet,
|
|
17
|
-
ExternalLink,
|
|
18
|
-
Loader2,
|
|
19
|
-
AlertTriangle,
|
|
20
|
-
} from 'lucide-react'
|
|
3
|
+
/**
|
|
4
|
+
* Per-form submissions (entries) — a compact, token-only table wired to the
|
|
5
|
+
* real entries API (`lib/forms-service.ts`). Replaces the previous card list.
|
|
6
|
+
*
|
|
7
|
+
* Features: summary chips, search + status pills (All / Unread / Starred /
|
|
8
|
+
* Archived), row selection with bulk actions (mark read, star, archive,
|
|
9
|
+
* delete), per-row star toggle, CSV export, and a detail drawer that opens on
|
|
10
|
+
* row click (marking the entry read). Loading / empty / filtered-empty / error
|
|
11
|
+
* states are all handled.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
14
|
+
import { ArrowLeft, Download, Inbox, Search } from 'lucide-react'
|
|
21
15
|
import { toast } from 'sonner'
|
|
22
|
-
import {
|
|
23
|
-
|
|
16
|
+
import {
|
|
17
|
+
bulkUpdateEntries,
|
|
18
|
+
deleteEntry,
|
|
19
|
+
fetchEntries,
|
|
20
|
+
fetchEntryCounts,
|
|
21
|
+
fetchFormById,
|
|
22
|
+
updateEntry,
|
|
23
|
+
type EntryCounts,
|
|
24
|
+
type FetchFormEntriesParams,
|
|
25
|
+
type FormSubmission,
|
|
26
|
+
} from '../lib/forms-service.js'
|
|
27
|
+
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
28
|
+
import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
|
|
29
|
+
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
30
|
+
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
31
|
+
import {
|
|
32
|
+
FormsEmptyState,
|
|
33
|
+
FormsErrorState,
|
|
34
|
+
FormsLoading,
|
|
35
|
+
FormsPagination,
|
|
36
|
+
SummaryChip,
|
|
37
|
+
btnSecondary,
|
|
38
|
+
} from '../components/forms/primitives.js'
|
|
24
39
|
|
|
25
|
-
interface
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
campaign: string
|
|
29
|
-
term: string
|
|
30
|
-
content: string
|
|
31
|
-
landingPage: string
|
|
32
|
-
referrer: string
|
|
33
|
-
deviceType: 'Desktop' | 'Mobile' | 'Tablet'
|
|
34
|
-
clickIds: Record<string, string | null>
|
|
35
|
-
capturedAt: string
|
|
40
|
+
export interface FormSubmissionsProps {
|
|
41
|
+
formId: string
|
|
42
|
+
onNavigate?: (path: string) => void
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
id: number
|
|
40
|
-
name: string
|
|
41
|
-
email: string
|
|
42
|
-
phone?: string
|
|
43
|
-
message: string
|
|
44
|
-
submittedAt: string
|
|
45
|
-
status: 'new' | 'read' | 'replied'
|
|
46
|
-
attribution: Attribution
|
|
47
|
-
}
|
|
45
|
+
type StatusFilter = 'all' | 'unread' | 'starred' | 'archived'
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (s === '(direct)') return 'bg-gray-100 text-gray-800'
|
|
56
|
-
return 'bg-purple-100 text-purple-800'
|
|
57
|
-
}
|
|
47
|
+
const FILTERS: { id: StatusFilter; label: string }[] = [
|
|
48
|
+
{ id: 'all', label: 'All' },
|
|
49
|
+
{ id: 'unread', label: 'Unread' },
|
|
50
|
+
{ id: 'starred', label: 'Starred' },
|
|
51
|
+
{ id: 'archived', label: 'Archived' },
|
|
52
|
+
]
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
54
|
+
const PAGE_SIZE = 25
|
|
55
|
+
|
|
56
|
+
function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
57
|
+
switch (filter) {
|
|
58
|
+
case 'unread':
|
|
59
|
+
return { unread: true }
|
|
60
|
+
case 'starred':
|
|
61
|
+
return { starred: true }
|
|
62
|
+
case 'archived':
|
|
63
|
+
return { archived: true }
|
|
64
|
+
default:
|
|
65
|
+
return {}
|
|
66
|
+
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return <Monitor className="h-3.5 w-3.5" />
|
|
69
|
+
function csvEscape(value: unknown): string {
|
|
70
|
+
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
71
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
function exportCsv(entries: FormSubmission[], formSlug: string) {
|
|
75
|
+
const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
|
|
76
|
+
const header = ['Entry', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
|
|
77
|
+
const lines = [header.map(csvEscape).join(',')]
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const row = [
|
|
80
|
+
e.entryNumber ?? '',
|
|
81
|
+
e.submittedAt,
|
|
82
|
+
e.senderName ?? '',
|
|
83
|
+
e.senderEmail ?? '',
|
|
84
|
+
e.senderPhone ?? '',
|
|
85
|
+
e.unread ? 'unread' : 'read',
|
|
86
|
+
...dataKeys.map((k) => e.data?.[k]),
|
|
87
|
+
]
|
|
88
|
+
lines.push(row.map(csvEscape).join(','))
|
|
89
|
+
}
|
|
90
|
+
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
91
|
+
const url = URL.createObjectURL(blob)
|
|
92
|
+
const a = document.createElement('a')
|
|
93
|
+
a.href = url
|
|
94
|
+
a.download = `${formSlug || 'form'}-submissions.csv`
|
|
95
|
+
a.click()
|
|
96
|
+
URL.revokeObjectURL(url)
|
|
78
97
|
}
|
|
79
98
|
|
|
80
99
|
export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
>(
|
|
84
|
-
const [
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
87
|
-
const [
|
|
100
|
+
const [formName, setFormName] = useState<string>('')
|
|
101
|
+
const [formSlug, setFormSlug] = useState<string>('')
|
|
102
|
+
const [entries, setEntries] = useState<FormSubmission[]>([])
|
|
103
|
+
const [total, setTotal] = useState(0)
|
|
104
|
+
const [counts, setCounts] = useState<EntryCounts | null>(null)
|
|
105
|
+
const [loading, setLoading] = useState(true)
|
|
106
|
+
const [error, setError] = useState<string | null>(null)
|
|
107
|
+
const [search, setSearch] = useState('')
|
|
108
|
+
const [filter, setFilter] = useState<StatusFilter>('all')
|
|
109
|
+
const [page, setPage] = useState(1)
|
|
110
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
111
|
+
const [activeEntry, setActiveEntry] = useState<string | null>(null)
|
|
112
|
+
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
113
|
+
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
|
88
114
|
|
|
89
|
-
|
|
115
|
+
// Form metadata for the title (loads once).
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
let cancelled = false
|
|
118
|
+
void fetchFormById(formId)
|
|
119
|
+
.then((form) => {
|
|
120
|
+
if (cancelled || !form) return
|
|
121
|
+
setFormName(form.name)
|
|
122
|
+
setFormSlug(form.slug)
|
|
123
|
+
})
|
|
124
|
+
.catch(() => {})
|
|
125
|
+
return () => {
|
|
126
|
+
cancelled = true
|
|
127
|
+
}
|
|
128
|
+
}, [formId])
|
|
90
129
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
(s.message ?? '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
97
|
-
const matchesStatus = filterStatus === 'all' || s.status === filterStatus
|
|
98
|
-
const matchesSource = filterSource === 'all' || s.attribution?.source === filterSource
|
|
99
|
-
return matchesSearch && matchesStatus && matchesSource
|
|
100
|
-
})
|
|
130
|
+
const loadCounts = useCallback(() => {
|
|
131
|
+
void fetchEntryCounts({ formId })
|
|
132
|
+
.then(setCounts)
|
|
133
|
+
.catch(() => {})
|
|
134
|
+
}, [formId])
|
|
101
135
|
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
const load = useCallback(async () => {
|
|
137
|
+
setLoading(true)
|
|
138
|
+
setError(null)
|
|
139
|
+
try {
|
|
140
|
+
const result = await fetchEntries({
|
|
141
|
+
formId,
|
|
142
|
+
search: search.trim() || undefined,
|
|
143
|
+
page,
|
|
144
|
+
pageSize: PAGE_SIZE,
|
|
145
|
+
...filterToParams(filter),
|
|
146
|
+
})
|
|
147
|
+
setEntries(result.entries)
|
|
148
|
+
setTotal(result.total)
|
|
149
|
+
setSelected(new Set())
|
|
150
|
+
} catch (e) {
|
|
151
|
+
setError(e instanceof Error ? e.message : 'Failed to load submissions')
|
|
152
|
+
} finally {
|
|
153
|
+
setLoading(false)
|
|
104
154
|
}
|
|
105
|
-
|
|
106
|
-
}, [submissions, searchQuery, filterStatus, filterSource])
|
|
155
|
+
}, [formId, search, filter, page])
|
|
107
156
|
|
|
108
|
-
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
void load()
|
|
159
|
+
}, [load])
|
|
109
160
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const key = `${s.attribution?.source ?? '(direct)'} / ${s.attribution?.medium ?? '(none)'}`
|
|
114
|
-
counts[key] = (counts[key] ?? 0) + 1
|
|
115
|
-
}
|
|
116
|
-
return Object.entries(counts).sort(([, a], [, b]) => b - a)
|
|
117
|
-
}, [submissions])
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
loadCounts()
|
|
163
|
+
}, [loadCounts])
|
|
118
164
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
165
|
+
// Refresh when another surface (drawer, sidebar) signals a change.
|
|
166
|
+
useEffect(
|
|
167
|
+
() =>
|
|
168
|
+
onFormsChanged(() => {
|
|
169
|
+
loadCounts()
|
|
170
|
+
}),
|
|
171
|
+
[loadCounts],
|
|
172
|
+
)
|
|
123
173
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
174
|
+
const refresh = useCallback(() => {
|
|
175
|
+
void load()
|
|
176
|
+
loadCounts()
|
|
177
|
+
}, [load, loadCounts])
|
|
127
178
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
179
|
+
const openEntry = useCallback((id: string) => {
|
|
180
|
+
setActiveEntry(id)
|
|
181
|
+
setDrawerOpen(true)
|
|
182
|
+
// Reflect read state immediately in the row; the drawer persists it.
|
|
183
|
+
setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
|
|
184
|
+
}, [])
|
|
185
|
+
|
|
186
|
+
const toggleStar = useCallback(async (entry: FormSubmission) => {
|
|
187
|
+
const next = !entry.starred
|
|
188
|
+
setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: next } : e)))
|
|
189
|
+
const res = await updateEntry(entry.id, { starred: next })
|
|
190
|
+
if (res.error) {
|
|
191
|
+
setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: !next } : e)))
|
|
192
|
+
toast.error(res.error)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
emitFormsChanged()
|
|
196
|
+
}, [])
|
|
197
|
+
|
|
198
|
+
const allSelected = entries.length > 0 && selected.size === entries.length
|
|
199
|
+
const toggleSelectAll = useCallback(() => {
|
|
200
|
+
setSelected((cur) =>
|
|
201
|
+
cur.size === entries.length ? new Set() : new Set(entries.map((e) => e.id)),
|
|
133
202
|
)
|
|
134
|
-
}
|
|
203
|
+
}, [entries])
|
|
135
204
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
205
|
+
const toggleSelect = useCallback((id: string) => {
|
|
206
|
+
setSelected((cur) => {
|
|
207
|
+
const next = new Set(cur)
|
|
208
|
+
if (next.has(id)) next.delete(id)
|
|
209
|
+
else next.add(id)
|
|
210
|
+
return next
|
|
211
|
+
})
|
|
212
|
+
}, [])
|
|
213
|
+
|
|
214
|
+
const bulkPatch = useCallback(
|
|
215
|
+
async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }, label: string) => {
|
|
216
|
+
const ids = [...selected]
|
|
217
|
+
if (ids.length === 0) return
|
|
218
|
+
const res = await bulkUpdateEntries(ids, patch)
|
|
219
|
+
if (res.error) {
|
|
220
|
+
toast.error(res.error)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} ${label}`)
|
|
224
|
+
emitFormsChanged()
|
|
225
|
+
refresh()
|
|
226
|
+
},
|
|
227
|
+
[selected, refresh],
|
|
228
|
+
)
|
|
150
229
|
|
|
151
|
-
|
|
230
|
+
const handleBulkDelete = useCallback(async () => {
|
|
231
|
+
const ids = [...selected]
|
|
232
|
+
if (ids.length === 0) return
|
|
233
|
+
const results = await Promise.allSettled(ids.map((id) => deleteEntry(id)))
|
|
234
|
+
const failed = results.filter((r) => r.status === 'rejected' || r.value.error).length
|
|
235
|
+
if (failed > 0) toast.error(`${failed} of ${ids.length} could not be deleted`)
|
|
236
|
+
else toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} deleted`)
|
|
237
|
+
emitFormsChanged()
|
|
238
|
+
refresh()
|
|
239
|
+
}, [selected, refresh])
|
|
240
|
+
|
|
241
|
+
const showEmpty = !loading && !error && entries.length === 0
|
|
242
|
+
const isFiltered = filter !== 'all' || search.trim().length > 0
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div className="space-y-5 p-6">
|
|
246
|
+
<div>
|
|
152
247
|
<button
|
|
248
|
+
type="button"
|
|
153
249
|
onClick={() => onNavigate?.('/forms')}
|
|
154
|
-
className="mb-3 inline-flex items-center gap-2 text-sm
|
|
250
|
+
className="text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-2 text-sm transition-colors"
|
|
155
251
|
>
|
|
156
|
-
<ArrowLeft className="h-4 w-4" />
|
|
252
|
+
<ArrowLeft className="h-4 w-4" aria-hidden />
|
|
157
253
|
Back to Forms
|
|
158
254
|
</button>
|
|
159
|
-
<div className="flex items-
|
|
255
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
160
256
|
<div>
|
|
161
|
-
<h1 className="
|
|
162
|
-
|
|
257
|
+
<h1 className="text-foreground text-2xl font-medium">
|
|
258
|
+
{formName ? `${formName} — Submissions` : 'Submissions'}
|
|
163
259
|
</h1>
|
|
164
|
-
<p className="text-
|
|
165
|
-
{
|
|
260
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
261
|
+
{total} {total === 1 ? 'submission' : 'submissions'}
|
|
166
262
|
</p>
|
|
167
263
|
</div>
|
|
168
264
|
<button
|
|
169
|
-
|
|
170
|
-
className=
|
|
265
|
+
type="button"
|
|
266
|
+
className={btnSecondary}
|
|
267
|
+
onClick={() => exportCsv(entries, formSlug)}
|
|
268
|
+
disabled={entries.length === 0}
|
|
171
269
|
>
|
|
172
|
-
<Download className="h-4 w-4" />
|
|
270
|
+
<Download className="h-4 w-4" aria-hidden />
|
|
173
271
|
Export CSV
|
|
174
272
|
</button>
|
|
175
273
|
</div>
|
|
176
274
|
</div>
|
|
177
275
|
|
|
178
|
-
|
|
179
|
-
<div className="
|
|
180
|
-
<
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
</div>
|
|
189
|
-
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
190
|
-
<div className="mb-1 text-xs text-gray-600">Paid Traffic</div>
|
|
191
|
-
<div className="text-2xl font-semibold text-orange-600">{paidCount}</div>
|
|
192
|
-
</div>
|
|
193
|
-
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
194
|
-
<div className="mb-1 text-xs text-gray-600">Organic</div>
|
|
195
|
-
<div className="text-2xl font-semibold text-green-600">{organicCount}</div>
|
|
196
|
-
</div>
|
|
197
|
-
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
|
198
|
-
<div className="mb-1 text-xs text-gray-600">Top Source</div>
|
|
199
|
-
<div className="text-lg font-semibold text-gray-900">{sourceSummary[0]?.[0] ?? '—'}</div>
|
|
276
|
+
{counts && (
|
|
277
|
+
<div className="flex flex-wrap gap-2">
|
|
278
|
+
<SummaryChip value={counts.total} label="total" />
|
|
279
|
+
<SummaryChip
|
|
280
|
+
value={counts.unread}
|
|
281
|
+
label="unread"
|
|
282
|
+
tone={counts.unread > 0 ? 'destructive' : 'neutral'}
|
|
283
|
+
/>
|
|
284
|
+
<SummaryChip value={counts.starred} label="starred" tone="warning" />
|
|
285
|
+
<SummaryChip value={counts.thisWeek} label="this week" />
|
|
200
286
|
</div>
|
|
201
|
-
|
|
287
|
+
)}
|
|
202
288
|
|
|
203
|
-
<div className="
|
|
204
|
-
<
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
289
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
290
|
+
<div className="relative w-full sm:max-w-xs">
|
|
291
|
+
<Search
|
|
292
|
+
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
293
|
+
aria-hidden
|
|
294
|
+
/>
|
|
295
|
+
<input
|
|
296
|
+
type="search"
|
|
297
|
+
placeholder="Search submissions…"
|
|
298
|
+
value={search}
|
|
299
|
+
onChange={(e) => {
|
|
300
|
+
setSearch(e.target.value)
|
|
301
|
+
setPage(1)
|
|
302
|
+
}}
|
|
303
|
+
aria-label="Search submissions"
|
|
304
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
<div className="flex gap-1" role="group" aria-label="Filter submissions">
|
|
308
|
+
{FILTERS.map((f) => (
|
|
309
|
+
<button
|
|
310
|
+
key={f.id}
|
|
311
|
+
type="button"
|
|
312
|
+
onClick={() => {
|
|
313
|
+
setFilter(f.id)
|
|
314
|
+
setPage(1)
|
|
315
|
+
}}
|
|
316
|
+
aria-pressed={filter === f.id}
|
|
317
|
+
className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
318
|
+
filter === f.id
|
|
319
|
+
? 'bg-primary text-primary-foreground'
|
|
320
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
321
|
+
}`}
|
|
322
|
+
>
|
|
323
|
+
{f.label}
|
|
324
|
+
</button>
|
|
216
325
|
))}
|
|
217
326
|
</div>
|
|
218
327
|
</div>
|
|
219
328
|
|
|
220
|
-
|
|
221
|
-
<div
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<select
|
|
233
|
-
value={filterStatus}
|
|
234
|
-
onChange={(e) => setFilterStatus(e.target.value)}
|
|
235
|
-
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
329
|
+
{selected.size > 0 && (
|
|
330
|
+
<div
|
|
331
|
+
className="border-border bg-accent/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
332
|
+
role="region"
|
|
333
|
+
aria-label="Bulk actions"
|
|
334
|
+
>
|
|
335
|
+
<span className="text-foreground font-medium">{selected.size} selected</span>
|
|
336
|
+
<span className="bg-border mx-1 h-4 w-px" aria-hidden />
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
|
|
340
|
+
onClick={() => void bulkPatch({ unread: false }, 'marked read')}
|
|
236
341
|
>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
value={filterSource}
|
|
244
|
-
onChange={(e) => setFilterSource(e.target.value)}
|
|
245
|
-
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
342
|
+
Mark read
|
|
343
|
+
</button>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
|
|
347
|
+
onClick={() => void bulkPatch({ starred: true }, 'starred')}
|
|
246
348
|
>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
349
|
+
Star
|
|
350
|
+
</button>
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
|
|
354
|
+
onClick={() =>
|
|
355
|
+
void bulkPatch(
|
|
356
|
+
{ archived: filter !== 'archived' },
|
|
357
|
+
filter !== 'archived' ? 'archived' : 'unarchived',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
>
|
|
361
|
+
{filter === 'archived' ? 'Unarchive' : 'Archive'}
|
|
362
|
+
</button>
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
className="text-destructive hover:bg-destructive/10 rounded-md px-2 py-1 font-medium transition-colors"
|
|
366
|
+
onClick={() => setConfirmBulkDelete(true)}
|
|
367
|
+
>
|
|
368
|
+
Delete
|
|
369
|
+
</button>
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
className="text-muted-foreground hover:text-foreground ml-auto rounded-md px-2 py-1 font-medium transition-colors"
|
|
373
|
+
onClick={() => setSelected(new Set())}
|
|
374
|
+
>
|
|
375
|
+
Clear
|
|
376
|
+
</button>
|
|
260
377
|
</div>
|
|
261
|
-
)
|
|
262
|
-
<div className="space-y-3">
|
|
263
|
-
{filteredSubmissions.map((submission) => {
|
|
264
|
-
const expanded = expandedId === submission.id
|
|
265
|
-
const attr = submission.attribution
|
|
266
|
-
const activeClickIds = Object.entries(attr.clickIds).filter(([, v]) => v)
|
|
378
|
+
)}
|
|
267
379
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
380
|
+
{error ? (
|
|
381
|
+
<FormsErrorState message={error} onRetry={load} />
|
|
382
|
+
) : loading ? (
|
|
383
|
+
<FormsLoading label="Loading submissions…" />
|
|
384
|
+
) : showEmpty ? (
|
|
385
|
+
<FormsEmptyState
|
|
386
|
+
icon={<Inbox className="h-6 w-6" aria-hidden />}
|
|
387
|
+
title={isFiltered ? 'No submissions match your filters' : 'No submissions yet'}
|
|
388
|
+
description={
|
|
389
|
+
isFiltered
|
|
390
|
+
? 'Try a different search term or status filter.'
|
|
391
|
+
: 'Submissions will appear here once your form starts receiving entries.'
|
|
392
|
+
}
|
|
393
|
+
action={
|
|
394
|
+
isFiltered ? (
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
onClick={() => {
|
|
398
|
+
setSearch('')
|
|
399
|
+
setFilter('all')
|
|
400
|
+
setPage(1)
|
|
401
|
+
}}
|
|
402
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
272
403
|
>
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
onClick={() => toast.success('Submission deleted')}
|
|
300
|
-
className="rounded p-1.5 transition-colors hover:bg-gray-100"
|
|
301
|
-
title="Delete"
|
|
302
|
-
>
|
|
303
|
-
<Trash2 className="h-4 w-4 text-red-600" />
|
|
304
|
-
</button>
|
|
305
|
-
</div>
|
|
306
|
-
|
|
307
|
-
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-gray-100 pt-3">
|
|
308
|
-
<span
|
|
309
|
-
className={`rounded-full px-2 py-0.5 text-xs font-medium ${sourceColor(attr.source)}`}
|
|
310
|
-
>
|
|
311
|
-
{attr.source}
|
|
312
|
-
</span>
|
|
313
|
-
<span className="text-xs text-gray-400">/</span>
|
|
314
|
-
<span
|
|
315
|
-
className={`rounded-full px-2 py-0.5 text-xs font-medium ${mediumColor(attr.medium)}`}
|
|
316
|
-
>
|
|
317
|
-
{attr.medium}
|
|
318
|
-
</span>
|
|
319
|
-
{attr.campaign && (
|
|
320
|
-
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
|
|
321
|
-
{attr.campaign}
|
|
322
|
-
</span>
|
|
323
|
-
)}
|
|
324
|
-
<span className="flex items-center gap-1 text-xs text-gray-500">
|
|
325
|
-
<DeviceIcon type={attr.deviceType} />
|
|
326
|
-
{attr.deviceType}
|
|
327
|
-
</span>
|
|
328
|
-
{activeClickIds.length > 0 && (
|
|
329
|
-
<span className="flex items-center gap-1 text-xs text-gray-500">
|
|
330
|
-
<MousePointerClick className="h-3.5 w-3.5" />
|
|
331
|
-
{activeClickIds.map(([k]) => k).join(', ')}
|
|
332
|
-
</span>
|
|
333
|
-
)}
|
|
334
|
-
<button
|
|
335
|
-
onClick={() => setExpandedId(expanded ? null : submission.id)}
|
|
336
|
-
className="ml-auto flex items-center gap-1 text-xs text-blue-600 transition-colors hover:text-blue-800"
|
|
337
|
-
>
|
|
338
|
-
{expanded ? (
|
|
339
|
-
<ChevronUp className="h-3.5 w-3.5" />
|
|
340
|
-
) : (
|
|
341
|
-
<ChevronDown className="h-3.5 w-3.5" />
|
|
342
|
-
)}
|
|
343
|
-
{expanded ? 'Hide' : 'Attribution'}
|
|
344
|
-
</button>
|
|
345
|
-
</div>
|
|
404
|
+
Clear filters
|
|
405
|
+
</button>
|
|
406
|
+
) : undefined
|
|
407
|
+
}
|
|
408
|
+
/>
|
|
409
|
+
) : (
|
|
410
|
+
<EntriesTable
|
|
411
|
+
entries={entries}
|
|
412
|
+
selected={selected}
|
|
413
|
+
allSelected={allSelected}
|
|
414
|
+
onToggleSelectAll={toggleSelectAll}
|
|
415
|
+
onToggleSelect={toggleSelect}
|
|
416
|
+
onOpen={openEntry}
|
|
417
|
+
onToggleStar={toggleStar}
|
|
418
|
+
footer={
|
|
419
|
+
<FormsPagination
|
|
420
|
+
page={page}
|
|
421
|
+
pageSize={PAGE_SIZE}
|
|
422
|
+
total={total}
|
|
423
|
+
onPageChange={setPage}
|
|
424
|
+
noun="submission"
|
|
425
|
+
label="Submissions pagination"
|
|
426
|
+
/>
|
|
427
|
+
}
|
|
428
|
+
/>
|
|
429
|
+
)}
|
|
346
430
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
<div className="space-y-1.5">
|
|
355
|
-
<div className="flex justify-between">
|
|
356
|
-
<span className="text-gray-500">Source</span>
|
|
357
|
-
<span className="font-medium text-gray-900">{attr.source}</span>
|
|
358
|
-
</div>
|
|
359
|
-
<div className="flex justify-between">
|
|
360
|
-
<span className="text-gray-500">Medium</span>
|
|
361
|
-
<span className="font-medium text-gray-900">{attr.medium}</span>
|
|
362
|
-
</div>
|
|
363
|
-
{attr.campaign && (
|
|
364
|
-
<div className="flex justify-between">
|
|
365
|
-
<span className="text-gray-500">Campaign</span>
|
|
366
|
-
<span className="font-medium text-gray-900">{attr.campaign}</span>
|
|
367
|
-
</div>
|
|
368
|
-
)}
|
|
369
|
-
{attr.term && (
|
|
370
|
-
<div className="flex justify-between">
|
|
371
|
-
<span className="text-gray-500">Keyword</span>
|
|
372
|
-
<span className="font-medium text-gray-900">{attr.term}</span>
|
|
373
|
-
</div>
|
|
374
|
-
)}
|
|
375
|
-
{attr.content && (
|
|
376
|
-
<div className="flex justify-between">
|
|
377
|
-
<span className="text-gray-500">Content</span>
|
|
378
|
-
<span className="font-medium text-gray-900">{attr.content}</span>
|
|
379
|
-
</div>
|
|
380
|
-
)}
|
|
381
|
-
</div>
|
|
382
|
-
</div>
|
|
383
|
-
<div>
|
|
384
|
-
<h4 className="mb-2 text-xs font-semibold tracking-wider text-gray-600 uppercase">
|
|
385
|
-
Page Context
|
|
386
|
-
</h4>
|
|
387
|
-
<div className="space-y-1.5">
|
|
388
|
-
<div>
|
|
389
|
-
<span className="text-xs text-gray-500">Landing Page</span>
|
|
390
|
-
<div className="mt-0.5 flex items-center gap-1">
|
|
391
|
-
<MapPin className="h-3 w-3 shrink-0 text-gray-400" />
|
|
392
|
-
<span className="text-xs font-medium break-all text-gray-900">
|
|
393
|
-
{attr.landingPage}
|
|
394
|
-
</span>
|
|
395
|
-
</div>
|
|
396
|
-
</div>
|
|
397
|
-
{attr.referrer && (
|
|
398
|
-
<div>
|
|
399
|
-
<span className="text-xs text-gray-500">Referrer</span>
|
|
400
|
-
<div className="mt-0.5 flex items-center gap-1">
|
|
401
|
-
<ExternalLink className="h-3 w-3 shrink-0 text-gray-400" />
|
|
402
|
-
<span className="text-xs font-medium break-all text-gray-900">
|
|
403
|
-
{attr.referrer}
|
|
404
|
-
</span>
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
)}
|
|
408
|
-
<div className="flex justify-between">
|
|
409
|
-
<span className="text-gray-500">Device</span>
|
|
410
|
-
<span className="flex items-center gap-1 font-medium text-gray-900">
|
|
411
|
-
<DeviceIcon type={attr.deviceType} />
|
|
412
|
-
{attr.deviceType}
|
|
413
|
-
</span>
|
|
414
|
-
</div>
|
|
415
|
-
</div>
|
|
416
|
-
</div>
|
|
417
|
-
</div>
|
|
418
|
-
{activeClickIds.length > 0 && (
|
|
419
|
-
<div>
|
|
420
|
-
<h4 className="mb-2 text-xs font-semibold tracking-wider text-gray-600 uppercase">
|
|
421
|
-
Click IDs
|
|
422
|
-
</h4>
|
|
423
|
-
<div className="flex flex-wrap gap-2">
|
|
424
|
-
{activeClickIds.map(([key, val]) => (
|
|
425
|
-
<div
|
|
426
|
-
key={key}
|
|
427
|
-
className="rounded-lg border border-gray-200 bg-white px-2.5 py-1"
|
|
428
|
-
>
|
|
429
|
-
<span className="text-xs text-gray-500">{key}: </span>
|
|
430
|
-
<span className="font-mono text-xs text-gray-800">
|
|
431
|
-
{String(val).slice(0, 20)}...
|
|
432
|
-
</span>
|
|
433
|
-
</div>
|
|
434
|
-
))}
|
|
435
|
-
</div>
|
|
436
|
-
</div>
|
|
437
|
-
)}
|
|
438
|
-
<div className="text-xs text-gray-500">
|
|
439
|
-
Attribution captured {attr.capturedAt}
|
|
440
|
-
</div>
|
|
441
|
-
</div>
|
|
442
|
-
)}
|
|
443
|
-
</div>
|
|
431
|
+
<EntryDetailDrawer
|
|
432
|
+
entryId={activeEntry}
|
|
433
|
+
open={drawerOpen}
|
|
434
|
+
onOpenChange={setDrawerOpen}
|
|
435
|
+
onChanged={refresh}
|
|
436
|
+
onDeleted={() => refresh()}
|
|
437
|
+
/>
|
|
444
438
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
)
|
|
455
|
-
})}
|
|
456
|
-
</div>
|
|
457
|
-
)}
|
|
439
|
+
<ConfirmDialog
|
|
440
|
+
open={confirmBulkDelete}
|
|
441
|
+
onClose={() => setConfirmBulkDelete(false)}
|
|
442
|
+
onConfirm={() => void handleBulkDelete()}
|
|
443
|
+
title={`Delete ${selected.size} ${selected.size === 1 ? 'submission' : 'submissions'}?`}
|
|
444
|
+
description="This permanently removes the selected submissions and any attached files. This cannot be undone."
|
|
445
|
+
confirmLabel="Delete"
|
|
446
|
+
destructive
|
|
447
|
+
/>
|
|
458
448
|
</div>
|
|
459
449
|
)
|
|
460
450
|
}
|