@actuate-media/cms-admin 0.79.0 → 0.80.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 (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +4 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/lib/pages-service-fetch.test.js +10 -0
  6. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
  7. package/dist/__tests__/lib/post-editor-service.test.js +35 -1
  8. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  11. package/dist/components/Breadcrumbs.js +2 -0
  12. package/dist/components/Breadcrumbs.js.map +1 -1
  13. package/dist/components/SEOPanel.d.ts +8 -0
  14. package/dist/components/SEOPanel.d.ts.map +1 -1
  15. package/dist/components/SEOPanel.js +7 -2
  16. package/dist/components/SEOPanel.js.map +1 -1
  17. package/dist/components/ui/Badge.d.ts +1 -1
  18. package/dist/components/ui/StatusBadge.d.ts +1 -1
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/layout/Sidebar.d.ts.map +1 -1
  24. package/dist/layout/Sidebar.js +2 -1
  25. package/dist/layout/Sidebar.js.map +1 -1
  26. package/dist/lib/forms-service.d.ts +42 -0
  27. package/dist/lib/forms-service.d.ts.map +1 -1
  28. package/dist/lib/forms-service.js +27 -0
  29. package/dist/lib/forms-service.js.map +1 -1
  30. package/dist/lib/pages-service.d.ts.map +1 -1
  31. package/dist/lib/pages-service.js +14 -15
  32. package/dist/lib/pages-service.js.map +1 -1
  33. package/dist/lib/post-editor-service.d.ts +6 -0
  34. package/dist/lib/post-editor-service.d.ts.map +1 -1
  35. package/dist/lib/post-editor-service.js +19 -1
  36. package/dist/lib/post-editor-service.js.map +1 -1
  37. package/dist/router/admin-routes.d.ts.map +1 -1
  38. package/dist/router/admin-routes.js +1 -0
  39. package/dist/router/admin-routes.js.map +1 -1
  40. package/dist/views/DocumentEdit.d.ts.map +1 -1
  41. package/dist/views/DocumentEdit.js +5 -1
  42. package/dist/views/DocumentEdit.js.map +1 -1
  43. package/dist/views/FormDeliveries.d.ts +5 -0
  44. package/dist/views/FormDeliveries.d.ts.map +1 -0
  45. package/dist/views/FormDeliveries.js +90 -0
  46. package/dist/views/FormDeliveries.js.map +1 -0
  47. package/package.json +2 -2
  48. package/src/AdminRoot.tsx +4 -0
  49. package/src/__tests__/lib/pages-service-fetch.test.ts +11 -0
  50. package/src/__tests__/lib/post-editor-service.test.ts +46 -0
  51. package/src/components/Breadcrumbs.tsx +2 -0
  52. package/src/components/SEOPanel.tsx +15 -3
  53. package/src/index.ts +3 -1
  54. package/src/layout/Sidebar.tsx +2 -0
  55. package/src/lib/forms-service.ts +74 -0
  56. package/src/lib/pages-service.ts +14 -15
  57. package/src/lib/post-editor-service.ts +17 -1
  58. package/src/router/admin-routes.ts +1 -0
  59. package/src/views/DocumentEdit.tsx +5 -1
  60. package/src/views/FormDeliveries.tsx +214 -0
@@ -15,6 +15,7 @@ const {
15
15
  fetchTemplateForEditor,
16
16
  missingPostFields,
17
17
  savePostDraft,
18
+ parseFeaturedImage,
18
19
  } = await import('../../lib/post-editor-service.js')
19
20
 
20
21
  type EditorPostT = Awaited<ReturnType<typeof emptyPostFromTemplate>>
@@ -459,4 +460,49 @@ describe('tags round-trip', () => {
459
460
  const post = await fetchPostForEditor('blog', 'p1')
460
461
  expect(post.tags).toEqual(['AI', 'Nextjs'])
461
462
  })
463
+
464
+ it('parseFeaturedImage / fetchPostForEditor keep denormalized media objects (#671)', async () => {
465
+ const mediaId = 'cmry1z9yr000004l832th7bf2'
466
+ expect(
467
+ parseFeaturedImage({
468
+ id: mediaId,
469
+ url: 'https://cdn.example.com/hero.webp',
470
+ alt: 'Hero',
471
+ }),
472
+ ).toBe(mediaId)
473
+ expect(parseFeaturedImage('https://cdn.example.com/hero.webp')).toBe(
474
+ 'https://cdn.example.com/hero.webp',
475
+ )
476
+ expect(parseFeaturedImage({ url: 'https://cdn.example.com/hero.webp' })).toBe(
477
+ 'https://cdn.example.com/hero.webp',
478
+ )
479
+
480
+ cmsApiMock.mockResolvedValueOnce({
481
+ data: {
482
+ id: 'p1',
483
+ title: 'Hello',
484
+ slug: 'hello',
485
+ status: 'DRAFT',
486
+ data: {
487
+ featuredImage: {
488
+ id: mediaId,
489
+ url: 'https://cdn.example.com/hero.webp',
490
+ alt: 'Hero',
491
+ },
492
+ },
493
+ },
494
+ })
495
+ const post = await fetchPostForEditor('blog', 'p1')
496
+ expect(post.featuredImage).toBe(mediaId)
497
+
498
+ cmsApiMock.mockResolvedValueOnce({
499
+ data: { id: 'p1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: {} },
500
+ })
501
+ await savePostDraft(basePost({ id: 'p1', featuredImage: mediaId }))
502
+ const body = JSON.parse(cmsApiMock.mock.calls[1]![1].body as string) as {
503
+ featuredImage: string
504
+ }
505
+ expect(body.featuredImage).toBe(mediaId)
506
+ expect(body.featuredImage).not.toBe('')
507
+ })
462
508
  })
@@ -17,6 +17,8 @@ const LABEL_MAP: Record<string, string> = {
17
17
  profile: 'Your Profile',
18
18
  collections: 'Collections',
19
19
  submissions: 'Submissions',
20
+ entries: 'All Entries',
21
+ deliveries: 'Deliveries',
20
22
  new: 'New',
21
23
  edit: 'Edit',
22
24
  }
@@ -27,6 +27,12 @@ import { Toggle } from './ui/Toggle.js'
27
27
  export interface SEOData {
28
28
  metaTitle?: string
29
29
  metaDescription?: string
30
+ /** Canonical stored key (API / audit / SeoEditorPane). */
31
+ focusKeyword?: string
32
+ /**
33
+ * @deprecated Legacy DocumentEdit alias — normalized to `focusKeyword` on save.
34
+ * Still accepted on load for older documents.
35
+ */
30
36
  focusKeyphrase?: string
31
37
  keyTakeaway?: string
32
38
  canonical?: string
@@ -48,6 +54,12 @@ export interface SEOData {
48
54
  schemaType?: string
49
55
  }
50
56
 
57
+ /** Resolve focus keyphrase from either the canonical or legacy field name. */
58
+ export function resolveFocusKeyword(seo: Pick<SEOData, 'focusKeyword' | 'focusKeyphrase'>): string {
59
+ const v = seo.focusKeyword ?? seo.focusKeyphrase
60
+ return typeof v === 'string' ? v : ''
61
+ }
62
+
51
63
  export interface SEOPanelProps {
52
64
  title: string
53
65
  slug: string
@@ -176,7 +188,7 @@ function runSEOChecks(seoData: SEOData, title: string, slug: string, content: st
176
188
  const checks: SEOCheck[] = []
177
189
  const plainText = stripHtml(content)
178
190
  const wordCount = countWords(plainText)
179
- const keyphrase = (seoData.focusKeyphrase ?? '').toLowerCase().trim()
191
+ const keyphrase = resolveFocusKeyword(seoData).toLowerCase().trim()
180
192
  const metaTitle = seoData.metaTitle ?? ''
181
193
  const metaDesc = seoData.metaDescription ?? ''
182
194
 
@@ -741,8 +753,8 @@ export function SEOPanel({
741
753
  />
742
754
  <InputField
743
755
  label="Focus Keyphrase"
744
- value={seoData.focusKeyphrase ?? ''}
745
- onChange={(v) => update({ focusKeyphrase: v })}
756
+ value={resolveFocusKeyword(seoData)}
757
+ onChange={(v) => update({ focusKeyword: v, focusKeyphrase: undefined })}
746
758
  placeholder="Primary keyword or phrase"
747
759
  />
748
760
  <TextareaField
package/src/index.ts CHANGED
@@ -59,6 +59,8 @@ export { Forms } from './views/Forms.js'
59
59
  export { FormEditor } from './views/FormEditor.js'
60
60
  export type { FormEditorProps } from './views/FormEditor.js'
61
61
  export { FormSubmissions } from './views/FormSubmissions.js'
62
+ export { FormDeliveries } from './views/FormDeliveries.js'
63
+ export type { FormDeliveriesProps } from './views/FormDeliveries.js'
62
64
  /** @deprecated Superseded by the Redirects tab inside the `SEO` view; will be removed in the next major. */
63
65
  export { Redirects } from './views/Redirects.js'
64
66
  export { Users } from './views/Users.js'
@@ -97,7 +99,7 @@ export type {
97
99
  SmartFolder,
98
100
  } from './components/FolderTree.js'
99
101
  export { TipTapEditor } from './components/TipTapEditor.js'
100
- export { SEOPanel } from './components/SEOPanel.js'
102
+ export { SEOPanel, resolveFocusKeyword } from './components/SEOPanel.js'
101
103
  export type { SEOData, SEOPanelProps } from './components/SEOPanel.js'
102
104
  export { LivePreview } from './components/LivePreview.js'
103
105
  export { VersionHistory } from './components/VersionHistory.js'
@@ -16,6 +16,7 @@ import {
16
16
  ChevronDown,
17
17
  ClipboardList,
18
18
  Inbox,
19
+ AlertTriangle,
19
20
  Search as SearchIcon,
20
21
  Briefcase,
21
22
  FolderOpen,
@@ -355,6 +356,7 @@ function applyFormsNav(items: NavItem[], state: FormsSidebarState): NavItem[] {
355
356
  if (item.path !== '/forms') return item
356
357
  const children: NavItem[] = [
357
358
  { path: '/forms/entries', label: 'All Entries', icon: Inbox },
359
+ { path: '/forms/deliveries', label: 'Deliveries', icon: AlertTriangle },
358
360
  ...state.forms.map((f) => ({
359
361
  path: `/forms/${f.formId}/submissions`,
360
362
  label: f.name,
@@ -493,3 +493,77 @@ export async function downloadEntriesExport(
493
493
  URL.revokeObjectURL(url)
494
494
  return {}
495
495
  }
496
+
497
+ // ─── Delivery attempts (#675) ─────────────────────────────────────────────
498
+
499
+ export type FormDeliveryChannel = 'email_notify' | 'email_autoresponder' | 'form_webhook'
500
+ export type FormDeliveryStatus = 'pending' | 'retrying' | 'success' | 'failed' | 'skipped'
501
+
502
+ export interface FormDeliveryAttempt {
503
+ id: string
504
+ submissionId: string
505
+ formId: string
506
+ channel: FormDeliveryChannel
507
+ destination: string
508
+ webhookId: string | null
509
+ status: FormDeliveryStatus
510
+ attempts: number
511
+ maxAttempts: number
512
+ lastAttemptAt: string | null
513
+ nextRetryAt: string | null
514
+ error: string | null
515
+ responseStatus: number | null
516
+ skippedReason: string | null
517
+ alertedAt: string | null
518
+ createdAt: string
519
+ updatedAt: string
520
+ entryNumber?: number | null
521
+ formName?: string | null
522
+ }
523
+
524
+ export interface FormDeliveriesPage {
525
+ docs: FormDeliveryAttempt[]
526
+ total: number
527
+ page: number
528
+ pageSize: number
529
+ }
530
+
531
+ export interface FetchFormDeliveriesParams {
532
+ formId?: string
533
+ submissionId?: string
534
+ status?: string
535
+ page?: number
536
+ pageSize?: number
537
+ }
538
+
539
+ export async function fetchFormDeliveries(
540
+ params: FetchFormDeliveriesParams = {},
541
+ ): Promise<FormDeliveriesPage> {
542
+ const qs = new URLSearchParams()
543
+ if (params.formId) qs.set('formId', params.formId)
544
+ if (params.submissionId) qs.set('submissionId', params.submissionId)
545
+ if (params.status) qs.set('status', params.status)
546
+ if (params.page) qs.set('page', String(params.page))
547
+ if (params.pageSize) qs.set('pageSize', String(params.pageSize))
548
+ const suffix = qs.toString() ? `?${qs.toString()}` : ''
549
+ const res = await cmsApi<FormDeliveriesPage>(`/forms/deliveries${suffix}`)
550
+ throwIfError(res)
551
+ return res.data ?? { docs: [], total: 0, page: 1, pageSize: 25 }
552
+ }
553
+
554
+ export async function retryFormDelivery(
555
+ attemptId: string,
556
+ ): Promise<{ data?: FormDeliveryAttempt; error?: string }> {
557
+ const res = await cmsApi<FormDeliveryAttempt>(
558
+ `/forms/deliveries/${encodeURIComponent(attemptId)}/retry`,
559
+ { method: 'POST' },
560
+ )
561
+ return { data: res.data, error: res.error }
562
+ }
563
+
564
+ export async function fetchFailedDeliveryCount(formId?: string): Promise<number> {
565
+ const qs = formId ? `?formId=${encodeURIComponent(formId)}` : ''
566
+ const res = await cmsApi<{ count: number }>(`/forms/deliveries/failed-count${qs}`)
567
+ throwIfError(res)
568
+ return res.data?.count ?? 0
569
+ }
@@ -528,9 +528,11 @@ export async function movePage(
528
528
  id: string,
529
529
  parentPageId: string | null,
530
530
  ): Promise<{ error?: string }> {
531
+ // Flat field map — nested `{ data: { parentPageId } }` is stripped by
532
+ // field-access (undeclared `data` key) and silently no-ops.
531
533
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
532
534
  method: 'PUT',
533
- body: JSON.stringify({ data: { parentPageId } }),
535
+ body: JSON.stringify({ parentPageId }),
534
536
  })
535
537
  return res.error ? { error: res.error } : {}
536
538
  }
@@ -573,7 +575,7 @@ export async function bulkChangePageTags(
573
575
  p.primaryTagId && next.has(p.primaryTagId) ? p.primaryTagId : (tags[0] ?? null)
574
576
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
575
577
  method: 'PUT',
576
- body: JSON.stringify({ data: { tags, primaryTag } }),
578
+ body: JSON.stringify({ tags, primaryTag }),
577
579
  })
578
580
  return !res.error
579
581
  })
@@ -607,12 +609,10 @@ export async function createPageTag(input: PageTagInput): Promise<{ error?: stri
607
609
  title: input.name,
608
610
  slug,
609
611
  status: 'PUBLISHED',
610
- data: {
611
- color: input.color ?? 'gray',
612
- description: input.description ?? '',
613
- sortOrder: input.sortOrder ?? 0,
614
- active: input.active !== false,
615
- },
612
+ color: input.color ?? 'gray',
613
+ description: input.description ?? '',
614
+ sortOrder: input.sortOrder ?? 0,
615
+ active: input.active !== false,
616
616
  }),
617
617
  })
618
618
  return res.error ? { error: res.error } : {}
@@ -622,12 +622,11 @@ export async function updatePageTag(
622
622
  tagDocId: string,
623
623
  input: Partial<PageTagInput>,
624
624
  ): Promise<{ error?: string }> {
625
- const data: Record<string, unknown> = {}
626
- if (input.color !== undefined) data.color = input.color
627
- if (input.description !== undefined) data.description = input.description
628
- if (input.sortOrder !== undefined) data.sortOrder = input.sortOrder
629
- if (input.active !== undefined) data.active = input.active
630
- const body: Record<string, unknown> = { data }
625
+ const body: Record<string, unknown> = {}
626
+ if (input.color !== undefined) body.color = input.color
627
+ if (input.description !== undefined) body.description = input.description
628
+ if (input.sortOrder !== undefined) body.sortOrder = input.sortOrder
629
+ if (input.active !== undefined) body.active = input.active
631
630
  if (input.name !== undefined) body.title = input.name
632
631
  const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
633
632
  method: 'PUT',
@@ -677,7 +676,7 @@ export async function mergePageTags(source: PageTag, target: PageTag): Promise<{
677
676
  const primaryTag = p.primaryTagId === source.slug ? target.slug : p.primaryTagId
678
677
  const put = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
679
678
  method: 'PUT',
680
- body: JSON.stringify({ data: { tags, primaryTag } }),
679
+ body: JSON.stringify({ tags, primaryTag }),
681
680
  })
682
681
  if (put.error) return { error: put.error }
683
682
  }
@@ -202,6 +202,22 @@ function dataStr(data: Record<string, unknown>, key: string): string {
202
202
  return typeof v === 'string' ? v : ''
203
203
  }
204
204
 
205
+ /**
206
+ * Media fields are stored denormalized as `{ id, url, alt, … }` after headless
207
+ * writes / admin picker. The editor state is a string (id or URL). Never coerce
208
+ * an object to `""` — that wiped featured images on the next save (#671).
209
+ */
210
+ export function parseFeaturedImage(raw: unknown): string {
211
+ if (typeof raw === 'string') return raw.trim()
212
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
213
+ const obj = raw as Record<string, unknown>
214
+ if (typeof obj.id === 'string' && obj.id.trim()) return obj.id.trim()
215
+ const url = obj.url ?? obj.publicUrl ?? obj.src
216
+ if (typeof url === 'string' && url.trim()) return url.trim()
217
+ }
218
+ return ''
219
+ }
220
+
205
221
  /**
206
222
  * Read stored tags into plain strings. The `tagsField` preset stores
207
223
  * `[{ tag: 'AI' }]` rows; older/hand-rolled configs may store `['AI']`
@@ -233,7 +249,7 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
233
249
  title: dataTitle || (doc.title ?? ''),
234
250
  slug: doc.slug ?? dataStr(data, 'slug'),
235
251
  excerpt: dataStr(data, 'excerpt'),
236
- featuredImage: dataStr(data, 'featuredImage'),
252
+ featuredImage: parseFeaturedImage(data.featuredImage),
237
253
  body: dataStr(data, 'body'),
238
254
  category: dataStr(data, 'category'),
239
255
  tags: parseTags(data.tags),
@@ -31,6 +31,7 @@ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
31
31
  '/forms/:id/edit',
32
32
  '/forms/:id/submissions',
33
33
  '/forms/entries',
34
+ '/forms/deliveries',
34
35
  '/forms/new',
35
36
  '/media',
36
37
  '/page-builder/:id',
@@ -109,7 +109,7 @@ export function DocumentEdit({
109
109
  const SEO_FIELDS: (keyof SEOData)[] = [
110
110
  'metaTitle',
111
111
  'metaDescription',
112
- 'focusKeyphrase',
112
+ 'focusKeyword',
113
113
  'keyTakeaway',
114
114
  'canonical',
115
115
  'robotsPolicy',
@@ -148,6 +148,10 @@ export function DocumentEdit({
148
148
  ;(loadedSeo as any)[key] = docData[key]
149
149
  }
150
150
  }
151
+ // Legacy docs may only have `focusKeyphrase` — promote on load.
152
+ if (!loadedSeo.focusKeyword && typeof docData.focusKeyphrase === 'string') {
153
+ loadedSeo.focusKeyword = docData.focusKeyphrase
154
+ }
151
155
  setSeoData(loadedSeo)
152
156
  setInitialSeoData(loadedSeo)
153
157
 
@@ -0,0 +1,214 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Failed / retrying form delivery attempts (#675).
5
+ * Operators can see silent notify/webhook failures and retry without
6
+ * re-submitting the lead.
7
+ */
8
+ import { useCallback, useEffect, useState } from 'react'
9
+ import { AlertTriangle, RefreshCw, RotateCcw } from 'lucide-react'
10
+ import { toast } from 'sonner'
11
+ import {
12
+ fetchFormDeliveries,
13
+ retryFormDelivery,
14
+ type FormDeliveryAttempt,
15
+ } from '../lib/forms-service.js'
16
+ import {
17
+ FormsEmptyState,
18
+ FormsErrorState,
19
+ FormsLoading,
20
+ FormsPagination,
21
+ } from '../components/forms/primitives.js'
22
+
23
+ export interface FormDeliveriesProps {
24
+ onNavigate?: (path: string) => void
25
+ }
26
+
27
+ const PAGE_SIZE = 25
28
+
29
+ function channelLabel(channel: string): string {
30
+ switch (channel) {
31
+ case 'email_notify':
32
+ return 'Admin email'
33
+ case 'email_autoresponder':
34
+ return 'Autoresponder'
35
+ case 'form_webhook':
36
+ return 'Webhook'
37
+ default:
38
+ return channel
39
+ }
40
+ }
41
+
42
+ function statusClass(status: string): string {
43
+ switch (status) {
44
+ case 'failed':
45
+ return 'bg-destructive/10 text-destructive'
46
+ case 'retrying':
47
+ return 'bg-accent text-accent-foreground'
48
+ case 'success':
49
+ return 'bg-primary/10 text-primary'
50
+ default:
51
+ return 'bg-muted text-muted-foreground'
52
+ }
53
+ }
54
+
55
+ export function FormDeliveries({ onNavigate }: FormDeliveriesProps) {
56
+ const [docs, setDocs] = useState<FormDeliveryAttempt[]>([])
57
+ const [total, setTotal] = useState(0)
58
+ const [page, setPage] = useState(1)
59
+ const [loading, setLoading] = useState(true)
60
+ const [error, setError] = useState<string | null>(null)
61
+ const [retryingId, setRetryingId] = useState<string | null>(null)
62
+
63
+ const load = useCallback(async () => {
64
+ setLoading(true)
65
+ setError(null)
66
+ try {
67
+ const result = await fetchFormDeliveries({
68
+ status: 'failed,retrying',
69
+ page,
70
+ pageSize: PAGE_SIZE,
71
+ })
72
+ setDocs(result.docs)
73
+ setTotal(result.total)
74
+ } catch (err) {
75
+ setError(err instanceof Error ? err.message : 'Failed to load deliveries')
76
+ } finally {
77
+ setLoading(false)
78
+ }
79
+ }, [page])
80
+
81
+ useEffect(() => {
82
+ void load()
83
+ }, [load])
84
+
85
+ async function handleRetry(id: string) {
86
+ setRetryingId(id)
87
+ const res = await retryFormDelivery(id)
88
+ setRetryingId(null)
89
+ if (res.error) {
90
+ toast.error(res.error)
91
+ return
92
+ }
93
+ if (res.data?.status === 'success') {
94
+ toast.success('Delivery succeeded')
95
+ } else if (res.data?.status === 'retrying') {
96
+ toast.message('Delivery still failing — scheduled for another retry')
97
+ } else {
98
+ toast.error(res.data?.error ?? 'Delivery failed again')
99
+ }
100
+ void load()
101
+ }
102
+
103
+ return (
104
+ <div className="flex h-full flex-col gap-4 p-6">
105
+ <div className="flex items-start justify-between gap-4">
106
+ <div>
107
+ <h1 className="text-foreground text-2xl font-medium">Deliveries</h1>
108
+ <p className="text-muted-foreground text-sm">
109
+ Failed or retrying notification and webhook deliveries. Leads stay saved — retry here
110
+ without re-submitting the form.
111
+ </p>
112
+ </div>
113
+ <button
114
+ type="button"
115
+ onClick={() => void load()}
116
+ className="border-border bg-card text-foreground hover:bg-muted inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-medium"
117
+ aria-label="Refresh deliveries"
118
+ >
119
+ <RefreshCw size={16} />
120
+ Refresh
121
+ </button>
122
+ </div>
123
+
124
+ {loading ? (
125
+ <FormsLoading />
126
+ ) : error ? (
127
+ <FormsErrorState message={error} onRetry={() => void load()} />
128
+ ) : docs.length === 0 ? (
129
+ <FormsEmptyState
130
+ icon={<AlertTriangle size={24} />}
131
+ title="No failed deliveries"
132
+ description="When a notify email or webhook fails after a submission is saved, it will appear here."
133
+ />
134
+ ) : (
135
+ <>
136
+ <div className="border-border overflow-hidden rounded-lg border">
137
+ <table className="w-full text-left text-sm" aria-label="Failed form deliveries">
138
+ <thead className="bg-muted/50 border-border border-b">
139
+ <tr>
140
+ <th className="text-muted-foreground px-4 py-3 font-medium">Form</th>
141
+ <th className="text-muted-foreground px-4 py-3 font-medium">Entry</th>
142
+ <th className="text-muted-foreground px-4 py-3 font-medium">Channel</th>
143
+ <th className="text-muted-foreground px-4 py-3 font-medium">Destination</th>
144
+ <th className="text-muted-foreground px-4 py-3 font-medium">Status</th>
145
+ <th className="text-muted-foreground px-4 py-3 font-medium">Error</th>
146
+ <th className="text-muted-foreground px-4 py-3 font-medium">Attempts</th>
147
+ <th className="text-muted-foreground px-4 py-3 font-medium">
148
+ <span className="sr-only">Actions</span>
149
+ </th>
150
+ </tr>
151
+ </thead>
152
+ <tbody>
153
+ {docs.map((row) => (
154
+ <tr key={row.id} className="border-border border-b last:border-b-0">
155
+ <td className="text-foreground px-4 py-3">
156
+ <button
157
+ type="button"
158
+ className="hover:text-primary font-medium"
159
+ onClick={() => onNavigate?.(`/forms/${row.formId}/submissions`)}
160
+ >
161
+ {row.formName ?? row.formId}
162
+ </button>
163
+ </td>
164
+ <td className="text-muted-foreground px-4 py-3">
165
+ {row.entryNumber != null
166
+ ? `#${String(row.entryNumber).padStart(5, '0')}`
167
+ : '—'}
168
+ </td>
169
+ <td className="text-foreground px-4 py-3">{channelLabel(row.channel)}</td>
170
+ <td
171
+ className="text-muted-foreground max-w-[220px] truncate px-4 py-3"
172
+ title={row.destination}
173
+ >
174
+ {row.destination}
175
+ </td>
176
+ <td className="px-4 py-3">
177
+ <span
178
+ className={`inline-flex rounded-md px-2 py-0.5 text-xs font-medium ${statusClass(row.status)}`}
179
+ >
180
+ {row.status}
181
+ </span>
182
+ </td>
183
+ <td
184
+ className="text-muted-foreground max-w-[240px] truncate px-4 py-3"
185
+ title={row.error ?? row.skippedReason ?? undefined}
186
+ >
187
+ {row.error ?? row.skippedReason ?? '—'}
188
+ </td>
189
+ <td className="text-muted-foreground px-4 py-3">
190
+ {row.attempts}/{row.maxAttempts}
191
+ </td>
192
+ <td className="px-4 py-3">
193
+ <button
194
+ type="button"
195
+ disabled={retryingId === row.id}
196
+ onClick={() => void handleRetry(row.id)}
197
+ className="border-border bg-card text-foreground hover:bg-muted inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-sm font-medium disabled:opacity-50"
198
+ aria-label={`Retry delivery to ${row.destination}`}
199
+ >
200
+ <RotateCcw size={14} />
201
+ Retry
202
+ </button>
203
+ </td>
204
+ </tr>
205
+ ))}
206
+ </tbody>
207
+ </table>
208
+ </div>
209
+ <FormsPagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
210
+ </>
211
+ )}
212
+ </div>
213
+ )
214
+ }