@actuate-media/cms-admin 0.21.1 → 0.23.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 (83) hide show
  1. package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
  2. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  4. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  5. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
  6. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
  8. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
  10. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
  11. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  12. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  13. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  14. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
  15. package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/forms-list.render.test.js +122 -0
  18. package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
  19. package/dist/actuate-admin.css +1 -1
  20. package/dist/components/forms/Drawer.d.ts +13 -0
  21. package/dist/components/forms/Drawer.d.ts.map +1 -0
  22. package/dist/components/forms/Drawer.js +13 -0
  23. package/dist/components/forms/Drawer.js.map +1 -0
  24. package/dist/components/forms/EmbedPanel.d.ts +7 -0
  25. package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
  26. package/dist/components/forms/EmbedPanel.js +91 -0
  27. package/dist/components/forms/EmbedPanel.js.map +1 -0
  28. package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
  29. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  30. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  31. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  32. package/dist/components/forms/FieldsPanel.d.ts +8 -0
  33. package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
  34. package/dist/components/forms/FieldsPanel.js +123 -0
  35. package/dist/components/forms/FieldsPanel.js.map +1 -0
  36. package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
  37. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
  38. package/dist/components/forms/FormSchemaDrawer.js +96 -0
  39. package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
  40. package/dist/components/forms/NotificationsPanel.d.ts +6 -0
  41. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
  42. package/dist/components/forms/NotificationsPanel.js +80 -0
  43. package/dist/components/forms/NotificationsPanel.js.map +1 -0
  44. package/dist/components/forms/primitives.d.ts +57 -0
  45. package/dist/components/forms/primitives.d.ts.map +1 -0
  46. package/dist/components/forms/primitives.js +131 -0
  47. package/dist/components/forms/primitives.js.map +1 -0
  48. package/dist/layout/Sidebar.d.ts +5 -0
  49. package/dist/layout/Sidebar.d.ts.map +1 -1
  50. package/dist/layout/Sidebar.js +43 -3
  51. package/dist/layout/Sidebar.js.map +1 -1
  52. package/dist/lib/forms-events.d.ts +17 -0
  53. package/dist/lib/forms-events.d.ts.map +1 -0
  54. package/dist/lib/forms-events.js +20 -0
  55. package/dist/lib/forms-events.js.map +1 -0
  56. package/dist/lib/forms-service.d.ts +114 -0
  57. package/dist/lib/forms-service.d.ts.map +1 -0
  58. package/dist/lib/forms-service.js +210 -0
  59. package/dist/lib/forms-service.js.map +1 -0
  60. package/dist/views/FormSubmissions.d.ts.map +1 -1
  61. package/dist/views/FormSubmissions.js +224 -77
  62. package/dist/views/FormSubmissions.js.map +1 -1
  63. package/dist/views/Forms.d.ts.map +1 -1
  64. package/dist/views/Forms.js +135 -17
  65. package/dist/views/Forms.js.map +1 -1
  66. package/package.json +1 -1
  67. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  68. package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
  69. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
  70. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  71. package/src/__tests__/views/forms-list.render.test.tsx +141 -0
  72. package/src/components/forms/Drawer.tsx +70 -0
  73. package/src/components/forms/EmbedPanel.tsx +173 -0
  74. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  75. package/src/components/forms/FieldsPanel.tsx +385 -0
  76. package/src/components/forms/FormSchemaDrawer.tsx +185 -0
  77. package/src/components/forms/NotificationsPanel.tsx +240 -0
  78. package/src/components/forms/primitives.tsx +300 -0
  79. package/src/layout/Sidebar.tsx +72 -6
  80. package/src/lib/forms-events.ts +32 -0
  81. package/src/lib/forms-service.ts +343 -0
  82. package/src/views/FormSubmissions.tsx +529 -394
  83. package/src/views/Forms.tsx +381 -106
@@ -1,460 +1,595 @@
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, type ReactNode } from 'react'
14
+ import { ArrowLeft, Download, Inbox, Search, Star, Trash2 } from 'lucide-react'
21
15
  import { toast } from 'sonner'
22
- import { sortByRelevance } from '../lib/search.js'
23
- import { useApiData } from '../lib/useApiData.js'
24
-
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
36
- }
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 { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
30
+ import {
31
+ FormsEmptyState,
32
+ FormsErrorState,
33
+ FormsLoading,
34
+ FormsPagination,
35
+ SummaryChip,
36
+ btnGhostIcon,
37
+ btnSecondary,
38
+ } from '../components/forms/primitives.js'
37
39
 
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
40
+ export interface FormSubmissionsProps {
41
+ formId: string
42
+ onNavigate?: (path: string) => void
47
43
  }
48
44
 
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'
45
+ type StatusFilter = 'all' | 'unread' | 'starred' | 'archived'
46
+
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
+ ]
53
+
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
+ }
57
67
  }
58
68
 
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'
69
+ function formatDateTime(iso: string | null | undefined): string {
70
+ if (!iso) return ''
71
+ const d = new Date(iso)
72
+ if (Number.isNaN(d.getTime())) return ''
73
+ return d.toLocaleString(undefined, {
74
+ month: 'short',
75
+ day: 'numeric',
76
+ year: 'numeric',
77
+ hour: 'numeric',
78
+ minute: '2-digit',
79
+ })
67
80
  }
68
81
 
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" />
82
+ function csvEscape(value: unknown): string {
83
+ const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
84
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
73
85
  }
74
86
 
75
- export interface FormSubmissionsProps {
76
- formId: string
77
- onNavigate?: (path: string) => void
87
+ function exportCsv(entries: FormSubmission[], formSlug: string) {
88
+ const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
89
+ const header = ['Entry', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
90
+ const lines = [header.map(csvEscape).join(',')]
91
+ for (const e of entries) {
92
+ const row = [
93
+ e.entryNumber ?? '',
94
+ e.submittedAt,
95
+ e.senderName ?? '',
96
+ e.senderEmail ?? '',
97
+ e.senderPhone ?? '',
98
+ e.unread ? 'unread' : 'read',
99
+ ...dataKeys.map((k) => e.data?.[k]),
100
+ ]
101
+ lines.push(row.map(csvEscape).join(','))
102
+ }
103
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
104
+ const url = URL.createObjectURL(blob)
105
+ const a = document.createElement('a')
106
+ a.href = url
107
+ a.download = `${formSlug || 'form'}-submissions.csv`
108
+ a.click()
109
+ URL.revokeObjectURL(url)
78
110
  }
79
111
 
80
112
  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)
88
-
89
- const submissions = Array.isArray(data) ? data : (data?.submissions ?? [])
90
-
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
- })
113
+ const [formName, setFormName] = useState<string>('')
114
+ const [formSlug, setFormSlug] = useState<string>('')
115
+ const [entries, setEntries] = useState<FormSubmission[]>([])
116
+ const [total, setTotal] = useState(0)
117
+ const [counts, setCounts] = useState<EntryCounts | null>(null)
118
+ const [loading, setLoading] = useState(true)
119
+ const [error, setError] = useState<string | null>(null)
120
+ const [search, setSearch] = useState('')
121
+ const [filter, setFilter] = useState<StatusFilter>('all')
122
+ const [page, setPage] = useState(1)
123
+ const [selected, setSelected] = useState<Set<string>>(new Set())
124
+ const [activeEntry, setActiveEntry] = useState<string | null>(null)
125
+ const [drawerOpen, setDrawerOpen] = useState(false)
126
+ const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
101
127
 
102
- if (searchQuery.trim()) {
103
- results = sortByRelevance(results, searchQuery, (s) => [s.name, s.email, s.message])
128
+ // Form metadata for the title (loads once).
129
+ useEffect(() => {
130
+ let cancelled = false
131
+ void fetchFormById(formId)
132
+ .then((form) => {
133
+ if (cancelled || !form) return
134
+ setFormName(form.name)
135
+ setFormSlug(form.slug)
136
+ })
137
+ .catch(() => {})
138
+ return () => {
139
+ cancelled = true
104
140
  }
105
- return results
106
- }, [submissions, searchQuery, filterStatus, filterSource])
141
+ }, [formId])
107
142
 
108
- const uniqueSources = [...new Set(submissions.map((s) => s.attribution?.source ?? '(direct)'))]
143
+ const loadCounts = useCallback(() => {
144
+ void fetchEntryCounts({ formId })
145
+ .then(setCounts)
146
+ .catch(() => {})
147
+ }, [formId])
109
148
 
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
149
+ const load = useCallback(async () => {
150
+ setLoading(true)
151
+ setError(null)
152
+ try {
153
+ const result = await fetchEntries({
154
+ formId,
155
+ search: search.trim() || undefined,
156
+ page,
157
+ pageSize: PAGE_SIZE,
158
+ ...filterToParams(filter),
159
+ })
160
+ setEntries(result.entries)
161
+ setTotal(result.total)
162
+ setSelected(new Set())
163
+ } catch (e) {
164
+ setError(e instanceof Error ? e.message : 'Failed to load submissions')
165
+ } finally {
166
+ setLoading(false)
115
167
  }
116
- return Object.entries(counts).sort(([, a], [, b]) => b - a)
117
- }, [submissions])
168
+ }, [formId, search, filter, page])
118
169
 
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
170
+ useEffect(() => {
171
+ void load()
172
+ }, [load])
123
173
 
124
- const handleExport = () => {
125
- toast.success('Submissions exported to CSV (includes attribution data)')
126
- }
174
+ useEffect(() => {
175
+ loadCounts()
176
+ }, [loadCounts])
127
177
 
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>
178
+ // Refresh when another surface (drawer, sidebar) signals a change.
179
+ useEffect(
180
+ () =>
181
+ onFormsChanged(() => {
182
+ loadCounts()
183
+ }),
184
+ [loadCounts],
185
+ )
186
+
187
+ const refresh = useCallback(() => {
188
+ void load()
189
+ loadCounts()
190
+ }, [load, loadCounts])
191
+
192
+ const openEntry = useCallback((id: string) => {
193
+ setActiveEntry(id)
194
+ setDrawerOpen(true)
195
+ // Reflect read state immediately in the row; the drawer persists it.
196
+ setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
197
+ }, [])
198
+
199
+ const toggleStar = useCallback(async (entry: FormSubmission) => {
200
+ const next = !entry.starred
201
+ setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: next } : e)))
202
+ const res = await updateEntry(entry.id, { starred: next })
203
+ if (res.error) {
204
+ setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: !next } : e)))
205
+ toast.error(res.error)
206
+ return
207
+ }
208
+ emitFormsChanged()
209
+ }, [])
210
+
211
+ const allSelected = entries.length > 0 && selected.size === entries.length
212
+ const toggleSelectAll = useCallback(() => {
213
+ setSelected((cur) =>
214
+ cur.size === entries.length ? new Set() : new Set(entries.map((e) => e.id)),
133
215
  )
134
- }
216
+ }, [entries])
135
217
 
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
- )}
218
+ const toggleSelect = useCallback((id: string) => {
219
+ setSelected((cur) => {
220
+ const next = new Set(cur)
221
+ if (next.has(id)) next.delete(id)
222
+ else next.add(id)
223
+ return next
224
+ })
225
+ }, [])
150
226
 
151
- <div className="mb-4">
227
+ const bulkPatch = useCallback(
228
+ async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }, label: string) => {
229
+ const ids = [...selected]
230
+ if (ids.length === 0) return
231
+ const res = await bulkUpdateEntries(ids, patch)
232
+ if (res.error) {
233
+ toast.error(res.error)
234
+ return
235
+ }
236
+ toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} ${label}`)
237
+ emitFormsChanged()
238
+ refresh()
239
+ },
240
+ [selected, refresh],
241
+ )
242
+
243
+ const handleBulkDelete = useCallback(async () => {
244
+ const ids = [...selected]
245
+ if (ids.length === 0) return
246
+ const results = await Promise.allSettled(ids.map((id) => deleteEntry(id)))
247
+ const failed = results.filter((r) => r.status === 'rejected' || r.value.error).length
248
+ if (failed > 0) toast.error(`${failed} of ${ids.length} could not be deleted`)
249
+ else toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} deleted`)
250
+ emitFormsChanged()
251
+ refresh()
252
+ }, [selected, refresh])
253
+
254
+ const showEmpty = !loading && !error && entries.length === 0
255
+ const isFiltered = filter !== 'all' || search.trim().length > 0
256
+
257
+ return (
258
+ <div className="space-y-5 p-6">
259
+ <div>
152
260
  <button
261
+ type="button"
153
262
  onClick={() => onNavigate?.('/forms')}
154
- className="mb-3 inline-flex items-center gap-2 text-sm text-gray-600 transition-colors hover:text-gray-900"
263
+ className="text-muted-foreground hover:text-foreground mb-3 inline-flex items-center gap-2 text-sm transition-colors"
155
264
  >
156
- <ArrowLeft className="h-4 w-4" />
265
+ <ArrowLeft className="h-4 w-4" aria-hidden />
157
266
  Back to Forms
158
267
  </button>
159
- <div className="flex items-center justify-between">
268
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
160
269
  <div>
161
- <h1 className="mb-1 text-2xl font-semibold text-gray-900">
162
- Contact Form - Submissions
270
+ <h1 className="text-foreground text-2xl font-medium">
271
+ {formName ? `${formName} Submissions` : 'Submissions'}
163
272
  </h1>
164
- <p className="text-sm text-gray-600">
165
- {filteredSubmissions.length} submission{filteredSubmissions.length !== 1 ? 's' : ''}
273
+ <p className="text-muted-foreground mt-1 text-sm">
274
+ {total} {total === 1 ? 'submission' : 'submissions'}
166
275
  </p>
167
276
  </div>
168
277
  <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"
278
+ type="button"
279
+ className={btnSecondary}
280
+ onClick={() => exportCsv(entries, formSlug)}
281
+ disabled={entries.length === 0}
171
282
  >
172
- <Download className="h-4 w-4" />
283
+ <Download className="h-4 w-4" aria-hidden />
173
284
  Export CSV
174
285
  </button>
175
286
  </div>
176
287
  </div>
177
288
 
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>
289
+ {counts && (
290
+ <div className="flex flex-wrap gap-2">
291
+ <SummaryChip value={counts.total} label="total" />
292
+ <SummaryChip
293
+ value={counts.unread}
294
+ label="unread"
295
+ tone={counts.unread > 0 ? 'destructive' : 'neutral'}
296
+ />
297
+ <SummaryChip value={counts.starred} label="starred" tone="warning" />
298
+ <SummaryChip value={counts.thisWeek} label="this week" />
200
299
  </div>
201
- </div>
300
+ )}
202
301
 
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>
302
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
303
+ <div className="relative w-full sm:max-w-xs">
304
+ <Search
305
+ className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
306
+ aria-hidden
307
+ />
308
+ <input
309
+ type="search"
310
+ placeholder="Search submissions…"
311
+ value={search}
312
+ onChange={(e) => {
313
+ setSearch(e.target.value)
314
+ setPage(1)
315
+ }}
316
+ aria-label="Search submissions"
317
+ 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"
318
+ />
319
+ </div>
320
+ <div className="flex gap-1" role="group" aria-label="Filter submissions">
321
+ {FILTERS.map((f) => (
322
+ <button
323
+ key={f.id}
324
+ type="button"
325
+ onClick={() => {
326
+ setFilter(f.id)
327
+ setPage(1)
328
+ }}
329
+ aria-pressed={filter === f.id}
330
+ className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
331
+ filter === f.id
332
+ ? 'bg-primary text-primary-foreground'
333
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
334
+ }`}
335
+ >
336
+ {f.label}
337
+ </button>
216
338
  ))}
217
339
  </div>
218
340
  </div>
219
341
 
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"
342
+ {selected.size > 0 && (
343
+ <div
344
+ className="border-border bg-accent/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
345
+ role="region"
346
+ aria-label="Bulk actions"
347
+ >
348
+ <span className="text-foreground font-medium">{selected.size} selected</span>
349
+ <span className="bg-border mx-1 h-4 w-px" aria-hidden />
350
+ <button
351
+ type="button"
352
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
353
+ onClick={() => void bulkPatch({ unread: false }, 'marked read')}
236
354
  >
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"
355
+ Mark read
356
+ </button>
357
+ <button
358
+ type="button"
359
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
360
+ onClick={() => void bulkPatch({ starred: true }, 'starred')}
246
361
  >
247
- <option value="all">All Sources</option>
248
- {uniqueSources.map((src) => (
249
- <option key={src} value={src}>
250
- {src}
251
- </option>
252
- ))}
253
- </select>
362
+ Star
363
+ </button>
364
+ <button
365
+ type="button"
366
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
367
+ onClick={() =>
368
+ void bulkPatch(
369
+ { archived: filter !== 'archived' },
370
+ filter !== 'archived' ? 'archived' : 'unarchived',
371
+ )
372
+ }
373
+ >
374
+ {filter === 'archived' ? 'Unarchive' : 'Archive'}
375
+ </button>
376
+ <button
377
+ type="button"
378
+ className="text-destructive hover:bg-destructive/10 rounded-md px-2 py-1 font-medium transition-colors"
379
+ onClick={() => setConfirmBulkDelete(true)}
380
+ >
381
+ Delete
382
+ </button>
383
+ <button
384
+ type="button"
385
+ className="text-muted-foreground hover:text-foreground ml-auto rounded-md px-2 py-1 font-medium transition-colors"
386
+ onClick={() => setSelected(new Set())}
387
+ >
388
+ Clear
389
+ </button>
254
390
  </div>
255
- </div>
391
+ )}
256
392
 
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>
260
- </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)
267
-
268
- return (
269
- <div
270
- key={submission.id}
271
- className="rounded-lg border border-gray-200 bg-white transition-shadow hover:shadow-md"
393
+ {error ? (
394
+ <FormsErrorState message={error} onRetry={load} />
395
+ ) : loading ? (
396
+ <FormsLoading label="Loading submissions…" />
397
+ ) : showEmpty ? (
398
+ <FormsEmptyState
399
+ icon={<Inbox className="h-6 w-6" aria-hidden />}
400
+ title={isFiltered ? 'No submissions match your filters' : 'No submissions yet'}
401
+ description={
402
+ isFiltered
403
+ ? 'Try a different search term or status filter.'
404
+ : 'Submissions will appear here once your form starts receiving entries.'
405
+ }
406
+ action={
407
+ isFiltered ? (
408
+ <button
409
+ type="button"
410
+ onClick={() => {
411
+ setSearch('')
412
+ setFilter('all')
413
+ setPage(1)
414
+ }}
415
+ className="text-primary text-sm font-medium hover:underline"
272
416
  >
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>
417
+ Clear filters
418
+ </button>
419
+ ) : undefined
420
+ }
421
+ />
422
+ ) : (
423
+ <SubmissionsTable
424
+ entries={entries}
425
+ selected={selected}
426
+ allSelected={allSelected}
427
+ onToggleSelectAll={toggleSelectAll}
428
+ onToggleSelect={toggleSelect}
429
+ onOpen={openEntry}
430
+ onToggleStar={toggleStar}
431
+ footer={
432
+ <FormsPagination
433
+ page={page}
434
+ pageSize={PAGE_SIZE}
435
+ total={total}
436
+ onPageChange={setPage}
437
+ noun="submission"
438
+ label="Submissions pagination"
439
+ />
440
+ }
441
+ />
442
+ )}
443
+
444
+ <EntryDetailDrawer
445
+ entryId={activeEntry}
446
+ open={drawerOpen}
447
+ onOpenChange={setDrawerOpen}
448
+ onChanged={refresh}
449
+ onDeleted={() => refresh()}
450
+ />
451
+
452
+ <ConfirmDialog
453
+ open={confirmBulkDelete}
454
+ onClose={() => setConfirmBulkDelete(false)}
455
+ onConfirm={() => void handleBulkDelete()}
456
+ title={`Delete ${selected.size} ${selected.size === 1 ? 'submission' : 'submissions'}?`}
457
+ description="This permanently removes the selected submissions and any attached files. This cannot be undone."
458
+ confirmLabel="Delete"
459
+ destructive
460
+ />
461
+ </div>
462
+ )
463
+ }
464
+
465
+ function SubmissionsTable({
466
+ entries,
467
+ selected,
468
+ allSelected,
469
+ onToggleSelectAll,
470
+ onToggleSelect,
471
+ onOpen,
472
+ onToggleStar,
473
+ footer,
474
+ }: {
475
+ entries: FormSubmission[]
476
+ selected: Set<string>
477
+ allSelected: boolean
478
+ onToggleSelectAll: () => void
479
+ onToggleSelect: (id: string) => void
480
+ onOpen: (id: string) => void
481
+ onToggleStar: (entry: FormSubmission) => void
482
+ footer?: ReactNode
483
+ }) {
484
+ return (
485
+ <div className="border-border bg-card rounded-lg border">
486
+ <div className="overflow-x-auto">
487
+ <table className="w-full min-w-[720px] text-sm" aria-label="Submissions">
488
+ <thead>
489
+ <tr className="border-border text-muted-foreground border-b text-left text-xs">
490
+ <th scope="col" className="w-10 px-4 py-2.5">
491
+ <input
492
+ type="checkbox"
493
+ checked={allSelected}
494
+ onChange={onToggleSelectAll}
495
+ aria-label="Select all submissions"
496
+ className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
497
+ />
498
+ </th>
499
+ <th scope="col" className="px-4 py-2.5 font-medium">
500
+ Sender
501
+ </th>
502
+ <th scope="col" className="px-4 py-2.5 font-medium">
503
+ Submission
504
+ </th>
505
+ <th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
506
+ Submitted
507
+ </th>
508
+ <th scope="col" className="w-10 px-4 py-2.5 text-center font-medium">
509
+ <span className="sr-only">Starred</span>
510
+ </th>
511
+ </tr>
512
+ </thead>
513
+ <tbody>
514
+ {entries.map((entry) => {
515
+ const isSelected = selected.has(entry.id)
516
+ const name = entry.senderName || 'Anonymous'
517
+ return (
518
+ <tr
519
+ key={entry.id}
520
+ className={`border-border hover:bg-accent/40 group cursor-pointer border-b transition-colors last:border-b-0 ${
521
+ isSelected ? 'bg-accent/30' : ''
522
+ }`}
523
+ onClick={() => onOpen(entry.id)}
524
+ >
525
+ <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
526
+ <input
527
+ type="checkbox"
528
+ checked={isSelected}
529
+ onChange={() => onToggleSelect(entry.id)}
530
+ aria-label={`Select submission from ${name}`}
531
+ className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
532
+ />
533
+ </td>
534
+ <td className="px-4 py-3">
535
+ <div className="flex items-center gap-3">
536
+ <span
537
+ className="bg-muted text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
538
+ aria-hidden
539
+ >
540
+ {name.charAt(0).toUpperCase()}
541
+ </span>
542
+ <div className="min-w-0">
543
+ <div className="flex items-center gap-2">
544
+ {entry.unread && (
545
+ <span
546
+ className="bg-primary h-1.5 w-1.5 shrink-0 rounded-full"
547
+ aria-label="Unread"
548
+ />
549
+ )}
282
550
  <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'}`}
551
+ className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
284
552
  >
285
- {submission.status}
553
+ {name}
286
554
  </span>
287
555
  </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>
556
+ {entry.senderEmail && (
557
+ <span className="text-muted-foreground block truncate text-xs">
558
+ {entry.senderEmail}
559
+ </span>
560
+ )}
296
561
  </div>
297
562
  </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}
563
+ </td>
564
+ <td className="px-4 py-3">
565
+ <span className="text-muted-foreground line-clamp-1 max-w-md">
566
+ {entry.preview || '—'}
318
567
  </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
- )}
568
+ </td>
569
+ <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
570
+ {formatDateTime(entry.submittedAt)}
571
+ </td>
572
+ <td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
334
573
  <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"
574
+ type="button"
575
+ className={btnGhostIcon}
576
+ aria-pressed={entry.starred}
577
+ aria-label={entry.starred ? 'Unstar submission' : 'Star submission'}
578
+ onClick={() => onToggleStar(entry)}
337
579
  >
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'}
580
+ <Star
581
+ className={`h-4 w-4 ${entry.starred ? 'fill-warning text-warning' : ''}`}
582
+ aria-hidden
583
+ />
344
584
  </button>
345
- </div>
346
-
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>
444
-
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
- )}
585
+ </td>
586
+ </tr>
587
+ )
588
+ })}
589
+ </tbody>
590
+ </table>
591
+ </div>
592
+ {footer}
458
593
  </div>
459
594
  )
460
595
  }