@decocms/apps 0.20.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/.github/workflows/release.yml +34 -0
- package/.releaserc.json +25 -0
- package/commerce/components/Image.tsx +209 -0
- package/commerce/components/JsonLd.tsx +285 -0
- package/commerce/sdk/analytics.ts +24 -0
- package/commerce/sdk/formatPrice.ts +23 -0
- package/commerce/sdk/url.ts +9 -0
- package/commerce/sdk/useOffer.ts +75 -0
- package/commerce/sdk/useVariantPossibilities.ts +43 -0
- package/commerce/types/commerce.ts +1105 -0
- package/commerce/utils/canonical.ts +11 -0
- package/commerce/utils/constants.ts +9 -0
- package/commerce/utils/filters.ts +10 -0
- package/commerce/utils/productToAnalyticsItem.ts +67 -0
- package/commerce/utils/stateByZip.ts +50 -0
- package/knip.json +19 -0
- package/package.json +77 -0
- package/shopify/actions/cart/addItems.ts +37 -0
- package/shopify/actions/cart/updateCoupons.ts +32 -0
- package/shopify/actions/cart/updateItems.ts +32 -0
- package/shopify/actions/user/signIn.ts +45 -0
- package/shopify/actions/user/signUp.ts +36 -0
- package/shopify/client.ts +58 -0
- package/shopify/index.ts +32 -0
- package/shopify/init.ts +40 -0
- package/shopify/loaders/ProductDetailsPage.ts +35 -0
- package/shopify/loaders/ProductList.ts +101 -0
- package/shopify/loaders/ProductListingPage.ts +180 -0
- package/shopify/loaders/RelatedProducts.ts +45 -0
- package/shopify/loaders/cart.ts +73 -0
- package/shopify/loaders/shop.ts +40 -0
- package/shopify/loaders/user.ts +44 -0
- package/shopify/utils/admin/admin.ts +57 -0
- package/shopify/utils/admin/queries.ts +29 -0
- package/shopify/utils/cart.ts +28 -0
- package/shopify/utils/cookies.ts +85 -0
- package/shopify/utils/enums.ts +438 -0
- package/shopify/utils/graphql.ts +69 -0
- package/shopify/utils/storefront/queries.ts +530 -0
- package/shopify/utils/storefront/storefront.graphql.gen.ts +113 -0
- package/shopify/utils/transform.ts +436 -0
- package/shopify/utils/types.ts +191 -0
- package/shopify/utils/user.ts +23 -0
- package/shopify/utils/utils.ts +164 -0
- package/tsconfig.json +11 -0
- package/vtex/README.md +6 -0
- package/vtex/actions/address.ts +211 -0
- package/vtex/actions/auth.ts +337 -0
- package/vtex/actions/checkout.ts +497 -0
- package/vtex/actions/index.ts +11 -0
- package/vtex/actions/masterData.ts +170 -0
- package/vtex/actions/misc.ts +196 -0
- package/vtex/actions/newsletter.ts +108 -0
- package/vtex/actions/orders.ts +37 -0
- package/vtex/actions/profile.ts +119 -0
- package/vtex/actions/session.ts +87 -0
- package/vtex/actions/trigger.ts +43 -0
- package/vtex/actions/wishlist.ts +116 -0
- package/vtex/client.ts +423 -0
- package/vtex/hooks/index.ts +4 -0
- package/vtex/hooks/useAutocomplete.ts +89 -0
- package/vtex/hooks/useCart.ts +219 -0
- package/vtex/hooks/useUser.ts +78 -0
- package/vtex/hooks/useWishlist.ts +119 -0
- package/vtex/index.ts +14 -0
- package/vtex/inline-loaders/productDetailsPage.ts +75 -0
- package/vtex/inline-loaders/productList.ts +163 -0
- package/vtex/inline-loaders/productListingPage.ts +447 -0
- package/vtex/inline-loaders/relatedProducts.ts +83 -0
- package/vtex/inline-loaders/suggestions.ts +49 -0
- package/vtex/inline-loaders/workflowProducts.ts +68 -0
- package/vtex/invoke.ts +202 -0
- package/vtex/loaders/address.ts +120 -0
- package/vtex/loaders/brands.ts +51 -0
- package/vtex/loaders/cart.ts +49 -0
- package/vtex/loaders/catalog.ts +165 -0
- package/vtex/loaders/collections.ts +57 -0
- package/vtex/loaders/index.ts +19 -0
- package/vtex/loaders/legacy.ts +671 -0
- package/vtex/loaders/logistics.ts +115 -0
- package/vtex/loaders/navbar.ts +29 -0
- package/vtex/loaders/orders.ts +103 -0
- package/vtex/loaders/pageType.ts +62 -0
- package/vtex/loaders/payment.ts +107 -0
- package/vtex/loaders/profile.ts +138 -0
- package/vtex/loaders/promotion.ts +33 -0
- package/vtex/loaders/search.ts +127 -0
- package/vtex/loaders/session.ts +91 -0
- package/vtex/loaders/user.ts +89 -0
- package/vtex/loaders/wishlist.ts +89 -0
- package/vtex/loaders/wishlistProducts.ts +81 -0
- package/vtex/loaders/workflow.ts +323 -0
- package/vtex/logo.png +0 -0
- package/vtex/middleware.ts +229 -0
- package/vtex/types.ts +248 -0
- package/vtex/utils/batch.ts +21 -0
- package/vtex/utils/cookies.ts +76 -0
- package/vtex/utils/enrichment.ts +540 -0
- package/vtex/utils/fetchCache.ts +150 -0
- package/vtex/utils/index.ts +17 -0
- package/vtex/utils/intelligentSearch.ts +84 -0
- package/vtex/utils/legacy.ts +155 -0
- package/vtex/utils/pickAndOmit.ts +30 -0
- package/vtex/utils/proxy.ts +196 -0
- package/vtex/utils/resourceRange.ts +10 -0
- package/vtex/utils/segment.ts +163 -0
- package/vtex/utils/similars.ts +38 -0
- package/vtex/utils/sitemap.ts +133 -0
- package/vtex/utils/slugCache.ts +32 -0
- package/vtex/utils/slugify.ts +13 -0
- package/vtex/utils/transform.ts +1331 -0
- package/vtex/utils/types.ts +1884 -0
- package/vtex/utils/vtexId.ts +103 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filtersFromPageTypes,
|
|
3
|
+
getVtexConfig,
|
|
4
|
+
intelligentSearch,
|
|
5
|
+
type PageType,
|
|
6
|
+
pageTypesFromPath,
|
|
7
|
+
toFacetPath,
|
|
8
|
+
} from "../client";
|
|
9
|
+
import { pickSku, toProduct } from "../utils/transform";
|
|
10
|
+
import type { Product as ProductVTEX } from "../utils/types";
|
|
11
|
+
|
|
12
|
+
export interface SelectedFacet {
|
|
13
|
+
key: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PLPProps {
|
|
18
|
+
query?: string;
|
|
19
|
+
count?: number;
|
|
20
|
+
sort?: string;
|
|
21
|
+
fuzzy?: string;
|
|
22
|
+
page?: number;
|
|
23
|
+
selectedFacets?: SelectedFacet[];
|
|
24
|
+
hideUnavailableItems?: boolean;
|
|
25
|
+
/** Injected by CMS resolve — the matched page path (e.g. "/pisos/piso-vinilico-clicado") */
|
|
26
|
+
__pagePath?: string;
|
|
27
|
+
/** Injected by CMS resolve — the full request URL (e.g. "https://site.com/s?q=telha&sort=price:asc") */
|
|
28
|
+
__pageUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// -- Types matching VTEX IS API responses --
|
|
32
|
+
|
|
33
|
+
interface ISPaginationItem {
|
|
34
|
+
index: number;
|
|
35
|
+
proxyUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ISPagination {
|
|
39
|
+
count: number;
|
|
40
|
+
current: ISPaginationItem;
|
|
41
|
+
before: ISPaginationItem[];
|
|
42
|
+
after: ISPaginationItem[];
|
|
43
|
+
perPage: number;
|
|
44
|
+
next: ISPaginationItem;
|
|
45
|
+
previous: ISPaginationItem;
|
|
46
|
+
first: ISPaginationItem;
|
|
47
|
+
last: ISPaginationItem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ISProductSearchResult {
|
|
51
|
+
products: any[];
|
|
52
|
+
recordsFiltered: number;
|
|
53
|
+
pagination: ISPagination;
|
|
54
|
+
correction?: { misspelled?: boolean };
|
|
55
|
+
operator?: string;
|
|
56
|
+
redirect?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ISFacetValueBoolean {
|
|
60
|
+
quantity: number;
|
|
61
|
+
name: string;
|
|
62
|
+
value: string;
|
|
63
|
+
selected: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ISFacetValueRange {
|
|
67
|
+
quantity: number;
|
|
68
|
+
name: string;
|
|
69
|
+
selected: boolean;
|
|
70
|
+
range: { from: number; to: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ISFacet {
|
|
74
|
+
key: string;
|
|
75
|
+
name: string;
|
|
76
|
+
type: "TEXT" | "PRICERANGE";
|
|
77
|
+
hidden: boolean;
|
|
78
|
+
quantity: number;
|
|
79
|
+
values: Array<ISFacetValueBoolean | ISFacetValueRange>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ISFacetsResult {
|
|
83
|
+
facets: ISFacet[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Valid page types for filtering (matching original getValidTypesFromPageTypes)
|
|
87
|
+
const VALID_PAGE_TYPES = new Set([
|
|
88
|
+
"Brand",
|
|
89
|
+
"Category",
|
|
90
|
+
"Department",
|
|
91
|
+
"SubCategory",
|
|
92
|
+
"Collection",
|
|
93
|
+
"Cluster",
|
|
94
|
+
"Search",
|
|
95
|
+
"FullText",
|
|
96
|
+
"Product",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
function getValidPageTypes(pageTypes: PageType[]): PageType[] {
|
|
100
|
+
return pageTypes.filter((pt) => VALID_PAGE_TYPES.has(pt.pageType));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// -- Filter transformation (mirrors original toFilter + facetToToggle) --
|
|
104
|
+
|
|
105
|
+
function formatRange(from: number, to: number): string {
|
|
106
|
+
return `${from}:${to}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isRangeValue(val: any): val is ISFacetValueRange {
|
|
110
|
+
return Boolean(val.range);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function filtersToSearchParams(
|
|
114
|
+
facets: SelectedFacet[],
|
|
115
|
+
paramsToPersist?: URLSearchParams,
|
|
116
|
+
): URLSearchParams {
|
|
117
|
+
const searchParams = new URLSearchParams(paramsToPersist);
|
|
118
|
+
for (const { key, value } of facets) {
|
|
119
|
+
searchParams.append(`filter.${key}`, value);
|
|
120
|
+
}
|
|
121
|
+
return searchParams;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function facetToToggle(
|
|
125
|
+
selectedFacets: SelectedFacet[],
|
|
126
|
+
key: string,
|
|
127
|
+
paramsToPersist?: URLSearchParams,
|
|
128
|
+
) {
|
|
129
|
+
return (item: ISFacetValueBoolean | ISFacetValueRange) => {
|
|
130
|
+
const { quantity, selected } = item;
|
|
131
|
+
const isRange = isRangeValue(item);
|
|
132
|
+
const value = isRange
|
|
133
|
+
? formatRange(item.range.from, item.range.to)
|
|
134
|
+
: (item as ISFacetValueBoolean).value;
|
|
135
|
+
const label = isRange ? value : (item as ISFacetValueBoolean).name;
|
|
136
|
+
const facet = { key, value };
|
|
137
|
+
|
|
138
|
+
const filters = selected
|
|
139
|
+
? selectedFacets.filter((f) => f.key !== key || f.value !== value)
|
|
140
|
+
: [...selectedFacets, facet];
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
value,
|
|
144
|
+
quantity,
|
|
145
|
+
selected,
|
|
146
|
+
url: `?${filtersToSearchParams(filters, paramsToPersist)}`,
|
|
147
|
+
label,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toFilter(
|
|
153
|
+
selectedFacets: SelectedFacet[],
|
|
154
|
+
paramsToPersist?: URLSearchParams,
|
|
155
|
+
) {
|
|
156
|
+
return (facet: ISFacet) => ({
|
|
157
|
+
"@type": "FilterToggle" as const,
|
|
158
|
+
key: facet.key,
|
|
159
|
+
label: facet.name,
|
|
160
|
+
quantity: facet.quantity,
|
|
161
|
+
values: facet.values.map(
|
|
162
|
+
facetToToggle(selectedFacets, facet.key, paramsToPersist),
|
|
163
|
+
),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// -- Breadcrumb from page types (mirrors original pageTypesToBreadcrumbList) --
|
|
168
|
+
|
|
169
|
+
function pageTypesToBreadcrumb(pageTypes: PageType[]) {
|
|
170
|
+
const filtered = pageTypes.filter(
|
|
171
|
+
(pt) =>
|
|
172
|
+
pt.pageType === "Category" ||
|
|
173
|
+
pt.pageType === "Department" ||
|
|
174
|
+
pt.pageType === "SubCategory",
|
|
175
|
+
);
|
|
176
|
+
return filtered.map((page, index) => {
|
|
177
|
+
const position = index + 1;
|
|
178
|
+
const slugParts = filtered.slice(0, position).map((x) => {
|
|
179
|
+
const urlPath = x.url ? new URL(`http://${x.url}`).pathname : "";
|
|
180
|
+
const segments = urlPath.split("/").filter(Boolean);
|
|
181
|
+
return segments[segments.length - 1]?.toLowerCase() ?? "";
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
"@type": "ListItem" as const,
|
|
185
|
+
name: page.name,
|
|
186
|
+
item: `/${slugParts.join("/")}`,
|
|
187
|
+
position,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// -- SEO from page types (mirrors original pageTypesToSeo) --
|
|
193
|
+
|
|
194
|
+
function pageTypesToSeo(pageTypes: PageType[]) {
|
|
195
|
+
const current = pageTypes[pageTypes.length - 1];
|
|
196
|
+
if (!current) return undefined;
|
|
197
|
+
return {
|
|
198
|
+
title: current.title || current.name || "",
|
|
199
|
+
description: current.metaTagDescription || "",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// -- Build IS query params (mirrors original withDefaultParams) --
|
|
204
|
+
|
|
205
|
+
function buildISParams(opts: {
|
|
206
|
+
query: string;
|
|
207
|
+
page: number;
|
|
208
|
+
count: number;
|
|
209
|
+
sort: string;
|
|
210
|
+
fuzzy?: string;
|
|
211
|
+
locale: string;
|
|
212
|
+
hideUnavailableItems: boolean;
|
|
213
|
+
}): Record<string, string> {
|
|
214
|
+
const params: Record<string, string> = {
|
|
215
|
+
page: String(opts.page + 1), // IS API is 1-indexed
|
|
216
|
+
count: String(opts.count),
|
|
217
|
+
query: opts.query,
|
|
218
|
+
sort: opts.sort,
|
|
219
|
+
locale: opts.locale,
|
|
220
|
+
hideUnavailableItems: String(opts.hideUnavailableItems),
|
|
221
|
+
};
|
|
222
|
+
if (opts.fuzzy) params.fuzzy = opts.fuzzy;
|
|
223
|
+
return params;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const INVALID_PLP_PREFIXES = [
|
|
227
|
+
"/image/",
|
|
228
|
+
"/.well-known/",
|
|
229
|
+
"/assets/",
|
|
230
|
+
"/favicon",
|
|
231
|
+
"/_serverFn/",
|
|
232
|
+
"/_build/",
|
|
233
|
+
"/node_modules/",
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
function isValidPLPPath(path: string): boolean {
|
|
237
|
+
const lower = path.toLowerCase();
|
|
238
|
+
if (INVALID_PLP_PREFIXES.some((p) => lower.startsWith(p))) return false;
|
|
239
|
+
const ext = lower.split("/").pop()?.split(".")?.pop();
|
|
240
|
+
if (
|
|
241
|
+
ext &&
|
|
242
|
+
[
|
|
243
|
+
"png",
|
|
244
|
+
"jpg",
|
|
245
|
+
"jpeg",
|
|
246
|
+
"gif",
|
|
247
|
+
"svg",
|
|
248
|
+
"webp",
|
|
249
|
+
"ico",
|
|
250
|
+
"css",
|
|
251
|
+
"js",
|
|
252
|
+
"woff",
|
|
253
|
+
"woff2",
|
|
254
|
+
"ttf",
|
|
255
|
+
].includes(ext)
|
|
256
|
+
) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Mirrors the original deco-cx/apps PLP loader:
|
|
264
|
+
*
|
|
265
|
+
* 1. Resolve facets from CMS props or Page Type API
|
|
266
|
+
* 2. Call product_search AND facets APIs in parallel (same params)
|
|
267
|
+
* 3. Transform products to schema.org format
|
|
268
|
+
* 4. Transform facets to FilterToggle format
|
|
269
|
+
* 5. Build pagination from IS response
|
|
270
|
+
*/
|
|
271
|
+
export default async function vtexProductListingPage(
|
|
272
|
+
props: PLPProps,
|
|
273
|
+
): Promise<any | null> {
|
|
274
|
+
const pageUrl = props.__pageUrl
|
|
275
|
+
? new URL(props.__pageUrl, "https://localhost")
|
|
276
|
+
: null;
|
|
277
|
+
|
|
278
|
+
const query = props.query ?? pageUrl?.searchParams.get("q") ?? "";
|
|
279
|
+
const countFromUrl = pageUrl?.searchParams.get("PS");
|
|
280
|
+
const rawCount = Number(countFromUrl ?? props.count ?? 12);
|
|
281
|
+
const count = Number.isFinite(rawCount) && rawCount > 0 ? rawCount : 12;
|
|
282
|
+
const sort = props.sort || pageUrl?.searchParams.get("sort") || "";
|
|
283
|
+
const fuzzy = props.fuzzy ?? pageUrl?.searchParams.get("fuzzy") ?? undefined;
|
|
284
|
+
const pageFromUrl = pageUrl?.searchParams.get("page");
|
|
285
|
+
const rawPage = props.page ?? (pageFromUrl ? Number(pageFromUrl) - 1 : 0);
|
|
286
|
+
const page =
|
|
287
|
+
Number.isFinite(rawPage) && rawPage >= 0 ? Math.floor(rawPage) : 0;
|
|
288
|
+
|
|
289
|
+
const {
|
|
290
|
+
selectedFacets: cmsSelectedFacets,
|
|
291
|
+
hideUnavailableItems = false,
|
|
292
|
+
__pagePath,
|
|
293
|
+
} = props;
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
// 1. Resolve selected facets (CMS + URL filter.* params, matching original)
|
|
297
|
+
let facets: SelectedFacet[] =
|
|
298
|
+
cmsSelectedFacets && cmsSelectedFacets.length > 0
|
|
299
|
+
? [...cmsSelectedFacets]
|
|
300
|
+
: [];
|
|
301
|
+
|
|
302
|
+
// Extract filter.* params from URL (e.g. filter.category-1=telhas)
|
|
303
|
+
if (pageUrl) {
|
|
304
|
+
for (const [name, value] of pageUrl.searchParams.entries()) {
|
|
305
|
+
const dotIndex = name.indexOf(".");
|
|
306
|
+
if (dotIndex > 0 && name.slice(0, dotIndex) === "filter") {
|
|
307
|
+
const key = name.slice(dotIndex + 1);
|
|
308
|
+
if (key && !facets.some((f) => f.key === key && f.value === value)) {
|
|
309
|
+
facets.push({ key, value });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let pageTypes: PageType[] = [];
|
|
316
|
+
|
|
317
|
+
if (
|
|
318
|
+
facets.length === 0 &&
|
|
319
|
+
__pagePath &&
|
|
320
|
+
__pagePath !== "/" &&
|
|
321
|
+
__pagePath !== "/*" &&
|
|
322
|
+
isValidPLPPath(__pagePath)
|
|
323
|
+
) {
|
|
324
|
+
const allPageTypes = await pageTypesFromPath(__pagePath);
|
|
325
|
+
pageTypes = getValidPageTypes(allPageTypes);
|
|
326
|
+
facets = filtersFromPageTypes(pageTypes);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!facets.length && !query) {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const facetPath = toFacetPath(facets);
|
|
334
|
+
const config = getVtexConfig();
|
|
335
|
+
const locale = config.locale ?? "pt-BR";
|
|
336
|
+
|
|
337
|
+
const params = buildISParams({
|
|
338
|
+
query,
|
|
339
|
+
page,
|
|
340
|
+
count,
|
|
341
|
+
sort,
|
|
342
|
+
fuzzy,
|
|
343
|
+
locale,
|
|
344
|
+
hideUnavailableItems,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const productEndpoint = facetPath
|
|
348
|
+
? `/product_search/${facetPath}`
|
|
349
|
+
: "/product_search/";
|
|
350
|
+
|
|
351
|
+
const facetsEndpoint = facetPath ? `/facets/${facetPath}` : "/facets/";
|
|
352
|
+
|
|
353
|
+
// 2. Parallel calls — exactly like the original
|
|
354
|
+
const [productsResult, facetsResult] = await Promise.all([
|
|
355
|
+
intelligentSearch<ISProductSearchResult>(productEndpoint, params),
|
|
356
|
+
intelligentSearch<ISFacetsResult>(facetsEndpoint, params),
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
const {
|
|
360
|
+
products: vtexProducts,
|
|
361
|
+
pagination,
|
|
362
|
+
recordsFiltered,
|
|
363
|
+
} = productsResult;
|
|
364
|
+
|
|
365
|
+
// 3. Transform products using shared transform pipeline (same as deco-cx/apps)
|
|
366
|
+
const baseUrl = config.publicUrl
|
|
367
|
+
? `https://${config.publicUrl}`
|
|
368
|
+
: `https://${config.account}.vtexcommercestable.com.br`;
|
|
369
|
+
|
|
370
|
+
const schemaProducts = (vtexProducts as ProductVTEX[]).map((p) => {
|
|
371
|
+
const sku = pickSku(p);
|
|
372
|
+
return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Persist URL params (q, sort, filter.*) across filter toggles and pagination links
|
|
376
|
+
const paramsToPersist = new URLSearchParams();
|
|
377
|
+
if (pageUrl) {
|
|
378
|
+
for (const [k, v] of pageUrl.searchParams.entries()) {
|
|
379
|
+
if (k !== "page" && k !== "PS" && !k.startsWith("filter.")) {
|
|
380
|
+
paramsToPersist.append(k, v);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
if (query) paramsToPersist.set("q", query);
|
|
385
|
+
if (sort) paramsToPersist.set("sort", sort);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 4. Transform facets to filters (matching original toFilter)
|
|
389
|
+
const visibleFacets = facetsResult.facets.filter((f) => !f.hidden);
|
|
390
|
+
const filters = visibleFacets.map(toFilter(facets, paramsToPersist));
|
|
391
|
+
|
|
392
|
+
// 5. Build pagination (matching original logic)
|
|
393
|
+
const currentPageoffset = 1;
|
|
394
|
+
const hasNextPage = Boolean(pagination.next?.proxyUrl);
|
|
395
|
+
const hasPreviousPage = page > 0;
|
|
396
|
+
|
|
397
|
+
const nextPageParams = new URLSearchParams(paramsToPersist);
|
|
398
|
+
const prevPageParams = new URLSearchParams(paramsToPersist);
|
|
399
|
+
|
|
400
|
+
// Re-add active filter.* params so pagination links preserve selected filters
|
|
401
|
+
for (const { key, value } of facets) {
|
|
402
|
+
nextPageParams.append(`filter.${key}`, value);
|
|
403
|
+
prevPageParams.append(`filter.${key}`, value);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (hasNextPage) {
|
|
407
|
+
nextPageParams.set("page", String(page + currentPageoffset + 1));
|
|
408
|
+
}
|
|
409
|
+
if (hasPreviousPage) {
|
|
410
|
+
prevPageParams.set("page", String(page + currentPageoffset - 1));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const breadcrumbItems = pageTypesToBreadcrumb(pageTypes);
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"@type": "ProductListingPage",
|
|
417
|
+
breadcrumb: {
|
|
418
|
+
"@type": "BreadcrumbList",
|
|
419
|
+
itemListElement: breadcrumbItems,
|
|
420
|
+
numberOfItems: breadcrumbItems.length,
|
|
421
|
+
},
|
|
422
|
+
filters,
|
|
423
|
+
products: schemaProducts,
|
|
424
|
+
pageInfo: {
|
|
425
|
+
nextPage: hasNextPage ? `?${nextPageParams}` : undefined,
|
|
426
|
+
previousPage: hasPreviousPage ? `?${prevPageParams}` : undefined,
|
|
427
|
+
currentPage: page + currentPageoffset,
|
|
428
|
+
records: recordsFiltered,
|
|
429
|
+
recordPerPage: pagination.perPage,
|
|
430
|
+
},
|
|
431
|
+
sortOptions: [
|
|
432
|
+
{ value: "", label: "relevance:desc" },
|
|
433
|
+
{ value: "price:desc", label: "price:desc" },
|
|
434
|
+
{ value: "price:asc", label: "price:asc" },
|
|
435
|
+
{ value: "orders:desc", label: "orders:desc" },
|
|
436
|
+
{ value: "name:desc", label: "name:desc" },
|
|
437
|
+
{ value: "name:asc", label: "name:asc" },
|
|
438
|
+
{ value: "release:desc", label: "release:desc" },
|
|
439
|
+
{ value: "discount:desc", label: "discount:desc" },
|
|
440
|
+
],
|
|
441
|
+
seo: pageTypesToSeo(pageTypes),
|
|
442
|
+
};
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error("[VTEX] PLP error:", error);
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Related/cross-selling products loader using Legacy Catalog API + shared transform.
|
|
3
|
+
* Maps VTEX catalog response to schema.org Product[] following deco-cx/apps pattern.
|
|
4
|
+
*
|
|
5
|
+
* Includes in-flight dedup for slug→productId resolution so multiple sections
|
|
6
|
+
* on the same page (similars, suggestions, whoboughtalsobought, etc.) share a
|
|
7
|
+
* single search/{slug}/p call instead of each doing their own.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Product } from "../../commerce/types/commerce";
|
|
11
|
+
import { getVtexConfig, vtexCachedFetch } from "../client";
|
|
12
|
+
import { resolveProductIdBySlug } from "../utils/slugCache";
|
|
13
|
+
import { pickSku, toProduct } from "../utils/transform";
|
|
14
|
+
import type { LegacyProduct } from "../utils/types";
|
|
15
|
+
|
|
16
|
+
export type CrossSellingType =
|
|
17
|
+
| "similars"
|
|
18
|
+
| "suggestions"
|
|
19
|
+
| "accessories"
|
|
20
|
+
| "whosawalsosaw"
|
|
21
|
+
| "whosawalsobought"
|
|
22
|
+
| "whoboughtalsobought"
|
|
23
|
+
| "showtogether";
|
|
24
|
+
|
|
25
|
+
export interface RelatedProductsProps {
|
|
26
|
+
slug?: string;
|
|
27
|
+
productId?: string;
|
|
28
|
+
crossSelling?: CrossSellingType;
|
|
29
|
+
count?: number;
|
|
30
|
+
hideUnavailableItems?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fetchCrossSelling(
|
|
34
|
+
type: CrossSellingType,
|
|
35
|
+
productId: string,
|
|
36
|
+
): Promise<LegacyProduct[] | null> {
|
|
37
|
+
return vtexCachedFetch<LegacyProduct[]>(
|
|
38
|
+
`/api/catalog_system/pub/products/crossselling/${type}/${productId}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default async function vtexRelatedProducts(
|
|
43
|
+
props: RelatedProductsProps,
|
|
44
|
+
): Promise<Product[] | null> {
|
|
45
|
+
const { slug, crossSelling = "similars", count = 8 } = props;
|
|
46
|
+
|
|
47
|
+
let productId = props.productId;
|
|
48
|
+
|
|
49
|
+
if (!productId) {
|
|
50
|
+
if (!slug) return null;
|
|
51
|
+
const linkText = slug.replace(/\/p$/, "").replace(/^\//, "");
|
|
52
|
+
productId = (await resolveProductIdBySlug(linkText)) ?? undefined;
|
|
53
|
+
if (!productId) return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const related = await fetchCrossSelling(crossSelling, productId);
|
|
58
|
+
if (!related?.length) return [];
|
|
59
|
+
|
|
60
|
+
const config = getVtexConfig();
|
|
61
|
+
const baseUrl = config.publicUrl
|
|
62
|
+
? `https://${config.publicUrl}`
|
|
63
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
64
|
+
|
|
65
|
+
let result = related.slice(0, count).map((p) => {
|
|
66
|
+
const sku = pickSku(p);
|
|
67
|
+
return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (props.hideUnavailableItems) {
|
|
71
|
+
result = result.filter((p) =>
|
|
72
|
+
p.offers?.offers?.some(
|
|
73
|
+
(o) => o.availability === "https://schema.org/InStock",
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return result;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error("[VTEX] Related products error:", error);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS autocomplete suggestions loader.
|
|
3
|
+
* Maps VTEX IS response to commerce Suggestion type.
|
|
4
|
+
*/
|
|
5
|
+
import { intelligentSearch, getVtexConfig, toFacetPath } from "../client";
|
|
6
|
+
import { toProduct, pickSku } from "../utils/transform";
|
|
7
|
+
import type { Product as ProductVTEX } from "../utils/types";
|
|
8
|
+
import type { Suggestion, Product } from "../../commerce/types/commerce";
|
|
9
|
+
|
|
10
|
+
export interface SuggestionsProps {
|
|
11
|
+
query?: string;
|
|
12
|
+
count?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default async function vtexSuggestions(
|
|
16
|
+
props: SuggestionsProps,
|
|
17
|
+
): Promise<Suggestion | null> {
|
|
18
|
+
const query = props.query || "";
|
|
19
|
+
if (!query.trim()) return { searches: [], products: [] };
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const data = await intelligentSearch<{
|
|
23
|
+
searches: Array<{ term: string; count: number }>;
|
|
24
|
+
products: ProductVTEX[];
|
|
25
|
+
}>("/autocomplete_suggestions/", { query });
|
|
26
|
+
|
|
27
|
+
const searches = (data.searches ?? []).map((s) => ({
|
|
28
|
+
term: s.term,
|
|
29
|
+
hits: s.count || 0,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const config = getVtexConfig();
|
|
33
|
+
const baseUrl = config.publicUrl
|
|
34
|
+
? `https://${config.publicUrl}`
|
|
35
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
36
|
+
|
|
37
|
+
const products: Product[] = (data.products ?? [])
|
|
38
|
+
.slice(0, props.count ?? 4)
|
|
39
|
+
.map((p) => {
|
|
40
|
+
const sku = pickSku(p);
|
|
41
|
+
return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return { searches, products };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error("[VTEX] Suggestions error:", error);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow/collection products loader using Intelligent Search + shared transform.
|
|
3
|
+
* Maps IS response to schema.org Product[] following deco-cx/apps pattern.
|
|
4
|
+
*/
|
|
5
|
+
import { intelligentSearch, getVtexConfig, toFacetPath } from "../client";
|
|
6
|
+
import { toProduct, pickSku } from "../utils/transform";
|
|
7
|
+
import type { Product as ProductVTEX } from "../utils/types";
|
|
8
|
+
import type { Product } from "../../commerce/types/commerce";
|
|
9
|
+
|
|
10
|
+
export interface WorkflowProductsProps {
|
|
11
|
+
props?: {
|
|
12
|
+
query?: string;
|
|
13
|
+
count?: number;
|
|
14
|
+
sort?: string;
|
|
15
|
+
collection?: string;
|
|
16
|
+
};
|
|
17
|
+
page?: number;
|
|
18
|
+
pagesize?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function vtexWorkflowProducts(
|
|
22
|
+
props: WorkflowProductsProps,
|
|
23
|
+
): Promise<Product[] | null> {
|
|
24
|
+
const inner = props.props ?? props;
|
|
25
|
+
const collection = (inner as any).collection;
|
|
26
|
+
const query = (inner as any).query ?? "";
|
|
27
|
+
const count = (inner as any).count ?? props.pagesize ?? 12;
|
|
28
|
+
const sort = (inner as any).sort ?? "";
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const config = getVtexConfig();
|
|
32
|
+
const locale = config.locale ?? "pt-BR";
|
|
33
|
+
|
|
34
|
+
const params: Record<string, string> = {
|
|
35
|
+
count: String(count),
|
|
36
|
+
locale,
|
|
37
|
+
page: String((props.page ?? 0) + 1),
|
|
38
|
+
};
|
|
39
|
+
if (query) params.query = query;
|
|
40
|
+
if (sort) params.sort = sort;
|
|
41
|
+
|
|
42
|
+
const facetPath = collection
|
|
43
|
+
? toFacetPath([{ key: "productClusterIds", value: collection }])
|
|
44
|
+
: "";
|
|
45
|
+
|
|
46
|
+
const endpoint = facetPath
|
|
47
|
+
? `/product_search/${facetPath}`
|
|
48
|
+
: "/product_search/";
|
|
49
|
+
|
|
50
|
+
const data = await intelligentSearch<{ products: ProductVTEX[] }>(
|
|
51
|
+
endpoint,
|
|
52
|
+
params,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const products = data.products ?? [];
|
|
56
|
+
const baseUrl = config.publicUrl
|
|
57
|
+
? `https://${config.publicUrl}`
|
|
58
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
59
|
+
|
|
60
|
+
return products.map((p) => {
|
|
61
|
+
const sku = pickSku(p);
|
|
62
|
+
return toProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error("[VTEX] Workflow products error:", error);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|