@agent-native/dispatch 0.17.3 → 0.18.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/dist/actions/provider-api-register.d.ts +8 -8
- package/dist/client/transactional-emails.d.ts +57 -0
- package/dist/client/transactional-emails.d.ts.map +1 -0
- package/dist/client/transactional-emails.js +116 -0
- package/dist/client/transactional-emails.js.map +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +8 -1
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/transactional-email-activity.d.ts +24 -0
- package/dist/components/transactional-email-activity.d.ts.map +1 -0
- package/dist/components/transactional-email-activity.js +31 -0
- package/dist/components/transactional-email-activity.js.map +1 -0
- package/dist/components/transactional-email-metrics.d.ts +40 -0
- package/dist/components/transactional-email-metrics.d.ts.map +1 -0
- package/dist/components/transactional-email-metrics.js +44 -0
- package/dist/components/transactional-email-metrics.js.map +1 -0
- package/dist/components/transactional-email-preview.d.ts +7 -0
- package/dist/components/transactional-email-preview.d.ts.map +1 -0
- package/dist/components/transactional-email-preview.js +41 -0
- package/dist/components/transactional-email-preview.js.map +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/transactional-email.$appId.$id.d.ts +5 -0
- package/dist/routes/pages/transactional-email.$appId.$id.d.ts.map +1 -0
- package/dist/routes/pages/transactional-email.$appId.$id.js +144 -0
- package/dist/routes/pages/transactional-email.$appId.$id.js.map +1 -0
- package/dist/routes/pages/transactional-email.d.ts +5 -0
- package/dist/routes/pages/transactional-email.d.ts.map +1 -0
- package/dist/routes/pages/transactional-email.js +139 -0
- package/dist/routes/pages/transactional-email.js.map +1 -0
- package/package.json +2 -2
- package/src/client/transactional-emails.spec.ts +91 -0
- package/src/client/transactional-emails.ts +201 -0
- package/src/components/layout/Layout.tsx +8 -0
- package/src/components/transactional-email-activity.tsx +102 -0
- package/src/components/transactional-email-metrics.tsx +127 -0
- package/src/components/transactional-email-preview.tsx +88 -0
- package/src/routes/index.ts +5 -0
- package/src/routes/pages/transactional-email.$appId.$id.tsx +362 -0
- package/src/routes/pages/transactional-email.tsx +571 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
|
|
2
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
3
|
+
import {
|
|
4
|
+
IconAlertTriangle,
|
|
5
|
+
IconChevronRight,
|
|
6
|
+
IconEye,
|
|
7
|
+
IconInfoCircle,
|
|
8
|
+
IconList,
|
|
9
|
+
IconMail,
|
|
10
|
+
} from "@tabler/icons-react";
|
|
11
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
12
|
+
import { useMemo, useState } from "react";
|
|
13
|
+
import { Link } from "react-router";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
aggregateSharedEmails,
|
|
17
|
+
callAppAction,
|
|
18
|
+
fetchAppEmailCatalog,
|
|
19
|
+
type AppEmailCatalog,
|
|
20
|
+
type AppTransactionalEmail,
|
|
21
|
+
type LocalTransactionalEmailCatalog,
|
|
22
|
+
} from "../../client/transactional-emails";
|
|
23
|
+
import { ActionQueryError } from "../../components/action-query-error";
|
|
24
|
+
import { DispatchShell } from "../../components/dispatch-shell";
|
|
25
|
+
import {
|
|
26
|
+
ActivityTable,
|
|
27
|
+
type EmailActivityEntry,
|
|
28
|
+
} from "../../components/transactional-email-activity";
|
|
29
|
+
import {
|
|
30
|
+
OpenRateCell,
|
|
31
|
+
SendsCell,
|
|
32
|
+
LastSentCell,
|
|
33
|
+
type EmailEngagement,
|
|
34
|
+
type ProviderMetricsResult,
|
|
35
|
+
} from "../../components/transactional-email-metrics";
|
|
36
|
+
import { EmailPreviewPane } from "../../components/transactional-email-preview";
|
|
37
|
+
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
|
|
38
|
+
import { Button } from "../../components/ui/button";
|
|
39
|
+
import {
|
|
40
|
+
Collapsible,
|
|
41
|
+
CollapsibleContent,
|
|
42
|
+
CollapsibleTrigger,
|
|
43
|
+
} from "../../components/ui/collapsible";
|
|
44
|
+
import {
|
|
45
|
+
Dialog,
|
|
46
|
+
DialogContent,
|
|
47
|
+
DialogDescription,
|
|
48
|
+
DialogHeader,
|
|
49
|
+
DialogTitle,
|
|
50
|
+
} from "../../components/ui/dialog";
|
|
51
|
+
import { Skeleton } from "../../components/ui/skeleton";
|
|
52
|
+
import {
|
|
53
|
+
Table,
|
|
54
|
+
TableBody,
|
|
55
|
+
TableCell,
|
|
56
|
+
TableHead,
|
|
57
|
+
TableHeader,
|
|
58
|
+
TableRow,
|
|
59
|
+
} from "../../components/ui/table";
|
|
60
|
+
|
|
61
|
+
export function meta() {
|
|
62
|
+
return [{ title: "Transactional email — Dispatch" }];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const WINDOW_DAYS = 30;
|
|
66
|
+
const ACTIVITY_LIMIT = 50;
|
|
67
|
+
|
|
68
|
+
interface WorkspaceAppRef {
|
|
69
|
+
id: string;
|
|
70
|
+
name: string;
|
|
71
|
+
path: string;
|
|
72
|
+
status?: "ready" | "pending";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shape of the local `list-transactional-emails` action response. */
|
|
76
|
+
function PreviewDialog({
|
|
77
|
+
email,
|
|
78
|
+
appId,
|
|
79
|
+
appPath,
|
|
80
|
+
open,
|
|
81
|
+
onOpenChange,
|
|
82
|
+
}: {
|
|
83
|
+
email: AppTransactionalEmail;
|
|
84
|
+
appId: string;
|
|
85
|
+
appPath: string;
|
|
86
|
+
open: boolean;
|
|
87
|
+
onOpenChange: (next: boolean) => void;
|
|
88
|
+
}) {
|
|
89
|
+
const t = useT();
|
|
90
|
+
return (
|
|
91
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
92
|
+
<DialogContent className="max-w-3xl">
|
|
93
|
+
<DialogHeader>
|
|
94
|
+
<DialogTitle>{email.name}</DialogTitle>
|
|
95
|
+
<DialogDescription>
|
|
96
|
+
{t("dispatch.transactionalEmail.previewDescription")}
|
|
97
|
+
</DialogDescription>
|
|
98
|
+
</DialogHeader>
|
|
99
|
+
{/* Dialog content only mounts while open, so this fetches on open and
|
|
100
|
+
drops the request when closed rather than needing an enabled flag. */}
|
|
101
|
+
<EmailPreviewPane
|
|
102
|
+
appId={appId}
|
|
103
|
+
appPath={appPath}
|
|
104
|
+
id={email.id}
|
|
105
|
+
name={email.name}
|
|
106
|
+
/>
|
|
107
|
+
</DialogContent>
|
|
108
|
+
</Dialog>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ActivityDialog({
|
|
113
|
+
email,
|
|
114
|
+
appPath,
|
|
115
|
+
unavailableReason,
|
|
116
|
+
open,
|
|
117
|
+
onOpenChange,
|
|
118
|
+
}: {
|
|
119
|
+
email: AppTransactionalEmail;
|
|
120
|
+
appPath: string;
|
|
121
|
+
unavailableReason: string | null;
|
|
122
|
+
open: boolean;
|
|
123
|
+
onOpenChange: (next: boolean) => void;
|
|
124
|
+
}) {
|
|
125
|
+
const t = useT();
|
|
126
|
+
const activityQuery = useQuery({
|
|
127
|
+
queryKey: ["list-email-activity", appPath, email.id, ACTIVITY_LIMIT],
|
|
128
|
+
queryFn: () =>
|
|
129
|
+
callAppAction<ProviderMetricsResult<EmailActivityEntry[]>>(
|
|
130
|
+
appPath,
|
|
131
|
+
"list-email-activity",
|
|
132
|
+
{ templateId: email.id, limit: ACTIVITY_LIMIT },
|
|
133
|
+
"GET",
|
|
134
|
+
),
|
|
135
|
+
enabled: open && !unavailableReason,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
140
|
+
<DialogContent className="max-w-4xl">
|
|
141
|
+
<DialogHeader>
|
|
142
|
+
<DialogTitle>
|
|
143
|
+
{t("dispatch.transactionalEmail.activityTitle", {
|
|
144
|
+
name: email.name,
|
|
145
|
+
})}
|
|
146
|
+
</DialogTitle>
|
|
147
|
+
<DialogDescription>
|
|
148
|
+
{t("dispatch.transactionalEmail.retentionNote")}
|
|
149
|
+
</DialogDescription>
|
|
150
|
+
</DialogHeader>
|
|
151
|
+
<ActivityTable
|
|
152
|
+
result={
|
|
153
|
+
unavailableReason
|
|
154
|
+
? { available: false, reason: unavailableReason }
|
|
155
|
+
: activityQuery.data
|
|
156
|
+
}
|
|
157
|
+
isLoading={activityQuery.isLoading}
|
|
158
|
+
isError={activityQuery.isError}
|
|
159
|
+
error={activityQuery.error}
|
|
160
|
+
onRetry={() => void activityQuery.refetch()}
|
|
161
|
+
/>
|
|
162
|
+
</DialogContent>
|
|
163
|
+
</Dialog>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function EmailRow({
|
|
168
|
+
email,
|
|
169
|
+
appId,
|
|
170
|
+
appPath,
|
|
171
|
+
engagement,
|
|
172
|
+
engagementUnavailable,
|
|
173
|
+
engagementLoading,
|
|
174
|
+
}: {
|
|
175
|
+
email: AppTransactionalEmail;
|
|
176
|
+
appId: string;
|
|
177
|
+
appPath: string;
|
|
178
|
+
engagement: EmailEngagement | undefined;
|
|
179
|
+
engagementUnavailable: string | null;
|
|
180
|
+
engagementLoading: boolean;
|
|
181
|
+
}) {
|
|
182
|
+
const t = useT();
|
|
183
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
184
|
+
const [activityOpen, setActivityOpen] = useState(false);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<TableRow>
|
|
188
|
+
<TableCell className="align-top">
|
|
189
|
+
<Link
|
|
190
|
+
to={`/transactional-email/${appId}/${email.id}`}
|
|
191
|
+
className="text-sm font-medium text-foreground hover:underline"
|
|
192
|
+
>
|
|
193
|
+
{email.name}
|
|
194
|
+
</Link>
|
|
195
|
+
<div className="font-mono text-xs text-muted-foreground">
|
|
196
|
+
{email.id}
|
|
197
|
+
</div>
|
|
198
|
+
</TableCell>
|
|
199
|
+
<TableCell className="max-w-64 align-top text-xs text-muted-foreground">
|
|
200
|
+
{email.trigger}
|
|
201
|
+
</TableCell>
|
|
202
|
+
<TableCell className="max-w-56 align-top text-xs text-muted-foreground">
|
|
203
|
+
{email.recipientLabel}
|
|
204
|
+
</TableCell>
|
|
205
|
+
<TableCell className="max-w-56 align-top text-xs text-muted-foreground">
|
|
206
|
+
{email.senderLabel}
|
|
207
|
+
</TableCell>
|
|
208
|
+
<TableCell className="align-top text-sm">
|
|
209
|
+
<SendsCell sent={email.sent} failed={email.failed} />
|
|
210
|
+
</TableCell>
|
|
211
|
+
<TableCell className="align-top text-sm">
|
|
212
|
+
<OpenRateCell
|
|
213
|
+
engagement={engagement}
|
|
214
|
+
unavailableReason={engagementUnavailable}
|
|
215
|
+
loading={engagementLoading}
|
|
216
|
+
/>
|
|
217
|
+
</TableCell>
|
|
218
|
+
<TableCell className="align-top text-xs text-muted-foreground">
|
|
219
|
+
<LastSentCell lastSentAt={email.lastSentAt} sent={email.sent} />
|
|
220
|
+
</TableCell>
|
|
221
|
+
<TableCell className="align-top">
|
|
222
|
+
<div className="flex justify-end gap-2">
|
|
223
|
+
<Button
|
|
224
|
+
variant="outline"
|
|
225
|
+
size="sm"
|
|
226
|
+
onClick={() => setPreviewOpen(true)}
|
|
227
|
+
>
|
|
228
|
+
<IconEye className="size-4" />
|
|
229
|
+
{t("dispatch.transactionalEmail.preview")}
|
|
230
|
+
</Button>
|
|
231
|
+
<Button
|
|
232
|
+
variant="ghost"
|
|
233
|
+
size="sm"
|
|
234
|
+
onClick={() => setActivityOpen(true)}
|
|
235
|
+
>
|
|
236
|
+
<IconList className="size-4" />
|
|
237
|
+
{t("dispatch.transactionalEmail.activityLink")}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
<PreviewDialog
|
|
241
|
+
email={email}
|
|
242
|
+
appId={appId}
|
|
243
|
+
appPath={appPath}
|
|
244
|
+
open={previewOpen}
|
|
245
|
+
onOpenChange={setPreviewOpen}
|
|
246
|
+
/>
|
|
247
|
+
<ActivityDialog
|
|
248
|
+
email={email}
|
|
249
|
+
appPath={appPath}
|
|
250
|
+
unavailableReason={engagementUnavailable}
|
|
251
|
+
open={activityOpen}
|
|
252
|
+
onOpenChange={setActivityOpen}
|
|
253
|
+
/>
|
|
254
|
+
</TableCell>
|
|
255
|
+
</TableRow>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function AppEmailTable({
|
|
260
|
+
catalog,
|
|
261
|
+
engagementByTemplate,
|
|
262
|
+
engagementUnavailable,
|
|
263
|
+
engagementLoading,
|
|
264
|
+
}: {
|
|
265
|
+
catalog: AppEmailCatalog;
|
|
266
|
+
engagementByTemplate: Map<string, EmailEngagement>;
|
|
267
|
+
engagementUnavailable: string | null;
|
|
268
|
+
engagementLoading: boolean;
|
|
269
|
+
}) {
|
|
270
|
+
const t = useT();
|
|
271
|
+
|
|
272
|
+
if (catalog.error) {
|
|
273
|
+
return (
|
|
274
|
+
<Alert variant="destructive">
|
|
275
|
+
<IconAlertTriangle className="size-4" />
|
|
276
|
+
<AlertTitle>
|
|
277
|
+
{t("dispatch.transactionalEmail.catalogUnreadable")}
|
|
278
|
+
</AlertTitle>
|
|
279
|
+
<AlertDescription>{catalog.error}</AlertDescription>
|
|
280
|
+
</Alert>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<>
|
|
286
|
+
{catalog.statsError ? (
|
|
287
|
+
<Alert className="mb-4">
|
|
288
|
+
<IconInfoCircle className="size-4" />
|
|
289
|
+
<AlertTitle>
|
|
290
|
+
{t("dispatch.transactionalEmail.countsUnreadable")}
|
|
291
|
+
</AlertTitle>
|
|
292
|
+
<AlertDescription>{catalog.statsError}</AlertDescription>
|
|
293
|
+
</Alert>
|
|
294
|
+
) : null}
|
|
295
|
+
{catalog.emails.length === 0 ? (
|
|
296
|
+
<div className="rounded-xl border border-dashed px-4 py-6 text-sm text-muted-foreground">
|
|
297
|
+
{t("dispatch.transactionalEmail.appSendsNoEmail")}
|
|
298
|
+
</div>
|
|
299
|
+
) : (
|
|
300
|
+
<Table>
|
|
301
|
+
<TableHeader>
|
|
302
|
+
<TableRow>
|
|
303
|
+
<TableHead>{t("dispatch.transactionalEmail.email")}</TableHead>
|
|
304
|
+
<TableHead>{t("dispatch.transactionalEmail.trigger")}</TableHead>
|
|
305
|
+
<TableHead>
|
|
306
|
+
{t("dispatch.transactionalEmail.recipient")}
|
|
307
|
+
</TableHead>
|
|
308
|
+
<TableHead>{t("dispatch.transactionalEmail.sender")}</TableHead>
|
|
309
|
+
<TableHead>{t("dispatch.transactionalEmail.sends")}</TableHead>
|
|
310
|
+
<TableHead>{t("dispatch.transactionalEmail.openRate")}</TableHead>
|
|
311
|
+
<TableHead>{t("dispatch.transactionalEmail.lastSent")}</TableHead>
|
|
312
|
+
<TableHead />
|
|
313
|
+
</TableRow>
|
|
314
|
+
</TableHeader>
|
|
315
|
+
<TableBody>
|
|
316
|
+
{catalog.emails.map((email) => (
|
|
317
|
+
<EmailRow
|
|
318
|
+
key={email.id}
|
|
319
|
+
email={email}
|
|
320
|
+
appId={catalog.appId}
|
|
321
|
+
appPath={catalog.appPath}
|
|
322
|
+
engagement={engagementByTemplate.get(email.id)}
|
|
323
|
+
engagementUnavailable={engagementUnavailable}
|
|
324
|
+
engagementLoading={engagementLoading}
|
|
325
|
+
/>
|
|
326
|
+
))}
|
|
327
|
+
</TableBody>
|
|
328
|
+
</Table>
|
|
329
|
+
)}
|
|
330
|
+
</>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function AppEmailCard({
|
|
335
|
+
name,
|
|
336
|
+
path,
|
|
337
|
+
open,
|
|
338
|
+
onOpenChange,
|
|
339
|
+
catalog,
|
|
340
|
+
isLoading,
|
|
341
|
+
isError,
|
|
342
|
+
error,
|
|
343
|
+
onRetry,
|
|
344
|
+
engagementByTemplate,
|
|
345
|
+
engagementUnavailable,
|
|
346
|
+
engagementLoading,
|
|
347
|
+
}: {
|
|
348
|
+
name: string;
|
|
349
|
+
path: string;
|
|
350
|
+
open: boolean;
|
|
351
|
+
onOpenChange: (next: boolean) => void;
|
|
352
|
+
catalog: AppEmailCatalog | undefined;
|
|
353
|
+
isLoading: boolean;
|
|
354
|
+
isError: boolean;
|
|
355
|
+
error: unknown;
|
|
356
|
+
onRetry: () => void;
|
|
357
|
+
engagementByTemplate: Map<string, EmailEngagement>;
|
|
358
|
+
engagementUnavailable: string | null;
|
|
359
|
+
engagementLoading: boolean;
|
|
360
|
+
}) {
|
|
361
|
+
return (
|
|
362
|
+
<Collapsible
|
|
363
|
+
open={open}
|
|
364
|
+
onOpenChange={onOpenChange}
|
|
365
|
+
className="rounded-2xl bg-card"
|
|
366
|
+
>
|
|
367
|
+
<CollapsibleTrigger className="group flex w-full cursor-pointer items-center justify-between gap-3 p-5 text-left hover:bg-muted/20">
|
|
368
|
+
<span className="flex items-center gap-2">
|
|
369
|
+
<IconChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
|
370
|
+
<span className="text-sm font-medium text-foreground">{name}</span>
|
|
371
|
+
</span>
|
|
372
|
+
<span className="font-mono text-xs text-muted-foreground">{path}</span>
|
|
373
|
+
</CollapsibleTrigger>
|
|
374
|
+
<CollapsibleContent className="border-t px-5 pb-5 pt-4">
|
|
375
|
+
{isError ? (
|
|
376
|
+
<ActionQueryError error={error} onRetry={onRetry} />
|
|
377
|
+
) : isLoading || !catalog ? (
|
|
378
|
+
<Skeleton className="h-24 w-full" />
|
|
379
|
+
) : (
|
|
380
|
+
<AppEmailTable
|
|
381
|
+
catalog={catalog}
|
|
382
|
+
engagementByTemplate={engagementByTemplate}
|
|
383
|
+
engagementUnavailable={engagementUnavailable}
|
|
384
|
+
engagementLoading={engagementLoading}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
</CollapsibleContent>
|
|
388
|
+
</Collapsible>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export default function TransactionalEmailRoute() {
|
|
393
|
+
const t = useT();
|
|
394
|
+
const appsQuery = useActionQuery<WorkspaceAppRef[]>("list-workspace-apps", {
|
|
395
|
+
includeAgentCards: false,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const apps = useMemo(
|
|
399
|
+
() =>
|
|
400
|
+
(appsQuery.data ?? [])
|
|
401
|
+
.filter((app) => app.status !== "pending")
|
|
402
|
+
.map((app) => ({ id: app.id, name: app.name, path: app.path })),
|
|
403
|
+
[appsQuery.data],
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
407
|
+
const [sharedOpen, setSharedOpen] = useState(true);
|
|
408
|
+
|
|
409
|
+
const sharedQuery = useActionQuery<LocalTransactionalEmailCatalog>(
|
|
410
|
+
"list-transactional-emails",
|
|
411
|
+
{ windowDays: WINDOW_DAYS },
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const catalogQueries = useQueries({
|
|
415
|
+
queries: apps.map((app) => ({
|
|
416
|
+
queryKey: ["transactional-email-catalog", app.id, WINDOW_DAYS],
|
|
417
|
+
queryFn: () => fetchAppEmailCatalog(app, WINDOW_DAYS),
|
|
418
|
+
enabled: sharedOpen || expanded[app.id] === true,
|
|
419
|
+
staleTime: Infinity,
|
|
420
|
+
})),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const sharedCatalog = useMemo<AppEmailCatalog | undefined>(() => {
|
|
424
|
+
if (!sharedQuery.data) return undefined;
|
|
425
|
+
const aggregate = aggregateSharedEmails(
|
|
426
|
+
sharedQuery.data,
|
|
427
|
+
catalogQueries
|
|
428
|
+
.map((query) => query.data)
|
|
429
|
+
.filter((catalog): catalog is AppEmailCatalog => Boolean(catalog)),
|
|
430
|
+
);
|
|
431
|
+
return {
|
|
432
|
+
appId: "core",
|
|
433
|
+
appName: t("dispatch.transactionalEmail.sharedTitle"),
|
|
434
|
+
appPath: t("dispatch.transactionalEmail.sharedSubtitle"),
|
|
435
|
+
emails: aggregate.emails,
|
|
436
|
+
error: null,
|
|
437
|
+
statsError: aggregate.statsError,
|
|
438
|
+
};
|
|
439
|
+
}, [sharedQuery.data, catalogQueries, t]);
|
|
440
|
+
|
|
441
|
+
const engagementQueries = useQueries({
|
|
442
|
+
queries: apps.map((app, index) => {
|
|
443
|
+
const catalog = catalogQueries[index]?.data;
|
|
444
|
+
const templateIds =
|
|
445
|
+
catalog && !catalog.error
|
|
446
|
+
? catalog.emails.map((email) => email.id)
|
|
447
|
+
: [];
|
|
448
|
+
return {
|
|
449
|
+
queryKey: [
|
|
450
|
+
"list-email-engagement",
|
|
451
|
+
app.id,
|
|
452
|
+
templateIds.join(","),
|
|
453
|
+
WINDOW_DAYS,
|
|
454
|
+
],
|
|
455
|
+
queryFn: () =>
|
|
456
|
+
callAppAction<ProviderMetricsResult<EmailEngagement[]>>(
|
|
457
|
+
app.path,
|
|
458
|
+
"list-email-engagement",
|
|
459
|
+
{ templateIds, windowDays: WINDOW_DAYS },
|
|
460
|
+
"POST",
|
|
461
|
+
),
|
|
462
|
+
enabled: expanded[app.id] === true && templateIds.length > 0,
|
|
463
|
+
};
|
|
464
|
+
}),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const sharedLoading =
|
|
468
|
+
sharedQuery.isLoading ||
|
|
469
|
+
(sharedOpen && catalogQueries.some((query) => query.isLoading));
|
|
470
|
+
const sharedError = catalogQueries.find((query) => query.isError)?.error;
|
|
471
|
+
const sharedProviderReason = t(
|
|
472
|
+
"dispatch.transactionalEmail.sharedProviderMetricsUnavailable",
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
<DispatchShell
|
|
477
|
+
title={t("dispatch.transactionalEmail.title")}
|
|
478
|
+
description={t("dispatch.transactionalEmail.description")}
|
|
479
|
+
>
|
|
480
|
+
<div className="flex flex-col gap-4">
|
|
481
|
+
<Alert>
|
|
482
|
+
<IconInfoCircle className="size-4" />
|
|
483
|
+
<AlertTitle>
|
|
484
|
+
{t("dispatch.transactionalEmail.retentionTitle")}
|
|
485
|
+
</AlertTitle>
|
|
486
|
+
<AlertDescription>
|
|
487
|
+
{t("dispatch.transactionalEmail.retentionNote")}
|
|
488
|
+
</AlertDescription>
|
|
489
|
+
</Alert>
|
|
490
|
+
|
|
491
|
+
{appsQuery.isError ? (
|
|
492
|
+
<ActionQueryError
|
|
493
|
+
error={appsQuery.error}
|
|
494
|
+
onRetry={() => void appsQuery.refetch()}
|
|
495
|
+
/>
|
|
496
|
+
) : null}
|
|
497
|
+
|
|
498
|
+
{appsQuery.isLoading ? (
|
|
499
|
+
<div className="space-y-3">
|
|
500
|
+
<Skeleton className="h-16 w-full rounded-2xl" />
|
|
501
|
+
<Skeleton className="h-16 w-full rounded-2xl" />
|
|
502
|
+
</div>
|
|
503
|
+
) : null}
|
|
504
|
+
|
|
505
|
+
{!appsQuery.isLoading && apps.length === 0 ? (
|
|
506
|
+
<div className="rounded-2xl border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
|
|
507
|
+
<IconMail className="mx-auto mb-2 size-5" />
|
|
508
|
+
{t("dispatch.transactionalEmail.noApps")}
|
|
509
|
+
</div>
|
|
510
|
+
) : null}
|
|
511
|
+
|
|
512
|
+
<AppEmailCard
|
|
513
|
+
name={t("dispatch.transactionalEmail.sharedTitle")}
|
|
514
|
+
path={t("dispatch.transactionalEmail.sharedSubtitle")}
|
|
515
|
+
open={sharedOpen}
|
|
516
|
+
onOpenChange={setSharedOpen}
|
|
517
|
+
catalog={sharedCatalog}
|
|
518
|
+
isLoading={sharedLoading}
|
|
519
|
+
isError={sharedQuery.isError || Boolean(sharedError)}
|
|
520
|
+
error={sharedQuery.error ?? sharedError}
|
|
521
|
+
onRetry={() => {
|
|
522
|
+
void sharedQuery.refetch();
|
|
523
|
+
for (const query of catalogQueries) void query.refetch();
|
|
524
|
+
}}
|
|
525
|
+
engagementByTemplate={new Map()}
|
|
526
|
+
engagementUnavailable={sharedProviderReason}
|
|
527
|
+
engagementLoading={false}
|
|
528
|
+
/>
|
|
529
|
+
|
|
530
|
+
{apps.map((app, index) => {
|
|
531
|
+
const query = catalogQueries[index];
|
|
532
|
+
const engagementQuery = engagementQueries[index];
|
|
533
|
+
const engagementResult = engagementQuery?.data;
|
|
534
|
+
const engagementUnavailable =
|
|
535
|
+
engagementResult && !engagementResult.available
|
|
536
|
+
? engagementResult.reason
|
|
537
|
+
: engagementQuery?.isError
|
|
538
|
+
? engagementQuery.error instanceof Error
|
|
539
|
+
? engagementQuery.error.message
|
|
540
|
+
: String(engagementQuery.error)
|
|
541
|
+
: null;
|
|
542
|
+
const engagementByTemplate = new Map<string, EmailEngagement>();
|
|
543
|
+
if (engagementResult?.available) {
|
|
544
|
+
for (const entry of engagementResult.data) {
|
|
545
|
+
engagementByTemplate.set(entry.templateId, entry);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return (
|
|
549
|
+
<AppEmailCard
|
|
550
|
+
key={app.id}
|
|
551
|
+
name={app.name}
|
|
552
|
+
path={app.path}
|
|
553
|
+
open={expanded[app.id] === true}
|
|
554
|
+
onOpenChange={(next) =>
|
|
555
|
+
setExpanded((current) => ({ ...current, [app.id]: next }))
|
|
556
|
+
}
|
|
557
|
+
catalog={query?.data}
|
|
558
|
+
isLoading={query?.isLoading ?? false}
|
|
559
|
+
isError={query?.isError ?? false}
|
|
560
|
+
error={query?.error}
|
|
561
|
+
onRetry={() => void query?.refetch()}
|
|
562
|
+
engagementByTemplate={engagementByTemplate}
|
|
563
|
+
engagementUnavailable={engagementUnavailable}
|
|
564
|
+
engagementLoading={engagementQuery?.isLoading ?? false}
|
|
565
|
+
/>
|
|
566
|
+
);
|
|
567
|
+
})}
|
|
568
|
+
</div>
|
|
569
|
+
</DispatchShell>
|
|
570
|
+
);
|
|
571
|
+
}
|