@meetelise/chat 1.55.0 → 1.55.2
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/dist/src/WebComponent/FeeCalculator/components/charge-inputs/charge-inputs.d.ts +3 -0
- package/dist/src/WebComponent/FeeCalculator/components/fee-calculator-layout/fee-calculator-layout.d.ts +4 -1
- package/dist/src/WebComponent/FeeCalculator/fee-calculator.d.ts +3 -0
- package/dist/src/WebComponent/FeeCalculator/model/marketable-fee.d.ts +2 -0
- package/dist/src/WebComponent/FeeCalculator/utils/date-utils.d.ts +9 -0
- package/dist/src/WebComponent/FeeCalculator/utils/pet-limit-utils.d.ts +10 -0
- package/dist/src/globals.d.ts +0 -1
- package/dist/src/services/fees/calculateQuote.d.ts +14 -1
- package/package.json +1 -1
- package/public/dist/index.js +324 -295
- package/src/WebComponent/FeeCalculator/components/charge-inputs/charge-inputs.ts +44 -10
- package/src/WebComponent/FeeCalculator/components/fee-calculator-layout/fee-calculator-layout-styles.ts +7 -0
- package/src/WebComponent/FeeCalculator/components/fee-calculator-layout/fee-calculator-layout.ts +32 -1
- package/src/WebComponent/FeeCalculator/components/floor-plan-selector/floor-plan-selector.ts +16 -6
- package/src/WebComponent/FeeCalculator/fee-calculator.ts +28 -13
- package/src/WebComponent/FeeCalculator/model/marketable-fee.ts +3 -0
- package/src/WebComponent/FeeCalculator/utils/date-utils.ts +17 -0
- package/src/WebComponent/FeeCalculator/utils/pet-limit-utils.ts +27 -0
- package/src/feeCalculatorMoveInDate.test.ts +113 -0
- package/src/feeCalculatorPetLimit.test.ts +346 -0
- package/src/globals.ts +0 -4
- package/src/services/fees/calculateQuote.ts +38 -3
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { expect } from "@esm-bundle/chai";
|
|
2
|
+
import { restore, useFakeXMLHttpRequest } from "sinon/pkg/sinon-esm";
|
|
3
|
+
import "../public/dist/index";
|
|
4
|
+
import type { ChargeInputs } from "./services/fees/calculateQuote";
|
|
5
|
+
import type { Unit } from "./services/fees/fetchBuildingUnits";
|
|
6
|
+
import type { ChargeInputsComponent } from "./WebComponent/FeeCalculator/components/charge-inputs/charge-inputs";
|
|
7
|
+
import type { FeeCalculatorLayout } from "./WebComponent/FeeCalculator/components/fee-calculator-layout/fee-calculator-layout";
|
|
8
|
+
import type { FeeCalculator } from "./WebComponent/FeeCalculator/fee-calculator";
|
|
9
|
+
import {
|
|
10
|
+
MarketableFeeCategory,
|
|
11
|
+
MarketableFeeModel,
|
|
12
|
+
} from "./WebComponent/FeeCalculator/model/marketable-fee";
|
|
13
|
+
import {
|
|
14
|
+
formatPetLimit,
|
|
15
|
+
getMaxPets,
|
|
16
|
+
} from "./WebComponent/FeeCalculator/utils/pet-limit-utils";
|
|
17
|
+
|
|
18
|
+
type FakeRequest = XMLHttpRequest & {
|
|
19
|
+
method: string;
|
|
20
|
+
requestBody?: string;
|
|
21
|
+
respond: (
|
|
22
|
+
status: number,
|
|
23
|
+
headers?: Record<string, string>,
|
|
24
|
+
body?: string
|
|
25
|
+
) => void;
|
|
26
|
+
url: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const jsonHeaders = { "Content-Type": "application/json" };
|
|
30
|
+
|
|
31
|
+
const marketableFeesResponse = {
|
|
32
|
+
fees: [
|
|
33
|
+
{
|
|
34
|
+
id: "pet-rent",
|
|
35
|
+
fee_name: "Pet Rent",
|
|
36
|
+
category: "PetRent",
|
|
37
|
+
frequency: "MONTHLY",
|
|
38
|
+
amount: 40,
|
|
39
|
+
limit: 2,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "pet-fee",
|
|
43
|
+
fee_name: "Pet Fee",
|
|
44
|
+
category: "PetFee",
|
|
45
|
+
frequency: "ONE_TIME",
|
|
46
|
+
amount: 400,
|
|
47
|
+
limit: 2,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "admin-fee",
|
|
51
|
+
fee_name: "Administrative Fee",
|
|
52
|
+
category: "Other",
|
|
53
|
+
frequency: "ONE_TIME",
|
|
54
|
+
amount: 500,
|
|
55
|
+
limit: null,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
rentable_items: [],
|
|
59
|
+
building_incentives: [],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const unitsResponse = [
|
|
63
|
+
{
|
|
64
|
+
id: "u1914038796",
|
|
65
|
+
unit_number: "2506",
|
|
66
|
+
number_of_bedrooms: 1,
|
|
67
|
+
number_of_bathrooms: 1,
|
|
68
|
+
square_footage: 698,
|
|
69
|
+
earliest_available: "2026-10-27",
|
|
70
|
+
starting_price: 2576,
|
|
71
|
+
max_price: 2598,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const unit = {
|
|
76
|
+
id: 1914038796,
|
|
77
|
+
unitNumber: "2506",
|
|
78
|
+
startingPrice: 2576,
|
|
79
|
+
maxPrice: 2598,
|
|
80
|
+
} as Unit;
|
|
81
|
+
|
|
82
|
+
const captureRequests = (): FakeRequest[] => {
|
|
83
|
+
const fakeXhr = useFakeXMLHttpRequest();
|
|
84
|
+
const requests: FakeRequest[] = [];
|
|
85
|
+
fakeXhr.onCreate = (request: FakeRequest) => requests.push(request);
|
|
86
|
+
return requests;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const waitFor = async (
|
|
90
|
+
isReady: () => boolean,
|
|
91
|
+
description: string
|
|
92
|
+
): Promise<void> => {
|
|
93
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
94
|
+
if (isReady()) return;
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Timed out waiting for ${description}`);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const findRequest = (
|
|
101
|
+
requests: FakeRequest[],
|
|
102
|
+
path: string
|
|
103
|
+
): FakeRequest | undefined =>
|
|
104
|
+
requests.find((request) => request.url.includes(path));
|
|
105
|
+
|
|
106
|
+
const getLayout = (calculator: FeeCalculator): FeeCalculatorLayout | null =>
|
|
107
|
+
calculator.shadowRoot?.querySelector("fee-calculator-layout") ?? null;
|
|
108
|
+
|
|
109
|
+
const getChargeInputs = (
|
|
110
|
+
calculator: FeeCalculator
|
|
111
|
+
): ChargeInputsComponent | null =>
|
|
112
|
+
getLayout(calculator)?.shadowRoot?.querySelector("charge-inputs") ?? null;
|
|
113
|
+
|
|
114
|
+
const getButton = (
|
|
115
|
+
calculator: FeeCalculator,
|
|
116
|
+
label: string
|
|
117
|
+
): HTMLButtonElement | null =>
|
|
118
|
+
getChargeInputs(calculator)?.shadowRoot?.querySelector<HTMLButtonElement>(
|
|
119
|
+
`button[aria-label="${label}"]`
|
|
120
|
+
) ?? null;
|
|
121
|
+
|
|
122
|
+
const getQuoteErrorText = (calculator: FeeCalculator): string | undefined =>
|
|
123
|
+
getLayout(calculator)
|
|
124
|
+
?.shadowRoot?.querySelector(".quote-error-message")
|
|
125
|
+
?.textContent?.trim();
|
|
126
|
+
|
|
127
|
+
const openCalculator = async (
|
|
128
|
+
requests: FakeRequest[]
|
|
129
|
+
): Promise<FeeCalculator> => {
|
|
130
|
+
const calculator = document.createElement("fee-calculator") as FeeCalculator;
|
|
131
|
+
calculator.buildingSlug = "test-building";
|
|
132
|
+
calculator.orgSlug = "test-org";
|
|
133
|
+
document.body.appendChild(calculator);
|
|
134
|
+
|
|
135
|
+
await waitFor(
|
|
136
|
+
() =>
|
|
137
|
+
Boolean(
|
|
138
|
+
findRequest(requests, "/api/pub/v1/organization/") &&
|
|
139
|
+
findRequest(requests, "/units") &&
|
|
140
|
+
findRequest(requests, "/marketable-fees")
|
|
141
|
+
),
|
|
142
|
+
"the calculator setup requests"
|
|
143
|
+
);
|
|
144
|
+
findRequest(requests, "/api/pub/v1/organization/")?.respond(
|
|
145
|
+
200,
|
|
146
|
+
jsonHeaders,
|
|
147
|
+
"{}"
|
|
148
|
+
);
|
|
149
|
+
findRequest(requests, "/units")?.respond(
|
|
150
|
+
200,
|
|
151
|
+
jsonHeaders,
|
|
152
|
+
JSON.stringify(unitsResponse)
|
|
153
|
+
);
|
|
154
|
+
findRequest(requests, "/marketable-fees")?.respond(
|
|
155
|
+
200,
|
|
156
|
+
jsonHeaders,
|
|
157
|
+
JSON.stringify(marketableFeesResponse)
|
|
158
|
+
);
|
|
159
|
+
return calculator;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const requestQuote = async (
|
|
163
|
+
calculator: FeeCalculator,
|
|
164
|
+
requests: FakeRequest[],
|
|
165
|
+
pets: Partial<ChargeInputs>
|
|
166
|
+
): Promise<FakeRequest> => {
|
|
167
|
+
calculator.handleChargeInputsChange(pets);
|
|
168
|
+
calculator.handleUnitSelect(unit);
|
|
169
|
+
await waitFor(
|
|
170
|
+
() => Boolean(findRequest(requests, "/quote/calculate")),
|
|
171
|
+
"the quote request"
|
|
172
|
+
);
|
|
173
|
+
return findRequest(requests, "/quote/calculate") as FakeRequest;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const quoteGeneratedEvents = () =>
|
|
177
|
+
(window.dataLayer ?? []).filter(
|
|
178
|
+
(event) => event.event === "feeCalculatorQuoteGenerated"
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const fee = (category: MarketableFeeCategory, limit: number | null) =>
|
|
182
|
+
new MarketableFeeModel({ category, limit });
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
restore();
|
|
186
|
+
document.querySelectorAll("fee-calculator").forEach((el) => el.remove());
|
|
187
|
+
window.dataLayer = [];
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("caps pets at the lowest limit across pet fees", () => {
|
|
191
|
+
const fees = [
|
|
192
|
+
fee(MarketableFeeCategory.PetRent, 3),
|
|
193
|
+
fee(MarketableFeeCategory.PetFee, 2),
|
|
194
|
+
fee(MarketableFeeCategory.PetDeposit, null),
|
|
195
|
+
fee(MarketableFeeCategory.Parking, 1),
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
expect(getMaxPets(fees)).to.equal(2);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("has no pet cap when no pet fee has a limit", () => {
|
|
202
|
+
const fees = [
|
|
203
|
+
fee(MarketableFeeCategory.PetRent, null),
|
|
204
|
+
fee(MarketableFeeCategory.Parking, 1),
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
expect(getMaxPets(fees)).to.equal(null);
|
|
208
|
+
expect(getMaxPets([])).to.equal(null);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("describes the pet limit", () => {
|
|
212
|
+
expect(formatPetLimit(1)).to.equal("Up to 1 pet allowed");
|
|
213
|
+
expect(formatPetLimit(2)).to.equal("Up to 2 pets allowed");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("stops adding pets at the lowest pet fee limit", async () => {
|
|
217
|
+
const requests = captureRequests();
|
|
218
|
+
const calculator = await openCalculator(requests);
|
|
219
|
+
|
|
220
|
+
await waitFor(
|
|
221
|
+
() =>
|
|
222
|
+
getChargeInputs(calculator)
|
|
223
|
+
?.shadowRoot?.querySelector(".charge-input-note")
|
|
224
|
+
?.textContent?.trim() === "Up to 2 pets allowed",
|
|
225
|
+
"the pet limit note"
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
calculator.handleChargeInputsChange({
|
|
229
|
+
num_cats: 1,
|
|
230
|
+
num_dogs: 1,
|
|
231
|
+
num_pets: 2,
|
|
232
|
+
});
|
|
233
|
+
await waitFor(
|
|
234
|
+
() => Boolean(getButton(calculator, "Increase Dogs")?.disabled),
|
|
235
|
+
"the pet steppers to stop at the limit"
|
|
236
|
+
);
|
|
237
|
+
expect(getButton(calculator, "Increase Cats")?.disabled).to.equal(true);
|
|
238
|
+
expect(getButton(calculator, "Increase All Other Pets")?.disabled).to.equal(
|
|
239
|
+
true
|
|
240
|
+
);
|
|
241
|
+
expect(getButton(calculator, "Increase Applicants")?.disabled).to.equal(
|
|
242
|
+
false
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
getButton(calculator, "Decrease Dogs")?.click();
|
|
246
|
+
await waitFor(
|
|
247
|
+
() => getButton(calculator, "Increase Dogs")?.disabled === false,
|
|
248
|
+
"the pet steppers to allow another pet"
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("explains the pet limit when the quote is rejected for too many pets", async () => {
|
|
253
|
+
const requests = captureRequests();
|
|
254
|
+
const calculator = await openCalculator(requests);
|
|
255
|
+
const quoteRequest = await requestQuote(calculator, requests, {
|
|
256
|
+
num_dogs: 3,
|
|
257
|
+
num_pets: 3,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(JSON.parse(quoteRequest.requestBody ?? "{}").charge_inputs).to.include(
|
|
261
|
+
{ num_dogs: 3 }
|
|
262
|
+
);
|
|
263
|
+
quoteRequest.respond(
|
|
264
|
+
422,
|
|
265
|
+
jsonHeaders,
|
|
266
|
+
JSON.stringify({
|
|
267
|
+
detail: [
|
|
268
|
+
{ fee_id: "pet-rent", fee_name: "Pet Rent", item_count: 3, limit: 2 },
|
|
269
|
+
{ fee_id: "pet-fee", fee_name: "Pet Fee", item_count: 3, limit: 2 },
|
|
270
|
+
],
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await waitFor(
|
|
275
|
+
() => Boolean(getQuoteErrorText(calculator)),
|
|
276
|
+
"the pet limit message"
|
|
277
|
+
);
|
|
278
|
+
expect(getQuoteErrorText(calculator)).to.equal(
|
|
279
|
+
"Up to 2 pets allowed. Remove a pet to see your estimate."
|
|
280
|
+
);
|
|
281
|
+
expect(quoteGeneratedEvents()).to.have.length(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("shows a general message when the quote fails for another reason", async () => {
|
|
285
|
+
const requests = captureRequests();
|
|
286
|
+
const calculator = await openCalculator(requests);
|
|
287
|
+
const quoteRequest = await requestQuote(calculator, requests, {
|
|
288
|
+
num_dogs: 1,
|
|
289
|
+
num_pets: 1,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
quoteRequest.respond(
|
|
293
|
+
422,
|
|
294
|
+
jsonHeaders,
|
|
295
|
+
JSON.stringify({
|
|
296
|
+
detail: [
|
|
297
|
+
{
|
|
298
|
+
loc: ["body", "move_in_date"],
|
|
299
|
+
msg: "move_in_date must be in the future",
|
|
300
|
+
type: "value_error",
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
})
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
await waitFor(
|
|
307
|
+
() => Boolean(getQuoteErrorText(calculator)),
|
|
308
|
+
"the quote error message"
|
|
309
|
+
);
|
|
310
|
+
expect(getQuoteErrorText(calculator)).to.equal(
|
|
311
|
+
"We couldn't calculate an estimate. Please adjust your selections or try again later."
|
|
312
|
+
);
|
|
313
|
+
expect(quoteGeneratedEvents()).to.have.length(0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("shows the quote when it is within the pet limit", async () => {
|
|
317
|
+
const requests = captureRequests();
|
|
318
|
+
const calculator = await openCalculator(requests);
|
|
319
|
+
const quoteRequest = await requestQuote(calculator, requests, {
|
|
320
|
+
num_dogs: 2,
|
|
321
|
+
num_pets: 2,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
quoteRequest.respond(
|
|
325
|
+
200,
|
|
326
|
+
jsonHeaders,
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
quote: {
|
|
329
|
+
rent_cost: 2598,
|
|
330
|
+
fee_quotes: [],
|
|
331
|
+
total_monthly_cost: { fixed_value: 2682.24 },
|
|
332
|
+
},
|
|
333
|
+
pdf_url: "https://example.com/quote.pdf",
|
|
334
|
+
})
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await waitFor(
|
|
338
|
+
() =>
|
|
339
|
+
getLayout(calculator)
|
|
340
|
+
?.shadowRoot?.querySelector(".total-cost-amount")
|
|
341
|
+
?.textContent?.trim() === "$2,682.24",
|
|
342
|
+
"the quote total"
|
|
343
|
+
);
|
|
344
|
+
expect(getQuoteErrorText(calculator)).to.equal(undefined);
|
|
345
|
+
expect(quoteGeneratedEvents()).to.have.length(1);
|
|
346
|
+
});
|
package/src/globals.ts
CHANGED
|
@@ -26,13 +26,39 @@ type CalculateQuoteRequest = {
|
|
|
26
26
|
moveInDate: string;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
export type FeeLimitViolation = {
|
|
30
|
+
feeId: string | null;
|
|
31
|
+
feeName: string;
|
|
32
|
+
itemCount: number;
|
|
33
|
+
limit: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type QuoteError =
|
|
37
|
+
| { type: "feeLimit"; violations: FeeLimitViolation[] }
|
|
38
|
+
| { type: "unknown" };
|
|
39
|
+
|
|
29
40
|
export type CalculateQuoteResponse = {
|
|
30
|
-
quote: Quote;
|
|
41
|
+
quote: Quote | null;
|
|
31
42
|
pdfUrl?: string;
|
|
43
|
+
error?: QuoteError;
|
|
32
44
|
};
|
|
33
45
|
|
|
34
46
|
const BASE_URL = BASE_DOMAIN;
|
|
35
47
|
|
|
48
|
+
// Request-validation 422s also send a detail list, but only fee limit violations carry a limit.
|
|
49
|
+
const getFeeLimitViolations = (
|
|
50
|
+
error: AxiosError<{ detail?: unknown }>
|
|
51
|
+
): FeeLimitViolation[] | null => {
|
|
52
|
+
const detail = error.response?.data?.detail;
|
|
53
|
+
if (error.response?.status !== 422 || !Array.isArray(detail)) return null;
|
|
54
|
+
|
|
55
|
+
const violations = detail.map((item) => camelize<FeeLimitViolation>(item));
|
|
56
|
+
const isFeeLimitError =
|
|
57
|
+
violations.length > 0 &&
|
|
58
|
+
violations.every((violation) => typeof violation.limit === "number");
|
|
59
|
+
return isFeeLimitError ? violations : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
36
62
|
export const calculateQuote = async (
|
|
37
63
|
{
|
|
38
64
|
buildingSlug,
|
|
@@ -62,7 +88,7 @@ export const calculateQuote = async (
|
|
|
62
88
|
return data;
|
|
63
89
|
}
|
|
64
90
|
} catch (error) {
|
|
65
|
-
const err = error as AxiosError
|
|
91
|
+
const err = error as AxiosError<{ detail?: unknown }>;
|
|
66
92
|
|
|
67
93
|
// Canceled requests were initiated by the frontend and are expected
|
|
68
94
|
if (err.code !== "ERR_CANCELED") {
|
|
@@ -74,10 +100,19 @@ export const calculateQuote = async (
|
|
|
74
100
|
error,
|
|
75
101
|
});
|
|
76
102
|
}
|
|
103
|
+
|
|
104
|
+
const violations = getFeeLimitViolations(err);
|
|
105
|
+
return {
|
|
106
|
+
quote: null,
|
|
107
|
+
error: violations
|
|
108
|
+
? { type: "feeLimit", violations }
|
|
109
|
+
: { type: "unknown" },
|
|
110
|
+
};
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
return {
|
|
80
|
-
quote:
|
|
114
|
+
quote: null,
|
|
115
|
+
error: { type: "unknown" },
|
|
81
116
|
};
|
|
82
117
|
};
|
|
83
118
|
|