@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.
Files changed (66) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +4 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
  5. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  7. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
  11. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
  12. package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/form-entries.render.test.js +112 -0
  15. package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
  16. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  19. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/components/forms/EntriesTable.d.ts +28 -0
  22. package/dist/components/forms/EntriesTable.d.ts.map +1 -0
  23. package/dist/components/forms/EntriesTable.js +26 -0
  24. package/dist/components/forms/EntriesTable.js.map +1 -0
  25. package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
  26. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  27. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  28. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  29. package/dist/components/forms/NotificationsPanel.js +1 -1
  30. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  31. package/dist/components/forms/primitives.d.ts +15 -0
  32. package/dist/components/forms/primitives.d.ts.map +1 -1
  33. package/dist/components/forms/primitives.js +36 -1
  34. package/dist/components/forms/primitives.js.map +1 -1
  35. package/dist/layout/Sidebar.d.ts.map +1 -1
  36. package/dist/layout/Sidebar.js +44 -21
  37. package/dist/layout/Sidebar.js.map +1 -1
  38. package/dist/lib/forms-service.d.ts +36 -2
  39. package/dist/lib/forms-service.d.ts.map +1 -1
  40. package/dist/lib/forms-service.js +66 -0
  41. package/dist/lib/forms-service.js.map +1 -1
  42. package/dist/views/FormEntries.d.ts +5 -0
  43. package/dist/views/FormEntries.d.ts.map +1 -0
  44. package/dist/views/FormEntries.js +211 -0
  45. package/dist/views/FormEntries.js.map +1 -0
  46. package/dist/views/FormSubmissions.d.ts.map +1 -1
  47. package/dist/views/FormSubmissions.js +204 -77
  48. package/dist/views/FormSubmissions.js.map +1 -1
  49. package/dist/views/Forms.d.ts.map +1 -1
  50. package/dist/views/Forms.js +26 -10
  51. package/dist/views/Forms.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/AdminRoot.tsx +4 -0
  54. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  55. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
  56. package/src/__tests__/views/form-entries.render.test.tsx +133 -0
  57. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  58. package/src/components/forms/EntriesTable.tsx +197 -0
  59. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  60. package/src/components/forms/NotificationsPanel.tsx +1 -1
  61. package/src/components/forms/primitives.tsx +101 -1
  62. package/src/layout/Sidebar.tsx +75 -17
  63. package/src/lib/forms-service.ts +99 -0
  64. package/src/views/FormEntries.tsx +449 -0
  65. package/src/views/FormSubmissions.tsx +391 -401
  66. package/src/views/Forms.tsx +145 -107
@@ -1,460 +1,450 @@
1
1
  'use client'
2
2
 
3
- import { useState, useMemo } from 'react'
4
- import {
5
- ArrowLeft,
6
- Download,
7
- Trash2,
8
- Search,
9
- Globe,
10
- ChevronDown,
11
- ChevronUp,
12
- MousePointerClick,
13
- MapPin,
14
- Smartphone,
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 { sortByRelevance } from '../lib/search.js'
23
- import { useApiData } from '../lib/useApiData.js'
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 Attribution {
26
- source: string
27
- medium: string
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
- interface Submission {
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
- function sourceColor(source: string): string {
50
- const s = (source ?? '').toLowerCase()
51
- if (s === 'google') return 'bg-blue-100 text-blue-800'
52
- if (s === 'facebook' || s === 'meta') return 'bg-indigo-100 text-indigo-800'
53
- if (s === 'linkedin') return 'bg-sky-100 text-sky-800'
54
- if (s === 'bing') return 'bg-teal-100 text-teal-800'
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
- function mediumColor(medium: string): string {
60
- const m = (medium ?? '').toLowerCase()
61
- if (m === 'cpc' || m === 'paid') return 'bg-orange-100 text-orange-800'
62
- if (m === 'organic') return 'bg-green-100 text-green-800'
63
- if (m === 'social') return 'bg-pink-100 text-pink-800'
64
- if (m === 'email') return 'bg-yellow-100 text-yellow-800'
65
- if (m === '(none)') return 'bg-gray-100 text-gray-600'
66
- return 'bg-gray-100 text-gray-800'
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 DeviceIcon({ type }: { type: string }) {
70
- if (type === 'Mobile') return <Smartphone className="h-3.5 w-3.5" />
71
- if (type === 'Tablet') return <Tablet className="h-3.5 w-3.5" />
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
- export interface FormSubmissionsProps {
76
- formId: string
77
- onNavigate?: (path: string) => void
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 { data, loading, error, refetch } = useApiData<
82
- Submission[] | { submissions: Submission[] }
83
- >(`/forms/${formId}/submissions`)
84
- const [searchQuery, setSearchQuery] = useState('')
85
- const [filterStatus, setFilterStatus] = useState('all')
86
- const [filterSource, setFilterSource] = useState('all')
87
- const [expandedId, setExpandedId] = useState<number | null>(null)
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
- const submissions = Array.isArray(data) ? data : (data?.submissions ?? [])
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 filteredSubmissions = useMemo(() => {
92
- let results = submissions.filter((s) => {
93
- const matchesSearch =
94
- (s.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
95
- (s.email ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
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
- if (searchQuery.trim()) {
103
- results = sortByRelevance(results, searchQuery, (s) => [s.name, s.email, s.message])
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
- return results
106
- }, [submissions, searchQuery, filterStatus, filterSource])
155
+ }, [formId, search, filter, page])
107
156
 
108
- const uniqueSources = [...new Set(submissions.map((s) => s.attribution?.source ?? '(direct)'))]
157
+ useEffect(() => {
158
+ void load()
159
+ }, [load])
109
160
 
110
- const sourceSummary = useMemo(() => {
111
- const counts: Record<string, number> = {}
112
- for (const s of submissions) {
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
- const paidCount = submissions.filter((s) =>
120
- ['cpc', 'paid'].includes(s.attribution?.medium ?? ''),
121
- ).length
122
- const organicCount = submissions.filter((s) => s.attribution?.medium === 'organic').length
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 handleExport = () => {
125
- toast.success('Submissions exported to CSV (includes attribution data)')
126
- }
174
+ const refresh = useCallback(() => {
175
+ void load()
176
+ loadCounts()
177
+ }, [load, loadCounts])
127
178
 
128
- if (loading) {
129
- return (
130
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
131
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
132
- </div>
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
- return (
137
- <div className="p-3 pr-6 sm:p-4 sm:pr-8">
138
- {error && (
139
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
140
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
141
- <span className="flex-1 text-sm text-red-800">{error}</span>
142
- <button
143
- onClick={refetch}
144
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
145
- >
146
- Retry
147
- </button>
148
- </div>
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
- <div className="mb-4">
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 text-gray-600 transition-colors hover:text-gray-900"
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-center justify-between">
255
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
160
256
  <div>
161
- <h1 className="mb-1 text-2xl font-semibold text-gray-900">
162
- Contact Form - Submissions
257
+ <h1 className="text-foreground text-2xl font-medium">
258
+ {formName ? `${formName} Submissions` : 'Submissions'}
163
259
  </h1>
164
- <p className="text-sm text-gray-600">
165
- {filteredSubmissions.length} submission{filteredSubmissions.length !== 1 ? 's' : ''}
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
- onClick={handleExport}
170
- className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
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
- <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-5">
179
- <div className="rounded-lg border border-gray-200 bg-white p-4">
180
- <div className="mb-1 text-xs text-gray-600">Total</div>
181
- <div className="text-2xl font-semibold text-gray-900">{submissions.length}</div>
182
- </div>
183
- <div className="rounded-lg border border-gray-200 bg-white p-4">
184
- <div className="mb-1 text-xs text-gray-600">New</div>
185
- <div className="text-2xl font-semibold text-blue-600">
186
- {submissions.filter((s) => s.status === 'new').length}
187
- </div>
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
- </div>
287
+ )}
202
288
 
203
- <div className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
204
- <h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900">
205
- <Globe className="h-4 w-4 text-blue-600" />
206
- Lead Source Breakdown
207
- </h3>
208
- <div className="flex flex-wrap gap-2">
209
- {sourceSummary.map(([label, count]) => (
210
- <div key={label} className="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
211
- <span className="text-sm font-medium text-gray-800">{label}</span>
212
- <span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
213
- {count}
214
- </span>
215
- </div>
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
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
221
- <div className="flex flex-col gap-3 p-3 sm:flex-row">
222
- <div className="relative flex-1">
223
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
224
- <input
225
- type="text"
226
- placeholder="Search submissions..."
227
- value={searchQuery}
228
- onChange={(e) => setSearchQuery(e.target.value)}
229
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
230
- />
231
- </div>
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
- <option value="all">All Status</option>
238
- <option value="new">New</option>
239
- <option value="read">Read</option>
240
- <option value="replied">Replied</option>
241
- </select>
242
- <select
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
- <option value="all">All Sources</option>
248
- {uniqueSources.map((src) => (
249
- <option key={src} value={src}>
250
- {src}
251
- </option>
252
- ))}
253
- </select>
254
- </div>
255
- </div>
256
-
257
- {filteredSubmissions.length === 0 ? (
258
- <div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
259
- <p className="text-sm text-gray-500">No submissions yet</p>
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
- return (
269
- <div
270
- key={submission.id}
271
- className="rounded-lg border border-gray-200 bg-white transition-shadow hover:shadow-md"
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
- <div className="p-4">
274
- <div className="mb-3 flex items-start justify-between">
275
- <div className="flex flex-1 items-start gap-3">
276
- <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-purple-600 text-sm font-medium text-white">
277
- {submission.name.charAt(0)}
278
- </div>
279
- <div className="min-w-0 flex-1">
280
- <div className="mb-1 flex items-center gap-2">
281
- <h3 className="text-sm font-semibold text-gray-900">{submission.name}</h3>
282
- <span
283
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${submission.status === 'new' ? 'bg-blue-100 text-blue-800' : submission.status === 'read' ? 'bg-gray-100 text-gray-800' : 'bg-green-100 text-green-800'}`}
284
- >
285
- {submission.status}
286
- </span>
287
- </div>
288
- <div className="mb-2 text-xs text-gray-600">
289
- {submission.email}
290
- {submission.phone ? ` · ${submission.phone}` : ''}
291
- </div>
292
- <p className="text-sm text-gray-700">{submission.message}</p>
293
- <div className="mt-2 text-xs text-gray-500">
294
- Submitted {submission.submittedAt}
295
- </div>
296
- </div>
297
- </div>
298
- <button
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
- {expanded && (
348
- <div className="mt-3 space-y-4 rounded-lg border-t border-gray-100 bg-gray-50 p-4 pt-3 text-sm">
349
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
350
- <div>
351
- <h4 className="mb-2 text-xs font-semibold tracking-wider text-gray-600 uppercase">
352
- Traffic Source
353
- </h4>
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
- <div className="flex items-center gap-2 border-t border-gray-200 px-4 py-3">
446
- <button className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700">
447
- Reply
448
- </button>
449
- <button className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm transition-colors hover:bg-gray-50">
450
- Mark as Read
451
- </button>
452
- </div>
453
- </div>
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
  }