@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.
Files changed (2) hide show
  1. package/README.md +306 -124
  2. 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
- ### 1. Defining Types and Fetching CMS Articles with `getContent<T, P>`
80
+ ### 🏗️ Clean Architecture Pattern: Dedicated Data Access Layer (`NewsApi`)
81
81
 
82
- ```tsx
83
- // app/news/page.tsx (Server Component)
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 formatted structure of your CMS content type
87
- interface NewsArticle {
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
- publishDate: string;
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
- // 2. Optional: Define normalized helper fields computed on the fly
96
- interface NewsNormalized {
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
- readTimeEstimate: number;
112
+ body: string;
113
+ readTime: number;
114
+ publishedAt?: number;
99
115
  }
100
116
 
101
- export default async function NewsPage() {
102
- // Call getContent with exact generic parameters: <T, P>
103
- const response = await podSdk.cms.getContent<NewsArticle, NewsNormalized>(
104
- {
105
- contentTypeUniqueId: 'company_news',
106
- size: 10,
107
- offset: 0,
108
- },
109
- {
110
- // Optional: compute custom normalized fields
111
- normalizer: (item) => ({
112
- summary: item.formatted.lead || item.formatted.title,
113
- readTimeEstimate: Math.ceil((item.formatted.body?.length || 0) / 500),
114
- }),
115
- // Next.js ISR options
116
- revalidate: 1800,
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
- <div className="container mx-auto p-6">
122
- <h1 className="text-2xl font-bold mb-4">Latest News</h1>
123
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
124
- {response.result.map((item) => (
125
- <article key={item.id} className="border rounded-lg p-4 shadow">
126
- {/* item.formatted is 100% typed as NewsArticle */}
127
- {item.formatted.coverImage && (
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={item.formatted.coverImage}
130
- alt={item.formatted.title}
131
- className="w-full h-48 object-cover rounded"
252
+ src={news.coverImage}
253
+ alt={news.title}
254
+ className="w-full h-48 object-cover"
132
255
  />
133
256
  )}
134
- <h2 className="text-xl font-semibold mt-2">{item.formatted.title}</h2>
135
- <p className="text-gray-600 mt-1">{item.__normalized.summary}</p>
136
- <span className="text-xs text-gray-400">
137
- Estimated read: {item.__normalized.readTimeEstimate} min
138
- </span>
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
- </div>
272
+ </main>
143
273
  );
144
274
  }
145
275
  ```
146
276
 
147
- ### 2. Fetching Single Content by Entity ID: `getContentByEntityId<T>`
277
+ ---
148
278
 
149
- ```typescript
150
- // app/news/[id]/page.tsx
151
- import { podSdk } from '@/lib/pod';
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 ArticleDetail {
155
- title: string;
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 }: { params: { id: string } }) {
162
- const res = await podSdk.cms.getContentByEntityId<ArticleDetail>({
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
- const article = res.result?.[0];
168
- if (!article) notFound();
293
+ if (!news) {
294
+ notFound();
295
+ }
169
296
 
170
297
  return (
171
- <main>
172
- <h1>{article.formatted.title}</h1>
173
- <p>By {article.formatted.author}</p>
174
- <div dangerouslySetInnerHTML={{ __html: article.formatted.content }} />
175
- </main>
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
- ### 3. Adding and Editing CMS Content: `addContent<T>` & `editContent<T>`
314
+ ---
315
+
316
+ #### Step 4: Server Action for CMS Publishing (`app/actions/news.ts`)
181
317
 
182
318
  ```typescript
183
- // app/actions/cms.ts
319
+ // app/actions/news.ts
184
320
  'use server';
185
321
 
186
- import { podSdk } from '@/lib/pod';
322
+ import { NewsApi } from '@/services/newsApi';
323
+ import { revalidatePath } from 'next/cache';
187
324
 
188
- export async function createNewsArticle(data: {
189
- title: string;
190
- body: string;
191
- imageHash: string;
192
- }) {
193
- return podSdk.cms.addContent({
194
- contentTypeUniqueId: 'company_news',
195
- body: {
196
- name: data.title,
197
- fieldCode: ['title', 'body', 'coverImage'],
198
- fieldValue: [data.title, data.body, data.imageHash],
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 in seconds:
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
- // lib/products.ts
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 "product_item"
273
- export const productCrud = podSdk.createCrud<ProductData>({
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
- All methods on `productCrud` are fully generic:
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
- ```typescript
283
- // app/actions/products.ts
284
- 'use server';
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
- import { productCrud } from '@/lib/products';
287
- import { revalidatePath } from 'next/cache';
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
- // 1. Fetch all products
290
- export async function getProducts() {
291
- const result = await productCrud.getAll();
292
- return result.result.map((r) => r.item.metadata.data);
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
- // 2. Get single product by numerical ID
296
- export async function getProductById(id: number) {
297
- const res = await productCrud.getById(id);
298
- return res.result?.[0]?.metadata.data;
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
- // 3. Create product and automatically bind entityId
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
- revalidatePath('/products');
312
- return res;
313
- }
469
+ #### Step 2: Consume `ProductsApi` in Server Components (`app/products/page.tsx`)
314
470
 
315
- // 4. Update product
316
- export async function updateProduct(entityId: number, data: ProductData) {
317
- return productCrud.update(entityId, data);
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
- // 5. Archive (soft-disable without deleting)
321
- export async function archiveProduct(entityId: number, data: ProductData) {
322
- return productCrud.archive(entityId, data);
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
- // 6. Delete permanently
326
- export async function deleteProduct(entityId: number) {
327
- return productCrud.delete(entityId);
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.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",