@decocms/apps-nuvemshop 7.74.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.
Files changed (35) hide show
  1. package/package.json +48 -0
  2. package/src/__fixtures__/browse-category.json +813 -0
  3. package/src/__fixtures__/categories.json +60 -0
  4. package/src/__fixtures__/checkout.json +3 -0
  5. package/src/__fixtures__/product-custom-fields.json +199 -0
  6. package/src/__fixtures__/product.json +313 -0
  7. package/src/__fixtures__/search.json +636 -0
  8. package/src/__fixtures__/shipping-options.json +9 -0
  9. package/src/__tests__/client.test.ts +118 -0
  10. package/src/__tests__/loaders.test.ts +220 -0
  11. package/src/__tests__/mod.test.ts +51 -0
  12. package/src/actions/createCheckout.ts +36 -0
  13. package/src/client.ts +165 -0
  14. package/src/hooks/__tests__/useCart.test.ts +81 -0
  15. package/src/hooks/useCart.ts +115 -0
  16. package/src/index.ts +23 -0
  17. package/src/loaders/categories.ts +27 -0
  18. package/src/loaders/productDetailsPage.ts +30 -0
  19. package/src/loaders/productList.ts +49 -0
  20. package/src/loaders/productListingPage.ts +94 -0
  21. package/src/loaders/relatedProducts.ts +41 -0
  22. package/src/loaders/shippingOptions.ts +29 -0
  23. package/src/loaders/suggestions.ts +24 -0
  24. package/src/manifest.gen.ts +31 -0
  25. package/src/mod.ts +42 -0
  26. package/src/registry.ts +9 -0
  27. package/src/utils/__tests__/listing.test.ts +167 -0
  28. package/src/utils/__tests__/transform.test.ts +212 -0
  29. package/src/utils/instrumentedFetch.ts +40 -0
  30. package/src/utils/listing.ts +183 -0
  31. package/src/utils/operationRouter.ts +21 -0
  32. package/src/utils/request.ts +17 -0
  33. package/src/utils/transform.ts +231 -0
  34. package/src/utils/types.ts +141 -0
  35. package/tsconfig.json +7 -0
@@ -0,0 +1,167 @@
1
+ import type { FilterRange, FilterToggle } from "@decocms/apps-commerce/types";
2
+ import { describe, expect, it } from "vitest";
3
+ import browseFixture from "../../__fixtures__/browse-category.json";
4
+ import { applyListing, SORT_OPTIONS } from "../listing";
5
+ import type { NuvemshopList, NuvemshopProduct } from "../types";
6
+
7
+ const browse: NuvemshopList<NuvemshopProduct> = browseFixture;
8
+ // camisa-xadrez (189.90, Azul e Preto | Vermelho e Preto), camisa-flanela (169.90 promo, Azul e Preto | Cinza),
9
+ // camiseta-gamer (89.90)
10
+ const products = browse.data;
11
+ const ORIGIN = "https://loja.example";
12
+ const run = (qs = "", count = 24) =>
13
+ applyListing(products, new URL(`${ORIGIN}/camisas/${qs}`), { origin: ORIGIN, count });
14
+ const toggle = (f: ReturnType<typeof run>["filters"], key: string) =>
15
+ f.find((x): x is FilterToggle => x.key === key && x["@type"] === "FilterToggle")!;
16
+
17
+ describe("applyListing — facets", () => {
18
+ it("builds one toggle facet per attribute with product counts", () => {
19
+ const cor = toggle(run().filters, "Cor");
20
+ expect(cor.label).toBe("Cor");
21
+ expect(cor.values.map((v) => [v.value, v.quantity, v.selected])).toEqual(
22
+ expect.arrayContaining([
23
+ ["Azul e Preto", 2, false],
24
+ ["Cinza", 1, false],
25
+ ["Vermelho e Preto", 1, false],
26
+ ]),
27
+ );
28
+ expect(cor.values.find((v) => v.value === "Cinza")!.url).toBe(`${ORIGIN}/camisas/?Cor=Cinza`);
29
+ });
30
+
31
+ it("filters by attribute (OR within, AND across) and keeps the URL toggle reversible", () => {
32
+ const r = run("?Cor=Cinza");
33
+ expect(r.products.map((p) => p.isVariantOf!.productGroupID)).toEqual(["372190471"]);
34
+ const cinza = toggle(r.filters, "Cor").values.find((v) => v.value === "Cinza")!;
35
+ expect(cinza.selected).toBe(true);
36
+ expect(cinza.url).toBe(`${ORIGIN}/camisas/`);
37
+ // facet counts ignore their own selection
38
+ expect(toggle(r.filters, "Cor").values.find((v) => v.value === "Azul e Preto")!.quantity).toBe(
39
+ 2,
40
+ );
41
+
42
+ // Nuvemshop theme format: multiple values joined with "|" (OR)
43
+ expect(run("?Cor=Cinza|Vermelho+e+Preto").products).toHaveLength(2);
44
+ expect(run("?Cor=Cinza&Tamanho=P").products).toHaveLength(1);
45
+ });
46
+
47
+ it("adds/removes values in the theme's pipe-joined format", () => {
48
+ const cor = (qs: string) => toggle(run(qs).filters, "Cor").values;
49
+ const url = (qs: string, value: string) =>
50
+ decodeURIComponent(cor(qs).find((v) => v.value === value)!.url);
51
+ expect(url("?Cor=Cinza", "Azul e Preto")).toBe(`${ORIGIN}/camisas/?Cor=Cinza|Azul+e+Preto`);
52
+ expect(url("?Cor=Cinza|Azul+e+Preto", "Cinza")).toBe(`${ORIGIN}/camisas/?Cor=Azul+e+Preto`);
53
+ expect(
54
+ cor("?Cor=Cinza|Azul+e+Preto")
55
+ .filter((v) => v.selected)
56
+ .map((v) => v.value)
57
+ .sort(),
58
+ ).toEqual(["Azul e Preto", "Cinza"]);
59
+ });
60
+
61
+ it("shows the card on the variant that matches the selected filter", () => {
62
+ const [card] = run("?Cor=Cinza").products;
63
+ expect(card.additionalProperty!.find((p) => p.name === "Cor")!.value).toBe("Cinza");
64
+ });
65
+
66
+ it("offers a price range facet and filters by sale price", () => {
67
+ const price = run().filters.find((f): f is FilterRange => f["@type"] === "FilterRange")!;
68
+ expect(price).toMatchObject({ key: "price", values: { min: 89.9, max: 189.9 } });
69
+ expect(run("?min_price=100&max_price=180").products.map((p) => p.offers!.lowPrice)).toEqual([
70
+ 169.9,
71
+ ]);
72
+ });
73
+
74
+ it("skips products with no visible variants", () => {
75
+ const hidden = {
76
+ ...products[0],
77
+ variants: products[0].variants!.map((v) => ({ ...v, visible: false })),
78
+ };
79
+ const r = applyListing([hidden, products[2]], new URL(`${ORIGIN}/x`), {
80
+ origin: ORIGIN,
81
+ count: 24,
82
+ });
83
+ expect(r.products).toHaveLength(1);
84
+ });
85
+ });
86
+
87
+ describe("applyListing — sort", () => {
88
+ const ids = (qs: string) => run(qs).products.map((p) => p.offers!.lowPrice);
89
+ it("sorts in memory by price, name and discount; no sort keeps the API order", () => {
90
+ expect(ids("")).toEqual([189.9, 169.9, 89.9]);
91
+ expect(ids("?sort_by=price-ascending")).toEqual([89.9, 169.9, 189.9]);
92
+ expect(ids("?sort_by=price-descending")).toEqual([189.9, 169.9, 89.9]);
93
+ expect(run("?sort_by=alpha-ascending").products.map((p) => p.isVariantOf!.name)).toEqual([
94
+ "Camisa Flanela Montanha",
95
+ "Camisa Xadrez Lenhador Azul",
96
+ "Camiseta Gamer Preta Estampada",
97
+ ]);
98
+ expect(ids("?sort_by=discount-descending")[0]).toBe(169.9);
99
+ });
100
+ it("uses the theme's sort_by values so migrated links keep sorting", () => {
101
+ expect(run().sortOptions).toBe(SORT_OPTIONS);
102
+ expect(SORT_OPTIONS.map((o) => o.value)).toEqual([
103
+ "best-selling",
104
+ "price-ascending",
105
+ "price-descending",
106
+ "alpha-ascending",
107
+ "alpha-descending",
108
+ "created-descending",
109
+ "created-ascending",
110
+ "discount-descending",
111
+ ]);
112
+ const names = (qs: string) => run(qs).products.map((p) => p.isVariantOf!.name);
113
+ expect(names("?sort_by=alpha-descending")[0]).toBe("Camiseta Gamer Preta Estampada");
114
+ // created_at order (fixture products share a timestamp → stable API order)
115
+ expect(names("?sort_by=created-ascending")).toHaveLength(3);
116
+ });
117
+ });
118
+
119
+ describe("applyListing — default order and ties (theme parity)", () => {
120
+ const at = (p: NuvemshopProduct, created_at: string, price?: string) => ({
121
+ ...p,
122
+ created_at,
123
+ variants: p.variants!.map((v) => (price ? { ...v, price, promotional_price: null } : v)),
124
+ });
125
+ const older = at(products[0], "2026-01-01T00:00:00+0000", "100.00");
126
+ const newer = at(products[2], "2026-02-01T00:00:00+0000", "100.00");
127
+ const list = (qs: string, defaultSort?: string) =>
128
+ applyListing([older, newer], new URL(`${ORIGIN}/c/${qs}`), {
129
+ origin: ORIGIN,
130
+ count: 24,
131
+ defaultSort,
132
+ }).products.map((p) => p.isVariantOf!.productGroupID);
133
+
134
+ it("applies the configured default sort when there's no sort_by (or an unknown one, e.g. 'user')", () => {
135
+ expect(list("", "created-descending")).toEqual([String(newer.id), String(older.id)]);
136
+ expect(list("?sort_by=user", "created-descending")).toEqual([
137
+ String(newer.id),
138
+ String(older.id),
139
+ ]);
140
+ expect(list("")).toEqual([String(older.id), String(newer.id)]); // no default → API order
141
+ });
142
+
143
+ it("breaks price ties newest-first like the theme", () => {
144
+ expect(list("?sort_by=price-ascending")).toEqual([String(newer.id), String(older.id)]);
145
+ expect(list("?sort_by=price-descending")).toEqual([String(newer.id), String(older.id)]);
146
+ });
147
+ });
148
+
149
+ describe("applyListing — pagination", () => {
150
+ it("paginates the filtered set and links next/previous preserving filters", () => {
151
+ const p1 = run("?Cor=Azul+e+Preto", 1);
152
+ expect(p1.products).toHaveLength(1);
153
+ expect(p1.pageInfo).toEqual({
154
+ currentPage: 1,
155
+ records: 2,
156
+ recordPerPage: 1,
157
+ nextPage: "?Cor=Azul+e+Preto&page=2",
158
+ previousPage: undefined,
159
+ });
160
+ const p2 = run("?Cor=Azul+e+Preto&page=2", 1);
161
+ expect(p2.pageInfo).toMatchObject({
162
+ currentPage: 2,
163
+ nextPage: undefined,
164
+ previousPage: "?Cor=Azul+e+Preto&page=1",
165
+ });
166
+ });
167
+ });
@@ -0,0 +1,212 @@
1
+ import type { Product, ProductDetailsPage } from "@decocms/apps-commerce/types";
2
+ import { describe, expect, expectTypeOf, it } from "vitest";
3
+ import browseFixture from "../../__fixtures__/browse-category.json";
4
+ import categoriesFixture from "../../__fixtures__/categories.json";
5
+ import productFixture from "../../__fixtures__/product.json";
6
+ import customFieldsFixture from "../../__fixtures__/product-custom-fields.json";
7
+ import { pickVariant, toBreadcrumbList, toProduct, toProductPage } from "../transform";
8
+ import type { NuvemshopCategory, NuvemshopList, NuvemshopProduct } from "../types";
9
+
10
+ // Contract: the captured API responses must satisfy our API types. If the API
11
+ // adds/renames a field in a breaking way, these assignments stop compiling.
12
+ const raw: NuvemshopProduct = productFixture;
13
+ const browse: NuvemshopList<NuvemshopProduct> = browseFixture;
14
+ const categories: NuvemshopList<NuvemshopCategory> = categoriesFixture;
15
+ const withCustomFields: NuvemshopProduct = customFieldsFixture;
16
+
17
+ const ORIGIN = "https://loja.example";
18
+ const IMG = "https://dcdn-us.mitiendanube.com/stores/008/336/778/products";
19
+
20
+ describe("toProduct", () => {
21
+ it("maps a variant with no discount to the commerce Product shape", () => {
22
+ const product = toProduct(raw, raw.variants![0], { origin: ORIGIN });
23
+ expectTypeOf(product).toEqualTypeOf<Product>();
24
+
25
+ expect(product).toMatchObject({
26
+ "@type": "Product",
27
+ productID: "1612648502",
28
+ sku: "1612648502",
29
+ name: "P / Azul e Preto",
30
+ url: `${ORIGIN}/produtos/camisa-xadrez-lenhador-azul?variant=1612648502`,
31
+ category: "Camisas",
32
+ additionalProperty: [
33
+ { "@type": "PropertyValue", name: "Tamanho", value: "P", valueReference: "SPECIFICATION" },
34
+ {
35
+ "@type": "PropertyValue",
36
+ name: "Cor",
37
+ value: "Azul e Preto",
38
+ valueReference: "SPECIFICATION",
39
+ },
40
+ ],
41
+ image: [
42
+ {
43
+ "@type": "ImageObject",
44
+ url: `${IMG}/1-0dc6e3225f75c6be6617912360695512-1024-1024.webp`,
45
+ alternateName: "Camisa Xadrez Lenhador Azul",
46
+ },
47
+ {
48
+ "@type": "ImageObject",
49
+ url: `${IMG}/2-c444e295855213e1e117912360699859-1024-1024.webp`,
50
+ alternateName: "Camisa Xadrez Lenhador Azul",
51
+ },
52
+ ],
53
+ offers: {
54
+ "@type": "AggregateOffer",
55
+ priceCurrency: "BRL",
56
+ lowPrice: 189.9,
57
+ highPrice: 189.9,
58
+ offerCount: 1,
59
+ offers: [
60
+ {
61
+ "@type": "Offer",
62
+ sku: "MD001-1-P",
63
+ price: 189.9,
64
+ availability: "https://schema.org/InStock",
65
+ inventoryLevel: { value: 17 },
66
+ priceSpecification: [
67
+ {
68
+ "@type": "UnitPriceSpecification",
69
+ priceType: "https://schema.org/ListPrice",
70
+ price: 189.9,
71
+ },
72
+ {
73
+ "@type": "UnitPriceSpecification",
74
+ priceType: "https://schema.org/SalePrice",
75
+ price: 189.9,
76
+ },
77
+ ],
78
+ },
79
+ ],
80
+ },
81
+ isVariantOf: {
82
+ "@type": "ProductGroup",
83
+ productGroupID: "372190453",
84
+ name: "Camisa Xadrez Lenhador Azul",
85
+ url: `${ORIGIN}/produtos/camisa-xadrez-lenhador-azul`,
86
+ },
87
+ });
88
+ expect(product.isVariantOf!.hasVariant).toHaveLength(8);
89
+ expect(product.isVariantOf!.hasVariant[4].name).toBe("P / Vermelho e Preto");
90
+ });
91
+
92
+ it("uses promotional_price as the sale price and compare_at as the list price", () => {
93
+ const flanela = browse.data[1];
94
+ const product = toProduct(flanela, flanela.variants![0], { origin: ORIGIN });
95
+ expect(product.offers).toMatchObject({ lowPrice: 169.9, highPrice: 199.9 });
96
+ expect(product.offers!.offers[0].price).toBe(169.9);
97
+ expect(product.offers!.offers[0].priceSpecification).toEqual([
98
+ {
99
+ "@type": "UnitPriceSpecification",
100
+ priceType: "https://schema.org/ListPrice",
101
+ price: 199.9,
102
+ },
103
+ {
104
+ "@type": "UnitPriceSpecification",
105
+ priceType: "https://schema.org/SalePrice",
106
+ price: 169.9,
107
+ },
108
+ ]);
109
+ });
110
+
111
+ it("puts the variant's own image first", () => {
112
+ const flanela = browse.data[1];
113
+ const cinza = flanela.variants!.find((v) => v.values[1] === "Cinza")!;
114
+ const product = toProduct(flanela, cinza, { origin: ORIGIN });
115
+ expect(product.image![0].url).toBe(flanela.images!.find((i) => i.id === cinza.image_id)!.src);
116
+ expect(product.image).toHaveLength(flanela.images!.length);
117
+ });
118
+
119
+ it("derives availability from stock_management/stock (null stock = unlimited)", () => {
120
+ const v = raw.variants![0];
121
+ const at = (patch: Partial<typeof v>) =>
122
+ toProduct(raw, { ...v, ...patch }, { origin: ORIGIN }).offers!.offers[0].availability;
123
+ expect(at({ stock: 0 })).toBe("https://schema.org/OutOfStock");
124
+ expect(at({ stock: null })).toBe("https://schema.org/InStock");
125
+ expect(at({ stock: 0, stock_management: false })).toBe("https://schema.org/InStock");
126
+ });
127
+
128
+ it("never leaks the variant cost price", () => {
129
+ const leaky = { ...raw.variants![0], cost: "42.00" };
130
+ expect(
131
+ JSON.stringify(toProduct({ ...raw, variants: [leaky] }, leaky, { origin: ORIGIN })),
132
+ ).not.toContain("42");
133
+ });
134
+
135
+ it("exposes brand, gtin and custom fields", () => {
136
+ const product = toProduct(withCustomFields, withCustomFields.variants![0], { origin: ORIGIN });
137
+ expect(product.brand).toEqual({ "@type": "Brand", name: "ACME" });
138
+ expect(product.gtin).toBeUndefined(); // barcode: null upstream
139
+ const v = { ...withCustomFields.variants![0], barcode: "7798446191071" };
140
+ expect(toProduct(withCustomFields, v, { origin: ORIGIN }).gtin).toBe("7798446191071");
141
+ expect(product.isVariantOf!.additionalProperty).toContainEqual({
142
+ "@type": "PropertyValue",
143
+ name: "manual",
144
+ value: "https://files.example.com/manuals/ergonomic-chair-freedom-pt.pdf",
145
+ valueReference: "CUSTOM_FIELD",
146
+ });
147
+ });
148
+
149
+ it("flags free shipping (the theme's shipping label) only when the product has it", () => {
150
+ const flag = (free_shipping: boolean) =>
151
+ toProduct({ ...raw, free_shipping }, raw.variants![0], {
152
+ origin: ORIGIN,
153
+ }).isVariantOf!.additionalProperty.filter((p) => p.valueReference === "SHIPPING");
154
+ expect(flag(true)).toEqual([
155
+ { "@type": "PropertyValue", name: "freeShipping", value: "true", valueReference: "SHIPPING" },
156
+ ]);
157
+ expect(flag(false)).toEqual([]);
158
+ });
159
+
160
+ it("honours a configured currency", () => {
161
+ expect(
162
+ toProduct(raw, raw.variants![0], { origin: ORIGIN, currency: "ARS" }).offers!.priceCurrency,
163
+ ).toBe("ARS");
164
+ });
165
+ });
166
+
167
+ describe("pickVariant", () => {
168
+ it("selects by ?variant= and falls back to the first available variant", () => {
169
+ const v = raw.variants!;
170
+ expect(pickVariant(raw, "1612648511").id).toBe(1612648511);
171
+ expect(pickVariant(raw, "nope").id).toBe(v[0].id);
172
+ const firstOut = { ...raw, variants: [{ ...v[0], stock: 0 }, v[1]] };
173
+ expect(pickVariant(firstOut).id).toBe(v[1].id);
174
+ });
175
+ });
176
+
177
+ describe("toBreadcrumbList", () => {
178
+ it("walks the category tree from root to leaf", () => {
179
+ const masculino = categories.data.find((c) => c.handle === "masculino")!;
180
+ expect(toBreadcrumbList(masculino, categories.data, ORIGIN)).toEqual({
181
+ "@type": "BreadcrumbList",
182
+ numberOfItems: 2,
183
+ itemListElement: [
184
+ { "@type": "ListItem", name: "Calçados", item: `${ORIGIN}/calcados`, position: 1 },
185
+ {
186
+ "@type": "ListItem",
187
+ name: "Masculino",
188
+ item: `${ORIGIN}/calcados/masculino`,
189
+ position: 2,
190
+ },
191
+ ],
192
+ });
193
+ });
194
+ });
195
+
196
+ describe("toProductPage", () => {
197
+ it("builds a PDP with breadcrumb ending on the product and SEO fallbacks", () => {
198
+ const page = toProductPage(raw, { origin: ORIGIN, variantId: "1612648503" });
199
+ expectTypeOf(page).toEqualTypeOf<ProductDetailsPage>();
200
+ expect(page.product.productID).toBe("1612648503");
201
+ expect(page.breadcrumbList.itemListElement.map((i) => i.name)).toEqual([
202
+ "Camisas",
203
+ "Camisa Xadrez Lenhador Azul",
204
+ ]);
205
+ expect(page.seo).toEqual({
206
+ title: "Camisa Xadrez Lenhador Azul",
207
+ description: expect.stringMatching(/^Camisa xadrez de flanela macia/),
208
+ canonical: `${ORIGIN}/produtos/camisa-xadrez-lenhador-azul`,
209
+ });
210
+ expect(page.seo!.description).not.toMatch(/<\w+/);
211
+ });
212
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pre-wired instrumented fetch for Nuvemshop — mirrors the Magento/Shopify
3
+ * factories: spans + `http.client.request.duration` with
4
+ * `provider: "nuvemshop"`. It is the client's default transport (like
5
+ * Salesforce), so sites get instrumentation without calling
6
+ * `setNuvemshopFetch`. SWR hit/miss is emitted by `createFetchCache`.
7
+ */
8
+
9
+ import type { FetchFn } from "@decocms/blocks/sdk/fetchTimeout";
10
+ import {
11
+ createInstrumentedFetch,
12
+ type InstrumentedFetch,
13
+ } from "@decocms/blocks/sdk/instrumentedFetch";
14
+ import { recordCommerceMetric, statusClassFor } from "@decocms/blocks/sdk/observability";
15
+ import { nuvemshopOperationRouter } from "./operationRouter";
16
+
17
+ export interface CreateNuvemshopFetchOptions {
18
+ /** Underlying fetch to wrap. Defaults to `globalThis.fetch`. */
19
+ baseFetch?: FetchFn;
20
+ /** Disable the duration histogram (spans/logs still emit). Default: false. */
21
+ disableHistogram?: boolean;
22
+ }
23
+
24
+ export function createNuvemshopFetch(options: CreateNuvemshopFetchOptions = {}): InstrumentedFetch {
25
+ const { baseFetch, disableHistogram = false } = options;
26
+ return createInstrumentedFetch({
27
+ name: "nuvemshop",
28
+ baseFetch,
29
+ resolveOperation: nuvemshopOperationRouter,
30
+ onComplete: disableHistogram
31
+ ? undefined
32
+ : (r) =>
33
+ recordCommerceMetric(r.durationMs, {
34
+ provider: "nuvemshop",
35
+ operation: r.operation,
36
+ status_class: statusClassFor(r.status),
37
+ cached: r.cached,
38
+ }),
39
+ });
40
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * In-memory PLP: facets, sort and pagination over a window of products.
3
+ *
4
+ * The Storefront API (v2026-11) has no facets and only three sorts, while the
5
+ * Nuvemshop core already filters by variant values and price. Until the API
6
+ * exposes those, we fetch a window of products and compute everything here.
7
+ * URL params mirror the core theme (`?Cor=Vermelho|Preto&min_price=100`, values
8
+ * OR-ed with "|") so links keep working after a migration.
9
+ *
10
+ * ponytail: correct only while the category/search fits in one API page
11
+ * (≤200 products, `LISTING_WINDOW`). Switch to native API facets when they ship.
12
+ */
13
+ import type { Filter, PageInfo, Product, SortOption } from "@decocms/apps-commerce/types";
14
+ import { isAvailable, type TransformOptions, toProduct, variantPrices } from "./transform";
15
+ import type { NuvemshopProduct, NuvemshopVariant } from "./types";
16
+
17
+ export const LISTING_WINDOW = 200;
18
+
19
+ /**
20
+ * The theme's `sort_by` values (so `?sort_by=alpha-ascending` links keep
21
+ * working), plus `discount-descending`, which the core doesn't offer.
22
+ * The theme's "user" (merchant's manual order) isn't in the API, so it's not
23
+ * offered; `?sort_by=user` falls back to the default sort.
24
+ */
25
+ export const SORT_OPTIONS: SortOption[] = [
26
+ { value: "best-selling", label: "Mais vendidos" },
27
+ { value: "price-ascending", label: "Menor preço" },
28
+ { value: "price-descending", label: "Maior preço" },
29
+ { value: "alpha-ascending", label: "A - Z" },
30
+ { value: "alpha-descending", label: "Z - A" },
31
+ { value: "created-descending", label: "Mais recentes" },
32
+ { value: "created-ascending", label: "Mais antigos" },
33
+ { value: "discount-descending", label: "Maior desconto" },
34
+ ];
35
+
36
+ export const SORT_PARAM = "sort_by";
37
+
38
+ type Selection = Map<string, Set<string>>;
39
+
40
+ /** The theme joins multiple values of one filter with "|" (`?Cor=Vermelho|Preto`). */
41
+ const VALUE_SEPARATOR = "|";
42
+ const valuesOf = (params: URLSearchParams, attr: string) =>
43
+ (params.get(attr) ?? "").split(VALUE_SEPARATOR).filter(Boolean);
44
+
45
+ const visibleVariants = (p: NuvemshopProduct) =>
46
+ (p.variants ?? []).filter((v) => v.visible !== false);
47
+ const minPrice = (p: NuvemshopProduct) =>
48
+ Math.min(...visibleVariants(p).map((v) => variantPrices(v).price));
49
+ const maxDiscount = (p: NuvemshopProduct) =>
50
+ Math.max(
51
+ ...visibleVariants(p).map((v) => {
52
+ const { price, listPrice } = variantPrices(v);
53
+ return listPrice ? (listPrice - price) / listPrice : 0;
54
+ }),
55
+ );
56
+
57
+ function variantMatches(p: NuvemshopProduct, v: NuvemshopVariant, sel: Selection, skip?: string) {
58
+ const attrs = p.attributes ?? [];
59
+ for (const [attr, values] of sel) {
60
+ if (attr === skip) continue;
61
+ const i = attrs.indexOf(attr);
62
+ if (i < 0 || !values.has(v.values[i])) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ const matchingVariants = (p: NuvemshopProduct, sel: Selection, skip?: string) =>
68
+ visibleVariants(p).filter((v) => variantMatches(p, v, sel, skip));
69
+
70
+ type Compare = (a: NuvemshopProduct, b: NuvemshopProduct) => number;
71
+
72
+ /** Newest first; created_at has second precision, so ids break same-second ties. */
73
+ const newest: Compare = (a, b) =>
74
+ (b.created_at ?? "").localeCompare(a.created_at ?? "") || b.id - a.id;
75
+
76
+ // Ties fall back to newest-first, as in the theme.
77
+ const sorters: Record<string, Compare> = {
78
+ "price-ascending": (a, b) => minPrice(a) - minPrice(b) || newest(a, b),
79
+ "price-descending": (a, b) => minPrice(b) - minPrice(a) || newest(a, b),
80
+ "discount-descending": (a, b) => maxDiscount(b) - maxDiscount(a) || newest(a, b),
81
+ "alpha-ascending": (a, b) => a.name.localeCompare(b.name) || newest(a, b),
82
+ "alpha-descending": (a, b) => b.name.localeCompare(a.name) || newest(a, b),
83
+ "created-descending": newest,
84
+ "created-ascending": (a, b) => -newest(a, b),
85
+ };
86
+
87
+ function withParams(url: URL, mutate: (p: URLSearchParams) => void) {
88
+ const next = new URL(url);
89
+ next.searchParams.delete("page");
90
+ mutate(next.searchParams);
91
+ return next.toString();
92
+ }
93
+
94
+ export interface ListingOptions extends TransformOptions {
95
+ /** Products per page. */
96
+ count: number;
97
+ /** Sort applied without (or with an unknown) `sort_by`. Unset = keep the API order (search relevance). */
98
+ defaultSort?: string;
99
+ }
100
+
101
+ export function applyListing(source: NuvemshopProduct[], url: URL, opts: ListingOptions) {
102
+ const products = source.filter((p) => visibleVariants(p).length > 0);
103
+ const attributes = [...new Set(products.flatMap((p) => p.attributes ?? []))];
104
+
105
+ const sel: Selection = new Map();
106
+ for (const attr of attributes) {
107
+ const values = valuesOf(url.searchParams, attr);
108
+ if (values.length) sel.set(attr, new Set(values));
109
+ }
110
+ const lo = Number(url.searchParams.get("min_price") ?? Number.NEGATIVE_INFINITY);
111
+ const hi = Number(url.searchParams.get("max_price") ?? Number.POSITIVE_INFINITY);
112
+ const inPrice = (p: NuvemshopProduct) => minPrice(p) >= lo && minPrice(p) <= hi;
113
+
114
+ const filtered = products.filter((p) => inPrice(p) && matchingVariants(p, sel).length > 0);
115
+ const sorter = sorters[url.searchParams.get(SORT_PARAM) ?? ""] ?? sorters[opts.defaultSort ?? ""];
116
+ if (sorter) filtered.sort(sorter);
117
+
118
+ // Facet counts ignore their own attribute's selection (standard facet UX).
119
+ const filters: Filter[] = attributes.map((attr) => {
120
+ const counts = new Map<string, number>();
121
+ for (const p of products) {
122
+ if (!inPrice(p)) continue;
123
+ const i = (p.attributes ?? []).indexOf(attr);
124
+ if (i < 0) continue;
125
+ for (const value of new Set(matchingVariants(p, sel, attr).map((v) => v.values[i]))) {
126
+ counts.set(value, (counts.get(value) ?? 0) + 1);
127
+ }
128
+ }
129
+ const selected = sel.get(attr) ?? new Set<string>();
130
+ return {
131
+ "@type": "FilterToggle",
132
+ key: attr,
133
+ label: attr,
134
+ quantity: counts.size,
135
+ values: [...counts].map(([value, quantity]) => ({
136
+ value,
137
+ label: value,
138
+ quantity,
139
+ selected: selected.has(value),
140
+ url: withParams(url, (p) => {
141
+ const rest = valuesOf(p, attr).filter((v) => v !== value);
142
+ const next = selected.has(value) ? rest : [...rest, value];
143
+ if (next.length) p.set(attr, next.join(VALUE_SEPARATOR));
144
+ else p.delete(attr);
145
+ }),
146
+ })),
147
+ };
148
+ });
149
+ if (products.length) {
150
+ const prices = products.map(minPrice);
151
+ filters.push({
152
+ "@type": "FilterRange",
153
+ key: "price",
154
+ label: "Preço",
155
+ values: { min: Math.min(...prices), max: Math.max(...prices) },
156
+ });
157
+ }
158
+
159
+ const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
160
+ const start = (page - 1) * opts.count;
161
+ const pageLink = (n: number) => {
162
+ const p = new URLSearchParams(url.search);
163
+ p.set("page", String(n));
164
+ return `?${p}`;
165
+ };
166
+ const pageInfo: PageInfo = {
167
+ currentPage: page,
168
+ records: filtered.length,
169
+ recordPerPage: opts.count,
170
+ nextPage: start + opts.count < filtered.length ? pageLink(page + 1) : undefined,
171
+ previousPage: page > 1 ? pageLink(page - 1) : undefined,
172
+ };
173
+
174
+ return {
175
+ products: filtered.slice(start, start + opts.count).map((p): Product => {
176
+ const matches = matchingVariants(p, sel);
177
+ return toProduct(p, matches.find(isAvailable) ?? matches[0], opts);
178
+ }),
179
+ filters,
180
+ pageInfo,
181
+ sortOptions: SORT_OPTIONS,
182
+ };
183
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * URL → operation name for Nuvemshop Storefront API calls, plugged into
3
+ * `createInstrumentedFetch`'s `resolveOperation`. Returns `undefined` when
4
+ * nothing matches so the framework falls back to `nuvemshop.fetch`.
5
+ */
6
+ import { extractPathname } from "@decocms/blocks/sdk/urlUtils";
7
+
8
+ const MATCHERS: ReadonlyArray<[RegExp, string]> = [
9
+ [/\/stores\/\d+\/search\/products\/?$/, "products.search"],
10
+ [/\/stores\/\d+\/products\/?$/, "products.browse"],
11
+ [/\/stores\/\d+\/products\/[^/]+\/?$/, "products.get"],
12
+ [/\/stores\/\d+\/categories\/?$/, "categories.list"],
13
+ [/\/stores\/\d+\/categories\/[^/]+\/?$/, "categories.get"],
14
+ [/\/stores\/\d+\/shipping-options\/?$/, "shipping.options"],
15
+ [/\/stores\/\d+\/checkouts\/?$/, "checkout.create"],
16
+ ];
17
+
18
+ export function nuvemshopOperationRouter(url: string, _method: string): string | undefined {
19
+ const pathname = extractPathname(url);
20
+ return MATCHERS.find(([pattern]) => pattern.test(pathname))?.[1];
21
+ }
@@ -0,0 +1,17 @@
1
+ /** Page props injected by the CMS resolver (`resolve.ts`) into commerce loaders. */
2
+ export interface PageProps {
3
+ /** @ignore */
4
+ __pageUrl?: string;
5
+ /** @ignore */
6
+ __pagePath?: string;
7
+ }
8
+
9
+ /** The storefront URL being rendered: CMS-injected page URL, else the invoke request. */
10
+ export const pageUrlOf = (props: PageProps, req?: Request) =>
11
+ new URL(props.__pageUrl ?? req?.url ?? "https://localhost/", "https://localhost");
12
+
13
+ /** Absolute origin for product URLs; empty (relative URLs) when unknown. */
14
+ export const originOf = (props: PageProps, req?: Request) => {
15
+ const url = pageUrlOf(props, req);
16
+ return url.hostname === "localhost" && !props.__pageUrl && !req ? "" : url.origin;
17
+ };