@carlonicora/nextjs-jsonapi 5.3.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-UJ6363LC.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
- package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
- package/dist/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
- package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
- package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
- package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
- package/dist/{BlockNoteViewer-EL4JFB6P.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-3BAM23VA.js → chunk-3NLAXVIN.js} +860 -849
- package/dist/chunk-3NLAXVIN.js.map +1 -0
- package/dist/{chunk-QKZUE5OF.mjs → chunk-3YW35YHQ.mjs} +1 -101
- package/dist/chunk-3YW35YHQ.mjs.map +1 -0
- package/dist/{chunk-QQQE3N2X.js → chunk-5PPG6PL7.js} +3 -3
- package/dist/{chunk-QQQE3N2X.js.map → chunk-5PPG6PL7.js.map} +1 -1
- package/dist/{chunk-FNTQQFLH.mjs → chunk-6DC2WVVB.mjs} +2 -2
- package/dist/{chunk-MIZTWBPX.mjs → chunk-EUKTBOI2.mjs} +19 -8
- package/dist/chunk-EUKTBOI2.mjs.map +1 -0
- package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
- package/dist/chunk-KSYQEKJX.js.map +1 -0
- package/dist/{chunk-CRI4T47Y.js → chunk-NFBOSPNJ.js} +756 -41
- package/dist/chunk-NFBOSPNJ.js.map +1 -0
- package/dist/{chunk-6WU74VFU.mjs → chunk-QMYQRPQC.mjs} +2 -2
- package/dist/{chunk-JQWI62NV.mjs → chunk-WC63PJSC.mjs} +716 -1
- 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-EO75CV76.js → chunk-YNCQSWQW.js} +8 -8
- package/dist/{chunk-EO75CV76.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.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +13 -2
- package/dist/components/index.d.ts +13 -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 +214 -3
- package/dist/core/index.d.ts +214 -3
- package/dist/core/index.js +36 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +35 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- 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 +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +36 -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/messages/en.json +93 -0
- package/messages/it.json +93 -0
- package/package.json +11 -1
- package/src/components/editors/BlockNoteEditor.tsx +13 -3
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/src/components/navigations/ContentTitle.tsx +14 -3
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +6 -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/s3/data/s3.service.ts +17 -0
- package/dist/BlockNoteEditor-UJ6363LC.js.map +0 -1
- package/dist/BlockNoteEditor-WTSQANKI.mjs.map +0 -1
- package/dist/chunk-3BAM23VA.js.map +0 -1
- package/dist/chunk-CRI4T47Y.js.map +0 -1
- package/dist/chunk-JQWI62NV.mjs.map +0 -1
- package/dist/chunk-MIZTWBPX.mjs.map +0 -1
- package/dist/chunk-QKZUE5OF.mjs.map +0 -1
- package/dist/chunk-XGTQWBDJ.js.map +0 -1
- /package/dist/{BlockNoteViewer-JVKWJZ6J.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
- /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
- /package/dist/{chunk-6WU74VFU.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { configureI18n } from "../../../../../i18n";
|
|
5
|
+
import type { PublicBlogCategory, PublicBlogPost } from "../../../types/blog-public.types";
|
|
6
|
+
import { BlogList } from "../BlogList";
|
|
7
|
+
|
|
8
|
+
// t interpolates its values after the key so assertions can see them.
|
|
9
|
+
vi.mock("next-intl", () => ({
|
|
10
|
+
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
|
11
|
+
values ? `${key} ${Object.values(values).join(" ")}` : key,
|
|
12
|
+
useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("next/image", () => ({
|
|
15
|
+
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const cats: PublicBlogCategory[] = [
|
|
19
|
+
{ id: "c1", name: "Game prep", slug: "game-prep" },
|
|
20
|
+
{ id: "c2", name: "Product", slug: "product" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const post = (overrides: Partial<PublicBlogPost>): PublicBlogPost => ({
|
|
24
|
+
id: "p",
|
|
25
|
+
language: "en",
|
|
26
|
+
title: "Title",
|
|
27
|
+
slug: "title",
|
|
28
|
+
summary: "Summary",
|
|
29
|
+
readingMinutes: 4,
|
|
30
|
+
publishedAt: "2026-10-12T09:00:00.000Z",
|
|
31
|
+
updatedAt: "2026-10-12T09:00:00.000Z",
|
|
32
|
+
authorName: "Carlo Nicora",
|
|
33
|
+
categoryIds: ["c1"],
|
|
34
|
+
...overrides,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const p1 = post({
|
|
38
|
+
id: "p1",
|
|
39
|
+
title: "Prep situations, not plots",
|
|
40
|
+
slug: "prep-situations",
|
|
41
|
+
summary: "Why a scene that can go three ways beats a scene that must go one way.",
|
|
42
|
+
coverImage: "https://cdn.test/p1.webp",
|
|
43
|
+
});
|
|
44
|
+
const p2 = post({
|
|
45
|
+
id: "p2",
|
|
46
|
+
title: "Voice attribution, explained",
|
|
47
|
+
slug: "voice",
|
|
48
|
+
summary: "How it knows who spoke.",
|
|
49
|
+
categoryIds: ["c2"],
|
|
50
|
+
});
|
|
51
|
+
const p3 = post({
|
|
52
|
+
id: "p3",
|
|
53
|
+
title: "Clues that point, not explain",
|
|
54
|
+
slug: "clues",
|
|
55
|
+
summary: "Make every clue a pointer.",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
configureI18n({
|
|
60
|
+
useRouter: () => ({
|
|
61
|
+
push: () => {},
|
|
62
|
+
replace: () => {},
|
|
63
|
+
back: () => {},
|
|
64
|
+
forward: () => {},
|
|
65
|
+
refresh: () => {},
|
|
66
|
+
prefetch: () => {},
|
|
67
|
+
}),
|
|
68
|
+
useTranslations: () => (key: string) => key,
|
|
69
|
+
usePathname: () => "/blog",
|
|
70
|
+
getLocale: async () => "en",
|
|
71
|
+
Link: ({ href, children, prefetch: _prefetch, scroll: _scroll, ...props }: any) => (
|
|
72
|
+
<a href={href} {...props}>
|
|
73
|
+
{children}
|
|
74
|
+
</a>
|
|
75
|
+
),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("BlogList", () => {
|
|
80
|
+
it.each(["featured", "column", "rail"] as const)("%s layout renders every post title and a summary", (layout) => {
|
|
81
|
+
render(
|
|
82
|
+
<BlogList
|
|
83
|
+
layout={layout}
|
|
84
|
+
list={{ posts: [p1, p2, p3], hasMore: false }}
|
|
85
|
+
categories={cats}
|
|
86
|
+
page={1}
|
|
87
|
+
basePath="/blog"
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.getAllByRole("link", { name: /Prep situations/ })[0]).toHaveAttribute(
|
|
92
|
+
"href",
|
|
93
|
+
"/blog/prep-situations",
|
|
94
|
+
);
|
|
95
|
+
expect(screen.getAllByRole("link", { name: /Voice attribution/ })[0]).toHaveAttribute("href", "/blog/voice");
|
|
96
|
+
expect(screen.getAllByRole("link", { name: /Clues that point/ })[0]).toHaveAttribute("href", "/blog/clues");
|
|
97
|
+
expect(screen.getByText(p3.summary!)).toBeInTheDocument();
|
|
98
|
+
expect(screen.getAllByText(/blog\.public\.readingTime 4/).length).toBeGreaterThanOrEqual(3);
|
|
99
|
+
// One real h1 per page (public SEO page), carried by ContentTitle; the eyebrow is not part of it.
|
|
100
|
+
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
|
101
|
+
expect(screen.getByRole("heading", { level: 1, name: "blog.public.title" })).toBeInTheDocument();
|
|
102
|
+
expect(screen.getByText("blog.public.eyebrow")).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("active category chip is marked and links back to all", () => {
|
|
106
|
+
render(
|
|
107
|
+
<BlogList
|
|
108
|
+
layout="featured"
|
|
109
|
+
list={{ posts: [p1], hasMore: false }}
|
|
110
|
+
categories={cats}
|
|
111
|
+
activeCategorySlug="game-prep"
|
|
112
|
+
page={1}
|
|
113
|
+
basePath="/blog"
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const active = screen.getByRole("link", { name: "Game prep" });
|
|
118
|
+
expect(active).toHaveAttribute("aria-current", "page");
|
|
119
|
+
expect(active).toHaveAttribute("href", "/blog?category=game-prep");
|
|
120
|
+
expect(screen.getByRole("link", { name: "Product" })).not.toHaveAttribute("aria-current");
|
|
121
|
+
const all = screen.getByRole("link", { name: "blog.public.all" });
|
|
122
|
+
expect(all).toHaveAttribute("href", "/blog");
|
|
123
|
+
expect(all).not.toHaveAttribute("aria-current");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("load more links to the next page keeping filters", () => {
|
|
127
|
+
render(
|
|
128
|
+
<BlogList
|
|
129
|
+
layout="column"
|
|
130
|
+
list={{ posts: [p1], hasMore: true }}
|
|
131
|
+
categories={cats}
|
|
132
|
+
activeCategorySlug="game-prep"
|
|
133
|
+
query="x"
|
|
134
|
+
page={1}
|
|
135
|
+
basePath="/blog"
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(screen.getByRole("link", { name: "blog.public.loadMore" })).toHaveAttribute(
|
|
140
|
+
"href",
|
|
141
|
+
"/blog?category=game-prep&q=x&page=2",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("hides load more when there is nothing more", () => {
|
|
146
|
+
render(
|
|
147
|
+
<BlogList layout="rail" list={{ posts: [p1], hasMore: false }} categories={cats} page={1} basePath="/blog" />,
|
|
148
|
+
);
|
|
149
|
+
expect(screen.queryByRole("link", { name: "blog.public.loadMore" })).not.toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("empty list with a query shows noResults", () => {
|
|
153
|
+
render(
|
|
154
|
+
<BlogList
|
|
155
|
+
layout="featured"
|
|
156
|
+
list={{ posts: [], hasMore: false }}
|
|
157
|
+
categories={cats}
|
|
158
|
+
query="zzz"
|
|
159
|
+
page={1}
|
|
160
|
+
basePath="/blog"
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
expect(screen.getByText("blog.public.noResults")).toBeInTheDocument();
|
|
164
|
+
expect(screen.queryByText("blog.public.empty")).not.toBeInTheDocument();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("empty list without a query shows empty", () => {
|
|
168
|
+
render(
|
|
169
|
+
<BlogList layout="column" list={{ posts: [], hasMore: false }} categories={cats} page={1} basePath="/blog" />,
|
|
170
|
+
);
|
|
171
|
+
expect(screen.getByText("blog.public.empty")).toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { configureI18n } from "../../../../../i18n";
|
|
5
|
+
import type { PublicBlogCategory, PublicBlogPost } from "../../../types/blog-public.types";
|
|
6
|
+
import { BlogPostView } from "../BlogPostView";
|
|
7
|
+
|
|
8
|
+
// t interpolates its values after the key so assertions can see them.
|
|
9
|
+
vi.mock("next-intl", () => ({
|
|
10
|
+
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
|
11
|
+
values ? `${key} ${Object.values(values).join(" ")}` : key,
|
|
12
|
+
useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("next/image", () => ({
|
|
15
|
+
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
|
16
|
+
}));
|
|
17
|
+
// The real editor is a next/dynamic ssr:false import; assert on the props it receives.
|
|
18
|
+
vi.mock("../../../../../components/editors/BlockNoteEditorContainer", () => ({
|
|
19
|
+
BlockNoteEditorContainer: ({ id, type, initialContent }: { id: string; type: string; initialContent: unknown }) => (
|
|
20
|
+
<div data-testid="post-body" data-id={id} data-type={type}>
|
|
21
|
+
{JSON.stringify(initialContent)}
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const cats: PublicBlogCategory[] = [{ id: "c1", name: "Game prep", slug: "game-prep" }];
|
|
27
|
+
|
|
28
|
+
const post = (overrides: Partial<PublicBlogPost>): PublicBlogPost => ({
|
|
29
|
+
id: "p",
|
|
30
|
+
language: "en",
|
|
31
|
+
title: "Title",
|
|
32
|
+
slug: "title",
|
|
33
|
+
readingMinutes: 4,
|
|
34
|
+
publishedAt: "2026-10-12T09:00:00.000Z",
|
|
35
|
+
updatedAt: "2026-10-12T09:00:00.000Z",
|
|
36
|
+
categoryIds: ["c1"],
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const main = post({
|
|
41
|
+
id: "p1",
|
|
42
|
+
title: "Prep situations, not plots",
|
|
43
|
+
slug: "prep-situations",
|
|
44
|
+
summary: "Why a scene that can go three ways beats a scene that must go one way.",
|
|
45
|
+
body: [{ type: "paragraph", content: [{ type: "text", text: "Every GM has lived that moment" }] }],
|
|
46
|
+
coverImage: "https://cdn.test/p1.webp",
|
|
47
|
+
coverImageAlt: "A table with dice",
|
|
48
|
+
readingMinutes: 6,
|
|
49
|
+
authorName: "Carlo Nicora",
|
|
50
|
+
updatedAt: "2026-10-15T09:00:00.000Z",
|
|
51
|
+
});
|
|
52
|
+
const r1 = post({
|
|
53
|
+
id: "r1",
|
|
54
|
+
title: "Clues that point, not explain",
|
|
55
|
+
slug: "clues",
|
|
56
|
+
summary: "Make every clue a pointer.",
|
|
57
|
+
});
|
|
58
|
+
const r2 = post({ id: "r2", title: "Three-way scenes", slug: "three-way" });
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
configureI18n({
|
|
62
|
+
useRouter: () => ({
|
|
63
|
+
push: () => {},
|
|
64
|
+
replace: () => {},
|
|
65
|
+
back: () => {},
|
|
66
|
+
forward: () => {},
|
|
67
|
+
refresh: () => {},
|
|
68
|
+
prefetch: () => {},
|
|
69
|
+
}),
|
|
70
|
+
useTranslations: () => (key: string) => key,
|
|
71
|
+
usePathname: () => "/blog/prep-situations",
|
|
72
|
+
getLocale: async () => "en",
|
|
73
|
+
Link: ({ href, children, prefetch: _prefetch, scroll: _scroll, ...props }: any) => (
|
|
74
|
+
<a href={href} {...props}>
|
|
75
|
+
{children}
|
|
76
|
+
</a>
|
|
77
|
+
),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("BlogPostView", () => {
|
|
82
|
+
it("shows category, title, author, reading time and more-in links", () => {
|
|
83
|
+
render(<BlogPostView post={main} categories={cats} related={[r1, r2]} basePath="/blog" />);
|
|
84
|
+
|
|
85
|
+
expect(screen.getByRole("link", { name: /blog\.public\.back/ })).toHaveAttribute("href", "/blog");
|
|
86
|
+
expect(screen.getAllByText("Game prep")[0]).toBeInTheDocument();
|
|
87
|
+
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
|
|
88
|
+
expect(screen.getByRole("heading", { level: 1, name: "Prep situations, not plots" })).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByText(main.summary!)).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByText("blog.public.by Carlo Nicora")).toBeInTheDocument();
|
|
91
|
+
expect(screen.getByText(/blog\.public\.readingTime 6/)).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByAltText("A table with dice")).toHaveAttribute("src", "https://cdn.test/p1.webp");
|
|
93
|
+
const body = screen.getByTestId("post-body");
|
|
94
|
+
expect(body).toHaveTextContent("Every GM has lived that moment");
|
|
95
|
+
expect(body).toHaveAttribute("data-id", "p1");
|
|
96
|
+
expect(body).toHaveAttribute("data-type", "blog-post");
|
|
97
|
+
|
|
98
|
+
expect(screen.getByRole("heading", { level: 2, name: "blog.public.moreIn Game prep" })).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByRole("link", { name: "Clues that point, not explain" })).toHaveAttribute("href", "/blog/clues");
|
|
100
|
+
expect(screen.getByRole("link", { name: "Three-way scenes" })).toHaveAttribute("href", "/blog/three-way");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("shows updated only when the post changed more than a day after publishing", () => {
|
|
104
|
+
const { rerender } = render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
|
|
105
|
+
expect(screen.getByText(/blog\.public\.updated 2026-10-15/)).toBeInTheDocument();
|
|
106
|
+
|
|
107
|
+
rerender(
|
|
108
|
+
<BlogPostView
|
|
109
|
+
post={{ ...main, updatedAt: "2026-10-12T15:00:00.000Z" }}
|
|
110
|
+
categories={cats}
|
|
111
|
+
related={[]}
|
|
112
|
+
basePath="/blog"
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
expect(screen.queryByText(/blog\.public\.updated/)).not.toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("omits the more-in section when there are no related posts", () => {
|
|
119
|
+
render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
|
|
120
|
+
expect(screen.queryByText(/blog\.public\.moreIn/)).not.toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("caps related posts at three", () => {
|
|
124
|
+
const many = [1, 2, 3, 4].map((n) => post({ id: `r${n}`, title: `Related ${n}`, slug: `related-${n}` }));
|
|
125
|
+
render(<BlogPostView post={main} categories={cats} related={many} basePath="/blog" />);
|
|
126
|
+
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Locale-free href to the public list, keeping the active filters.
|
|
5
|
+
* Parameter order is fixed (category, q, page) so links are stable.
|
|
6
|
+
*/
|
|
7
|
+
export function blogListHref(basePath: string, params: { category?: string; q?: string; page?: number }): string {
|
|
8
|
+
const search = new URLSearchParams();
|
|
9
|
+
if (params.category) search.set("category", params.category);
|
|
10
|
+
if (params.q) search.set("q", params.q);
|
|
11
|
+
if (params.page && params.page > 1) search.set("page", String(params.page));
|
|
12
|
+
const query = search.toString();
|
|
13
|
+
return query ? `${basePath}?${query}` : basePath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function blogPostHref(basePath: string, slug: string): string {
|
|
17
|
+
return `${basePath}/${encodeURIComponent(slug)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The first of the post's categories that the visitor's category list knows. */
|
|
21
|
+
export function resolvePostCategory(
|
|
22
|
+
post: PublicBlogPost,
|
|
23
|
+
categories: PublicBlogCategory[],
|
|
24
|
+
): PublicBlogCategory | undefined {
|
|
25
|
+
for (const id of post.categoryIds) {
|
|
26
|
+
const category = categories.find((c) => c.id === id);
|
|
27
|
+
if (category) return category;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { BlogPostInterface, BlogPostService } from "../data";
|
|
5
|
+
|
|
6
|
+
/** How often the post page re-reads the post while a translation job runs. */
|
|
7
|
+
const TRANSLATION_POLL_MS = 5000;
|
|
8
|
+
|
|
9
|
+
export interface BlogPostContextType {
|
|
10
|
+
post: BlogPostInterface | undefined;
|
|
11
|
+
reload: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const BlogPostContext = createContext<BlogPostContextType | undefined>(undefined);
|
|
15
|
+
|
|
16
|
+
export type BlogPostProviderProps = {
|
|
17
|
+
postId: string;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Loads one post (with its translations, categories and author) for the admin
|
|
23
|
+
* post page. While any translation is `pending` or `in_progress` it re-reads
|
|
24
|
+
* the post every 5 s, so the language tabs move from "Translating…" to the
|
|
25
|
+
* translated text without a manual refresh; it stops once none are running.
|
|
26
|
+
*/
|
|
27
|
+
export const BlogPostProvider = ({ postId, children }: BlogPostProviderProps) => {
|
|
28
|
+
const [post, setPost] = useState<BlogPostInterface | undefined>(undefined);
|
|
29
|
+
|
|
30
|
+
const reload = useCallback(async () => {
|
|
31
|
+
const loaded = await BlogPostService.findOne({ id: postId });
|
|
32
|
+
setPost(loaded);
|
|
33
|
+
}, [postId]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
void reload();
|
|
37
|
+
}, [reload]);
|
|
38
|
+
|
|
39
|
+
const isTranslating = useMemo(
|
|
40
|
+
() =>
|
|
41
|
+
(post?.translations ?? []).some(
|
|
42
|
+
(translation) => translation.aiStatus === "pending" || translation.aiStatus === "in_progress",
|
|
43
|
+
),
|
|
44
|
+
[post],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isTranslating) return;
|
|
49
|
+
const handle = setInterval(() => {
|
|
50
|
+
reload().catch(() => {});
|
|
51
|
+
}, TRANSLATION_POLL_MS);
|
|
52
|
+
return () => clearInterval(handle);
|
|
53
|
+
}, [isTranslating, reload]);
|
|
54
|
+
|
|
55
|
+
const value = useMemo<BlogPostContextType>(() => ({ post, reload }), [post, reload]);
|
|
56
|
+
|
|
57
|
+
return <BlogPostContext.Provider value={value}>{children}</BlogPostContext.Provider>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const useBlogPost = (): BlogPostContextType => {
|
|
61
|
+
const context = useContext(BlogPostContext);
|
|
62
|
+
if (context === undefined) {
|
|
63
|
+
throw new Error("useBlogPost must be used within a BlogPostProvider");
|
|
64
|
+
}
|
|
65
|
+
return context;
|
|
66
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./BlogPostContext";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
|
+
import { BlogCategoryInput, BlogCategoryInterface } from "./BlogCategoryInterface";
|
|
3
|
+
import { BlogCategoryTranslationInterface } from "./BlogCategoryTranslationInterface";
|
|
4
|
+
|
|
5
|
+
export class BlogCategory extends AbstractApiData implements BlogCategoryInterface {
|
|
6
|
+
private _originalLanguage?: string;
|
|
7
|
+
private _image?: string;
|
|
8
|
+
private _translations?: BlogCategoryTranslationInterface[];
|
|
9
|
+
|
|
10
|
+
private _language?: string;
|
|
11
|
+
private _name?: string;
|
|
12
|
+
private _slug?: string;
|
|
13
|
+
private _description?: string;
|
|
14
|
+
|
|
15
|
+
get originalLanguage(): string {
|
|
16
|
+
return this._originalLanguage ?? "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get image(): string | undefined {
|
|
20
|
+
return this._image;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get translations(): BlogCategoryTranslationInterface[] {
|
|
24
|
+
return this._translations ?? [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get original(): BlogCategoryTranslationInterface | undefined {
|
|
28
|
+
return this.translations.find((translation) => translation.isOriginal);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get language(): string | undefined {
|
|
32
|
+
return this._language;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get name(): string | undefined {
|
|
36
|
+
return this._name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get slug(): string | undefined {
|
|
40
|
+
return this._slug;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get description(): string | undefined {
|
|
44
|
+
return this._description;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
48
|
+
super.rehydrate(data);
|
|
49
|
+
|
|
50
|
+
const attributes = data.jsonApi.attributes ?? {};
|
|
51
|
+
const meta = data.jsonApi.meta ?? {};
|
|
52
|
+
// The resolved-language values are computed on the API. Depending on the
|
|
53
|
+
// descriptor they arrive as attributes or as meta, so both are read.
|
|
54
|
+
const resolved = (key: string) => attributes[key] ?? meta[key] ?? undefined;
|
|
55
|
+
|
|
56
|
+
this._originalLanguage = attributes.originalLanguage;
|
|
57
|
+
this._image = attributes.image ?? undefined;
|
|
58
|
+
|
|
59
|
+
this._language = resolved("language");
|
|
60
|
+
this._name = resolved("name");
|
|
61
|
+
this._slug = resolved("slug");
|
|
62
|
+
this._description = resolved("description");
|
|
63
|
+
|
|
64
|
+
this._translations = this._readIncluded(
|
|
65
|
+
data,
|
|
66
|
+
"translations",
|
|
67
|
+
Modules.BlogCategoryTranslation,
|
|
68
|
+
) as BlogCategoryTranslationInterface[];
|
|
69
|
+
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
createJsonApi(data: BlogCategoryInput) {
|
|
74
|
+
const response: any = {
|
|
75
|
+
data: {
|
|
76
|
+
type: Modules.BlogCategory.name,
|
|
77
|
+
id: data.id,
|
|
78
|
+
attributes: {},
|
|
79
|
+
meta: {},
|
|
80
|
+
relationships: {},
|
|
81
|
+
},
|
|
82
|
+
included: [],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (data.originalLanguage !== undefined) response.data.attributes.originalLanguage = data.originalLanguage;
|
|
86
|
+
if (data.name !== undefined) response.data.attributes.name = data.name;
|
|
87
|
+
if (data.slug !== undefined) response.data.attributes.slug = data.slug;
|
|
88
|
+
if (data.description !== undefined) response.data.attributes.description = data.description;
|
|
89
|
+
if (data.image !== undefined) response.data.attributes.image = data.image;
|
|
90
|
+
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ApiDataInterface } from "../../../core";
|
|
2
|
+
import { BlogCategoryTranslationInterface } from "./BlogCategoryTranslationInterface";
|
|
3
|
+
|
|
4
|
+
export type BlogCategoryInput = {
|
|
5
|
+
id: string;
|
|
6
|
+
originalLanguage?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
slug?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
image?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface BlogCategoryInterface extends ApiDataInterface {
|
|
14
|
+
get originalLanguage(): string;
|
|
15
|
+
get image(): string | undefined;
|
|
16
|
+
get translations(): BlogCategoryTranslationInterface[];
|
|
17
|
+
get original(): BlogCategoryTranslationInterface | undefined;
|
|
18
|
+
|
|
19
|
+
// Resolved for one language by the public and list queries.
|
|
20
|
+
get language(): string | undefined;
|
|
21
|
+
get name(): string | undefined;
|
|
22
|
+
get slug(): string | undefined;
|
|
23
|
+
get description(): string | undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef } from "../../../core";
|
|
2
|
+
import { BlogCategoryInput, BlogCategoryInterface } from "./BlogCategoryInterface";
|
|
3
|
+
|
|
4
|
+
export class BlogCategoryService extends AbstractService {
|
|
5
|
+
static async findMany(
|
|
6
|
+
params: { search?: string; fetchAll?: boolean; next?: NextRef } = {},
|
|
7
|
+
): Promise<BlogCategoryInterface[]> {
|
|
8
|
+
const endpoint = new EndpointCreator({ endpoint: Modules.BlogCategory });
|
|
9
|
+
|
|
10
|
+
if (params.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
|
|
11
|
+
if (params.search) endpoint.addAdditionalParam("search", params.search);
|
|
12
|
+
|
|
13
|
+
return this.callApi<BlogCategoryInterface[]>({
|
|
14
|
+
type: Modules.BlogCategory,
|
|
15
|
+
method: HttpMethod.GET,
|
|
16
|
+
endpoint: endpoint.generate(),
|
|
17
|
+
next: params.next,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static async findOne(params: { id: string }): Promise<BlogCategoryInterface> {
|
|
22
|
+
return this.callApi<BlogCategoryInterface>({
|
|
23
|
+
type: Modules.BlogCategory,
|
|
24
|
+
method: HttpMethod.GET,
|
|
25
|
+
endpoint: new EndpointCreator({ endpoint: Modules.BlogCategory, id: params.id }).generate(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static async create(params: BlogCategoryInput): Promise<BlogCategoryInterface> {
|
|
30
|
+
return this.callApi<BlogCategoryInterface>({
|
|
31
|
+
type: Modules.BlogCategory,
|
|
32
|
+
method: HttpMethod.POST,
|
|
33
|
+
endpoint: new EndpointCreator({ endpoint: Modules.BlogCategory }).generate(),
|
|
34
|
+
input: params,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static async update(params: BlogCategoryInput): Promise<BlogCategoryInterface> {
|
|
39
|
+
return this.callApi<BlogCategoryInterface>({
|
|
40
|
+
type: Modules.BlogCategory,
|
|
41
|
+
method: HttpMethod.PUT,
|
|
42
|
+
endpoint: new EndpointCreator({ endpoint: Modules.BlogCategory, id: params.id }).generate(),
|
|
43
|
+
input: params,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static async delete(params: { id: string }): Promise<void> {
|
|
48
|
+
await this.callApi({
|
|
49
|
+
type: Modules.BlogCategory,
|
|
50
|
+
method: HttpMethod.DELETE,
|
|
51
|
+
endpoint: new EndpointCreator({ endpoint: Modules.BlogCategory, id: params.id }).generate(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
|
|
2
|
+
import { BlogAiStatus, BlogTranslationSource } from "../types/blog.constants";
|
|
3
|
+
import { BlogCategoryInterface } from "./BlogCategoryInterface";
|
|
4
|
+
import { BlogCategoryTranslationInput, BlogCategoryTranslationInterface } from "./BlogCategoryTranslationInterface";
|
|
5
|
+
|
|
6
|
+
export class BlogCategoryTranslation extends AbstractApiData implements BlogCategoryTranslationInterface {
|
|
7
|
+
private _language?: string;
|
|
8
|
+
private _isOriginal?: boolean;
|
|
9
|
+
private _source?: BlogTranslationSource;
|
|
10
|
+
private _aiStatus?: BlogAiStatus;
|
|
11
|
+
private _name?: string;
|
|
12
|
+
private _slug?: string;
|
|
13
|
+
private _description?: string;
|
|
14
|
+
private _translatedAt?: Date;
|
|
15
|
+
private _category?: BlogCategoryInterface;
|
|
16
|
+
|
|
17
|
+
get language(): string {
|
|
18
|
+
return this._language ?? "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get isOriginal(): boolean {
|
|
22
|
+
return this._isOriginal ?? false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get source(): BlogTranslationSource {
|
|
26
|
+
return this._source ?? "ai";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get aiStatus(): BlogAiStatus {
|
|
30
|
+
return this._aiStatus ?? "pending";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get name(): string {
|
|
34
|
+
return this._name ?? "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get slug(): string {
|
|
38
|
+
return this._slug ?? "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get description(): string | undefined {
|
|
42
|
+
return this._description;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get translatedAt(): Date | undefined {
|
|
46
|
+
return this._translatedAt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get category(): BlogCategoryInterface | undefined {
|
|
50
|
+
return this._category;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
54
|
+
super.rehydrate(data);
|
|
55
|
+
|
|
56
|
+
const attributes = data.jsonApi.attributes ?? {};
|
|
57
|
+
|
|
58
|
+
this._language = attributes.language;
|
|
59
|
+
this._isOriginal = attributes.isOriginal === true;
|
|
60
|
+
this._source = attributes.source;
|
|
61
|
+
this._aiStatus = attributes.aiStatus;
|
|
62
|
+
this._name = attributes.name ?? undefined;
|
|
63
|
+
this._slug = attributes.slug ?? undefined;
|
|
64
|
+
this._description = attributes.description ?? undefined;
|
|
65
|
+
this._translatedAt = attributes.translatedAt ? new Date(attributes.translatedAt) : undefined;
|
|
66
|
+
|
|
67
|
+
this._category = this._readIncluded(data, "category", Modules.BlogCategory) as BlogCategoryInterface;
|
|
68
|
+
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
createJsonApi(data: BlogCategoryTranslationInput) {
|
|
73
|
+
const response: any = {
|
|
74
|
+
data: {
|
|
75
|
+
type: Modules.BlogCategoryTranslation.name,
|
|
76
|
+
id: data.id,
|
|
77
|
+
attributes: {},
|
|
78
|
+
meta: {},
|
|
79
|
+
relationships: {},
|
|
80
|
+
},
|
|
81
|
+
included: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (data.name !== undefined) response.data.attributes.name = data.name;
|
|
85
|
+
if (data.slug !== undefined) response.data.attributes.slug = data.slug;
|
|
86
|
+
if (data.description !== undefined) response.data.attributes.description = data.description;
|
|
87
|
+
|
|
88
|
+
return response;
|
|
89
|
+
}
|
|
90
|
+
}
|