@agent-native/dispatch 0.17.2 → 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.
Files changed (48) hide show
  1. package/dist/actions/provider-api-catalog.d.ts +1 -1
  2. package/dist/actions/provider-api-register.d.ts +11 -11
  3. package/dist/client/transactional-emails.d.ts +57 -0
  4. package/dist/client/transactional-emails.d.ts.map +1 -0
  5. package/dist/client/transactional-emails.js +116 -0
  6. package/dist/client/transactional-emails.js.map +1 -0
  7. package/dist/components/layout/Layout.d.ts.map +1 -1
  8. package/dist/components/layout/Layout.js +8 -1
  9. package/dist/components/layout/Layout.js.map +1 -1
  10. package/dist/components/transactional-email-activity.d.ts +24 -0
  11. package/dist/components/transactional-email-activity.d.ts.map +1 -0
  12. package/dist/components/transactional-email-activity.js +31 -0
  13. package/dist/components/transactional-email-activity.js.map +1 -0
  14. package/dist/components/transactional-email-metrics.d.ts +40 -0
  15. package/dist/components/transactional-email-metrics.d.ts.map +1 -0
  16. package/dist/components/transactional-email-metrics.js +44 -0
  17. package/dist/components/transactional-email-metrics.js.map +1 -0
  18. package/dist/components/transactional-email-preview.d.ts +7 -0
  19. package/dist/components/transactional-email-preview.d.ts.map +1 -0
  20. package/dist/components/transactional-email-preview.js +41 -0
  21. package/dist/components/transactional-email-preview.js.map +1 -0
  22. package/dist/routes/index.d.ts.map +1 -1
  23. package/dist/routes/index.js +2 -0
  24. package/dist/routes/index.js.map +1 -1
  25. package/dist/routes/pages/transactional-email.$appId.$id.d.ts +5 -0
  26. package/dist/routes/pages/transactional-email.$appId.$id.d.ts.map +1 -0
  27. package/dist/routes/pages/transactional-email.$appId.$id.js +144 -0
  28. package/dist/routes/pages/transactional-email.$appId.$id.js.map +1 -0
  29. package/dist/routes/pages/transactional-email.d.ts +5 -0
  30. package/dist/routes/pages/transactional-email.d.ts.map +1 -0
  31. package/dist/routes/pages/transactional-email.js +139 -0
  32. package/dist/routes/pages/transactional-email.js.map +1 -0
  33. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  34. package/dist/server/lib/app-creation-store.js +1 -1
  35. package/dist/server/lib/app-creation-store.js.map +1 -1
  36. package/dist/server/lib/provider-api.d.ts +2 -2
  37. package/dist/server/lib/provider-api.d.ts.map +1 -1
  38. package/package.json +3 -3
  39. package/src/client/transactional-emails.spec.ts +91 -0
  40. package/src/client/transactional-emails.ts +201 -0
  41. package/src/components/layout/Layout.tsx +8 -0
  42. package/src/components/transactional-email-activity.tsx +102 -0
  43. package/src/components/transactional-email-metrics.tsx +127 -0
  44. package/src/components/transactional-email-preview.tsx +88 -0
  45. package/src/routes/index.ts +5 -0
  46. package/src/routes/pages/transactional-email.$appId.$id.tsx +362 -0
  47. package/src/routes/pages/transactional-email.tsx +571 -0
  48. package/src/server/lib/app-creation-store.ts +2 -1
@@ -0,0 +1,362 @@
1
+ import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
2
+ import { useT } from "@agent-native/core/client/i18n";
3
+ import { IconAlertTriangle, IconArrowLeft } from "@tabler/icons-react";
4
+ import { useQueries, useQuery } from "@tanstack/react-query";
5
+ import { useMemo } from "react";
6
+ import { Link, useParams } from "react-router";
7
+
8
+ import {
9
+ aggregateSharedEmails,
10
+ callAppAction,
11
+ fetchAppEmailCatalog,
12
+ type AppEmailCatalog,
13
+ type AppTransactionalEmail,
14
+ type LocalTransactionalEmailCatalog,
15
+ } from "../../client/transactional-emails";
16
+ import { ActionQueryError } from "../../components/action-query-error";
17
+ import { DispatchShell } from "../../components/dispatch-shell";
18
+ import {
19
+ ActivityTable,
20
+ type EmailActivityEntry,
21
+ } from "../../components/transactional-email-activity";
22
+ import {
23
+ OpenRateCell,
24
+ SendsCell,
25
+ LastSentCell,
26
+ type EmailEngagement,
27
+ type ProviderMetricsResult,
28
+ } from "../../components/transactional-email-metrics";
29
+ import { EmailPreviewPane } from "../../components/transactional-email-preview";
30
+ import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
31
+ import { Button } from "../../components/ui/button";
32
+ import { Skeleton } from "../../components/ui/skeleton";
33
+
34
+ export function meta() {
35
+ return [{ title: "Transactional email detail — Dispatch" }];
36
+ }
37
+
38
+ const WINDOW_DAYS = 30;
39
+ const ACTIVITY_LIMIT = 50;
40
+
41
+ interface WorkspaceAppRef {
42
+ id: string;
43
+ name: string;
44
+ path: string;
45
+ status?: "ready" | "pending";
46
+ }
47
+
48
+ /**
49
+ * Resolves the one email this page shows, plus the app name/path to display.
50
+ * The "core" appId is Dispatch's own registry, not a cross-app fetch — see
51
+ * the same split in transactional-email.tsx.
52
+ */
53
+ function useEmailDetail(appId: string, id: string) {
54
+ const isCore = appId === "core";
55
+ const t = useT();
56
+
57
+ const localQuery = useActionQuery<LocalTransactionalEmailCatalog>(
58
+ "list-transactional-emails",
59
+ { windowDays: WINDOW_DAYS },
60
+ { enabled: isCore },
61
+ );
62
+
63
+ const appsQuery = useActionQuery<WorkspaceAppRef[]>("list-workspace-apps", {
64
+ includeAgentCards: false,
65
+ });
66
+ const readyApps = useMemo(
67
+ () =>
68
+ (appsQuery.data ?? []).filter(
69
+ (candidate) => candidate.status !== "pending",
70
+ ),
71
+ [appsQuery.data],
72
+ );
73
+ const app = useMemo(
74
+ () => readyApps.find((candidate) => candidate.id === appId),
75
+ [readyApps, appId],
76
+ );
77
+ const coreCatalogQueries = useQueries({
78
+ queries: readyApps.map((candidate) => ({
79
+ queryKey: ["transactional-email-catalog", candidate.id, WINDOW_DAYS],
80
+ queryFn: () => fetchAppEmailCatalog(candidate, WINDOW_DAYS),
81
+ enabled: isCore,
82
+ staleTime: Infinity,
83
+ })),
84
+ });
85
+ const remoteQuery = useQuery({
86
+ queryKey: ["transactional-email-catalog", appId, WINDOW_DAYS],
87
+ queryFn: () => fetchAppEmailCatalog(app!, WINDOW_DAYS),
88
+ enabled: !isCore && !!app,
89
+ staleTime: Infinity,
90
+ });
91
+
92
+ if (isCore) {
93
+ const appCatalogs = coreCatalogQueries
94
+ .map((query) => query.data)
95
+ .filter((catalog): catalog is AppEmailCatalog => Boolean(catalog));
96
+ const aggregate = localQuery.data
97
+ ? aggregateSharedEmails(localQuery.data, appCatalogs)
98
+ : undefined;
99
+ const email = aggregate?.emails.find((candidate) => candidate.id === id);
100
+ const catalogError = coreCatalogQueries.find(
101
+ (query) => query.data?.error || query.data?.statsError,
102
+ )?.data;
103
+ const queryError = coreCatalogQueries.find((query) => query.isError)?.error;
104
+ const isLoading =
105
+ localQuery.isLoading ||
106
+ appsQuery.isLoading ||
107
+ coreCatalogQueries.some((query) => query.isLoading);
108
+ const isError =
109
+ localQuery.isError || appsQuery.isError || Boolean(queryError);
110
+ return {
111
+ isLoading,
112
+ isError,
113
+ error: localQuery.error ?? appsQuery.error ?? queryError,
114
+ onRetry: () => {
115
+ void localQuery.refetch();
116
+ void appsQuery.refetch();
117
+ for (const query of coreCatalogQueries) void query.refetch();
118
+ },
119
+ appName: t("dispatch.transactionalEmail.sharedTitle"),
120
+ appPath: t("dispatch.transactionalEmail.sharedSubtitle"),
121
+ email,
122
+ catalogError:
123
+ aggregate?.statsError ??
124
+ catalogError?.error ??
125
+ catalogError?.statsError ??
126
+ null,
127
+ notFound: !isLoading && !isError && !email,
128
+ };
129
+ }
130
+
131
+ const notFound =
132
+ !appsQuery.isLoading &&
133
+ !appsQuery.isError &&
134
+ (!app ||
135
+ (!remoteQuery.isLoading &&
136
+ !remoteQuery.isError &&
137
+ !remoteQuery.data?.error &&
138
+ !remoteQuery.data?.emails.some((candidate) => candidate.id === id)));
139
+
140
+ return {
141
+ isLoading: appsQuery.isLoading || remoteQuery.isLoading,
142
+ isError: appsQuery.isError || remoteQuery.isError,
143
+ error: appsQuery.error ?? remoteQuery.error,
144
+ onRetry: () => {
145
+ void appsQuery.refetch();
146
+ void remoteQuery.refetch();
147
+ },
148
+ appName: app?.name ?? appId,
149
+ appPath: app?.path ?? "",
150
+ email: remoteQuery.data?.emails.find((candidate) => candidate.id === id),
151
+ catalogError: remoteQuery.data?.error ?? null,
152
+ notFound,
153
+ };
154
+ }
155
+
156
+ export default function TransactionalEmailDetailRoute() {
157
+ const t = useT();
158
+ const params = useParams<{ appId: string; id: string }>();
159
+ const appId = params.appId ?? "";
160
+ const id = params.id ?? "";
161
+
162
+ const detail = useEmailDetail(appId, id);
163
+
164
+ const sharedProviderReason = t(
165
+ "dispatch.transactionalEmail.sharedProviderMetricsUnavailable",
166
+ );
167
+ const engagementQuery = useQuery({
168
+ queryKey: ["list-email-engagement", detail.appPath, id, WINDOW_DAYS],
169
+ queryFn: () =>
170
+ callAppAction<ProviderMetricsResult<EmailEngagement[]>>(
171
+ detail.appPath,
172
+ "list-email-engagement",
173
+ { templateIds: [id], windowDays: WINDOW_DAYS },
174
+ "POST",
175
+ ),
176
+ enabled: appId !== "core" && id.length > 0 && detail.appPath.length > 0,
177
+ });
178
+ const engagementResult = engagementQuery.data;
179
+ const engagementUnavailable =
180
+ appId === "core"
181
+ ? sharedProviderReason
182
+ : engagementResult && !engagementResult.available
183
+ ? engagementResult.reason
184
+ : engagementQuery.isError
185
+ ? engagementQuery.error instanceof Error
186
+ ? engagementQuery.error.message
187
+ : String(engagementQuery.error)
188
+ : null;
189
+ const engagement = engagementResult?.available
190
+ ? engagementResult.data.find((entry) => entry.templateId === id)
191
+ : undefined;
192
+
193
+ const activityQuery = useQuery({
194
+ queryKey: ["list-email-activity", detail.appPath, id, ACTIVITY_LIMIT],
195
+ queryFn: () =>
196
+ callAppAction<ProviderMetricsResult<EmailActivityEntry[]>>(
197
+ detail.appPath,
198
+ "list-email-activity",
199
+ { templateId: id, limit: ACTIVITY_LIMIT },
200
+ "GET",
201
+ ),
202
+ enabled:
203
+ appId !== "core" &&
204
+ id.length > 0 &&
205
+ detail.appPath.length > 0 &&
206
+ !engagementUnavailable,
207
+ });
208
+
209
+ return (
210
+ <DispatchShell
211
+ title={detail.email?.name ?? t("dispatch.transactionalEmail.title")}
212
+ description={t("dispatch.transactionalEmail.description")}
213
+ >
214
+ <div className="max-w-3xl space-y-4">
215
+ <Button asChild size="sm" variant="ghost" className="-ml-2">
216
+ <Link to="/transactional-email">
217
+ <IconArrowLeft size={15} className="mr-1.5" />
218
+ {t("dispatch.transactionalEmail.title")}
219
+ </Link>
220
+ </Button>
221
+
222
+ {detail.isError ? (
223
+ <ActionQueryError error={detail.error} onRetry={detail.onRetry} />
224
+ ) : detail.isLoading ? (
225
+ <div className="space-y-3">
226
+ <Skeleton className="h-6 w-64" />
227
+ <Skeleton className="h-32 w-full" />
228
+ </div>
229
+ ) : detail.notFound || !detail.email ? (
230
+ <Alert variant="destructive">
231
+ <IconAlertTriangle className="size-4" />
232
+ <AlertTitle>
233
+ {t("dispatch.transactionalEmail.emailNotFound")}
234
+ </AlertTitle>
235
+ <AlertDescription>
236
+ {detail.catalogError ??
237
+ t("dispatch.transactionalEmail.emailNotFoundDescription")}
238
+ </AlertDescription>
239
+ </Alert>
240
+ ) : (
241
+ <>
242
+ <section className="rounded-2xl bg-card p-5">
243
+ <div className="text-lg font-semibold text-foreground">
244
+ {detail.email.name}
245
+ </div>
246
+ <div className="mt-1 font-mono text-xs text-muted-foreground">
247
+ {detail.email.id}
248
+ </div>
249
+ <div className="mt-1 text-xs text-muted-foreground">
250
+ {detail.appName}
251
+ {detail.appPath ? ` · ${detail.appPath}` : ""}
252
+ </div>
253
+
254
+ <dl className="mt-4 space-y-3 text-sm">
255
+ <div>
256
+ <dt className="text-xs font-medium text-muted-foreground">
257
+ {t("dispatch.transactionalEmail.trigger")}
258
+ </dt>
259
+ <dd className="mt-1 text-foreground">
260
+ {detail.email.trigger}
261
+ </dd>
262
+ </div>
263
+ <div>
264
+ <dt className="text-xs font-medium text-muted-foreground">
265
+ {t("dispatch.transactionalEmail.recipient")}
266
+ </dt>
267
+ <dd className="mt-1 text-foreground">
268
+ {detail.email.recipient}
269
+ </dd>
270
+ </div>
271
+ <div>
272
+ <dt className="text-xs font-medium text-muted-foreground">
273
+ {t("dispatch.transactionalEmail.sender")}
274
+ </dt>
275
+ <dd className="mt-1 text-foreground">
276
+ {detail.email.sender}
277
+ </dd>
278
+ </div>
279
+ </dl>
280
+
281
+ <div className="mt-4 flex flex-wrap gap-6 border-t pt-4 text-sm">
282
+ <div>
283
+ <div className="text-xs font-medium text-muted-foreground">
284
+ {t("dispatch.transactionalEmail.sends")}
285
+ </div>
286
+ <div className="mt-1">
287
+ <SendsCell
288
+ sent={detail.email.sent}
289
+ failed={detail.email.failed}
290
+ />
291
+ </div>
292
+ </div>
293
+ <div>
294
+ <div className="text-xs font-medium text-muted-foreground">
295
+ {t("dispatch.transactionalEmail.openRate")}
296
+ </div>
297
+ <div className="mt-1">
298
+ <OpenRateCell
299
+ engagement={engagement}
300
+ unavailableReason={engagementUnavailable}
301
+ loading={engagementQuery.isLoading}
302
+ />
303
+ </div>
304
+ </div>
305
+ <div>
306
+ <div className="text-xs font-medium text-muted-foreground">
307
+ {t("dispatch.transactionalEmail.lastSent")}
308
+ </div>
309
+ <div className="mt-1">
310
+ <LastSentCell
311
+ lastSentAt={detail.email.lastSentAt}
312
+ sent={detail.email.sent}
313
+ />
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </section>
318
+
319
+ <section className="rounded-2xl bg-card p-5">
320
+ <h2 className="text-sm font-medium text-foreground">
321
+ {t("dispatch.transactionalEmail.preview")}
322
+ </h2>
323
+ <p className="mt-1 text-xs text-muted-foreground">
324
+ {t("dispatch.transactionalEmail.previewDescription")}
325
+ </p>
326
+ <div className="mt-4">
327
+ <EmailPreviewPane
328
+ appId={appId}
329
+ appPath={detail.appPath}
330
+ id={detail.email.id}
331
+ name={detail.email.name}
332
+ />
333
+ </div>
334
+ </section>
335
+
336
+ <section className="rounded-2xl bg-card p-5">
337
+ <h2 className="text-sm font-medium text-foreground">
338
+ {t("dispatch.transactionalEmail.activityLink")}
339
+ </h2>
340
+ <p className="mt-1 text-xs text-muted-foreground">
341
+ {t("dispatch.transactionalEmail.retentionNote")}
342
+ </p>
343
+ <div className="mt-4">
344
+ <ActivityTable
345
+ result={
346
+ engagementUnavailable
347
+ ? { available: false, reason: engagementUnavailable }
348
+ : activityQuery.data
349
+ }
350
+ isLoading={activityQuery.isLoading}
351
+ isError={activityQuery.isError}
352
+ error={activityQuery.error}
353
+ onRetry={() => void activityQuery.refetch()}
354
+ />
355
+ </div>
356
+ </section>
357
+ </>
358
+ )}
359
+ </div>
360
+ </DispatchShell>
361
+ );
362
+ }