@carlonicora/nextjs-jsonapi 6.8.0 → 6.10.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/{BlockNoteEditor-D3LDTJCP.mjs → BlockNoteEditor-GK3DRTOP.mjs} +51 -17
- package/dist/BlockNoteEditor-GK3DRTOP.mjs.map +1 -0
- package/dist/{BlockNoteEditor-FB6FQFRF.js → BlockNoteEditor-YOKJOVV3.js} +74 -40
- package/dist/BlockNoteEditor-YOKJOVV3.js.map +1 -0
- package/dist/{BlockNoteViewer-LGFMGTYX.js → BlockNoteViewer-EKAWVLIR.js} +3 -3
- package/dist/{BlockNoteViewer-LGFMGTYX.js.map → BlockNoteViewer-EKAWVLIR.js.map} +1 -1
- package/dist/{BlockNoteViewer-YSPYUDZE.mjs → BlockNoteViewer-RWRI3QO2.mjs} +2 -2
- package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-BPgqqwgw.d.mts} +20 -1
- package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-_51oA3om.d.ts} +20 -1
- package/dist/billing/index.js +492 -492
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-D44UDP4G.mjs → chunk-3TNGKMJU.mjs} +2 -2
- package/dist/{chunk-UT4GVYJE.js → chunk-4UFMLTL2.js} +1387 -955
- package/dist/chunk-4UFMLTL2.js.map +1 -0
- package/dist/{chunk-VT5HRZH7.mjs → chunk-6HFRXEF5.mjs} +326 -3
- package/dist/chunk-6HFRXEF5.mjs.map +1 -0
- package/dist/{chunk-5HBFQEYU.mjs → chunk-AWLTSKMM.mjs} +2 -2
- package/dist/{chunk-TL5O7EGW.mjs → chunk-DPAM7BCE.mjs} +2 -2
- package/dist/{chunk-PSLT5JLE.js → chunk-G3YKDIAL.js} +3 -3
- package/dist/{chunk-PSLT5JLE.js.map → chunk-G3YKDIAL.js.map} +1 -1
- package/dist/{chunk-ZXK2OMCC.js → chunk-KRQRJA4J.js} +8 -8
- package/dist/{chunk-ZXK2OMCC.js.map → chunk-KRQRJA4J.js.map} +1 -1
- package/dist/{chunk-JHT32ODN.js → chunk-RNZ3IK5J.js} +4 -4
- package/dist/{chunk-JHT32ODN.js.map → chunk-RNZ3IK5J.js.map} +1 -1
- package/dist/{chunk-UZP7KRAV.js → chunk-SQTXJPRE.js} +500 -177
- package/dist/chunk-SQTXJPRE.js.map +1 -0
- package/dist/{chunk-5HTX4RDA.mjs → chunk-THZAK7S3.mjs} +1743 -1311
- package/dist/chunk-THZAK7S3.mjs.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +69 -8
- package/dist/components/index.d.ts +69 -8
- package/dist/components/index.js +14 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +13 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +134 -3
- package/dist/core/index.d.ts +134 -3
- package/dist/core/index.js +24 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +23 -1
- package/dist/email-automations/index.d.mts +6 -1
- package/dist/email-automations/index.d.ts +6 -1
- package/dist/email-automations/index.js +101 -94
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +12 -5
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.js +121 -121
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +4 -4
- package/dist/features/analytics/server.mjs +2 -2
- package/dist/features/blog/index.js +150 -150
- package/dist/features/blog/index.mjs +3 -3
- package/dist/features/blog/server.js +16 -16
- package/dist/features/blog/server.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +127 -127
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +25 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/messages/en.json +62 -0
- package/messages/it.json +62 -0
- package/package.json +1 -1
- package/src/components/editors/BlockNoteEditor.tsx +69 -14
- package/src/components/editors/__tests__/BlockNoteEditor.ai.spec.tsx +295 -0
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +3 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/assistant/components/containers/AssistantPageContainer.tsx +20 -9
- package/src/features/assistant/components/containers/__tests__/AssistantPageContainer.spec.tsx +116 -0
- package/src/features/assistant/components/parts/AssistantThread.tsx +2 -1
- package/src/features/assistant-message/components/MessageItem.tsx +1 -1
- package/src/features/assistant-message/components/MessageList.tsx +1 -1
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +1 -1
- package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +1 -1
- package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +17 -1
- package/src/features/assistant-message/components/parts/__tests__/SuggestedQuestionsTab.spec.tsx +22 -0
- package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +5 -3
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +14 -2
- package/src/features/email-blast/EmailBlastAudienceModule.ts +12 -0
- package/src/features/email-blast/EmailBlastDeliveryModule.ts +12 -0
- package/src/features/email-blast/EmailBlastModule.ts +28 -0
- package/src/features/email-blast/components/containers/EmailBlastContainer.tsx +145 -0
- package/src/features/email-blast/components/containers/EmailBlastsListContainer.tsx +36 -0
- package/src/features/email-blast/components/forms/EmailBlastEditor.tsx +132 -0
- package/src/features/email-blast/components/forms/EmailBlastSendDialog.tsx +104 -0
- package/src/features/email-blast/components/index.ts +5 -0
- package/src/features/email-blast/components/lists/EmailBlastsList.tsx +57 -0
- package/src/features/email-blast/data/EmailBlast.ts +89 -0
- package/src/features/email-blast/data/EmailBlastAudience.ts +22 -0
- package/src/features/email-blast/data/EmailBlastAudienceInterface.ts +5 -0
- package/src/features/email-blast/data/EmailBlastDelivery.ts +36 -0
- package/src/features/email-blast/data/EmailBlastDeliveryInterface.ts +7 -0
- package/src/features/email-blast/data/EmailBlastDeliveryService.ts +25 -0
- package/src/features/email-blast/data/EmailBlastInterface.ts +22 -0
- package/src/features/email-blast/data/EmailBlastService.ts +101 -0
- package/src/features/email-blast/data/__tests__/EmailBlast.spec.ts +73 -0
- package/src/features/email-blast/data/email-blast.fields.ts +8 -0
- package/src/features/email-blast/data/email-blast.segments.ts +10 -0
- package/src/features/email-blast/data/index.ts +10 -0
- package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +104 -0
- package/src/features/email-blast/index.ts +4 -0
- package/src/features/index.ts +1 -0
- package/dist/BlockNoteEditor-D3LDTJCP.mjs.map +0 -1
- package/dist/BlockNoteEditor-FB6FQFRF.js.map +0 -1
- package/dist/chunk-5HTX4RDA.mjs.map +0 -1
- package/dist/chunk-UT4GVYJE.js.map +0 -1
- package/dist/chunk-UZP7KRAV.js.map +0 -1
- package/dist/chunk-VT5HRZH7.mjs.map +0 -1
- /package/dist/{BlockNoteViewer-YSPYUDZE.mjs.map → BlockNoteViewer-RWRI3QO2.mjs.map} +0 -0
- /package/dist/{chunk-D44UDP4G.mjs.map → chunk-3TNGKMJU.mjs.map} +0 -0
- /package/dist/{chunk-5HBFQEYU.mjs.map → chunk-AWLTSKMM.mjs.map} +0 -0
- /package/dist/{chunk-TL5O7EGW.mjs.map → chunk-DPAM7BCE.mjs.map} +0 -0
|
@@ -10,7 +10,8 @@ interface Props {
|
|
|
10
10
|
messages: AssistantMessageInterface[];
|
|
11
11
|
sending: boolean;
|
|
12
12
|
status?: string;
|
|
13
|
-
|
|
13
|
+
/** Omitted, the thread is read-only: suggested questions render disabled. */
|
|
14
|
+
onSelectFollowUp?: (q: string) => void;
|
|
14
15
|
failedMessageIds?: Set<string>;
|
|
15
16
|
onRetry?: (tempId: string) => void;
|
|
16
17
|
renderApprovalAction?: ApprovalActionRenderer;
|
|
@@ -36,7 +36,7 @@ function mentionUrlTransform(url: string): string {
|
|
|
36
36
|
interface Props {
|
|
37
37
|
message: AssistantMessageInterface;
|
|
38
38
|
isLatestAssistant: boolean;
|
|
39
|
-
onSelectFollowUp
|
|
39
|
+
onSelectFollowUp?: (q: string) => void;
|
|
40
40
|
failedMessageIds?: Set<string>;
|
|
41
41
|
onRetry?: (tempId: string) => void;
|
|
42
42
|
renderApprovalAction?: ApprovalActionRenderer;
|
|
@@ -6,7 +6,7 @@ import { MessageItem, type ApprovalActionRenderer, type MentionRenderer } from "
|
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
8
8
|
messages: AssistantMessageInterface[];
|
|
9
|
-
onSelectFollowUp
|
|
9
|
+
onSelectFollowUp?: (q: string) => void;
|
|
10
10
|
failedMessageIds?: Set<string>;
|
|
11
11
|
onRetry?: (tempId: string) => void;
|
|
12
12
|
renderApprovalAction?: ApprovalActionRenderer;
|
|
@@ -9,7 +9,7 @@ import { MessageSourcesPanel } from "./MessageSourcesPanel";
|
|
|
9
9
|
interface Props {
|
|
10
10
|
message: AssistantMessageInterface;
|
|
11
11
|
isLatestAssistant: boolean;
|
|
12
|
-
onSelectFollowUp
|
|
12
|
+
onSelectFollowUp?: (q: string) => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
class SourcesFetcher extends ClientAbstractService {
|
|
@@ -16,7 +16,7 @@ import { SuggestedQuestionsTab } from "./tabs/SuggestedQuestionsTab";
|
|
|
16
16
|
interface Props {
|
|
17
17
|
message: AssistantMessageInterface;
|
|
18
18
|
isLatestAssistant: boolean;
|
|
19
|
-
onSelectFollowUp
|
|
19
|
+
onSelectFollowUp?: (q: string) => void;
|
|
20
20
|
/**
|
|
21
21
|
* Map of resolved source entities keyed by `chunk.nodeId`. When provided, the
|
|
22
22
|
* Contents and Citations tabs use these to render real entity names. The
|
package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
4
4
|
import { MessageSourcesPanel } from "../MessageSourcesPanel";
|
|
5
5
|
|
|
@@ -62,6 +62,22 @@ describe("MessageSourcesPanel", () => {
|
|
|
62
62
|
expect(screen.getByText("Why?")).toBeInTheDocument();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("suggested questions call onSelectFollowUp when it is given", () => {
|
|
66
|
+
const onSelectFollowUp = vi.fn();
|
|
67
|
+
const msg = { ...baseMessage, suggestedQuestions: ["Why?"] };
|
|
68
|
+
render(<MessageSourcesPanel message={msg} isLatestAssistant={true} onSelectFollowUp={onSelectFollowUp} />);
|
|
69
|
+
const button = screen.getByRole("button", { name: "Why?" });
|
|
70
|
+
expect(button).toBeEnabled();
|
|
71
|
+
fireEvent.click(button);
|
|
72
|
+
expect(onSelectFollowUp).toHaveBeenCalledWith("Why?");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("suggested questions render disabled without onSelectFollowUp (read-only)", () => {
|
|
76
|
+
const msg = { ...baseMessage, suggestedQuestions: ["Why?"] };
|
|
77
|
+
render(<MessageSourcesPanel message={msg} isLatestAssistant={true} />);
|
|
78
|
+
expect(screen.getByRole("button", { name: "Why?" })).toBeDisabled();
|
|
79
|
+
});
|
|
80
|
+
|
|
65
81
|
it("does not show Suggested questions tab on non-latest message", () => {
|
|
66
82
|
const msg = { ...baseMessage, suggestedQuestions: ["Why?"] };
|
|
67
83
|
render(<MessageSourcesPanel message={msg} isLatestAssistant={false} onSelectFollowUp={vi.fn()} />);
|
package/src/features/assistant-message/components/parts/__tests__/SuggestedQuestionsTab.spec.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { SuggestedQuestionsTab } from "../tabs/SuggestedQuestionsTab";
|
|
5
|
+
|
|
6
|
+
describe("SuggestedQuestionsTab", () => {
|
|
7
|
+
it("renders enabled buttons that call onSelect with the question", async () => {
|
|
8
|
+
const onSelect = vi.fn();
|
|
9
|
+
render(<SuggestedQuestionsTab questions={["Why?", "How?"]} onSelect={onSelect} />);
|
|
10
|
+
const button = screen.getByRole("button", { name: "Why?" });
|
|
11
|
+
expect(button).toBeEnabled();
|
|
12
|
+
await userEvent.setup().click(button);
|
|
13
|
+
expect(onSelect).toHaveBeenCalledWith("Why?");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("renders the buttons disabled when no onSelect is given (read-only)", () => {
|
|
17
|
+
render(<SuggestedQuestionsTab questions={["Why?", "How?"]} />);
|
|
18
|
+
const buttons = screen.getAllByRole("button");
|
|
19
|
+
expect(buttons).toHaveLength(2);
|
|
20
|
+
for (const button of buttons) expect(button).toBeDisabled();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
interface Props {
|
|
4
4
|
questions: string[];
|
|
5
|
-
|
|
5
|
+
/** Omitted, the questions render disabled (read-only thread, no sending). */
|
|
6
|
+
onSelect?: (question: string) => void;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function SuggestedQuestionsTab({ questions, onSelect }: Props) {
|
|
@@ -13,8 +14,9 @@ export function SuggestedQuestionsTab({ questions, onSelect }: Props) {
|
|
|
13
14
|
<button
|
|
14
15
|
key={q}
|
|
15
16
|
type="button"
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
disabled={!onSelect}
|
|
18
|
+
onClick={() => onSelect?.(q)}
|
|
19
|
+
className="border-border bg-muted/30 hover:bg-muted disabled:hover:bg-muted/30 rounded-md border px-3 py-1.5 text-start text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
|
18
20
|
>
|
|
19
21
|
{q}
|
|
20
22
|
</button>
|
|
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|
|
5
5
|
import { useEffect, useState } from "react";
|
|
6
6
|
import { Button } from "../../../../shadcnui";
|
|
7
7
|
import { SectionHeader } from "../../../../components/typography";
|
|
8
|
+
import { EmailBlastDeliveryService } from "../../../email-blast/data/EmailBlastDeliveryService";
|
|
8
9
|
import { EmailAutomationEnrollmentService } from "../../data/EmailAutomationEnrollmentService";
|
|
9
10
|
|
|
10
11
|
type UnsubscribeScope = "automation" | "all";
|
|
@@ -12,11 +13,16 @@ type UnsubscribeScope = "automation" | "all";
|
|
|
12
13
|
interface Props {
|
|
13
14
|
token: string;
|
|
14
15
|
scope: UnsubscribeScope;
|
|
16
|
+
/**
|
|
17
|
+
* Which email the link came from. A blast link always stops every marketing
|
|
18
|
+
* email (the shared opt-out), so it lands straight on the "stopped all" state.
|
|
19
|
+
*/
|
|
20
|
+
source?: "automation" | "blast";
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
type UnsubscribeState = "loading" | "success" | "error";
|
|
18
24
|
|
|
19
|
-
export function EmailUnsubscribe({ token, scope }: Props) {
|
|
25
|
+
export function EmailUnsubscribe({ token, scope, source = "automation" }: Props) {
|
|
20
26
|
const t = useTranslations();
|
|
21
27
|
const [state, setState] = useState<UnsubscribeState>("loading");
|
|
22
28
|
const [stoppedScope, setStoppedScope] = useState<UnsubscribeScope>(scope);
|
|
@@ -26,6 +32,12 @@ export function EmailUnsubscribe({ token, scope }: Props) {
|
|
|
26
32
|
useEffect(() => {
|
|
27
33
|
async function unsubscribe() {
|
|
28
34
|
try {
|
|
35
|
+
if (source === "blast") {
|
|
36
|
+
await EmailBlastDeliveryService.unsubscribe({ token });
|
|
37
|
+
setStoppedScope("all");
|
|
38
|
+
setState("success");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
29
41
|
const enrollment = await EmailAutomationEnrollmentService.unsubscribe({ token, scope });
|
|
30
42
|
setAutomationName(enrollment?.automation?.name ?? "");
|
|
31
43
|
setStoppedScope(scope);
|
|
@@ -36,7 +48,7 @@ export function EmailUnsubscribe({ token, scope }: Props) {
|
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
unsubscribe();
|
|
39
|
-
}, [token, scope]);
|
|
51
|
+
}, [token, scope, source]);
|
|
40
52
|
|
|
41
53
|
const stopAll = async () => {
|
|
42
54
|
setIsStoppingAll(true);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { UsersIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailBlastAudience } from "./data/EmailBlastAudience";
|
|
4
|
+
|
|
5
|
+
export const EmailBlastAudienceModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
moduleId: "e2b6d8f0-4c19-4a7e-8d35-6f9a0c2b7e14",
|
|
8
|
+
pageUrl: undefined,
|
|
9
|
+
name: "email-blast-audiences",
|
|
10
|
+
model: EmailBlastAudience,
|
|
11
|
+
icon: UsersIcon,
|
|
12
|
+
});
|
|
@@ -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
|
+
}
|