pricing-renderer 0.1.0 → 0.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/CHANGELOG.md +133 -0
- package/README.md +142 -25
- package/THIRD_PARTY_NOTICES.md +84 -6
- package/custom-elements.json +233 -3
- package/dist/core/apply-selection.d.ts +8 -0
- package/dist/core/apply-selection.d.ts.map +1 -0
- package/dist/core/apply-selection.js +76 -0
- package/dist/core/apply-selection.js.map +1 -0
- package/dist/core/breakdown.d.ts +13 -0
- package/dist/core/breakdown.d.ts.map +1 -0
- package/dist/core/breakdown.js +56 -0
- package/dist/core/breakdown.js.map +1 -0
- package/dist/core/compare-sides.d.ts +16 -0
- package/dist/core/compare-sides.d.ts.map +1 -0
- package/dist/core/compare-sides.js +73 -0
- package/dist/core/compare-sides.js.map +1 -0
- package/dist/core/compare-values.d.ts +13 -0
- package/dist/core/compare-values.d.ts.map +1 -0
- package/dist/core/compare-values.js +100 -0
- package/dist/core/compare-values.js.map +1 -0
- package/dist/core/compare.d.ts +7 -0
- package/dist/core/compare.d.ts.map +1 -0
- package/dist/core/compare.js +301 -0
- package/dist/core/compare.js.map +1 -0
- package/dist/core/expression.js +2 -2
- package/dist/core/expression.js.map +1 -1
- package/dist/core/i18n.d.ts.map +1 -1
- package/dist/core/i18n.js +136 -2
- package/dist/core/i18n.js.map +1 -1
- package/dist/core/index.d.ts +9 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +6 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/normalize.d.ts.map +1 -1
- package/dist/core/normalize.js +70 -9
- package/dist/core/normalize.js.map +1 -1
- package/dist/core/plan-relation.d.ts +8 -0
- package/dist/core/plan-relation.d.ts.map +1 -0
- package/dist/core/plan-relation.js +15 -0
- package/dist/core/plan-relation.js.map +1 -0
- package/dist/core/price-stabilizer.d.ts +3 -0
- package/dist/core/price-stabilizer.d.ts.map +1 -0
- package/dist/core/price-stabilizer.js +73 -0
- package/dist/core/price-stabilizer.js.map +1 -0
- package/dist/core/resolve.d.ts +7 -1
- package/dist/core/resolve.d.ts.map +1 -1
- package/dist/core/resolve.js +66 -21
- package/dist/core/resolve.js.map +1 -1
- package/dist/core/search-params.d.ts +15 -0
- package/dist/core/search-params.d.ts.map +1 -0
- package/dist/core/search-params.js +175 -0
- package/dist/core/search-params.js.map +1 -0
- package/dist/core/selection-change.d.ts +10 -0
- package/dist/core/selection-change.d.ts.map +1 -0
- package/dist/core/selection-change.js +124 -0
- package/dist/core/selection-change.js.map +1 -0
- package/dist/core/types.d.ts +209 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/units.d.ts +13 -0
- package/dist/core/units.d.ts.map +1 -0
- package/dist/core/units.js +84 -0
- package/dist/core/units.js.map +1 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/core/utils.js +91 -32
- package/dist/core/utils.js.map +1 -1
- package/dist/core/view-model.d.ts +3 -1
- package/dist/core/view-model.d.ts.map +1 -1
- package/dist/core/view-model.js +113 -7
- package/dist/core/view-model.js.map +1 -1
- package/dist/element/change-review-define.d.ts +4 -0
- package/dist/element/change-review-define.d.ts.map +1 -0
- package/dist/element/change-review-define.js +17 -0
- package/dist/element/change-review-define.js.map +1 -0
- package/dist/element/change-review-element.d.ts +53 -0
- package/dist/element/change-review-element.d.ts.map +1 -0
- package/dist/element/change-review-element.js +104 -0
- package/dist/element/change-review-element.js.map +1 -0
- package/dist/element/change-review-events.d.ts +10 -0
- package/dist/element/change-review-events.d.ts.map +1 -0
- package/dist/element/change-review-events.js +2 -0
- package/dist/element/change-review-events.js.map +1 -0
- package/dist/element/change-review-renderers.d.ts +23 -0
- package/dist/element/change-review-renderers.d.ts.map +1 -0
- package/dist/element/change-review-renderers.js +391 -0
- package/dist/element/change-review-renderers.js.map +1 -0
- package/dist/element/cta.d.ts +45 -0
- package/dist/element/cta.d.ts.map +1 -0
- package/dist/element/cta.js +90 -0
- package/dist/element/cta.js.map +1 -0
- package/dist/element/current-selection.d.ts +13 -0
- package/dist/element/current-selection.d.ts.map +1 -0
- package/dist/element/current-selection.js +57 -0
- package/dist/element/current-selection.js.map +1 -0
- package/dist/element/define-change-review.d.ts +2 -0
- package/dist/element/define-change-review.d.ts.map +1 -0
- package/dist/element/define-change-review.js +7 -0
- package/dist/element/define-change-review.js.map +1 -0
- package/dist/element/events.d.ts +32 -6
- package/dist/element/events.d.ts.map +1 -1
- package/dist/element/hide-sections.d.ts +6 -0
- package/dist/element/hide-sections.d.ts.map +1 -0
- package/dist/element/hide-sections.js +26 -0
- package/dist/element/hide-sections.js.map +1 -0
- package/dist/element/index.d.ts +8 -1
- package/dist/element/index.d.ts.map +1 -1
- package/dist/element/index.js +3 -0
- package/dist/element/index.js.map +1 -1
- package/dist/element/pricing-renderer-element.d.ts +95 -7
- package/dist/element/pricing-renderer-element.d.ts.map +1 -1
- package/dist/element/pricing-renderer-element.js +563 -217
- package/dist/element/pricing-renderer-element.js.map +1 -1
- package/dist/element/renderers.d.ts +49 -4
- package/dist/element/renderers.d.ts.map +1 -1
- package/dist/element/renderers.js +795 -263
- package/dist/element/renderers.js.map +1 -1
- package/dist/element/same-input.d.ts +3 -0
- package/dist/element/same-input.d.ts.map +1 -0
- package/dist/element/same-input.js +56 -0
- package/dist/element/same-input.js.map +1 -0
- package/dist/react/headless.d.ts +9 -0
- package/dist/react/headless.d.ts.map +1 -0
- package/dist/react/headless.js +8 -0
- package/dist/react/headless.js.map +1 -0
- package/dist/react/index.d.ts +54 -4
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +156 -6
- package/dist/react/index.js.map +1 -1
- package/dist/react/provider.d.ts +17 -0
- package/dist/react/provider.d.ts.map +1 -0
- package/dist/react/provider.js +38 -0
- package/dist/react/provider.js.map +1 -0
- package/dist/react/selection-store.d.ts +51 -0
- package/dist/react/selection-store.d.ts.map +1 -0
- package/dist/react/selection-store.js +239 -0
- package/dist/react/selection-store.js.map +1 -0
- package/dist/react/slots-types.d.ts +38 -0
- package/dist/react/slots-types.d.ts.map +1 -0
- package/dist/react/slots-types.js +2 -0
- package/dist/react/slots-types.js.map +1 -0
- package/dist/react/slots.d.ts +40 -0
- package/dist/react/slots.d.ts.map +1 -0
- package/dist/react/slots.js +165 -0
- package/dist/react/slots.js.map +1 -0
- package/dist/react/use-pricing-selection.d.ts +10 -0
- package/dist/react/use-pricing-selection.d.ts.map +1 -0
- package/dist/react/use-pricing-selection.js +44 -0
- package/dist/react/use-pricing-selection.js.map +1 -0
- package/dist/react/use-pricing-source.d.ts +34 -0
- package/dist/react/use-pricing-source.d.ts.map +1 -0
- package/dist/react/use-pricing-source.js +186 -0
- package/dist/react/use-pricing-source.js.map +1 -0
- package/dist/react/use-renderer-state.d.ts +22 -0
- package/dist/react/use-renderer-state.d.ts.map +1 -0
- package/dist/react/use-renderer-state.js +112 -0
- package/dist/react/use-renderer-state.js.map +1 -0
- package/dist/space/index.d.ts +26 -0
- package/dist/space/index.d.ts.map +1 -0
- package/dist/space/index.js +71 -0
- package/dist/space/index.js.map +1 -0
- package/dist/styles/base.css +1030 -76
- package/dist/styles/theme-minimal.css +103 -0
- package/dist/styles/theme.css +22 -3
- package/dist/styles/tokens-shadcn.css +40 -0
- package/dist/yaml/index.d.ts +2 -0
- package/dist/yaml/index.d.ts.map +1 -1
- package/dist/yaml/index.js +1 -0
- package/dist/yaml/index.js.map +1 -1
- package/dist/yaml/load.d.ts.map +1 -1
- package/dist/yaml/load.js +106 -17
- package/dist/yaml/load.js.map +1 -1
- package/dist/yaml/source.d.ts +18 -0
- package/dist/yaml/source.d.ts.map +1 -0
- package/dist/yaml/source.js +46 -0
- package/dist/yaml/source.js.map +1 -0
- package/package.json +25 -13
|
@@ -1,27 +1,127 @@
|
|
|
1
|
+
var _a;
|
|
1
2
|
import { LitElement, html, nothing } from 'lit';
|
|
2
|
-
import { cloneValue, createPricingViewModel, getPricingRendererConfig, mergePresentationOptions, mergeSelection,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { applySelectionChange, cloneValue, compareSelections, createPriceStabilizer, createPricingViewModel, deepEqual, getMessages, getPricingRendererConfig, mergePresentationOptions, mergeSelection, planSelectionChange, reconcileSelection, selectionInEffect, selectionWithAddOnQuantity, selectionWithVariable, translate, } from '../core/index.js';
|
|
4
|
+
import { applySelectionInput } from '../core/apply-selection.js';
|
|
5
|
+
import { planCtaView, summaryActionState } from './cta.js';
|
|
6
|
+
import { currentSelectionDiagnostics, deriveCurrentContext } from './current-selection.js';
|
|
7
|
+
import { inputChanged } from './same-input.js';
|
|
8
|
+
import { loadPricingSource, } from '../yaml/index.js';
|
|
9
|
+
import { pricingSourceError } from '../yaml/source.js';
|
|
10
|
+
import { comparisonRowTotal, DEFAULT_ADDON_FILTER_THRESHOLD, DEFAULT_TOOLBAR_THRESHOLD, renderAddOns, renderBilling, renderCatalogMetadata, renderComparison, renderErrorSlotShell, renderHeader, renderLoadingSlotShell, renderPendingDialog, renderPlans, renderSummary, renderVariables, resolveOnlyDifferences, toolbarThresholdOf, addOnFilterThresholdOf, instanceId, heroTitle, PRICING_SECTION_NAMES, PRICING_SLOT_NAMES, } from './renderers.js';
|
|
11
|
+
import { hiddenNamesOf, sectionsConverter } from './hide-sections.js';
|
|
12
|
+
// Everything after the plans lives in one flex column, so a sticky summary has the whole column as
|
|
13
|
+
// its containing block (a grid item would only have its own row).
|
|
14
|
+
//
|
|
15
|
+
// The summary never sits inside the add-on layout, which is where a pricing with add-ons used to
|
|
16
|
+
// put it. A sticky element is only pinned while its own containing block is on screen, and that
|
|
17
|
+
// layout is one section near the end of the page: the bottom bar it is meant to be existed only
|
|
18
|
+
// after the reader had already scrolled past the comparison and the add-on grid -- furthest away
|
|
19
|
+
// exactly when add-ons are what move the total.
|
|
20
|
+
//
|
|
21
|
+
// At 840px or more, outside the compact layout and with a plan selected, it is the first child of
|
|
22
|
+
// the column and sticks to its top. Otherwise it closes the content, outside the column, where its
|
|
23
|
+
// containing block is everything after the plans and the bottom bar is pinned for that whole scroll.
|
|
24
|
+
export function renderAfterPlans(state, wide = true) {
|
|
25
|
+
const { selection, compact } = state;
|
|
26
|
+
const hidden = state.hiddenSections;
|
|
27
|
+
const summaryHidden = Boolean(hidden?.has('summary'));
|
|
28
|
+
const docked = !compact && wide && !summaryHidden && selection.planId !== undefined;
|
|
29
|
+
const summary = summaryHidden ? nothing : renderSummary(state);
|
|
30
|
+
// The add-on layout never holds the summary now, whatever the width.
|
|
31
|
+
const inner = { ...state, summaryOutsideAddOns: true };
|
|
32
|
+
const parts = [
|
|
33
|
+
docked ? summary : nothing,
|
|
34
|
+
hidden?.has('comparison') ? nothing : renderComparison(inner),
|
|
35
|
+
renderAddOns(inner),
|
|
36
|
+
];
|
|
37
|
+
return html `<div class="pr-flow">${parts}</div>
|
|
38
|
+
${docked ? nothing : summary}`;
|
|
39
|
+
}
|
|
40
|
+
const breakdownToggleSelector = '[data-pr-part="breakdown-toggle"]';
|
|
41
|
+
const comparisonRowSelector = '[data-pr-part="comparison-row"]';
|
|
42
|
+
const addOnCardSelector = '[data-pr-part="add-on-card"]';
|
|
43
|
+
const breakdownPanelSelector = '[data-pr-part="breakdown"]';
|
|
44
|
+
let breakdownPanelCount = 0;
|
|
45
|
+
// the sentence is chosen by the first error diagnostic.
|
|
46
|
+
// The code prefix is cut off so that no partial code is spelled out: the docs test treats every
|
|
47
|
+
// full code token of the source as a code to document.
|
|
48
|
+
function errorMessageKey(diagnostics) {
|
|
49
|
+
const first = diagnostics.find((item) => item.severity === 'error');
|
|
50
|
+
const name = (first?.code ?? '').replace(/^PR_/, '');
|
|
51
|
+
if (name.startsWith('SOURCE_') || name === 'FETCH_UNAVAILABLE')
|
|
52
|
+
return 'pricing.errorSource';
|
|
53
|
+
if (name.startsWith('YAML_') || name === 'INPUT_INVALID')
|
|
54
|
+
return 'pricing.errorFormat';
|
|
55
|
+
if (name.startsWith('VERSION_'))
|
|
56
|
+
return 'pricing.errorVersion';
|
|
57
|
+
return 'pricing.errorGeneric';
|
|
58
|
+
}
|
|
59
|
+
// absent is automatic, `false` is off, anything else is on
|
|
60
|
+
export const onlyDifferencesConverter = {
|
|
61
|
+
fromAttribute(value) {
|
|
62
|
+
if (value === null)
|
|
63
|
+
return undefined;
|
|
64
|
+
return value !== 'false';
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
// Whitespace separated; only tokens that exactly match a section name are kept, in order.
|
|
68
|
+
export const hideSectionsConverter = sectionsConverter(PRICING_SECTION_NAMES);
|
|
69
|
+
// A value that is not an array hides nothing, and neither does an item that is not a section name.
|
|
70
|
+
function hiddenSectionsOf(value) {
|
|
71
|
+
return hiddenNamesOf(PRICING_SECTION_NAMES, value);
|
|
72
|
+
}
|
|
73
|
+
// A value that is not an array requests nothing, and neither does an item that is not a slot name.
|
|
74
|
+
function requestedSlotsOf(value) {
|
|
75
|
+
const names = new Set();
|
|
76
|
+
if (!Array.isArray(value))
|
|
77
|
+
return names;
|
|
78
|
+
for (const item of value) {
|
|
79
|
+
if (PRICING_SLOT_NAMES.includes(item)) {
|
|
80
|
+
names.add(item);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
const slotNodeSelector = '[data-pr-slot]';
|
|
86
|
+
const slotOwnerSelector = '[data-pr-root], [data-pr-slot]';
|
|
5
87
|
export class PricingRendererElement extends LitElement {
|
|
6
88
|
constructor() {
|
|
7
89
|
super();
|
|
90
|
+
this._lastSlotsDispatch = { nodes: [], data: [] };
|
|
8
91
|
this._status = 'idle';
|
|
9
92
|
this._diagnostics = [];
|
|
10
93
|
this._compact = true;
|
|
94
|
+
this._wide = false;
|
|
11
95
|
this._search = '';
|
|
12
|
-
this._onlyDifferences = false;
|
|
13
96
|
this._expandedGroups = new Set();
|
|
14
97
|
this._revealedGroups = new Set();
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
98
|
+
this._addOnSearch = '';
|
|
99
|
+
this._addOnsRevealed = false;
|
|
100
|
+
// every view model the element builds goes
|
|
101
|
+
// through the stabilizer, which keeps the last valid prices and folds the subtotal from the
|
|
102
|
+
// breakdown, so a removed add-on has no line and no share of the subtotal
|
|
103
|
+
this._priceStabilizer = createPriceStabilizer();
|
|
17
104
|
this._loadSequence = 0;
|
|
18
105
|
this._loadedOnce = false;
|
|
19
106
|
this._reloadOnPropertyChange = false;
|
|
107
|
+
this._breakdownOpen = false;
|
|
108
|
+
// the element renders in light DOM, so ids are global
|
|
109
|
+
this._instanceNumber = ++breakdownPanelCount;
|
|
110
|
+
this._breakdownId = `pr-breakdown-${this._instanceNumber}`;
|
|
111
|
+
// the prefix of every other id of this instance
|
|
112
|
+
this._idPrefix = `pr-${this._instanceNumber}`;
|
|
20
113
|
this._selectPlan = (planId) => {
|
|
114
|
+
if (!this._normalized)
|
|
115
|
+
return;
|
|
21
116
|
const selection = this._effectiveSelection();
|
|
22
117
|
if (!selection)
|
|
23
118
|
return;
|
|
24
|
-
this.
|
|
119
|
+
const change = planSelectionChange(this._normalized, selection, { type: 'plan', planId });
|
|
120
|
+
if (change.kind === 'apply') {
|
|
121
|
+
this._setSelection(change.selection);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this._openChangeDialog(change.pending);
|
|
25
125
|
};
|
|
26
126
|
this._selectBilling = (billingPeriod) => {
|
|
27
127
|
const selection = this._effectiveSelection();
|
|
@@ -33,9 +133,7 @@ export class PricingRendererElement extends LitElement {
|
|
|
33
133
|
const selection = this._effectiveSelection();
|
|
34
134
|
if (!selection)
|
|
35
135
|
return;
|
|
36
|
-
|
|
37
|
-
setAtPath(variables, path, value);
|
|
38
|
-
this._setSelection({ ...selection, variables });
|
|
136
|
+
this._setSelection(selectionWithVariable(selection, path, value));
|
|
39
137
|
};
|
|
40
138
|
this._resetVariables = () => {
|
|
41
139
|
const selection = this._effectiveSelection();
|
|
@@ -43,48 +141,29 @@ export class PricingRendererElement extends LitElement {
|
|
|
43
141
|
return;
|
|
44
142
|
this._setSelection({ ...selection, variables: cloneValue(this._normalized.variables) });
|
|
45
143
|
};
|
|
144
|
+
// the dependencies to add are the transitive closure of
|
|
145
|
+
// dependsOn, so a two-level chain is added whole
|
|
46
146
|
this._toggleAddOn = (addOnId, selected) => {
|
|
47
147
|
if (!this._normalized)
|
|
48
148
|
return;
|
|
49
149
|
const selection = this._effectiveSelection();
|
|
50
150
|
if (!selection)
|
|
51
151
|
return;
|
|
52
|
-
|
|
53
|
-
if (!
|
|
152
|
+
// an unknown add-on changes nothing and dispatches no event
|
|
153
|
+
if (!this._normalized.addOns.some((candidate) => candidate.id === addOnId))
|
|
154
|
+
return;
|
|
155
|
+
const change = planSelectionChange(this._normalized, selection, {
|
|
156
|
+
type: 'addOn',
|
|
157
|
+
addOnId,
|
|
158
|
+
selected,
|
|
159
|
+
});
|
|
160
|
+
if (change.kind === 'confirm') {
|
|
161
|
+
this._openChangeDialog(change.pending);
|
|
54
162
|
return;
|
|
55
|
-
let addIds = [];
|
|
56
|
-
let removeIds = [];
|
|
57
|
-
if (selected) {
|
|
58
|
-
addIds = [...this._collectDependencies(addOnId)].filter((id) => !selection.addOns[id]?.selected);
|
|
59
|
-
const conflicts = new Set(addOn.excludes);
|
|
60
|
-
for (const candidate of this._normalized.addOns) {
|
|
61
|
-
if (candidate.excludes.includes(addOnId))
|
|
62
|
-
conflicts.add(candidate.id);
|
|
63
|
-
}
|
|
64
|
-
for (const dependency of addIds) {
|
|
65
|
-
const item = this._normalized.addOns.find((candidate) => candidate.id === dependency);
|
|
66
|
-
item?.excludes.forEach((id) => conflicts.add(id));
|
|
67
|
-
}
|
|
68
|
-
removeIds = [...conflicts].filter((id) => selection.addOns[id]?.selected);
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
removeIds = this._selectedDependents(addOnId);
|
|
72
|
-
}
|
|
73
|
-
const pending = { addOnId, select: selected, addIds, removeIds };
|
|
74
|
-
if (addIds.length || removeIds.length) {
|
|
75
|
-
this._pendingAddOnChange = pending;
|
|
76
|
-
this._previousFocus =
|
|
77
|
-
this.ownerDocument.activeElement instanceof HTMLElement
|
|
78
|
-
? this.ownerDocument.activeElement
|
|
79
|
-
: undefined;
|
|
80
|
-
this.requestUpdate();
|
|
81
|
-
void this.updateComplete.then(() => {
|
|
82
|
-
this.querySelector('.pr-dialog button[autofocus]')?.focus();
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
this._applyAddOnChange(pending);
|
|
87
163
|
}
|
|
164
|
+
this._pendingAddOnChange = undefined;
|
|
165
|
+
this._setSelection(change.selection);
|
|
166
|
+
void this.updateComplete.then(() => this._restoreDialogFocus());
|
|
88
167
|
};
|
|
89
168
|
this._updateAddOnQuantity = (addOnId, quantity) => {
|
|
90
169
|
if (!this._normalized)
|
|
@@ -92,19 +171,7 @@ export class PricingRendererElement extends LitElement {
|
|
|
92
171
|
const selection = this._effectiveSelection();
|
|
93
172
|
if (!selection)
|
|
94
173
|
return;
|
|
95
|
-
|
|
96
|
-
const normalized = normalizeAddOnQuantity(this._normalized, addOnId, {
|
|
97
|
-
...current,
|
|
98
|
-
selected: true,
|
|
99
|
-
quantity,
|
|
100
|
-
});
|
|
101
|
-
this._setSelection({
|
|
102
|
-
...selection,
|
|
103
|
-
addOns: {
|
|
104
|
-
...selection.addOns,
|
|
105
|
-
[addOnId]: { selected: true, quantity: normalized },
|
|
106
|
-
},
|
|
107
|
-
});
|
|
174
|
+
this._setSelection(selectionWithAddOnQuantity(this._normalized, selection, addOnId, quantity));
|
|
108
175
|
};
|
|
109
176
|
this._confirmAddOnChange = () => {
|
|
110
177
|
if (this._pendingAddOnChange) {
|
|
@@ -116,17 +183,23 @@ export class PricingRendererElement extends LitElement {
|
|
|
116
183
|
this.requestUpdate();
|
|
117
184
|
void this.updateComplete.then(() => this._restoreDialogFocus());
|
|
118
185
|
};
|
|
119
|
-
this._triggerAction = (actionId, planId, href, metadata) => {
|
|
120
|
-
|
|
121
|
-
if (
|
|
186
|
+
this._triggerAction = (actionId, planId, href, metadata, origin) => {
|
|
187
|
+
// a link CTA sees `false` and cancels its navigation
|
|
188
|
+
if (this.pendingActionId)
|
|
122
189
|
return false;
|
|
190
|
+
const current = this._viewModel();
|
|
191
|
+
if (!current || !this._normalized)
|
|
192
|
+
return false;
|
|
193
|
+
const reconciled = reconcileSelection(this._normalized, current.resolved.selection).selection;
|
|
194
|
+
const viewModel = this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, reconciled, { ...this._viewModelOptions() }));
|
|
123
195
|
const detail = {
|
|
124
196
|
actionId,
|
|
125
197
|
...(planId ? { planId } : {}),
|
|
126
198
|
...(href ? { href } : {}),
|
|
127
|
-
selection:
|
|
199
|
+
selection: reconciled,
|
|
128
200
|
resolved: viewModel.resolved,
|
|
129
201
|
...(metadata ? { metadata } : {}),
|
|
202
|
+
...(origin ? { relation: origin.relation, source: origin.source } : {}),
|
|
130
203
|
};
|
|
131
204
|
const event = new CustomEvent('pricing-action', {
|
|
132
205
|
detail,
|
|
@@ -152,13 +225,30 @@ export class PricingRendererElement extends LitElement {
|
|
|
152
225
|
: new Set();
|
|
153
226
|
this.requestUpdate();
|
|
154
227
|
};
|
|
155
|
-
|
|
228
|
+
// focus moves to the first row the reveal adds
|
|
229
|
+
this._revealGroup = (groupId, origin, byKeyboard = false) => {
|
|
230
|
+
const container = origin?.closest('tbody, .pr-comparison-group') ?? undefined;
|
|
231
|
+
const before = container?.querySelectorAll(comparisonRowSelector).length ?? 0;
|
|
156
232
|
this._revealedGroups = new Set([...this._revealedGroups, groupId]);
|
|
157
233
|
this.requestUpdate();
|
|
234
|
+
if (!container)
|
|
235
|
+
return;
|
|
236
|
+
void this.updateComplete.then(() => {
|
|
237
|
+
const rows = container.querySelectorAll(comparisonRowSelector);
|
|
238
|
+
if (rows.length > before)
|
|
239
|
+
this._focusRevealed(rows[before], byKeyboard);
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
// no selection change resets the open state
|
|
243
|
+
this._toggleBreakdown = () => {
|
|
244
|
+
this._breakdownOpen = !this._breakdownOpen;
|
|
245
|
+
this.requestUpdate();
|
|
158
246
|
};
|
|
159
247
|
this._handleDialogKeyboard = (event) => {
|
|
160
|
-
if (!this._pendingAddOnChange)
|
|
248
|
+
if (!this._pendingAddOnChange) {
|
|
249
|
+
this._closeBreakdownOnEscape(event);
|
|
161
250
|
return;
|
|
251
|
+
}
|
|
162
252
|
if (event.key === 'Escape') {
|
|
163
253
|
event.preventDefault();
|
|
164
254
|
this._cancelAddOnChange();
|
|
@@ -195,14 +285,31 @@ export class PricingRendererElement extends LitElement {
|
|
|
195
285
|
this.requestUpdate();
|
|
196
286
|
},
|
|
197
287
|
updateOnlyDifferences: (value) => {
|
|
198
|
-
this.
|
|
288
|
+
this._onlyDifferencesOverride = value;
|
|
199
289
|
this.requestUpdate();
|
|
200
290
|
},
|
|
201
291
|
toggleGroup: this._toggleGroup,
|
|
202
292
|
setAllGroups: this._setAllGroups,
|
|
203
293
|
revealGroup: this._revealGroup,
|
|
294
|
+
updateAddOnSearch: (value) => {
|
|
295
|
+
this._addOnSearch = value;
|
|
296
|
+
this.requestUpdate();
|
|
297
|
+
},
|
|
298
|
+
// focus moves to the first add-on card the reveal adds
|
|
299
|
+
revealAddOns: (byKeyboard = false) => {
|
|
300
|
+
const shown = () => [...this.querySelectorAll(addOnCardSelector)];
|
|
301
|
+
const before = new Set(shown().map((card) => card.dataset.addOnId));
|
|
302
|
+
this._addOnsRevealed = true;
|
|
303
|
+
this.requestUpdate();
|
|
304
|
+
void this.updateComplete.then(() => {
|
|
305
|
+
const added = shown().find((card) => !before.has(card.dataset.addOnId));
|
|
306
|
+
if (added)
|
|
307
|
+
this._focusRevealed(added, byKeyboard);
|
|
308
|
+
});
|
|
309
|
+
},
|
|
204
310
|
confirmAddOnChange: this._confirmAddOnChange,
|
|
205
311
|
cancelAddOnChange: this._cancelAddOnChange,
|
|
312
|
+
toggleBreakdown: this._toggleBreakdown,
|
|
206
313
|
};
|
|
207
314
|
const configuration = getPricingRendererConfig();
|
|
208
315
|
this.locale = configuration.locale;
|
|
@@ -215,6 +322,41 @@ export class PricingRendererElement extends LitElement {
|
|
|
215
322
|
this.layout = 'auto';
|
|
216
323
|
this.visibility = 'public-only';
|
|
217
324
|
this.theme = 'light';
|
|
325
|
+
this.diagnostics = 'auto';
|
|
326
|
+
this.toolbarThreshold = DEFAULT_TOOLBAR_THRESHOLD;
|
|
327
|
+
this.addOnFilterThreshold = DEFAULT_ADDON_FILTER_THRESHOLD;
|
|
328
|
+
}
|
|
329
|
+
get density() {
|
|
330
|
+
return this._density === 'compact' ? 'compact' : 'comfortable';
|
|
331
|
+
}
|
|
332
|
+
set density(value) {
|
|
333
|
+
// The old value is the one read back: Lit takes the first old value as the default that a
|
|
334
|
+
// removed attribute restores, and it must be comfortable.
|
|
335
|
+
const previous = this.density;
|
|
336
|
+
const wasSet = this._density !== undefined && this._density !== null;
|
|
337
|
+
this._density = value;
|
|
338
|
+
// Unsetting, for example a React prop that becomes undefined, removes the attribute instead of
|
|
339
|
+
// reflecting density="comfortable". A value set and unset before the same update is still
|
|
340
|
+
// reflected, as comfortable.
|
|
341
|
+
if (value === undefined || value === null) {
|
|
342
|
+
if (wasSet)
|
|
343
|
+
this.removeAttribute('density');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.requestUpdate('density', previous);
|
|
347
|
+
}
|
|
348
|
+
// The element keeps a deep copy, so a later change of the host's object is not seen until the
|
|
349
|
+
// host sets the property again, and the getter returns a copy, so a change of what it returns is
|
|
350
|
+
// not seen either. An equal value keeps the stored copy: a host that assigns its props on every
|
|
351
|
+
// render does not report the diagnostics again. It never becomes the selection in effect, so
|
|
352
|
+
// setting it emits no `pricing-selection-change`.
|
|
353
|
+
get currentSelection() {
|
|
354
|
+
return cloneValue(this._currentSelection);
|
|
355
|
+
}
|
|
356
|
+
set currentSelection(value) {
|
|
357
|
+
if (deepEqual(value, this._currentSelection))
|
|
358
|
+
return;
|
|
359
|
+
this._currentSelection = cloneValue(value);
|
|
218
360
|
}
|
|
219
361
|
createRenderRoot() {
|
|
220
362
|
return this;
|
|
@@ -223,6 +365,8 @@ export class PricingRendererElement extends LitElement {
|
|
|
223
365
|
super.connectedCallback();
|
|
224
366
|
this.setAttribute('data-pr-root', '');
|
|
225
367
|
this.addEventListener('keydown', this._handleDialogKeyboard);
|
|
368
|
+
if (this.hasUpdated)
|
|
369
|
+
this._resumeAfterReconnect();
|
|
226
370
|
}
|
|
227
371
|
disconnectedCallback() {
|
|
228
372
|
this._resizeObserver?.disconnect();
|
|
@@ -230,7 +374,18 @@ export class PricingRendererElement extends LitElement {
|
|
|
230
374
|
this.removeEventListener('keydown', this._handleDialogKeyboard);
|
|
231
375
|
super.disconnectedCallback();
|
|
232
376
|
}
|
|
233
|
-
|
|
377
|
+
// The disconnect stopped the observer and aborted the load in flight; an aborted load is dropped
|
|
378
|
+
// without a result, so a status still `loading` would never end. A ready or failed element keeps
|
|
379
|
+
// what it shows.
|
|
380
|
+
_resumeAfterReconnect() {
|
|
381
|
+
this._observeSize();
|
|
382
|
+
if (this._status === 'loading')
|
|
383
|
+
void this.reload();
|
|
384
|
+
}
|
|
385
|
+
// Observes the size of the element with a new observer and measures its width once.
|
|
386
|
+
_observeSize() {
|
|
387
|
+
this._resizeObserver?.disconnect();
|
|
388
|
+
this._resizeObserver = undefined;
|
|
234
389
|
if (typeof ResizeObserver === 'function') {
|
|
235
390
|
this._resizeObserver = new ResizeObserver((entries) => {
|
|
236
391
|
const width = entries[0]?.contentRect.width ?? this.getBoundingClientRect().width;
|
|
@@ -239,11 +394,18 @@ export class PricingRendererElement extends LitElement {
|
|
|
239
394
|
this._resizeObserver.observe(this);
|
|
240
395
|
}
|
|
241
396
|
this._updateCompactLayout(this.getBoundingClientRect().width);
|
|
397
|
+
}
|
|
398
|
+
firstUpdated() {
|
|
399
|
+
this._observeSize();
|
|
242
400
|
void this.reload();
|
|
243
401
|
queueMicrotask(() => {
|
|
244
402
|
this._reloadOnPropertyChange = true;
|
|
245
403
|
});
|
|
246
404
|
}
|
|
405
|
+
willUpdate(changed) {
|
|
406
|
+
if (changed.has('onlyDifferences'))
|
|
407
|
+
this._onlyDifferencesOverride = undefined;
|
|
408
|
+
}
|
|
247
409
|
updated(changed) {
|
|
248
410
|
const inputChanged = changed.has('pricing') ||
|
|
249
411
|
changed.has('yaml') ||
|
|
@@ -257,28 +419,80 @@ export class PricingRendererElement extends LitElement {
|
|
|
257
419
|
if (changed.has('layout')) {
|
|
258
420
|
this._updateCompactLayout(this.getBoundingClientRect().width);
|
|
259
421
|
}
|
|
422
|
+
this._reportCurrentSelection();
|
|
423
|
+
this._reportSlots();
|
|
424
|
+
}
|
|
425
|
+
// Once per change of the slot nodes this element rendered, of their data-* values or, while a
|
|
426
|
+
// summary slot node exists, of the selection and the resolved prices. A node that the host or a
|
|
427
|
+
// nested renderer put inside a slot node has another owner and is not listed.
|
|
428
|
+
_reportSlots() {
|
|
429
|
+
const nodes = [...this.querySelectorAll(slotNodeSelector)].filter((node) => node.isConnected && node.parentElement?.closest(slotOwnerSelector) === this);
|
|
430
|
+
const data = nodes.map((node) => JSON.stringify(Object.entries(node.dataset)));
|
|
431
|
+
const summary = nodes.some((node) => node.dataset.prSlot === 'summary-actions')
|
|
432
|
+
? this._snapshot()
|
|
433
|
+
: undefined;
|
|
434
|
+
const last = this._lastSlotsDispatch;
|
|
435
|
+
if (nodes.length === last.nodes.length &&
|
|
436
|
+
nodes.every((node, index) => node === last.nodes[index] && data[index] === last.data[index]) &&
|
|
437
|
+
deepEqual(summary, last.summary)) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
this._lastSlotsDispatch = { nodes, data, ...(summary ? { summary } : {}) };
|
|
441
|
+
this.dispatchEvent(new CustomEvent('pricing-slots-change', {
|
|
442
|
+
detail: { nodes: [...nodes] },
|
|
443
|
+
bubbles: true,
|
|
444
|
+
composed: true,
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
// The same pricing-action as a click on the plan's built-in CTA, for a host that draws its own.
|
|
448
|
+
triggerPlanAction(planId) {
|
|
449
|
+
if (!this.ctaEnabled || this.pendingActionId || !this._isReady())
|
|
450
|
+
return false;
|
|
451
|
+
if (hiddenSectionsOf(this.hideSections).has('plans'))
|
|
452
|
+
return false;
|
|
453
|
+
const viewModel = this._viewModel();
|
|
454
|
+
const plan = viewModel?.plans.find((candidate) => candidate.id === planId);
|
|
455
|
+
if (!viewModel || !plan)
|
|
456
|
+
return false;
|
|
457
|
+
const current = deriveCurrentContext(this._normalized, this._currentSelection);
|
|
458
|
+
const cta = planCtaView(viewModel, plan, {
|
|
459
|
+
currentPlanId: current?.planId,
|
|
460
|
+
pendingActionId: this.pendingActionId,
|
|
461
|
+
});
|
|
462
|
+
if (cta.disabled)
|
|
463
|
+
return false;
|
|
464
|
+
const origin = cta.relation
|
|
465
|
+
? { relation: cta.relation, source: 'plan' }
|
|
466
|
+
: undefined;
|
|
467
|
+
return this._triggerAction(cta.actionId, plan.id, cta.eventHref, cta.metadata, origin);
|
|
468
|
+
}
|
|
469
|
+
// Once per change of the current selection or of the loaded pricing; not in the rendered list.
|
|
470
|
+
_reportCurrentSelection() {
|
|
471
|
+
if (!this._isReady())
|
|
472
|
+
return;
|
|
473
|
+
if (this._diagnosedSelection === this._currentSelection &&
|
|
474
|
+
this._diagnosedPricing === this._normalized) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
this._diagnosedSelection = this._currentSelection;
|
|
478
|
+
this._diagnosedPricing = this._normalized;
|
|
479
|
+
this._dispatchSelectionDiagnostics(currentSelectionDiagnostics(this._normalized, this._currentSelection));
|
|
260
480
|
}
|
|
261
481
|
async reload() {
|
|
262
482
|
this._loadedOnce = true;
|
|
263
|
-
const
|
|
264
|
-
this.pricing !== undefined ?
|
|
265
|
-
this.yaml !== undefined ?
|
|
266
|
-
this.src ?
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
? 'Provide exactly one pricing source: pricing, yaml or src.'
|
|
277
|
-
: 'Only one pricing source can be provided at a time.',
|
|
278
|
-
path: '$',
|
|
279
|
-
},
|
|
280
|
-
],
|
|
281
|
-
});
|
|
483
|
+
const input = {
|
|
484
|
+
...(this.pricing !== undefined ? { pricing: this.pricing } : {}),
|
|
485
|
+
...(this.yaml !== undefined ? { yaml: this.yaml } : {}),
|
|
486
|
+
...(this.src !== undefined ? { src: this.src } : {}),
|
|
487
|
+
};
|
|
488
|
+
const sourceError = pricingSourceError(input);
|
|
489
|
+
if (sourceError) {
|
|
490
|
+
// the load in flight must neither finish nor be
|
|
491
|
+
// applied after this error
|
|
492
|
+
this._abortController?.abort();
|
|
493
|
+
this._abortController = undefined;
|
|
494
|
+
this._loadSequence += 1;
|
|
495
|
+
this._applyLoadResult(sourceError);
|
|
282
496
|
return;
|
|
283
497
|
}
|
|
284
498
|
this._abortController?.abort();
|
|
@@ -287,43 +501,116 @@ export class PricingRendererElement extends LitElement {
|
|
|
287
501
|
const sequence = ++this._loadSequence;
|
|
288
502
|
this._status = 'loading';
|
|
289
503
|
this.requestUpdate();
|
|
290
|
-
let result
|
|
291
|
-
|
|
292
|
-
|
|
504
|
+
let result = loadPricingSource(input, {
|
|
505
|
+
...(this.request ? { request: this.request } : {}),
|
|
506
|
+
...(this.loadPricing ? { loadPricing: this.loadPricing } : {}),
|
|
507
|
+
...(this.normalizeOptions ? { normalizeOptions: this.normalizeOptions } : {}),
|
|
508
|
+
signal: controller.signal,
|
|
509
|
+
});
|
|
510
|
+
if (result instanceof Promise) {
|
|
511
|
+
result = await result;
|
|
293
512
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
513
|
+
if (sequence !== this._loadSequence || controller.signal.aborted) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this._applyLoadResult(result);
|
|
517
|
+
}
|
|
518
|
+
get resolvedState() {
|
|
519
|
+
return this._snapshot();
|
|
520
|
+
}
|
|
521
|
+
applySelection(selection) {
|
|
522
|
+
if (!this._isReady()) {
|
|
523
|
+
return this._refuseSelection(undefined, {
|
|
524
|
+
code: 'PR_SELECTION_NOT_READY',
|
|
525
|
+
severity: 'error',
|
|
526
|
+
message: 'The pricing is not ready; applySelection was ignored.',
|
|
297
527
|
});
|
|
298
528
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
signal: controller.signal,
|
|
529
|
+
if (this.selection !== undefined) {
|
|
530
|
+
return this._refuseSelection(this._snapshot(), {
|
|
531
|
+
code: 'PR_SELECTION_CONTROLLED',
|
|
532
|
+
severity: 'error',
|
|
533
|
+
message: 'The selection property is set; applySelection was ignored.',
|
|
305
534
|
});
|
|
306
535
|
}
|
|
307
|
-
|
|
536
|
+
const { removedAddOnIds, diagnostics, ...next } = applySelectionInput(this._normalized, this._selectionInEffect(), cloneValue(selection));
|
|
537
|
+
this._internalSelection = next.selection;
|
|
538
|
+
if (this._pendingAddOnChange)
|
|
539
|
+
this._cancelAddOnChange();
|
|
540
|
+
this.requestUpdate();
|
|
541
|
+
const state = this._snapshot();
|
|
542
|
+
const result = {
|
|
543
|
+
applied: true,
|
|
544
|
+
selection: state.selection,
|
|
545
|
+
resolved: state.resolved,
|
|
546
|
+
removedAddOnIds,
|
|
547
|
+
diagnostics,
|
|
548
|
+
};
|
|
549
|
+
this._dispatchSelectionDiagnostics(diagnostics);
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
_isReady() {
|
|
553
|
+
return this._status === 'ready' && this._normalized !== undefined;
|
|
554
|
+
}
|
|
555
|
+
_selectionInEffect() {
|
|
556
|
+
const selection = this._effectiveSelection();
|
|
557
|
+
if (!selection || !this._normalized)
|
|
558
|
+
return selection;
|
|
559
|
+
return reconcileSelection(this._normalized, selection).selection;
|
|
560
|
+
}
|
|
561
|
+
_snapshot() {
|
|
562
|
+
if (!this._isReady())
|
|
563
|
+
return undefined;
|
|
564
|
+
const selection = this._selectionInEffect();
|
|
565
|
+
if (!selection)
|
|
566
|
+
return undefined;
|
|
567
|
+
const { resolved } = this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, selection, { ...this._viewModelOptions() }));
|
|
568
|
+
return cloneValue({ selection, resolved });
|
|
569
|
+
}
|
|
570
|
+
_refuseSelection(state, diagnostic) {
|
|
571
|
+
const result = {
|
|
572
|
+
applied: false,
|
|
573
|
+
selection: state?.selection,
|
|
574
|
+
resolved: state?.resolved,
|
|
575
|
+
removedAddOnIds: [],
|
|
576
|
+
diagnostics: [diagnostic],
|
|
577
|
+
};
|
|
578
|
+
this._dispatchSelectionDiagnostics(result.diagnostics);
|
|
579
|
+
return result;
|
|
580
|
+
}
|
|
581
|
+
// the rendered list is left unchanged
|
|
582
|
+
_dispatchSelectionDiagnostics(diagnostics) {
|
|
583
|
+
if (diagnostics.length === 0)
|
|
308
584
|
return;
|
|
309
|
-
|
|
310
|
-
|
|
585
|
+
this.dispatchEvent(new CustomEvent('pricing-diagnostic', {
|
|
586
|
+
detail: { diagnostics: cloneValue(diagnostics) },
|
|
587
|
+
bubbles: true,
|
|
588
|
+
composed: true,
|
|
589
|
+
}));
|
|
311
590
|
}
|
|
312
591
|
_applyLoadResult(result) {
|
|
313
592
|
this._diagnostics = result.diagnostics;
|
|
314
593
|
if (result.value) {
|
|
315
594
|
this._normalized = result.value;
|
|
316
|
-
this.
|
|
317
|
-
this.
|
|
318
|
-
this._internalSelection = mergeSelection(result.value, this.defaultSelection);
|
|
595
|
+
this._priceStabilizer.reset();
|
|
596
|
+
this._internalSelection = reconcileSelection(result.value, mergeSelection(result.value, this.defaultSelection)).selection;
|
|
319
597
|
this._expandedGroups = new Set();
|
|
598
|
+
// the add-on search and reveal state start over with a new pricing
|
|
599
|
+
this._addOnSearch = '';
|
|
600
|
+
this._addOnsRevealed = false;
|
|
601
|
+
this._onlyDifferencesOverride = undefined;
|
|
602
|
+
this._pendingAddOnChange = undefined;
|
|
603
|
+
this._previousFocus = undefined;
|
|
604
|
+
this._breakdownOpen = false;
|
|
320
605
|
const viewModel = createPricingViewModel(result.value, this._effectiveSelection(), this._viewModelOptions());
|
|
321
606
|
if (viewModel.comparisonGroups[0]) {
|
|
322
607
|
this._expandedGroups.add(viewModel.comparisonGroups[0].id);
|
|
323
608
|
}
|
|
324
609
|
this._status = 'ready';
|
|
610
|
+
// Ready state and internal selection are set before the dispatch and not assigned after it,
|
|
611
|
+
// so a listener's applySelection call stays in effect for this load.
|
|
325
612
|
this.dispatchEvent(new CustomEvent('pricing-ready', {
|
|
326
|
-
detail: { pricing: result.value, diagnostics: result.diagnostics },
|
|
613
|
+
detail: { pricing: result.value, diagnostics: result.diagnostics, ...this._snapshot() },
|
|
327
614
|
bubbles: true,
|
|
328
615
|
composed: true,
|
|
329
616
|
}));
|
|
@@ -340,14 +627,15 @@ export class PricingRendererElement extends LitElement {
|
|
|
340
627
|
}
|
|
341
628
|
this.requestUpdate();
|
|
342
629
|
}
|
|
630
|
+
// the core treats only `false` as off, the element any
|
|
631
|
+
// falsy value
|
|
343
632
|
_effectiveSelection() {
|
|
344
633
|
const selection = this.selection ?? this._internalSelection;
|
|
345
|
-
if (!selection ||
|
|
634
|
+
if (!selection || !this._normalized)
|
|
346
635
|
return selection;
|
|
347
|
-
return {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
};
|
|
636
|
+
return selectionInEffect(this._normalized, selection, {
|
|
637
|
+
variablesEnabled: Boolean(this.variablesEnabled),
|
|
638
|
+
});
|
|
351
639
|
}
|
|
352
640
|
_viewModelOptions() {
|
|
353
641
|
return {
|
|
@@ -359,56 +647,39 @@ export class PricingRendererElement extends LitElement {
|
|
|
359
647
|
...(this.expressionOptions ? { expression: this.expressionOptions } : {}),
|
|
360
648
|
};
|
|
361
649
|
}
|
|
362
|
-
_stabilizeResolvedPrices(viewModel) {
|
|
363
|
-
for (const [id, price] of Object.entries(viewModel.resolved.planPrices)) {
|
|
364
|
-
if (price.kind === 'amount') {
|
|
365
|
-
this._lastValidPlanPrices[id] = price;
|
|
366
|
-
}
|
|
367
|
-
else if (price.kind === 'error' && this._lastValidPlanPrices[id]) {
|
|
368
|
-
viewModel.resolved.planPrices[id] = this._lastValidPlanPrices[id];
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
for (const [id, price] of Object.entries(viewModel.resolved.addOnPrices)) {
|
|
372
|
-
if (price.kind === 'amount') {
|
|
373
|
-
this._lastValidAddOnPrices[id] = price;
|
|
374
|
-
}
|
|
375
|
-
else if (price.kind === 'error' && this._lastValidAddOnPrices[id]) {
|
|
376
|
-
viewModel.resolved.addOnPrices[id] = this._lastValidAddOnPrices[id];
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const planPrice = viewModel.resolved.selection.planId
|
|
380
|
-
? viewModel.resolved.planPrices[viewModel.resolved.selection.planId]
|
|
381
|
-
: undefined;
|
|
382
|
-
let subtotal = planPrice?.kind === 'amount' ? planPrice.amount : 0;
|
|
383
|
-
for (const [id, state] of Object.entries(viewModel.resolved.selection.addOns)) {
|
|
384
|
-
const addOnPrice = viewModel.resolved.addOnPrices[id];
|
|
385
|
-
if (state.selected && addOnPrice?.kind === 'amount') {
|
|
386
|
-
subtotal += addOnPrice.amount;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
viewModel.resolved.subtotal = subtotal;
|
|
390
|
-
return viewModel;
|
|
391
|
-
}
|
|
392
650
|
_viewModel() {
|
|
393
651
|
if (!this._normalized)
|
|
394
652
|
return undefined;
|
|
395
|
-
return this.
|
|
653
|
+
return this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, this._effectiveSelection(), {
|
|
396
654
|
...this._viewModelOptions(),
|
|
397
655
|
}));
|
|
398
656
|
}
|
|
657
|
+
// the relation between the current side and the selection in
|
|
658
|
+
// effect, both on the loaded pricing
|
|
659
|
+
_summaryAction(viewModel, pricing) {
|
|
660
|
+
const { relation } = compareSelections({
|
|
661
|
+
before: { pricing, selection: this._currentSelection },
|
|
662
|
+
after: { pricing, selection: viewModel.resolved.selection },
|
|
663
|
+
...(this.expressionOptions ? { expression: this.expressionOptions } : {}),
|
|
664
|
+
});
|
|
665
|
+
return summaryActionState(viewModel, relation);
|
|
666
|
+
}
|
|
399
667
|
_updateCompactLayout(width) {
|
|
400
668
|
const next = this.layout === 'compact' ? true : this.layout === 'table' ? false : width < 840;
|
|
401
|
-
|
|
669
|
+
const wide = width >= 840;
|
|
670
|
+
if (next !== this._compact || wide !== this._wide) {
|
|
402
671
|
this._compact = next;
|
|
672
|
+
this._wide = wide;
|
|
403
673
|
this.requestUpdate();
|
|
404
674
|
}
|
|
405
675
|
}
|
|
406
|
-
_setSelection(
|
|
676
|
+
_setSelection(input) {
|
|
677
|
+
const next = this._normalized ? reconcileSelection(this._normalized, input).selection : input;
|
|
407
678
|
if (this.selection === undefined) {
|
|
408
679
|
this._internalSelection = next;
|
|
409
680
|
}
|
|
410
681
|
const resolved = this._normalized
|
|
411
|
-
? this.
|
|
682
|
+
? this._priceStabilizer.stabilize(createPricingViewModel(this._normalized, next, {
|
|
412
683
|
...this._viewModelOptions(),
|
|
413
684
|
})).resolved
|
|
414
685
|
: undefined;
|
|
@@ -421,36 +692,18 @@ export class PricingRendererElement extends LitElement {
|
|
|
421
692
|
}
|
|
422
693
|
this.requestUpdate();
|
|
423
694
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
_selectedDependents(addOnId) {
|
|
437
|
-
if (!this._normalized)
|
|
438
|
-
return [];
|
|
439
|
-
const selection = this._effectiveSelection();
|
|
440
|
-
const dependents = new Set();
|
|
441
|
-
let changed = true;
|
|
442
|
-
while (changed) {
|
|
443
|
-
changed = false;
|
|
444
|
-
for (const addOn of this._normalized.addOns) {
|
|
445
|
-
if (!selection?.addOns[addOn.id]?.selected || dependents.has(addOn.id))
|
|
446
|
-
continue;
|
|
447
|
-
if (addOn.dependsOn.some((id) => id === addOnId || dependents.has(id))) {
|
|
448
|
-
dependents.add(addOn.id);
|
|
449
|
-
changed = true;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
return [...dependents];
|
|
695
|
+
// Keeps the focused element for the cancel and confirm paths, then focuses the dialog's default
|
|
696
|
+
// button once it is rendered.
|
|
697
|
+
_openChangeDialog(pending) {
|
|
698
|
+
this._pendingAddOnChange = pending;
|
|
699
|
+
this._previousFocus =
|
|
700
|
+
this.ownerDocument.activeElement instanceof HTMLElement
|
|
701
|
+
? this.ownerDocument.activeElement
|
|
702
|
+
: undefined;
|
|
703
|
+
this.requestUpdate();
|
|
704
|
+
void this.updateComplete.then(() => {
|
|
705
|
+
this.querySelector('.pr-dialog button[autofocus]')?.focus();
|
|
706
|
+
});
|
|
454
707
|
}
|
|
455
708
|
_applyAddOnChange(change) {
|
|
456
709
|
if (!this._normalized)
|
|
@@ -458,35 +711,55 @@ export class PricingRendererElement extends LitElement {
|
|
|
458
711
|
const selection = this._effectiveSelection();
|
|
459
712
|
if (!selection)
|
|
460
713
|
return;
|
|
461
|
-
const
|
|
462
|
-
const setState = (id, selected) => {
|
|
463
|
-
const item = this._normalized.addOns.find((candidate) => candidate.id === id);
|
|
464
|
-
const current = addOns[id];
|
|
465
|
-
addOns[id] = {
|
|
466
|
-
selected,
|
|
467
|
-
quantity: current?.quantity ?? item?.subscriptionConstraints?.minQuantity ?? 1,
|
|
468
|
-
};
|
|
469
|
-
};
|
|
470
|
-
setState(change.addOnId, change.select);
|
|
471
|
-
change.addIds.forEach((id) => setState(id, true));
|
|
472
|
-
change.removeIds.forEach((id) => setState(id, false));
|
|
714
|
+
const next = applySelectionChange(this._normalized, selection, change);
|
|
473
715
|
this._pendingAddOnChange = undefined;
|
|
474
|
-
this._setSelection(
|
|
716
|
+
this._setSelection(next);
|
|
475
717
|
void this.updateComplete.then(() => this._restoreDialogFocus());
|
|
476
718
|
}
|
|
477
719
|
_restoreDialogFocus() {
|
|
478
720
|
this._previousFocus?.focus();
|
|
479
721
|
this._previousFocus = undefined;
|
|
480
722
|
}
|
|
723
|
+
_focusRevealed(target, byKeyboard) {
|
|
724
|
+
if (!target.hasAttribute('tabindex')) {
|
|
725
|
+
target.setAttribute('tabindex', '-1');
|
|
726
|
+
// the attribute is only a focus aid for the reveal, so it goes away on blur
|
|
727
|
+
target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true });
|
|
728
|
+
}
|
|
729
|
+
// Firefox keeps the cause of the earlier mouse click on the removed button and then skips
|
|
730
|
+
// :focus-visible for a script focus, so a keyboard activation asks for it explicitly.
|
|
731
|
+
// lib.dom has no focusVisible member in FocusOptions yet.
|
|
732
|
+
if (byKeyboard)
|
|
733
|
+
target.focus({ focusVisible: true });
|
|
734
|
+
else
|
|
735
|
+
target.focus();
|
|
736
|
+
}
|
|
737
|
+
// Escape closes the panel only from the toggle or from inside the panel; the dialog, which makes
|
|
738
|
+
// the rest inert, keeps its own Escape handling and leaves the panel as it is.
|
|
739
|
+
_closeBreakdownOnEscape(event) {
|
|
740
|
+
if (!this._breakdownOpen || event.key !== 'Escape')
|
|
741
|
+
return;
|
|
742
|
+
const target = event.target instanceof Element ? event.target : undefined;
|
|
743
|
+
const origin = target?.closest(`${breakdownToggleSelector}, ${breakdownPanelSelector}`);
|
|
744
|
+
if (!origin || !this.contains(origin))
|
|
745
|
+
return;
|
|
746
|
+
event.preventDefault();
|
|
747
|
+
this._breakdownOpen = false;
|
|
748
|
+
this.querySelector(breakdownToggleSelector)?.focus();
|
|
749
|
+
this.requestUpdate();
|
|
750
|
+
}
|
|
481
751
|
render() {
|
|
752
|
+
const slots = requestedSlotsOf(this.slots);
|
|
482
753
|
if (this._status === 'loading' || this._status === 'idle') {
|
|
754
|
+
const loadingTitleId = `${this._idPrefix}-loading-title`;
|
|
755
|
+
const loadingLabel = this.loadingLabel ?? translate(getMessages(this.locale, this.messages), 'pricing.loading');
|
|
756
|
+
if (slots.has('loading'))
|
|
757
|
+
return renderLoadingSlotShell(loadingTitleId, loadingLabel);
|
|
483
758
|
return html `
|
|
484
|
-
<section class="pr-shell pr-shell--loading" aria-labelledby
|
|
485
|
-
<h2 id
|
|
486
|
-
${this.loadingLabel ?? 'Loading pricing'}
|
|
487
|
-
</h2>
|
|
759
|
+
<section class="pr-shell pr-shell--loading" aria-labelledby=${loadingTitleId}>
|
|
760
|
+
<h2 id=${loadingTitleId} class="pr-sr-only">${loadingLabel}</h2>
|
|
488
761
|
<div class="pr-skeleton" role="status" aria-live="polite">
|
|
489
|
-
<span>${
|
|
762
|
+
<span>${loadingLabel}</span>
|
|
490
763
|
<div></div>
|
|
491
764
|
<div></div>
|
|
492
765
|
<div></div>
|
|
@@ -495,23 +768,23 @@ export class PricingRendererElement extends LitElement {
|
|
|
495
768
|
`;
|
|
496
769
|
}
|
|
497
770
|
if (!this._normalized || this._status === 'error') {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
features: [],
|
|
503
|
-
usageLimits: [],
|
|
504
|
-
plans: [],
|
|
505
|
-
addOns: [],
|
|
506
|
-
custom: {},
|
|
507
|
-
raw: {},
|
|
508
|
-
}, undefined, { locale: this.locale, messages: this.messages }).messages;
|
|
771
|
+
if (slots.has('error'))
|
|
772
|
+
return renderErrorSlotShell(this._diagnostics);
|
|
773
|
+
const messages = getMessages(this.locale, this.messages);
|
|
774
|
+
// the codes and messages stay inside a closed details
|
|
509
775
|
return html `
|
|
510
776
|
<section class="pr-shell pr-error" role="alert">
|
|
511
777
|
<h2>${translate(messages, 'pricing.error')}</h2>
|
|
512
|
-
<
|
|
513
|
-
|
|
514
|
-
|
|
778
|
+
<p>${translate(messages, errorMessageKey(this._diagnostics))}</p>
|
|
779
|
+
<details class="pr-diagnostics" data-pr-part="diagnostics">
|
|
780
|
+
<summary>${translate(messages, 'pricing.errorDetails')}</summary>
|
|
781
|
+
<ul>
|
|
782
|
+
${this._diagnostics.map((item) => html `<li>
|
|
783
|
+
<strong>${item.code}</strong>:
|
|
784
|
+
${item.message}${item.path ? ` (${item.path})` : ''}
|
|
785
|
+
</li>`)}
|
|
786
|
+
</ul>
|
|
787
|
+
</details>
|
|
515
788
|
${this.src
|
|
516
789
|
? html `<button
|
|
517
790
|
class="pr-button pr-button--primary"
|
|
@@ -525,44 +798,85 @@ export class PricingRendererElement extends LitElement {
|
|
|
525
798
|
`;
|
|
526
799
|
}
|
|
527
800
|
const viewModel = this._viewModel();
|
|
801
|
+
const toolbarThreshold = toolbarThresholdOf(this.toolbarThreshold);
|
|
802
|
+
// Without a toolbar the user cannot undo a filter, so the search text and the checkbox override
|
|
803
|
+
// are ignored while it is hidden (the stored values are kept).
|
|
804
|
+
const toolbarShown = comparisonRowTotal(viewModel) > toolbarThreshold;
|
|
805
|
+
// a forced value is ignored without the toolbar,
|
|
806
|
+
// because no checkbox exists to undo it
|
|
807
|
+
const initialOnlyDifferences = this._onlyDifferencesOverride ??
|
|
808
|
+
resolveOnlyDifferences(viewModel, this.onlyDifferences, toolbarThreshold);
|
|
809
|
+
// the pricing, not the visible plans, defines it
|
|
810
|
+
const current = deriveCurrentContext(this._normalized, this._currentSelection);
|
|
811
|
+
const hidden = hiddenSectionsOf(this.hideSections);
|
|
528
812
|
const state = {
|
|
529
813
|
viewModel,
|
|
814
|
+
...(hidden.size > 0 ? { hiddenSections: hidden } : {}),
|
|
815
|
+
...(slots.size > 0 ? { slots } : {}),
|
|
530
816
|
selection: viewModel.resolved.selection,
|
|
531
817
|
compact: this._compact,
|
|
532
|
-
search: this._search,
|
|
533
|
-
onlyDifferences:
|
|
818
|
+
search: toolbarShown ? this._search : '',
|
|
819
|
+
onlyDifferences: toolbarShown && initialOnlyDifferences,
|
|
820
|
+
toolbarThreshold,
|
|
534
821
|
selectionEnabled: this.selectionEnabled,
|
|
535
822
|
ctaEnabled: this.ctaEnabled,
|
|
536
823
|
variablesEnabled: this.variablesEnabled,
|
|
537
824
|
expandedGroups: this._expandedGroups,
|
|
538
825
|
revealedGroups: this._revealedGroups,
|
|
826
|
+
addOnSearch: this._addOnSearch,
|
|
827
|
+
addOnsRevealed: this._addOnsRevealed,
|
|
828
|
+
addOnFilterThreshold: addOnFilterThresholdOf(this.addOnFilterThreshold),
|
|
539
829
|
...(this._pendingAddOnChange ? { pendingAddOnChange: this._pendingAddOnChange } : {}),
|
|
830
|
+
breakdownOpen: this._breakdownOpen,
|
|
831
|
+
breakdownId: this._breakdownId,
|
|
832
|
+
idPrefix: this._idPrefix,
|
|
833
|
+
...(current ? { currentPlanId: current.planId, currentAddOnIds: current.addOnIds } : {}),
|
|
834
|
+
...(this.pendingActionId ? { pendingActionId: this.pendingActionId } : {}),
|
|
835
|
+
...(current && this.ctaEnabled
|
|
836
|
+
? { summaryAction: this._summaryAction(viewModel, this._normalized) }
|
|
837
|
+
: {}),
|
|
540
838
|
actions: this._actions,
|
|
541
839
|
};
|
|
542
|
-
|
|
840
|
+
// auto, the default, keeps warnings out of the page: the pricing rendered, so they are a
|
|
841
|
+
// developer's concern and stay in the pricing-diagnostic event. Errors are shown because a
|
|
842
|
+
// visitor can see what they broke. verbose shows both; off shows none.
|
|
843
|
+
const shownDiagnostics = this.diagnostics === 'off'
|
|
844
|
+
? []
|
|
845
|
+
: this.diagnostics === 'verbose'
|
|
846
|
+
? this._diagnostics.filter((item) => item.severity !== 'info')
|
|
847
|
+
: this._diagnostics.filter((item) => item.severity === 'error');
|
|
543
848
|
return html `
|
|
544
|
-
<section class="pr-shell" aria-labelledby
|
|
849
|
+
<section class="pr-shell" aria-labelledby=${instanceId(state, 'title')}>
|
|
545
850
|
<div
|
|
546
851
|
class="pr-content"
|
|
547
852
|
aria-hidden=${this._pendingAddOnChange ? 'true' : nothing}
|
|
548
853
|
?inert=${Boolean(this._pendingAddOnChange)}
|
|
549
854
|
>
|
|
550
|
-
${
|
|
855
|
+
${shownDiagnostics.length
|
|
551
856
|
? html `<details class="pr-diagnostics" data-pr-part="diagnostics">
|
|
552
857
|
<summary>
|
|
553
|
-
${translate(viewModel.messages, 'pricing.warning')} (${
|
|
858
|
+
${translate(viewModel.messages, 'pricing.warning')} (${shownDiagnostics.length})
|
|
554
859
|
</summary>
|
|
555
860
|
<ul>
|
|
556
|
-
${
|
|
861
|
+
${shownDiagnostics.map((item) => html `<li>
|
|
557
862
|
<strong>${item.code}</strong>:
|
|
558
863
|
${item.message}${item.path ? ` (${item.path})` : ''}
|
|
559
864
|
</li>`)}
|
|
560
865
|
</ul>
|
|
561
866
|
</details>`
|
|
562
867
|
: nothing}
|
|
563
|
-
${
|
|
564
|
-
|
|
565
|
-
|
|
868
|
+
${
|
|
869
|
+
// the shell stays labelled by the title when the hero is hidden
|
|
870
|
+
hidden.has('hero')
|
|
871
|
+
? html `<h2 class="pr-sr-only" id=${instanceId(state, 'title')}>
|
|
872
|
+
${heroTitle(viewModel)}
|
|
873
|
+
</h2>`
|
|
874
|
+
: renderHeader(state)}
|
|
875
|
+
${hidden.has('metadata') ? nothing : renderCatalogMetadata(state)}
|
|
876
|
+
${hidden.has('billing') ? nothing : renderBilling(state)}
|
|
877
|
+
${hidden.has('variables') ? nothing : renderVariables(state)}
|
|
878
|
+
${hidden.has('plans') ? nothing : renderPlans(state)}
|
|
879
|
+
${renderAfterPlans(state, this._wide)}
|
|
566
880
|
</div>
|
|
567
881
|
${renderPendingDialog(state)}
|
|
568
882
|
<p class="pr-sr-only" aria-live="polite">
|
|
@@ -572,16 +886,26 @@ export class PricingRendererElement extends LitElement {
|
|
|
572
886
|
`;
|
|
573
887
|
}
|
|
574
888
|
}
|
|
889
|
+
_a = PricingRendererElement;
|
|
575
890
|
PricingRendererElement.properties = {
|
|
576
|
-
|
|
891
|
+
// a host that builds these objects in its render
|
|
892
|
+
// function does not reload on every render; call reload() to force a load. loadPricing, yaml
|
|
893
|
+
// and src keep the identity compare.
|
|
894
|
+
pricing: { attribute: false, hasChanged: inputChanged },
|
|
577
895
|
yaml: { attribute: false },
|
|
578
896
|
src: { type: String, reflect: true },
|
|
579
|
-
request: { attribute: false },
|
|
897
|
+
request: { attribute: false, hasChanged: inputChanged },
|
|
580
898
|
loadPricing: { attribute: false },
|
|
581
|
-
normalizeOptions: { attribute: false },
|
|
899
|
+
normalizeOptions: { attribute: false, hasChanged: inputChanged },
|
|
582
900
|
expressionOptions: { attribute: false },
|
|
583
901
|
selection: { attribute: false },
|
|
584
902
|
defaultSelection: { attribute: false },
|
|
903
|
+
// the getter returns a copy, so the default identity check would see every set as a change
|
|
904
|
+
currentSelection: {
|
|
905
|
+
attribute: false,
|
|
906
|
+
hasChanged: (value, previous) => !deepEqual(value, previous),
|
|
907
|
+
},
|
|
908
|
+
pendingActionId: { type: String, attribute: 'pending-action-id' },
|
|
585
909
|
presentation: { attribute: false },
|
|
586
910
|
locale: { type: String },
|
|
587
911
|
pricingPath: { type: String, attribute: 'pricing-path', reflect: true },
|
|
@@ -593,6 +917,28 @@ PricingRendererElement.properties = {
|
|
|
593
917
|
layout: { type: String, reflect: true },
|
|
594
918
|
visibility: { type: String, reflect: true },
|
|
595
919
|
theme: { type: String, reflect: true },
|
|
920
|
+
// Which diagnostics reach the page. Warnings mean the pricing rendered anyway, so they are
|
|
921
|
+
// for whoever builds the page, not for a customer: "verbose" is the one that shows them.
|
|
922
|
+
diagnostics: { type: String, reflect: true },
|
|
923
|
+
// The accessor below is the class's own, so Lit reads it on the first update and would reflect
|
|
924
|
+
// density="comfortable" onto every host; useDefault records that value as the default instead.
|
|
925
|
+
density: { type: String, reflect: true, noAccessor: true, useDefault: true },
|
|
596
926
|
loadingLabel: { type: String, attribute: 'loading-label' },
|
|
927
|
+
onlyDifferences: { attribute: 'only-differences', converter: onlyDifferencesConverter },
|
|
928
|
+
toolbarThreshold: { type: Number, attribute: 'toolbar-threshold' },
|
|
929
|
+
addOnFilterThreshold: { type: Number, attribute: 'add-on-filter-threshold' },
|
|
930
|
+
// a host that builds the array in its render function does
|
|
931
|
+
// not re-render on every render
|
|
932
|
+
hideSections: {
|
|
933
|
+
attribute: 'hide-sections',
|
|
934
|
+
converter: hideSectionsConverter,
|
|
935
|
+
hasChanged: (value, previous) => !deepEqual(value, previous),
|
|
936
|
+
},
|
|
937
|
+
// the slot names the host fills; no attribute
|
|
938
|
+
slots: { attribute: false },
|
|
597
939
|
};
|
|
940
|
+
(() => {
|
|
941
|
+
// elementProperties is complete without defining the tag
|
|
942
|
+
_a.finalize();
|
|
943
|
+
})();
|
|
598
944
|
//# sourceMappingURL=pricing-renderer-element.js.map
|