@decocms/apps-nuvemshop 7.76.0 → 7.77.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-nuvemshop",
3
- "version": "7.76.0",
3
+ "version": "7.77.1",
4
4
  "type": "module",
5
5
  "description": "Deco commerce app: Nuvemshop (Tiendanube) Storefront API integration",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  "lint:unused": "knip"
31
31
  },
32
32
  "dependencies": {
33
- "@decocms/blocks": "7.76.0",
34
- "@decocms/apps-commerce": "7.76.0",
35
- "@decocms/apps-website": "7.76.0"
33
+ "@decocms/blocks": "7.77.1",
34
+ "@decocms/apps-commerce": "7.77.1",
35
+ "@decocms/apps-website": "7.77.1"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "react": "^19.0.0"
@@ -0,0 +1,118 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import browseFixture from "../__fixtures__/browse-category.json";
3
+ import { clearNuvemshopCache, configureNuvemshop, setNuvemshopFetch } from "../client";
4
+ import cart from "../loaders/cart";
5
+
6
+ // browse fixture: [0] camisa-xadrez 189.90 (stock 17,20,…), [1] camisa-flanela 169.90 promo / 199.90, [2] camiseta-gamer 89.90
7
+ const [xadrez, flanela, gamer] = browseFixture.data;
8
+ const SITE = "https://www.loja.example";
9
+ const fetchMock = vi.fn(async (input: string | URL | Request) => {
10
+ const url = new URL(String(input));
11
+ const ids = (url.searchParams.get("ids") ?? "").split(",").map(Number);
12
+ const data = browseFixture.data.filter((p) => ids.includes(p.id));
13
+ return new Response(
14
+ JSON.stringify({
15
+ data,
16
+ pagination: { page: 1, per_page: 30, total: data.length, total_pages: 1 },
17
+ }),
18
+ );
19
+ });
20
+ const item = (p: (typeof browseFixture.data)[number], v = 0, quantity = 1) => ({
21
+ productId: p.id,
22
+ variantId: p.variants[v].id,
23
+ quantity,
24
+ });
25
+
26
+ beforeEach(() => {
27
+ fetchMock.mockClear();
28
+ clearNuvemshopCache();
29
+ configureNuvemshop({ storeId: "8336778" });
30
+ setNuvemshopFetch(fetchMock as unknown as typeof fetch);
31
+ });
32
+
33
+ describe("cart loader", () => {
34
+ it("resolves each line from the Storefront API with prices, image and variant", async () => {
35
+ const c = await cart({ items: [item(xadrez, 0, 2), item(flanela, 5)], __pageUrl: `${SITE}/` });
36
+ expect(c.lines).toHaveLength(2);
37
+ expect(c.lines[0]).toMatchObject({
38
+ productId: xadrez.id,
39
+ variantId: xadrez.variants[0].id,
40
+ name: "Camisa Xadrez Lenhador Azul",
41
+ variantName: "P / Azul e Preto",
42
+ quantity: 2,
43
+ maxQuantity: 17,
44
+ price: 189.9,
45
+ listPrice: 189.9,
46
+ subtotal: 379.8,
47
+ url: `${SITE}/produtos/camisa-xadrez-lenhador-azul?variant=${xadrez.variants[0].id}`,
48
+ });
49
+ expect(c.lines[1]).toMatchObject({
50
+ variantName: "M / Cinza",
51
+ price: 169.9,
52
+ listPrice: 199.9,
53
+ subtotal: 169.9,
54
+ });
55
+ expect(c.lines[1].image).toBe(
56
+ flanela.images.find((i) => i.id === flanela.variants[5].image_id)!.src,
57
+ );
58
+ expect(c).toMatchObject({
59
+ itemCount: 3,
60
+ subtotal: 549.7,
61
+ listSubtotal: 579.7,
62
+ savings: 30,
63
+ currency: "BRL",
64
+ unavailable: [],
65
+ });
66
+ });
67
+
68
+ it("asks only for the products in the cart (ids, deduped) and nothing for an empty cart", async () => {
69
+ await cart({ items: [item(xadrez, 0), item(xadrez, 1), item(gamer)] });
70
+ const url = new URL(String(fetchMock.mock.calls[0][0]));
71
+ expect(url.searchParams.get("ids")).toBe(`${xadrez.id},${gamer.id}`);
72
+ expect(url.searchParams.get("per_page")).toBe("30");
73
+
74
+ fetchMock.mockClear();
75
+ expect(await cart({ items: [] })).toMatchObject({ lines: [], itemCount: 0, subtotal: 0 });
76
+ expect(fetchMock).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("clamps quantities to stock (the checkout rejects more) and to 99", async () => {
80
+ const c = await cart({ items: [{ ...item(xadrez, 0), quantity: 50 }] });
81
+ expect(c.lines[0]).toMatchObject({ quantity: 17, maxQuantity: 17, adjusted: true });
82
+ });
83
+
84
+ it("moves missing and sold-out variants out of the lines", async () => {
85
+ const soldOut = { ...item(gamer, 0), variantId: gamer.variants[0].id };
86
+ fetchMock.mockImplementationOnce(async () =>
87
+ Response.json({
88
+ data: [
89
+ { ...gamer, variants: gamer.variants.map((v, i) => (i === 0 ? { ...v, stock: 0 } : v)) },
90
+ ],
91
+ pagination: { page: 1, per_page: 30, total: 1, total_pages: 1 },
92
+ }),
93
+ );
94
+ const c = await cart({
95
+ items: [
96
+ soldOut,
97
+ { productId: gamer.id, variantId: 999, quantity: 1 },
98
+ { productId: 1, variantId: 2, quantity: 1 },
99
+ ],
100
+ });
101
+ expect(c.lines).toEqual([]);
102
+ expect(c.unavailable).toEqual([
103
+ { ...soldOut, reason: "out_of_stock" },
104
+ { productId: gamer.id, variantId: 999, quantity: 1, reason: "not_found" },
105
+ { productId: 1, variantId: 2, quantity: 1, reason: "not_found" },
106
+ ]);
107
+ });
108
+
109
+ it("fetches in batches of 30 ids (the API limit)", async () => {
110
+ const items = Array.from({ length: 31 }, (_, i) => ({
111
+ productId: i + 1,
112
+ variantId: 1,
113
+ quantity: 1,
114
+ }));
115
+ await cart({ items });
116
+ expect(fetchMock).toHaveBeenCalledTimes(2);
117
+ });
118
+ });
@@ -72,6 +72,15 @@ describe("productListingPage", () => {
72
72
  expect(plp!.seo).toMatchObject({ title: "Camisas", canonical: `${SITE}/camisas` });
73
73
  });
74
74
 
75
+ it("lists the category's default sort first, so a UI without ?sort_by= shows the real order", async () => {
76
+ const plp = await productListingPage({ __pageUrl: `${SITE}/camisas/` });
77
+ expect(plp!.sortOptions[0].value).toBe("created-descending");
78
+ configureNuvemshop({ storeId: "8336778", defaultSort: "price-ascending" });
79
+ const cheap = await productListingPage({ __pageUrl: `${SITE}/camisas/?x=1` });
80
+ expect(cheap!.sortOptions[0].value).toBe("price-ascending");
81
+ expect(cheap!.sortOptions).toHaveLength(plp!.sortOptions.length);
82
+ });
83
+
75
84
  it("asks the API for best-selling order (the only sort it must do itself)", async () => {
76
85
  await productListingPage({ __pageUrl: `${SITE}/camisas/?sort_by=best-selling` });
77
86
  expect(lastUrl("/products").searchParams.get("sort_by")).toBe("best-selling");
@@ -205,7 +214,7 @@ describe("createCheckout", () => {
205
214
  items: [{ productId: 372190453, variantId: 1612648502, quantity: 2 }],
206
215
  coupon: " TEST ",
207
216
  });
208
- expect(res.checkoutUrl).toBe(checkoutFixture.checkout_url);
217
+ expect(res).toEqual({ checkoutUrl: checkoutFixture.checkout_url });
209
218
  const body = JSON.parse(String(fetchMock.mock.calls.at(-1)![1]!.body));
210
219
  expect(body).toEqual({
211
220
  line_items: [{ product_id: 372190453, variant_id: 1612648502, quantity: 2 }],
@@ -213,8 +222,27 @@ describe("createCheckout", () => {
213
222
  });
214
223
  });
215
224
 
216
- it("rejects an empty cart without calling the API", async () => {
217
- await expect(createCheckout({ items: [] })).rejects.toThrow(/empty/);
225
+ it("returns a typed error for an empty cart without calling the API", async () => {
226
+ expect(await createCheckout({ items: [] })).toEqual({
227
+ error: "invalid",
228
+ message: "Cannot checkout an empty cart",
229
+ });
218
230
  expect(fetchMock).not.toHaveBeenCalled();
219
231
  });
232
+
233
+ it.each([
234
+ [422, "coupon_rejected", "coupon_rejected"],
235
+ [422, "checkout_rejected", "out_of_stock"],
236
+ [404, "resource_not_found", "unavailable"],
237
+ [400, "invalid_request", "invalid"],
238
+ [500, "boom", "unknown"],
239
+ ])("maps the API's %i %s to a typed error (%s)", async (status, code, error) => {
240
+ fetchMock.mockImplementationOnce(async () =>
241
+ json({ error: { code, message: `msg ${code}` } }, status),
242
+ );
243
+ expect(await createCheckout({ items: [{ productId: 1, variantId: 2, quantity: 1 }] })).toEqual({
244
+ error,
245
+ message: `msg ${code}`,
246
+ });
247
+ });
220
248
  });
@@ -1,4 +1,4 @@
1
- import { nuvemshopPost } from "../client";
1
+ import { NuvemshopApiError, nuvemshopPost } from "../client";
2
2
  import type { NuvemshopCheckout, NuvemshopLineItem } from "../utils/types";
3
3
 
4
4
  export interface CartItem {
@@ -12,25 +12,53 @@ export interface Props {
12
12
  coupon?: string;
13
13
  }
14
14
 
15
+ export type CheckoutErrorCode =
16
+ | "coupon_rejected"
17
+ | "out_of_stock"
18
+ | "unavailable"
19
+ | "invalid"
20
+ | "unknown";
21
+
22
+ export type CreateCheckoutResult =
23
+ | { checkoutUrl: string }
24
+ | { error: CheckoutErrorCode; message: string };
25
+
26
+ // API error code → what the buyer can act on.
27
+ const ERRORS: Record<string, CheckoutErrorCode> = {
28
+ coupon_rejected: "coupon_rejected",
29
+ checkout_rejected: "out_of_stock", // returned when a line exceeds the variant's stock
30
+ resource_not_found: "unavailable",
31
+ invalid_request: "invalid",
32
+ };
33
+
15
34
  /**
16
35
  * @title Nuvemshop - Create Checkout
17
36
  * @description Opens a Nuvemshop cart with the items (and coupon) and returns the hosted checkout URL.
37
+ * Failures come back as `{ error, message }` (rejected coupon, stock, removed variant) instead of throwing.
18
38
  */
19
39
  export default async function createCheckout(
20
40
  props: Props,
21
41
  req?: Request,
22
- ): Promise<{ checkoutUrl: string }> {
23
- if (!props.items?.length) throw new Error("Cannot checkout an empty cart");
42
+ ): Promise<CreateCheckoutResult> {
43
+ if (!props?.items?.length) return { error: "invalid", message: "Cannot checkout an empty cart" };
24
44
  const line_items: NuvemshopLineItem[] = props.items.map((i) => ({
25
45
  product_id: i.productId,
26
46
  variant_id: i.variantId,
27
47
  quantity: i.quantity,
28
48
  }));
29
49
  const coupon_code = props.coupon?.trim() || undefined;
30
- const res = await nuvemshopPost<NuvemshopCheckout>(
31
- "/checkouts",
32
- { line_items, coupon_code },
33
- { buyerIp: req?.headers.get("cf-connecting-ip") },
34
- );
35
- return { checkoutUrl: res.checkout_url };
50
+ try {
51
+ const res = await nuvemshopPost<NuvemshopCheckout>(
52
+ "/checkouts",
53
+ { line_items, coupon_code },
54
+ { buyerIp: req?.headers.get("cf-connecting-ip") },
55
+ );
56
+ return { checkoutUrl: res.checkout_url };
57
+ } catch (e) {
58
+ if (!(e instanceof NuvemshopApiError)) throw e;
59
+ return {
60
+ error: ERRORS[e.code] ?? "unknown",
61
+ message: e.message.replace(/^Nuvemshop \d+ [\w-]*: /, ""),
62
+ };
63
+ }
36
64
  }
package/src/client.ts CHANGED
@@ -141,12 +141,26 @@ function headers(extra?: Record<string, string>) {
141
141
  };
142
142
  }
143
143
 
144
+ /** A non-2xx Storefront API response, with the API's `error.code` (e.g. `coupon_rejected`). */
145
+ export class NuvemshopApiError extends Error {
146
+ constructor(
147
+ readonly status: number,
148
+ readonly code: string,
149
+ message: string,
150
+ ) {
151
+ super(`Nuvemshop ${status} ${code}: ${message}`);
152
+ this.name = "NuvemshopApiError";
153
+ }
154
+ }
155
+
144
156
  async function errorFrom(res: Response) {
145
157
  const body = (await res.json().catch(() => null)) as {
146
158
  error?: { code?: string; message?: string };
147
159
  } | null;
148
- return new Error(
149
- `Nuvemshop ${res.status} ${body?.error?.code ?? ""}: ${body?.error?.message ?? res.statusText}`,
160
+ return new NuvemshopApiError(
161
+ res.status,
162
+ body?.error?.code ?? "",
163
+ body?.error?.message ?? res.statusText,
150
164
  );
151
165
  }
152
166
 
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  addItem,
4
4
  CART_STORAGE_KEY,
5
+ CheckoutError,
5
6
  cartStore,
6
7
  checkout,
8
+ fetchCartDetails,
7
9
  removeItem,
8
10
  setQuantity,
9
11
  } from "../useCart";
@@ -69,6 +71,20 @@ describe("checkout", () => {
69
71
  fetchSpy.mockRestore();
70
72
  });
71
73
 
74
+ it("throws a typed CheckoutError (e.g. rejected coupon) instead of navigating", async () => {
75
+ const fetchSpy = vi
76
+ .spyOn(globalThis, "fetch")
77
+ .mockResolvedValue(
78
+ new Response(JSON.stringify({ error: "coupon_rejected", message: "nope" })),
79
+ );
80
+ const navigate = vi.fn();
81
+ const err = await checkout([shirt], "BAD", navigate).catch((e) => e);
82
+ expect(err).toBeInstanceOf(CheckoutError);
83
+ expect(err.code).toBe("coupon_rejected");
84
+ expect(navigate).not.toHaveBeenCalled();
85
+ fetchSpy.mockRestore();
86
+ });
87
+
72
88
  it("throws the server error instead of navigating", async () => {
73
89
  const fetchSpy = vi
74
90
  .spyOn(globalThis, "fetch")
@@ -79,3 +95,24 @@ describe("checkout", () => {
79
95
  fetchSpy.mockRestore();
80
96
  });
81
97
  });
98
+
99
+ describe("fetchCartDetails", () => {
100
+ it("invokes the cart loader with the items and returns its result", async () => {
101
+ const details = { lines: [], itemCount: 0, subtotal: 0 };
102
+ const fetchSpy = vi
103
+ .spyOn(globalThis, "fetch")
104
+ .mockResolvedValue(new Response(JSON.stringify(details)));
105
+ expect(await fetchCartDetails([shirt])).toEqual(details);
106
+ const [url, init] = fetchSpy.mock.calls[0];
107
+ expect(url).toBe("/deco/invoke/nuvemshop/loaders/cart");
108
+ expect(JSON.parse(String(init!.body))).toEqual({ items: [shirt] });
109
+ fetchSpy.mockRestore();
110
+ });
111
+
112
+ it("skips the request for an empty cart", async () => {
113
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
114
+ expect(await fetchCartDetails([])).toBeNull();
115
+ expect(fetchSpy).not.toHaveBeenCalled();
116
+ fetchSpy.mockRestore();
117
+ });
118
+ });
@@ -14,10 +14,22 @@
14
14
  * <button onClick={() => addItem({ productId, variantId, quantity: 1 })}>Comprar</button>
15
15
  * ```
16
16
  */
17
- import { useSyncExternalStore } from "react";
18
- import type { CartItem } from "../actions/createCheckout";
17
+ import { useEffect, useState, useSyncExternalStore } from "react";
18
+ import type { CartItem, CheckoutErrorCode, CreateCheckoutResult } from "../actions/createCheckout";
19
+ import type { Cart } from "../loaders/cart";
19
20
 
20
- export type { CartItem };
21
+ export type { Cart, CartItem, CheckoutErrorCode };
22
+
23
+ /** A checkout the buyer can fix: rejected coupon, stock changed, variant removed… */
24
+ export class CheckoutError extends Error {
25
+ constructor(
26
+ readonly code: CheckoutErrorCode,
27
+ message: string,
28
+ ) {
29
+ super(message);
30
+ this.name = "CheckoutError";
31
+ }
32
+ }
21
33
 
22
34
  export const CART_STORAGE_KEY = "nuvemshop:cart";
23
35
  /** The API sums repeated variants up to 99. */
@@ -96,8 +108,42 @@ export async function checkout(
96
108
  body: JSON.stringify({ items, coupon }),
97
109
  });
98
110
  if (!res.ok) throw new Error(`Checkout failed (${res.status}): ${await res.text()}`);
99
- const { checkoutUrl } = (await res.json()) as { checkoutUrl: string };
100
- navigate(checkoutUrl);
111
+ const result = (await res.json()) as CreateCheckoutResult;
112
+ if ("error" in result) throw new CheckoutError(result.error, result.message);
113
+ navigate(result.checkoutUrl);
114
+ }
115
+
116
+ /** Minicart data (names, images, prices, totals) for the given items, from the `cart` loader. */
117
+ export async function fetchCartDetails(items: CartItem[]): Promise<Cart | null> {
118
+ if (!items.length) return null;
119
+ const res = await fetch("/deco/invoke/nuvemshop/loaders/cart", {
120
+ method: "POST",
121
+ headers: { "content-type": "application/json" },
122
+ body: JSON.stringify({ items }),
123
+ });
124
+ if (!res.ok) throw new Error(`Cart failed (${res.status})`);
125
+ return (await res.json()) as Cart;
126
+ }
127
+
128
+ /** `useCart()` items resolved into minicart data; refetches when the items change. */
129
+ export function useCartDetails() {
130
+ const items = useSyncExternalStore(cartStore.subscribe, cartStore.get, () => EMPTY);
131
+ const [state, setState] = useState<{ cart: Cart | null; loading: boolean; error?: Error }>({
132
+ cart: null,
133
+ loading: false,
134
+ });
135
+ useEffect(() => {
136
+ let live = true;
137
+ setState((s) => ({ ...s, loading: true }));
138
+ fetchCartDetails(items).then(
139
+ (cart) => live && setState({ cart, loading: false }),
140
+ (error: Error) => live && setState((s) => ({ ...s, loading: false, error })),
141
+ );
142
+ return () => {
143
+ live = false;
144
+ };
145
+ }, [items]);
146
+ return state;
101
147
  }
102
148
 
103
149
  export function useCart() {
package/src/index.ts CHANGED
@@ -6,12 +6,14 @@ export {
6
6
  clearNuvemshopCache,
7
7
  configureNuvemshop,
8
8
  getNuvemshopConfig,
9
+ NuvemshopApiError,
9
10
  type NuvemshopConfig,
10
11
  nuvemshopGet,
11
12
  nuvemshopPost,
12
13
  PRODUCT_FIELDS,
13
14
  setNuvemshopFetch,
14
15
  } from "./client";
16
+ export { default as cart } from "./loaders/cart";
15
17
  export { default as categories } from "./loaders/categories";
16
18
  export { default as productDetailsPage } from "./loaders/productDetailsPage";
17
19
  export { default as productList } from "./loaders/productList";
@@ -0,0 +1,108 @@
1
+ import type { CartItem } from "../actions/createCheckout";
2
+ import { getNuvemshopConfig, nuvemshopGet } from "../client";
3
+ import { originOf, type PageProps } from "../utils/request";
4
+ import { isAvailable, productPath, variantPrices } from "../utils/transform";
5
+ import type { NuvemshopList, NuvemshopProduct } from "../utils/types";
6
+
7
+ export interface Props extends PageProps {
8
+ /** Items held by the client cart (`useCart`). */
9
+ items: CartItem[];
10
+ }
11
+
12
+ export interface CartLine extends CartItem {
13
+ name: string;
14
+ variantName: string;
15
+ image?: string;
16
+ url: string;
17
+ price: number;
18
+ listPrice: number;
19
+ subtotal: number;
20
+ /** Most the checkout will accept for this variant (stock, capped at 99). */
21
+ maxQuantity: number;
22
+ /** `quantity` was lowered to `maxQuantity`. */
23
+ adjusted?: boolean;
24
+ }
25
+
26
+ export interface Cart {
27
+ lines: CartLine[];
28
+ unavailable: (CartItem & { reason: "not_found" | "out_of_stock" })[];
29
+ itemCount: number;
30
+ subtotal: number;
31
+ listSubtotal: number;
32
+ savings: number;
33
+ currency: string;
34
+ }
35
+
36
+ const MAX_PER_VARIANT = 99;
37
+ const IDS_PER_REQUEST = 30; // API limit for `ids`
38
+ const cents = (n: number) => Math.round(n * 100) / 100;
39
+
40
+ /**
41
+ * @title Nuvemshop - Cart
42
+ * @description Minicart data for the client-held items: names, images, prices, totals and savings,
43
+ * fresh from the Storefront API. Quantities are clamped to stock so the checkout won't reject them.
44
+ * No coupon preview (the API has none) — the coupon is validated when the checkout is created.
45
+ */
46
+ export default async function cart(props: Props, req?: Request): Promise<Cart> {
47
+ const { currency = "BRL" } = getNuvemshopConfig();
48
+ const items = props?.items ?? [];
49
+ const ids = [...new Set(items.map((i) => i.productId))];
50
+ const products = new Map<number, NuvemshopProduct>();
51
+ for (let i = 0; i < ids.length; i += IDS_PER_REQUEST) {
52
+ const list = await nuvemshopGet<NuvemshopList<NuvemshopProduct>>("/products", {
53
+ ids: ids.slice(i, i + IDS_PER_REQUEST).join(","),
54
+ per_page: IDS_PER_REQUEST,
55
+ });
56
+ for (const p of list?.data ?? []) products.set(p.id, p);
57
+ }
58
+
59
+ const origin = originOf(props, req);
60
+ const lines: CartLine[] = [];
61
+ const unavailable: Cart["unavailable"] = [];
62
+ for (const item of items) {
63
+ const product = products.get(item.productId);
64
+ const variant = product?.variants?.find((v) => v.id === item.variantId);
65
+ if (!product || !variant || variant.visible === false) {
66
+ unavailable.push({ ...item, reason: "not_found" });
67
+ continue;
68
+ }
69
+ if (!isAvailable(variant)) {
70
+ unavailable.push({ ...item, reason: "out_of_stock" });
71
+ continue;
72
+ }
73
+ const maxQuantity = Math.min(
74
+ variant.stock_management && variant.stock != null ? variant.stock : MAX_PER_VARIANT,
75
+ MAX_PER_VARIANT,
76
+ );
77
+ const quantity = Math.min(Math.max(1, item.quantity), maxQuantity);
78
+ const { price, listPrice } = variantPrices(variant);
79
+ const image = product.images?.find((i) => i.id === variant.image_id) ?? product.images?.[0];
80
+ lines.push({
81
+ ...item,
82
+ quantity,
83
+ maxQuantity,
84
+ ...(quantity !== item.quantity && { adjusted: true }),
85
+ name: product.name,
86
+ variantName: variant.values.join(" / "),
87
+ image: image?.src,
88
+ url: `${origin}${productPath(product.handle)}?variant=${variant.id}`,
89
+ price,
90
+ listPrice,
91
+ subtotal: cents(price * quantity),
92
+ });
93
+ }
94
+
95
+ const subtotal = cents(lines.reduce((s, l) => s + l.subtotal, 0));
96
+ const listSubtotal = cents(lines.reduce((s, l) => s + l.listPrice * l.quantity, 0));
97
+ return {
98
+ lines,
99
+ unavailable,
100
+ itemCount: lines.reduce((n, l) => n + l.quantity, 0),
101
+ subtotal,
102
+ listSubtotal,
103
+ savings: cents(listSubtotal - subtotal),
104
+ currency,
105
+ };
106
+ }
107
+
108
+ export const cache = "no-store";
@@ -77,6 +77,11 @@ export default async function productListingPage(
77
77
  "@type": "ProductListingPage",
78
78
  ...listing,
79
79
  ...withPageType(listing.pageInfo, leaf.parent ? "SubCategory" : "Category"),
80
+ // Default sort first: a sort UI with no ?sort_by= falls back to the first option.
81
+ sortOptions: [
82
+ ...listing.sortOptions.filter((o) => o.value === defaultSort),
83
+ ...listing.sortOptions.filter((o) => o.value !== defaultSort),
84
+ ],
80
85
  breadcrumb: toBreadcrumbList(leaf, categories, origin),
81
86
  seo: {
82
87
  title: leaf.seo_title || leaf.name,
@@ -5,6 +5,7 @@ import * as actions_account_login from "./actions/account/login";
5
5
  import * as actions_account_logout from "./actions/account/logout";
6
6
  import * as actions_account_register from "./actions/account/register";
7
7
  import * as actions_createCheckout from "./actions/createCheckout";
8
+ import * as loaders_cart from "./loaders/cart";
8
9
  import * as loaders_categories from "./loaders/categories";
9
10
  import * as loaders_productDetailsPage from "./loaders/productDetailsPage";
10
11
  import * as loaders_productList from "./loaders/productList";
@@ -17,6 +18,7 @@ import * as loaders_user from "./loaders/user";
17
18
  const manifest = {
18
19
  name: "nuvemshop",
19
20
  loaders: {
21
+ "nuvemshop/loaders/cart": loaders_cart,
20
22
  "nuvemshop/loaders/categories": loaders_categories,
21
23
  "nuvemshop/loaders/productDetailsPage": loaders_productDetailsPage,
22
24
  "nuvemshop/loaders/productList": loaders_productList,