@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.
- 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-badge.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
- 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/__tests__/views/forms-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.js +122 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/Drawer.d.ts +13 -0
- package/dist/components/forms/Drawer.d.ts.map +1 -0
- package/dist/components/forms/Drawer.js +13 -0
- package/dist/components/forms/Drawer.js.map +1 -0
- package/dist/components/forms/EmbedPanel.d.ts +7 -0
- package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
- package/dist/components/forms/EmbedPanel.js +91 -0
- package/dist/components/forms/EmbedPanel.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/FieldsPanel.d.ts +8 -0
- package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
- package/dist/components/forms/FieldsPanel.js +123 -0
- package/dist/components/forms/FieldsPanel.js.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.js +96 -0
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.d.ts +6 -0
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +80 -0
- package/dist/components/forms/NotificationsPanel.js.map +1 -0
- package/dist/components/forms/primitives.d.ts +57 -0
- package/dist/components/forms/primitives.d.ts.map +1 -0
- package/dist/components/forms/primitives.js +131 -0
- package/dist/components/forms/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +5 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +43 -3
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-events.d.ts +17 -0
- package/dist/lib/forms-events.d.ts.map +1 -0
- package/dist/lib/forms-events.js +20 -0
- package/dist/lib/forms-events.js.map +1 -0
- package/dist/lib/forms-service.d.ts +114 -0
- package/dist/lib/forms-service.d.ts.map +1 -0
- package/dist/lib/forms-service.js +210 -0
- package/dist/lib/forms-service.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +224 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +135 -17
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
- package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
- package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/__tests__/views/forms-list.render.test.tsx +141 -0
- package/src/components/forms/Drawer.tsx +70 -0
- package/src/components/forms/EmbedPanel.tsx +173 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/FieldsPanel.tsx +385 -0
- package/src/components/forms/FormSchemaDrawer.tsx +185 -0
- package/src/components/forms/NotificationsPanel.tsx +240 -0
- package/src/components/forms/primitives.tsx +300 -0
- package/src/layout/Sidebar.tsx +72 -6
- package/src/lib/forms-events.ts +32 -0
- package/src/lib/forms-service.ts +343 -0
- package/src/views/FormSubmissions.tsx +529 -394
- package/src/views/Forms.tsx +381 -106
|
@@ -1,460 +1,595 @@
|
|
|
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, type ReactNode } from 'react'
|
|
14
|
+
import { ArrowLeft, Download, Inbox, Search, Star, Trash2 } from 'lucide-react'
|
|
21
15
|
import { toast } from 'sonner'
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
>(
|
|
84
|
-
const [
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
87
|
-
const [
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
}, [submissions, searchQuery, filterStatus, filterSource])
|
|
141
|
+
}, [formId])
|
|
107
142
|
|
|
108
|
-
const
|
|
143
|
+
const loadCounts = useCallback(() => {
|
|
144
|
+
void fetchEntryCounts({ formId })
|
|
145
|
+
.then(setCounts)
|
|
146
|
+
.catch(() => {})
|
|
147
|
+
}, [formId])
|
|
109
148
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
}, [submissions])
|
|
168
|
+
}, [formId, search, filter, page])
|
|
118
169
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
const organicCount = submissions.filter((s) => s.attribution?.medium === 'organic').length
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
void load()
|
|
172
|
+
}, [load])
|
|
123
173
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
loadCounts()
|
|
176
|
+
}, [loadCounts])
|
|
127
177
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
268
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
160
269
|
<div>
|
|
161
|
-
<h1 className="
|
|
162
|
-
|
|
270
|
+
<h1 className="text-foreground text-2xl font-medium">
|
|
271
|
+
{formName ? `${formName} — Submissions` : 'Submissions'}
|
|
163
272
|
</h1>
|
|
164
|
-
<p className="text-
|
|
165
|
-
{
|
|
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
|
-
|
|
170
|
-
className=
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
300
|
+
)}
|
|
202
301
|
|
|
203
|
-
<div className="
|
|
204
|
-
<
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
)
|
|
253
|
-
|
|
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
|
-
|
|
391
|
+
)}
|
|
256
392
|
|
|
257
|
-
{
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
) : (
|
|
262
|
-
<
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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={`
|
|
551
|
+
className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
|
|
284
552
|
>
|
|
285
|
-
{
|
|
553
|
+
{name}
|
|
286
554
|
</span>
|
|
287
555
|
</div>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
336
|
-
className=
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
</
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
}
|