@kelviq/js-promotions-ui 0.0.1
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/.env.development +3 -0
- package/.env.production +2 -0
- package/.eslintignore +6 -0
- package/.eslintrc +18 -0
- package/.husky/commit-msg +4 -0
- package/.husky/pre-commit +1 -0
- package/.lintstagedrc +4 -0
- package/.prettierignore +6 -0
- package/.prettierrc +15 -0
- package/.stylelintignore +6 -0
- package/.stylelintrc +20 -0
- package/CHANGELOG.md +24 -0
- package/DEVELOPER.md +61 -0
- package/LICENSE.md +9 -0
- package/README.md +1 -0
- package/commitlint.config.cjs +4 -0
- package/dts-bundle-generator.config.ts +11 -0
- package/favicon.svg +15 -0
- package/index.html +485 -0
- package/package.json +79 -0
- package/scripts/publish.sh +67 -0
- package/src/dom-utils.ts +6 -0
- package/src/global.d.ts +9 -0
- package/src/http-utils.ts +62 -0
- package/src/index.ts +2 -0
- package/src/public-script.js +44 -0
- package/src/sdk.ts +504 -0
- package/src/types.ts +60 -0
- package/src/utils.ts +146 -0
- package/src/vite-env.d.ts +1 -0
- package/test/dom-utils.test.ts +49 -0
- package/test/http-utils.test.ts +152 -0
- package/test/sdk.test.ts +575 -0
- package/test/setup.ts +83 -0
- package/test/utils.test.ts +137 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +57 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { DefaultConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CONFIG: DefaultConfig = {
|
|
4
|
+
showBanner: true,
|
|
5
|
+
localizePricing: true,
|
|
6
|
+
currencyDisplay: "symbol",
|
|
7
|
+
baseCurrencySymbol: "$",
|
|
8
|
+
baseCurrencyCode: "USD",
|
|
9
|
+
environment: "production",
|
|
10
|
+
showDecimal: false,
|
|
11
|
+
minimumDecimalDigits: 2,
|
|
12
|
+
maximumDecimalDigits: 2,
|
|
13
|
+
isOriginalDisplay: false,
|
|
14
|
+
banner: {},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type CurrencyFormatStyle = "standard" | "compact";
|
|
18
|
+
|
|
19
|
+
export type CurrencyDisplay = "symbol" | "code" | "name";
|
|
20
|
+
|
|
21
|
+
interface FormatCurrencyOptions {
|
|
22
|
+
amount: number;
|
|
23
|
+
currency: string;
|
|
24
|
+
locale?: string;
|
|
25
|
+
currencyDisplay?: "symbol" | "code" | "name";
|
|
26
|
+
formatStyle?: CurrencyFormatStyle;
|
|
27
|
+
minimumFractionDigits?: number;
|
|
28
|
+
maximumFractionDigits?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FormatCurrencyResult {
|
|
32
|
+
formatted: string;
|
|
33
|
+
integer: string;
|
|
34
|
+
decimal: string;
|
|
35
|
+
decimalSeparator: string;
|
|
36
|
+
thousandsSeparator: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STRIPE_ZERO_DECIMAL_CURRENCIES = new Set([
|
|
40
|
+
"BIF",
|
|
41
|
+
"CLP",
|
|
42
|
+
"DJF",
|
|
43
|
+
"GNF",
|
|
44
|
+
"JPY",
|
|
45
|
+
"KMF",
|
|
46
|
+
"KRW",
|
|
47
|
+
"MGA",
|
|
48
|
+
"PYG",
|
|
49
|
+
"RWF",
|
|
50
|
+
"UGX",
|
|
51
|
+
"VND",
|
|
52
|
+
"VUV",
|
|
53
|
+
"XAF",
|
|
54
|
+
"XOF",
|
|
55
|
+
"XPF",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function clampFractionDigit(value: number): number {
|
|
59
|
+
return Math.max(0, Math.min(2, Math.round(value)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Ensures fraction digit values are within safe bounds
|
|
64
|
+
* and respects Stripe zero-decimal currency rules.
|
|
65
|
+
*/
|
|
66
|
+
export function sanitizeFractionDigits(
|
|
67
|
+
currency: string,
|
|
68
|
+
min: number,
|
|
69
|
+
max: number
|
|
70
|
+
): { minimumFractionDigits: number; maximumFractionDigits: number } {
|
|
71
|
+
const isZeroDecimal = STRIPE_ZERO_DECIMAL_CURRENCIES.has(
|
|
72
|
+
currency.toUpperCase()
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (isZeroDecimal) {
|
|
76
|
+
return { minimumFractionDigits: 0, maximumFractionDigits: 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const minimumFractionDigits = clampFractionDigit(min);
|
|
80
|
+
const maximumFractionDigits = Math.max(
|
|
81
|
+
minimumFractionDigits,
|
|
82
|
+
clampFractionDigit(max)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return { minimumFractionDigits, maximumFractionDigits };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Formats a currency value with locale-aware formatting and fraction digit control.
|
|
90
|
+
*/
|
|
91
|
+
export function pdFormatCurrency({
|
|
92
|
+
amount,
|
|
93
|
+
currency,
|
|
94
|
+
locale = "en-US",
|
|
95
|
+
formatStyle = "standard",
|
|
96
|
+
currencyDisplay = "symbol",
|
|
97
|
+
minimumFractionDigits = 2,
|
|
98
|
+
maximumFractionDigits = 2,
|
|
99
|
+
}: FormatCurrencyOptions): FormatCurrencyResult {
|
|
100
|
+
const { minimumFractionDigits: minDigits, maximumFractionDigits: maxDigits } =
|
|
101
|
+
sanitizeFractionDigits(
|
|
102
|
+
currency,
|
|
103
|
+
minimumFractionDigits,
|
|
104
|
+
maximumFractionDigits
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
108
|
+
style: "currency",
|
|
109
|
+
currency,
|
|
110
|
+
notation: formatStyle === "compact" ? "compact" : "standard",
|
|
111
|
+
currencyDisplay,
|
|
112
|
+
minimumFractionDigits: minDigits,
|
|
113
|
+
maximumFractionDigits: maxDigits,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const parts = formatter.formatToParts(amount);
|
|
117
|
+
|
|
118
|
+
let formatted = "";
|
|
119
|
+
let integer = "";
|
|
120
|
+
let decimal = "";
|
|
121
|
+
let decimalSeparator = "";
|
|
122
|
+
let thousandsSeparator = "";
|
|
123
|
+
|
|
124
|
+
for (const part of parts) {
|
|
125
|
+
formatted += part.value;
|
|
126
|
+
|
|
127
|
+
if (part.type === "integer") {
|
|
128
|
+
integer += part.value;
|
|
129
|
+
} else if (part.type === "fraction") {
|
|
130
|
+
decimal = part.value;
|
|
131
|
+
} else if (part.type === "decimal") {
|
|
132
|
+
decimalSeparator = part.value;
|
|
133
|
+
} else if (part.type === "group") {
|
|
134
|
+
thousandsSeparator = part.value;
|
|
135
|
+
integer += part.value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
formatted,
|
|
141
|
+
integer,
|
|
142
|
+
decimal,
|
|
143
|
+
decimalSeparator,
|
|
144
|
+
thousandsSeparator,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { JSDOM } from "jsdom";
|
|
2
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { findRelatedElements } from "../src/dom-utils";
|
|
4
|
+
|
|
5
|
+
describe("findRelatedElements", () => {
|
|
6
|
+
// setup.ts replaces global.document with a mock; restore a real jsdom for these tests
|
|
7
|
+
const originalDocument = global.document;
|
|
8
|
+
let dom: JSDOM;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
|
12
|
+
global.document = dom.window.document as unknown as Document;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
global.document = originalDocument;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should return null when element has no data-kq-rel attribute", () => {
|
|
20
|
+
const el = document.createElement("div");
|
|
21
|
+
document.body.appendChild(el);
|
|
22
|
+
expect(findRelatedElements(el)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should return all elements sharing the same data-kq-rel value", () => {
|
|
26
|
+
document.body.innerHTML = `
|
|
27
|
+
<div data-kq-rel="price-a" id="el1"></div>
|
|
28
|
+
<div data-kq-rel="price-a" id="el2"></div>
|
|
29
|
+
<div data-kq-rel="price-b" id="el3"></div>
|
|
30
|
+
`;
|
|
31
|
+
const el = document.getElementById("el1") as HTMLElement;
|
|
32
|
+
const related = findRelatedElements(el);
|
|
33
|
+
expect(related).not.toBeNull();
|
|
34
|
+
expect(related!.length).toBe(2);
|
|
35
|
+
const ids = Array.from(related!).map(e => e.id);
|
|
36
|
+
expect(ids).toContain("el1");
|
|
37
|
+
expect(ids).toContain("el2");
|
|
38
|
+
expect(ids).not.toContain("el3");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should return only the element itself when no other element shares the rel value", () => {
|
|
42
|
+
document.body.innerHTML = `<div data-kq-rel="unique-rel" id="solo"></div>`;
|
|
43
|
+
const el = document.getElementById("solo") as HTMLElement;
|
|
44
|
+
const related = findRelatedElements(el);
|
|
45
|
+
expect(related).not.toBeNull();
|
|
46
|
+
expect(related!.length).toBe(1);
|
|
47
|
+
expect((related![0] as HTMLElement).id).toBe("solo");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { fetchPricingData, ERROR_MSG } from "../src/http-utils";
|
|
3
|
+
|
|
4
|
+
// ─── XHR mock helpers ──────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface MockXHR {
|
|
7
|
+
open: ReturnType<typeof vi.fn>;
|
|
8
|
+
send: ReturnType<typeof vi.fn>;
|
|
9
|
+
setRequestHeader: ReturnType<typeof vi.fn>;
|
|
10
|
+
onload: (() => void) | null;
|
|
11
|
+
onerror: (() => void) | null;
|
|
12
|
+
status: number;
|
|
13
|
+
response: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeMockXHR(): MockXHR {
|
|
17
|
+
return {
|
|
18
|
+
open: vi.fn(),
|
|
19
|
+
send: vi.fn(),
|
|
20
|
+
setRequestHeader: vi.fn(),
|
|
21
|
+
onload: null,
|
|
22
|
+
onerror: null,
|
|
23
|
+
status: 200,
|
|
24
|
+
response: "{}",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function installXHR(xhr: MockXHR) {
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
(global as any).XMLHttpRequest = vi.fn(() => xhr);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
describe("fetchPricingData", () => {
|
|
36
|
+
let mockXHR: MockXHR;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mockXHR = makeMockXHR();
|
|
40
|
+
installXHR(mockXHR);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should resolve with parsed JSON on a 200 response", async () => {
|
|
48
|
+
const payload = { country: "India", percentage: 20 };
|
|
49
|
+
mockXHR.status = 200;
|
|
50
|
+
mockXHR.response = JSON.stringify(payload);
|
|
51
|
+
|
|
52
|
+
const promise = fetchPricingData({ promotionId: "promo-123" });
|
|
53
|
+
mockXHR.onload!.call(mockXHR);
|
|
54
|
+
|
|
55
|
+
const result = await promise;
|
|
56
|
+
expect(result).toEqual(payload);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should include promotion_id in the query string", async () => {
|
|
60
|
+
mockXHR.response = "{}";
|
|
61
|
+
const promise = fetchPricingData({ promotionId: "promo-abc" });
|
|
62
|
+
mockXHR.onload!.call(mockXHR);
|
|
63
|
+
await promise;
|
|
64
|
+
|
|
65
|
+
const [, url] = mockXHR.open.mock.calls[0] as [string, string];
|
|
66
|
+
expect(url).toContain("promotion_id=promo-abc");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should not include promotion_id when not provided", async () => {
|
|
70
|
+
mockXHR.response = "{}";
|
|
71
|
+
const promise = fetchPricingData({});
|
|
72
|
+
mockXHR.onload!.call(mockXHR);
|
|
73
|
+
await promise;
|
|
74
|
+
|
|
75
|
+
const [, url] = mockXHR.open.mock.calls[0] as [string, string];
|
|
76
|
+
expect(url).not.toContain("promotion_id");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should set Authorization header when accessToken is provided", async () => {
|
|
80
|
+
mockXHR.response = "{}";
|
|
81
|
+
const promise = fetchPricingData({
|
|
82
|
+
promotionId: "p1",
|
|
83
|
+
accessToken: "tok-xyz",
|
|
84
|
+
});
|
|
85
|
+
mockXHR.onload!.call(mockXHR);
|
|
86
|
+
await promise;
|
|
87
|
+
|
|
88
|
+
expect(mockXHR.setRequestHeader).toHaveBeenCalledWith(
|
|
89
|
+
"Authorization",
|
|
90
|
+
"Bearer tok-xyz"
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should not set Authorization header when accessToken is absent", async () => {
|
|
95
|
+
mockXHR.response = "{}";
|
|
96
|
+
const promise = fetchPricingData({ promotionId: "p1" });
|
|
97
|
+
mockXHR.onload!.call(mockXHR);
|
|
98
|
+
await promise;
|
|
99
|
+
|
|
100
|
+
expect(mockXHR.setRequestHeader).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should use sandbox URL when environment is 'sandbox'", async () => {
|
|
104
|
+
mockXHR.response = "{}";
|
|
105
|
+
const promise = fetchPricingData({
|
|
106
|
+
promotionId: "p1",
|
|
107
|
+
environment: "sandbox",
|
|
108
|
+
});
|
|
109
|
+
mockXHR.onload!.call(mockXHR);
|
|
110
|
+
await promise;
|
|
111
|
+
|
|
112
|
+
const [, url] = mockXHR.open.mock.calls[0] as [string, string];
|
|
113
|
+
// VITE_SANDBOX_API_URL is undefined in test env — we just confirm it's a different value
|
|
114
|
+
// than what production would resolve to (both may be undefined in tests, so verify open was called)
|
|
115
|
+
expect(mockXHR.open).toHaveBeenCalled();
|
|
116
|
+
expect(url).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should reject with an error on HTTP error status", async () => {
|
|
120
|
+
mockXHR.status = 401;
|
|
121
|
+
mockXHR.response = "";
|
|
122
|
+
|
|
123
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
124
|
+
|
|
125
|
+
const promise = fetchPricingData({ promotionId: "p1" });
|
|
126
|
+
mockXHR.onload!.call(mockXHR);
|
|
127
|
+
|
|
128
|
+
await expect(promise).rejects.toThrow("API request failed with status 401");
|
|
129
|
+
expect(consoleSpy).toHaveBeenCalledWith(ERROR_MSG, 401);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should reject with a parse error when response is not valid JSON", async () => {
|
|
133
|
+
mockXHR.status = 200;
|
|
134
|
+
mockXHR.response = "not-json";
|
|
135
|
+
|
|
136
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
137
|
+
|
|
138
|
+
const promise = fetchPricingData({ promotionId: "p1" });
|
|
139
|
+
mockXHR.onload!.call(mockXHR);
|
|
140
|
+
|
|
141
|
+
await expect(promise).rejects.toThrow("Failed to parse API response");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should reject with a network error on XHR onerror", async () => {
|
|
145
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
146
|
+
|
|
147
|
+
const promise = fetchPricingData({ promotionId: "p1" });
|
|
148
|
+
mockXHR.onerror!.call(mockXHR);
|
|
149
|
+
|
|
150
|
+
await expect(promise).rejects.toThrow("Network error occurred");
|
|
151
|
+
});
|
|
152
|
+
});
|