diff --git a/.agents/skills/apps-nuvemshop/references/cart-and-checkout.md b/.agents/skills/apps-nuvemshop/references/cart-and-checkout.md index f3a04ca5..8d2f27b7 100644 --- a/.agents/skills/apps-nuvemshop/references/cart-and-checkout.md +++ b/.agents/skills/apps-nuvemshop/references/cart-and-checkout.md @@ -1,5 +1,23 @@ # Cart and checkout +## The supported path (API) + +The cart lives on the client (`hooks/useCart`: items in `localStorage`), the +minicart reads `loaders/cart` (`useCartDetails`), and "finalizar" calls +`actions/createCheckout` → `POST /checkouts` → hosted `checkout_url`. + +- `cart` fetches the products by `ids` (≤30 per call, cached) and returns lines + with name, variant, image, price/list price, subtotal, `maxQuantity`, plus + totals and savings. It **clamps quantity to stock**: the API answers + `422 checkout_rejected` for a line above stock. Missing/hidden variants and + sold-out ones go to `unavailable` instead of the totals. +- `createCheckout` returns `{ checkoutUrl }` or `{ error, message }`: + `422 coupon_rejected` → `coupon_rejected`, `422 checkout_rejected` → + `out_of_stock`, `404 resource_not_found` → `unavailable`, `400` → `invalid`. + `useCart().checkout()` throws `CheckoutError(code)` so the UI can react. +- No coupon preview before the checkout (the API has none); the checkout's + totals equal the `cart` loader's subtotal (checked live). + ## What exists - **API**: `POST /checkouts {line_items, coupon_code?}` → `checkout_url` @@ -24,7 +42,8 @@ challenge, including a real browser clicking the theme's button, for 35+ min. No merchant setting changes it. A server-side proxy sends every buyer through the Worker's egress IPs, so this can block a whole store's add-to-cart. The API path (`/checkouts` with a Storefront token + buyer IP) doesn't have this. -Ask Nuvemshop before shipping a classic-cart proxy. +Ask Nuvemshop before shipping a classic-cart proxy — that's why the API path +above is the supported one. ## Checkout proxy (spike) diff --git a/packages/apps-nuvemshop/src/__tests__/cart.test.ts b/packages/apps-nuvemshop/src/__tests__/cart.test.ts new file mode 100644 index 00000000..9006c94a --- /dev/null +++ b/packages/apps-nuvemshop/src/__tests__/cart.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import browseFixture from "../__fixtures__/browse-category.json"; +import { clearNuvemshopCache, configureNuvemshop, setNuvemshopFetch } from "../client"; +import cart from "../loaders/cart"; + +// browse fixture: [0] camisa-xadrez 189.90 (stock 17,20,…), [1] camisa-flanela 169.90 promo / 199.90, [2] camiseta-gamer 89.90 +const [xadrez, flanela, gamer] = browseFixture.data; +const SITE = "https://www.loja.example"; +const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + const ids = (url.searchParams.get("ids") ?? "").split(",").map(Number); + const data = browseFixture.data.filter((p) => ids.includes(p.id)); + return new Response( + JSON.stringify({ + data, + pagination: { page: 1, per_page: 30, total: data.length, total_pages: 1 }, + }), + ); +}); +const item = (p: (typeof browseFixture.data)[number], v = 0, quantity = 1) => ({ + productId: p.id, + variantId: p.variants[v].id, + quantity, +}); + +beforeEach(() => { + fetchMock.mockClear(); + clearNuvemshopCache(); + configureNuvemshop({ storeId: "8336778" }); + setNuvemshopFetch(fetchMock as unknown as typeof fetch); +}); + +describe("cart loader", () => { + it("resolves each line from the Storefront API with prices, image and variant", async () => { + const c = await cart({ items: [item(xadrez, 0, 2), item(flanela, 5)], __pageUrl: `${SITE}/` }); + expect(c.lines).toHaveLength(2); + expect(c.lines[0]).toMatchObject({ + productId: xadrez.id, + variantId: xadrez.variants[0].id, + name: "Camisa Xadrez Lenhador Azul", + variantName: "P / Azul e Preto", + quantity: 2, + maxQuantity: 17, + price: 189.9, + listPrice: 189.9, + subtotal: 379.8, + url: `${SITE}/produtos/camisa-xadrez-lenhador-azul?variant=${xadrez.variants[0].id}`, + }); + expect(c.lines[1]).toMatchObject({ + variantName: "M / Cinza", + price: 169.9, + listPrice: 199.9, + subtotal: 169.9, + }); + expect(c.lines[1].image).toBe( + flanela.images.find((i) => i.id === flanela.variants[5].image_id)!.src, + ); + expect(c).toMatchObject({ + itemCount: 3, + subtotal: 549.7, + listSubtotal: 579.7, + savings: 30, + currency: "BRL", + unavailable: [], + }); + }); + + it("asks only for the products in the cart (ids, deduped) and nothing for an empty cart", async () => { + await cart({ items: [item(xadrez, 0), item(xadrez, 1), item(gamer)] }); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.searchParams.get("ids")).toBe(`${xadrez.id},${gamer.id}`); + expect(url.searchParams.get("per_page")).toBe("30"); + + fetchMock.mockClear(); + expect(await cart({ items: [] })).toMatchObject({ lines: [], itemCount: 0, subtotal: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("clamps quantities to stock (the checkout rejects more) and to 99", async () => { + const c = await cart({ items: [{ ...item(xadrez, 0), quantity: 50 }] }); + expect(c.lines[0]).toMatchObject({ quantity: 17, maxQuantity: 17, adjusted: true }); + }); + + it("moves missing and sold-out variants out of the lines", async () => { + const soldOut = { ...item(gamer, 0), variantId: gamer.variants[0].id }; + fetchMock.mockImplementationOnce(async () => + Response.json({ + data: [ + { ...gamer, variants: gamer.variants.map((v, i) => (i === 0 ? { ...v, stock: 0 } : v)) }, + ], + pagination: { page: 1, per_page: 30, total: 1, total_pages: 1 }, + }), + ); + const c = await cart({ + items: [ + soldOut, + { productId: gamer.id, variantId: 999, quantity: 1 }, + { productId: 1, variantId: 2, quantity: 1 }, + ], + }); + expect(c.lines).toEqual([]); + expect(c.unavailable).toEqual([ + { ...soldOut, reason: "out_of_stock" }, + { productId: gamer.id, variantId: 999, quantity: 1, reason: "not_found" }, + { productId: 1, variantId: 2, quantity: 1, reason: "not_found" }, + ]); + }); + + it("fetches in batches of 30 ids (the API limit)", async () => { + const items = Array.from({ length: 31 }, (_, i) => ({ + productId: i + 1, + variantId: 1, + quantity: 1, + })); + await cart({ items }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/apps-nuvemshop/src/__tests__/loaders.test.ts b/packages/apps-nuvemshop/src/__tests__/loaders.test.ts index 8a5d40d7..daeb9017 100644 --- a/packages/apps-nuvemshop/src/__tests__/loaders.test.ts +++ b/packages/apps-nuvemshop/src/__tests__/loaders.test.ts @@ -205,7 +205,7 @@ describe("createCheckout", () => { items: [{ productId: 372190453, variantId: 1612648502, quantity: 2 }], coupon: " TEST ", }); - expect(res.checkoutUrl).toBe(checkoutFixture.checkout_url); + expect(res).toEqual({ checkoutUrl: checkoutFixture.checkout_url }); const body = JSON.parse(String(fetchMock.mock.calls.at(-1)![1]!.body)); expect(body).toEqual({ line_items: [{ product_id: 372190453, variant_id: 1612648502, quantity: 2 }], @@ -213,8 +213,27 @@ describe("createCheckout", () => { }); }); - it("rejects an empty cart without calling the API", async () => { - await expect(createCheckout({ items: [] })).rejects.toThrow(/empty/); + it("returns a typed error for an empty cart without calling the API", async () => { + expect(await createCheckout({ items: [] })).toEqual({ + error: "invalid", + message: "Cannot checkout an empty cart", + }); expect(fetchMock).not.toHaveBeenCalled(); }); + + it.each([ + [422, "coupon_rejected", "coupon_rejected"], + [422, "checkout_rejected", "out_of_stock"], + [404, "resource_not_found", "unavailable"], + [400, "invalid_request", "invalid"], + [500, "boom", "unknown"], + ])("maps the API's %i %s to a typed error (%s)", async (status, code, error) => { + fetchMock.mockImplementationOnce(async () => + json({ error: { code, message: `msg ${code}` } }, status), + ); + expect(await createCheckout({ items: [{ productId: 1, variantId: 2, quantity: 1 }] })).toEqual({ + error, + message: `msg ${code}`, + }); + }); }); diff --git a/packages/apps-nuvemshop/src/actions/createCheckout.ts b/packages/apps-nuvemshop/src/actions/createCheckout.ts index 31cb29cb..fb4471aa 100644 --- a/packages/apps-nuvemshop/src/actions/createCheckout.ts +++ b/packages/apps-nuvemshop/src/actions/createCheckout.ts @@ -1,4 +1,4 @@ -import { nuvemshopPost } from "../client"; +import { NuvemshopApiError, nuvemshopPost } from "../client"; import type { NuvemshopCheckout, NuvemshopLineItem } from "../utils/types"; export interface CartItem { @@ -12,25 +12,53 @@ export interface Props { coupon?: string; } +export type CheckoutErrorCode = + | "coupon_rejected" + | "out_of_stock" + | "unavailable" + | "invalid" + | "unknown"; + +export type CreateCheckoutResult = + | { checkoutUrl: string } + | { error: CheckoutErrorCode; message: string }; + +// API error code → what the buyer can act on. +const ERRORS: Record = { + coupon_rejected: "coupon_rejected", + checkout_rejected: "out_of_stock", // returned when a line exceeds the variant's stock + resource_not_found: "unavailable", + invalid_request: "invalid", +}; + /** * @title Nuvemshop - Create Checkout * @description Opens a Nuvemshop cart with the items (and coupon) and returns the hosted checkout URL. + * Failures come back as `{ error, message }` (rejected coupon, stock, removed variant) instead of throwing. */ export default async function createCheckout( props: Props, req?: Request, -): Promise<{ checkoutUrl: string }> { - if (!props.items?.length) throw new Error("Cannot checkout an empty cart"); +): Promise { + if (!props?.items?.length) return { error: "invalid", message: "Cannot checkout an empty cart" }; const line_items: NuvemshopLineItem[] = props.items.map((i) => ({ product_id: i.productId, variant_id: i.variantId, quantity: i.quantity, })); const coupon_code = props.coupon?.trim() || undefined; - const res = await nuvemshopPost( - "/checkouts", - { line_items, coupon_code }, - { buyerIp: req?.headers.get("cf-connecting-ip") }, - ); - return { checkoutUrl: res.checkout_url }; + try { + const res = await nuvemshopPost( + "/checkouts", + { line_items, coupon_code }, + { buyerIp: req?.headers.get("cf-connecting-ip") }, + ); + return { checkoutUrl: res.checkout_url }; + } catch (e) { + if (!(e instanceof NuvemshopApiError)) throw e; + return { + error: ERRORS[e.code] ?? "unknown", + message: e.message.replace(/^Nuvemshop \d+ [\w-]*: /, ""), + }; + } } diff --git a/packages/apps-nuvemshop/src/client.ts b/packages/apps-nuvemshop/src/client.ts index 455f4732..191917cd 100644 --- a/packages/apps-nuvemshop/src/client.ts +++ b/packages/apps-nuvemshop/src/client.ts @@ -141,12 +141,26 @@ function headers(extra?: Record) { }; } +/** A non-2xx Storefront API response, with the API's `error.code` (e.g. `coupon_rejected`). */ +export class NuvemshopApiError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(`Nuvemshop ${status} ${code}: ${message}`); + this.name = "NuvemshopApiError"; + } +} + async function errorFrom(res: Response) { const body = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string }; } | null; - return new Error( - `Nuvemshop ${res.status} ${body?.error?.code ?? ""}: ${body?.error?.message ?? res.statusText}`, + return new NuvemshopApiError( + res.status, + body?.error?.code ?? "", + body?.error?.message ?? res.statusText, ); } diff --git a/packages/apps-nuvemshop/src/hooks/__tests__/useCart.test.ts b/packages/apps-nuvemshop/src/hooks/__tests__/useCart.test.ts index 014d741e..afeb338b 100644 --- a/packages/apps-nuvemshop/src/hooks/__tests__/useCart.test.ts +++ b/packages/apps-nuvemshop/src/hooks/__tests__/useCart.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { addItem, CART_STORAGE_KEY, + CheckoutError, cartStore, checkout, + fetchCartDetails, removeItem, setQuantity, } from "../useCart"; @@ -69,6 +71,20 @@ describe("checkout", () => { fetchSpy.mockRestore(); }); + it("throws a typed CheckoutError (e.g. rejected coupon) instead of navigating", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify({ error: "coupon_rejected", message: "nope" })), + ); + const navigate = vi.fn(); + const err = await checkout([shirt], "BAD", navigate).catch((e) => e); + expect(err).toBeInstanceOf(CheckoutError); + expect(err.code).toBe("coupon_rejected"); + expect(navigate).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + it("throws the server error instead of navigating", async () => { const fetchSpy = vi .spyOn(globalThis, "fetch") @@ -79,3 +95,24 @@ describe("checkout", () => { fetchSpy.mockRestore(); }); }); + +describe("fetchCartDetails", () => { + it("invokes the cart loader with the items and returns its result", async () => { + const details = { lines: [], itemCount: 0, subtotal: 0 }; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(JSON.stringify(details))); + expect(await fetchCartDetails([shirt])).toEqual(details); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("/deco/invoke/nuvemshop/loaders/cart"); + expect(JSON.parse(String(init!.body))).toEqual({ items: [shirt] }); + fetchSpy.mockRestore(); + }); + + it("skips the request for an empty cart", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + expect(await fetchCartDetails([])).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); diff --git a/packages/apps-nuvemshop/src/hooks/useCart.ts b/packages/apps-nuvemshop/src/hooks/useCart.ts index a2053fea..af6b90d1 100644 --- a/packages/apps-nuvemshop/src/hooks/useCart.ts +++ b/packages/apps-nuvemshop/src/hooks/useCart.ts @@ -14,10 +14,22 @@ * * ``` */ -import { useSyncExternalStore } from "react"; -import type { CartItem } from "../actions/createCheckout"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { CartItem, CheckoutErrorCode, CreateCheckoutResult } from "../actions/createCheckout"; +import type { Cart } from "../loaders/cart"; -export type { CartItem }; +export type { Cart, CartItem, CheckoutErrorCode }; + +/** A checkout the buyer can fix: rejected coupon, stock changed, variant removed… */ +export class CheckoutError extends Error { + constructor( + readonly code: CheckoutErrorCode, + message: string, + ) { + super(message); + this.name = "CheckoutError"; + } +} export const CART_STORAGE_KEY = "nuvemshop:cart"; /** The API sums repeated variants up to 99. */ @@ -96,8 +108,42 @@ export async function checkout( body: JSON.stringify({ items, coupon }), }); if (!res.ok) throw new Error(`Checkout failed (${res.status}): ${await res.text()}`); - const { checkoutUrl } = (await res.json()) as { checkoutUrl: string }; - navigate(checkoutUrl); + const result = (await res.json()) as CreateCheckoutResult; + if ("error" in result) throw new CheckoutError(result.error, result.message); + navigate(result.checkoutUrl); +} + +/** Minicart data (names, images, prices, totals) for the given items, from the `cart` loader. */ +export async function fetchCartDetails(items: CartItem[]): Promise { + if (!items.length) return null; + const res = await fetch("/deco/invoke/nuvemshop/loaders/cart", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ items }), + }); + if (!res.ok) throw new Error(`Cart failed (${res.status})`); + return (await res.json()) as Cart; +} + +/** `useCart()` items resolved into minicart data; refetches when the items change. */ +export function useCartDetails() { + const items = useSyncExternalStore(cartStore.subscribe, cartStore.get, () => EMPTY); + const [state, setState] = useState<{ cart: Cart | null; loading: boolean; error?: Error }>({ + cart: null, + loading: false, + }); + useEffect(() => { + let live = true; + setState((s) => ({ ...s, loading: true })); + fetchCartDetails(items).then( + (cart) => live && setState({ cart, loading: false }), + (error: Error) => live && setState((s) => ({ ...s, loading: false, error })), + ); + return () => { + live = false; + }; + }, [items]); + return state; } export function useCart() { diff --git a/packages/apps-nuvemshop/src/index.ts b/packages/apps-nuvemshop/src/index.ts index 9259b8b6..2763210a 100644 --- a/packages/apps-nuvemshop/src/index.ts +++ b/packages/apps-nuvemshop/src/index.ts @@ -6,12 +6,14 @@ export { clearNuvemshopCache, configureNuvemshop, getNuvemshopConfig, + NuvemshopApiError, type NuvemshopConfig, nuvemshopGet, nuvemshopPost, PRODUCT_FIELDS, setNuvemshopFetch, } from "./client"; +export { default as cart } from "./loaders/cart"; export { default as categories } from "./loaders/categories"; export { default as productDetailsPage } from "./loaders/productDetailsPage"; export { default as productList } from "./loaders/productList"; diff --git a/packages/apps-nuvemshop/src/loaders/cart.ts b/packages/apps-nuvemshop/src/loaders/cart.ts new file mode 100644 index 00000000..576c2a17 --- /dev/null +++ b/packages/apps-nuvemshop/src/loaders/cart.ts @@ -0,0 +1,108 @@ +import type { CartItem } from "../actions/createCheckout"; +import { getNuvemshopConfig, nuvemshopGet } from "../client"; +import { originOf, type PageProps } from "../utils/request"; +import { isAvailable, productPath, variantPrices } from "../utils/transform"; +import type { NuvemshopList, NuvemshopProduct } from "../utils/types"; + +export interface Props extends PageProps { + /** Items held by the client cart (`useCart`). */ + items: CartItem[]; +} + +export interface CartLine extends CartItem { + name: string; + variantName: string; + image?: string; + url: string; + price: number; + listPrice: number; + subtotal: number; + /** Most the checkout will accept for this variant (stock, capped at 99). */ + maxQuantity: number; + /** `quantity` was lowered to `maxQuantity`. */ + adjusted?: boolean; +} + +export interface Cart { + lines: CartLine[]; + unavailable: (CartItem & { reason: "not_found" | "out_of_stock" })[]; + itemCount: number; + subtotal: number; + listSubtotal: number; + savings: number; + currency: string; +} + +const MAX_PER_VARIANT = 99; +const IDS_PER_REQUEST = 30; // API limit for `ids` +const cents = (n: number) => Math.round(n * 100) / 100; + +/** + * @title Nuvemshop - Cart + * @description Minicart data for the client-held items: names, images, prices, totals and savings, + * fresh from the Storefront API. Quantities are clamped to stock so the checkout won't reject them. + * No coupon preview (the API has none) — the coupon is validated when the checkout is created. + */ +export default async function cart(props: Props, req?: Request): Promise { + const { currency = "BRL" } = getNuvemshopConfig(); + const items = props?.items ?? []; + const ids = [...new Set(items.map((i) => i.productId))]; + const products = new Map(); + for (let i = 0; i < ids.length; i += IDS_PER_REQUEST) { + const list = await nuvemshopGet>("/products", { + ids: ids.slice(i, i + IDS_PER_REQUEST).join(","), + per_page: IDS_PER_REQUEST, + }); + for (const p of list?.data ?? []) products.set(p.id, p); + } + + const origin = originOf(props, req); + const lines: CartLine[] = []; + const unavailable: Cart["unavailable"] = []; + for (const item of items) { + const product = products.get(item.productId); + const variant = product?.variants?.find((v) => v.id === item.variantId); + if (!product || !variant || variant.visible === false) { + unavailable.push({ ...item, reason: "not_found" }); + continue; + } + if (!isAvailable(variant)) { + unavailable.push({ ...item, reason: "out_of_stock" }); + continue; + } + const maxQuantity = Math.min( + variant.stock_management && variant.stock != null ? variant.stock : MAX_PER_VARIANT, + MAX_PER_VARIANT, + ); + const quantity = Math.min(Math.max(1, item.quantity), maxQuantity); + const { price, listPrice } = variantPrices(variant); + const image = product.images?.find((i) => i.id === variant.image_id) ?? product.images?.[0]; + lines.push({ + ...item, + quantity, + maxQuantity, + ...(quantity !== item.quantity && { adjusted: true }), + name: product.name, + variantName: variant.values.join(" / "), + image: image?.src, + url: `${origin}${productPath(product.handle)}?variant=${variant.id}`, + price, + listPrice, + subtotal: cents(price * quantity), + }); + } + + const subtotal = cents(lines.reduce((s, l) => s + l.subtotal, 0)); + const listSubtotal = cents(lines.reduce((s, l) => s + l.listPrice * l.quantity, 0)); + return { + lines, + unavailable, + itemCount: lines.reduce((n, l) => n + l.quantity, 0), + subtotal, + listSubtotal, + savings: cents(listSubtotal - subtotal), + currency, + }; +} + +export const cache = "no-store"; diff --git a/packages/apps-nuvemshop/src/manifest.gen.ts b/packages/apps-nuvemshop/src/manifest.gen.ts index fa65894c..0cb13b0b 100644 --- a/packages/apps-nuvemshop/src/manifest.gen.ts +++ b/packages/apps-nuvemshop/src/manifest.gen.ts @@ -5,6 +5,7 @@ import * as actions_account_login from "./actions/account/login"; import * as actions_account_logout from "./actions/account/logout"; import * as actions_account_register from "./actions/account/register"; import * as actions_createCheckout from "./actions/createCheckout"; +import * as loaders_cart from "./loaders/cart"; import * as loaders_categories from "./loaders/categories"; import * as loaders_productDetailsPage from "./loaders/productDetailsPage"; import * as loaders_productList from "./loaders/productList"; @@ -17,6 +18,7 @@ import * as loaders_user from "./loaders/user"; const manifest = { name: "nuvemshop", loaders: { + "nuvemshop/loaders/cart": loaders_cart, "nuvemshop/loaders/categories": loaders_categories, "nuvemshop/loaders/productDetailsPage": loaders_productDetailsPage, "nuvemshop/loaders/productList": loaders_productList,