@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.
@@ -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
+ }