@actuate-media/cms-admin 0.77.0 → 0.79.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/CHANGELOG.md +35 -0
- package/dist/__tests__/components/attribution-rows.test.d.ts +2 -0
- package/dist/__tests__/components/attribution-rows.test.d.ts.map +1 -0
- package/dist/__tests__/components/attribution-rows.test.js +82 -0
- package/dist/__tests__/components/attribution-rows.test.js.map +1 -0
- package/dist/__tests__/views/form-editor.render.test.js +1 -0
- package/dist/__tests__/views/form-editor.render.test.js.map +1 -1
- package/dist/__tests__/views/form-entries.render.test.js +20 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.js +12 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +2 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +2 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/components/forms/ExportMenu.d.ts +8 -0
- package/dist/components/forms/ExportMenu.d.ts.map +1 -0
- package/dist/components/forms/ExportMenu.js +32 -0
- package/dist/components/forms/ExportMenu.js.map +1 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
- package/dist/components/forms/FormEntryDetailPane.js +74 -8
- package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.js +1 -1
- package/dist/components/forms/FormSchemaPane.js.map +1 -1
- package/dist/components/forms/NotificationsPanel.d.ts +4 -1
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
- package/dist/components/forms/NotificationsPanel.js +66 -12
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/ShortcodeGlossary.d.ts +5 -0
- package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -0
- package/dist/components/forms/ShortcodeGlossary.js +62 -0
- package/dist/components/forms/ShortcodeGlossary.js.map +1 -0
- package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
- package/dist/components/forms/WebhooksPanel.js +1 -1
- package/dist/components/forms/WebhooksPanel.js.map +1 -1
- package/dist/components/ui/ReauthDialog.d.ts +17 -0
- package/dist/components/ui/ReauthDialog.d.ts.map +1 -0
- package/dist/components/ui/ReauthDialog.js +34 -0
- package/dist/components/ui/ReauthDialog.js.map +1 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/lib/forms-service.d.ts +23 -0
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +42 -1
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +4 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +1 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +22 -23
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/FormEditor.js +1 -1
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/FormEntries.d.ts.map +1 -1
- package/dist/views/FormEntries.js +8 -36
- package/dist/views/FormEntries.js.map +1 -1
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +7 -36
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +3 -2
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +2 -30
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/attribution-rows.test.ts +93 -0
- package/src/__tests__/views/form-editor.render.test.tsx +1 -0
- package/src/__tests__/views/form-entries.render.test.tsx +29 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
- package/src/__tests__/views/forms-list.render.test.tsx +2 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +2 -0
- package/src/components/forms/ExportMenu.tsx +70 -0
- package/src/components/forms/FormEntryDetailPane.tsx +82 -8
- package/src/components/forms/FormSchemaPane.tsx +1 -1
- package/src/components/forms/NotificationsPanel.tsx +150 -20
- package/src/components/forms/ShortcodeGlossary.tsx +146 -0
- package/src/components/forms/WebhooksPanel.tsx +5 -1
- package/src/components/ui/ReauthDialog.tsx +93 -0
- package/src/components/ui/index.ts +2 -0
- package/src/lib/forms-service.ts +57 -1
- package/src/lib/seo-service.ts +5 -0
- package/src/views/ApiKeys.tsx +48 -41
- package/src/views/FormEditor.tsx +1 -1
- package/src/views/FormEntries.tsx +7 -46
- package/src/views/FormSubmissions.tsx +6 -46
- package/src/views/seo/RedirectsTab.tsx +3 -1
- package/src/views/users/UserDetailDrawer.tsx +1 -76
package/src/views/ApiKeys.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import { type FormEvent, useState } from 'react'
|
|
|
17
17
|
import { toast } from 'sonner'
|
|
18
18
|
import { useApiData } from '../lib/useApiData.js'
|
|
19
19
|
import { cmsApi } from '../lib/api.js'
|
|
20
|
+
import { ReauthDialog } from '../components/ui/ReauthDialog.js'
|
|
20
21
|
import { SettingsCard } from './settings/components.js'
|
|
21
22
|
|
|
22
23
|
const DIALOG_INPUT_CLASS =
|
|
@@ -80,26 +81,11 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
|
|
|
80
81
|
const { data, loading, error, refetch } = useApiData<ApiKeyRecord[]>('/api-keys')
|
|
81
82
|
const [showCreate, setShowCreate] = useState(false)
|
|
82
83
|
const [createdKey, setCreatedKey] = useState<{ key: string; record: ApiKeyRecord } | null>(null)
|
|
84
|
+
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null)
|
|
83
85
|
|
|
84
86
|
const keys = data ?? []
|
|
85
87
|
const rootPadding = embedded ? '' : 'p-4 sm:p-6'
|
|
86
88
|
|
|
87
|
-
const handleRevoke = async (id: string, name: string) => {
|
|
88
|
-
if (!confirm(`Revoke API key "${name}"? This cannot be undone.`)) return
|
|
89
|
-
const password = prompt('Confirm your password to revoke this API key:')
|
|
90
|
-
if (!password) return
|
|
91
|
-
const res = await cmsApi(`/api-keys/${id}`, {
|
|
92
|
-
method: 'DELETE',
|
|
93
|
-
headers: { 'x-reauth-password': password },
|
|
94
|
-
})
|
|
95
|
-
if (res.error) {
|
|
96
|
-
toast.error(res.error)
|
|
97
|
-
} else {
|
|
98
|
-
toast.success('API key revoked')
|
|
99
|
-
refetch()
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
89
|
if (loading) {
|
|
104
90
|
return (
|
|
105
91
|
<div className={`flex h-64 items-center justify-center ${embedded ? '' : 'p-4 sm:p-6'}`}>
|
|
@@ -193,10 +179,9 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
|
|
|
193
179
|
</thead>
|
|
194
180
|
<tbody className="divide-border divide-y">
|
|
195
181
|
{keys.map((k) => {
|
|
196
|
-
const revoked = !!k.revokedAt
|
|
197
182
|
const expired = k.expiresAt && new Date(k.expiresAt).getTime() < Date.now()
|
|
198
183
|
return (
|
|
199
|
-
<tr key={k.id} className={
|
|
184
|
+
<tr key={k.id} className={expired ? 'opacity-60' : undefined}>
|
|
200
185
|
<td className="px-4 py-3 text-sm">
|
|
201
186
|
<div className="text-foreground font-medium">{k.name}</div>
|
|
202
187
|
{k.user && (
|
|
@@ -218,11 +203,7 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
|
|
|
218
203
|
{formatDate(k.expiresAt)}
|
|
219
204
|
</td>
|
|
220
205
|
<td className="px-4 py-3">
|
|
221
|
-
{
|
|
222
|
-
<span className="bg-destructive/10 text-destructive inline-flex rounded-full px-2 py-0.5 text-xs font-medium">
|
|
223
|
-
Revoked
|
|
224
|
-
</span>
|
|
225
|
-
) : expired ? (
|
|
206
|
+
{expired ? (
|
|
226
207
|
<span className="bg-warning/10 text-warning inline-flex rounded-full px-2 py-0.5 text-xs font-medium">
|
|
227
208
|
Expired
|
|
228
209
|
</span>
|
|
@@ -233,17 +214,15 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
|
|
|
233
214
|
)}
|
|
234
215
|
</td>
|
|
235
216
|
<td className="px-4 py-3 text-right">
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
</button>
|
|
246
|
-
)}
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => setPendingDelete({ id: k.id, name: k.name })}
|
|
220
|
+
className="hover:bg-destructive/10 rounded p-1.5 transition-colors"
|
|
221
|
+
aria-label={`Delete ${k.name}`}
|
|
222
|
+
title="Delete"
|
|
223
|
+
>
|
|
224
|
+
<Trash2 className="text-destructive h-4 w-4" aria-hidden="true" />
|
|
225
|
+
</button>
|
|
247
226
|
</td>
|
|
248
227
|
</tr>
|
|
249
228
|
)
|
|
@@ -266,6 +245,33 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
|
|
|
266
245
|
/>
|
|
267
246
|
|
|
268
247
|
{createdKey && <RevealKeyDialog created={createdKey} onClose={() => setCreatedKey(null)} />}
|
|
248
|
+
|
|
249
|
+
<ReauthDialog
|
|
250
|
+
open={!!pendingDelete}
|
|
251
|
+
onClose={() => setPendingDelete(null)}
|
|
252
|
+
title="Delete API key"
|
|
253
|
+
description={
|
|
254
|
+
pendingDelete
|
|
255
|
+
? `Permanently delete “${pendingDelete.name}”? Clients using this key will fail immediately. This cannot be undone.`
|
|
256
|
+
: 'Permanently delete this API key? This cannot be undone.'
|
|
257
|
+
}
|
|
258
|
+
confirmLabel="Delete key"
|
|
259
|
+
destructive
|
|
260
|
+
onSubmit={async (password) => {
|
|
261
|
+
if (!pendingDelete) return
|
|
262
|
+
const res = await cmsApi(`/api-keys/${pendingDelete.id}`, {
|
|
263
|
+
method: 'DELETE',
|
|
264
|
+
headers: { 'x-reauth-password': password },
|
|
265
|
+
})
|
|
266
|
+
if (res.error) {
|
|
267
|
+
toast.error(res.error)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
toast.success('API key deleted')
|
|
271
|
+
setPendingDelete(null)
|
|
272
|
+
refetch()
|
|
273
|
+
}}
|
|
274
|
+
/>
|
|
269
275
|
</div>
|
|
270
276
|
)
|
|
271
277
|
}
|
|
@@ -442,24 +448,25 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
|
|
|
442
448
|
/>
|
|
443
449
|
</div>
|
|
444
450
|
|
|
445
|
-
<div>
|
|
451
|
+
<div className="border-border bg-muted/30 space-y-2 rounded-md border p-3">
|
|
446
452
|
<label
|
|
447
453
|
htmlFor="apikey-password"
|
|
448
|
-
className="text-foreground
|
|
454
|
+
className="text-foreground block text-sm font-medium"
|
|
449
455
|
>
|
|
450
|
-
Confirm
|
|
456
|
+
Confirm it's you
|
|
451
457
|
</label>
|
|
458
|
+
<p className="text-muted-foreground text-sm">
|
|
459
|
+
Creating an API key is a sensitive action. Re-enter your password to continue.
|
|
460
|
+
</p>
|
|
452
461
|
<input
|
|
453
462
|
id="apikey-password"
|
|
454
463
|
type="password"
|
|
455
464
|
value={password}
|
|
456
465
|
onChange={(e) => setPassword(e.target.value)}
|
|
466
|
+
autoComplete="current-password"
|
|
457
467
|
className={DIALOG_INPUT_CLASS}
|
|
458
468
|
required
|
|
459
469
|
/>
|
|
460
|
-
<p className="text-muted-foreground mt-1 text-sm">
|
|
461
|
-
Creating an API key is a sensitive action and requires re-authentication.
|
|
462
|
-
</p>
|
|
463
470
|
</div>
|
|
464
471
|
|
|
465
472
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
@@ -509,7 +516,7 @@ function RevealKeyDialog({
|
|
|
509
516
|
<div className="border-warning/30 bg-warning/10 mb-4 flex items-start gap-2 rounded-lg border p-4">
|
|
510
517
|
<AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
511
518
|
<p className="text-foreground text-xs">
|
|
512
|
-
We only store a hash of this key. If you lose it, you'll need to
|
|
519
|
+
We only store a hash of this key. If you lose it, you'll need to delete it and
|
|
513
520
|
create a new one.
|
|
514
521
|
</p>
|
|
515
522
|
</div>
|
package/src/views/FormEditor.tsx
CHANGED
|
@@ -314,7 +314,7 @@ function ExistingFormEditor({
|
|
|
314
314
|
|
|
315
315
|
<Tabs.Content value="notifications" tabIndex={-1}>
|
|
316
316
|
<div className="border-border bg-card rounded-lg border p-4">
|
|
317
|
-
<NotificationsPanel formId={formId} />
|
|
317
|
+
<NotificationsPanel formId={formId} fields={fields} />
|
|
318
318
|
</div>
|
|
319
319
|
</Tabs.Content>
|
|
320
320
|
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* All Entries — a cross-form submissions inbox. Same token-only table, search,
|
|
5
|
-
* status pills (All / Unread / Starred / Archived), bulk actions,
|
|
6
|
-
* pagination, and inline detail pane as the per-form
|
|
7
|
-
* Form filter and a Form column so editors can
|
|
8
|
-
* place.
|
|
5
|
+
* status pills (All / Unread / Starred / Archived), bulk actions, server-side
|
|
6
|
+
* CSV/Excel export, pagination, and inline detail pane as the per-form
|
|
7
|
+
* Submissions view, plus a Form filter and a Form column so editors can
|
|
8
|
+
* triage every form from one place.
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
-
import {
|
|
11
|
+
import { Inbox, Search } from 'lucide-react'
|
|
12
12
|
import { toast } from 'sonner'
|
|
13
13
|
import {
|
|
14
14
|
bulkUpdateEntries,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
26
26
|
import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
|
|
27
27
|
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
28
|
+
import { ExportMenu } from '../components/forms/ExportMenu.js'
|
|
28
29
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
29
30
|
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
30
31
|
import {
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
FormsLoading,
|
|
34
35
|
FormsPagination,
|
|
35
36
|
SummaryChip,
|
|
36
|
-
btnSecondary,
|
|
37
37
|
} from '../components/forms/primitives.js'
|
|
38
38
|
|
|
39
39
|
export interface FormEntriesProps {
|
|
@@ -64,37 +64,6 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function csvEscape(value: unknown): string {
|
|
68
|
-
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
69
|
-
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function exportCsv(entries: FormSubmission[]) {
|
|
73
|
-
const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
|
|
74
|
-
const header = ['Entry', 'Form', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
|
|
75
|
-
const lines = [header.map(csvEscape).join(',')]
|
|
76
|
-
for (const e of entries) {
|
|
77
|
-
const row = [
|
|
78
|
-
e.entryNumber ?? '',
|
|
79
|
-
e.formName ?? '',
|
|
80
|
-
e.submittedAt,
|
|
81
|
-
e.senderName ?? '',
|
|
82
|
-
e.senderEmail ?? '',
|
|
83
|
-
e.senderPhone ?? '',
|
|
84
|
-
e.unread ? 'unread' : 'read',
|
|
85
|
-
...dataKeys.map((k) => e.data?.[k]),
|
|
86
|
-
]
|
|
87
|
-
lines.push(row.map(csvEscape).join(','))
|
|
88
|
-
}
|
|
89
|
-
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
90
|
-
const url = URL.createObjectURL(blob)
|
|
91
|
-
const a = document.createElement('a')
|
|
92
|
-
a.href = url
|
|
93
|
-
a.download = 'all-entries.csv'
|
|
94
|
-
a.click()
|
|
95
|
-
URL.revokeObjectURL(url)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
67
|
export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
99
68
|
const [entries, setEntries] = useState<FormSubmission[]>([])
|
|
100
69
|
const [total, setTotal] = useState(0)
|
|
@@ -240,15 +209,7 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
|
240
209
|
{total} {total === 1 ? 'submission' : 'submissions'} across your forms
|
|
241
210
|
</p>
|
|
242
211
|
</div>
|
|
243
|
-
<
|
|
244
|
-
type="button"
|
|
245
|
-
className={btnSecondary}
|
|
246
|
-
onClick={() => exportCsv(entries)}
|
|
247
|
-
disabled={entries.length === 0}
|
|
248
|
-
>
|
|
249
|
-
<Download className="h-4 w-4" aria-hidden />
|
|
250
|
-
Export CSV
|
|
251
|
-
</button>
|
|
212
|
+
<ExportMenu formId={scopedFormId} disabled={counts !== null && counts.total === 0} />
|
|
252
213
|
</div>
|
|
253
214
|
|
|
254
215
|
{counts && (
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Features: summary chips, search + status pills (All / Unread / Starred /
|
|
8
8
|
* Archived), row selection with bulk actions (mark read, star, archive,
|
|
9
|
-
* delete), per-row star toggle, CSV export, and an inline
|
|
10
|
-
* opens on row click (marking the entry read). Loading /
|
|
11
|
-
* filtered-empty / error states are all handled.
|
|
9
|
+
* delete), per-row star toggle, server-side CSV/Excel export, and an inline
|
|
10
|
+
* detail pane that opens on row click (marking the entry read). Loading /
|
|
11
|
+
* empty / filtered-empty / error states are all handled.
|
|
12
12
|
*/
|
|
13
13
|
import { useCallback, useEffect, useState } from 'react'
|
|
14
|
-
import { ArrowLeft,
|
|
14
|
+
import { ArrowLeft, Inbox, Search } from 'lucide-react'
|
|
15
15
|
import { toast } from 'sonner'
|
|
16
16
|
import {
|
|
17
17
|
bulkUpdateEntries,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
28
28
|
import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
|
|
29
29
|
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
30
|
+
import { ExportMenu } from '../components/forms/ExportMenu.js'
|
|
30
31
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
31
32
|
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
32
33
|
import {
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
FormsLoading,
|
|
36
37
|
FormsPagination,
|
|
37
38
|
SummaryChip,
|
|
38
|
-
btnSecondary,
|
|
39
39
|
} from '../components/forms/primitives.js'
|
|
40
40
|
|
|
41
41
|
export interface FormSubmissionsProps {
|
|
@@ -67,39 +67,8 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function csvEscape(value: unknown): string {
|
|
71
|
-
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
72
|
-
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function exportCsv(entries: FormSubmission[], formSlug: string) {
|
|
76
|
-
const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
|
|
77
|
-
const header = ['Entry', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
|
|
78
|
-
const lines = [header.map(csvEscape).join(',')]
|
|
79
|
-
for (const e of entries) {
|
|
80
|
-
const row = [
|
|
81
|
-
e.entryNumber ?? '',
|
|
82
|
-
e.submittedAt,
|
|
83
|
-
e.senderName ?? '',
|
|
84
|
-
e.senderEmail ?? '',
|
|
85
|
-
e.senderPhone ?? '',
|
|
86
|
-
e.unread ? 'unread' : 'read',
|
|
87
|
-
...dataKeys.map((k) => e.data?.[k]),
|
|
88
|
-
]
|
|
89
|
-
lines.push(row.map(csvEscape).join(','))
|
|
90
|
-
}
|
|
91
|
-
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
92
|
-
const url = URL.createObjectURL(blob)
|
|
93
|
-
const a = document.createElement('a')
|
|
94
|
-
a.href = url
|
|
95
|
-
a.download = `${formSlug || 'form'}-submissions.csv`
|
|
96
|
-
a.click()
|
|
97
|
-
URL.revokeObjectURL(url)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
70
|
export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
101
71
|
const [formName, setFormName] = useState<string>('')
|
|
102
|
-
const [formSlug, setFormSlug] = useState<string>('')
|
|
103
72
|
const [entries, setEntries] = useState<FormSubmission[]>([])
|
|
104
73
|
const [total, setTotal] = useState(0)
|
|
105
74
|
const [counts, setCounts] = useState<EntryCounts | null>(null)
|
|
@@ -119,7 +88,6 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
119
88
|
.then((form) => {
|
|
120
89
|
if (cancelled || !form) return
|
|
121
90
|
setFormName(form.name)
|
|
122
|
-
setFormSlug(form.slug)
|
|
123
91
|
})
|
|
124
92
|
.catch(() => {})
|
|
125
93
|
return () => {
|
|
@@ -261,15 +229,7 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
261
229
|
{total} {total === 1 ? 'submission' : 'submissions'}
|
|
262
230
|
</p>
|
|
263
231
|
</div>
|
|
264
|
-
<
|
|
265
|
-
type="button"
|
|
266
|
-
className={btnSecondary}
|
|
267
|
-
onClick={() => exportCsv(entries, formSlug)}
|
|
268
|
-
disabled={entries.length === 0}
|
|
269
|
-
>
|
|
270
|
-
<Download className="h-4 w-4" aria-hidden />
|
|
271
|
-
Export CSV
|
|
272
|
-
</button>
|
|
232
|
+
<ExportMenu formId={formId} disabled={counts !== null && counts.total === 0} />
|
|
273
233
|
</div>
|
|
274
234
|
</div>
|
|
275
235
|
|
|
@@ -53,12 +53,13 @@ function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
|
|
|
53
53
|
if (!data) return null
|
|
54
54
|
const cards = [
|
|
55
55
|
{ label: 'Active redirects', value: data.activeRedirects },
|
|
56
|
+
{ label: 'Never hit', value: data.neverHitRedirects },
|
|
56
57
|
{ label: 'Stale redirects', value: data.staleRedirects },
|
|
57
58
|
{ label: '404s tracked', value: data.notFoundCount },
|
|
58
59
|
{ label: 'Chains & loops', value: data.chainsAndLoops },
|
|
59
60
|
]
|
|
60
61
|
return (
|
|
61
|
-
<div className="grid grid-cols-2 gap-4 lg:grid-cols-
|
|
62
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-5">
|
|
62
63
|
{cards.map((c) => (
|
|
63
64
|
<div key={c.label} className="border-border bg-card rounded-lg border p-4">
|
|
64
65
|
<p className="text-muted-foreground text-sm">{c.label}</p>
|
|
@@ -470,6 +471,7 @@ export function RedirectsTab() {
|
|
|
470
471
|
label={r.enabled ? 'Enabled' : 'Disabled'}
|
|
471
472
|
tone={r.enabled ? 'good' : 'neutral'}
|
|
472
473
|
/>
|
|
474
|
+
{r.neverHit && <SeoStatusBadge label="Never hit" tone="neutral" />}
|
|
473
475
|
{r.stale && <SeoStatusBadge label="Stale" tone="fair" />}
|
|
474
476
|
</span>
|
|
475
477
|
</td>
|
|
@@ -39,6 +39,7 @@ import { Button } from '../../components/ui/Button.js'
|
|
|
39
39
|
import { Skeleton } from '../../components/ui/Skeleton.js'
|
|
40
40
|
import { EmptyState } from '../../components/ui/EmptyState.js'
|
|
41
41
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
42
|
+
import { ReauthDialog } from '../../components/ui/ReauthDialog.js'
|
|
42
43
|
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
44
45
|
// Types (mirror cms-core serialized shapes)
|
|
@@ -151,82 +152,6 @@ const STATUS_META: Record<UserDetail['status'], { dot: string; text: string; lab
|
|
|
151
152
|
},
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
// Reauth dialog
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
|
|
158
|
-
function ReauthDialog({
|
|
159
|
-
open,
|
|
160
|
-
onClose,
|
|
161
|
-
onSubmit,
|
|
162
|
-
}: {
|
|
163
|
-
open: boolean
|
|
164
|
-
onClose: () => void
|
|
165
|
-
onSubmit: (password: string) => Promise<void>
|
|
166
|
-
}) {
|
|
167
|
-
const [password, setPassword] = useState('')
|
|
168
|
-
const [busy, setBusy] = useState(false)
|
|
169
|
-
|
|
170
|
-
const submit = async () => {
|
|
171
|
-
if (!password || busy) return
|
|
172
|
-
setBusy(true)
|
|
173
|
-
try {
|
|
174
|
-
await onSubmit(password)
|
|
175
|
-
setPassword('')
|
|
176
|
-
} finally {
|
|
177
|
-
setBusy(false)
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return (
|
|
182
|
-
<Modal
|
|
183
|
-
open={open}
|
|
184
|
-
onClose={() => {
|
|
185
|
-
setPassword('')
|
|
186
|
-
onClose()
|
|
187
|
-
}}
|
|
188
|
-
title="Confirm it's you"
|
|
189
|
-
actions={
|
|
190
|
-
<>
|
|
191
|
-
<Button
|
|
192
|
-
variant="ghost"
|
|
193
|
-
onClick={() => {
|
|
194
|
-
setPassword('')
|
|
195
|
-
onClose()
|
|
196
|
-
}}
|
|
197
|
-
>
|
|
198
|
-
Cancel
|
|
199
|
-
</Button>
|
|
200
|
-
<Button variant="primary" loading={busy} disabled={!password || busy} onClick={submit}>
|
|
201
|
-
Confirm
|
|
202
|
-
</Button>
|
|
203
|
-
</>
|
|
204
|
-
}
|
|
205
|
-
>
|
|
206
|
-
<p className="text-muted-foreground mb-3 text-sm">
|
|
207
|
-
This is a sensitive action. Re-enter your password to continue.
|
|
208
|
-
</p>
|
|
209
|
-
<label
|
|
210
|
-
htmlFor="reauth-password"
|
|
211
|
-
className="text-card-foreground mb-1 block text-sm font-medium"
|
|
212
|
-
>
|
|
213
|
-
Your password
|
|
214
|
-
</label>
|
|
215
|
-
<input
|
|
216
|
-
id="reauth-password"
|
|
217
|
-
type="password"
|
|
218
|
-
value={password}
|
|
219
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
220
|
-
onKeyDown={(e) => {
|
|
221
|
-
if (e.key === 'Enter') submit()
|
|
222
|
-
}}
|
|
223
|
-
autoComplete="current-password"
|
|
224
|
-
className="border-border bg-input-background text-foreground focus:ring-brand w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
|
|
225
|
-
/>
|
|
226
|
-
</Modal>
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
155
|
// ---------------------------------------------------------------------------
|
|
231
156
|
// Transfer content modal
|
|
232
157
|
// ---------------------------------------------------------------------------
|