@decocms/apps-nuvemshop 7.76.0 → 7.77.0
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 +4 -4
- package/src/__tests__/cart.test.ts +118 -0
- package/src/__tests__/loaders.test.ts +22 -3
- package/src/actions/createCheckout.ts +37 -9
- package/src/client.ts +16 -2
- package/src/hooks/__tests__/useCart.test.ts +37 -0
- package/src/hooks/useCart.ts +51 -5
- package/src/index.ts +2 -0
- package/src/loaders/cart.ts +108 -0
- package/src/manifest.gen.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-nuvemshop",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.77.0",
|
|
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.
|
|
34
|
-
"@decocms/apps-commerce": "7.
|
|
35
|
-
"@decocms/apps-website": "7.
|
|
33
|
+
"@decocms/blocks": "7.77.0",
|
|
34
|
+
"@decocms/apps-commerce": "7.77.0",
|
|
35
|
+
"@decocms/apps-website": "7.77.0"
|
|
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
|
+
});
|
|
@@ -205,7 +205,7 @@ describe("createCheckout", () => {
|
|
|
205
205
|
items: [{ productId: 372190453, variantId: 1612648502, quantity: 2 }],
|
|
206
206
|
coupon: " TEST ",
|
|
207
207
|
});
|
|
208
|
-
expect(res
|
|
208
|
+
expect(res).toEqual({ checkoutUrl: checkoutFixture.checkout_url });
|
|
209
209
|
const body = JSON.parse(String(fetchMock.mock.calls.at(-1)![1]!.body));
|
|
210
210
|
expect(body).toEqual({
|
|
211
211
|
line_items: [{ product_id: 372190453, variant_id: 1612648502, quantity: 2 }],
|
|
@@ -213,8 +213,27 @@ describe("createCheckout", () => {
|
|
|
213
213
|
});
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("
|
|
217
|
-
await
|
|
216
|
+
it("returns a typed error for an empty cart without calling the API", async () => {
|
|
217
|
+
expect(await createCheckout({ items: [] })).toEqual({
|
|
218
|
+
error: "invalid",
|
|
219
|
+
message: "Cannot checkout an empty cart",
|
|
220
|
+
});
|
|
218
221
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
219
222
|
});
|
|
223
|
+
|
|
224
|
+
it.each([
|
|
225
|
+
[422, "coupon_rejected", "coupon_rejected"],
|
|
226
|
+
[422, "checkout_rejected", "out_of_stock"],
|
|
227
|
+
[404, "resource_not_found", "unavailable"],
|
|
228
|
+
[400, "invalid_request", "invalid"],
|
|
229
|
+
[500, "boom", "unknown"],
|
|
230
|
+
])("maps the API's %i %s to a typed error (%s)", async (status, code, error) => {
|
|
231
|
+
fetchMock.mockImplementationOnce(async () =>
|
|
232
|
+
json({ error: { code, message: `msg ${code}` } }, status),
|
|
233
|
+
);
|
|
234
|
+
expect(await createCheckout({ items: [{ productId: 1, variantId: 2, quantity: 1 }] })).toEqual({
|
|
235
|
+
error,
|
|
236
|
+
message: `msg ${code}`,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
220
239
|
});
|
|
@@ -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<
|
|
23
|
-
if (!props
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
149
|
-
|
|
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
|
+
});
|
package/src/hooks/useCart.ts
CHANGED
|
@@ -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
|
|
100
|
-
|
|
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";
|
package/src/manifest.gen.ts
CHANGED
|
@@ -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,
|