@decocms/apps-nuvemshop 7.74.1 → 7.76.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 -3
- package/src/__tests__/account.test.ts +250 -0
- package/src/__tests__/mod.test.ts +58 -5
- package/src/actions/account/login.ts +30 -0
- package/src/actions/account/logout.ts +10 -0
- package/src/actions/account/register.ts +83 -0
- package/src/admin.ts +23 -0
- package/src/client.ts +18 -0
- package/src/index.ts +5 -0
- package/src/loaders/user.ts +30 -0
- package/src/manifest.gen.ts +8 -0
- package/src/mod.ts +73 -0
- package/src/store.ts +88 -0
- package/src/utils/__tests__/sitemap.test.ts +57 -0
- package/src/utils/listing.ts +11 -1
- package/src/utils/operationRouter.ts +7 -1
- package/src/utils/sitemap.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps-nuvemshop",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.76.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce app: Nuvemshop (Tiendanube) Storefront API integration",
|
|
6
6
|
"repository": {
|
|
@@ -30,8 +30,9 @@
|
|
|
30
30
|
"lint:unused": "knip"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@decocms/blocks": "7.
|
|
34
|
-
"@decocms/apps-commerce": "7.
|
|
33
|
+
"@decocms/blocks": "7.76.0",
|
|
34
|
+
"@decocms/apps-commerce": "7.76.0",
|
|
35
|
+
"@decocms/apps-website": "7.76.0"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"react": "^19.0.0"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import login from "../actions/account/login";
|
|
4
|
+
import logout from "../actions/account/logout";
|
|
5
|
+
import register from "../actions/account/register";
|
|
6
|
+
import { configureNuvemshop, setNuvemshopFetch } from "../client";
|
|
7
|
+
import user from "../loaders/user";
|
|
8
|
+
|
|
9
|
+
const STORE = "https://demodeco.lojavirtualnuvem.com.br";
|
|
10
|
+
const SITE = "https://www.loja.example";
|
|
11
|
+
const ADMIN = "https://api.nuvemshop.com.br/v1/8336778";
|
|
12
|
+
|
|
13
|
+
type Route = (url: URL, init: RequestInit) => Response | Promise<Response>;
|
|
14
|
+
let routes: Record<string, Route> = {};
|
|
15
|
+
const fetchMock = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => {
|
|
16
|
+
const url = new URL(String(input));
|
|
17
|
+
const key = `${init.method ?? "GET"} ${url.origin}${url.pathname}`;
|
|
18
|
+
const route = routes[key];
|
|
19
|
+
if (!route) throw new Error(`unexpected ${key}`);
|
|
20
|
+
return route(url, init);
|
|
21
|
+
});
|
|
22
|
+
const calls = (key: string) =>
|
|
23
|
+
fetchMock.mock.calls.filter(
|
|
24
|
+
([u, i]) =>
|
|
25
|
+
`${i?.method ?? "GET"} ${new URL(String(u)).origin}${new URL(String(u)).pathname}` === key,
|
|
26
|
+
);
|
|
27
|
+
const html = (body: string, init: ResponseInit = {}) =>
|
|
28
|
+
new Response(body, { ...init, headers: { "content-type": "text/html", ...init.headers } });
|
|
29
|
+
const redirect = (location: string, cookies: string[] = []) => {
|
|
30
|
+
const headers = new Headers({ location });
|
|
31
|
+
for (const c of cookies) headers.append("set-cookie", c);
|
|
32
|
+
return new Response(null, { status: 302, headers });
|
|
33
|
+
};
|
|
34
|
+
const SESSION_SET = `store_session_payload_8336778=NEW; expires=Thu, 05-Nov-2026 23:56:42 GMT; path=/; domain=demodeco.lojavirtualnuvem.com.br; secure; HttpOnly; SameSite=Lax`;
|
|
35
|
+
const inRequest = <T>(fn: () => Promise<T>, cookie = "") =>
|
|
36
|
+
RequestContext.run(new Request(`${SITE}/deco/invoke/x`, { headers: { cookie } }), async () => {
|
|
37
|
+
const result = await fn();
|
|
38
|
+
return { result, setCookies: RequestContext.current!.responseHeaders.getSetCookie() };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
fetchMock.mockClear();
|
|
43
|
+
routes = {};
|
|
44
|
+
configureNuvemshop({ storeId: "8336778", storeUrl: STORE, adminToken: "admintoken" });
|
|
45
|
+
setNuvemshopFetch(fetchMock as unknown as typeof fetch);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("login", () => {
|
|
49
|
+
it("posts the classic form and forwards the store session cookie onto our domain", async () => {
|
|
50
|
+
const CF =
|
|
51
|
+
"__cf_bm=abc; HttpOnly; SameSite=None; Secure; Path=/; Domain=lojavirtualnuvem.com.br";
|
|
52
|
+
routes[`POST ${STORE}/account/login/`] = () => redirect(`${STORE}/account/`, [SESSION_SET, CF]);
|
|
53
|
+
const { result, setCookies } = await inRequest(
|
|
54
|
+
() => login({ email: "a@b.com", password: "pw" }),
|
|
55
|
+
"store_session_payload_8336778=OLD; _ga=GA1.1; other=x",
|
|
56
|
+
);
|
|
57
|
+
expect(result).toEqual({ ok: true });
|
|
58
|
+
|
|
59
|
+
const [, init] = calls(`POST ${STORE}/account/login/`)[0];
|
|
60
|
+
expect(String(init!.body)).toBe("email=a%40b.com&password=pw");
|
|
61
|
+
const headers = new Headers(init!.headers);
|
|
62
|
+
// only the store's own cookies go upstream — never the site's (_ga, other)
|
|
63
|
+
expect(headers.get("cookie")).toBe("store_session_payload_8336778=OLD");
|
|
64
|
+
expect(headers.get("origin")).toBe(STORE);
|
|
65
|
+
expect(init!.redirect).toBe("manual");
|
|
66
|
+
|
|
67
|
+
// only the store session is re-emitted on our domain (not the store's Cloudflare cookies)
|
|
68
|
+
expect(setCookies).toEqual([
|
|
69
|
+
SESSION_SET.replace("domain=demodeco.lojavirtualnuvem.com.br", "Domain=www.loja.example"),
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
[
|
|
75
|
+
'<div class="alert alert-danger js-login-general-error"> Esses dados estão incorretos.',
|
|
76
|
+
"invalid_credentials",
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
'<div class="js-account-validation-pending alert alert-danger">Valide seu e-mail',
|
|
80
|
+
"email_not_validated",
|
|
81
|
+
],
|
|
82
|
+
["<form id=login-form></form>", "unknown"],
|
|
83
|
+
])("maps the login page marker to a reason (%#)", async (marker, error) => {
|
|
84
|
+
routes[`POST ${STORE}/account/login/`] = () =>
|
|
85
|
+
redirect(`${STORE}/account/login/`, [SESSION_SET]);
|
|
86
|
+
routes[`GET ${STORE}/account/login/`] = () => html(`<html>${marker}</html>`);
|
|
87
|
+
const { result } = await inRequest(() => login({ email: "a@b.com", password: "bad" }));
|
|
88
|
+
expect(result).toEqual({ ok: false, error });
|
|
89
|
+
// the follow-up page read reuses the session the POST just set
|
|
90
|
+
expect(new Headers(calls(`GET ${STORE}/account/login/`)[0][1]!.headers).get("cookie")).toBe(
|
|
91
|
+
"store_session_payload_8336778=NEW",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects missing credentials without calling the store", async () => {
|
|
96
|
+
const { result } = await inRequest(() => login({ email: "", password: "" }));
|
|
97
|
+
expect(result).toEqual({ ok: false, error: "invalid_credentials" });
|
|
98
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("requires storeUrl", async () => {
|
|
102
|
+
configureNuvemshop({ storeId: "8336778" });
|
|
103
|
+
await expect(inRequest(() => login({ email: "a@b.com", password: "pw" }))).rejects.toThrow(
|
|
104
|
+
/storeUrl/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("logout", () => {
|
|
110
|
+
it("hits /account/logout/ with the session and forwards the expiring cookie", async () => {
|
|
111
|
+
const expired =
|
|
112
|
+
"store_session_payload_8336778=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT; path=/; domain=demodeco.lojavirtualnuvem.com.br";
|
|
113
|
+
routes[`GET ${STORE}/account/logout/`] = () => redirect(`${STORE}/`, [expired]);
|
|
114
|
+
const { result, setCookies } = await inRequest(
|
|
115
|
+
() => logout({}),
|
|
116
|
+
"store_session_payload_8336778=S",
|
|
117
|
+
);
|
|
118
|
+
expect(result).toEqual({ ok: true });
|
|
119
|
+
expect(setCookies[0]).toContain("Domain=www.loja.example");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("register", () => {
|
|
124
|
+
const ok = () =>
|
|
125
|
+
new Response(JSON.stringify({ id: 350524152, email: "a@b.com", active: true }), {
|
|
126
|
+
status: 201,
|
|
127
|
+
});
|
|
128
|
+
const props = { name: "Ana", email: "a@b.com", password: "s3nha-forte", captchaToken: "tok" };
|
|
129
|
+
|
|
130
|
+
it("creates the customer through the Admin API (no reCAPTCHA) after verifying Turnstile", async () => {
|
|
131
|
+
configureNuvemshop({
|
|
132
|
+
storeId: "8336778",
|
|
133
|
+
storeUrl: STORE,
|
|
134
|
+
adminToken: "admintoken",
|
|
135
|
+
turnstileSecret: "ts",
|
|
136
|
+
});
|
|
137
|
+
routes["POST https://challenges.cloudflare.com/turnstile/v0/siteverify"] = () =>
|
|
138
|
+
Response.json({ success: true });
|
|
139
|
+
routes[`POST ${ADMIN}/customers`] = ok;
|
|
140
|
+
const { result } = await inRequest(() => register(props));
|
|
141
|
+
expect(result).toEqual({ ok: true, customerId: 350524152, emailValidationRequired: true });
|
|
142
|
+
|
|
143
|
+
const [, init] = calls(`POST ${ADMIN}/customers`)[0];
|
|
144
|
+
expect(new Headers(init!.headers).get("authentication")).toBe("bearer admintoken");
|
|
145
|
+
expect(new Headers(init!.headers).get("user-agent")).toMatch(/deco/);
|
|
146
|
+
expect(JSON.parse(String(init!.body))).toEqual({
|
|
147
|
+
name: "Ana",
|
|
148
|
+
email: "a@b.com",
|
|
149
|
+
password: "s3nha-forte",
|
|
150
|
+
send_email_invite: false,
|
|
151
|
+
});
|
|
152
|
+
const verify = new URLSearchParams(
|
|
153
|
+
String(calls("POST https://challenges.cloudflare.com/turnstile/v0/siteverify")[0][1]!.body),
|
|
154
|
+
);
|
|
155
|
+
expect(Object.fromEntries(verify)).toMatchObject({ secret: "ts", response: "tok" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("refuses when bot protection isn't configured (the Admin API has no captcha)", async () => {
|
|
159
|
+
const { result } = await inRequest(() => register(props));
|
|
160
|
+
expect(result).toEqual({ ok: false, error: "captcha_not_configured" });
|
|
161
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("allows an explicit opt-out for demos", async () => {
|
|
165
|
+
configureNuvemshop({
|
|
166
|
+
storeId: "8336778",
|
|
167
|
+
storeUrl: STORE,
|
|
168
|
+
adminToken: "admintoken",
|
|
169
|
+
allowUnverifiedRegistration: true,
|
|
170
|
+
});
|
|
171
|
+
routes[`POST ${ADMIN}/customers`] = ok;
|
|
172
|
+
expect(
|
|
173
|
+
(await inRequest(() => register({ ...props, captchaToken: undefined }))).result,
|
|
174
|
+
).toMatchObject({ ok: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("maps a failed captcha, a taken email and weak input", async () => {
|
|
178
|
+
configureNuvemshop({
|
|
179
|
+
storeId: "8336778",
|
|
180
|
+
storeUrl: STORE,
|
|
181
|
+
adminToken: "admintoken",
|
|
182
|
+
turnstileSecret: "ts",
|
|
183
|
+
});
|
|
184
|
+
routes["POST https://challenges.cloudflare.com/turnstile/v0/siteverify"] = () =>
|
|
185
|
+
Response.json({ success: false });
|
|
186
|
+
expect((await inRequest(() => register(props))).result).toEqual({
|
|
187
|
+
ok: false,
|
|
188
|
+
error: "captcha_failed",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
routes["POST https://challenges.cloudflare.com/turnstile/v0/siteverify"] = () =>
|
|
192
|
+
Response.json({ success: true });
|
|
193
|
+
routes[`POST ${ADMIN}/customers`] = () =>
|
|
194
|
+
Response.json({ email: ["The email must be unique."] }, { status: 422 });
|
|
195
|
+
expect((await inRequest(() => register(props))).result).toEqual({
|
|
196
|
+
ok: false,
|
|
197
|
+
error: "email_taken",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect((await inRequest(() => register({ ...props, email: "nope" }))).result).toEqual({
|
|
201
|
+
ok: false,
|
|
202
|
+
error: "invalid_input",
|
|
203
|
+
});
|
|
204
|
+
expect((await inRequest(() => register({ ...props, password: "123" }))).result).toEqual({
|
|
205
|
+
ok: false,
|
|
206
|
+
error: "invalid_input",
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("requires the admin token", async () => {
|
|
211
|
+
configureNuvemshop({ storeId: "8336778", storeUrl: STORE, allowUnverifiedRegistration: true });
|
|
212
|
+
await expect(inRequest(() => register(props))).rejects.toThrow(/adminToken/);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("user", () => {
|
|
217
|
+
it("returns null without a store session and never calls upstream", async () => {
|
|
218
|
+
const { result } = await inRequest(() => user({}), "_ga=1");
|
|
219
|
+
expect(result).toBeNull();
|
|
220
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null when the store redirects to login", async () => {
|
|
224
|
+
routes[`GET ${STORE}/account/`] = () => redirect(`${STORE}/account/login/`);
|
|
225
|
+
expect((await inRequest(() => user({}), "store_session_payload_8336778=S")).result).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("reads the logged-in customer id from the account page and its data from the Admin API", async () => {
|
|
229
|
+
routes[`GET ${STORE}/account/`] = () => html("<script>LS.customer = 350524152;</script>");
|
|
230
|
+
routes[`GET ${ADMIN}/customers/350524152`] = () =>
|
|
231
|
+
Response.json({
|
|
232
|
+
id: 350524152,
|
|
233
|
+
name: "Ana",
|
|
234
|
+
email: "a@b.com",
|
|
235
|
+
phone: "11999990000",
|
|
236
|
+
total_spent: "10.00",
|
|
237
|
+
addresses: [],
|
|
238
|
+
});
|
|
239
|
+
const { result } = await inRequest(() => user({}), "store_session_payload_8336778=S");
|
|
240
|
+
expect(result).toEqual({ id: 350524152, name: "Ana", email: "a@b.com", phone: "11999990000" });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("falls back to just the id without an admin token", async () => {
|
|
244
|
+
configureNuvemshop({ storeId: "8336778", storeUrl: STORE });
|
|
245
|
+
routes[`GET ${STORE}/account/`] = () => html("<script>LS.customer = 7;</script>");
|
|
246
|
+
expect((await inRequest(() => user({}), "store_session_payload_8336778=S")).result).toEqual({
|
|
247
|
+
id: 7,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { getNuvemshopConfig } from "../client";
|
|
3
|
-
import { configure } from "../mod";
|
|
3
|
+
import NuvemshopApp, { configure, type Props } from "../mod";
|
|
4
4
|
import { nuvemshopOperationRouter } from "../utils/operationRouter";
|
|
5
5
|
|
|
6
6
|
const noSecret = async () => null;
|
|
@@ -23,17 +23,64 @@ describe("configure", () => {
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it("resolves the admin token and Turnstile secret as secrets and keeps storeUrl", async () => {
|
|
27
|
+
const keys: string[] = [];
|
|
28
|
+
await configure(
|
|
29
|
+
{
|
|
30
|
+
storeId: "1",
|
|
31
|
+
storeUrl: "https://loja.example/",
|
|
32
|
+
adminToken: { e: 1 },
|
|
33
|
+
turnstileSecret: { e: 2 },
|
|
34
|
+
allowUnverifiedRegistration: true,
|
|
35
|
+
},
|
|
36
|
+
async (_v, key) => {
|
|
37
|
+
keys.push(key);
|
|
38
|
+
return `secret-for-${key}`;
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
expect(keys).toEqual([
|
|
42
|
+
"NUVEMSHOP_STOREFRONT_TOKEN",
|
|
43
|
+
"NUVEMSHOP_ADMIN_TOKEN",
|
|
44
|
+
"NUVEMSHOP_TURNSTILE_SECRET",
|
|
45
|
+
]);
|
|
46
|
+
expect(getNuvemshopConfig()).toMatchObject({
|
|
47
|
+
storeUrl: "https://loja.example/",
|
|
48
|
+
adminToken: "secret-for-NUVEMSHOP_ADMIN_TOKEN",
|
|
49
|
+
turnstileSecret: "secret-for-NUVEMSHOP_TURNSTILE_SECRET",
|
|
50
|
+
allowUnverifiedRegistration: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
26
54
|
it("resolves the token through the secret resolver (env fallback key)", async () => {
|
|
27
|
-
|
|
55
|
+
const keys: string[] = [];
|
|
28
56
|
await configure({ storeId: "1", token: { encrypted: "x" } }, async (_v, key) => {
|
|
29
|
-
|
|
30
|
-
return "tok";
|
|
57
|
+
keys.push(key);
|
|
58
|
+
return key === "NUVEMSHOP_STOREFRONT_TOKEN" ? "tok" : null;
|
|
31
59
|
});
|
|
32
|
-
expect(
|
|
60
|
+
expect(keys[0]).toBe("NUVEMSHOP_STOREFRONT_TOKEN");
|
|
33
61
|
expect(getNuvemshopConfig().token).toBe("tok");
|
|
34
62
|
});
|
|
35
63
|
});
|
|
36
64
|
|
|
65
|
+
describe("CMS Props (admin form for the deco-nuvemshop block)", () => {
|
|
66
|
+
it("is the shape configure() reads, and is the default export's input", async () => {
|
|
67
|
+
const props: Props = {
|
|
68
|
+
storeId: "8336778",
|
|
69
|
+
currency: "ARS",
|
|
70
|
+
defaultSort: "price-ascending",
|
|
71
|
+
apiVersion: "v2026-11",
|
|
72
|
+
};
|
|
73
|
+
expect(NuvemshopApp(props)).toEqual({ state: props });
|
|
74
|
+
await configure({ ...props }, noSecret);
|
|
75
|
+
expect(getNuvemshopConfig()).toMatchObject({
|
|
76
|
+
storeId: "8336778",
|
|
77
|
+
currency: "ARS",
|
|
78
|
+
defaultSort: "price-ascending",
|
|
79
|
+
apiVersion: "v2026-11",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
37
84
|
describe("nuvemshopOperationRouter", () => {
|
|
38
85
|
const base = "https://storefront-api.tiendanube.com/v2026-11/stores/1";
|
|
39
86
|
it.each([
|
|
@@ -44,6 +91,12 @@ describe("nuvemshopOperationRouter", () => {
|
|
|
44
91
|
[`${base}/categories/calcados`, "categories.get"],
|
|
45
92
|
[`${base}/shipping-options`, "shipping.options"],
|
|
46
93
|
[`${base}/checkouts`, "checkout.create"],
|
|
94
|
+
["https://demodeco.lojavirtualnuvem.com.br/account/login/", "store.account.login"],
|
|
95
|
+
["https://demodeco.lojavirtualnuvem.com.br/account/logout/", "store.account.logout"],
|
|
96
|
+
["https://demodeco.lojavirtualnuvem.com.br/account/", "store.account.page"],
|
|
97
|
+
["https://api.nuvemshop.com.br/v1/1/customers", "admin.customers"],
|
|
98
|
+
["https://api.nuvemshop.com.br/v1/1/customers/42", "admin.customers"],
|
|
99
|
+
["https://challenges.cloudflare.com/turnstile/v0/siteverify", "turnstile.verify"],
|
|
47
100
|
["https://example.com/other", undefined],
|
|
48
101
|
])("%s → %s", (url, op) => {
|
|
49
102
|
expect(nuvemshopOperationRouter(url, "GET")).toBe(op);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { storeFetch } from "../../store";
|
|
2
|
+
|
|
3
|
+
export interface Props {
|
|
4
|
+
email: string;
|
|
5
|
+
password: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type LoginResult =
|
|
9
|
+
| { ok: true }
|
|
10
|
+
| { ok: false; error: "invalid_credentials" | "email_not_validated" | "unknown" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @title Nuvemshop - Login
|
|
14
|
+
* @description Signs the buyer into the store session (the theme's login form, server-side).
|
|
15
|
+
* Success is a redirect to /account/; on failure the login page carries the reason.
|
|
16
|
+
*/
|
|
17
|
+
export default async function login(props: Props): Promise<LoginResult> {
|
|
18
|
+
if (!props?.email || !props?.password) return { ok: false, error: "invalid_credentials" };
|
|
19
|
+
const res = await storeFetch("/account/login/", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
form: { email: props.email, password: props.password },
|
|
22
|
+
});
|
|
23
|
+
if (res.location && new URL(res.location).pathname === "/account/") return { ok: true };
|
|
24
|
+
|
|
25
|
+
const page = await (await storeFetch("/account/login/", { cookie: res.cookie })).text();
|
|
26
|
+
if (page.includes("js-account-validation-pending"))
|
|
27
|
+
return { ok: false, error: "email_not_validated" };
|
|
28
|
+
if (page.includes("js-login-general-error")) return { ok: false, error: "invalid_credentials" };
|
|
29
|
+
return { ok: false, error: "unknown" };
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { storeFetch } from "../../store";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @title Nuvemshop - Logout
|
|
5
|
+
* @description Ends the store session (and forwards the expired session cookie).
|
|
6
|
+
*/
|
|
7
|
+
export default async function logout(_props: unknown): Promise<{ ok: true }> {
|
|
8
|
+
await storeFetch("/account/logout/");
|
|
9
|
+
return { ok: true };
|
|
10
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
2
|
+
import { nuvemshopAdmin } from "../../admin";
|
|
3
|
+
import { getNuvemshopConfig, nuvemshopFetch } from "../../client";
|
|
4
|
+
|
|
5
|
+
export interface Props {
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
password: string;
|
|
9
|
+
phone?: string;
|
|
10
|
+
/** Cloudflare Turnstile token from the registration form. */
|
|
11
|
+
captchaToken?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RegisterResult =
|
|
15
|
+
| { ok: true; customerId: number; emailValidationRequired: true }
|
|
16
|
+
| {
|
|
17
|
+
ok: false;
|
|
18
|
+
error:
|
|
19
|
+
| "invalid_input"
|
|
20
|
+
| "email_taken"
|
|
21
|
+
| "captcha_failed"
|
|
22
|
+
| "captcha_not_configured"
|
|
23
|
+
| "unknown";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
27
|
+
|
|
28
|
+
async function verifyTurnstile(secret: string, token: string | undefined) {
|
|
29
|
+
if (!token) return false;
|
|
30
|
+
const remoteip = RequestContext.current?.request.headers.get("cf-connecting-ip") ?? "";
|
|
31
|
+
const res = await nuvemshopFetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
34
|
+
body: new URLSearchParams({
|
|
35
|
+
secret,
|
|
36
|
+
response: token,
|
|
37
|
+
...(remoteip && { remoteip }),
|
|
38
|
+
}).toString(),
|
|
39
|
+
});
|
|
40
|
+
return ((await res.json().catch(() => null)) as { success?: boolean } | null)?.success === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @title Nuvemshop - Register
|
|
45
|
+
* @description Creates a customer through the Admin API. The store's own form needs a reCAPTCHA
|
|
46
|
+
* bound to its domain; the Admin API has none, so this action requires Cloudflare Turnstile
|
|
47
|
+
* (`turnstileSecret`) unless `allowUnverifiedRegistration` is set. The store still emails a
|
|
48
|
+
* validation link before the first login.
|
|
49
|
+
*/
|
|
50
|
+
export default async function register(props: Props): Promise<RegisterResult> {
|
|
51
|
+
const { turnstileSecret, allowUnverifiedRegistration } = getNuvemshopConfig();
|
|
52
|
+
const name = props?.name?.trim();
|
|
53
|
+
const email = props?.email?.trim();
|
|
54
|
+
if (!name || !email || !EMAIL.test(email) || !props.password || props.password.length < 6) {
|
|
55
|
+
return { ok: false, error: "invalid_input" };
|
|
56
|
+
}
|
|
57
|
+
if (!allowUnverifiedRegistration) {
|
|
58
|
+
if (!turnstileSecret) return { ok: false, error: "captcha_not_configured" };
|
|
59
|
+
if (!(await verifyTurnstile(turnstileSecret, props.captchaToken))) {
|
|
60
|
+
return { ok: false, error: "captcha_failed" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const res = await nuvemshopAdmin("/customers", {
|
|
64
|
+
method: "POST",
|
|
65
|
+
body: {
|
|
66
|
+
name,
|
|
67
|
+
email,
|
|
68
|
+
password: props.password,
|
|
69
|
+
...(props.phone && { phone: props.phone }),
|
|
70
|
+
send_email_invite: false,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (res.status === 422) {
|
|
74
|
+
const body = (await res.json().catch(() => ({}))) as { email?: string[] };
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: body.email?.some((m) => /unique/i.test(m)) ? "email_taken" : "invalid_input",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!res.ok) return { ok: false, error: "unknown" };
|
|
81
|
+
const customer = (await res.json()) as { id: number };
|
|
82
|
+
return { ok: true, customerId: customer.id, emailValidationRequired: true };
|
|
83
|
+
}
|
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,
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
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,
|
|
@@ -16,8 +19,10 @@ export { default as productListingPage } from "./loaders/productListingPage";
|
|
|
16
19
|
export { default as relatedProducts } from "./loaders/relatedProducts";
|
|
17
20
|
export { default as shippingOptions } from "./loaders/shippingOptions";
|
|
18
21
|
export { default as suggestions } from "./loaders/suggestions";
|
|
22
|
+
export { default as user } from "./loaders/user";
|
|
19
23
|
export { configure } from "./mod";
|
|
20
24
|
export { NUVEMSHOP_REGISTRY_ENTRY } from "./registry";
|
|
21
25
|
export { createNuvemshopFetch } from "./utils/instrumentedFetch";
|
|
22
26
|
export { nuvemshopOperationRouter } from "./utils/operationRouter";
|
|
27
|
+
export { nuvemshopSitemap } from "./utils/sitemap";
|
|
23
28
|
export type * from "./utils/types";
|
|
@@ -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";
|
package/src/manifest.gen.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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";
|
|
5
8
|
import * as loaders_categories from "./loaders/categories";
|
|
6
9
|
import * as loaders_productDetailsPage from "./loaders/productDetailsPage";
|
|
@@ -9,6 +12,7 @@ import * as loaders_productListingPage from "./loaders/productListingPage";
|
|
|
9
12
|
import * as loaders_relatedProducts from "./loaders/relatedProducts";
|
|
10
13
|
import * as loaders_shippingOptions from "./loaders/shippingOptions";
|
|
11
14
|
import * as loaders_suggestions from "./loaders/suggestions";
|
|
15
|
+
import * as loaders_user from "./loaders/user";
|
|
12
16
|
|
|
13
17
|
const manifest = {
|
|
14
18
|
name: "nuvemshop",
|
|
@@ -20,8 +24,12 @@ const manifest = {
|
|
|
20
24
|
"nuvemshop/loaders/relatedProducts": loaders_relatedProducts,
|
|
21
25
|
"nuvemshop/loaders/shippingOptions": loaders_shippingOptions,
|
|
22
26
|
"nuvemshop/loaders/suggestions": loaders_suggestions,
|
|
27
|
+
"nuvemshop/loaders/user": loaders_user,
|
|
23
28
|
},
|
|
24
29
|
actions: {
|
|
30
|
+
"nuvemshop/actions/account/login": actions_account_login,
|
|
31
|
+
"nuvemshop/actions/account/logout": actions_account_logout,
|
|
32
|
+
"nuvemshop/actions/account/register": actions_account_register,
|
|
25
33
|
"nuvemshop/actions/createCheckout": actions_createCheckout,
|
|
26
34
|
},
|
|
27
35
|
sections: {},
|
package/src/mod.ts
CHANGED
|
@@ -9,8 +9,66 @@
|
|
|
9
9
|
* production — see `client.ts` for the rate-limit trade-off.
|
|
10
10
|
*/
|
|
11
11
|
import type { AppDefinition, ResolveSecretFn } from "@decocms/apps-commerce/app-types";
|
|
12
|
+
import type { Secret } from "@decocms/apps-website/mod";
|
|
12
13
|
import { configureNuvemshop, type NuvemshopConfig } from "./client";
|
|
13
14
|
import manifest from "./manifest.gen";
|
|
15
|
+
import type { SortValue } from "./utils/listing";
|
|
16
|
+
|
|
17
|
+
// -------------------------------------------------------------------------
|
|
18
|
+
// CMS Props — the admin form for the `deco-nuvemshop` block. Sites expose it
|
|
19
|
+
// through a bridge (`src/apps/nuvemshop.ts` re-exporting this module's default
|
|
20
|
+
// and `Props`); generate-schema builds the form from this interface.
|
|
21
|
+
// -------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** @title Nuvemshop */
|
|
24
|
+
export interface Props {
|
|
25
|
+
/**
|
|
26
|
+
* @title Store ID
|
|
27
|
+
* @description Numeric Nuvemshop/Tiendanube store id (e.g. 8336778).
|
|
28
|
+
*/
|
|
29
|
+
storeId: string;
|
|
30
|
+
/**
|
|
31
|
+
* @title Storefront token
|
|
32
|
+
* @description Optional. Without it all buyers share 120 req/min per IP; with it the store gets 1,200 req/min.
|
|
33
|
+
*/
|
|
34
|
+
token?: Secret;
|
|
35
|
+
/**
|
|
36
|
+
* @title Currency
|
|
37
|
+
* @description The API returns no currency. @default BRL
|
|
38
|
+
*/
|
|
39
|
+
currency?: string;
|
|
40
|
+
/**
|
|
41
|
+
* @title Default category sort
|
|
42
|
+
* @description The API doesn't expose the category's configured sort. @default created-descending
|
|
43
|
+
*/
|
|
44
|
+
defaultSort?: SortValue;
|
|
45
|
+
/**
|
|
46
|
+
* @title API version
|
|
47
|
+
* @default v2026-11
|
|
48
|
+
*/
|
|
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;
|
|
71
|
+
}
|
|
14
72
|
|
|
15
73
|
export interface NuvemshopState {
|
|
16
74
|
config: NuvemshopConfig;
|
|
@@ -27,9 +85,19 @@ export async function configure(
|
|
|
27
85
|
(await resolveSecret(block.token, "NUVEMSHOP_STOREFRONT_TOKEN")) ??
|
|
28
86
|
(typeof block.token === "string" ? block.token : undefined);
|
|
29
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
|
+
|
|
30
94
|
const config: NuvemshopConfig = {
|
|
31
95
|
storeId,
|
|
32
96
|
token: token || undefined,
|
|
97
|
+
storeUrl: block.storeUrl as string | undefined,
|
|
98
|
+
adminToken: adminToken || undefined,
|
|
99
|
+
turnstileSecret: turnstileSecret || undefined,
|
|
100
|
+
allowUnverifiedRegistration: block.allowUnverifiedRegistration === true,
|
|
33
101
|
apiVersion: block.apiVersion as string | undefined,
|
|
34
102
|
currency: block.currency as string | undefined,
|
|
35
103
|
defaultSort: block.defaultSort as string | undefined,
|
|
@@ -40,3 +108,8 @@ export async function configure(
|
|
|
40
108
|
}
|
|
41
109
|
|
|
42
110
|
export const preview = undefined;
|
|
111
|
+
|
|
112
|
+
/** Default export for schema generation and app bridges. */
|
|
113
|
+
export default function Nuvemshop(props: Props) {
|
|
114
|
+
return { state: props };
|
|
115
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import categoriesFixture from "../../__fixtures__/categories.json";
|
|
3
|
+
import { clearNuvemshopCache, configureNuvemshop, setNuvemshopFetch } from "../../client";
|
|
4
|
+
import { nuvemshopSitemap } from "../sitemap";
|
|
5
|
+
|
|
6
|
+
const json = (body: unknown) =>
|
|
7
|
+
new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
|
|
8
|
+
const page = (n: number, total: number, handles: string[]) => ({
|
|
9
|
+
data: handles.map((handle, i) => ({
|
|
10
|
+
id: n * 100 + i,
|
|
11
|
+
handle,
|
|
12
|
+
updated_at: `2026-10-0${n}T10:00:00+0000`,
|
|
13
|
+
})),
|
|
14
|
+
pagination: { page: n, per_page: 200, total: 3, total_pages: total },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
|
18
|
+
const url = new URL(String(input));
|
|
19
|
+
if (url.pathname.endsWith("/categories")) return json(categoriesFixture);
|
|
20
|
+
const p = Number(url.searchParams.get("page") ?? 1);
|
|
21
|
+
return json(p === 1 ? page(1, 2, ["camisa-a", "camisa-b&c"]) : page(2, 2, ["tenis-x"]));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
fetchMock.mockClear();
|
|
26
|
+
clearNuvemshopCache();
|
|
27
|
+
configureNuvemshop({ storeId: "8336778" });
|
|
28
|
+
setNuvemshopFetch(fetchMock as unknown as typeof fetch);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("nuvemshopSitemap", () => {
|
|
32
|
+
it("pages through every product and lists categories with nested URLs", async () => {
|
|
33
|
+
const xml = await nuvemshopSitemap({ origin: "https://www.loja.example" });
|
|
34
|
+
|
|
35
|
+
const productCalls = fetchMock.mock.calls
|
|
36
|
+
.map(([u]) => new URL(String(u)))
|
|
37
|
+
.filter((u) => u.pathname.endsWith("/products"));
|
|
38
|
+
expect(productCalls.map((u) => u.searchParams.get("page"))).toEqual(["1", "2"]);
|
|
39
|
+
expect(productCalls[0].searchParams.get("per_page")).toBe("200");
|
|
40
|
+
expect(productCalls[0].searchParams.get("fields")).toBe("handle,updated_at");
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
xml.startsWith(
|
|
44
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
45
|
+
),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
expect(xml).toContain(
|
|
48
|
+
"<url><loc>https://www.loja.example/produtos/camisa-a</loc><lastmod>2026-10-01</lastmod></url>",
|
|
49
|
+
);
|
|
50
|
+
expect(xml).toContain("<loc>https://www.loja.example/produtos/tenis-x</loc>");
|
|
51
|
+
expect(xml).toContain("<loc>https://www.loja.example/produtos/camisa-b&c</loc>"); // XML-escaped
|
|
52
|
+
expect(xml).toContain("<url><loc>https://www.loja.example/calcados/masculino</loc></url>");
|
|
53
|
+
expect(xml).toContain("<url><loc>https://www.loja.example/</loc></url>");
|
|
54
|
+
expect(xml.match(/<url>/g)).toHaveLength(1 + categoriesFixture.data.length + 3);
|
|
55
|
+
expect(xml.trimEnd().endsWith("</urlset>")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/utils/listing.ts
CHANGED
|
@@ -22,7 +22,17 @@ export const LISTING_WINDOW = 200;
|
|
|
22
22
|
* The theme's "user" (merchant's manual order) isn't in the API, so it's not
|
|
23
23
|
* offered; `?sort_by=user` falls back to the default sort.
|
|
24
24
|
*/
|
|
25
|
-
export
|
|
25
|
+
export type SortValue =
|
|
26
|
+
| "best-selling"
|
|
27
|
+
| "price-ascending"
|
|
28
|
+
| "price-descending"
|
|
29
|
+
| "alpha-ascending"
|
|
30
|
+
| "alpha-descending"
|
|
31
|
+
| "created-descending"
|
|
32
|
+
| "created-ascending"
|
|
33
|
+
| "discount-descending";
|
|
34
|
+
|
|
35
|
+
export const SORT_OPTIONS: (SortOption & { value: SortValue })[] = [
|
|
26
36
|
{ value: "best-selling", label: "Mais vendidos" },
|
|
27
37
|
{ value: "price-ascending", label: "Menor preço" },
|
|
28
38
|
{ value: "price-descending", label: "Maior preço" },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* URL → operation name for Nuvemshop Storefront API
|
|
2
|
+
* URL → operation name for Nuvemshop egress (Storefront API, the store's own
|
|
3
|
+
* session endpoints, the Admin API and Turnstile), plugged into
|
|
3
4
|
* `createInstrumentedFetch`'s `resolveOperation`. Returns `undefined` when
|
|
4
5
|
* nothing matches so the framework falls back to `nuvemshop.fetch`.
|
|
5
6
|
*/
|
|
@@ -13,6 +14,11 @@ const MATCHERS: ReadonlyArray<[RegExp, string]> = [
|
|
|
13
14
|
[/\/stores\/\d+\/categories\/[^/]+\/?$/, "categories.get"],
|
|
14
15
|
[/\/stores\/\d+\/shipping-options\/?$/, "shipping.options"],
|
|
15
16
|
[/\/stores\/\d+\/checkouts\/?$/, "checkout.create"],
|
|
17
|
+
[/^\/account\/login\/?$/, "store.account.login"],
|
|
18
|
+
[/^\/account\/logout\/?$/, "store.account.logout"],
|
|
19
|
+
[/^\/account\/?$/, "store.account.page"],
|
|
20
|
+
[/^\/v1\/\d+\/customers(\/\d+)?\/?$/, "admin.customers"],
|
|
21
|
+
[/^\/turnstile\/v0\/siteverify$/, "turnstile.verify"],
|
|
16
22
|
];
|
|
17
23
|
|
|
18
24
|
export function nuvemshopOperationRouter(url: string, _method: string): string | undefined {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product + category sitemap built from the Storefront API (which has no
|
|
3
|
+
* sitemap endpoint). URLs use the same paths as the loaders (`/produtos/<handle>`,
|
|
4
|
+
* `/<cat>/<sub>`), so they resolve without redirects. Serve it from a site route:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // src/routes/sitemap[.]xml.ts
|
|
8
|
+
* GET: async ({ request }) => new Response(await nuvemshopSitemap({ origin: new URL(request.url).origin }),
|
|
9
|
+
* { headers: { "content-type": "application/xml; charset=utf-8" } })
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { nuvemshopGet } from "../client";
|
|
13
|
+
import { categoryChain, categoryPath, productPath } from "./transform";
|
|
14
|
+
import type { NuvemshopCategory, NuvemshopList } from "./types";
|
|
15
|
+
|
|
16
|
+
// ponytail: one request per 200 products, sequential; split into a sitemap index if a catalog
|
|
17
|
+
// ever outgrows the 50k-URL sitemap limit.
|
|
18
|
+
const PER_PAGE = 200;
|
|
19
|
+
|
|
20
|
+
const escapeXml = (s: string) =>
|
|
21
|
+
s
|
|
22
|
+
.replace(/&/g, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """)
|
|
26
|
+
.replace(/'/g, "'");
|
|
27
|
+
const entry = (loc: string, lastmod?: string) =>
|
|
28
|
+
`<url><loc>${escapeXml(loc)}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ""}</url>`;
|
|
29
|
+
|
|
30
|
+
export async function nuvemshopSitemap({ origin }: { origin: string }): Promise<string> {
|
|
31
|
+
const urls = [entry(`${origin}/`)];
|
|
32
|
+
|
|
33
|
+
const categories =
|
|
34
|
+
(await nuvemshopGet<NuvemshopList<NuvemshopCategory>>("/categories", { per_page: PER_PAGE }))
|
|
35
|
+
?.data ?? [];
|
|
36
|
+
for (const c of categories)
|
|
37
|
+
urls.push(entry(`${origin}${categoryPath(categoryChain(c, categories))}`));
|
|
38
|
+
|
|
39
|
+
for (let page = 1, pages = 1; page <= pages; page++) {
|
|
40
|
+
const list = await nuvemshopGet<NuvemshopList<{ handle: string; updated_at?: string }>>(
|
|
41
|
+
"/products",
|
|
42
|
+
{
|
|
43
|
+
fields: "handle,updated_at",
|
|
44
|
+
per_page: PER_PAGE,
|
|
45
|
+
page,
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
if (!list) break;
|
|
49
|
+
pages = list.pagination.total_pages;
|
|
50
|
+
for (const p of list.data) urls.push(entry(`${origin}${productPath(p.handle)}`, p.updated_at));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>\n`;
|
|
54
|
+
}
|