@rezamirzapour/pod-sdk 1.0.0 → 1.0.2
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 +306 -124
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,131 +77,266 @@ export const podSdk = createPodSdk({
|
|
|
77
77
|
|
|
78
78
|
POD CMS stores dynamic fields inside a `metadata.content` array of `{ code, value, type }` objects. `@rezamirzapour/pod-sdk` automatically formats these into clean typed key-value pairs (`item.formatted`) and allows an optional custom `normalizer` function (`item.__normalized`).
|
|
79
79
|
|
|
80
|
-
###
|
|
80
|
+
### 🏗️ Clean Architecture Pattern: Dedicated Data Access Layer (`NewsApi`)
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
In enterprise Next.js applications, avoid calling SDK clients directly inside UI components. Instead, encapsulate CMS queries, generics, normalizers, and caching logic inside a dedicated Data Access Layer (`services/newsApi.ts`).
|
|
83
|
+
|
|
84
|
+
#### Step 1: Create the Data Access Layer (`services/newsApi.ts`)
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// services/newsApi.ts
|
|
84
88
|
import { podSdk } from '@/lib/pod';
|
|
85
89
|
|
|
86
|
-
// 1. Define the
|
|
87
|
-
interface
|
|
90
|
+
// 1. Define the raw CMS Formatted Metadata schema (T)
|
|
91
|
+
export interface NewsArticleFormatted {
|
|
88
92
|
title: string;
|
|
89
93
|
coverImage: string; // Auto-transformed from hash to full Podspace CDN URL!
|
|
90
94
|
lead: string;
|
|
91
95
|
body: string;
|
|
92
|
-
|
|
96
|
+
author?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Define computed / normalized domain attributes (P)
|
|
100
|
+
export interface NewsArticleNormalized {
|
|
101
|
+
summary: string;
|
|
102
|
+
readTime: number;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
|
-
//
|
|
96
|
-
interface
|
|
105
|
+
// 3. Define the Clean Domain Model consumed by your React Components
|
|
106
|
+
export interface NewsItem {
|
|
107
|
+
id: number;
|
|
108
|
+
entityId: number;
|
|
109
|
+
title: string;
|
|
110
|
+
coverImage: string;
|
|
97
111
|
summary: string;
|
|
98
|
-
|
|
112
|
+
body: string;
|
|
113
|
+
readTime: number;
|
|
114
|
+
publishedAt?: number;
|
|
99
115
|
}
|
|
100
116
|
|
|
101
|
-
export
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
export interface GetNewsListParams {
|
|
118
|
+
page?: number;
|
|
119
|
+
pageSize?: number;
|
|
120
|
+
tagTrees?: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class NewsApi {
|
|
124
|
+
private static readonly CONTENT_TYPE = 'company_news';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fetches published news articles with Next.js App Router ISR Caching
|
|
128
|
+
*/
|
|
129
|
+
public static async getNewsList({
|
|
130
|
+
page = 1,
|
|
131
|
+
pageSize = 10,
|
|
132
|
+
tagTrees,
|
|
133
|
+
}: GetNewsListParams = {}): Promise<{ items: NewsItem[]; total: number }> {
|
|
134
|
+
const offset = (page - 1) * pageSize;
|
|
135
|
+
|
|
136
|
+
// Call getContent with exact generics: <NewsArticleFormatted, NewsArticleNormalized>
|
|
137
|
+
const response = await podSdk.cms.getContent<NewsArticleFormatted, NewsArticleNormalized>(
|
|
138
|
+
{
|
|
139
|
+
contentTypeUniqueId: this.CONTENT_TYPE,
|
|
140
|
+
size: pageSize,
|
|
141
|
+
offset,
|
|
142
|
+
tagTrees,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
// Custom normalizer executed during data formatting
|
|
146
|
+
normalizer: (item) => ({
|
|
147
|
+
summary: item.formatted.lead || item.formatted.title,
|
|
148
|
+
readTime: Math.max(1, Math.ceil((item.formatted.body?.length || 0) / 500)),
|
|
149
|
+
}),
|
|
150
|
+
revalidate: 1800, // Next.js ISR: cache for 30 minutes
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (response.hasError || !Array.isArray(response.result)) {
|
|
155
|
+
return { items: [], total: 0 };
|
|
117
156
|
}
|
|
118
|
-
|
|
157
|
+
|
|
158
|
+
const items: NewsItem[] = response.result.map((item) => ({
|
|
159
|
+
id: item.id,
|
|
160
|
+
entityId: item.entityId,
|
|
161
|
+
title: item.formatted.title,
|
|
162
|
+
coverImage: item.formatted.coverImage,
|
|
163
|
+
summary: item.__normalized.summary,
|
|
164
|
+
body: item.formatted.body,
|
|
165
|
+
readTime: item.__normalized.readTime,
|
|
166
|
+
publishedAt: item.timestamp,
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
items,
|
|
171
|
+
total: response.count || items.length,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fetches a single news article by its entity ID
|
|
177
|
+
*/
|
|
178
|
+
public static async getNewsById(entityId: number): Promise<NewsItem | null> {
|
|
179
|
+
const response = await podSdk.cms.getContentByEntityId<NewsArticleFormatted, NewsArticleNormalized>({
|
|
180
|
+
entityId,
|
|
181
|
+
contentTypeUniqueId: this.CONTENT_TYPE,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const item = response.result?.[0];
|
|
185
|
+
if (!item) return null;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
id: item.id,
|
|
189
|
+
entityId: item.entityId,
|
|
190
|
+
title: item.formatted.title,
|
|
191
|
+
coverImage: item.formatted.coverImage,
|
|
192
|
+
summary: item.formatted.lead || item.formatted.title,
|
|
193
|
+
body: item.formatted.body,
|
|
194
|
+
readTime: Math.max(1, Math.ceil((item.formatted.body?.length || 0) / 500)),
|
|
195
|
+
publishedAt: item.timestamp,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Publishes a new article to CMS
|
|
201
|
+
*/
|
|
202
|
+
public static async publishNews(data: {
|
|
203
|
+
title: string;
|
|
204
|
+
lead: string;
|
|
205
|
+
body: string;
|
|
206
|
+
imageHash: string;
|
|
207
|
+
}) {
|
|
208
|
+
return podSdk.cms.addContent({
|
|
209
|
+
contentTypeUniqueId: this.CONTENT_TYPE,
|
|
210
|
+
body: {
|
|
211
|
+
name: data.title,
|
|
212
|
+
fieldCode: ['title', 'lead', 'body', 'coverImage'],
|
|
213
|
+
fieldValue: [data.title, data.lead, data.body, data.imageHash],
|
|
214
|
+
canComment: true,
|
|
215
|
+
canLike: true,
|
|
216
|
+
enable: true,
|
|
217
|
+
isSearchable: true,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
#### Step 2: Consume `NewsApi` in Next.js Server Components (`app/news/page.tsx`)
|
|
227
|
+
|
|
228
|
+
Notice how clean the UI layer becomes! No low-level SDK parameters, headers, or raw hashes leaked into your views:
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
// app/news/page.tsx (Server Component)
|
|
232
|
+
import { NewsApi } from '@/services/newsApi';
|
|
233
|
+
import Link from 'next/link';
|
|
234
|
+
|
|
235
|
+
interface NewsPageProps {
|
|
236
|
+
searchParams: { page?: string };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export default async function NewsPage({ searchParams }: NewsPageProps) {
|
|
240
|
+
const page = Number(searchParams.page) || 1;
|
|
241
|
+
const { items, total } = await NewsApi.getNewsList({ page, pageSize: 12 });
|
|
119
242
|
|
|
120
243
|
return (
|
|
121
|
-
<
|
|
122
|
-
<h1 className="text-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{
|
|
244
|
+
<main className="container mx-auto py-8 px-4">
|
|
245
|
+
<h1 className="text-3xl font-bold mb-6">Company News</h1>
|
|
246
|
+
|
|
247
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
248
|
+
{items.map((news) => (
|
|
249
|
+
<article key={news.entityId} className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
|
|
250
|
+
{news.coverImage && (
|
|
128
251
|
<img
|
|
129
|
-
src={
|
|
130
|
-
alt={
|
|
131
|
-
className="w-full h-48 object-cover
|
|
252
|
+
src={news.coverImage}
|
|
253
|
+
alt={news.title}
|
|
254
|
+
className="w-full h-48 object-cover"
|
|
132
255
|
/>
|
|
133
256
|
)}
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
257
|
+
<div className="p-4">
|
|
258
|
+
<h2 className="text-xl font-semibold mb-2">
|
|
259
|
+
<Link href={`/news/${news.entityId}`}>{news.title}</Link>
|
|
260
|
+
</h2>
|
|
261
|
+
<p className="text-gray-600 text-sm line-clamp-2">{news.summary}</p>
|
|
262
|
+
<div className="mt-4 flex items-center justify-between text-xs text-gray-400">
|
|
263
|
+
<span>{news.readTime} min read</span>
|
|
264
|
+
<Link href={`/news/${news.entityId}`} className="text-blue-600 font-medium hover:underline">
|
|
265
|
+
Read more →
|
|
266
|
+
</Link>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
139
269
|
</article>
|
|
140
270
|
))}
|
|
141
271
|
</div>
|
|
142
|
-
</
|
|
272
|
+
</main>
|
|
143
273
|
);
|
|
144
274
|
}
|
|
145
275
|
```
|
|
146
276
|
|
|
147
|
-
|
|
277
|
+
---
|
|
148
278
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
279
|
+
#### Step 3: Detail Page with `NewsApi.getNewsById` (`app/news/[id]/page.tsx`)
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
// app/news/[id]/page.tsx (Server Component)
|
|
283
|
+
import { NewsApi } from '@/services/newsApi';
|
|
152
284
|
import { notFound } from 'next/navigation';
|
|
153
285
|
|
|
154
|
-
interface
|
|
155
|
-
|
|
156
|
-
content: string;
|
|
157
|
-
author: string;
|
|
158
|
-
coverImage: string;
|
|
286
|
+
interface NewsDetailPageProps {
|
|
287
|
+
params: { id: string };
|
|
159
288
|
}
|
|
160
289
|
|
|
161
|
-
export default async function NewsDetailPage({ params }:
|
|
162
|
-
const
|
|
163
|
-
entityId: Number(params.id),
|
|
164
|
-
contentTypeUniqueId: 'company_news',
|
|
165
|
-
});
|
|
290
|
+
export default async function NewsDetailPage({ params }: NewsDetailPageProps) {
|
|
291
|
+
const news = await NewsApi.getNewsById(Number(params.id));
|
|
166
292
|
|
|
167
|
-
|
|
168
|
-
|
|
293
|
+
if (!news) {
|
|
294
|
+
notFound();
|
|
295
|
+
}
|
|
169
296
|
|
|
170
297
|
return (
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
298
|
+
<article className="max-w-3xl mx-auto py-10 px-4">
|
|
299
|
+
{news.coverImage && (
|
|
300
|
+
<img
|
|
301
|
+
src={news.coverImage}
|
|
302
|
+
alt={news.title}
|
|
303
|
+
className="w-full h-72 object-cover rounded-xl mb-6"
|
|
304
|
+
/>
|
|
305
|
+
)}
|
|
306
|
+
<h1 className="text-3xl font-bold mb-3">{news.title}</h1>
|
|
307
|
+
<p className="text-sm text-gray-500 mb-6">{news.readTime} min read</p>
|
|
308
|
+
<div className="prose lg:prose-lg" dangerouslySetInnerHTML={{ __html: news.body }} />
|
|
309
|
+
</article>
|
|
176
310
|
);
|
|
177
311
|
}
|
|
178
312
|
```
|
|
179
313
|
|
|
180
|
-
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
#### Step 4: Server Action for CMS Publishing (`app/actions/news.ts`)
|
|
181
317
|
|
|
182
318
|
```typescript
|
|
183
|
-
// app/actions/
|
|
319
|
+
// app/actions/news.ts
|
|
184
320
|
'use server';
|
|
185
321
|
|
|
186
|
-
import {
|
|
322
|
+
import { NewsApi } from '@/services/newsApi';
|
|
323
|
+
import { revalidatePath } from 'next/cache';
|
|
187
324
|
|
|
188
|
-
export async function
|
|
189
|
-
title
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
canComment: true,
|
|
200
|
-
canLike: true,
|
|
201
|
-
enable: true,
|
|
202
|
-
isSearchable: true,
|
|
203
|
-
},
|
|
325
|
+
export async function publishArticle(formData: FormData) {
|
|
326
|
+
const title = formData.get('title') as string;
|
|
327
|
+
const lead = formData.get('lead') as string;
|
|
328
|
+
const body = formData.get('body') as string;
|
|
329
|
+
const imageHash = formData.get('imageHash') as string;
|
|
330
|
+
|
|
331
|
+
const result = await NewsApi.publishNews({
|
|
332
|
+
title,
|
|
333
|
+
lead,
|
|
334
|
+
body,
|
|
335
|
+
imageHash,
|
|
204
336
|
});
|
|
337
|
+
|
|
338
|
+
revalidatePath('/news');
|
|
339
|
+
return { success: !result.hasError };
|
|
205
340
|
}
|
|
206
341
|
```
|
|
207
342
|
|
|
@@ -254,10 +389,12 @@ export async function createBlogPost(meta: BlogPostMeta) {
|
|
|
254
389
|
|
|
255
390
|
### 2. High-Level Generic CRUD Repository (`createCrud<DataType>`)
|
|
256
391
|
|
|
257
|
-
Create an ORM-like typed repository for any entity type
|
|
392
|
+
Create an ORM-like typed repository for any entity type and expose it through a dedicated service layer:
|
|
393
|
+
|
|
394
|
+
#### Step 1: Create the Products Repository & API (`services/productsApi.ts`)
|
|
258
395
|
|
|
259
396
|
```typescript
|
|
260
|
-
//
|
|
397
|
+
// services/productsApi.ts
|
|
261
398
|
import { podSdk } from '@/lib/pod';
|
|
262
399
|
|
|
263
400
|
export interface ProductData {
|
|
@@ -269,62 +406,107 @@ export interface ProductData {
|
|
|
269
406
|
entityId?: number;
|
|
270
407
|
}
|
|
271
408
|
|
|
272
|
-
// Instantiate typed CRUD service for "
|
|
273
|
-
|
|
409
|
+
// 1. Instantiate typed CRUD service for "product_entity"
|
|
410
|
+
const productCrud = podSdk.createCrud<ProductData>({
|
|
274
411
|
name: 'product_entity',
|
|
275
412
|
type: 'commerce',
|
|
276
413
|
detailedType: 'gadgets',
|
|
277
414
|
});
|
|
278
|
-
```
|
|
279
415
|
|
|
280
|
-
|
|
416
|
+
// 2. Encapsulate business queries and domain operations
|
|
417
|
+
export class ProductsApi {
|
|
418
|
+
/**
|
|
419
|
+
* Retrieves all products formatted as clean domain objects
|
|
420
|
+
*/
|
|
421
|
+
public static async getProducts(): Promise<ProductData[]> {
|
|
422
|
+
const result = await productCrud.getAll();
|
|
423
|
+
return result.result.map((r) => r.item.metadata.data);
|
|
424
|
+
}
|
|
281
425
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
426
|
+
/**
|
|
427
|
+
* Retrieves a single product by numerical entity ID
|
|
428
|
+
*/
|
|
429
|
+
public static async getProductById(id: number): Promise<ProductData | null> {
|
|
430
|
+
const res = await productCrud.getById(id);
|
|
431
|
+
return res.result?.[0]?.metadata.data || null;
|
|
432
|
+
}
|
|
285
433
|
|
|
286
|
-
|
|
287
|
-
|
|
434
|
+
/**
|
|
435
|
+
* Creates a product and automatically binds the generated entityId
|
|
436
|
+
*/
|
|
437
|
+
public static async createProduct(data: Omit<ProductData, 'entityId'>) {
|
|
438
|
+
return productCrud.createAndBindEntityId({
|
|
439
|
+
...data,
|
|
440
|
+
inStock: true,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
288
443
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Updates an existing product
|
|
446
|
+
*/
|
|
447
|
+
public static async updateProduct(entityId: number, data: ProductData) {
|
|
448
|
+
return productCrud.update(entityId, data);
|
|
449
|
+
}
|
|
294
450
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
451
|
+
/**
|
|
452
|
+
* Soft-archives a product without deleting history
|
|
453
|
+
*/
|
|
454
|
+
public static async archiveProduct(entityId: number, data: ProductData) {
|
|
455
|
+
return productCrud.archive(entityId, data);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Deletes a product permanently
|
|
460
|
+
*/
|
|
461
|
+
public static async deleteProduct(entityId: number) {
|
|
462
|
+
return productCrud.delete(entityId);
|
|
463
|
+
}
|
|
299
464
|
}
|
|
465
|
+
```
|
|
300
466
|
|
|
301
|
-
|
|
302
|
-
export async function saveNewProduct(data: { title: string; sku: string; price: number }) {
|
|
303
|
-
const res = await productCrud.createAndBindEntityId({
|
|
304
|
-
title: data.title,
|
|
305
|
-
sku: data.sku,
|
|
306
|
-
price: data.price,
|
|
307
|
-
inStock: true,
|
|
308
|
-
category: 'electronics',
|
|
309
|
-
});
|
|
467
|
+
---
|
|
310
468
|
|
|
311
|
-
|
|
312
|
-
return res;
|
|
313
|
-
}
|
|
469
|
+
#### Step 2: Consume `ProductsApi` in Server Components (`app/products/page.tsx`)
|
|
314
470
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
471
|
+
```tsx
|
|
472
|
+
// app/products/page.tsx (Server Component)
|
|
473
|
+
import { ProductsApi } from '@/services/productsApi';
|
|
474
|
+
|
|
475
|
+
export default async function ProductsPage() {
|
|
476
|
+
const products = await ProductsApi.getProducts();
|
|
319
477
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
478
|
+
return (
|
|
479
|
+
<main className="container mx-auto py-8 px-4">
|
|
480
|
+
<h1 className="text-2xl font-bold mb-6">Products Catalog</h1>
|
|
481
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
482
|
+
{products.map((p) => (
|
|
483
|
+
<div key={p.sku} className="border p-4 rounded-lg shadow-sm">
|
|
484
|
+
<h2 className="font-semibold text-lg">{p.title}</h2>
|
|
485
|
+
<p className="text-gray-500 text-sm">SKU: {p.sku}</p>
|
|
486
|
+
<p className="text-blue-600 font-bold mt-2">${p.price.toLocaleString()}</p>
|
|
487
|
+
</div>
|
|
488
|
+
))}
|
|
489
|
+
</div>
|
|
490
|
+
</main>
|
|
491
|
+
);
|
|
323
492
|
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
---
|
|
324
496
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
497
|
+
#### Step 3: Mutate via Server Actions (`app/actions/products.ts`)
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// app/actions/products.ts
|
|
501
|
+
'use server';
|
|
502
|
+
|
|
503
|
+
import { ProductsApi } from '@/services/productsApi';
|
|
504
|
+
import { revalidatePath } from 'next/cache';
|
|
505
|
+
|
|
506
|
+
export async function addProduct(data: { title: string; sku: string; price: number; category: string }) {
|
|
507
|
+
const result = await ProductsApi.createProduct(data);
|
|
508
|
+
revalidatePath('/products');
|
|
509
|
+
return result;
|
|
328
510
|
}
|
|
329
511
|
```
|
|
330
512
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezamirzapour/pod-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Unified, type-safe enterprise SDK for POD Platform microservices (SSO, CustomPost, Podspace, Podform, Notification, Social, CMS, IUMS) powered by @rezamirzapour/http.",
|
|
5
5
|
"author": "Reza",
|
|
6
6
|
"license": "MIT",
|