@carlonicora/nextjs-jsonapi 6.8.0 → 6.9.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 (106) hide show
  1. package/dist/{BlockNoteEditor-D3LDTJCP.mjs → BlockNoteEditor-WN73IHXR.mjs} +4 -4
  2. package/dist/{BlockNoteEditor-FB6FQFRF.js → BlockNoteEditor-XXH3VBZ7.js} +19 -19
  3. package/dist/{BlockNoteEditor-FB6FQFRF.js.map → BlockNoteEditor-XXH3VBZ7.js.map} +1 -1
  4. package/dist/{BlockNoteViewer-LGFMGTYX.js → BlockNoteViewer-EKAWVLIR.js} +3 -3
  5. package/dist/{BlockNoteViewer-LGFMGTYX.js.map → BlockNoteViewer-EKAWVLIR.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-YSPYUDZE.mjs → BlockNoteViewer-RWRI3QO2.mjs} +2 -2
  7. package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-BPgqqwgw.d.mts} +20 -1
  8. package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-_51oA3om.d.ts} +20 -1
  9. package/dist/billing/index.js +492 -492
  10. package/dist/billing/index.mjs +3 -3
  11. package/dist/{chunk-D44UDP4G.mjs → chunk-3TNGKMJU.mjs} +2 -2
  12. package/dist/{chunk-VT5HRZH7.mjs → chunk-6HFRXEF5.mjs} +326 -3
  13. package/dist/chunk-6HFRXEF5.mjs.map +1 -0
  14. package/dist/{chunk-5HBFQEYU.mjs → chunk-AWLTSKMM.mjs} +2 -2
  15. package/dist/{chunk-TL5O7EGW.mjs → chunk-DPAM7BCE.mjs} +2 -2
  16. package/dist/{chunk-PSLT5JLE.js → chunk-G3YKDIAL.js} +3 -3
  17. package/dist/{chunk-PSLT5JLE.js.map → chunk-G3YKDIAL.js.map} +1 -1
  18. package/dist/{chunk-ZXK2OMCC.js → chunk-KRQRJA4J.js} +8 -8
  19. package/dist/{chunk-ZXK2OMCC.js.map → chunk-KRQRJA4J.js.map} +1 -1
  20. package/dist/{chunk-5HTX4RDA.mjs → chunk-Q4XLE3QO.mjs} +1737 -1307
  21. package/dist/chunk-Q4XLE3QO.mjs.map +1 -0
  22. package/dist/{chunk-JHT32ODN.js → chunk-RNZ3IK5J.js} +4 -4
  23. package/dist/{chunk-JHT32ODN.js.map → chunk-RNZ3IK5J.js.map} +1 -1
  24. package/dist/{chunk-UZP7KRAV.js → chunk-SQTXJPRE.js} +500 -177
  25. package/dist/chunk-SQTXJPRE.js.map +1 -0
  26. package/dist/{chunk-UT4GVYJE.js → chunk-ZCM4JLM7.js} +1380 -950
  27. package/dist/chunk-ZCM4JLM7.js.map +1 -0
  28. package/dist/client/index.js +4 -4
  29. package/dist/client/index.mjs +3 -3
  30. package/dist/components/index.d.mts +57 -3
  31. package/dist/components/index.d.ts +57 -3
  32. package/dist/components/index.js +14 -4
  33. package/dist/components/index.js.map +1 -1
  34. package/dist/components/index.mjs +13 -3
  35. package/dist/contexts/index.js +4 -4
  36. package/dist/contexts/index.mjs +3 -3
  37. package/dist/core/index.d.mts +134 -3
  38. package/dist/core/index.d.ts +134 -3
  39. package/dist/core/index.js +24 -2
  40. package/dist/core/index.js.map +1 -1
  41. package/dist/core/index.mjs +23 -1
  42. package/dist/email-automations/index.d.mts +6 -1
  43. package/dist/email-automations/index.d.ts +6 -1
  44. package/dist/email-automations/index.js +101 -94
  45. package/dist/email-automations/index.js.map +1 -1
  46. package/dist/email-automations/index.mjs +12 -5
  47. package/dist/email-automations/index.mjs.map +1 -1
  48. package/dist/features/analytics/index.js +121 -121
  49. package/dist/features/analytics/index.mjs +4 -4
  50. package/dist/features/analytics/server.js +4 -4
  51. package/dist/features/analytics/server.mjs +2 -2
  52. package/dist/features/blog/index.js +150 -150
  53. package/dist/features/blog/index.mjs +3 -3
  54. package/dist/features/blog/server.js +16 -16
  55. package/dist/features/blog/server.mjs +1 -1
  56. package/dist/features/help/index.js +37 -37
  57. package/dist/features/help/index.mjs +3 -3
  58. package/dist/features/tokenusage/index.js +127 -127
  59. package/dist/features/tokenusage/index.mjs +4 -4
  60. package/dist/index.d.mts +2 -2
  61. package/dist/index.d.ts +2 -2
  62. package/dist/index.js +25 -3
  63. package/dist/index.js.map +1 -1
  64. package/dist/index.mjs +24 -2
  65. package/dist/server/index.js +3 -3
  66. package/dist/server/index.mjs +2 -2
  67. package/messages/en.json +62 -0
  68. package/messages/it.json +62 -0
  69. package/package.json +1 -1
  70. package/src/components/index.ts +1 -0
  71. package/src/core/index.ts +3 -0
  72. package/src/core/registry/ModuleRegistry.ts +4 -0
  73. package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +14 -2
  74. package/src/features/email-blast/EmailBlastAudienceModule.ts +12 -0
  75. package/src/features/email-blast/EmailBlastDeliveryModule.ts +12 -0
  76. package/src/features/email-blast/EmailBlastModule.ts +28 -0
  77. package/src/features/email-blast/components/containers/EmailBlastContainer.tsx +145 -0
  78. package/src/features/email-blast/components/containers/EmailBlastsListContainer.tsx +36 -0
  79. package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +132 -0
  80. package/src/features/email-blast/components/forms/EmailBlastSendDialog.tsx +104 -0
  81. package/src/features/email-blast/components/index.ts +5 -0
  82. package/src/features/email-blast/components/lists/EmailBlastsList.tsx +57 -0
  83. package/src/features/email-blast/data/EmailBlast.ts +89 -0
  84. package/src/features/email-blast/data/EmailBlastAudience.ts +22 -0
  85. package/src/features/email-blast/data/EmailBlastAudienceInterface.ts +5 -0
  86. package/src/features/email-blast/data/EmailBlastDelivery.ts +36 -0
  87. package/src/features/email-blast/data/EmailBlastDeliveryInterface.ts +7 -0
  88. package/src/features/email-blast/data/EmailBlastDeliveryService.ts +25 -0
  89. package/src/features/email-blast/data/EmailBlastInterface.ts +22 -0
  90. package/src/features/email-blast/data/EmailBlastService.ts +101 -0
  91. package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +73 -0
  92. package/src/features/email-blast/data/email-blast.fields.ts +8 -0
  93. package/src/features/email-blast/data/email-blast.segments.ts +10 -0
  94. package/src/features/email-blast/data/index.ts +10 -0
  95. package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +104 -0
  96. package/src/features/email-blast/index.ts +4 -0
  97. package/src/features/index.ts +1 -0
  98. package/dist/chunk-5HTX4RDA.mjs.map +0 -1
  99. package/dist/chunk-UT4GVYJE.js.map +0 -1
  100. package/dist/chunk-UZP7KRAV.js.map +0 -1
  101. package/dist/chunk-VT5HRZH7.mjs.map +0 -1
  102. /package/dist/{BlockNoteEditor-D3LDTJCP.mjs.map → BlockNoteEditor-WN73IHXR.mjs.map} +0 -0
  103. /package/dist/{BlockNoteViewer-YSPYUDZE.mjs.map → BlockNoteViewer-RWRI3QO2.mjs.map} +0 -0
  104. /package/dist/{chunk-D44UDP4G.mjs.map → chunk-3TNGKMJU.mjs.map} +0 -0
  105. /package/dist/{chunk-5HBFQEYU.mjs.map → chunk-AWLTSKMM.mjs.map} +0 -0
  106. /package/dist/{chunk-TL5O7EGW.mjs.map → chunk-DPAM7BCE.mjs.map} +0 -0
@@ -0,0 +1,12 @@
1
+ import { MailIcon } from "lucide-react";
2
+ import { ModuleFactory } from "../../permissions";
3
+ import { EmailBlastDelivery } from "./data/EmailBlastDelivery";
4
+
5
+ export const EmailBlastDeliveryModule = (factory: ModuleFactory) =>
6
+ factory({
7
+ moduleId: "c47e9b1d-2a5f-4c83-9e60-1b7d4a3f8e25",
8
+ pageUrl: undefined,
9
+ name: "email-blast-deliveries",
10
+ model: EmailBlastDelivery,
11
+ icon: MailIcon,
12
+ });
@@ -0,0 +1,28 @@
1
+ import { SendIcon } from "lucide-react";
2
+ import { createJsonApiInclusion } from "../../core";
3
+ import { ModuleFactory } from "../../permissions";
4
+ import { EmailBlast } from "./data/EmailBlast";
5
+
6
+ export const EmailBlastModule = (factory: ModuleFactory) =>
7
+ factory({
8
+ moduleId: "8a1f4c2e-6b3d-4e9a-b7c5-2d0e9f1a6b48",
9
+ pageUrl: "/administration/email-blasts",
10
+ name: "email-blasts",
11
+ model: EmailBlast,
12
+ icon: SendIcon,
13
+ inclusions: {
14
+ lists: {
15
+ fields: [
16
+ createJsonApiInclusion("email-blasts", [
17
+ "subject",
18
+ "status",
19
+ "sentAt",
20
+ "recipientCount",
21
+ "sentCount",
22
+ "failedCount",
23
+ "unsubscribedCount",
24
+ ]),
25
+ ],
26
+ },
27
+ },
28
+ });
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import { LoaderCircleIcon, MailCheckIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useCallback, useEffect, useState } from "react";
6
+ import { CommonDeleter, RoundPageContainer } from "../../../../components";
7
+ import { errorToast } from "../../../../components/errors/errorToast";
8
+ import { DetailField } from "../../../../components/typography";
9
+ import { SharedProvider } from "../../../../contexts/SharedContext";
10
+ import { Modules } from "../../../../core";
11
+ import { usePageUrlGenerator } from "../../../../hooks";
12
+ import { useI18nLocale } from "../../../../i18n";
13
+ import { BreadcrumbItemData } from "../../../../interfaces";
14
+ import { Badge, Button } from "../../../../shadcnui";
15
+ import { showToast } from "../../../../utils/toast";
16
+ import { EmailBlastInterface, EmailBlastStatus } from "../../data/EmailBlastInterface";
17
+ import { EmailBlastService } from "../../data/EmailBlastService";
18
+ import { EmailBlastEditor } from "../forms/EmailBlastEditor";
19
+ import { EmailBlastSendDialog } from "../forms/EmailBlastSendDialog";
20
+
21
+ const STATUS_VARIANTS: Record<EmailBlastStatus, "softGray" | "softAmber" | "softGreen"> = {
22
+ draft: "softGray",
23
+ sending: "softAmber",
24
+ sent: "softGreen",
25
+ };
26
+
27
+ export type EmailBlastContainerProps = {
28
+ blastId: string;
29
+ };
30
+
31
+ function EmailBlastSendTestButton({ blast }: { blast: EmailBlastInterface }) {
32
+ const t = useTranslations();
33
+ const [isSending, setIsSending] = useState<boolean>(false);
34
+
35
+ const handleClick = async () => {
36
+ setIsSending(true);
37
+ try {
38
+ await EmailBlastService.sendTest({ id: blast.id });
39
+ showToast(t("emailBlasts.test.sent"));
40
+ } catch (error) {
41
+ errorToast({ title: t("common.errors.error"), error });
42
+ } finally {
43
+ setIsSending(false);
44
+ }
45
+ };
46
+
47
+ return (
48
+ <Button size="sm" variant="outline" type="button" onClick={handleClick} disabled={isSending}>
49
+ {isSending ? <LoaderCircleIcon className="animate-spin-slow" /> : <MailCheckIcon />}
50
+ {t("emailBlasts.test.button")}
51
+ </Button>
52
+ );
53
+ }
54
+
55
+ export function EmailBlastContainer({ blastId }: EmailBlastContainerProps) {
56
+ const t = useTranslations();
57
+ const locale = useI18nLocale();
58
+ const generateUrl = usePageUrlGenerator();
59
+ const [blast, setBlast] = useState<EmailBlastInterface | undefined>(undefined);
60
+
61
+ const reload = useCallback(async () => {
62
+ setBlast(await EmailBlastService.findOne({ id: blastId }));
63
+ }, [blastId]);
64
+
65
+ useEffect(() => {
66
+ void reload();
67
+ }, [reload]);
68
+
69
+ if (!blast) return null;
70
+
71
+ const listUrl = generateUrl({ page: Modules.EmailBlast });
72
+
73
+ const breadcrumbs: BreadcrumbItemData[] = [
74
+ { name: t("emailBlasts.admin.title"), href: listUrl },
75
+ { name: blast.subject },
76
+ ];
77
+
78
+ // Only a draft can be edited, test-sent, sent or deleted (the API refuses the rest).
79
+ const functions: ReactNode[] =
80
+ blast.status === "draft"
81
+ ? [
82
+ <EmailBlastEditor key="emailBlastEditor" blast={blast} onSuccess={reload} />,
83
+ <EmailBlastSendTestButton key="emailBlastSendTest" blast={blast} />,
84
+ <EmailBlastSendDialog key="emailBlastSendDialog" blast={blast} onSuccess={reload} />,
85
+ <CommonDeleter
86
+ key="emailBlastDeleter"
87
+ type="email-blasts"
88
+ deleteFunction={() => EmailBlastService.delete({ id: blast.id })}
89
+ redirectTo={listUrl}
90
+ />,
91
+ ]
92
+ : [];
93
+
94
+ // The page chrome (title + header functions) is published here rather than by
95
+ // AdministrationProvider: SharedProvider replaces its value for the subtree,
96
+ // so this one carries the administration title type plus the blast subject.
97
+ return (
98
+ <SharedProvider
99
+ value={{
100
+ breadcrumbs,
101
+ title: {
102
+ type: t("emailBlasts.admin.title"),
103
+ element: blast.subject,
104
+ functions,
105
+ },
106
+ }}
107
+ >
108
+ <RoundPageContainer module={Modules.EmailBlast} id={blast.id} fullWidth forceHeader>
109
+ <div className="flex w-full flex-col gap-y-6 p-4">
110
+ <div>
111
+ <Badge variant={STATUS_VARIANTS[blast.status]}>{t(`emailBlasts.list.status.${blast.status}`)}</Badge>
112
+ </div>
113
+ <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
114
+ <DetailField
115
+ label={t("emailBlasts.detail.recipients")}
116
+ value={<span className="tabular-nums">{blast.recipientCount ?? "—"}</span>}
117
+ />
118
+ <DetailField
119
+ label={t("emailBlasts.detail.sentTo")}
120
+ value={<span className="tabular-nums">{blast.sentCount}</span>}
121
+ />
122
+ <DetailField
123
+ label={t("emailBlasts.detail.failed")}
124
+ value={<span className="tabular-nums">{blast.failedCount}</span>}
125
+ />
126
+ <DetailField
127
+ label={t("emailBlasts.detail.unsubscribed")}
128
+ value={<span className="tabular-nums">{blast.unsubscribedCount}</span>}
129
+ />
130
+ </div>
131
+ <DetailField
132
+ label={t("emailBlasts.detail.sentAt")}
133
+ value={blast.sentAt ? blast.sentAt.toLocaleString(locale) : "—"}
134
+ />
135
+ <DetailField label={t("emailBlasts.list.columns.subject")} value={blast.subject} />
136
+ <DetailField label={t("emailBlasts.detail.replyTo")} value={blast.replyTo ?? "—"} />
137
+ <DetailField
138
+ label={t("emailBlasts.detail.body")}
139
+ value={<div className="whitespace-pre-wrap leading-relaxed">{blast.body}</div>}
140
+ />
141
+ </div>
142
+ </RoundPageContainer>
143
+ </SharedProvider>
144
+ );
145
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { RoundPageContainer } from "../../../../components";
5
+ import { SharedProvider } from "../../../../contexts/SharedContext";
6
+ import { Modules } from "../../../../core";
7
+ import { BreadcrumbItemData } from "../../../../interfaces";
8
+ import { EmailBlastsList } from "../lists/EmailBlastsList";
9
+
10
+ /**
11
+ * Page container for the administrative email-blast list.
12
+ *
13
+ * Client component by necessity: `module={Modules.EmailBlast}` is a
14
+ * registry entry carrying an icon component and methods, which a Server
15
+ * Component cannot pass across the boundary. Routes mount this container
16
+ * instead — the same shape as every other list page.
17
+ *
18
+ * No `forceHeader`: ContentListTable draws its own header row with the title
19
+ * and the create action, exactly as the company and user list pages do.
20
+ *
21
+ * Publishes its own breadcrumb, as CompanyProvider does for the companies list:
22
+ * the list root comes first and has no href because it is the current page.
23
+ */
24
+ export function EmailBlastsListContainer() {
25
+ const t = useTranslations();
26
+
27
+ const breadcrumbs: BreadcrumbItemData[] = [{ name: t("emailBlasts.admin.title") }];
28
+
29
+ return (
30
+ <SharedProvider value={{ breadcrumbs, title: { type: t("emailBlasts.admin.title") } }}>
31
+ <RoundPageContainer module={Modules.EmailBlast} fullWidth>
32
+ <EmailBlastsList fullWidth />
33
+ </RoundPageContainer>
34
+ </SharedProvider>
35
+ );
36
+ }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode, useCallback, useMemo } from "react";
6
+ import { useForm } from "react-hook-form";
7
+ import { v4 } from "uuid";
8
+ import { z } from "zod";
9
+ import { EditorSheet, FormInput, FormTextarea } from "../../../../components";
10
+ import { Modules } from "../../../../core";
11
+ import { useI18nRouter } from "../../../../i18n";
12
+ import { EmailBlastInput, EmailBlastInterface } from "../../data/EmailBlastInterface";
13
+ import { EmailBlastService } from "../../data/EmailBlastService";
14
+
15
+ /** The fixed variable set the blast renderer fills. */
16
+ const TEMPLATE_VARIABLES = ["{{name}}", "{{email}}", "{{url}}", "{{unsubscribeAllUrl}}"];
17
+
18
+ export type EmailBlastEditorProps = {
19
+ blast?: EmailBlastInterface;
20
+ propagateChanges?: (blast: EmailBlastInterface) => void;
21
+ onSuccess?: () => void | Promise<void>;
22
+ trigger?: ReactNode;
23
+ forceShow?: boolean;
24
+ onClose?: () => void;
25
+ dialogOpen?: boolean;
26
+ onDialogOpenChange?: (open: boolean) => void;
27
+ };
28
+
29
+ function EmailBlastEditorInternal({
30
+ blast,
31
+ propagateChanges,
32
+ onSuccess,
33
+ trigger,
34
+ forceShow,
35
+ onClose,
36
+ dialogOpen,
37
+ onDialogOpenChange,
38
+ }: EmailBlastEditorProps) {
39
+ const t = useTranslations();
40
+ const router = useI18nRouter();
41
+ const isEdit = !!blast;
42
+
43
+ const formSchema = useMemo(
44
+ () =>
45
+ z.object({
46
+ id: z.uuidv4(),
47
+ subject: z.string().min(1, { message: t("emailBlasts.editor.fields.subject.error") }),
48
+ body: z.string().min(1, { message: t("emailBlasts.editor.fields.body.error") }),
49
+ replyTo: z.string().refine((value) => value === "" || z.email().safeParse(value).success, {
50
+ message: t("emailBlasts.editor.fields.replyTo.error"),
51
+ }),
52
+ }),
53
+ [t],
54
+ );
55
+
56
+ const getDefaultValues = useCallback(
57
+ () => ({
58
+ id: blast?.id ?? v4(),
59
+ subject: blast?.subject ?? "",
60
+ body: blast?.body ?? "",
61
+ replyTo: blast?.replyTo ?? "",
62
+ }),
63
+ [blast],
64
+ );
65
+
66
+ const form = useForm<z.infer<typeof formSchema>>({
67
+ resolver: zodResolver(formSchema),
68
+ defaultValues: getDefaultValues(),
69
+ });
70
+
71
+ return (
72
+ <EditorSheet
73
+ form={form}
74
+ entityType={t("emailBlasts.editor.title")}
75
+ entityName={blast?.subject}
76
+ title={isEdit ? t("emailBlasts.editor.editTitle") : t("emailBlasts.editor.title")}
77
+ isEdit={isEdit}
78
+ module={Modules.EmailBlast}
79
+ size="lg"
80
+ propagateChanges={propagateChanges}
81
+ onSuccess={onSuccess}
82
+ onNavigate={(url) => router.push(url)}
83
+ onSubmit={async (values) => {
84
+ const payload: EmailBlastInput = {
85
+ id: values.id,
86
+ subject: values.subject,
87
+ body: values.body,
88
+ replyTo: values.replyTo || undefined,
89
+ };
90
+ return isEdit ? await EmailBlastService.update(payload) : await EmailBlastService.create(payload);
91
+ }}
92
+ onReset={getDefaultValues}
93
+ trigger={trigger}
94
+ forceShow={forceShow}
95
+ onClose={onClose}
96
+ dialogOpen={dialogOpen}
97
+ onDialogOpenChange={onDialogOpenChange}
98
+ >
99
+ <div className="flex w-full flex-col gap-y-4">
100
+ <FormInput form={form} id="subject" name={t("emailBlasts.editor.fields.subject.label")} isRequired />
101
+ <FormInput form={form} id="replyTo" name={t("emailBlasts.editor.fields.replyTo.label")} />
102
+ {/* FormTextarea has no `rows` prop; min-h-72 is the height of ~14 rows. */}
103
+ <FormTextarea
104
+ form={form}
105
+ id="body"
106
+ name={t("emailBlasts.editor.fields.body.label")}
107
+ className="min-h-72"
108
+ isRequired
109
+ description={
110
+ <>
111
+ <span className="block">
112
+ {t("emailBlasts.editor.variablesHint")}{" "}
113
+ {TEMPLATE_VARIABLES.map((variable) => (
114
+ <span key={variable} className="me-2 text-foreground">
115
+ {variable}
116
+ </span>
117
+ ))}
118
+ </span>
119
+ <span className="mt-1 block">{t("emailBlasts.editor.unsubscribeHint")}</span>
120
+ </>
121
+ }
122
+ />
123
+ </div>
124
+ </EditorSheet>
125
+ );
126
+ }
127
+
128
+ export function EmailBlastEditor(props: EmailBlastEditorProps) {
129
+ return <EmailBlastEditorInternal {...props} />;
130
+ }
131
+
132
+ export default EmailBlastEditor;
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { LoaderCircleIcon, SendIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useState } from "react";
6
+ import { errorToast } from "../../../../components/errors/errorToast";
7
+ import {
8
+ AlertDialog,
9
+ AlertDialogContent,
10
+ AlertDialogDescription,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ AlertDialogTrigger,
14
+ } from "../../../../shadcnui/ui/alert-dialog";
15
+ import { Button } from "../../../../shadcnui/ui/button";
16
+ import { EmailBlastInterface } from "../../data/EmailBlastInterface";
17
+ import { EmailBlastService } from "../../data/EmailBlastService";
18
+
19
+ export type EmailBlastSendDialogProps = {
20
+ blast: EmailBlastInterface;
21
+ onSuccess?: () => void | Promise<void>;
22
+ };
23
+
24
+ /**
25
+ * Header action on a draft blast: asks how many people it reaches now, then sends.
26
+ * Same AlertDialog shape as AccountDeletionAdminTrigger.
27
+ */
28
+ export function EmailBlastSendDialog({ blast, onSuccess }: EmailBlastSendDialogProps) {
29
+ const t = useTranslations();
30
+ const [open, setOpen] = useState<boolean>(false);
31
+ const [count, setCount] = useState<number | undefined>(undefined);
32
+ const [isSending, setIsSending] = useState<boolean>(false);
33
+
34
+ // The count is read each time the dialog opens: consent and opt-outs change.
35
+ useEffect(() => {
36
+ if (!open) return;
37
+ let cancelled = false;
38
+ setCount(undefined);
39
+ EmailBlastService.findAudience({ id: blast.id })
40
+ .then((audience) => {
41
+ if (!cancelled) setCount(audience.recipientCount);
42
+ })
43
+ .catch((error) => {
44
+ if (!cancelled) errorToast({ title: t("common.errors.error"), error });
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ }, [open, blast.id, t]);
50
+
51
+ const isLoading = count === undefined;
52
+ const canSend = !isLoading && count > 0 && !isSending;
53
+
54
+ const handleSend = async () => {
55
+ setIsSending(true);
56
+ try {
57
+ await EmailBlastService.send({ id: blast.id });
58
+ await onSuccess?.();
59
+ setOpen(false);
60
+ } catch (error) {
61
+ errorToast({ title: t("common.errors.error"), error });
62
+ } finally {
63
+ setIsSending(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <AlertDialog open={open} onOpenChange={setOpen}>
69
+ <AlertDialogTrigger render={<Button size="sm" variant="default" />}>
70
+ <SendIcon />
71
+ {t("emailBlasts.send.button")}
72
+ </AlertDialogTrigger>
73
+ <AlertDialogContent className="flex max-h-[70vh] max-w-3xl flex-col overflow-y-auto">
74
+ <AlertDialogHeader>
75
+ <AlertDialogTitle>{t("emailBlasts.send.title")}</AlertDialogTitle>
76
+ <AlertDialogDescription>
77
+ {isLoading ? (
78
+ <LoaderCircleIcon className="animate-spin-slow h-5 w-5" />
79
+ ) : count === 0 ? (
80
+ t("emailBlasts.send.nobody")
81
+ ) : (
82
+ t("emailBlasts.send.confirm", { count })
83
+ )}
84
+ </AlertDialogDescription>
85
+ </AlertDialogHeader>
86
+ <div className="flex justify-end">
87
+ <Button className="me-2" variant="outline" type="button" onClick={() => setOpen(false)} disabled={isSending}>
88
+ {t("emailBlasts.send.cancel")}
89
+ </Button>
90
+ <Button type="button" variant="default" onClick={handleSend} disabled={!canSend}>
91
+ {isSending ? (
92
+ <>
93
+ {t("emailBlasts.send.action")}
94
+ <LoaderCircleIcon className="animate-spin-slow h-5 w-5" />
95
+ </>
96
+ ) : (
97
+ t("emailBlasts.send.action")
98
+ )}
99
+ </Button>
100
+ </div>
101
+ </AlertDialogContent>
102
+ </AlertDialog>
103
+ );
104
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./containers/EmailBlastsListContainer";
2
+ export * from "./containers/EmailBlastContainer";
3
+ export * from "./lists/EmailBlastsList";
4
+ export * from "./forms/EmailBlastEditor";
5
+ export * from "./forms/EmailBlastSendDialog";
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { SendIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { ReactNode } from "react";
6
+ import { ContentListTable } from "../../../../components";
7
+ import { Modules } from "../../../../core";
8
+ import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
9
+ import { EmptyState } from "../../../../shadcnui";
10
+ import { EmailBlastFields } from "../../data/email-blast.fields";
11
+ import { EmailBlastInterface } from "../../data/EmailBlastInterface";
12
+ import { EmailBlastService } from "../../data/EmailBlastService";
13
+ // Registers the "email-blasts" table generator. The blast components ship in the
14
+ // shared ./components entry, which does not re-export feature hooks (HowToList pattern).
15
+ import "../../hooks/useEmailBlastTableStructure";
16
+ import EmailBlastEditor from "../forms/EmailBlastEditor";
17
+
18
+ type EmailBlastsListProps = {
19
+ /**
20
+ * Pass this whenever the list is the page's own content inside a
21
+ * `RoundPageContainer fullWidth`. Without it ContentListTable wraps itself in
22
+ * `rounded-md border`, drawing a second bordered card inside the page's
23
+ * rounded shell.
24
+ */
25
+ fullWidth?: boolean;
26
+ };
27
+
28
+ export function EmailBlastsList({ fullWidth }: EmailBlastsListProps = {}) {
29
+ const t = useTranslations();
30
+
31
+ const data: DataListRetriever<EmailBlastInterface> = useDataListRetriever({
32
+ retriever: (params) => EmailBlastService.findMany(params),
33
+ retrieverParams: {},
34
+ module: Modules.EmailBlast,
35
+ });
36
+
37
+ const functions: ReactNode[] = [<EmailBlastEditor key="create-email-blast" />];
38
+
39
+ return (
40
+ <ContentListTable
41
+ data={data}
42
+ fields={[
43
+ EmailBlastFields.subject,
44
+ EmailBlastFields.status,
45
+ EmailBlastFields.sentAt,
46
+ EmailBlastFields.sent,
47
+ EmailBlastFields.failed,
48
+ EmailBlastFields.unsubscribed,
49
+ ]}
50
+ tableGeneratorType={Modules.EmailBlast}
51
+ functions={functions}
52
+ fullWidth={fullWidth}
53
+ title={t("emailBlasts.admin.title")}
54
+ emptyState={<EmptyState icon={SendIcon} title={t("emailBlasts.admin.empty")} />}
55
+ />
56
+ );
57
+ }
@@ -0,0 +1,89 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { EmailBlastInput, EmailBlastInterface, EmailBlastStatus } from "./EmailBlastInterface";
3
+
4
+ export class EmailBlast extends AbstractApiData implements EmailBlastInterface {
5
+ private _subject?: string;
6
+ private _body?: string;
7
+ private _replyTo?: string;
8
+ private _status: EmailBlastStatus = "draft";
9
+ private _sentAt?: Date;
10
+ private _recipientCount?: number;
11
+ private _sentCount?: number;
12
+ private _failedCount?: number;
13
+ private _unsubscribedCount?: number;
14
+
15
+ get subject(): string {
16
+ return this._subject ?? "";
17
+ }
18
+
19
+ get body(): string {
20
+ return this._body ?? "";
21
+ }
22
+
23
+ get replyTo(): string | undefined {
24
+ return this._replyTo;
25
+ }
26
+
27
+ get status(): EmailBlastStatus {
28
+ return this._status;
29
+ }
30
+
31
+ get sentAt(): Date | undefined {
32
+ return this._sentAt;
33
+ }
34
+
35
+ get recipientCount(): number | undefined {
36
+ return this._recipientCount;
37
+ }
38
+
39
+ get sentCount(): number {
40
+ return this._sentCount ?? 0;
41
+ }
42
+
43
+ get failedCount(): number {
44
+ return this._failedCount ?? 0;
45
+ }
46
+
47
+ get unsubscribedCount(): number {
48
+ return this._unsubscribedCount ?? 0;
49
+ }
50
+
51
+ rehydrate(data: JsonApiHydratedDataInterface): this {
52
+ super.rehydrate(data);
53
+
54
+ const attributes = data.jsonApi.attributes;
55
+ this._subject = attributes.subject;
56
+ this._body = attributes.body;
57
+ this._replyTo = attributes.replyTo ?? undefined;
58
+ this._status = attributes.status ?? "draft";
59
+ this._sentAt = attributes.sentAt ? new Date(attributes.sentAt) : undefined;
60
+ this._recipientCount = attributes.recipientCount ?? undefined;
61
+ this._sentCount = attributes.sentCount;
62
+ this._failedCount = attributes.failedCount;
63
+ this._unsubscribedCount = attributes.unsubscribedCount;
64
+
65
+ return this;
66
+ }
67
+
68
+ /**
69
+ * Only the editable text travels: status, counters and sentAt are set by the API.
70
+ */
71
+ createJsonApi(data: EmailBlastInput) {
72
+ const response: any = {
73
+ data: {
74
+ type: Modules.EmailBlast.name,
75
+ id: data.id,
76
+ attributes: {},
77
+ meta: {},
78
+ relationships: {},
79
+ },
80
+ included: [],
81
+ };
82
+
83
+ if (data.subject !== undefined) response.data.attributes.subject = data.subject;
84
+ if (data.body !== undefined) response.data.attributes.body = data.body;
85
+ if (data.replyTo !== undefined) response.data.attributes.replyTo = data.replyTo;
86
+
87
+ return response;
88
+ }
89
+ }
@@ -0,0 +1,22 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
2
+ import { EmailBlastAudienceInterface } from "./EmailBlastAudienceInterface";
3
+
4
+ export class EmailBlastAudience extends AbstractApiData implements EmailBlastAudienceInterface {
5
+ private _recipientCount?: number;
6
+
7
+ get recipientCount(): number {
8
+ return this._recipientCount ?? 0;
9
+ }
10
+
11
+ rehydrate(data: JsonApiHydratedDataInterface): this {
12
+ super.rehydrate(data);
13
+
14
+ this._recipientCount = data.jsonApi.attributes.recipientCount;
15
+
16
+ return this;
17
+ }
18
+
19
+ createJsonApi(_data?: any): any {
20
+ throw new Error("EmailBlastAudience is computed by the API and is read-only");
21
+ }
22
+ }
@@ -0,0 +1,5 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ export interface EmailBlastAudienceInterface extends ApiDataInterface {
4
+ get recipientCount(): number;
5
+ }
@@ -0,0 +1,36 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
2
+ import { EmailBlastDeliveryInterface, EmailBlastDeliveryStatus } from "./EmailBlastDeliveryInterface";
3
+
4
+ /** JSON:API type of the POST email-blasts/unsubscribe/:token request body (not a module: nothing is stored). */
5
+ export const EMAIL_BLAST_UNSUBSCRIBE_TYPE = "email-blast-unsubscribes";
6
+
7
+ export class EmailBlastDelivery extends AbstractApiData implements EmailBlastDeliveryInterface {
8
+ private _status: EmailBlastDeliveryStatus = "pending";
9
+
10
+ get status(): EmailBlastDeliveryStatus {
11
+ return this._status;
12
+ }
13
+
14
+ rehydrate(data: JsonApiHydratedDataInterface): this {
15
+ super.rehydrate(data);
16
+
17
+ this._status = data.jsonApi.attributes.status ?? "pending";
18
+
19
+ return this;
20
+ }
21
+
22
+ createJsonApi(_data?: any): any {
23
+ throw new Error("EmailBlastDelivery is created by the API and cannot be created from the client");
24
+ }
25
+
26
+ /**
27
+ * Body of POST email-blasts/unsubscribe/:token.
28
+ */
29
+ createUnsubscribeJsonApi() {
30
+ return {
31
+ data: {
32
+ type: EMAIL_BLAST_UNSUBSCRIBE_TYPE,
33
+ },
34
+ };
35
+ }
36
+ }