@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.
- package/CHANGELOG.md +12 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/pages-service-fetch.test.js +10 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +35 -1
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +2 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/components/SEOPanel.d.ts +8 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +7 -2
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -1
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-service.d.ts +42 -0
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +27 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -15
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +19 -1
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/router/admin-routes.d.ts.map +1 -1
- package/dist/router/admin-routes.js +1 -0
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +5 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/FormDeliveries.d.ts +5 -0
- package/dist/views/FormDeliveries.d.ts.map +1 -0
- package/dist/views/FormDeliveries.js +90 -0
- package/dist/views/FormDeliveries.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +11 -0
- package/src/__tests__/lib/post-editor-service.test.ts +46 -0
- package/src/components/Breadcrumbs.tsx +2 -0
- package/src/components/SEOPanel.tsx +15 -3
- package/src/index.ts +3 -1
- package/src/layout/Sidebar.tsx +2 -0
- package/src/lib/forms-service.ts +74 -0
- package/src/lib/pages-service.ts +14 -15
- package/src/lib/post-editor-service.ts +17 -1
- package/src/router/admin-routes.ts +1 -0
- package/src/views/DocumentEdit.tsx +5 -1
- 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
|
})
|
|
@@ -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
|
|
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
|
|
745
|
-
onChange={(v) => update({
|
|
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'
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -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,
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
|
626
|
-
if (input.color !== undefined)
|
|
627
|
-
if (input.description !== undefined)
|
|
628
|
-
if (input.sortOrder !== undefined)
|
|
629
|
-
if (input.active !== undefined)
|
|
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({
|
|
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:
|
|
252
|
+
featuredImage: parseFeaturedImage(data.featuredImage),
|
|
237
253
|
body: dataStr(data, 'body'),
|
|
238
254
|
category: dataStr(data, 'category'),
|
|
239
255
|
tags: parseTags(data.tags),
|
|
@@ -109,7 +109,7 @@ export function DocumentEdit({
|
|
|
109
109
|
const SEO_FIELDS: (keyof SEOData)[] = [
|
|
110
110
|
'metaTitle',
|
|
111
111
|
'metaDescription',
|
|
112
|
-
'
|
|
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
|
+
}
|