@actuate-media/cms-admin 0.79.1 → 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 +6 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.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/ui/Badge.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- 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/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/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 +3 -3
- package/src/AdminRoot.tsx +4 -0
- package/src/components/Breadcrumbs.tsx +2 -0
- package/src/index.ts +2 -0
- package/src/layout/Sidebar.tsx +2 -0
- package/src/lib/forms-service.ts +74 -0
- package/src/router/admin-routes.ts +1 -0
- package/src/views/FormDeliveries.tsx +214 -0
|
@@ -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
|
+
}
|