@decocms/apps 0.24.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -30,6 +30,7 @@
30
30
  "./vtex/inline-loaders/productDetailsPage": "./vtex/inline-loaders/productDetailsPage.ts",
31
31
  "./vtex/inline-loaders/productListingPage": "./vtex/inline-loaders/productListingPage.ts",
32
32
  "./vtex/inline-loaders/productList": "./vtex/inline-loaders/productList.ts",
33
+ "./vtex/inline-loaders/productListShelf": "./vtex/inline-loaders/productListShelf.ts",
33
34
  "./vtex/inline-loaders/relatedProducts": "./vtex/inline-loaders/relatedProducts.ts",
34
35
  "./vtex/inline-loaders/suggestions": "./vtex/inline-loaders/suggestions.ts",
35
36
  "./vtex/hooks": "./vtex/hooks/index.ts",
@@ -134,7 +134,7 @@ async function updateItemQuantity(
134
134
  export interface UseCartOptions {
135
135
  /** Enable automatic refetching. @default true */
136
136
  enabled?: boolean;
137
- /** Stale time in ms. @default 0 (always refetch) */
137
+ /** Stale time in ms. @default 30000 */
138
138
  staleTime?: number;
139
139
  }
140
140
 
@@ -144,7 +144,7 @@ export function useCart(options?: UseCartOptions) {
144
144
  const query = useQuery({
145
145
  queryKey: CART_QUERY_KEY,
146
146
  queryFn: fetchCart,
147
- staleTime: options?.staleTime ?? 0,
147
+ staleTime: options?.staleTime ?? 30_000,
148
148
  enabled: options?.enabled !== false,
149
149
  });
150
150
 
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Lean product list loader for shelf/card display.
3
+ * Same API call as productList.ts but uses toProductShelf() for ~90% smaller payloads.
4
+ *
5
+ * Use this loader for ProductShelf sections where only card-level data is needed
6
+ * (name, URL, images, price, installments, PIX, availability, brand).
7
+ */
8
+
9
+ import type { Product } from "../../commerce/types/commerce";
10
+ import { getVtexConfig, intelligentSearch, toFacetPath } from "../client";
11
+ import { pickSku, sortProducts, toProductShelf } from "../utils/transform";
12
+ import type { Product as ProductVTEX } from "../utils/types";
13
+
14
+ export interface ProductListProps {
15
+ props?: CollectionProps | QueryProps | ProductIDProps | FacetsProps;
16
+ query?: string;
17
+ count?: number;
18
+ sort?: string;
19
+ collection?: string;
20
+ hideUnavailableItems?: boolean;
21
+ }
22
+
23
+ interface CollectionProps {
24
+ collection: string;
25
+ count?: number;
26
+ sort?: string;
27
+ hideUnavailableItems?: boolean;
28
+ }
29
+
30
+ interface QueryProps {
31
+ query: string;
32
+ count?: number;
33
+ sort?: string;
34
+ fuzzy?: string;
35
+ hideUnavailableItems?: boolean;
36
+ }
37
+
38
+ interface ProductIDProps {
39
+ ids: string[];
40
+ hideUnavailableItems?: boolean;
41
+ }
42
+
43
+ interface FacetsProps {
44
+ query?: string;
45
+ facets: string;
46
+ count?: number;
47
+ sort?: string;
48
+ hideUnavailableItems?: boolean;
49
+ }
50
+
51
+ function isCollectionProps(p: any): p is CollectionProps {
52
+ return typeof p?.collection === "string";
53
+ }
54
+ function isProductIDProps(p: any): p is ProductIDProps {
55
+ return Array.isArray(p?.ids) && p.ids.length > 0;
56
+ }
57
+ function isFacetsProps(p: any): p is FacetsProps {
58
+ return typeof p?.facets === "string";
59
+ }
60
+
61
+ function resolveParams(props: ProductListProps): {
62
+ query: string;
63
+ count: number;
64
+ sort: string;
65
+ facetPath: string;
66
+ fuzzy?: string;
67
+ hideUnavailableItems: boolean;
68
+ ids?: string[];
69
+ } {
70
+ const inner = props.props ?? props;
71
+
72
+ if (isProductIDProps(inner)) {
73
+ return {
74
+ query: `sku:${inner.ids.join(";")}`,
75
+ count: inner.ids.length,
76
+ sort: "",
77
+ facetPath: "",
78
+ hideUnavailableItems: inner.hideUnavailableItems ?? false,
79
+ ids: inner.ids,
80
+ };
81
+ }
82
+
83
+ if (isFacetsProps(inner)) {
84
+ return {
85
+ query: inner.query ?? "",
86
+ count: inner.count ?? 12,
87
+ sort: inner.sort ?? "",
88
+ facetPath: inner.facets,
89
+ hideUnavailableItems: inner.hideUnavailableItems ?? false,
90
+ };
91
+ }
92
+
93
+ if (isCollectionProps(inner)) {
94
+ return {
95
+ query: "",
96
+ count: inner.count ?? 12,
97
+ sort: inner.sort ?? "",
98
+ facetPath: toFacetPath([{ key: "productClusterIds", value: inner.collection }]),
99
+ hideUnavailableItems: inner.hideUnavailableItems ?? false,
100
+ };
101
+ }
102
+
103
+ return {
104
+ query: (inner as any).query ?? "",
105
+ count: (inner as any).count ?? 12,
106
+ sort: (inner as any).sort ?? "",
107
+ facetPath: "",
108
+ fuzzy: (inner as any).fuzzy,
109
+ hideUnavailableItems: (inner as any).hideUnavailableItems ?? false,
110
+ };
111
+ }
112
+
113
+ export default async function vtexProductListShelf(
114
+ props: ProductListProps,
115
+ ): Promise<Product[] | null> {
116
+ try {
117
+ const { query, count, sort, facetPath, fuzzy, hideUnavailableItems, ids } =
118
+ resolveParams(props);
119
+
120
+ const config = getVtexConfig();
121
+ const locale = config.locale ?? "pt-BR";
122
+
123
+ const params: Record<string, string> = {
124
+ page: "1",
125
+ count: String(count),
126
+ locale,
127
+ hideUnavailableItems: String(hideUnavailableItems),
128
+ };
129
+ if (query) params.query = query;
130
+ if (sort) params.sort = sort;
131
+ if (fuzzy) params.fuzzy = fuzzy;
132
+
133
+ const endpoint = facetPath ? `/product_search/${facetPath}` : "/product_search/";
134
+
135
+ const data = await intelligentSearch<{ products: ProductVTEX[] }>(endpoint, params);
136
+
137
+ const vtexProducts = data.products ?? [];
138
+ const baseUrl = config.publicUrl
139
+ ? `https://${config.publicUrl}`
140
+ : `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
141
+
142
+ let products = vtexProducts.map((p) => {
143
+ const fetchedSkus = ids ? new Set(ids) : null;
144
+ const preferredSku = fetchedSkus
145
+ ? (p.items.find((item) => fetchedSkus.has(item.itemId)) ?? pickSku(p))
146
+ : pickSku(p);
147
+ return toProductShelf(p, preferredSku, 0, { baseUrl, priceCurrency: "BRL" });
148
+ });
149
+
150
+ if (ids) {
151
+ products = sortProducts(products, ids, "sku");
152
+ }
153
+
154
+ return products;
155
+ } catch (error) {
156
+ console.error("[VTEX] ProductListShelf error:", error);
157
+ return null;
158
+ }
159
+ }
@@ -492,6 +492,172 @@ export const toProduct = <P extends LegacyProductVTEX | ProductVTEX>(
492
492
  };
493
493
  };
494
494
 
495
+ /**
496
+ * Determines if an installment has no interest by checking if
497
+ * billingDuration * billingIncrement ≈ total price (within 1 cent tolerance).
498
+ */
499
+ const isNoInterest = (spec: UnitPriceSpecification): boolean => {
500
+ if (spec.billingDuration == null || spec.billingIncrement == null || spec.price == null) {
501
+ return false;
502
+ }
503
+ return Math.abs(spec.billingDuration * spec.billingIncrement - spec.price) < 0.01;
504
+ };
505
+
506
+ /**
507
+ * Build a lean offer for shelf display. Keeps only:
508
+ * - ListPrice, SalePrice, SRP price types
509
+ * - PIX installment (name?.toUpperCase() === "PIX")
510
+ * - Best no-interest installment (highest billingDuration)
511
+ * Drops: inventoryLevel, giftSkuIds, priceValidUntil
512
+ */
513
+ const buildOfferShelf = (offer: Offer): Offer => {
514
+ const leanSpecs: UnitPriceSpecification[] = [];
515
+
516
+ let bestNoInterest: UnitPriceSpecification | null = null;
517
+
518
+ for (const spec of offer.priceSpecification ?? []) {
519
+ // Keep base price types
520
+ if (
521
+ spec.priceType === SCHEMA_LIST_PRICE ||
522
+ spec.priceType === SCHEMA_SALE_PRICE ||
523
+ spec.priceType === SCHEMA_SRP
524
+ ) {
525
+ if (spec.priceComponentType !== SCHEMA_INSTALLMENT) {
526
+ leanSpecs.push(spec);
527
+ continue;
528
+ }
529
+ }
530
+
531
+ // Keep PIX installment
532
+ if (spec.priceComponentType === SCHEMA_INSTALLMENT && spec.name?.toUpperCase() === "PIX") {
533
+ leanSpecs.push(spec);
534
+ continue;
535
+ }
536
+
537
+ // Track best no-interest installment (highest billingDuration)
538
+ if (
539
+ spec.priceComponentType === SCHEMA_INSTALLMENT &&
540
+ isNoInterest(spec) &&
541
+ (bestNoInterest == null ||
542
+ (spec.billingDuration ?? 0) > (bestNoInterest.billingDuration ?? 0))
543
+ ) {
544
+ bestNoInterest = spec;
545
+ }
546
+ }
547
+
548
+ if (bestNoInterest) {
549
+ leanSpecs.push(bestNoInterest);
550
+ }
551
+
552
+ return {
553
+ "@type": "Offer",
554
+ identifier: offer.identifier,
555
+ price: offer.price,
556
+ seller: offer.seller,
557
+ sellerName: offer.sellerName,
558
+ teasers: offer.teasers,
559
+ priceSpecification: leanSpecs,
560
+ availability: offer.availability,
561
+ inventoryLevel: { value: 0 },
562
+ };
563
+ };
564
+
565
+ /** Property names commonly used by ProductCard/Shelf components */
566
+ const SHELF_PROPERTY_NAMES = new Set([
567
+ "category",
568
+ "cluster",
569
+ "Cor",
570
+ "Tamanho",
571
+ "Voltagem",
572
+ "sellerId",
573
+ ]);
574
+
575
+ /**
576
+ * Lean product transform for shelf/card display. Same signature as toProduct().
577
+ *
578
+ * Differences from toProduct():
579
+ * - Images: capped at 2 per SKU (front + back)
580
+ * - Offers: first seller only, stripped installments (keeps ListPrice, SalePrice, SRP, PIX, best no-interest)
581
+ * - isVariantOf: single in-stock variant at level 0
582
+ * - additionalProperty: filtered to known-used property names
583
+ * - Drops: description, video, isAccessoryOrSparePartFor, alternateName, gtin, releaseDate, model
584
+ */
585
+ export const toProductShelf = <P extends LegacyProductVTEX | ProductVTEX>(
586
+ product: P,
587
+ sku: P["items"][number],
588
+ level = 0,
589
+ options: ProductOptions,
590
+ ): Product => {
591
+ const { baseUrl, priceCurrency } = options;
592
+ const { productId, items } = product;
593
+ const { name, itemId: skuId } = sku;
594
+
595
+ // Images: cap at 2
596
+ const rawImages = nonEmptyArray(sku.images);
597
+ const mappedImages = (rawImages ?? []).slice(0, 2).map(({ imageUrl, imageText, imageLabel }) => ({
598
+ "@type": "ImageObject" as const,
599
+ alternateName: imageText || imageLabel || "",
600
+ url: imageUrl,
601
+ name: imageLabel || "",
602
+ encodingFormat: "image",
603
+ }));
604
+ const finalImages = mappedImages.length > 0 ? mappedImages : [DEFAULT_IMAGE];
605
+
606
+ // Offers: first seller only, lean
607
+ const firstSeller = (sku.sellers ?? [])[0];
608
+ const fullOffer = firstSeller
609
+ ? (isLegacyProduct(product) ? toOfferLegacy : toOffer)(firstSeller)
610
+ : undefined;
611
+ const leanOffers = fullOffer ? [buildOfferShelf(fullOffer)] : [];
612
+
613
+ // isVariantOf: single in-stock variant at level 0
614
+ const isVariantOf =
615
+ level < 1
616
+ ? (() => {
617
+ const inStockSku = findFirstAvailable(items) ?? items[0];
618
+ const singleVariant = inStockSku ? [toProductShelf(product, inStockSku, 1, options)] : [];
619
+ return {
620
+ "@type": "ProductGroup" as const,
621
+ productGroupID: productId,
622
+ hasVariant: singleVariant,
623
+ url: getProductGroupURL(baseUrl, product).href,
624
+ name: product.productName,
625
+ additionalProperty: [],
626
+ } satisfies ProductGroup;
627
+ })()
628
+ : undefined;
629
+
630
+ // additionalProperty: filter to known-used names
631
+ const specificationsAdditionalProperty = isLegacySku(sku)
632
+ ? toAdditionalPropertiesLegacy(sku)
633
+ : toAdditionalProperties(sku);
634
+ const categoryAdditionalProperties = toAdditionalPropertyCategories(product) ?? [];
635
+ const clusterAdditionalProperties = toAdditionalPropertyClusters(product) ?? [];
636
+
637
+ const additionalProperty = [
638
+ ...specificationsAdditionalProperty,
639
+ ...categoryAdditionalProperties,
640
+ ...clusterAdditionalProperties,
641
+ ].filter((prop) => SHELF_PROPERTY_NAMES.has(prop.name ?? ""));
642
+
643
+ const categoriesString = splitCategory(product.categories[0]).join(DEFAULT_CATEGORY_SEPARATOR);
644
+
645
+ return {
646
+ "@type": "Product",
647
+ category: categoriesString,
648
+ productID: skuId,
649
+ url: getProductURL(baseUrl, product, sku.itemId).href,
650
+ name,
651
+ brand: { "@type": "Brand", name: product.brand },
652
+ inProductGroupWithID: productId,
653
+ sku: skuId,
654
+ image: finalImages,
655
+ offers: aggregateOffers(leanOffers, priceCurrency),
656
+ isVariantOf,
657
+ additionalProperty,
658
+ };
659
+ };
660
+
495
661
  const toBreadcrumbList = (
496
662
  product: ProductVTEX | LegacyProductVTEX,
497
663
  { baseUrl }: ProductOptions,