@decocms/apps-nuvemshop 7.75.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.
@@ -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/admin.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Nuvemshop Admin API (`api.nuvemshop.com.br/v1/<store>`) — server-only.
3
+ * Authenticated with a custom-app token (`adminToken`); used for what the
4
+ * Storefront API lacks: customer registration (no reCAPTCHA) and account data.
5
+ */
6
+ import { getNuvemshopConfig, nuvemshopFetch } from "./client";
7
+
8
+ const ADMIN_BASE = "https://api.nuvemshop.com.br/v1";
9
+
10
+ export async function nuvemshopAdmin(path: string, init: { method?: string; body?: unknown } = {}) {
11
+ const { storeId, adminToken } = getNuvemshopConfig();
12
+ if (!adminToken) throw new Error("Nuvemshop adminToken is not configured (custom app token)");
13
+ return nuvemshopFetch(`${ADMIN_BASE}/${storeId}${path}`, {
14
+ method: init.method ?? "GET",
15
+ headers: {
16
+ authentication: `bearer ${adminToken}`,
17
+ // Nuvemshop requires an identifying User-Agent on Admin API calls.
18
+ "user-agent": "deco.cx storefront (https://deco.cx)",
19
+ ...(init.body !== undefined && { "content-type": "application/json" }),
20
+ },
21
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
22
+ });
23
+ }
package/src/client.ts CHANGED
@@ -32,6 +32,21 @@ export interface NuvemshopConfig {
32
32
  * category's configured sort and returns oldest-first. Default "created-descending".
33
33
  */
34
34
  defaultSort?: string;
35
+ /**
36
+ * The store's own domain (e.g. https://demodeco.lojavirtualnuvem.com.br).
37
+ * Needed for the session endpoints the API doesn't cover (login/logout/account).
38
+ */
39
+ storeUrl?: string;
40
+ /**
41
+ * Admin API token from a custom app (Configurações → Aplicativos sob medida).
42
+ * Server-only: used for customer registration and account data. It is NOT a
43
+ * Storefront token (the Storefront API rejects it).
44
+ */
45
+ adminToken?: string;
46
+ /** Cloudflare Turnstile secret, verified before registering a customer. */
47
+ turnstileSecret?: string;
48
+ /** Skip bot verification on registration (demos only — the Admin API has no captcha). */
49
+ allowUnverifiedRegistration?: boolean;
35
50
  }
36
51
 
37
52
  /**
@@ -100,6 +115,9 @@ export function clearNuvemshopCache() {
100
115
 
101
116
  const transport = () => (_fetch ??= createNuvemshopFetch() as FetchFn);
102
117
 
118
+ /** The instrumented transport shared by every Nuvemshop egress (Storefront, store, Admin). */
119
+ export const nuvemshopFetch: FetchFn = (input, init) => transport()(input, init);
120
+
103
121
  function storeUrl(path: string, params: Record<string, string | number | undefined> = {}) {
104
122
  const {
105
123
  storeId,
@@ -123,12 +141,26 @@ function headers(extra?: Record<string, string>) {
123
141
  };
124
142
  }
125
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
+
126
156
  async function errorFrom(res: Response) {
127
157
  const body = (await res.json().catch(() => null)) as {
128
158
  error?: { code?: string; message?: string };
129
159
  } | null;
130
- return new Error(
131
- `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,
132
164
  );
133
165
  }
134
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
@@ -1,14 +1,19 @@
1
+ export { default as login } from "./actions/account/login";
2
+ export { default as logout } from "./actions/account/logout";
3
+ export { default as register } from "./actions/account/register";
1
4
  export { default as createCheckout } from "./actions/createCheckout";
2
5
  export {
3
6
  clearNuvemshopCache,
4
7
  configureNuvemshop,
5
8
  getNuvemshopConfig,
9
+ NuvemshopApiError,
6
10
  type NuvemshopConfig,
7
11
  nuvemshopGet,
8
12
  nuvemshopPost,
9
13
  PRODUCT_FIELDS,
10
14
  setNuvemshopFetch,
11
15
  } from "./client";
16
+ export { default as cart } from "./loaders/cart";
12
17
  export { default as categories } from "./loaders/categories";
13
18
  export { default as productDetailsPage } from "./loaders/productDetailsPage";
14
19
  export { default as productList } from "./loaders/productList";
@@ -16,8 +21,10 @@ export { default as productListingPage } from "./loaders/productListingPage";
16
21
  export { default as relatedProducts } from "./loaders/relatedProducts";
17
22
  export { default as shippingOptions } from "./loaders/shippingOptions";
18
23
  export { default as suggestions } from "./loaders/suggestions";
24
+ export { default as user } from "./loaders/user";
19
25
  export { configure } from "./mod";
20
26
  export { NUVEMSHOP_REGISTRY_ENTRY } from "./registry";
21
27
  export { createNuvemshopFetch } from "./utils/instrumentedFetch";
22
28
  export { nuvemshopOperationRouter } from "./utils/operationRouter";
29
+ export { nuvemshopSitemap } from "./utils/sitemap";
23
30
  export type * from "./utils/types";
@@ -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";
@@ -0,0 +1,30 @@
1
+ import { nuvemshopAdmin } from "../admin";
2
+ import { getNuvemshopConfig } from "../client";
3
+ import { storeCookies, storeFetch } from "../store";
4
+
5
+ export interface NuvemshopUser {
6
+ id: number;
7
+ name?: string;
8
+ email?: string;
9
+ phone?: string | null;
10
+ }
11
+
12
+ /**
13
+ * @title Nuvemshop - Logged-in customer
14
+ * @description The buyer's account from the store session (null when logged out).
15
+ * Reads the customer id from the store's account page and the profile from the Admin API.
16
+ */
17
+ export default async function user(_props: unknown): Promise<NuvemshopUser | null> {
18
+ if (!storeCookies()) return null;
19
+ const page = await storeFetch("/account/");
20
+ if (page.status !== 200) return null;
21
+ const id = Number((await page.text()).match(/LS\.customer\s*=\s*(\d+)/)?.[1]);
22
+ if (!id) return null;
23
+ if (!getNuvemshopConfig().adminToken) return { id };
24
+ const res = await nuvemshopAdmin(`/customers/${id}`);
25
+ if (!res.ok) return { id };
26
+ const c = (await res.json()) as NuvemshopUser;
27
+ return { id: c.id, name: c.name, email: c.email, phone: c.phone };
28
+ }
29
+
30
+ export const cache = "no-store";
@@ -1,7 +1,11 @@
1
1
  // Hand-maintained (the generate-manifests script referenced by other apps doesn't exist).
2
2
  // Keys are the `__resolveType` / `/deco/invoke/<key>` names.
3
3
 
4
+ import * as actions_account_login from "./actions/account/login";
5
+ import * as actions_account_logout from "./actions/account/logout";
6
+ import * as actions_account_register from "./actions/account/register";
4
7
  import * as actions_createCheckout from "./actions/createCheckout";
8
+ import * as loaders_cart from "./loaders/cart";
5
9
  import * as loaders_categories from "./loaders/categories";
6
10
  import * as loaders_productDetailsPage from "./loaders/productDetailsPage";
7
11
  import * as loaders_productList from "./loaders/productList";
@@ -9,10 +13,12 @@ import * as loaders_productListingPage from "./loaders/productListingPage";
9
13
  import * as loaders_relatedProducts from "./loaders/relatedProducts";
10
14
  import * as loaders_shippingOptions from "./loaders/shippingOptions";
11
15
  import * as loaders_suggestions from "./loaders/suggestions";
16
+ import * as loaders_user from "./loaders/user";
12
17
 
13
18
  const manifest = {
14
19
  name: "nuvemshop",
15
20
  loaders: {
21
+ "nuvemshop/loaders/cart": loaders_cart,
16
22
  "nuvemshop/loaders/categories": loaders_categories,
17
23
  "nuvemshop/loaders/productDetailsPage": loaders_productDetailsPage,
18
24
  "nuvemshop/loaders/productList": loaders_productList,
@@ -20,8 +26,12 @@ const manifest = {
20
26
  "nuvemshop/loaders/relatedProducts": loaders_relatedProducts,
21
27
  "nuvemshop/loaders/shippingOptions": loaders_shippingOptions,
22
28
  "nuvemshop/loaders/suggestions": loaders_suggestions,
29
+ "nuvemshop/loaders/user": loaders_user,
23
30
  },
24
31
  actions: {
32
+ "nuvemshop/actions/account/login": actions_account_login,
33
+ "nuvemshop/actions/account/logout": actions_account_logout,
34
+ "nuvemshop/actions/account/register": actions_account_register,
25
35
  "nuvemshop/actions/createCheckout": actions_createCheckout,
26
36
  },
27
37
  sections: {},
package/src/mod.ts CHANGED
@@ -47,6 +47,27 @@ export interface Props {
47
47
  * @default v2026-11
48
48
  */
49
49
  apiVersion?: string;
50
+ /**
51
+ * @title Store URL
52
+ * @description The store's own domain (e.g. https://minhaloja.lojavirtualnuvem.com.br). Required for login/logout/account.
53
+ */
54
+ storeUrl?: string;
55
+ /**
56
+ * @title Admin API token
57
+ * @description Custom app token (Configurações → Aplicativos sob medida). Server-only; enables registration and account data.
58
+ */
59
+ adminToken?: Secret;
60
+ /**
61
+ * @title Turnstile secret
62
+ * @description Cloudflare Turnstile secret verified before registering customers (the Admin API has no captcha).
63
+ */
64
+ turnstileSecret?: Secret;
65
+ /**
66
+ * @title Allow registration without bot verification
67
+ * @description Demos only.
68
+ * @default false
69
+ */
70
+ allowUnverifiedRegistration?: boolean;
50
71
  }
51
72
 
52
73
  export interface NuvemshopState {
@@ -64,9 +85,19 @@ export async function configure(
64
85
  (await resolveSecret(block.token, "NUVEMSHOP_STOREFRONT_TOKEN")) ??
65
86
  (typeof block.token === "string" ? block.token : undefined);
66
87
 
88
+ const secret = async (key: string, env: string) =>
89
+ (await resolveSecret(block[key], env)) ??
90
+ (typeof block[key] === "string" ? (block[key] as string) : undefined);
91
+ const adminToken = await secret("adminToken", "NUVEMSHOP_ADMIN_TOKEN");
92
+ const turnstileSecret = await secret("turnstileSecret", "NUVEMSHOP_TURNSTILE_SECRET");
93
+
67
94
  const config: NuvemshopConfig = {
68
95
  storeId,
69
96
  token: token || undefined,
97
+ storeUrl: block.storeUrl as string | undefined,
98
+ adminToken: adminToken || undefined,
99
+ turnstileSecret: turnstileSecret || undefined,
100
+ allowUnverifiedRegistration: block.allowUnverifiedRegistration === true,
70
101
  apiVersion: block.apiVersion as string | undefined,
71
102
  currency: block.currency as string | undefined,
72
103
  defaultSort: block.defaultSort as string | undefined,
package/src/store.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The store's own (theme) session endpoints — the parts the Storefront API
3
+ * doesn't cover: login, logout and the logged-in account page.
4
+ *
5
+ * The session lives in the store's cookies (`store_session_payload_<id>`,
6
+ * `store_login_session`, …). We forward only those (`store_*`) upstream —
7
+ * never the site's own cookies — and re-emit the store's `Set-Cookie` on our
8
+ * domain through `RequestContext.responseHeaders`, so the browser keeps one
9
+ * session that follows it across our pages.
10
+ */
11
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
12
+ import { getNuvemshopConfig, nuvemshopFetch } from "./client";
13
+
14
+ const STORE_COOKIE = /^store_/;
15
+
16
+ export function storeOrigin(): string {
17
+ const { storeUrl } = getNuvemshopConfig();
18
+ if (!storeUrl) throw new Error("Nuvemshop storeUrl is not configured (the store's own domain)");
19
+ return new URL(storeUrl).origin;
20
+ }
21
+
22
+ /** `store_*` cookies of the current request, as a Cookie header ("" when none). */
23
+ export function storeCookies(): string {
24
+ const raw = RequestContext.current?.request.headers.get("cookie") ?? "";
25
+ return raw
26
+ .split(/;\s*/)
27
+ .filter((c) => STORE_COOKIE.test(c))
28
+ .join("; ");
29
+ }
30
+
31
+ function rewriteDomain(cookie: string, host: string | null) {
32
+ return host
33
+ ? cookie.replace(/(;\s*)domain=[^;]*/i, `$1Domain=${host}`)
34
+ : cookie.replace(/;\s*domain=[^;]*/gi, "");
35
+ }
36
+
37
+ /** Merge upstream Set-Cookie values into a Cookie header (for follow-up calls in the same action). */
38
+ function mergeCookies(header: string, setCookies: string[]) {
39
+ const jar = new Map(header ? header.split(/;\s*/).map((c) => [c.split("=")[0], c] as const) : []);
40
+ for (const s of setCookies) {
41
+ const kv = s.split(";")[0];
42
+ const name = kv.split("=")[0];
43
+ if (STORE_COOKIE.test(name)) jar.set(name, kv);
44
+ }
45
+ return [...jar.values()].join("; ");
46
+ }
47
+
48
+ export interface StoreResponse {
49
+ status: number;
50
+ location: string | null;
51
+ text: () => Promise<string>;
52
+ }
53
+
54
+ /**
55
+ * Calls the store with the buyer's store session and forwards any new
56
+ * session cookies to the browser. `cookie` overrides the request's cookies
57
+ * (used to chain calls inside one action). Redirects are not followed.
58
+ */
59
+ export async function storeFetch(
60
+ path: string,
61
+ init: { method?: "GET" | "POST"; form?: Record<string, string>; cookie?: string } = {},
62
+ ): Promise<StoreResponse & { cookie: string }> {
63
+ const origin = storeOrigin();
64
+ const cookie = init.cookie ?? storeCookies();
65
+ const headers: Record<string, string> = { origin, referer: `${origin}${path}` };
66
+ if (init.form) headers["content-type"] = "application/x-www-form-urlencoded";
67
+ if (cookie) headers.cookie = cookie;
68
+ const res = await nuvemshopFetch(`${origin}${path}`, {
69
+ method: init.method ?? "GET",
70
+ headers,
71
+ body: init.form ? new URLSearchParams(init.form).toString() : undefined,
72
+ redirect: "manual",
73
+ });
74
+ const setCookies = res.headers.getSetCookie();
75
+ const ctx = RequestContext.current;
76
+ if (ctx) {
77
+ const host = new URL(ctx.request.url).hostname;
78
+ for (const c of setCookies) {
79
+ if (STORE_COOKIE.test(c)) ctx.responseHeaders.append("set-cookie", rewriteDomain(c, host));
80
+ }
81
+ }
82
+ return {
83
+ status: res.status,
84
+ location: res.headers.get("location"),
85
+ text: () => res.text(),
86
+ cookie: mergeCookies(cookie, setCookies),
87
+ };
88
+ }