@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.
|
|
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",
|
package/vtex/hooks/useCart.ts
CHANGED
|
@@ -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
|
|
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 ??
|
|
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
|
+
}
|
package/vtex/utils/transform.ts
CHANGED
|
@@ -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,
|