@decocms/apps-commerce 7.2.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 +44 -0
- package/src/app-types.ts +62 -0
- package/src/manifest-utils.test.ts +57 -0
- package/src/manifest-utils.ts +38 -0
- package/src/registry.ts +38 -0
- package/src/resolve.ts +114 -0
- package/src/sdk/analytics.ts +151 -0
- package/src/sdk/formatPrice.test.ts +59 -0
- package/src/sdk/formatPrice.ts +44 -0
- package/src/sdk/url.test.ts +125 -0
- package/src/sdk/url.ts +52 -0
- package/src/sdk/useOffer.ts +64 -0
- package/src/sdk/useVariantPossibilities.ts +43 -0
- package/src/types/commerce.ts +1228 -0
- package/src/utils/canonical.ts +8 -0
- package/src/utils/constants.ts +8 -0
- package/src/utils/filters.test.ts +34 -0
- package/src/utils/filters.ts +10 -0
- package/src/utils/productToAnalyticsItem.ts +63 -0
- package/src/utils/stateByZip.test.ts +56 -0
- package/src/utils/stateByZip.ts +50 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { relative } from "./url";
|
|
3
|
+
|
|
4
|
+
describe("relative()", () => {
|
|
5
|
+
describe("base behaviour (no options)", () => {
|
|
6
|
+
it("returns undefined for undefined input", () => {
|
|
7
|
+
expect(relative(undefined)).toBeUndefined();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns undefined for empty string input", () => {
|
|
11
|
+
expect(relative("")).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("preserves a relative path-only URL", () => {
|
|
15
|
+
expect(relative("/p/foo")).toBe("/p/foo");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("preserves a relative path with search params", () => {
|
|
19
|
+
expect(relative("/p/foo?a=1&b=2")).toBe("/p/foo?a=1&b=2");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("strips the origin from an absolute URL", () => {
|
|
23
|
+
expect(relative("https://x.example.com/p/foo?a=1")).toBe("/p/foo?a=1");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns the original string when URL parsing fails", () => {
|
|
27
|
+
// `new URL` against the `https://localhost` base accepts almost
|
|
28
|
+
// anything string-shaped, so genuine throws are rare. The catch
|
|
29
|
+
// branch exists as a defence against non-string-like values
|
|
30
|
+
// reaching the helper through type-erased call sites — we
|
|
31
|
+
// exercise it by forcing a non-string through the public API.
|
|
32
|
+
const malformed = {
|
|
33
|
+
toString() {
|
|
34
|
+
throw new Error("boom");
|
|
35
|
+
},
|
|
36
|
+
} as unknown as string;
|
|
37
|
+
expect(relative(malformed)).toBe(malformed);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("stripSearchParams", () => {
|
|
42
|
+
it("removes the listed key", () => {
|
|
43
|
+
expect(
|
|
44
|
+
relative("/p/foo?idsku=1&keep=2", {
|
|
45
|
+
stripSearchParams: ["idsku"],
|
|
46
|
+
}),
|
|
47
|
+
).toBe("/p/foo?keep=2");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("removes multiple listed keys", () => {
|
|
51
|
+
expect(
|
|
52
|
+
relative("/p/foo?idsku=1&skuId=2&keep=3", {
|
|
53
|
+
stripSearchParams: ["idsku", "skuId"],
|
|
54
|
+
}),
|
|
55
|
+
).toBe("/p/foo?keep=3");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("drops the trailing ? when ALL params are stripped", () => {
|
|
59
|
+
expect(
|
|
60
|
+
relative("/p/foo?idsku=1&skuId=2", {
|
|
61
|
+
stripSearchParams: ["idsku", "skuId"],
|
|
62
|
+
}),
|
|
63
|
+
).toBe("/p/foo");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("is a no-op when stripSearchParams is empty", () => {
|
|
67
|
+
expect(relative("/p/foo?a=1", { stripSearchParams: [] })).toBe("/p/foo?a=1");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is a no-op when stripSearchParams is undefined (option object only)", () => {
|
|
71
|
+
expect(relative("/p/foo?a=1", {})).toBe("/p/foo?a=1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("silently ignores keys that are not present in the URL", () => {
|
|
75
|
+
expect(
|
|
76
|
+
relative("/p/foo?keep=1", {
|
|
77
|
+
stripSearchParams: ["idsku", "skuId"],
|
|
78
|
+
}),
|
|
79
|
+
).toBe("/p/foo?keep=1");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("strips keys from absolute URLs while still removing the origin", () => {
|
|
83
|
+
expect(
|
|
84
|
+
relative("https://x.example.com/p/foo?idsku=1&keep=2", {
|
|
85
|
+
stripSearchParams: ["idsku"],
|
|
86
|
+
}),
|
|
87
|
+
).toBe("/p/foo?keep=2");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("preserves repeated keys for params not in the strip list", () => {
|
|
91
|
+
// URLSearchParams keeps repeats; relative() must not collapse them.
|
|
92
|
+
expect(
|
|
93
|
+
relative("/p/foo?tag=a&tag=b&idsku=1", {
|
|
94
|
+
stripSearchParams: ["idsku"],
|
|
95
|
+
}),
|
|
96
|
+
).toBe("/p/foo?tag=a&tag=b");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("removes ALL occurrences of a repeated key when listed", () => {
|
|
100
|
+
expect(
|
|
101
|
+
relative("/p/foo?idsku=1&idsku=2&keep=3", {
|
|
102
|
+
stripSearchParams: ["idsku"],
|
|
103
|
+
}),
|
|
104
|
+
).toBe("/p/foo?keep=3");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("backwards compatibility with the 1-arg signature", () => {
|
|
109
|
+
it("matches the pre-options behaviour byte-for-byte for relative paths", () => {
|
|
110
|
+
expect(relative("/p/foo?a=1")).toBe("/p/foo?a=1");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("matches the pre-options behaviour byte-for-byte for absolute URLs", () => {
|
|
114
|
+
expect(relative("https://x.example.com/path?q=1")).toBe("/path?q=1");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('preserves the previous "://path-style" passthrough behaviour', () => {
|
|
118
|
+
// The original 9-line apps `relative()` parsed this against
|
|
119
|
+
// the localhost base too — both old and new implementations
|
|
120
|
+
// return "/://no-scheme". This assertion locks in the byte-
|
|
121
|
+
// for-byte identical behaviour for the no-options case.
|
|
122
|
+
expect(relative("://no-scheme")).toBe("/://no-scheme");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/sdk/url.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link relative}.
|
|
3
|
+
*
|
|
4
|
+
* `stripSearchParams` is the primitive escape hatch used by sites that
|
|
5
|
+
* need to drop platform-specific query keys (commonly VTEX's `idsku`
|
|
6
|
+
* / `skuId`) before linking to a PDP. Any keys not listed are kept.
|
|
7
|
+
*
|
|
8
|
+
* Sites previously hand-rolled this by forking `relative()` locally
|
|
9
|
+
* with a `removeIdSku?: boolean` flag — see the migration guide and
|
|
10
|
+
* the `local-framework-duplicate` audit rule in `@decocms/start`.
|
|
11
|
+
*/
|
|
12
|
+
export interface RelativeOptions {
|
|
13
|
+
stripSearchParams?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert an absolute or relative URL string into a path + search
|
|
18
|
+
* fragment safe to feed into `<Link to={…} />` or `<a href={…}>`.
|
|
19
|
+
*
|
|
20
|
+
* - Returns `undefined` when `link` is falsy (the empty / undefined
|
|
21
|
+
* case is the common "no permalink yet" branch in product cards).
|
|
22
|
+
* - Returns the original string when URL parsing fails — preserves
|
|
23
|
+
* pre-existing behaviour for malformed inputs.
|
|
24
|
+
* - When `options.stripSearchParams` is non-empty, every listed key
|
|
25
|
+
* is removed from the resulting `?...` portion. Keys not present
|
|
26
|
+
* in the input are silently ignored. The pathname is never
|
|
27
|
+
* touched — only search params.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* relative("/p/foo"); // "/p/foo"
|
|
31
|
+
* relative("https://x.com/p/foo?a=1"); // "/p/foo?a=1"
|
|
32
|
+
* relative("/p/foo?idsku=1&keep=2", {
|
|
33
|
+
* stripSearchParams: ["idsku"],
|
|
34
|
+
* }); // "/p/foo?keep=2"
|
|
35
|
+
* relative(undefined); // undefined
|
|
36
|
+
*/
|
|
37
|
+
export function relative(link?: string, options?: RelativeOptions): string | undefined {
|
|
38
|
+
if (!link) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const linkUrl = new URL(link, "https://localhost");
|
|
41
|
+
const stripKeys = options?.stripSearchParams;
|
|
42
|
+
if (stripKeys && stripKeys.length > 0) {
|
|
43
|
+
for (const key of stripKeys) {
|
|
44
|
+
linkUrl.searchParams.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const search = linkUrl.searchParams.toString();
|
|
48
|
+
return `${linkUrl.pathname}${search ? `?${search}` : ""}`;
|
|
49
|
+
} catch {
|
|
50
|
+
return link;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AggregateOffer, UnitPriceSpecification } from "../types/commerce";
|
|
2
|
+
|
|
3
|
+
const bestInstallment = (acc: UnitPriceSpecification | null, curr: UnitPriceSpecification) => {
|
|
4
|
+
if (curr.priceComponentType !== "https://schema.org/Installment") {
|
|
5
|
+
return acc;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (!acc) {
|
|
9
|
+
return curr;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (acc.price > curr.price) {
|
|
13
|
+
return curr;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (acc.price < curr.price) {
|
|
17
|
+
return acc;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (acc.billingDuration && curr.billingDuration && acc.billingDuration < curr.billingDuration) {
|
|
21
|
+
return curr;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return acc;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const installmentToString = (
|
|
28
|
+
installment: UnitPriceSpecification,
|
|
29
|
+
locale = "pt-BR",
|
|
30
|
+
currency = "BRL",
|
|
31
|
+
) => {
|
|
32
|
+
const { billingDuration, billingIncrement } = installment;
|
|
33
|
+
|
|
34
|
+
if (!billingDuration || !billingIncrement) {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const formatted = new Intl.NumberFormat(locale, {
|
|
39
|
+
style: "currency",
|
|
40
|
+
currency,
|
|
41
|
+
}).format(billingIncrement);
|
|
42
|
+
|
|
43
|
+
return `${billingDuration}x ${formatted}`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const useOffer = (aggregateOffer?: AggregateOffer) => {
|
|
47
|
+
const offer = aggregateOffer?.offers?.[0];
|
|
48
|
+
const listPrice = offer?.priceSpecification?.find(
|
|
49
|
+
(spec) => spec.priceType === "https://schema.org/ListPrice",
|
|
50
|
+
);
|
|
51
|
+
const installment = offer?.priceSpecification?.reduce(bestInstallment, null);
|
|
52
|
+
const seller = offer?.seller;
|
|
53
|
+
const price = offer?.price;
|
|
54
|
+
const availability = offer?.availability;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
price,
|
|
58
|
+
listPrice: listPrice?.price,
|
|
59
|
+
availability,
|
|
60
|
+
seller,
|
|
61
|
+
installments: installment && price ? installmentToString(installment) : null,
|
|
62
|
+
installment,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ProductLeaf, PropertyValue } from "../types/commerce";
|
|
2
|
+
|
|
3
|
+
export type Possibilities = Record<string, Record<string, string | undefined>>;
|
|
4
|
+
|
|
5
|
+
const hash = ({ name, value }: PropertyValue) => `${name}::${value}`;
|
|
6
|
+
|
|
7
|
+
const omit = new Set(["category", "cluster", "RefId", "descriptionHtml"]);
|
|
8
|
+
|
|
9
|
+
export const useVariantPossibilities = (
|
|
10
|
+
variants: ProductLeaf[],
|
|
11
|
+
selected: ProductLeaf,
|
|
12
|
+
): Possibilities => {
|
|
13
|
+
const possibilities: Possibilities = {};
|
|
14
|
+
const selectedSpecs = new Set(selected.additionalProperty?.map(hash));
|
|
15
|
+
|
|
16
|
+
for (const variant of variants) {
|
|
17
|
+
const { url, additionalProperty = [], productID } = variant;
|
|
18
|
+
const isSelected = productID === selected.productID;
|
|
19
|
+
const specs = additionalProperty.filter(({ name }) => !omit.has(name!));
|
|
20
|
+
|
|
21
|
+
for (let it = 0; it < specs.length; it++) {
|
|
22
|
+
const name = specs[it].name!;
|
|
23
|
+
const value = specs[it].value!;
|
|
24
|
+
|
|
25
|
+
if (omit.has(name)) continue;
|
|
26
|
+
|
|
27
|
+
if (!possibilities[name]) {
|
|
28
|
+
possibilities[name] = {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isSelectable =
|
|
32
|
+
it === 0 || specs.every((s) => s.name === name || selectedSpecs.has(hash(s)));
|
|
33
|
+
|
|
34
|
+
possibilities[name][value] = isSelected
|
|
35
|
+
? url
|
|
36
|
+
: isSelectable
|
|
37
|
+
? possibilities[name][value] || url
|
|
38
|
+
: possibilities[name][value];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return possibilities;
|
|
43
|
+
};
|