perspectapi-ts-sdk 1.1.1

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/src/loaders.ts ADDED
@@ -0,0 +1,644 @@
1
+ /**
2
+ * High-level data loading helpers that wrap the PerspectAPI SDK clients.
3
+ * These helpers provide convenient product and content loading utilities with
4
+ * graceful fallbacks that can be reused across applications.
5
+ */
6
+
7
+ import type { PerspectApiClient } from './perspect-api-client';
8
+ import type {
9
+ ApiResponse,
10
+ MediaItem,
11
+ PaginatedResponse,
12
+ Product,
13
+ ProductQueryParams,
14
+ Content,
15
+ ContentStatus,
16
+ ContentType,
17
+ BlogPost,
18
+ CreateCheckoutSessionRequest,
19
+ CheckoutSession,
20
+ CheckoutMetadata
21
+ } from './types';
22
+
23
+ /**
24
+ * Logger interface so consumers can supply custom logging behaviour (or noop).
25
+ */
26
+ export interface LoaderLogger {
27
+ debug?(...args: unknown[]): void;
28
+ info?(...args: unknown[]): void;
29
+ warn?(...args: unknown[]): void;
30
+ error?(...args: unknown[]): void;
31
+ }
32
+
33
+ const noopLogger: LoaderLogger = {};
34
+
35
+ const log = (
36
+ logger: LoaderLogger | undefined,
37
+ level: keyof LoaderLogger,
38
+ ...args: unknown[]
39
+ ) => {
40
+ const target = logger?.[level];
41
+ if (typeof target === 'function') {
42
+ target(...args);
43
+ }
44
+ };
45
+
46
+ const isMediaItem = (value: unknown): value is MediaItem => {
47
+ if (!value || typeof value !== 'object') {
48
+ return false;
49
+ }
50
+ const media = value as Record<string, unknown>;
51
+ return typeof media.link === 'string' && typeof media.media_id === 'string';
52
+ };
53
+
54
+ const normalizeMediaList = (rawMedia: unknown): MediaItem[] => {
55
+ if (!Array.isArray(rawMedia)) {
56
+ return [];
57
+ }
58
+
59
+ if (rawMedia.length > 0 && Array.isArray(rawMedia[0])) {
60
+ return (rawMedia as unknown[])
61
+ .flat()
62
+ .filter(isMediaItem);
63
+ }
64
+
65
+ return (rawMedia as unknown[]).filter(isMediaItem);
66
+ };
67
+
68
+ const normalizeQueryParamList = (
69
+ value: string | number | Array<string | number> | undefined
70
+ ): string | undefined => {
71
+ if (value === undefined || value === null) {
72
+ return undefined;
73
+ }
74
+
75
+ const values = Array.isArray(value) ? value : [value];
76
+ const normalized = values
77
+ .map(item => String(item).trim())
78
+ .filter(item => item.length > 0);
79
+
80
+ return normalized.length > 0 ? normalized.join(',') : undefined;
81
+ };
82
+
83
+ /**
84
+ * Transform a PerspectAPI product payload into a friendlier shape for UI layers.
85
+ */
86
+ export const transformProduct = (
87
+ perspectProduct: any,
88
+ logger?: LoaderLogger
89
+ ): Product => {
90
+ const rawProduct = perspectProduct ?? {};
91
+
92
+ const productId =
93
+ rawProduct.product_id ?? rawProduct.id ?? rawProduct.sku ?? 'unknown';
94
+ const productName =
95
+ rawProduct.product ?? rawProduct.name ?? rawProduct.title ?? 'Untitled';
96
+ const productSlug =
97
+ rawProduct.slug ??
98
+ rawProduct.product_slug ??
99
+ (productId ? `product-${productId}` : 'product');
100
+ const slugPrefix =
101
+ rawProduct.slug_prefix ?? rawProduct.slugPrefix ?? rawProduct.category ?? 'artwork';
102
+
103
+ const mediaItems = normalizeMediaList(rawProduct.media);
104
+ const primaryImage =
105
+ mediaItems[0]?.link ??
106
+ rawProduct.image ??
107
+ rawProduct.image_url ??
108
+ rawProduct.thumbnail ??
109
+ '';
110
+
111
+ log(logger, 'debug', '[PerspectAPI] Transform product', {
112
+ productId,
113
+ productSlug,
114
+ mediaCount: mediaItems.length
115
+ });
116
+
117
+ return {
118
+ id: typeof productId === 'string' ? productId : String(productId),
119
+ product: productName,
120
+ name: productName,
121
+ slug: productSlug,
122
+ slug_prefix: slugPrefix,
123
+ price: typeof rawProduct.price === 'number' ? rawProduct.price : Number(rawProduct.price ?? 0),
124
+ currency: rawProduct.currency ?? 'USD',
125
+ description: rawProduct.description ?? '',
126
+ description_markdown:
127
+ rawProduct.description_markdown ?? rawProduct.description ?? '',
128
+ image: primaryImage,
129
+ media: mediaItems,
130
+ gateway_product_id_live: rawProduct.gateway_product_id_live,
131
+ gateway_product_id_test: rawProduct.gateway_product_id_test,
132
+ stripe_product_id_live: rawProduct.stripe_product_id_live,
133
+ stripe_product_id_test: rawProduct.stripe_product_id_test,
134
+ isActive: rawProduct.isActive ?? rawProduct.is_active ?? true,
135
+ // Preserve original payload for advanced consumers
136
+ ...rawProduct
137
+ };
138
+ };
139
+
140
+ /**
141
+ * Transform PerspectAPI content payload to a simplified BlogPost structure.
142
+ */
143
+ export const transformContent = (
144
+ perspectContent: Content | (Content & Record<string, unknown>)
145
+ ): BlogPost => {
146
+ const raw = perspectContent ?? ({} as Content);
147
+
148
+ return {
149
+ id: typeof raw.id === 'string' ? raw.id : String(raw.id ?? ''),
150
+ slug: raw.slug ?? `post-${raw.id ?? 'unknown'}`,
151
+ slug_prefix:
152
+ (raw as any).slug_prefix ??
153
+ (raw.pageType === 'post' ? 'blog' : 'page'),
154
+ page_type: raw.pageType,
155
+ title: raw.pageTitle,
156
+ content: raw.pageContent,
157
+ excerpt:
158
+ typeof raw.pageContent === 'string'
159
+ ? `${raw.pageContent.slice(0, 200)}...`
160
+ : undefined,
161
+ published: raw.pageStatus === 'publish',
162
+ created_at: raw.createdAt,
163
+ updated_at: raw.updatedAt,
164
+ description: (raw as any).description,
165
+ published_date: (raw as any).published_date,
166
+ author: (raw as any).author,
167
+ tags: (raw as any).tags,
168
+ image: (raw as any).image
169
+ };
170
+ };
171
+
172
+ const getDefaultFallbackProducts = (siteName: string): Product[] => {
173
+ const safeSite = siteName.replace(/\s+/g, '-').toLowerCase();
174
+
175
+ const mediaSamples: MediaItem[] = [
176
+ {
177
+ media_id: `${safeSite}-media-1`,
178
+ attachment_id: 1001,
179
+ file_name: 'sample-artwork-1.jpg',
180
+ link: 'https://picsum.photos/1200/800?random=1',
181
+ content_type: 'image/jpeg',
182
+ width: 1200,
183
+ height: 800,
184
+ filesize: 245760,
185
+ r2_key: `${safeSite}/artwork-1.jpg`,
186
+ site_name: siteName
187
+ },
188
+ {
189
+ media_id: `${safeSite}-media-2`,
190
+ attachment_id: 1002,
191
+ file_name: 'sample-artwork-2.jpg',
192
+ link: 'https://picsum.photos/1200/800?random=2',
193
+ content_type: 'image/jpeg',
194
+ width: 1200,
195
+ height: 800,
196
+ filesize: 312480,
197
+ r2_key: `${safeSite}/artwork-2.jpg`,
198
+ site_name: siteName
199
+ }
200
+ ];
201
+
202
+ return [
203
+ {
204
+ id: `${safeSite}-product-1`,
205
+ product: `${siteName} Sample Artwork 1`,
206
+ slug: `${safeSite}-sample-artwork-1`,
207
+ slug_prefix: 'artwork',
208
+ price: 299,
209
+ description: `Beautiful artwork from ${siteName}`,
210
+ description_markdown: `Beautiful artwork from **${siteName}**`,
211
+ image: 'https://picsum.photos/800/600?random=1',
212
+ media: mediaSamples
213
+ },
214
+ {
215
+ id: `${safeSite}-product-2`,
216
+ product: `${siteName} Sample Artwork 2`,
217
+ slug: `${safeSite}-sample-artwork-2`,
218
+ slug_prefix: 'artwork',
219
+ price: 499,
220
+ description: `Exquisite piece from ${siteName} collection`,
221
+ description_markdown: `Exquisite piece from **${siteName}** collection`,
222
+ image: 'https://picsum.photos/800/600?random=2',
223
+ media: mediaSamples
224
+ }
225
+ ];
226
+ };
227
+
228
+ const getDefaultFallbackPosts = (siteName: string): BlogPost[] => {
229
+ return [
230
+ {
231
+ id: `${siteName}-post-1`,
232
+ slug: 'welcome-post',
233
+ slug_prefix: 'blog',
234
+ page_type: 'post',
235
+ title: `Welcome to ${siteName}`,
236
+ content: `<p>Welcome to our ${siteName} blog. Here you'll find articles about art, culture, and our collections.</p>`,
237
+ excerpt: `Welcome to our ${siteName} blog.`,
238
+ published: true,
239
+ created_at: new Date().toISOString()
240
+ }
241
+ ];
242
+ };
243
+
244
+ export interface LoaderOptions {
245
+ /**
246
+ * Pre-configured PerspectAPI client. Pass null/undefined to force fallback behaviour.
247
+ */
248
+ client?: PerspectApiClient | null;
249
+ /**
250
+ * Site name used for API calls and fallback data generation.
251
+ */
252
+ siteName: string;
253
+ /**
254
+ * Optional logger to capture debug/warn/error output.
255
+ */
256
+ logger?: LoaderLogger;
257
+ /**
258
+ * Optional fallback datasets.
259
+ */
260
+ fallbackProducts?: Product[];
261
+ fallbackPosts?: BlogPost[];
262
+ /**
263
+ * Limit for list fetching (defaults to 100).
264
+ */
265
+ limit?: number;
266
+ }
267
+
268
+ export interface LoadProductsOptions extends LoaderOptions {
269
+ /**
270
+ * Optional search term applied server-side.
271
+ */
272
+ search?: string;
273
+ /**
274
+ * Offset for manual pagination.
275
+ */
276
+ offset?: number;
277
+ /**
278
+ * Filter products by category name (case-insensitive). Accepts a single name or multiple names.
279
+ */
280
+ category?: string | string[];
281
+ /**
282
+ * Filter products by category IDs (mixed string/number values supported).
283
+ */
284
+ categoryIds?: Array<number | string>;
285
+ }
286
+
287
+ const resolveFallbackProducts = (
288
+ { siteName, fallbackProducts }: Pick<LoaderOptions, 'siteName' | 'fallbackProducts'>
289
+ ): Product[] => {
290
+ if (fallbackProducts && fallbackProducts.length > 0) {
291
+ return fallbackProducts;
292
+ }
293
+ return getDefaultFallbackProducts(siteName);
294
+ };
295
+
296
+ const resolveFallbackPosts = (
297
+ { siteName, fallbackPosts }: Pick<LoaderOptions, 'siteName' | 'fallbackPosts'>
298
+ ): BlogPost[] => {
299
+ if (fallbackPosts && fallbackPosts.length > 0) {
300
+ return fallbackPosts;
301
+ }
302
+ return getDefaultFallbackPosts(siteName);
303
+ };
304
+
305
+ /**
306
+ * Load products for a site with graceful fallbacks.
307
+ */
308
+ export async function loadProducts(options: LoadProductsOptions): Promise<Product[]> {
309
+ const {
310
+ client,
311
+ siteName,
312
+ logger = noopLogger,
313
+ fallbackProducts,
314
+ limit = 100,
315
+ offset,
316
+ search,
317
+ category,
318
+ categoryIds
319
+ } = options;
320
+
321
+ if (!client) {
322
+ log(logger, 'warn', '[PerspectAPI] No client configured, using fallback products');
323
+ return resolveFallbackProducts({ siteName, fallbackProducts });
324
+ }
325
+
326
+ try {
327
+ log(logger, 'info', `[PerspectAPI] Loading products for site "${siteName}"`);
328
+ const queryParams: ProductQueryParams = {
329
+ isActive: true,
330
+ limit,
331
+ offset,
332
+ search
333
+ };
334
+
335
+ const normalizedCategory = normalizeQueryParamList(category as any);
336
+ if (normalizedCategory) {
337
+ queryParams.category = normalizedCategory;
338
+ }
339
+
340
+ const normalizedCategoryIds = normalizeQueryParamList(categoryIds);
341
+ if (normalizedCategoryIds) {
342
+ queryParams.category_id = normalizedCategoryIds;
343
+ }
344
+
345
+ const response: PaginatedResponse<Product> = await client.products.getProducts(siteName, queryParams);
346
+
347
+ if (!response.data) {
348
+ log(logger, 'warn', '[PerspectAPI] Products response missing data, returning fallback set');
349
+ return resolveFallbackProducts({ siteName, fallbackProducts });
350
+ }
351
+
352
+ log(logger, 'debug', `[PerspectAPI] Found ${response.data.length} products, transforming...`);
353
+ return response.data.map(product => transformProduct(product, logger));
354
+ } catch (error) {
355
+ log(logger, 'error', '[PerspectAPI] Error loading products', error);
356
+ return resolveFallbackProducts({ siteName, fallbackProducts });
357
+ }
358
+ }
359
+
360
+ export interface LoadProductBySlugOptions extends LoaderOptions {
361
+ slug: string;
362
+ }
363
+
364
+ /**
365
+ * Load single product by slug.
366
+ */
367
+ export async function loadProductBySlug(
368
+ options: LoadProductBySlugOptions
369
+ ): Promise<Product | null> {
370
+ const {
371
+ client,
372
+ siteName,
373
+ slug,
374
+ logger = noopLogger,
375
+ fallbackProducts
376
+ } = options;
377
+
378
+ if (!client) {
379
+ log(logger, 'warn', '[PerspectAPI] No client configured, searching fallback products');
380
+ const fallback = resolveFallbackProducts({ siteName, fallbackProducts });
381
+ return fallback.find(product => product.slug === slug) ?? null;
382
+ }
383
+
384
+ try {
385
+ log(logger, 'info', `[PerspectAPI] Loading product "${slug}" for site "${siteName}"`);
386
+ const response: ApiResponse<Product & { variants?: unknown[] }> =
387
+ await client.products.getProductBySlug(siteName, slug);
388
+
389
+ if (!response.data) {
390
+ log(logger, 'warn', `[PerspectAPI] Product not found for slug "${slug}"`);
391
+ return null;
392
+ }
393
+
394
+ return transformProduct(response.data, logger);
395
+ } catch (error) {
396
+ log(logger, 'error', `[PerspectAPI] Error loading product slug "${slug}"`, error);
397
+ return null;
398
+ }
399
+ }
400
+
401
+ export interface LoadContentOptions extends LoaderOptions {
402
+ /**
403
+ * Additional query parameters for content filtering.
404
+ */
405
+ params?: Partial<{
406
+ page_status: string;
407
+ page_type: string;
408
+ limit: number;
409
+ search: string;
410
+ }>;
411
+ }
412
+
413
+ /**
414
+ * Load published pages for a site.
415
+ */
416
+ export async function loadPages(
417
+ options: LoadContentOptions
418
+ ): Promise<BlogPost[]> {
419
+ const { client, siteName, logger = noopLogger, params, fallbackPosts } = options;
420
+
421
+ if (!client) {
422
+ log(logger, 'warn', '[PerspectAPI] No client configured, using fallback posts for pages');
423
+ return resolveFallbackPosts({ siteName, fallbackPosts }).filter(
424
+ post => post.page_type !== 'post'
425
+ );
426
+ }
427
+
428
+ try {
429
+ log(logger, 'info', `[PerspectAPI] Loading pages for site "${siteName}"`);
430
+ const response: PaginatedResponse<Content> = await client.content.getContent(
431
+ siteName,
432
+ {
433
+ ...params,
434
+ page_status: (params?.page_status ?? 'publish') as ContentStatus,
435
+ page_type: (params?.page_type ?? 'page') as ContentType,
436
+ limit: options.limit ?? params?.limit ?? 100
437
+ }
438
+ );
439
+
440
+ if (!response.data) {
441
+ return [];
442
+ }
443
+
444
+ return response.data.map(content => transformContent(content));
445
+ } catch (error) {
446
+ log(logger, 'error', '[PerspectAPI] Error loading pages', error);
447
+ return [];
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Load published blog posts for a site.
453
+ */
454
+ export async function loadPosts(
455
+ options: LoadContentOptions
456
+ ): Promise<BlogPost[]> {
457
+ const { client, siteName, logger = noopLogger, params, fallbackPosts } = options;
458
+
459
+ if (!client) {
460
+ log(logger, 'warn', '[PerspectAPI] No client configured, using fallback posts');
461
+ return resolveFallbackPosts({ siteName, fallbackPosts });
462
+ }
463
+
464
+ try {
465
+ log(logger, 'info', `[PerspectAPI] Loading posts for site "${siteName}"`);
466
+ const response: PaginatedResponse<Content> = await client.content.getContent(
467
+ siteName,
468
+ {
469
+ ...params,
470
+ page_status: (params?.page_status ?? 'publish') as ContentStatus,
471
+ page_type: (params?.page_type ?? 'post') as ContentType,
472
+ limit: options.limit ?? params?.limit ?? 100
473
+ }
474
+ );
475
+
476
+ if (!response.data) {
477
+ log(logger, 'warn', '[PerspectAPI] Posts response missing data');
478
+ return [];
479
+ }
480
+
481
+ return response.data.map(content => transformContent(content));
482
+ } catch (error) {
483
+ log(logger, 'error', '[PerspectAPI] Error loading posts', error);
484
+ return [];
485
+ }
486
+ }
487
+
488
+ export interface LoadContentBySlugOptions extends LoaderOptions {
489
+ slug: string;
490
+ }
491
+
492
+ /**
493
+ * Load a single content item (post or page) by slug.
494
+ */
495
+ export async function loadContentBySlug(
496
+ options: LoadContentBySlugOptions
497
+ ): Promise<BlogPost | null> {
498
+ const {
499
+ client,
500
+ siteName,
501
+ slug,
502
+ logger = noopLogger,
503
+ fallbackPosts
504
+ } = options;
505
+
506
+ if (!client) {
507
+ log(logger, 'warn', '[PerspectAPI] No client configured, searching fallback posts');
508
+ const fallback = resolveFallbackPosts({ siteName, fallbackPosts });
509
+ return fallback.find(post => post.slug === slug) ?? null;
510
+ }
511
+
512
+ try {
513
+ log(logger, 'info', `[PerspectAPI] Loading content slug "${slug}" for site "${siteName}"`);
514
+ const response: ApiResponse<Content> =
515
+ await client.content.getContentBySlug(siteName, slug);
516
+
517
+ if (!response.data) {
518
+ log(logger, 'warn', `[PerspectAPI] Content not found for slug "${slug}"`);
519
+ return null;
520
+ }
521
+
522
+ return transformContent(response.data);
523
+ } catch (error) {
524
+ log(logger, 'error', `[PerspectAPI] Error loading content slug "${slug}"`, error);
525
+ return null;
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Load all published content (pages + posts).
531
+ */
532
+ export async function loadAllContent(
533
+ options: LoaderOptions
534
+ ): Promise<BlogPost[]> {
535
+ const { logger = noopLogger } = options;
536
+
537
+ const [pages, posts] = await Promise.all([
538
+ loadPages(options),
539
+ loadPosts(options)
540
+ ]);
541
+
542
+ log(logger, 'debug', `[PerspectAPI] Loaded ${pages.length + posts.length} total content items`);
543
+ return [...pages, ...posts];
544
+ }
545
+
546
+ export interface CheckoutSessionOptions {
547
+ client?: PerspectApiClient | null;
548
+ siteName: string;
549
+ items: Array<{ productId: string; quantity: number }>;
550
+ successUrl: string;
551
+ cancelUrl: string;
552
+ customerEmail?: string;
553
+ mode?: 'live' | 'test';
554
+ logger?: LoaderLogger;
555
+ fallbackProducts?: Product[];
556
+ metadata?: CheckoutMetadata;
557
+ /**
558
+ * Optional resolver to convert a product record into a Stripe price ID.
559
+ */
560
+ priceIdResolver?: (product: Product, mode: 'live' | 'test') => string | undefined;
561
+ }
562
+
563
+ /**
564
+ * Convenience helper that creates a checkout session by looking up Stripe price IDs
565
+ * from product metadata. Falls back to gateway price IDs if Stripe IDs are missing.
566
+ */
567
+ export async function createCheckoutSession(
568
+ options: CheckoutSessionOptions
569
+ ): Promise<ApiResponse<CheckoutSession> | { error: string }> {
570
+ const {
571
+ client,
572
+ siteName,
573
+ items,
574
+ successUrl,
575
+ cancelUrl,
576
+ customerEmail,
577
+ mode = 'live',
578
+ logger = noopLogger,
579
+ fallbackProducts,
580
+ metadata,
581
+ priceIdResolver
582
+ } = options;
583
+
584
+ if (!client) {
585
+ log(logger, 'error', '[PerspectAPI] Cannot create checkout session without SDK client');
586
+ return { error: 'PerspectAPI client not configured' };
587
+ }
588
+
589
+ try {
590
+ // Fetch active products to build price map
591
+ const products = await loadProducts({
592
+ client,
593
+ siteName,
594
+ logger,
595
+ limit: 200,
596
+ fallbackProducts
597
+ });
598
+
599
+ const productMap = new Map<string, Product>(
600
+ products.map(product => [String(product.id), product])
601
+ );
602
+
603
+ const line_items = items.map(item => {
604
+ const product = productMap.get(String(item.productId));
605
+
606
+ if (!product) {
607
+ throw new Error(`Product ${item.productId} not found while building checkout session`);
608
+ }
609
+
610
+ const resolvedPrice =
611
+ priceIdResolver?.(product, mode) ??
612
+ (mode === 'test'
613
+ ? product.stripe_product_id_test ?? (product as any).gateway_product_id_test
614
+ : product.stripe_product_id_live ?? (product as any).gateway_product_id_live);
615
+
616
+ if (!resolvedPrice) {
617
+ throw new Error(`Missing Stripe price ID for product ${item.productId} (${product.product ?? product.name ?? 'unknown'})`);
618
+ }
619
+
620
+ return {
621
+ price: resolvedPrice,
622
+ quantity: item.quantity
623
+ };
624
+ });
625
+
626
+ const checkoutData: CreateCheckoutSessionRequest = {
627
+ line_items,
628
+ successUrl,
629
+ cancelUrl,
630
+ success_url: successUrl,
631
+ cancel_url: cancelUrl,
632
+ customerEmail,
633
+ customer_email: customerEmail,
634
+ mode: mode === 'test' ? 'payment' : 'payment',
635
+ metadata
636
+ };
637
+
638
+ log(logger, 'info', '[PerspectAPI] Creating checkout session');
639
+ return client.checkout.createCheckoutSession(siteName, checkoutData);
640
+ } catch (error) {
641
+ log(logger, 'error', '[PerspectAPI] Failed to create checkout session', error);
642
+ return { error: error instanceof Error ? error.message : 'Unknown error creating checkout session' };
643
+ }
644
+ }