@gosalesdeal/blog 0.1.0 → 0.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/README.md +265 -34
- package/dist/client/blogClient.js +79 -13
- package/dist/client/blogClient.js.map +1 -1
- package/dist/components/BlogPost.d.ts +12 -1
- package/dist/components/BlogPost.js +27 -16
- package/dist/components/BlogPost.js.map +1 -1
- package/dist/components/BlogPostHeader.d.ts +15 -0
- package/dist/components/BlogPostHeader.js +41 -0
- package/dist/components/BlogPostHeader.js.map +1 -0
- package/dist/components/landing/BlogLayoutComponents.d.ts +14 -0
- package/dist/components/landing/BlogLayoutComponents.js +198 -0
- package/dist/components/landing/BlogLayoutComponents.js.map +1 -0
- package/dist/components/landing/FlexibleLayoutComponent.d.ts +40 -0
- package/dist/components/landing/FlexibleLayoutComponent.js +352 -0
- package/dist/components/landing/FlexibleLayoutComponent.js.map +1 -0
- package/dist/components/landing/HeroFeatureBanner.d.ts +36 -0
- package/dist/components/landing/HeroFeatureBanner.js +88 -0
- package/dist/components/landing/HeroFeatureBanner.js.map +1 -0
- package/dist/components/landing/HeroFeatureBannerComponent.d.ts +4 -0
- package/dist/components/landing/HeroFeatureBannerComponent.js +100 -0
- package/dist/components/landing/HeroFeatureBannerComponent.js.map +1 -0
- package/dist/components/landing/HeroFeatureBannerVideo.d.ts +34 -0
- package/dist/components/landing/HeroFeatureBannerVideo.js +261 -0
- package/dist/components/landing/HeroFeatureBannerVideo.js.map +1 -0
- package/dist/components/landing/HeroFeatureBannerVideoComponent.d.ts +5 -0
- package/dist/components/landing/HeroFeatureBannerVideoComponent.js +69 -0
- package/dist/components/landing/HeroFeatureBannerVideoComponent.js.map +1 -0
- package/dist/components/landing/VarietyReviewslistComponent.d.ts +4 -0
- package/dist/components/landing/VarietyReviewslistComponent.js +172 -0
- package/dist/components/landing/VarietyReviewslistComponent.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/postUtils.d.ts +3 -0
- package/dist/lib/postUtils.js +8 -0
- package/dist/lib/postUtils.js.map +1 -0
- package/dist/lib/resolveMediaSrc.d.ts +2 -0
- package/dist/lib/resolveMediaSrc.js +21 -0
- package/dist/lib/resolveMediaSrc.js.map +1 -0
- package/dist/render/BlockRenderer.js +5 -0
- package/dist/render/BlockRenderer.js.map +1 -1
- package/dist/render/LandingPageRenderer.d.ts +5 -0
- package/dist/render/LandingPageRenderer.js +50 -0
- package/dist/render/LandingPageRenderer.js.map +1 -0
- package/dist/render/landingPageHtml.d.ts +28 -0
- package/dist/render/landingPageHtml.js +205 -0
- package/dist/render/landingPageHtml.js.map +1 -0
- package/dist/styles.css +27 -0
- package/dist/types/blog.d.ts +8 -2
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @gosalesdeal/blog
|
|
2
2
|
|
|
3
|
-
Render
|
|
3
|
+
Render GoEnterprise blog content (list + post detail) in any React 18+ / Next.js App Router app, straight from the BlogPosts API. Server-component friendly, typed data client, optional baseline styles, zero UI framework dependencies.
|
|
4
|
+
|
|
5
|
+
**Current version:** `0.2.0`
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -8,89 +10,318 @@ Render the GoEnterprise blog (list + post detail) in any React 18+ / Next.js App
|
|
|
8
10
|
npm install @gosalesdeal/blog
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
`react`
|
|
13
|
+
`react` is a required peer dependency. `react-dom` is optional (only needed if you render interactive block types in the browser).
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
### 1. Environment variables
|
|
18
|
+
|
|
19
|
+
```env
|
|
20
|
+
BLOG_API_URL=https://api.gosalesdeal.com
|
|
21
|
+
BLOG_TENANT_ID=your-tenant-id
|
|
22
|
+
```
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
Supported fallbacks: `NEXT_PUBLIC_BLOG_API_URL`, `NEXT_PUBLIC_BLOG_TENANT_ID`.
|
|
14
25
|
|
|
15
|
-
|
|
26
|
+
### 2. Optional global config
|
|
16
27
|
|
|
17
28
|
```ts
|
|
18
|
-
// e.g. instrumentation.ts
|
|
29
|
+
// e.g. instrumentation.ts
|
|
19
30
|
import { configureBlog } from '@gosalesdeal/blog';
|
|
20
31
|
|
|
21
32
|
configureBlog({
|
|
22
|
-
apiBaseUrl: process.env.BLOG_API_URL!,
|
|
23
|
-
tenantId: process.env.BLOG_TENANT_ID!,
|
|
33
|
+
apiBaseUrl: process.env.BLOG_API_URL!,
|
|
34
|
+
tenantId: process.env.BLOG_TENANT_ID!,
|
|
35
|
+
headers: {
|
|
36
|
+
// Avoid bot-middleware blocks on server-side fetch (Next.js SSR/SSG)
|
|
37
|
+
'User-Agent': 'Mozilla/5.0 (compatible; MyApp/1.0)',
|
|
38
|
+
},
|
|
24
39
|
});
|
|
25
40
|
```
|
|
26
41
|
|
|
27
|
-
|
|
42
|
+
Components also accept a `config` prop per call, which overrides these defaults.
|
|
43
|
+
|
|
44
|
+
### 3. Next.js setup
|
|
45
|
+
|
|
46
|
+
Add the package to `transpilePackages` in `next.config`:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
/** @type {import('next').NextConfig} */
|
|
50
|
+
const nextConfig = {
|
|
51
|
+
transpilePackages: ['@gosalesdeal/blog'],
|
|
52
|
+
};
|
|
53
|
+
export default nextConfig;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Import styles once in your blog layout:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import '@gosalesdeal/blog/styles.css';
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Next.js examples
|
|
63
|
+
|
|
64
|
+
### Blog list
|
|
28
65
|
|
|
29
66
|
```tsx
|
|
30
67
|
// app/blog/page.tsx
|
|
68
|
+
import type { Metadata } from 'next';
|
|
31
69
|
import { BlogList, generateBlogListMetadata } from '@gosalesdeal/blog';
|
|
32
|
-
import '@gosalesdeal/blog/styles.css';
|
|
33
70
|
|
|
34
|
-
|
|
71
|
+
const seo = { siteUrl: 'https://acme.com', siteName: 'Acme', basePath: '/blog' };
|
|
72
|
+
|
|
73
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
74
|
+
return generateBlogListMetadata({
|
|
75
|
+
...seo,
|
|
76
|
+
title: 'Blog – Acme',
|
|
77
|
+
description: 'Latest news and insights from Acme.',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
35
80
|
|
|
36
|
-
export default async function
|
|
81
|
+
export default async function BlogPage({
|
|
82
|
+
searchParams,
|
|
83
|
+
}: {
|
|
84
|
+
searchParams: Promise<{ page?: string }>;
|
|
85
|
+
}) {
|
|
37
86
|
const { page } = await searchParams;
|
|
38
|
-
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<BlogList
|
|
90
|
+
page={Number(page) || 1}
|
|
91
|
+
basePath="/blog"
|
|
92
|
+
config={{
|
|
93
|
+
apiBaseUrl: process.env.BLOG_API_URL!,
|
|
94
|
+
tenantId: process.env.BLOG_TENANT_ID!,
|
|
95
|
+
}}
|
|
96
|
+
emptyState={<p>No articles published yet.</p>}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
39
99
|
}
|
|
40
100
|
```
|
|
41
101
|
|
|
102
|
+
### Blog post detail
|
|
103
|
+
|
|
104
|
+
Fetch the post first, then call `notFound()` — do **not** pass `notFound()` as a `fallback` prop (that runs during render).
|
|
105
|
+
|
|
42
106
|
```tsx
|
|
43
107
|
// app/blog/[slug]/page.tsx
|
|
108
|
+
import type { Metadata } from 'next';
|
|
44
109
|
import { notFound } from 'next/navigation';
|
|
45
|
-
import {
|
|
46
|
-
|
|
110
|
+
import {
|
|
111
|
+
BlogPost,
|
|
112
|
+
getPostBySlug,
|
|
113
|
+
getPublishedSlugs,
|
|
114
|
+
generateBlogPostMetadata,
|
|
115
|
+
} from '@gosalesdeal/blog';
|
|
116
|
+
|
|
117
|
+
const seo = { siteUrl: 'https://acme.com', siteName: 'Acme', basePath: '/blog' };
|
|
47
118
|
|
|
48
119
|
export async function generateStaticParams() {
|
|
49
|
-
const slugs = await getPublishedSlugs();
|
|
120
|
+
const slugs = await getPublishedSlugs({ apiBaseUrl: process.env.BLOG_API_URL! });
|
|
50
121
|
return slugs.map((slug) => ({ slug }));
|
|
51
122
|
}
|
|
52
123
|
|
|
53
|
-
export async function generateMetadata({
|
|
124
|
+
export async function generateMetadata({
|
|
125
|
+
params,
|
|
126
|
+
}: {
|
|
127
|
+
params: Promise<{ slug: string }>;
|
|
128
|
+
}): Promise<Metadata> {
|
|
54
129
|
const { slug } = await params;
|
|
55
|
-
const post = await getPostBySlug(slug);
|
|
130
|
+
const post = await getPostBySlug(slug, { apiBaseUrl: process.env.BLOG_API_URL! });
|
|
56
131
|
if (!post) return {};
|
|
57
|
-
return generateBlogPostMetadata(post,
|
|
132
|
+
return generateBlogPostMetadata(post, seo);
|
|
58
133
|
}
|
|
59
134
|
|
|
60
|
-
export default async function
|
|
135
|
+
export default async function BlogPostPage({
|
|
136
|
+
params,
|
|
137
|
+
}: {
|
|
138
|
+
params: Promise<{ slug: string }>;
|
|
139
|
+
}) {
|
|
61
140
|
const { slug } = await params;
|
|
141
|
+
const config = {
|
|
142
|
+
apiBaseUrl: process.env.BLOG_API_URL!,
|
|
143
|
+
tenantId: process.env.BLOG_TENANT_ID!,
|
|
144
|
+
};
|
|
145
|
+
const post = await getPostBySlug(slug, config);
|
|
146
|
+
|
|
147
|
+
if (!post) notFound();
|
|
148
|
+
|
|
62
149
|
return (
|
|
63
150
|
<BlogPost
|
|
64
151
|
slug={slug}
|
|
65
|
-
|
|
66
|
-
|
|
152
|
+
post={post}
|
|
153
|
+
config={config}
|
|
154
|
+
seo={seo}
|
|
155
|
+
trackView
|
|
156
|
+
renderJsonLd
|
|
157
|
+
showTitle
|
|
158
|
+
showFeatured={Boolean(post.featured_image)}
|
|
159
|
+
showMeta
|
|
160
|
+
showExcerpt={Boolean(post.excerpt)}
|
|
67
161
|
/>
|
|
68
162
|
);
|
|
69
163
|
}
|
|
70
164
|
```
|
|
71
165
|
|
|
72
|
-
##
|
|
166
|
+
## Components
|
|
167
|
+
|
|
168
|
+
### `<BlogList />`
|
|
169
|
+
|
|
170
|
+
Async server component. Fetches and renders a paginated grid of post cards.
|
|
171
|
+
|
|
172
|
+
| Prop | Default | Description |
|
|
173
|
+
|------|---------|-------------|
|
|
174
|
+
| `page` | `1` | 1-based page number |
|
|
175
|
+
| `limit` | `12` | Posts per page |
|
|
176
|
+
| `category` | — | Filter by category slug |
|
|
177
|
+
| `basePath` | `'/blog'` | URL prefix for post/pagination links |
|
|
178
|
+
| `config` | — | `{ apiBaseUrl, tenantId, headers, fetch }` |
|
|
179
|
+
| `locale` | `'en-US'` | Date formatting locale |
|
|
180
|
+
| `emptyState` | — | React node when no posts are found |
|
|
181
|
+
|
|
182
|
+
### `<BlogPost />`
|
|
73
183
|
|
|
74
|
-
|
|
75
|
-
- `<BlogList page limit category basePath apiBaseUrl tenantId locale emptyState />` — paginated listing of cards.
|
|
76
|
-
- `<BlogPost slug seo trackView renderJsonLd locale fallback />` — full post with header, block body, and JSON-LD.
|
|
77
|
-
- `<BlockRenderer block />` / `renderBlocks(blocks)` — render raw `ContentBlock`s yourself.
|
|
184
|
+
Async server component. Renders post header, body, and optional JSON-LD.
|
|
78
185
|
|
|
79
|
-
|
|
80
|
-
|
|
186
|
+
| Prop | Default | Description |
|
|
187
|
+
|------|---------|-------------|
|
|
188
|
+
| `slug` | — | Post slug (used to fetch when `post` is omitted) |
|
|
189
|
+
| `post` | — | Pre-fetched post (skips API call) |
|
|
190
|
+
| `config` | — | API config override |
|
|
191
|
+
| `seo` | — | `{ siteUrl, siteName, logoUrl?, basePath? }` for JSON-LD |
|
|
192
|
+
| `trackView` | `true` | Best-effort view increment |
|
|
193
|
+
| `renderJsonLd` | `true` | Emit BlogPosting + breadcrumb JSON-LD |
|
|
194
|
+
| `showTitle` | `true` | Show `<h1>` post title |
|
|
195
|
+
| `showFeatured` | `true` | Show featured image when set |
|
|
196
|
+
| `showMeta` | `true` | Show author, date, reading time |
|
|
197
|
+
| `showExcerpt` | `true` | Show excerpt paragraph |
|
|
198
|
+
| `locale` | `'en-US'` | Date formatting locale |
|
|
199
|
+
| `fallback` | `null` | Rendered when post is not found |
|
|
81
200
|
|
|
82
|
-
|
|
83
|
-
|
|
201
|
+
**Post body rendering order:**
|
|
202
|
+
|
|
203
|
+
1. `content_blocks` — block-based CMS content (17 block types)
|
|
204
|
+
2. `landing_page_html` / page-builder `schema_json` — hero, text, image, columns, etc.
|
|
205
|
+
3. Empty state when neither is available
|
|
206
|
+
|
|
207
|
+
### `<BlogPostHeader />`
|
|
208
|
+
|
|
209
|
+
Composable post header (title, meta, excerpt, featured image). Used internally by `<BlogPost />` but can be imported separately for custom layouts.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
import { BlogPostHeader } from '@gosalesdeal/blog';
|
|
213
|
+
// or direct subpath import:
|
|
214
|
+
import { BlogPostHeader } from '@gosalesdeal/blog/BlogPostHeader';
|
|
215
|
+
|
|
216
|
+
<BlogPostHeader
|
|
217
|
+
post={post}
|
|
218
|
+
showTitle
|
|
219
|
+
showFeatured={false}
|
|
220
|
+
showMeta
|
|
221
|
+
showExcerpt={Boolean(post.excerpt)}
|
|
222
|
+
/>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
| Prop | Default | Description |
|
|
226
|
+
|------|---------|-------------|
|
|
227
|
+
| `post` | — | Post fields: `title`, `featured_image`, `excerpt`, `author_name`, `published_at`, `reading_time` |
|
|
228
|
+
| `showTitle` | `true` | Show title |
|
|
229
|
+
| `showFeatured` | `true` | Show featured image |
|
|
230
|
+
| `showMeta` | `true` | Show author / date / reading time |
|
|
231
|
+
| `showExcerpt` | `true` | Show excerpt |
|
|
232
|
+
| `locale` | `'en-US'` | Date formatting locale |
|
|
233
|
+
|
|
234
|
+
### `<BlockRenderer />` / `renderBlocks()`
|
|
235
|
+
|
|
236
|
+
Low-level block rendering if you build your own post layout.
|
|
237
|
+
|
|
238
|
+
## Data client
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import {
|
|
242
|
+
createBlogClient,
|
|
243
|
+
getPostBySlug,
|
|
244
|
+
getPosts,
|
|
245
|
+
getPostsByCategory,
|
|
246
|
+
getPublishedSlugs,
|
|
247
|
+
getCategories,
|
|
248
|
+
incrementView,
|
|
249
|
+
} from '@gosalesdeal/blog';
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Each standalone function accepts an optional trailing `config` argument.
|
|
253
|
+
|
|
254
|
+
`createBlogClient(config)` returns `{ getPostBySlug, getPosts, getPostsByCategory, getPublishedSlugs, getCategories, incrementView }`.
|
|
255
|
+
|
|
256
|
+
List requests automatically filter to `status=published` and normalize the API `{ list, pagination }` response shape.
|
|
257
|
+
|
|
258
|
+
## Utilities
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { formatDate, postHasLandingPageBody } from '@gosalesdeal/blog';
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `formatDate(iso, locale?)` — locale-aware date formatting
|
|
265
|
+
- `postHasLandingPageBody(post)` — `true` when the body is rendered from page-builder landing HTML (no `content_blocks`)
|
|
266
|
+
|
|
267
|
+
Useful when you want to adjust layout for landing-page posts, e.g. hide a duplicate hero title:
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
import { postHasLandingPageBody } from '@gosalesdeal/blog';
|
|
271
|
+
|
|
272
|
+
const isLanding = postHasLandingPageBody(post);
|
|
273
|
+
// showTitle={!isLanding} — hide package title if hero already has a headline
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## SEO helpers
|
|
277
|
+
|
|
278
|
+
Framework-neutral plain objects assignable to Next.js `Metadata`:
|
|
279
|
+
|
|
280
|
+
- `generateBlogPostMetadata(post, seo?)`
|
|
281
|
+
- `generateBlogListMetadata(options)`
|
|
282
|
+
- `generateBlogPostJsonLd(post, seo?)`
|
|
283
|
+
- `generateBlogBreadcrumbJsonLd(post, seo?)`
|
|
284
|
+
|
|
285
|
+
`SeoOptions`: `{ siteUrl, siteName, logoUrl?, basePath? }`
|
|
84
286
|
|
|
85
287
|
## Styling
|
|
86
288
|
|
|
87
|
-
|
|
289
|
+
Import the optional baseline stylesheet:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import '@gosalesdeal/blog/styles.css';
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
All classes are namespaced under `.ggblog-*`. Theme via CSS variables on `.ggblog-post` / `.ggblog-list`:
|
|
296
|
+
|
|
297
|
+
| Variable | Purpose |
|
|
298
|
+
|----------|---------|
|
|
299
|
+
| `--ggblog-fg` | Primary text |
|
|
300
|
+
| `--ggblog-muted` | Secondary text |
|
|
301
|
+
| `--ggblog-accent` | Links, accents |
|
|
302
|
+
| `--ggblog-border` | Borders |
|
|
303
|
+
| `--ggblog-surface` | Card background |
|
|
304
|
+
| `--ggblog-surface-alt` | Alt surface |
|
|
305
|
+
| `--ggblog-radius` | Border radius |
|
|
306
|
+
| `--ggblog-gap` | Block spacing |
|
|
307
|
+
|
|
308
|
+
You can skip the bundled CSS entirely and style `.ggblog-*` classes yourself.
|
|
309
|
+
|
|
310
|
+
## Package exports
|
|
311
|
+
|
|
312
|
+
| Import | Description |
|
|
313
|
+
|--------|-------------|
|
|
314
|
+
| `@gosalesdeal/blog` | Main entry — components, client, SEO, types |
|
|
315
|
+
| `@gosalesdeal/blog/BlogPostHeader` | Direct subpath for `BlogPostHeader` |
|
|
316
|
+
| `@gosalesdeal/blog/styles.css` | Optional baseline styles |
|
|
88
317
|
|
|
89
318
|
## Scope & notes
|
|
90
319
|
|
|
91
|
-
-
|
|
92
|
-
-
|
|
93
|
-
-
|
|
320
|
+
- **Block posts:** all 17 `content_blocks` types are supported. `accordion` and `tabs` are interactive (`"use client"`); everything else is a server component.
|
|
321
|
+
- **Page-builder posts:** when `content_blocks` is empty, the package renders `landing_page_html` (pre-computed from `schema_json.components`) or generates HTML from the landing schema at render time. Supports hero, text, image, button, columns, spacer, divider, video, and more.
|
|
322
|
+
- **Trusted HTML:** block HTML and landing-page output are treated as trusted CMS content. Sanitize upstream if authors are untrusted.
|
|
323
|
+
- **Bot middleware:** if your API blocks server `fetch` without a browser-like `User-Agent`, pass `headers` in config (see configure example above).
|
|
324
|
+
- **Pre-fetched posts:** pass `post={...}` to `<BlogPost />` to avoid a duplicate API call when you already fetched for metadata or `generateStaticParams`.
|
|
94
325
|
|
|
95
326
|
## License
|
|
96
327
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveConfig } from "../config.js";
|
|
2
|
+
import { getLandingPageSchema, generateHtmlFromSchema } from "../render/landingPageHtml.js";
|
|
2
3
|
const EMPTY_PAGINATION = {
|
|
3
4
|
total: 0,
|
|
4
5
|
page: 1,
|
|
@@ -31,50 +32,115 @@ async function doFetch(path, config, query, init) {
|
|
|
31
32
|
...init?.headers ?? {}
|
|
32
33
|
};
|
|
33
34
|
if (cfg.tenantId) headers["X-Tenant"] = cfg.tenantId;
|
|
34
|
-
return fetchFn(buildUrl(cfg.apiBaseUrl, path, query), {
|
|
35
|
+
return fetchFn(buildUrl(cfg.apiBaseUrl, path, query), {
|
|
36
|
+
...init,
|
|
37
|
+
headers,
|
|
38
|
+
cache: "no-store"
|
|
39
|
+
});
|
|
35
40
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
41
|
+
function resolveFeaturedImageUrl(url, apiBaseUrl) {
|
|
42
|
+
const trimmed = String(url || "").trim();
|
|
43
|
+
if (!trimmed) return void 0;
|
|
44
|
+
if (trimmed.startsWith("data:")) return trimmed;
|
|
45
|
+
const base = apiBaseUrl.replace(/\/$/, "");
|
|
46
|
+
const assetsBases = [
|
|
47
|
+
"https://assets.gosalesdeal.com/images/product",
|
|
48
|
+
"https://assets.gosalesdeal.tech/images/product"
|
|
49
|
+
];
|
|
50
|
+
for (const assetsBase of assetsBases) {
|
|
51
|
+
if (trimmed.startsWith(`${assetsBase}/`)) {
|
|
52
|
+
const relative = trimmed.slice(assetsBase.length + 1);
|
|
53
|
+
return base ? `${base}/uploads/${relative}` : `/uploads/${relative}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
57
|
+
if (!base) return trimmed;
|
|
58
|
+
if (trimmed.startsWith("/")) return `${base}${trimmed}`;
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
function normalizePost(post, apiBaseUrl = "") {
|
|
62
|
+
if (!post || typeof post !== "object") return null;
|
|
63
|
+
if (!("post_id" in post) && !("slug" in post)) return null;
|
|
38
64
|
const blocks = Array.isArray(post.content_blocks) ? post.content_blocks : Array.isArray(post.blocks) ? post.blocks : [];
|
|
65
|
+
const landingSchema = getLandingPageSchema(post);
|
|
66
|
+
const landingPageContent = blocks.length === 0 && landingSchema ? generateHtmlFromSchema(landingSchema) : null;
|
|
67
|
+
const relationUrl = post?.featureImage?.url;
|
|
68
|
+
const featured_image = resolveFeaturedImageUrl(
|
|
69
|
+
relationUrl || post.featured_image,
|
|
70
|
+
apiBaseUrl
|
|
71
|
+
);
|
|
39
72
|
return {
|
|
40
73
|
...post,
|
|
41
|
-
|
|
74
|
+
...featured_image ? { featured_image } : {},
|
|
75
|
+
content_blocks: [...blocks].sort(
|
|
76
|
+
(a, b) => (a.display_order ?? 0) - (b.display_order ?? 0)
|
|
77
|
+
),
|
|
78
|
+
...landingPageContent?.html ? {
|
|
79
|
+
landing_page_html: landingPageContent.html,
|
|
80
|
+
landing_page_css: landingPageContent.css
|
|
81
|
+
} : {}
|
|
42
82
|
};
|
|
43
83
|
}
|
|
44
|
-
function
|
|
84
|
+
function normalizePagination(raw, itemsLength) {
|
|
85
|
+
if (!raw || typeof raw !== "object") {
|
|
86
|
+
return { ...EMPTY_PAGINATION, total: itemsLength, limit: itemsLength };
|
|
87
|
+
}
|
|
88
|
+
const page = Number(raw.page ?? raw.currentPage ?? 1);
|
|
89
|
+
const limit = Number(raw.limit ?? raw.per_page ?? (itemsLength || 1));
|
|
90
|
+
const total = Number(raw.total ?? raw.totalRecords ?? itemsLength);
|
|
91
|
+
const pages = Number(
|
|
92
|
+
raw.pages ?? raw.totalPages ?? (limit > 0 ? Math.ceil(total / limit) : 1)
|
|
93
|
+
);
|
|
94
|
+
const hasNext = raw.hasNext != null ? Boolean(raw.hasNext) : page < pages;
|
|
95
|
+
const hasPrev = raw.hasPrev != null ? Boolean(raw.hasPrev) : page > 1;
|
|
96
|
+
return { total, page, limit, pages, hasNext, hasPrev };
|
|
97
|
+
}
|
|
98
|
+
function normalizeList(body, apiBaseUrl = "") {
|
|
45
99
|
const root = body;
|
|
100
|
+
if (root && typeof root === "object" && typeof root.message === "string" && !("list" in root) && !("items" in root) && !("data" in root)) {
|
|
101
|
+
return { items: [], pagination: EMPTY_PAGINATION };
|
|
102
|
+
}
|
|
46
103
|
const data = root?.data ?? root;
|
|
47
104
|
if (Array.isArray(data)) {
|
|
48
105
|
return {
|
|
49
|
-
items: data.map((p) => normalizePost(p)).filter(Boolean),
|
|
106
|
+
items: data.map((p) => normalizePost(p, apiBaseUrl)).filter(Boolean),
|
|
50
107
|
pagination: { ...EMPTY_PAGINATION, total: data.length, limit: data.length }
|
|
51
108
|
};
|
|
52
109
|
}
|
|
53
|
-
const
|
|
110
|
+
const rawItems = data?.items ?? data?.list ?? [];
|
|
111
|
+
const items = Array.isArray(rawItems) ? rawItems : [];
|
|
54
112
|
return {
|
|
55
|
-
items: items.map((p) => normalizePost(p)).filter(Boolean),
|
|
56
|
-
pagination:
|
|
113
|
+
items: items.map((p) => normalizePost(p, apiBaseUrl)).filter(Boolean),
|
|
114
|
+
pagination: normalizePagination(
|
|
115
|
+
data?.pagination,
|
|
116
|
+
items.length
|
|
117
|
+
)
|
|
57
118
|
};
|
|
58
119
|
}
|
|
59
120
|
async function getPostBySlug(slug, config) {
|
|
121
|
+
const cfg = resolveConfig(config);
|
|
60
122
|
const res = await doFetch(`/blog-posts/slug/${encodeURIComponent(slug)}`, config);
|
|
61
123
|
if (res.status === 404) return null;
|
|
62
124
|
if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/slug/${slug} -> ${res.status}`);
|
|
63
125
|
const body = await res.json();
|
|
64
|
-
return normalizePost(body?.data ?? body);
|
|
126
|
+
return normalizePost(body?.data ?? body, cfg.apiBaseUrl);
|
|
65
127
|
}
|
|
66
128
|
async function getPosts(params = {}, config) {
|
|
129
|
+
const cfg = resolveConfig(config);
|
|
67
130
|
const res = await doFetch("/blog-posts/list/paginated", config, {
|
|
68
131
|
page: params.page,
|
|
69
132
|
limit: params.limit,
|
|
133
|
+
per_page: params.limit,
|
|
70
134
|
category: params.category,
|
|
71
135
|
search: params.search,
|
|
72
|
-
featured: params.featured
|
|
136
|
+
featured: params.featured,
|
|
137
|
+
status: "published"
|
|
73
138
|
});
|
|
74
139
|
if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/list/paginated -> ${res.status}`);
|
|
75
|
-
return normalizeList(await res.json());
|
|
140
|
+
return normalizeList(await res.json(), cfg.apiBaseUrl);
|
|
76
141
|
}
|
|
77
142
|
async function getPostsByCategory(categorySlug, params = {}, config) {
|
|
143
|
+
const cfg = resolveConfig(config);
|
|
78
144
|
const res = await doFetch(`/blog-posts/category/${encodeURIComponent(categorySlug)}`, config, {
|
|
79
145
|
page: params.page,
|
|
80
146
|
limit: params.limit
|
|
@@ -82,7 +148,7 @@ async function getPostsByCategory(categorySlug, params = {}, config) {
|
|
|
82
148
|
if (!res.ok) {
|
|
83
149
|
throw new Error(`[@gosalesdeal/blog] GET /blog-posts/category/${categorySlug} -> ${res.status}`);
|
|
84
150
|
}
|
|
85
|
-
return normalizeList(await res.json());
|
|
151
|
+
return normalizeList(await res.json(), cfg.apiBaseUrl);
|
|
86
152
|
}
|
|
87
153
|
async function getPublishedSlugs(config) {
|
|
88
154
|
const res = await doFetch("/blog-posts/published/slugs", config);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blogClient.js","sources":["../../src/client/blogClient.ts"],"sourcesContent":["import { resolveConfig, type BlogConfig } from '../config';\nimport type {\n BlogPost,\n BlogCategory,\n ContentBlock,\n PaginatedResponse,\n PaginationMeta,\n BlogApiResponse,\n} from '../types/blog';\n\nexport interface GetPostsParams {\n page?: number;\n limit?: number;\n category?: string;\n search?: string;\n featured?: boolean;\n}\n\nconst EMPTY_PAGINATION: PaginationMeta = {\n total: 0,\n page: 1,\n limit: 0,\n pages: 0,\n hasNext: false,\n hasPrev: false,\n};\n\nfunction buildUrl(base: string, path: string, query?: Record<string, unknown>): string {\n const url = `${base.replace(/\\/$/, '')}${path}`;\n if (!query) return url;\n const search = new URLSearchParams();\n for (const [key, value] of Object.entries(query)) {\n if (value !== undefined && value !== null && value !== '') {\n search.set(key, String(value));\n }\n }\n const qs = search.toString();\n return qs ? `${url}?${qs}` : url;\n}\n\nasync function doFetch(\n path: string,\n config: Partial<BlogConfig> | undefined,\n query?: Record<string, unknown>,\n init?: RequestInit,\n): Promise<Response> {\n const cfg = resolveConfig(config);\n const fetchFn = cfg.fetch ?? globalThis.fetch;\n if (typeof fetchFn !== 'function') {\n throw new Error('[@gosalesdeal/blog] No fetch implementation available. Provide config.fetch.');\n }\n const headers: Record<string, string> = {\n Accept: 'application/json',\n ...(cfg.headers ?? {}),\n ...((init?.headers as Record<string, string>) ?? {}),\n };\n if (cfg.tenantId) headers['X-Tenant'] = cfg.tenantId;\n return fetchFn(buildUrl(cfg.apiBaseUrl, path, query), { ...init, headers });\n}\n\n/** Sort blocks by display_order and guarantee an array. */\nfunction normalizePost(post: BlogPost | null | undefined): BlogPost | null {\n if (!post) return null;\n const blocks: ContentBlock[] = Array.isArray(post.content_blocks)\n ? post.content_blocks\n : Array.isArray(post.blocks)\n ? post.blocks\n : [];\n return {\n ...post,\n content_blocks: [...blocks].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0)),\n };\n}\n\n/** Unwrap `{data:{items,pagination}}`, `{items,pagination}`, or `{data:[...]}`. */\nfunction normalizeList(body: unknown): PaginatedResponse<BlogPost> {\n const root = body as BlogApiResponse<unknown> & PaginatedResponse<BlogPost>;\n const data = (root?.data ?? root) as\n | PaginatedResponse<BlogPost>\n | BlogPost[]\n | undefined;\n if (Array.isArray(data)) {\n return {\n items: data.map((p) => normalizePost(p)!).filter(Boolean),\n pagination: { ...EMPTY_PAGINATION, total: data.length, limit: data.length },\n };\n }\n const items = Array.isArray(data?.items) ? data!.items : [];\n return {\n items: items.map((p) => normalizePost(p)!).filter(Boolean),\n pagination: data?.pagination ?? { ...EMPTY_PAGINATION, total: items.length },\n };\n}\n\n/** Fetch a single published post by slug. Returns null when not found. */\nexport async function getPostBySlug(\n slug: string,\n config?: Partial<BlogConfig>,\n): Promise<BlogPost | null> {\n const res = await doFetch(`/blog-posts/slug/${encodeURIComponent(slug)}`, config);\n if (res.status === 404) return null;\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/slug/${slug} -> ${res.status}`);\n const body = (await res.json()) as BlogApiResponse<BlogPost>;\n return normalizePost(body?.data ?? (body as unknown as BlogPost));\n}\n\n/** Fetch a paginated list of posts. */\nexport async function getPosts(\n params: GetPostsParams = {},\n config?: Partial<BlogConfig>,\n): Promise<PaginatedResponse<BlogPost>> {\n const res = await doFetch('/blog-posts/list/paginated', config, {\n page: params.page,\n limit: params.limit,\n category: params.category,\n search: params.search,\n featured: params.featured,\n });\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/list/paginated -> ${res.status}`);\n return normalizeList(await res.json());\n}\n\n/** Fetch posts within a category by its slug. */\nexport async function getPostsByCategory(\n categorySlug: string,\n params: GetPostsParams = {},\n config?: Partial<BlogConfig>,\n): Promise<PaginatedResponse<BlogPost>> {\n const res = await doFetch(`/blog-posts/category/${encodeURIComponent(categorySlug)}`, config, {\n page: params.page,\n limit: params.limit,\n });\n if (!res.ok) {\n throw new Error(`[@gosalesdeal/blog] GET /blog-posts/category/${categorySlug} -> ${res.status}`);\n }\n return normalizeList(await res.json());\n}\n\n/** Slugs of all published posts (for static generation / sitemaps). */\nexport async function getPublishedSlugs(config?: Partial<BlogConfig>): Promise<string[]> {\n const res = await doFetch('/blog-posts/published/slugs', config);\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/published/slugs -> ${res.status}`);\n const body = (await res.json()) as BlogApiResponse<Array<{ slug: string } | string>>;\n const rows = body?.data ?? [];\n return rows.map((r) => (typeof r === 'string' ? r : r.slug)).filter(Boolean);\n}\n\n/** All blog categories. */\nexport async function getCategories(config?: Partial<BlogConfig>): Promise<BlogCategory[]> {\n const res = await doFetch('/blog-posts/categories', config);\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/categories -> ${res.status}`);\n const body = (await res.json()) as BlogApiResponse<BlogCategory[]>;\n return body?.data ?? [];\n}\n\n/** Best-effort view-count increment. Never throws. */\nexport async function incrementView(\n postId: string,\n config?: Partial<BlogConfig>,\n): Promise<void> {\n try {\n await doFetch(`/blog-posts/${encodeURIComponent(postId)}/view`, config, undefined, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ increment: 1 }),\n });\n } catch {\n // view tracking is non-critical\n }\n}\n\nexport interface BlogClient {\n getPostBySlug: (slug: string) => Promise<BlogPost | null>;\n getPosts: (params?: GetPostsParams) => Promise<PaginatedResponse<BlogPost>>;\n getPostsByCategory: (\n categorySlug: string,\n params?: GetPostsParams,\n ) => Promise<PaginatedResponse<BlogPost>>;\n getPublishedSlugs: () => Promise<string[]>;\n getCategories: () => Promise<BlogCategory[]>;\n incrementView: (postId: string) => Promise<void>;\n}\n\n/** Bind all fetchers to a single config. */\nexport function createBlogClient(config: Partial<BlogConfig>): BlogClient {\n return {\n getPostBySlug: (slug) => getPostBySlug(slug, config),\n getPosts: (params) => getPosts(params, config),\n getPostsByCategory: (categorySlug, params) => getPostsByCategory(categorySlug, params, config),\n getPublishedSlugs: () => getPublishedSlugs(config),\n getCategories: () => getCategories(config),\n incrementView: (postId) => incrementView(postId, config),\n };\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"blogClient.js","sources":["../../src/client/blogClient.ts"],"sourcesContent":["import { resolveConfig, type BlogConfig } from '../config';\nimport { generateHtmlFromSchema, getLandingPageSchema } from '../render/landingPageHtml';\nimport type {\n BlogPost,\n BlogCategory,\n ContentBlock,\n PaginatedResponse,\n PaginationMeta,\n BlogApiResponse,\n} from '../types/blog';\n\nexport interface GetPostsParams {\n page?: number;\n limit?: number;\n category?: string;\n search?: string;\n featured?: boolean;\n}\n\nconst EMPTY_PAGINATION: PaginationMeta = {\n total: 0,\n page: 1,\n limit: 0,\n pages: 0,\n hasNext: false,\n hasPrev: false,\n};\n\nfunction buildUrl(base: string, path: string, query?: Record<string, unknown>): string {\n const url = `${base.replace(/\\/$/, '')}${path}`;\n if (!query) return url;\n const search = new URLSearchParams();\n for (const [key, value] of Object.entries(query)) {\n if (value !== undefined && value !== null && value !== '') {\n search.set(key, String(value));\n }\n }\n const qs = search.toString();\n return qs ? `${url}?${qs}` : url;\n}\n\nasync function doFetch(\n path: string,\n config: Partial<BlogConfig> | undefined,\n query?: Record<string, unknown>,\n init?: RequestInit,\n): Promise<Response> {\n const cfg = resolveConfig(config);\n const fetchFn = cfg.fetch ?? globalThis.fetch;\n if (typeof fetchFn !== 'function') {\n throw new Error('[@gosalesdeal/blog] No fetch implementation available. Provide config.fetch.');\n }\n const headers: Record<string, string> = {\n Accept: 'application/json',\n ...(cfg.headers ?? {}),\n ...((init?.headers as Record<string, string>) ?? {}),\n };\n if (cfg.tenantId) headers['X-Tenant'] = cfg.tenantId;\n return fetchFn(buildUrl(cfg.apiBaseUrl, path, query), {\n ...init,\n headers,\n cache: 'no-store',\n });\n}\n\n/** Make API-relative / assets CDN media paths loadable against the blog API origin. */\nfunction resolveFeaturedImageUrl(\n url: string | undefined,\n apiBaseUrl: string,\n): string | undefined {\n const trimmed = String(url || '').trim();\n if (!trimmed) return undefined;\n if (trimmed.startsWith('data:')) return trimmed;\n\n const base = apiBaseUrl.replace(/\\/$/, '');\n const assetsBases = [\n 'https://assets.gosalesdeal.com/images/product',\n 'https://assets.gosalesdeal.tech/images/product',\n ];\n for (const assetsBase of assetsBases) {\n if (trimmed.startsWith(`${assetsBase}/`)) {\n const relative = trimmed.slice(assetsBase.length + 1);\n return base ? `${base}/uploads/${relative}` : `/uploads/${relative}`;\n }\n }\n\n if (/^https?:\\/\\//i.test(trimmed)) return trimmed;\n if (!base) return trimmed;\n if (trimmed.startsWith('/')) return `${base}${trimmed}`;\n return trimmed;\n}\n\n/** Sort blocks by display_order and guarantee an array. */\nfunction normalizePost(\n post: BlogPost | null | undefined,\n apiBaseUrl = '',\n): BlogPost | null {\n if (!post || typeof post !== 'object') return null;\n if (!('post_id' in post) && !('slug' in post)) return null;\n\n const blocks: ContentBlock[] = Array.isArray(post.content_blocks)\n ? post.content_blocks\n : Array.isArray(post.blocks)\n ? post.blocks\n : [];\n\n const landingSchema = getLandingPageSchema(post as Parameters<typeof getLandingPageSchema>[0]);\n const landingPageContent =\n blocks.length === 0 && landingSchema ? generateHtmlFromSchema(landingSchema) : null;\n\n const relationUrl = (post as any)?.featureImage?.url as string | undefined;\n const featured_image = resolveFeaturedImageUrl(\n relationUrl || post.featured_image,\n apiBaseUrl,\n );\n\n return {\n ...post,\n ...(featured_image ? { featured_image } : {}),\n content_blocks: [...blocks].sort(\n (a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),\n ),\n ...(landingPageContent?.html\n ? {\n landing_page_html: landingPageContent.html,\n landing_page_css: landingPageContent.css,\n }\n : {}),\n };\n}\n\n/** Unwrap `{data:{items,pagination}}`, `{items,pagination}`, `{list,pagination}`, or `{data:[...]}`. */\nfunction normalizePagination(\n raw: Record<string, unknown> | undefined,\n itemsLength: number,\n): PaginationMeta {\n if (!raw || typeof raw !== 'object') {\n return { ...EMPTY_PAGINATION, total: itemsLength, limit: itemsLength };\n }\n\n const page = Number(raw.page ?? raw.currentPage ?? 1);\n const limit = Number(raw.limit ?? raw.per_page ?? (itemsLength || 1));\n const total = Number(raw.total ?? raw.totalRecords ?? itemsLength);\n const pages = Number(\n raw.pages ?? raw.totalPages ?? (limit > 0 ? Math.ceil(total / limit) : 1),\n );\n const hasNext = raw.hasNext != null ? Boolean(raw.hasNext) : page < pages;\n const hasPrev = raw.hasPrev != null ? Boolean(raw.hasPrev) : page > 1;\n\n return { total, page, limit, pages, hasNext, hasPrev };\n}\n\nfunction normalizeList(\n body: unknown,\n apiBaseUrl = '',\n): PaginatedResponse<BlogPost> {\n const root = body as BlogApiResponse<unknown> &\n PaginatedResponse<BlogPost> & { list?: BlogPost[] };\n\n // Bot middleware returns 200 with a message and no posts.\n if (\n root &&\n typeof root === 'object' &&\n typeof root.message === 'string' &&\n !('list' in root) &&\n !('items' in root) &&\n !('data' in root)\n ) {\n return { items: [], pagination: EMPTY_PAGINATION };\n }\n\n const data = (root?.data ?? root) as\n | (PaginatedResponse<BlogPost> & { list?: BlogPost[] })\n | BlogPost[]\n | undefined;\n if (Array.isArray(data)) {\n return {\n items: data.map((p) => normalizePost(p, apiBaseUrl)!).filter(Boolean),\n pagination: { ...EMPTY_PAGINATION, total: data.length, limit: data.length },\n };\n }\n const rawItems = data?.items ?? data?.list ?? [];\n const items = Array.isArray(rawItems) ? rawItems : [];\n return {\n items: items.map((p) => normalizePost(p, apiBaseUrl)!).filter(Boolean),\n pagination: normalizePagination(\n data?.pagination as Record<string, unknown> | undefined,\n items.length,\n ),\n };\n}\n\n/** Fetch a single published post by slug. Returns null when not found. */\nexport async function getPostBySlug(\n slug: string,\n config?: Partial<BlogConfig>,\n): Promise<BlogPost | null> {\n const cfg = resolveConfig(config);\n const res = await doFetch(`/blog-posts/slug/${encodeURIComponent(slug)}`, config);\n if (res.status === 404) return null;\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/slug/${slug} -> ${res.status}`);\n const body = (await res.json()) as BlogApiResponse<BlogPost>;\n return normalizePost(body?.data ?? (body as unknown as BlogPost), cfg.apiBaseUrl);\n}\n\n/** Fetch a paginated list of posts. */\nexport async function getPosts(\n params: GetPostsParams = {},\n config?: Partial<BlogConfig>,\n): Promise<PaginatedResponse<BlogPost>> {\n const cfg = resolveConfig(config);\n const res = await doFetch('/blog-posts/list/paginated', config, {\n page: params.page,\n limit: params.limit,\n per_page: params.limit,\n category: params.category,\n search: params.search,\n featured: params.featured,\n status: 'published',\n });\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/list/paginated -> ${res.status}`);\n return normalizeList(await res.json(), cfg.apiBaseUrl);\n}\n\n/** Fetch posts within a category by its slug. */\nexport async function getPostsByCategory(\n categorySlug: string,\n params: GetPostsParams = {},\n config?: Partial<BlogConfig>,\n): Promise<PaginatedResponse<BlogPost>> {\n const cfg = resolveConfig(config);\n const res = await doFetch(`/blog-posts/category/${encodeURIComponent(categorySlug)}`, config, {\n page: params.page,\n limit: params.limit,\n });\n if (!res.ok) {\n throw new Error(`[@gosalesdeal/blog] GET /blog-posts/category/${categorySlug} -> ${res.status}`);\n }\n return normalizeList(await res.json(), cfg.apiBaseUrl);\n}\n\n/** Slugs of all published posts (for static generation / sitemaps). */\nexport async function getPublishedSlugs(config?: Partial<BlogConfig>): Promise<string[]> {\n const res = await doFetch('/blog-posts/published/slugs', config);\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/published/slugs -> ${res.status}`);\n const body = (await res.json()) as BlogApiResponse<Array<{ slug: string } | string>>;\n const rows = body?.data ?? [];\n return rows.map((r) => (typeof r === 'string' ? r : r.slug)).filter(Boolean);\n}\n\n/** All blog categories. */\nexport async function getCategories(config?: Partial<BlogConfig>): Promise<BlogCategory[]> {\n const res = await doFetch('/blog-posts/categories', config);\n if (!res.ok) throw new Error(`[@gosalesdeal/blog] GET /blog-posts/categories -> ${res.status}`);\n const body = (await res.json()) as BlogApiResponse<BlogCategory[]>;\n return body?.data ?? [];\n}\n\n/** Best-effort view-count increment. Never throws. */\nexport async function incrementView(\n postId: string,\n config?: Partial<BlogConfig>,\n): Promise<void> {\n try {\n await doFetch(`/blog-posts/${encodeURIComponent(postId)}/view`, config, undefined, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ increment: 1 }),\n });\n } catch {\n // view tracking is non-critical\n }\n}\n\nexport interface BlogClient {\n getPostBySlug: (slug: string) => Promise<BlogPost | null>;\n getPosts: (params?: GetPostsParams) => Promise<PaginatedResponse<BlogPost>>;\n getPostsByCategory: (\n categorySlug: string,\n params?: GetPostsParams,\n ) => Promise<PaginatedResponse<BlogPost>>;\n getPublishedSlugs: () => Promise<string[]>;\n getCategories: () => Promise<BlogCategory[]>;\n incrementView: (postId: string) => Promise<void>;\n}\n\n/** Bind all fetchers to a single config. */\nexport function createBlogClient(config: Partial<BlogConfig>): BlogClient {\n return {\n getPostBySlug: (slug) => getPostBySlug(slug, config),\n getPosts: (params) => getPosts(params, config),\n getPostsByCategory: (categorySlug, params) => getPostsByCategory(categorySlug, params, config),\n getPublishedSlugs: () => getPublishedSlugs(config),\n getCategories: () => getCategories(config),\n incrementView: (postId) => incrementView(postId, config),\n };\n}\n"],"names":[],"mappings":";;AAmBA,MAAM,mBAAmC;AAAA,EACvC,OAAO;AAAA,EACP,MAAM;AAAA,EACN,OAAO;AAAA,EACP,OAAO;AAAA,EACP,SAAS;AAAA,EACT,SAAS;AACX;AAEA,SAAS,SAAS,MAAc,MAAc,OAAyC;AACrF,QAAM,MAAM,GAAG,KAAK,QAAQ,OAAO,EAAE,CAAC,GAAG,IAAI;AAC7C,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,SAAS,IAAI,gBAAA;AACnB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,IAAI;AACzD,aAAO,IAAI,KAAK,OAAO,KAAK,CAAC;AAAA,IAC/B;AAAA,EACF;AACA,QAAM,KAAK,OAAO,SAAA;AAClB,SAAO,KAAK,GAAG,GAAG,IAAI,EAAE,KAAK;AAC/B;AAEA,eAAe,QACb,MACA,QACA,OACA,MACmB;AACnB,QAAM,MAAM,cAAc,MAAM;AAChC,QAAM,UAAU,IAAI,SAAS,WAAW;AACxC,MAAI,OAAO,YAAY,YAAY;AACjC,UAAM,IAAI,MAAM,8EAA8E;AAAA,EAChG;AACA,QAAM,UAAkC;AAAA,IACtC,QAAQ;AAAA,IACR,GAAI,IAAI,WAAW,CAAA;AAAA,IACnB,GAAK,MAAM,WAAsC,CAAA;AAAA,EAAC;AAEpD,MAAI,IAAI,SAAU,SAAQ,UAAU,IAAI,IAAI;AAC5C,SAAO,QAAQ,SAAS,IAAI,YAAY,MAAM,KAAK,GAAG;AAAA,IACpD,GAAG;AAAA,IACH;AAAA,IACA,OAAO;AAAA,EAAA,CACR;AACH;AAGA,SAAS,wBACP,KACA,YACoB;AACpB,QAAM,UAAU,OAAO,OAAO,EAAE,EAAE,KAAA;AAClC,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,QAAQ,WAAW,OAAO,EAAG,QAAO;AAExC,QAAM,OAAO,WAAW,QAAQ,OAAO,EAAE;AACzC,QAAM,cAAc;AAAA,IAClB;AAAA,IACA;AAAA,EAAA;AAEF,aAAW,cAAc,aAAa;AACpC,QAAI,QAAQ,WAAW,GAAG,UAAU,GAAG,GAAG;AACxC,YAAM,WAAW,QAAQ,MAAM,WAAW,SAAS,CAAC;AACpD,aAAO,OAAO,GAAG,IAAI,YAAY,QAAQ,KAAK,YAAY,QAAQ;AAAA,IACpE;AAAA,EACF;AAEA,MAAI,gBAAgB,KAAK,OAAO,EAAG,QAAO;AAC1C,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,QAAQ,WAAW,GAAG,UAAU,GAAG,IAAI,GAAG,OAAO;AACrD,SAAO;AACT;AAGA,SAAS,cACP,MACA,aAAa,IACI;AACjB,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,MAAI,EAAE,aAAa,SAAS,EAAE,UAAU,MAAO,QAAO;AAEtD,QAAM,SAAyB,MAAM,QAAQ,KAAK,cAAc,IAC5D,KAAK,iBACL,MAAM,QAAQ,KAAK,MAAM,IACvB,KAAK,SACL,CAAA;AAEN,QAAM,gBAAgB,qBAAqB,IAAkD;AAC7F,QAAM,qBACJ,OAAO,WAAW,KAAK,gBAAgB,uBAAuB,aAAa,IAAI;AAEjF,QAAM,cAAe,MAAc,cAAc;AACjD,QAAM,iBAAiB;AAAA,IACrB,eAAe,KAAK;AAAA,IACpB;AAAA,EAAA;AAGF,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAI,iBAAiB,EAAE,eAAA,IAAmB,CAAA;AAAA,IAC1C,gBAAgB,CAAC,GAAG,MAAM,EAAE;AAAA,MAC1B,CAAC,GAAG,OAAO,EAAE,iBAAiB,MAAM,EAAE,iBAAiB;AAAA,IAAA;AAAA,IAEzD,GAAI,oBAAoB,OACpB;AAAA,MACE,mBAAmB,mBAAmB;AAAA,MACtC,kBAAkB,mBAAmB;AAAA,IAAA,IAEvC,CAAA;AAAA,EAAC;AAET;AAGA,SAAS,oBACP,KACA,aACgB;AAChB,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,WAAO,EAAE,GAAG,kBAAkB,OAAO,aAAa,OAAO,YAAA;AAAA,EAC3D;AAEA,QAAM,OAAO,OAAO,IAAI,QAAQ,IAAI,eAAe,CAAC;AACpD,QAAM,QAAQ,OAAO,IAAI,SAAS,IAAI,aAAa,eAAe,EAAE;AACpE,QAAM,QAAQ,OAAO,IAAI,SAAS,IAAI,gBAAgB,WAAW;AACjE,QAAM,QAAQ;AAAA,IACZ,IAAI,SAAS,IAAI,eAAe,QAAQ,IAAI,KAAK,KAAK,QAAQ,KAAK,IAAI;AAAA,EAAA;AAEzE,QAAM,UAAU,IAAI,WAAW,OAAO,QAAQ,IAAI,OAAO,IAAI,OAAO;AACpE,QAAM,UAAU,IAAI,WAAW,OAAO,QAAQ,IAAI,OAAO,IAAI,OAAO;AAEpE,SAAO,EAAE,OAAO,MAAM,OAAO,OAAO,SAAS,QAAA;AAC/C;AAEA,SAAS,cACP,MACA,aAAa,IACgB;AAC7B,QAAM,OAAO;AAIb,MACE,QACA,OAAO,SAAS,YAChB,OAAO,KAAK,YAAY,YACxB,EAAE,UAAU,SACZ,EAAE,WAAW,SACb,EAAE,UAAU,OACZ;AACA,WAAO,EAAE,OAAO,IAAI,YAAY,iBAAA;AAAA,EAClC;AAEA,QAAM,OAAQ,MAAM,QAAQ;AAI5B,MAAI,MAAM,QAAQ,IAAI,GAAG;AACvB,WAAO;AAAA,MACL,OAAO,KAAK,IAAI,CAAC,MAAM,cAAc,GAAG,UAAU,CAAE,EAAE,OAAO,OAAO;AAAA,MACpE,YAAY,EAAE,GAAG,kBAAkB,OAAO,KAAK,QAAQ,OAAO,KAAK,OAAA;AAAA,IAAO;AAAA,EAE9E;AACA,QAAM,WAAW,MAAM,SAAS,MAAM,QAAQ,CAAA;AAC9C,QAAM,QAAQ,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAA;AACnD,SAAO;AAAA,IACL,OAAO,MAAM,IAAI,CAAC,MAAM,cAAc,GAAG,UAAU,CAAE,EAAE,OAAO,OAAO;AAAA,IACrE,YAAY;AAAA,MACV,MAAM;AAAA,MACN,MAAM;AAAA,IAAA;AAAA,EACR;AAEJ;AAGA,eAAsB,cACpB,MACA,QAC0B;AAC1B,QAAM,MAAM,cAAc,MAAM;AAChC,QAAM,MAAM,MAAM,QAAQ,oBAAoB,mBAAmB,IAAI,CAAC,IAAI,MAAM;AAChF,MAAI,IAAI,WAAW,IAAK,QAAO;AAC/B,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,4CAA4C,IAAI,OAAO,IAAI,MAAM,EAAE;AAChG,QAAM,OAAQ,MAAM,IAAI,KAAA;AACxB,SAAO,cAAc,MAAM,QAAS,MAA8B,IAAI,UAAU;AAClF;AAGA,eAAsB,SACpB,SAAyB,CAAA,GACzB,QACsC;AACtC,QAAM,MAAM,cAAc,MAAM;AAChC,QAAM,MAAM,MAAM,QAAQ,8BAA8B,QAAQ;AAAA,IAC9D,MAAM,OAAO;AAAA,IACb,OAAO,OAAO;AAAA,IACd,UAAU,OAAO;AAAA,IACjB,UAAU,OAAO;AAAA,IACjB,QAAQ,OAAO;AAAA,IACf,UAAU,OAAO;AAAA,IACjB,QAAQ;AAAA,EAAA,CACT;AACD,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,yDAAyD,IAAI,MAAM,EAAE;AAClG,SAAO,cAAc,MAAM,IAAI,KAAA,GAAQ,IAAI,UAAU;AACvD;AAGA,eAAsB,mBACpB,cACA,SAAyB,CAAA,GACzB,QACsC;AACtC,QAAM,MAAM,cAAc,MAAM;AAChC,QAAM,MAAM,MAAM,QAAQ,wBAAwB,mBAAmB,YAAY,CAAC,IAAI,QAAQ;AAAA,IAC5F,MAAM,OAAO;AAAA,IACb,OAAO,OAAO;AAAA,EAAA,CACf;AACD,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,MAAM,gDAAgD,YAAY,OAAO,IAAI,MAAM,EAAE;AAAA,EACjG;AACA,SAAO,cAAc,MAAM,IAAI,KAAA,GAAQ,IAAI,UAAU;AACvD;AAGA,eAAsB,kBAAkB,QAAiD;AACvF,QAAM,MAAM,MAAM,QAAQ,+BAA+B,MAAM;AAC/D,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,0DAA0D,IAAI,MAAM,EAAE;AACnG,QAAM,OAAQ,MAAM,IAAI,KAAA;AACxB,QAAM,OAAO,MAAM,QAAQ,CAAA;AAC3B,SAAO,KAAK,IAAI,CAAC,MAAO,OAAO,MAAM,WAAW,IAAI,EAAE,IAAK,EAAE,OAAO,OAAO;AAC7E;AAGA,eAAsB,cAAc,QAAuD;AACzF,QAAM,MAAM,MAAM,QAAQ,0BAA0B,MAAM;AAC1D,MAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,qDAAqD,IAAI,MAAM,EAAE;AAC9F,QAAM,OAAQ,MAAM,IAAI,KAAA;AACxB,SAAO,MAAM,QAAQ,CAAA;AACvB;AAGA,eAAsB,cACpB,QACA,QACe;AACf,MAAI;AACF,UAAM,QAAQ,eAAe,mBAAmB,MAAM,CAAC,SAAS,QAAQ,QAAW;AAAA,MACjF,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAA;AAAA,MAC3B,MAAM,KAAK,UAAU,EAAE,WAAW,GAAG;AAAA,IAAA,CACtC;AAAA,EACH,QAAQ;AAAA,EAER;AACF;AAeO,SAAS,iBAAiB,QAAyC;AACxE,SAAO;AAAA,IACL,eAAe,CAAC,SAAS,cAAc,MAAM,MAAM;AAAA,IACnD,UAAU,CAAC,WAAW,SAAS,QAAQ,MAAM;AAAA,IAC7C,oBAAoB,CAAC,cAAc,WAAW,mBAAmB,cAAc,QAAQ,MAAM;AAAA,IAC7F,mBAAmB,MAAM,kBAAkB,MAAM;AAAA,IACjD,eAAe,MAAM,cAAc,MAAM;AAAA,IACzC,eAAe,CAAC,WAAW,cAAc,QAAQ,MAAM;AAAA,EAAA;AAE3D;"}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { ReactNode } from 'react';
|
|
2
2
|
import { BlogConfig } from '../config';
|
|
3
3
|
import { SeoOptions } from '../seo';
|
|
4
|
+
import { BlogPost as BlogPostData } from '../types/blog';
|
|
4
5
|
export interface BlogPostProps {
|
|
5
6
|
/** Slug of the post to render. */
|
|
6
7
|
slug: string;
|
|
8
|
+
/** Pre-fetched post (skips the slug API call when provided). */
|
|
9
|
+
post?: BlogPostData;
|
|
7
10
|
/** API base URL (overrides configured/env default). */
|
|
8
11
|
apiBaseUrl?: string;
|
|
9
12
|
/** Tenant id sent as `X-Tenant`. */
|
|
@@ -18,8 +21,16 @@ export interface BlogPostProps {
|
|
|
18
21
|
seo?: SeoOptions;
|
|
19
22
|
/** Locale for date formatting. Default 'en-US'. */
|
|
20
23
|
locale?: string;
|
|
24
|
+
/** Show the post title in the header. Default true. */
|
|
25
|
+
showTitle?: boolean;
|
|
26
|
+
/** Show the featured image when available. Default true. */
|
|
27
|
+
showFeatured?: boolean;
|
|
28
|
+
/** Show author, date, and reading time. Default true. */
|
|
29
|
+
showMeta?: boolean;
|
|
30
|
+
/** Show the excerpt. Default true. */
|
|
31
|
+
showExcerpt?: boolean;
|
|
21
32
|
/** Rendered when the post is not found (consumer typically calls notFound()). */
|
|
22
33
|
fallback?: ReactNode;
|
|
23
34
|
}
|
|
24
35
|
/** Async server component: fetches a post by slug and renders it. */
|
|
25
|
-
export declare function BlogPost({ slug, apiBaseUrl, tenantId, config, trackView, renderJsonLd, seo, locale, fallback, }: BlogPostProps): Promise<import("react").JSX.Element>;
|
|
36
|
+
export declare function BlogPost({ slug, post: initialPost, apiBaseUrl, tenantId, config, trackView, renderJsonLd, seo, locale, showTitle, showFeatured, showMeta, showExcerpt, fallback, }: BlogPostProps): Promise<import("react").JSX.Element>;
|