@carlonicora/nextjs-jsonapi 5.2.0 → 5.4.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-4YCAIEPT.js} +23 -22
- package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
- package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
- package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
- package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
- package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
- package/dist/{BlockNoteViewer-I2MMFH3S.js.map → BlockNoteViewer-YL3J7J2Y.js.map} +1 -1
- package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
- package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
- package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
- package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
- package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
- package/dist/{chunk-3VEQ5JZJ.js → chunk-3NLAXVIN.js} +860 -849
- package/dist/{chunk-3VEQ5JZJ.js.map → chunk-3NLAXVIN.js.map} +1 -1
- package/dist/{chunk-YMUPTXOT.mjs → chunk-3YW35YHQ.mjs} +1 -101
- package/dist/chunk-3YW35YHQ.mjs.map +1 -0
- package/dist/{chunk-EXICQC4A.js → chunk-5PPG6PL7.js} +3 -3
- package/dist/{chunk-EXICQC4A.js.map → chunk-5PPG6PL7.js.map} +1 -1
- package/dist/{chunk-NPKFMRH5.mjs → chunk-6DC2WVVB.mjs} +2 -2
- package/dist/{chunk-2Z3ZCYXX.mjs → chunk-EUKTBOI2.mjs} +19 -8
- package/dist/chunk-EUKTBOI2.mjs.map +1 -0
- package/dist/{chunk-THKYH65N.js → chunk-KSYQEKJX.js} +11 -111
- package/dist/chunk-KSYQEKJX.js.map +1 -0
- package/dist/{chunk-S5MQDWWC.js → chunk-NFBOSPNJ.js} +1451 -176
- package/dist/chunk-NFBOSPNJ.js.map +1 -0
- package/dist/{chunk-JEZQTZQ4.mjs → chunk-QMYQRPQC.mjs} +2 -2
- package/dist/{chunk-HNOWSFM5.mjs → chunk-WC63PJSC.mjs} +1278 -3
- package/dist/chunk-WC63PJSC.mjs.map +1 -0
- package/dist/chunk-XCHSX3VC.mjs +107 -0
- package/dist/chunk-XCHSX3VC.mjs.map +1 -0
- package/dist/{chunk-75MLT74B.js → chunk-YNCQSWQW.js} +8 -8
- package/dist/{chunk-75MLT74B.js.map → chunk-YNCQSWQW.js.map} +1 -1
- package/dist/chunk-YW4ZCEWB.js +107 -0
- package/dist/chunk-YW4ZCEWB.js.map +1 -0
- 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 +15 -4
- package/dist/components/index.d.ts +15 -4
- 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 +400 -3
- package/dist/core/index.d.ts +400 -3
- package/dist/core/index.js +64 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +63 -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 +6 -5
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +4 -3
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.css +30 -0
- package/dist/features/blog/index.css.map +1 -0
- package/dist/features/blog/index.d.mts +266 -0
- package/dist/features/blog/index.d.ts +266 -0
- package/dist/features/blog/index.js +1799 -0
- package/dist/features/blog/index.js.map +1 -0
- package/dist/features/blog/index.mjs +1799 -0
- package/dist/features/blog/index.mjs.map +1 -0
- package/dist/features/blog/server.d.mts +33 -0
- package/dist/features/blog/server.d.ts +33 -0
- package/dist/features/blog/server.js +139 -0
- package/dist/features/blog/server.js.map +1 -0
- package/dist/features/blog/server.mjs +139 -0
- package/dist/features/blog/server.mjs.map +1 -0
- 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 +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +65 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -2
- package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
- package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
- package/dist/server/index.d.mts +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +8 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +12 -10
- package/dist/types-70acd-8C.d.mts +39 -0
- package/dist/types-l8SFleXi.d.ts +39 -0
- package/messages/en.json +210 -0
- package/messages/it.json +210 -0
- package/package.json +21 -1
- package/src/components/editors/BlockNoteEditor.tsx +13 -3
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/src/components/forms/FormFieldWrapper.tsx +1 -1
- package/src/components/forms/FormTextarea.tsx +2 -1
- package/src/components/navigations/ContentTitle.tsx +14 -3
- package/src/core/index.ts +12 -0
- package/src/core/registry/ModuleRegistry.ts +11 -0
- package/src/email-automations/index.ts +5 -0
- package/src/features/blog/BlogModules.ts +49 -0
- package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -0
- package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
- package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
- package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
- package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
- package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
- package/src/features/blog/components/admin/BlogPostContainer.tsx +102 -0
- package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
- package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
- package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
- package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -0
- package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
- package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
- package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
- package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
- package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
- package/src/features/blog/components/admin/index.ts +14 -0
- package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
- package/src/features/blog/components/public/BlogList.tsx +21 -0
- package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
- package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
- package/src/features/blog/components/public/BlogListParts.tsx +74 -0
- package/src/features/blog/components/public/BlogListRail.tsx +62 -0
- package/src/features/blog/components/public/BlogPostCard.tsx +132 -0
- package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
- package/src/features/blog/components/public/BlogPostView.tsx +74 -0
- package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -0
- package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
- package/src/features/blog/components/public/index.ts +3 -0
- package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
- package/src/features/blog/contexts/index.ts +1 -0
- package/src/features/blog/data/BlogCategory.ts +93 -0
- package/src/features/blog/data/BlogCategoryFields.ts +4 -0
- package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
- package/src/features/blog/data/BlogCategoryService.ts +54 -0
- package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
- package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
- package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
- package/src/features/blog/data/BlogPost.spec.ts +123 -0
- package/src/features/blog/data/BlogPost.ts +159 -0
- package/src/features/blog/data/BlogPostFields.ts +7 -0
- package/src/features/blog/data/BlogPostInterface.ts +39 -0
- package/src/features/blog/data/BlogPostService.ts +82 -0
- package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
- package/src/features/blog/data/BlogPostTranslation.ts +110 -0
- package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
- package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
- package/src/features/blog/data/BlogSettings.ts +37 -0
- package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
- package/src/features/blog/data/BlogSettingsService.ts +24 -0
- package/src/features/blog/data/index.ts +17 -0
- package/src/features/blog/hooks/index.ts +3 -0
- package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
- package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
- package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
- package/src/features/blog/index.ts +15 -0
- package/src/features/blog/server/fetchers.ts +120 -0
- package/src/features/blog/server/toPublic.spec.ts +91 -0
- package/src/features/blog/server/toPublic.ts +36 -0
- package/src/features/blog/server-entry.ts +17 -0
- package/src/features/blog/types/blog-public.types.ts +34 -0
- package/src/features/blog/types/blog.constants.ts +40 -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/src/features/s3/data/s3.service.ts +17 -0
- package/dist/BlockNoteEditor-2QJVK2HH.mjs.map +0 -1
- package/dist/BlockNoteEditor-EGY4ZAAM.js.map +0 -1
- package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
- package/dist/chunk-HNOWSFM5.mjs.map +0 -1
- package/dist/chunk-S5MQDWWC.js.map +0 -1
- package/dist/chunk-THKYH65N.js.map +0 -1
- package/dist/chunk-YMUPTXOT.mjs.map +0 -1
- /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
- /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
- /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Modules } from "../../../core";
|
|
7
|
+
import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
|
|
8
|
+
import { useI18nLocale } from "../../../i18n";
|
|
9
|
+
import { Badge, Link } from "../../../shadcnui";
|
|
10
|
+
import { BLOG_STATUS_BADGE } from "../components/admin/BlogTranslationStatus";
|
|
11
|
+
import { useBlogCategoryNames } from "./useBlogCategoryNames";
|
|
12
|
+
import { BlogPostFields, BlogPostInterface } from "../data";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Admin posts table: title (original language, links to the post page),
|
|
16
|
+
* status, categories, published date and "languages ready" (completed
|
|
17
|
+
* translations / all translations). Mirrors hooks/useEmailAutomationTableStructure.tsx
|
|
18
|
+
* in features/email-automation.
|
|
19
|
+
*/
|
|
20
|
+
export const useBlogPostTableStructure: UseTableStructureHook<BlogPostInterface, BlogPostFields> = (params) => {
|
|
21
|
+
const t = useTranslations();
|
|
22
|
+
const locale = useI18nLocale();
|
|
23
|
+
const generateUrl = usePageUrlGenerator();
|
|
24
|
+
const categoryName = useBlogCategoryNames();
|
|
25
|
+
|
|
26
|
+
const tableData = useMemo(() => {
|
|
27
|
+
return params.data.map((post: BlogPostInterface) => {
|
|
28
|
+
const entry: TableContent<BlogPostInterface> = { jsonApiData: post };
|
|
29
|
+
params.fields.forEach((field) => {
|
|
30
|
+
entry[field] = (post as any)[field as keyof BlogPostInterface];
|
|
31
|
+
});
|
|
32
|
+
return entry;
|
|
33
|
+
});
|
|
34
|
+
}, [params.data, params.fields]);
|
|
35
|
+
|
|
36
|
+
const fieldColumnMap: Partial<Record<BlogPostFields, () => any>> = {
|
|
37
|
+
[BlogPostFields.title]: () => ({
|
|
38
|
+
id: BlogPostFields.title,
|
|
39
|
+
accessorKey: BlogPostFields.title,
|
|
40
|
+
header: t("blog.fields.title"),
|
|
41
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
42
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
43
|
+
return (
|
|
44
|
+
<Link href={generateUrl({ page: Modules.BlogPost, id: post.id })}>
|
|
45
|
+
{post.original?.title ?? post.title ?? post.id}
|
|
46
|
+
</Link>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
enableSorting: false,
|
|
50
|
+
enableHiding: false,
|
|
51
|
+
}),
|
|
52
|
+
[BlogPostFields.status]: () => ({
|
|
53
|
+
id: BlogPostFields.status,
|
|
54
|
+
accessorKey: BlogPostFields.status,
|
|
55
|
+
header: t("blog.fields.status"),
|
|
56
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
57
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
58
|
+
const label = {
|
|
59
|
+
draft: t("blog.status.draft"),
|
|
60
|
+
published: t("blog.status.published"),
|
|
61
|
+
unpublished: t("blog.status.unpublished"),
|
|
62
|
+
}[post.status];
|
|
63
|
+
return <Badge variant={BLOG_STATUS_BADGE[post.status] ?? "softGray"}>{label ?? post.status}</Badge>;
|
|
64
|
+
},
|
|
65
|
+
enableSorting: false,
|
|
66
|
+
enableHiding: false,
|
|
67
|
+
}),
|
|
68
|
+
[BlogPostFields.categories]: () => ({
|
|
69
|
+
id: BlogPostFields.categories,
|
|
70
|
+
accessorKey: BlogPostFields.categories,
|
|
71
|
+
header: t("blog.fields.categories"),
|
|
72
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
73
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
74
|
+
const names = post.categories.map((category) => categoryName(category.id)).filter(Boolean);
|
|
75
|
+
if (names.length === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
|
76
|
+
return <span className="text-xs">{names.join(", ")}</span>;
|
|
77
|
+
},
|
|
78
|
+
enableSorting: false,
|
|
79
|
+
enableHiding: false,
|
|
80
|
+
}),
|
|
81
|
+
[BlogPostFields.publishedAt]: () => ({
|
|
82
|
+
id: BlogPostFields.publishedAt,
|
|
83
|
+
accessorKey: BlogPostFields.publishedAt,
|
|
84
|
+
header: t("blog.fields.publishedAt"),
|
|
85
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
86
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
87
|
+
if (!post.publishedAt) return <span className="text-muted-foreground text-xs">—</span>;
|
|
88
|
+
return (
|
|
89
|
+
<span className="text-xs tabular-nums">
|
|
90
|
+
{post.publishedAt.toLocaleDateString(locale, { dateStyle: "medium" })}
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
enableSorting: false,
|
|
95
|
+
enableHiding: false,
|
|
96
|
+
}),
|
|
97
|
+
[BlogPostFields.languages]: () => ({
|
|
98
|
+
id: BlogPostFields.languages,
|
|
99
|
+
accessorKey: BlogPostFields.languages,
|
|
100
|
+
header: t("blog.fields.languages"),
|
|
101
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
102
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
103
|
+
const total = post.translations.length;
|
|
104
|
+
if (total === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
|
105
|
+
const ready = post.translations.filter((translation) => translation.aiStatus === "completed").length;
|
|
106
|
+
return (
|
|
107
|
+
<span className="block text-xs tabular-nums text-end">
|
|
108
|
+
{ready}/{total}
|
|
109
|
+
</span>
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
enableSorting: false,
|
|
113
|
+
enableHiding: false,
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const columns = useMemo(() => {
|
|
118
|
+
return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
|
|
119
|
+
TableContent<BlogPostInterface>
|
|
120
|
+
>[];
|
|
121
|
+
}, [params.fields, fieldColumnMap, t, generateUrl, locale, categoryName]);
|
|
122
|
+
|
|
123
|
+
return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
registerTableGenerator("blog-posts", useBlogPostTableStructure);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Client-only public barrel for the blog feature.
|
|
2
|
+
// Consumed via `@carlonicora/nextjs-jsonapi/blog`. Tsup adds a top-level
|
|
3
|
+
// "use client" directive to the bundled output via clientEntries.
|
|
4
|
+
//
|
|
5
|
+
// Models, services and modules are server-safe and ship from
|
|
6
|
+
// `@carlonicora/nextjs-jsonapi/core`. Server fetchers ship from
|
|
7
|
+
// `@carlonicora/nextjs-jsonapi/blog/server`.
|
|
8
|
+
|
|
9
|
+
export * from "./components/admin";
|
|
10
|
+
export * from "./components/public";
|
|
11
|
+
export * from "./contexts";
|
|
12
|
+
// Imported for its registerTableGenerator side effect as well as its exports.
|
|
13
|
+
export * from "./hooks";
|
|
14
|
+
export * from "./types/blog.constants";
|
|
15
|
+
export type { PublicBlogCategory, PublicBlogList, PublicBlogPost } from "./types/blog-public.types";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { EndpointCreator, Modules } from "../../../core";
|
|
2
|
+
import { ServerJsonApiGet } from "../../../server/JsonApiServer";
|
|
3
|
+
import { BlogCategoryInterface } from "../data/BlogCategoryInterface";
|
|
4
|
+
import { BlogPostInterface } from "../data/BlogPostInterface";
|
|
5
|
+
import { BlogSettingsInterface } from "../data/BlogSettingsInterface";
|
|
6
|
+
import { BLOG_ACTION, BLOG_LIST_LAYOUTS, BLOG_PUBLIC_PREFIX, BlogListLayout } from "../types/blog.constants";
|
|
7
|
+
import { PublicBlogCategory, PublicBlogList, PublicBlogPost } from "../types/blog-public.types";
|
|
8
|
+
import { toPublicBlogCategory, toPublicBlogPost } from "./toPublic";
|
|
9
|
+
|
|
10
|
+
// Public, token-free reads for the marketing blog pages.
|
|
11
|
+
//
|
|
12
|
+
// `cache: "minutes"` is a cacheLife profile name: the GET goes through
|
|
13
|
+
// cachedServerGet ("use cache"), so an admin change shows up within about a
|
|
14
|
+
// minute. Nothing connects API or worker writes to the web cache, and
|
|
15
|
+
// translations finish asynchronously in the worker, so there is no tag to expire.
|
|
16
|
+
//
|
|
17
|
+
// Each classKey is built INSIDE the function, never at module scope: reading
|
|
18
|
+
// Modules.X while the module evaluates throws "Module X not registered" in build
|
|
19
|
+
// workers that never ran the app's bootstrapper (see the app's publicHowTo.ts).
|
|
20
|
+
|
|
21
|
+
const BLOG_PAGE_SIZE = 12;
|
|
22
|
+
|
|
23
|
+
const publicPostsEndpoint = () => `${BLOG_PUBLIC_PREFIX}/${Modules.BlogPost.name}`;
|
|
24
|
+
|
|
25
|
+
const asArray = <T>(data: unknown): T[] => (Array.isArray(data) ? data : data ? [data] : []) as T[];
|
|
26
|
+
|
|
27
|
+
export async function fetchPublicBlogPosts(params: {
|
|
28
|
+
language: string;
|
|
29
|
+
categorySlug?: string;
|
|
30
|
+
search?: string;
|
|
31
|
+
page: number;
|
|
32
|
+
}): Promise<PublicBlogList> {
|
|
33
|
+
// Matches the API cap (120 = 10 pages of 12); a larger ?page= is served as the last allowed one.
|
|
34
|
+
const page = Math.min(10, Math.max(1, Math.floor(params.page || 1)));
|
|
35
|
+
const size = BLOG_PAGE_SIZE * page;
|
|
36
|
+
|
|
37
|
+
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
|
|
38
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
39
|
+
if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
|
|
40
|
+
if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
|
|
41
|
+
endpoint.addAdditionalParam("page[size]", String(size));
|
|
42
|
+
|
|
43
|
+
const res = await ServerJsonApiGet({
|
|
44
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
45
|
+
endpoint: endpoint.generate(),
|
|
46
|
+
language: params.language,
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) return { posts: [], hasMore: false };
|
|
49
|
+
|
|
50
|
+
const posts = asArray<BlogPostInterface>(res.data).map(toPublicBlogPost);
|
|
51
|
+
const total = Number(res.meta?.total);
|
|
52
|
+
const hasMore = Number.isFinite(total) ? total > posts.length : false;
|
|
53
|
+
|
|
54
|
+
return { posts, hasMore };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function fetchPublicBlogPost(params: { language: string; slug: string }): Promise<PublicBlogPost | null> {
|
|
58
|
+
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint(), id: encodeURIComponent(params.slug) });
|
|
59
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
60
|
+
|
|
61
|
+
const res = await ServerJsonApiGet({
|
|
62
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
63
|
+
endpoint: endpoint.generate(),
|
|
64
|
+
language: params.language,
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) return null;
|
|
67
|
+
|
|
68
|
+
const post = asArray<BlogPostInterface>(res.data)[0];
|
|
69
|
+
return post ? toPublicBlogPost(post) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function fetchRelatedBlogPosts(params: { language: string; slug: string }): Promise<PublicBlogPost[]> {
|
|
73
|
+
const endpoint = new EndpointCreator({
|
|
74
|
+
endpoint: publicPostsEndpoint(),
|
|
75
|
+
id: encodeURIComponent(params.slug),
|
|
76
|
+
childEndpoint: BLOG_ACTION.related,
|
|
77
|
+
});
|
|
78
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
79
|
+
|
|
80
|
+
const res = await ServerJsonApiGet({
|
|
81
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
82
|
+
endpoint: endpoint.generate(),
|
|
83
|
+
language: params.language,
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) return [];
|
|
86
|
+
|
|
87
|
+
return asArray<BlogPostInterface>(res.data).map(toPublicBlogPost);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function fetchPublicBlogCategories(params: { language: string }): Promise<PublicBlogCategory[]> {
|
|
91
|
+
const endpoint = new EndpointCreator({ endpoint: `${BLOG_PUBLIC_PREFIX}/${Modules.BlogCategory.name}` });
|
|
92
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
93
|
+
|
|
94
|
+
const res = await ServerJsonApiGet({
|
|
95
|
+
classKey: { ...Modules.BlogCategory, cache: "minutes" },
|
|
96
|
+
endpoint: endpoint.generate(),
|
|
97
|
+
language: params.language,
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) return [];
|
|
100
|
+
|
|
101
|
+
return asArray<BlogCategoryInterface>(res.data).map(toPublicBlogCategory);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function fetchBlogSettings(): Promise<{ listLayout: BlogListLayout }> {
|
|
105
|
+
const fallback: { listLayout: BlogListLayout } = { listLayout: "featured" };
|
|
106
|
+
|
|
107
|
+
// The settings are language-neutral; the language only fills the required
|
|
108
|
+
// request header, so one fixed value keeps a single cache entry.
|
|
109
|
+
const res = await ServerJsonApiGet({
|
|
110
|
+
classKey: { ...Modules.BlogSettings, cache: "minutes" },
|
|
111
|
+
endpoint: new EndpointCreator({ endpoint: `${BLOG_PUBLIC_PREFIX}/${Modules.BlogSettings.name}` }).generate(),
|
|
112
|
+
language: "en",
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) return fallback;
|
|
115
|
+
|
|
116
|
+
const settings = asArray<BlogSettingsInterface>(res.data)[0];
|
|
117
|
+
if (!settings || !BLOG_LIST_LAYOUTS.includes(settings.listLayout)) return fallback;
|
|
118
|
+
|
|
119
|
+
return { listLayout: settings.listLayout };
|
|
120
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { User } from "../../user/data/user";
|
|
3
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
|
|
5
|
+
import {
|
|
6
|
+
BlogCategoryModule,
|
|
7
|
+
BlogCategoryTranslationModule,
|
|
8
|
+
BlogPostModule,
|
|
9
|
+
BlogPostTranslationModule,
|
|
10
|
+
} from "../BlogModules";
|
|
11
|
+
import { BlogCategory } from "../data/BlogCategory";
|
|
12
|
+
import { BlogPost } from "../data/BlogPost";
|
|
13
|
+
import { toPublicBlogCategory, toPublicBlogPost } from "./toPublic";
|
|
14
|
+
|
|
15
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
16
|
+
({
|
|
17
|
+
pageUrl: params.pageUrl,
|
|
18
|
+
name: params.name,
|
|
19
|
+
model: params.model,
|
|
20
|
+
}) as ModuleWithPermissions;
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
ModuleRegistry.register("BlogPost", BlogPostModule(moduleFactory));
|
|
24
|
+
ModuleRegistry.register("BlogPostTranslation", BlogPostTranslationModule(moduleFactory));
|
|
25
|
+
ModuleRegistry.register("BlogCategory", BlogCategoryModule(moduleFactory));
|
|
26
|
+
ModuleRegistry.register("BlogCategoryTranslation", BlogCategoryTranslationModule(moduleFactory));
|
|
27
|
+
ModuleRegistry.register("User", { name: "users", model: User } as ModuleWithPermissions);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const blocks = [{ type: "paragraph", content: [{ type: "text", text: "Hello", styles: {} }] }];
|
|
31
|
+
|
|
32
|
+
const makePost = () =>
|
|
33
|
+
new BlogPost().rehydrate({
|
|
34
|
+
jsonApi: {
|
|
35
|
+
id: "p",
|
|
36
|
+
type: "blog-posts",
|
|
37
|
+
attributes: {
|
|
38
|
+
status: "published",
|
|
39
|
+
originalLanguage: "en",
|
|
40
|
+
publishedAt: "2026-10-12T09:00:00.000Z",
|
|
41
|
+
language: "en",
|
|
42
|
+
title: "Prep situations",
|
|
43
|
+
slug: "prep-situations",
|
|
44
|
+
summary: "What to prepare.",
|
|
45
|
+
body: JSON.stringify(blocks),
|
|
46
|
+
readingMinutes: 4,
|
|
47
|
+
authorName: "Ada",
|
|
48
|
+
categoryIds: ["c1"],
|
|
49
|
+
},
|
|
50
|
+
meta: { createdAt: "2026-10-10T09:00:00.000Z", updatedAt: "2026-10-14T09:00:00.000Z" },
|
|
51
|
+
},
|
|
52
|
+
included: [],
|
|
53
|
+
} as any);
|
|
54
|
+
|
|
55
|
+
describe("toPublicBlogPost", () => {
|
|
56
|
+
it("toPublicBlogPost maps a rehydrated post to plain serialisable data", () => {
|
|
57
|
+
expect(JSON.parse(JSON.stringify(toPublicBlogPost(makePost())))).toEqual(toPublicBlogPost(makePost()));
|
|
58
|
+
expect(toPublicBlogPost(makePost()).publishedAt).toBe("2026-10-12T09:00:00.000Z");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("carries the resolved fields", () => {
|
|
62
|
+
const result = toPublicBlogPost(makePost());
|
|
63
|
+
expect(result).toMatchObject({
|
|
64
|
+
id: "p",
|
|
65
|
+
language: "en",
|
|
66
|
+
title: "Prep situations",
|
|
67
|
+
slug: "prep-situations",
|
|
68
|
+
summary: "What to prepare.",
|
|
69
|
+
readingMinutes: 4,
|
|
70
|
+
updatedAt: "2026-10-14T09:00:00.000Z",
|
|
71
|
+
authorName: "Ada",
|
|
72
|
+
categoryIds: ["c1"],
|
|
73
|
+
});
|
|
74
|
+
expect(result.body).toEqual(blocks);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("toPublicBlogCategory", () => {
|
|
79
|
+
it("maps the resolved name and slug", () => {
|
|
80
|
+
const category = new BlogCategory().rehydrate({
|
|
81
|
+
jsonApi: {
|
|
82
|
+
id: "c1",
|
|
83
|
+
type: "blog-categories",
|
|
84
|
+
attributes: { originalLanguage: "en", name: "Game prep", slug: "game-prep" },
|
|
85
|
+
},
|
|
86
|
+
included: [],
|
|
87
|
+
} as any);
|
|
88
|
+
|
|
89
|
+
expect(toPublicBlogCategory(category)).toEqual({ id: "c1", name: "Game prep", slug: "game-prep" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BlogCategoryInterface } from "../data/BlogCategoryInterface";
|
|
2
|
+
import { BlogPostInterface } from "../data/BlogPostInterface";
|
|
3
|
+
import { PublicBlogCategory, PublicBlogPost } from "../types/blog-public.types";
|
|
4
|
+
|
|
5
|
+
// Serialisation boundary: a rehydrated model (class instance, Date getters)
|
|
6
|
+
// becomes plain data a server page can pass to the public client components.
|
|
7
|
+
|
|
8
|
+
export function toPublicBlogPost(post: BlogPostInterface): PublicBlogPost {
|
|
9
|
+
const publishedAt = post.publishedAt ?? post.createdAt;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
id: post.id,
|
|
13
|
+
language: post.language ?? post.originalLanguage,
|
|
14
|
+
title: post.title ?? "",
|
|
15
|
+
slug: post.slug ?? "",
|
|
16
|
+
summary: post.summary,
|
|
17
|
+
body: post.body,
|
|
18
|
+
coverImage: post.coverImage,
|
|
19
|
+
coverImageAlt: post.coverImageAlt,
|
|
20
|
+
readingMinutes: post.readingMinutes ?? 1,
|
|
21
|
+
publishedAt: publishedAt.toISOString(),
|
|
22
|
+
updatedAt: post.updatedAt.toISOString(),
|
|
23
|
+
authorName: post.authorName,
|
|
24
|
+
categoryIds: [...post.categoryIds],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toPublicBlogCategory(category: BlogCategoryInterface): PublicBlogCategory {
|
|
29
|
+
return {
|
|
30
|
+
id: category.id,
|
|
31
|
+
name: category.name ?? "",
|
|
32
|
+
slug: category.slug ?? "",
|
|
33
|
+
description: category.description,
|
|
34
|
+
image: category.image,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Server-safe public barrel for the blog feature.
|
|
2
|
+
// Consumed via `@carlonicora/nextjs-jsonapi/blog/server`. Must NOT receive a
|
|
3
|
+
// "use client" directive: server pages import the cached public fetchers and
|
|
4
|
+
// the plain view-model types from here without pulling the client bundle.
|
|
5
|
+
// Client components live in `@carlonicora/nextjs-jsonapi/blog`.
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
fetchBlogSettings,
|
|
9
|
+
fetchPublicBlogCategories,
|
|
10
|
+
fetchPublicBlogPost,
|
|
11
|
+
fetchPublicBlogPosts,
|
|
12
|
+
fetchRelatedBlogPosts,
|
|
13
|
+
} from "./server/fetchers";
|
|
14
|
+
export { toPublicBlogCategory, toPublicBlogPost } from "./server/toPublic";
|
|
15
|
+
export { BLOG_LIST_LAYOUTS } from "./types/blog.constants";
|
|
16
|
+
export type { BlogListLayout } from "./types/blog.constants";
|
|
17
|
+
export type { PublicBlogCategory, PublicBlogList, PublicBlogPost } from "./types/blog-public.types";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Plain, serialisable view models handed from server pages to the public
|
|
2
|
+
// components. No class instances and no Date objects: they cross the
|
|
3
|
+
// server → client boundary as props.
|
|
4
|
+
|
|
5
|
+
import type { PartialBlock } from "@blocknote/core";
|
|
6
|
+
|
|
7
|
+
export type PublicBlogCategory = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PublicBlogPost = {
|
|
16
|
+
id: string;
|
|
17
|
+
language: string;
|
|
18
|
+
title: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
summary?: string;
|
|
21
|
+
body?: PartialBlock[];
|
|
22
|
+
coverImage?: string;
|
|
23
|
+
coverImageAlt?: string;
|
|
24
|
+
readingMinutes: number;
|
|
25
|
+
publishedAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
authorName?: string;
|
|
28
|
+
categoryIds: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PublicBlogList = {
|
|
32
|
+
posts: PublicBlogPost[];
|
|
33
|
+
hasMore: boolean;
|
|
34
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Mirrors packages/nestjs-neo4jsonapi/src/foundations/blog/blog.constants.ts and
|
|
2
|
+
// CoreModuleId.Blog. The two packages share no code, so the values are repeated.
|
|
3
|
+
|
|
4
|
+
export const BLOG_LIST_LAYOUTS = ["featured", "column", "rail"] as const;
|
|
5
|
+
export type BlogListLayout = (typeof BLOG_LIST_LAYOUTS)[number];
|
|
6
|
+
|
|
7
|
+
export const BLOG_POST_STATUSES = ["draft", "published", "unpublished"] as const;
|
|
8
|
+
export type BlogPostStatus = (typeof BLOG_POST_STATUSES)[number];
|
|
9
|
+
|
|
10
|
+
export const BLOG_TRANSLATION_SOURCES = ["original", "ai", "manual"] as const;
|
|
11
|
+
export type BlogTranslationSource = (typeof BLOG_TRANSLATION_SOURCES)[number];
|
|
12
|
+
|
|
13
|
+
// Same values as the backend AiStatus enum.
|
|
14
|
+
export type BlogAiStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
15
|
+
|
|
16
|
+
export const BLOG_SETTINGS_ID = "5f3c2a9e-8d41-4b7a-9c2e-1a6b0d4e7f13";
|
|
17
|
+
|
|
18
|
+
export const BLOG_MODULE_ID = "c1d0e6a2-4b8f-4f3e-9a7d-2e5b8c6f1a90";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Storage root for blog images (covers, category images, images pasted into a
|
|
22
|
+
* post body). Blog content belongs to the platform, not to a company, and the
|
|
23
|
+
* administrators who write it may belong to no company, so the keys are
|
|
24
|
+
* `blog/<folder>/<id>/<file>` instead of `companies/<companyId>/...`. An app's
|
|
25
|
+
* S3 key access policy must allow Administrators on this root.
|
|
26
|
+
*/
|
|
27
|
+
export const BLOG_UPLOAD_ROOT = "blog";
|
|
28
|
+
export const BLOG_POST_UPLOAD_PREFIX = `${BLOG_UPLOAD_ROOT}/blog-posts`;
|
|
29
|
+
export const BLOG_CATEGORY_UPLOAD_PREFIX = `${BLOG_UPLOAD_ROOT}/blog-categories`;
|
|
30
|
+
|
|
31
|
+
/** Path prefix of the token-free public blog endpoints (`public/blog-posts`, ...). */
|
|
32
|
+
export const BLOG_PUBLIC_PREFIX = "public";
|
|
33
|
+
|
|
34
|
+
/** Action segments under a blog resource (`blog-posts/:id/publish`, ...). */
|
|
35
|
+
export const BLOG_ACTION = {
|
|
36
|
+
publish: "publish",
|
|
37
|
+
unpublish: "unpublish",
|
|
38
|
+
retranslate: "retranslate",
|
|
39
|
+
related: "related",
|
|
40
|
+
} as const;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { UsersIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationEnrollment } from "./data/EmailAutomationEnrollment";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationEnrollmentModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
pageUrl: undefined,
|
|
8
|
+
name: "email-automation-enrollments",
|
|
9
|
+
model: EmailAutomationEnrollment,
|
|
10
|
+
icon: UsersIcon,
|
|
11
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MailIcon } from "lucide-react";
|
|
2
|
+
import { createJsonApiInclusion } from "../../core";
|
|
3
|
+
import { ModuleFactory } from "../../permissions";
|
|
4
|
+
import { EmailAutomation } from "./data/EmailAutomation";
|
|
5
|
+
|
|
6
|
+
export const EmailAutomationModule = (factory: ModuleFactory) =>
|
|
7
|
+
factory({
|
|
8
|
+
moduleId: "3f6c1a2e-7b94-4d1e-8c55-9a0b2e4f6d71",
|
|
9
|
+
pageUrl: "/administration/email-automations",
|
|
10
|
+
name: "email-automations",
|
|
11
|
+
model: EmailAutomation,
|
|
12
|
+
icon: MailIcon,
|
|
13
|
+
inclusions: {
|
|
14
|
+
lists: {
|
|
15
|
+
fields: [
|
|
16
|
+
createJsonApiInclusion("email-automations", [
|
|
17
|
+
"name",
|
|
18
|
+
"trigger",
|
|
19
|
+
"exitTrigger",
|
|
20
|
+
"replyTo",
|
|
21
|
+
"isActive",
|
|
22
|
+
"stepCount",
|
|
23
|
+
"activeCount",
|
|
24
|
+
"completedCount",
|
|
25
|
+
"unsubscribedCount",
|
|
26
|
+
]),
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MailIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationStep } from "./data/EmailAutomationStep";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationStepModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
pageUrl: undefined,
|
|
8
|
+
name: "email-automation-steps",
|
|
9
|
+
model: EmailAutomationStep,
|
|
10
|
+
icon: MailIcon,
|
|
11
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ZapIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { EmailAutomationTrigger } from "./data/EmailAutomationTrigger";
|
|
4
|
+
|
|
5
|
+
export const EmailAutomationTriggerModule = (factory: ModuleFactory) =>
|
|
6
|
+
factory({
|
|
7
|
+
pageUrl: undefined,
|
|
8
|
+
name: "email-automation-triggers",
|
|
9
|
+
model: EmailAutomationTrigger,
|
|
10
|
+
icon: ZapIcon,
|
|
11
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
2
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { configureI18n } from "../../../../i18n";
|
|
5
|
+
import { EmailAutomationEditor } from "../forms/EmailAutomationEditor";
|
|
6
|
+
|
|
7
|
+
// Hoisted so the vi.mock factories below (which run before the module body)
|
|
8
|
+
// can close over them.
|
|
9
|
+
const mocks = vi.hoisted(() => ({
|
|
10
|
+
findTriggers: vi.fn(),
|
|
11
|
+
create: vi.fn(),
|
|
12
|
+
update: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("../../data/EmailAutomationService", () => ({
|
|
16
|
+
EmailAutomationService: {
|
|
17
|
+
findTriggers: mocks.findTriggers,
|
|
18
|
+
create: mocks.create,
|
|
19
|
+
update: mocks.update,
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Base UI renders a closed Select's options nowhere in the DOM, so FormSelect
|
|
24
|
+
// is replaced by a stub that prints its label, its empty option and its
|
|
25
|
+
// values. Everything else in the barrel stays real.
|
|
26
|
+
vi.mock("../../../../components", async () => {
|
|
27
|
+
const actual = await vi.importActual<typeof import("../../../../components")>("../../../../components");
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
FormSelect: (props: {
|
|
31
|
+
id: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
allowEmpty?: boolean;
|
|
35
|
+
values: { id: string; text: string }[];
|
|
36
|
+
}) => (
|
|
37
|
+
<div data-testid={`form-select-${props.id}`}>
|
|
38
|
+
<span>{props.name}</span>
|
|
39
|
+
{props.allowEmpty && <span data-value="">{props.placeholder}</span>}
|
|
40
|
+
{props.values.map((value) => (
|
|
41
|
+
<span key={value.id} data-value={value.id}>
|
|
42
|
+
{value.text}
|
|
43
|
+
</span>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const TRIGGERS = [
|
|
51
|
+
{ id: "waitlist-confirmed", key: "waitlist-confirmed", label: "Waitlist confirmed" },
|
|
52
|
+
{ id: "registration-completed", key: "registration-completed", label: "Registration completed" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// `Modules.EmailAutomation` resolves through the registry at render time.
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
ModuleRegistry.register("EmailAutomation", {
|
|
58
|
+
name: "email-automations",
|
|
59
|
+
pageUrl: "/administration/email-automations",
|
|
60
|
+
} as any);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
mocks.findTriggers.mockResolvedValue(TRIGGERS);
|
|
66
|
+
configureI18n({
|
|
67
|
+
useRouter: () => ({
|
|
68
|
+
push: vi.fn(),
|
|
69
|
+
replace: vi.fn(),
|
|
70
|
+
back: vi.fn(),
|
|
71
|
+
forward: vi.fn(),
|
|
72
|
+
refresh: vi.fn(),
|
|
73
|
+
prefetch: vi.fn(),
|
|
74
|
+
}),
|
|
75
|
+
useTranslations: () => (key: string) => key,
|
|
76
|
+
Link: ({ children }: any) => children,
|
|
77
|
+
usePathname: () => "/",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("EmailAutomationEditor", () => {
|
|
82
|
+
it("renders the five fields by their labels", async () => {
|
|
83
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
84
|
+
|
|
85
|
+
expect(await screen.findByText("emailAutomations.fields.name.label")).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText("emailAutomations.fields.trigger.label")).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByText("emailAutomations.fields.exitTrigger.label")).toBeInTheDocument();
|
|
88
|
+
expect(screen.getByText("emailAutomations.fields.replyTo.label")).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByText("emailAutomations.fields.isActive.label")).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lists the none option plus the trigger labels returned by the API", async () => {
|
|
93
|
+
render(<EmailAutomationEditor forceShow dialogOpen />);
|
|
94
|
+
|
|
95
|
+
const triggerSelect = await screen.findByTestId("form-select-trigger");
|
|
96
|
+
await waitFor(() => expect(within(triggerSelect).getByText("Waitlist confirmed")).toBeInTheDocument());
|
|
97
|
+
|
|
98
|
+
expect(mocks.findTriggers).toHaveBeenCalled();
|
|
99
|
+
expect(within(triggerSelect).getByText("emailAutomations.fields.trigger.none")).toBeInTheDocument();
|
|
100
|
+
expect(within(triggerSelect).getByText("Registration completed")).toBeInTheDocument();
|
|
101
|
+
// The option values are the trigger keys, the visible text the plain label.
|
|
102
|
+
expect(within(triggerSelect).getByText("Waitlist confirmed")).toHaveAttribute("data-value", "waitlist-confirmed");
|
|
103
|
+
});
|
|
104
|
+
});
|