@carlonicora/nextjs-jsonapi 5.2.0 → 5.3.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-EGY4ZAAM.js → BlockNoteEditor-UJ6363LC.js} +19 -19
- package/dist/{BlockNoteEditor-EGY4ZAAM.js.map → BlockNoteEditor-UJ6363LC.js.map} +1 -1
- package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-WTSQANKI.mjs} +4 -4
- package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-EL4JFB6P.js} +3 -3
- package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-EL4JFB6P.js.map} +1 -1
- package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-JVKWJZ6J.mjs} +2 -2
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-3VEQ5JZJ.js → chunk-3BAM23VA.js} +847 -847
- package/dist/chunk-3BAM23VA.js.map +1 -0
- package/dist/{chunk-JEZQTZQ4.mjs → chunk-6WU74VFU.mjs} +2 -2
- package/dist/{chunk-S5MQDWWC.js → chunk-CRI4T47Y.js} +736 -176
- package/dist/chunk-CRI4T47Y.js.map +1 -0
- package/dist/{chunk-75MLT74B.js → chunk-EO75CV76.js} +8 -8
- package/dist/{chunk-75MLT74B.js.map → chunk-EO75CV76.js.map} +1 -1
- package/dist/{chunk-NPKFMRH5.mjs → chunk-FNTQQFLH.mjs} +2 -2
- package/dist/{chunk-HNOWSFM5.mjs → chunk-JQWI62NV.mjs} +563 -3
- package/dist/chunk-JQWI62NV.mjs.map +1 -0
- package/dist/{chunk-2Z3ZCYXX.mjs → chunk-MIZTWBPX.mjs} +5 -5
- package/dist/chunk-MIZTWBPX.mjs.map +1 -0
- package/dist/{chunk-YMUPTXOT.mjs → chunk-QKZUE5OF.mjs} +2 -2
- package/dist/{chunk-EXICQC4A.js → chunk-QQQE3N2X.js} +3 -3
- package/dist/{chunk-EXICQC4A.js.map → chunk-QQQE3N2X.js.map} +1 -1
- package/dist/{chunk-THKYH65N.js → chunk-XGTQWBDJ.js} +4 -4
- package/dist/{chunk-THKYH65N.js.map → chunk-XGTQWBDJ.js.map} +1 -1
- package/dist/client/index.d.mts +6 -39
- package/dist/client/index.d.ts +6 -39
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +2 -2
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +187 -1
- package/dist/core/index.d.ts +187 -1
- package/dist/core/index.js +30 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +29 -1
- package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
- package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
- package/dist/email-automations/index.css +30 -0
- package/dist/email-automations/index.css.map +1 -0
- package/dist/email-automations/index.d.mts +303 -0
- package/dist/email-automations/index.d.ts +303 -0
- package/dist/email-automations/index.js +1705 -0
- package/dist/email-automations/index.js.map +1 -0
- package/dist/email-automations/index.mjs +1705 -0
- package/dist/email-automations/index.mjs.map +1 -0
- package/dist/features/analytics/index.js +122 -122
- 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/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +31 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +30 -2
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +2 -2
- package/dist/types-70acd-8C.d.mts +39 -0
- package/dist/types-l8SFleXi.d.ts +39 -0
- package/messages/en.json +117 -0
- package/messages/it.json +117 -0
- package/package.json +11 -1
- package/src/components/forms/FormFieldWrapper.tsx +1 -1
- package/src/components/forms/FormTextarea.tsx +2 -1
- package/src/core/index.ts +8 -0
- package/src/core/registry/ModuleRegistry.ts +5 -0
- package/src/email-automations/index.ts +5 -0
- package/src/features/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationModule.ts +30 -0
- package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
- package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
- package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
- package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
- package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
- package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
- package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
- package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
- package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
- package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
- package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
- package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
- package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
- package/src/features/email-automation/components/index.ts +15 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
- package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
- package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
- package/src/features/email-automation/data/EmailAutomation.ts +87 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
- package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
- package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
- package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
- package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
- package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
- package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
- package/src/features/email-automation/data/email-automation.fields.ts +9 -0
- package/src/features/email-automation/data/index.ts +13 -0
- package/src/features/email-automation/hooks/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
- package/src/features/email-automation/index.ts +5 -0
- package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
- package/dist/chunk-3VEQ5JZJ.js.map +0 -1
- package/dist/chunk-HNOWSFM5.mjs.map +0 -1
- package/dist/chunk-S5MQDWWC.js.map +0 -1
- /package/dist/{BlockNoteEditor-2QJVK2HH.mjs.map → BlockNoteEditor-WTSQANKI.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-JVKWJZ6J.mjs.map} +0 -0
- /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-6WU74VFU.mjs.map} +0 -0
- /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-FNTQQFLH.mjs.map} +0 -0
- /package/dist/{chunk-YMUPTXOT.mjs.map → chunk-QKZUE5OF.mjs.map} +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Edge, Node } from "@xyflow/react";
|
|
2
|
+
import { autoLayout, LayoutEdge, LayoutNode } from "./shared";
|
|
3
|
+
|
|
4
|
+
export const TRIGGER_NODE_ID = "trigger";
|
|
5
|
+
export const TRIGGER_SOURCE_HANDLE = "trigger-source";
|
|
6
|
+
export const STEP_NODE_PREFIX = "step:";
|
|
7
|
+
export const STEP_SOURCE_HANDLE_PREFIX = "step-source-";
|
|
8
|
+
export const STEP_TARGET_HANDLE_PREFIX = "step-target-";
|
|
9
|
+
|
|
10
|
+
export const TRIGGER_NODE_WIDTH = 200;
|
|
11
|
+
export const TRIGGER_NODE_HEIGHT = 56;
|
|
12
|
+
export const STEP_NODE_WIDTH = 200;
|
|
13
|
+
export const STEP_NODE_HEIGHT = 72;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The fields the canvas reads from a step. `EmailAutomationStepInterface`
|
|
17
|
+
* satisfies it structurally; the narrow shape keeps this function pure and
|
|
18
|
+
* testable with plain objects.
|
|
19
|
+
*/
|
|
20
|
+
export type CanvasStep = {
|
|
21
|
+
id: string;
|
|
22
|
+
subject: string;
|
|
23
|
+
delayHours: number;
|
|
24
|
+
positionX?: number;
|
|
25
|
+
positionY?: number;
|
|
26
|
+
sentCount: number;
|
|
27
|
+
next?: { id: string };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TriggerNodeData = { label: string };
|
|
31
|
+
|
|
32
|
+
export type EmailStepNodeData = {
|
|
33
|
+
stepId: string;
|
|
34
|
+
subject: string;
|
|
35
|
+
sentCount: number;
|
|
36
|
+
orphan: boolean;
|
|
37
|
+
manuallyPositioned: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type DelayEdgeData = { targetStepId: string; delayHours: number };
|
|
41
|
+
|
|
42
|
+
export type CanvasGraph = { nodes: Node[]; edges: Edge[]; orphanIds: string[] };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The head is the oldest step with no incoming NEXT, exactly as the API picks
|
|
46
|
+
* it (`findHead`). `steps` arrive ordered by createdAt ASC from
|
|
47
|
+
* `findByAutomation`, so the first step nobody points at is the head.
|
|
48
|
+
*/
|
|
49
|
+
export function findHeadStepId(steps: CanvasStep[]): string | null {
|
|
50
|
+
const pointedAt = new Set(steps.map((s) => s.next?.id).filter((id): id is string => !!id));
|
|
51
|
+
return steps.find((s) => !pointedAt.has(s.id))?.id ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stepNodeId = (stepId: string) => `${STEP_NODE_PREFIX}${stepId}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turns the automation's steps into React Flow nodes and edges: the trigger
|
|
58
|
+
* node, one node per step, a trigger→head edge, and one edge per NEXT link.
|
|
59
|
+
* Steps not reachable from the head (walking next→next) are orphans: they
|
|
60
|
+
* never send and render dashed.
|
|
61
|
+
*/
|
|
62
|
+
export function buildCanvasGraph(steps: CanvasStep[], triggerLabel: string): CanvasGraph {
|
|
63
|
+
const byId = new Map(steps.map((s) => [s.id, s]));
|
|
64
|
+
const headId = findHeadStepId(steps);
|
|
65
|
+
|
|
66
|
+
const chain = new Set<string>();
|
|
67
|
+
let cursor = headId ? byId.get(headId) : undefined;
|
|
68
|
+
while (cursor && !chain.has(cursor.id)) {
|
|
69
|
+
chain.add(cursor.id);
|
|
70
|
+
cursor = cursor.next ? byId.get(cursor.next.id) : undefined;
|
|
71
|
+
}
|
|
72
|
+
const orphanIds = steps.filter((s) => !chain.has(s.id)).map((s) => s.id);
|
|
73
|
+
|
|
74
|
+
const layoutNodes: LayoutNode[] = [
|
|
75
|
+
{ id: TRIGGER_NODE_ID, width: TRIGGER_NODE_WIDTH, height: TRIGGER_NODE_HEIGHT, manuallyPositioned: false },
|
|
76
|
+
...steps.map((s) => ({
|
|
77
|
+
id: stepNodeId(s.id),
|
|
78
|
+
width: STEP_NODE_WIDTH,
|
|
79
|
+
height: STEP_NODE_HEIGHT,
|
|
80
|
+
manuallyPositioned: typeof s.positionX === "number" && typeof s.positionY === "number",
|
|
81
|
+
x: s.positionX,
|
|
82
|
+
y: s.positionY,
|
|
83
|
+
})),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const edges: Edge[] = [];
|
|
87
|
+
const layoutEdges: LayoutEdge[] = [];
|
|
88
|
+
|
|
89
|
+
const pushEdge = (source: string, sourceHandle: string, target: CanvasStep) => {
|
|
90
|
+
const data: DelayEdgeData = { targetStepId: target.id, delayHours: target.delayHours };
|
|
91
|
+
edges.push({
|
|
92
|
+
id: `next:${source}->${target.id}`,
|
|
93
|
+
source,
|
|
94
|
+
sourceHandle,
|
|
95
|
+
target: stepNodeId(target.id),
|
|
96
|
+
targetHandle: `${STEP_TARGET_HANDLE_PREFIX}${target.id}`,
|
|
97
|
+
type: "delayEdge",
|
|
98
|
+
label: `${target.delayHours}h`,
|
|
99
|
+
data,
|
|
100
|
+
});
|
|
101
|
+
layoutEdges.push({ source, target: stepNodeId(target.id) });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const head = headId ? byId.get(headId) : undefined;
|
|
105
|
+
if (head) pushEdge(TRIGGER_NODE_ID, TRIGGER_SOURCE_HANDLE, head);
|
|
106
|
+
for (const s of steps) {
|
|
107
|
+
const next = s.next ? byId.get(s.next.id) : undefined;
|
|
108
|
+
if (next) pushEdge(stepNodeId(s.id), `${STEP_SOURCE_HANDLE_PREFIX}${s.id}`, next);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const positions = autoLayout(layoutNodes, layoutEdges);
|
|
112
|
+
|
|
113
|
+
const nodes: Node[] = [
|
|
114
|
+
{
|
|
115
|
+
id: TRIGGER_NODE_ID,
|
|
116
|
+
type: "triggerNode",
|
|
117
|
+
position: positions[TRIGGER_NODE_ID],
|
|
118
|
+
deletable: false,
|
|
119
|
+
data: { data: { label: triggerLabel } satisfies TriggerNodeData },
|
|
120
|
+
},
|
|
121
|
+
...steps.map((s) => {
|
|
122
|
+
const id = stepNodeId(s.id);
|
|
123
|
+
return {
|
|
124
|
+
id,
|
|
125
|
+
type: "emailStepNode",
|
|
126
|
+
position: positions[id],
|
|
127
|
+
data: {
|
|
128
|
+
data: {
|
|
129
|
+
stepId: s.id,
|
|
130
|
+
subject: s.subject,
|
|
131
|
+
sentCount: s.sentCount,
|
|
132
|
+
orphan: !chain.has(s.id),
|
|
133
|
+
manuallyPositioned: typeof s.positionX === "number" && typeof s.positionY === "number",
|
|
134
|
+
} satisfies EmailStepNodeData,
|
|
135
|
+
},
|
|
136
|
+
} satisfies Node;
|
|
137
|
+
}),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
return { nodes, edges, orphanIds };
|
|
141
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BaseEdge, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath } from "@xyflow/react";
|
|
4
|
+
import { useEmailAutomation } from "../../../contexts/EmailAutomationContext";
|
|
5
|
+
import { DelayEdgeData } from "../buildCanvasGraph";
|
|
6
|
+
|
|
7
|
+
type DelayEdgeType = Edge<DelayEdgeData>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ClueEdge recipe: a bezier with a label chip at its midpoint. The chip shows
|
|
11
|
+
* the target step's delay and opens that step's editor. It is a plain
|
|
12
|
+
* <button>: Base UI popovers do not work inside the React Flow canvas.
|
|
13
|
+
*/
|
|
14
|
+
export function DelayEdge({
|
|
15
|
+
id,
|
|
16
|
+
sourceX,
|
|
17
|
+
sourceY,
|
|
18
|
+
targetX,
|
|
19
|
+
targetY,
|
|
20
|
+
sourcePosition,
|
|
21
|
+
targetPosition,
|
|
22
|
+
label,
|
|
23
|
+
data,
|
|
24
|
+
}: EdgeProps<DelayEdgeType>) {
|
|
25
|
+
const { select } = useEmailAutomation();
|
|
26
|
+
const [edgePath, labelX, labelY] = getBezierPath({
|
|
27
|
+
sourceX,
|
|
28
|
+
sourceY,
|
|
29
|
+
sourcePosition,
|
|
30
|
+
targetX,
|
|
31
|
+
targetY,
|
|
32
|
+
targetPosition,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const targetStepId = data?.targetStepId;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<BaseEdge id={id} path={edgePath} className="stroke-foreground/60" interactionWidth={24} />
|
|
40
|
+
<EdgeLabelRenderer>
|
|
41
|
+
{/* Inline position + pointer-events per React Flow docs: the label
|
|
42
|
+
portal sets pointer-events:none, so the label sets it back to
|
|
43
|
+
'all' and carries `nodrag nopan nowheel` for event isolation. */}
|
|
44
|
+
<div
|
|
45
|
+
className="nodrag nopan nowheel"
|
|
46
|
+
style={{
|
|
47
|
+
position: "absolute",
|
|
48
|
+
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
|
49
|
+
pointerEvents: "all",
|
|
50
|
+
zIndex: 1000,
|
|
51
|
+
}}
|
|
52
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
53
|
+
>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className="nodrag nopan bg-background hover:bg-accent text-foreground border-border h-auto rounded border px-2 py-1 text-xs tabular-nums shadow-sm"
|
|
57
|
+
onClick={(e) => {
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
if (targetStepId) select(targetStepId);
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{label}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</EdgeLabelRenderer>
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Handle, Node, NodeProps, Position } from "@xyflow/react";
|
|
4
|
+
import { MailIcon } from "lucide-react";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { EmailStepNodeData, STEP_SOURCE_HANDLE_PREFIX, STEP_TARGET_HANDLE_PREFIX } from "../buildCanvasGraph";
|
|
7
|
+
|
|
8
|
+
type EmailStepNodeType = Node<{ data: EmailStepNodeData }>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 200px card (SceneNode recipe): subject bold, "sent N" muted. An orphan (not
|
|
12
|
+
* reachable from the trigger, so it never sends) is the dashed, faded variant
|
|
13
|
+
* with a caption. Plain elements only: Base UI menus/popovers do not work
|
|
14
|
+
* inside React Flow nodes; clicks and context menus are handled by the canvas.
|
|
15
|
+
*/
|
|
16
|
+
export function EmailStepNode({ data }: NodeProps<EmailStepNodeType>) {
|
|
17
|
+
const d = data.data;
|
|
18
|
+
const t = useTranslations();
|
|
19
|
+
|
|
20
|
+
const shellClass = d.orphan
|
|
21
|
+
? "border border-dashed border-foreground/30 bg-muted/40 opacity-60"
|
|
22
|
+
: "border bg-background border-foreground/25 text-foreground";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={`w-[200px] cursor-pointer rounded-md p-2 shadow-sm ${shellClass}`}>
|
|
26
|
+
<Handle type="target" position={Position.Left} id={`${STEP_TARGET_HANDLE_PREFIX}${d.stepId}`} />
|
|
27
|
+
<Handle type="source" position={Position.Right} id={`${STEP_SOURCE_HANDLE_PREFIX}${d.stepId}`} />
|
|
28
|
+
|
|
29
|
+
<div className="flex items-center gap-1 text-sm font-medium">
|
|
30
|
+
<MailIcon className="size-3 shrink-0" />
|
|
31
|
+
<span className="truncate">{d.subject}</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div className="text-muted-foreground mt-1 text-xs tabular-nums">
|
|
35
|
+
{t("emailAutomations.canvas.sent", { count: d.sentCount })}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{d.orphan && (
|
|
39
|
+
<div className="text-muted-foreground mt-1 text-xs italic">{t("emailAutomations.canvas.orphan")}</div>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Handle, Node, NodeProps, Position } from "@xyflow/react";
|
|
4
|
+
import { ZapIcon } from "lucide-react";
|
|
5
|
+
import { TRIGGER_SOURCE_HANDLE, TriggerNodeData } from "../buildCanvasGraph";
|
|
6
|
+
|
|
7
|
+
// React Flow v12 requires the Node generic to carry the data shape.
|
|
8
|
+
type TriggerNodeType = Node<{ data: TriggerNodeData }>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The fixed head of the chain (SecretTargetNode recipe). Source handle only:
|
|
12
|
+
* people enter here. Plain element, no Base UI inside the node; the click that
|
|
13
|
+
* opens EmailAutomationEditor is handled by the canvas (`onNodeClick`).
|
|
14
|
+
*/
|
|
15
|
+
export function TriggerNode({ data }: NodeProps<TriggerNodeType>) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="bg-foreground text-background flex w-[200px] cursor-pointer items-center justify-center gap-1 rounded-full px-4 py-2 text-center shadow-md">
|
|
18
|
+
<ZapIcon className="size-3 shrink-0 opacity-60" />
|
|
19
|
+
<div className="truncate text-sm font-medium">{data.data.label}</div>
|
|
20
|
+
<Handle type="source" position={Position.Right} id={TRIGGER_SOURCE_HANDLE} />
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useReactFlow } from "@xyflow/react";
|
|
4
|
+
import { ListRestartIcon, MaximizeIcon, MinusIcon, PlusIcon } from "lucide-react";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { Button } from "../../../../../shadcnui";
|
|
7
|
+
|
|
8
|
+
export function CanvasToolbar({ onTidy }: { onTidy: () => void | Promise<void> }) {
|
|
9
|
+
const t = useTranslations();
|
|
10
|
+
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div data-help="canvas.toolbar" className="absolute end-2 top-2 z-10 flex flex-col gap-1 max-md:top-14">
|
|
14
|
+
<Button
|
|
15
|
+
type="button"
|
|
16
|
+
variant="outline"
|
|
17
|
+
size="icon"
|
|
18
|
+
onClick={() => zoomIn()}
|
|
19
|
+
aria-label={t("canvas.toolbar.zoomIn")}
|
|
20
|
+
>
|
|
21
|
+
<PlusIcon className="h-4 w-4" />
|
|
22
|
+
</Button>
|
|
23
|
+
<Button
|
|
24
|
+
type="button"
|
|
25
|
+
variant="outline"
|
|
26
|
+
size="icon"
|
|
27
|
+
onClick={() => zoomOut()}
|
|
28
|
+
aria-label={t("canvas.toolbar.zoomOut")}
|
|
29
|
+
>
|
|
30
|
+
<MinusIcon className="h-4 w-4" />
|
|
31
|
+
</Button>
|
|
32
|
+
<Button
|
|
33
|
+
type="button"
|
|
34
|
+
variant="outline"
|
|
35
|
+
size="icon"
|
|
36
|
+
onClick={() => fitView({ duration: 300 })}
|
|
37
|
+
aria-label={t("canvas.toolbar.fitView")}
|
|
38
|
+
>
|
|
39
|
+
<MaximizeIcon className="h-4 w-4" />
|
|
40
|
+
</Button>
|
|
41
|
+
<Button
|
|
42
|
+
type="button"
|
|
43
|
+
variant="outline"
|
|
44
|
+
size="icon"
|
|
45
|
+
onClick={() => void onTidy()}
|
|
46
|
+
title={t("canvas.toolbar.tidyTooltip")}
|
|
47
|
+
aria-label={t("canvas.toolbar.tidy")}
|
|
48
|
+
>
|
|
49
|
+
<ListRestartIcon className="h-4 w-4" />
|
|
50
|
+
</Button>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
export function EmptyCanvas({ children }: { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="text-muted-foreground flex h-full w-full items-center justify-center px-6 text-center text-sm">
|
|
8
|
+
{children}
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { autoLayout, LayoutEdge, LayoutNode } from "../autoLayout";
|
|
3
|
+
|
|
4
|
+
describe("autoLayout", () => {
|
|
5
|
+
it("returns a position for every node", () => {
|
|
6
|
+
const nodes: LayoutNode[] = [
|
|
7
|
+
{ id: "a", width: 200, height: 120, manuallyPositioned: false },
|
|
8
|
+
{ id: "b", width: 200, height: 120, manuallyPositioned: false },
|
|
9
|
+
];
|
|
10
|
+
const edges: LayoutEdge[] = [{ source: "a", target: "b" }];
|
|
11
|
+
const result = autoLayout(nodes, edges);
|
|
12
|
+
expect(Object.keys(result).sort()).toEqual(["a", "b"]);
|
|
13
|
+
expect(typeof result.a.x).toBe("number");
|
|
14
|
+
expect(typeof result.b.x).toBe("number");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("respects manuallyPositioned: keeps pinned x/y verbatim", () => {
|
|
18
|
+
const nodes: LayoutNode[] = [
|
|
19
|
+
{ id: "a", width: 200, height: 120, manuallyPositioned: true, x: 999, y: 888 },
|
|
20
|
+
{ id: "b", width: 200, height: 120, manuallyPositioned: false },
|
|
21
|
+
];
|
|
22
|
+
const edges: LayoutEdge[] = [{ source: "a", target: "b" }];
|
|
23
|
+
const result = autoLayout(nodes, edges);
|
|
24
|
+
expect(result.a).toEqual({ x: 999, y: 888 });
|
|
25
|
+
expect(result.b).not.toEqual({ x: 999, y: 888 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is deterministic on identical input", () => {
|
|
29
|
+
const nodes: LayoutNode[] = [
|
|
30
|
+
{ id: "a", width: 200, height: 120, manuallyPositioned: false },
|
|
31
|
+
{ id: "b", width: 200, height: 120, manuallyPositioned: false },
|
|
32
|
+
{ id: "c", width: 200, height: 120, manuallyPositioned: false },
|
|
33
|
+
];
|
|
34
|
+
const edges: LayoutEdge[] = [
|
|
35
|
+
{ source: "a", target: "b" },
|
|
36
|
+
{ source: "b", target: "c" },
|
|
37
|
+
];
|
|
38
|
+
const r1 = autoLayout(nodes, edges);
|
|
39
|
+
const r2 = autoLayout(nodes, edges);
|
|
40
|
+
expect(r1).toEqual(r2);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createLongPressTracker } from "../longPress";
|
|
3
|
+
|
|
4
|
+
describe("createLongPressTracker", () => {
|
|
5
|
+
beforeEach(() => vi.useFakeTimers());
|
|
6
|
+
afterEach(() => vi.useRealTimers());
|
|
7
|
+
|
|
8
|
+
it("fires after the hold delay with the press point", () => {
|
|
9
|
+
const onLongPress = vi.fn();
|
|
10
|
+
const tracker = createLongPressTracker({ onLongPress });
|
|
11
|
+
tracker.down("scene:1", 40, 50);
|
|
12
|
+
vi.advanceTimersByTime(499);
|
|
13
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
14
|
+
vi.advanceTimersByTime(1);
|
|
15
|
+
expect(onLongPress).toHaveBeenCalledWith({ target: "scene:1", clientX: 40, clientY: 50 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does not fire when the finger lifts early", () => {
|
|
19
|
+
const onLongPress = vi.fn();
|
|
20
|
+
const tracker = createLongPressTracker({ onLongPress });
|
|
21
|
+
tracker.down("scene:1", 0, 0);
|
|
22
|
+
vi.advanceTimersByTime(300);
|
|
23
|
+
tracker.up();
|
|
24
|
+
vi.advanceTimersByTime(500);
|
|
25
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
26
|
+
expect(tracker.consumeClick()).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("tolerates jitter but cancels on a real move (pan or drag)", () => {
|
|
30
|
+
const onLongPress = vi.fn();
|
|
31
|
+
const tracker = createLongPressTracker({ onLongPress });
|
|
32
|
+
tracker.down("scene:1", 0, 0);
|
|
33
|
+
tracker.move(5, 5);
|
|
34
|
+
vi.advanceTimersByTime(500);
|
|
35
|
+
expect(onLongPress).toHaveBeenCalledTimes(1);
|
|
36
|
+
|
|
37
|
+
tracker.down("scene:1", 0, 0);
|
|
38
|
+
tracker.move(20, 0);
|
|
39
|
+
vi.advanceTimersByTime(500);
|
|
40
|
+
expect(onLongPress).toHaveBeenCalledTimes(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("swallows exactly one click after a fired press", () => {
|
|
44
|
+
const tracker = createLongPressTracker({ onLongPress: () => {} });
|
|
45
|
+
tracker.down("scene:1", 0, 0);
|
|
46
|
+
vi.advanceTimersByTime(500);
|
|
47
|
+
tracker.up();
|
|
48
|
+
expect(tracker.consumeClick()).toBe(true);
|
|
49
|
+
expect(tracker.consumeClick()).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import dagre from "dagre";
|
|
2
|
+
|
|
3
|
+
export type LayoutNode = {
|
|
4
|
+
id: string;
|
|
5
|
+
/** node body dimensions in canvas px — used by dagre to allocate space */
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
/** if true, dagre leaves position alone and uses these (x, y) verbatim */
|
|
9
|
+
manuallyPositioned: boolean;
|
|
10
|
+
x?: number;
|
|
11
|
+
y?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type LayoutEdge = {
|
|
15
|
+
source: string;
|
|
16
|
+
target: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type LayoutResult = Record<string, { x: number; y: number }>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Deterministic LR layout. Pinned nodes keep their (x, y). Unpinned nodes
|
|
23
|
+
* are laid out by dagre and returned in the result map.
|
|
24
|
+
*/
|
|
25
|
+
export function autoLayout(nodes: LayoutNode[], edges: LayoutEdge[]): LayoutResult {
|
|
26
|
+
const g = new dagre.graphlib.Graph();
|
|
27
|
+
g.setGraph({ rankdir: "LR", nodesep: 60, ranksep: 100, marginx: 24, marginy: 24 });
|
|
28
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
29
|
+
|
|
30
|
+
for (const n of nodes) {
|
|
31
|
+
g.setNode(n.id, { width: n.width, height: n.height });
|
|
32
|
+
}
|
|
33
|
+
for (const e of edges) {
|
|
34
|
+
if (g.hasNode(e.source) && g.hasNode(e.target)) {
|
|
35
|
+
g.setEdge(e.source, e.target);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
dagre.layout(g);
|
|
40
|
+
|
|
41
|
+
const result: LayoutResult = {};
|
|
42
|
+
for (const n of nodes) {
|
|
43
|
+
if (n.manuallyPositioned && typeof n.x === "number" && typeof n.y === "number") {
|
|
44
|
+
result[n.id] = { x: n.x, y: n.y };
|
|
45
|
+
} else {
|
|
46
|
+
const laid = g.node(n.id);
|
|
47
|
+
// dagre returns center coords; React Flow expects top-left, so subtract half dims.
|
|
48
|
+
result[n.id] = {
|
|
49
|
+
x: laid.x - n.width / 2,
|
|
50
|
+
y: laid.y - n.height / 2,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const LONG_PRESS_MS = 500;
|
|
2
|
+
export const LONG_PRESS_MOVE_TOLERANCE_PX = 10;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Touch long-press detector for canvas nodes. A finger held still for
|
|
6
|
+
* LONG_PRESS_MS on a node fires `onLongPress` with the press point; any move
|
|
7
|
+
* beyond the tolerance (a pan or a node drag), a lift or a cancel aborts it.
|
|
8
|
+
* After a fired press, `consumeClick()` returns true once, so the caller can
|
|
9
|
+
* swallow the click that the finger lift produces.
|
|
10
|
+
*/
|
|
11
|
+
export function createLongPressTracker(params: {
|
|
12
|
+
onLongPress: (press: { target: string; clientX: number; clientY: number }) => void;
|
|
13
|
+
delayMs?: number;
|
|
14
|
+
moveTolerancePx?: number;
|
|
15
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
16
|
+
clearTimer?: (handle: unknown) => void;
|
|
17
|
+
}) {
|
|
18
|
+
const delay = params.delayMs ?? LONG_PRESS_MS;
|
|
19
|
+
const tolerance = params.moveTolerancePx ?? LONG_PRESS_MOVE_TOLERANCE_PX;
|
|
20
|
+
const setTimer = params.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
|
|
21
|
+
const clearTimer = params.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
22
|
+
|
|
23
|
+
let timer: unknown = null;
|
|
24
|
+
let start: { x: number; y: number } | null = null;
|
|
25
|
+
let fired = false;
|
|
26
|
+
|
|
27
|
+
const cancel = () => {
|
|
28
|
+
if (timer !== null) clearTimer(timer);
|
|
29
|
+
timer = null;
|
|
30
|
+
start = null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
down(target: string, clientX: number, clientY: number) {
|
|
35
|
+
cancel();
|
|
36
|
+
fired = false;
|
|
37
|
+
start = { x: clientX, y: clientY };
|
|
38
|
+
timer = setTimer(() => {
|
|
39
|
+
timer = null;
|
|
40
|
+
start = null;
|
|
41
|
+
fired = true;
|
|
42
|
+
params.onLongPress({ target, clientX, clientY });
|
|
43
|
+
}, delay);
|
|
44
|
+
},
|
|
45
|
+
move(clientX: number, clientY: number) {
|
|
46
|
+
if (!start) return;
|
|
47
|
+
if (Math.hypot(clientX - start.x, clientY - start.y) > tolerance) cancel();
|
|
48
|
+
},
|
|
49
|
+
up: cancel,
|
|
50
|
+
cancel,
|
|
51
|
+
consumeClick(): boolean {
|
|
52
|
+
const wasFired = fired;
|
|
53
|
+
fired = false;
|
|
54
|
+
return wasFired;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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 { usePageUrlGenerator } from "../../../../hooks";
|
|
8
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
9
|
+
import { EmailAutomationProvider, useEmailAutomation } from "../../contexts/EmailAutomationContext";
|
|
10
|
+
import { EmailAutomationActiveSwitch } from "../canvas/EmailAutomationActiveSwitch";
|
|
11
|
+
import { EmailAutomationCanvasTab } from "../canvas/EmailAutomationCanvasTab";
|
|
12
|
+
import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
|
|
13
|
+
import { EmailAutomationRecipientsList } from "../lists/EmailAutomationRecipientsList";
|
|
14
|
+
|
|
15
|
+
export type EmailAutomationContainerProps = {
|
|
16
|
+
automationId: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function EmailAutomationContainer({ automationId }: EmailAutomationContainerProps) {
|
|
20
|
+
return (
|
|
21
|
+
<EmailAutomationProvider automationId={automationId}>
|
|
22
|
+
<EmailAutomationShell />
|
|
23
|
+
</EmailAutomationProvider>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function EmailAutomationShell() {
|
|
28
|
+
const t = useTranslations();
|
|
29
|
+
const generateUrl = usePageUrlGenerator();
|
|
30
|
+
const { automation, reload } = useEmailAutomation();
|
|
31
|
+
|
|
32
|
+
if (!automation) return null;
|
|
33
|
+
|
|
34
|
+
const breadcrumbs: BreadcrumbItemData[] = [
|
|
35
|
+
{ name: t("emailAutomations.admin.title"), href: generateUrl({ page: Modules.EmailAutomation }) },
|
|
36
|
+
{ name: automation.name },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// The page chrome (title + header functions) is published here rather than by
|
|
40
|
+
// AdministrationProvider: SharedProvider replaces its value for the subtree,
|
|
41
|
+
// so this one carries the administration title type plus the automation name.
|
|
42
|
+
return (
|
|
43
|
+
<SharedProvider
|
|
44
|
+
value={{
|
|
45
|
+
breadcrumbs,
|
|
46
|
+
title: {
|
|
47
|
+
type: t("emailAutomations.admin.title"),
|
|
48
|
+
element: automation.name,
|
|
49
|
+
functions: [
|
|
50
|
+
<EmailAutomationActiveSwitch key="emailAutomationActiveSwitch" />,
|
|
51
|
+
<EmailAutomationEditor key="emailAutomationEditor" automation={automation} onSuccess={reload} />,
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<RoundPageContainer
|
|
57
|
+
module={Modules.EmailAutomation}
|
|
58
|
+
id={automation.id}
|
|
59
|
+
fullWidth
|
|
60
|
+
forceHeader
|
|
61
|
+
// The canvas is sized h-full: it needs the viewport-bound shell and a
|
|
62
|
+
// fillHeight tab, or it resolves to zero height.
|
|
63
|
+
scroll="fixed"
|
|
64
|
+
tabs={[
|
|
65
|
+
{
|
|
66
|
+
sectionKey: "canvas",
|
|
67
|
+
label: t("emailAutomations.tabs.canvas"),
|
|
68
|
+
content: <EmailAutomationCanvasTab />,
|
|
69
|
+
fillHeight: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
sectionKey: "recipients",
|
|
73
|
+
label: t("emailAutomations.tabs.recipients"),
|
|
74
|
+
content: <EmailAutomationRecipientsList />,
|
|
75
|
+
},
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
</SharedProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { RoundPageContainer } from "../../../../components";
|
|
4
|
+
import { Modules } from "../../../../core";
|
|
5
|
+
import { AdministrationProvider } from "../../../administration/contexts/AdministrationContext";
|
|
6
|
+
import { EmailAutomationsList } from "../lists/EmailAutomationsList";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Page container for the administrative email-automation list.
|
|
10
|
+
*
|
|
11
|
+
* Client component by necessity: `module={Modules.EmailAutomation}` is a
|
|
12
|
+
* registry entry carrying an icon component and methods, which a Server
|
|
13
|
+
* Component cannot pass across the boundary. Routes mount this container
|
|
14
|
+
* instead — the same shape as every other list page.
|
|
15
|
+
*
|
|
16
|
+
* No `forceHeader`: ContentListTable draws its own header row with the title
|
|
17
|
+
* and the create action, exactly as the company and user list pages do.
|
|
18
|
+
*/
|
|
19
|
+
export function EmailAutomationsListContainer() {
|
|
20
|
+
return (
|
|
21
|
+
<AdministrationProvider>
|
|
22
|
+
<RoundPageContainer module={Modules.EmailAutomation} fullWidth>
|
|
23
|
+
<EmailAutomationsList fullWidth />
|
|
24
|
+
</RoundPageContainer>
|
|
25
|
+
</AdministrationProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|