@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.
@@ -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
+ };