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,8 +1,78 @@
|
|
|
1
1
|
import { html, nothing } from 'lit';
|
|
2
|
-
import {
|
|
2
|
+
import { keyed as keyedBy } from 'lit/directives/keyed.js';
|
|
3
|
+
import { live } from 'lit/directives/live.js';
|
|
4
|
+
import { repeat } from 'lit/directives/repeat.js';
|
|
5
|
+
import { deepEqual, formatCurrency, formatDate, getAtPath, getPlanHighlightSummary, humanizeIdentifier, isSafeLink, normalizeAddOnQuantity, translate, unwrapValue, } from '../core/index.js';
|
|
6
|
+
import { maxQuantityOnGrid } from '../core/resolve.js';
|
|
7
|
+
import { blockingAddOnIds } from '../core/selection-change.js';
|
|
8
|
+
import { addOnProvidesForPlan, isAddOnOnlyRow } from '../core/view-model.js';
|
|
9
|
+
import { formatUnit } from '../core/units.js';
|
|
10
|
+
import { isPendingActionId, planCtaView } from './cta.js';
|
|
11
|
+
// the sections a host can hide
|
|
12
|
+
export const PRICING_SECTION_NAMES = [
|
|
13
|
+
'hero',
|
|
14
|
+
'metadata',
|
|
15
|
+
'billing',
|
|
16
|
+
'variables',
|
|
17
|
+
'plans',
|
|
18
|
+
'comparison',
|
|
19
|
+
'addOns',
|
|
20
|
+
'summary',
|
|
21
|
+
// Pieces of the hero, for a page that already has its own title or lead paragraph.
|
|
22
|
+
'saasName',
|
|
23
|
+
'description',
|
|
24
|
+
'tags',
|
|
25
|
+
];
|
|
26
|
+
// the slots a host can request
|
|
27
|
+
export const PRICING_SLOT_NAMES = ['plan-cta', 'summary-actions', 'loading', 'error'];
|
|
28
|
+
// ids are global in the light DOM, so every id carries the
|
|
29
|
+
// instance prefix. The data part (a path, a group or add-on id) keeps [A-Za-z0-9]; every other
|
|
30
|
+
// code point becomes _<lowercase hex>_, so distinct data never yields the same id.
|
|
31
|
+
export function instanceId(state, name, data) {
|
|
32
|
+
const base = `${state.idPrefix ?? 'pr'}-${name}`;
|
|
33
|
+
if (data === undefined)
|
|
34
|
+
return base;
|
|
35
|
+
const escaped = data.replace(/[^A-Za-z0-9]/gu, (char) => `_${char.codePointAt(0)?.toString(16)}_`);
|
|
36
|
+
return `${base}-${escaped}`;
|
|
37
|
+
}
|
|
38
|
+
export const DEFAULT_TOOLBAR_THRESHOLD = 40;
|
|
39
|
+
export const AUTO_DIFFERENCES_MIN_ROWS = 100;
|
|
40
|
+
export const ADDON_DENSE_THRESHOLD = 8;
|
|
41
|
+
export const ADDON_VISIBLE_CAP = 8;
|
|
42
|
+
export const DEFAULT_ADDON_FILTER_THRESHOLD = 13;
|
|
43
|
+
export function comparisonRowTotal(viewModel) {
|
|
44
|
+
return viewModel.comparisonGroups.reduce((total, group) => total + group.rows.length, 0);
|
|
45
|
+
}
|
|
46
|
+
// a missing, non-finite or negative value gives 40
|
|
47
|
+
export function toolbarThresholdOf(value) {
|
|
48
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
49
|
+
? value
|
|
50
|
+
: DEFAULT_TOOLBAR_THRESHOLD;
|
|
51
|
+
}
|
|
52
|
+
// a missing, non-finite or negative value gives 13
|
|
53
|
+
export function addOnFilterThresholdOf(value) {
|
|
54
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
55
|
+
? value
|
|
56
|
+
: DEFAULT_ADDON_FILTER_THRESHOLD;
|
|
57
|
+
}
|
|
58
|
+
// An explicit initial state wins. Otherwise a long comparison with several plans starts with only
|
|
59
|
+
// the differences, as long as the toolbar is there to switch the filter off.
|
|
60
|
+
export function resolveOnlyDifferences(viewModel, initial, toolbarThreshold = DEFAULT_TOOLBAR_THRESHOLD) {
|
|
61
|
+
if (initial !== undefined)
|
|
62
|
+
return initial;
|
|
63
|
+
const total = comparisonRowTotal(viewModel);
|
|
64
|
+
return (total > AUTO_DIFFERENCES_MIN_ROWS &&
|
|
65
|
+
viewModel.plans.length >= 2 &&
|
|
66
|
+
total > toolbarThresholdOf(toolbarThreshold));
|
|
67
|
+
}
|
|
3
68
|
function t(viewModel, key, parameters) {
|
|
4
69
|
return translate(viewModel.messages, key, parameters);
|
|
5
70
|
}
|
|
71
|
+
function addOnNames(viewModel, ids) {
|
|
72
|
+
return ids
|
|
73
|
+
.map((id) => viewModel.addOns.find((addOn) => addOn.id === id)?.name ?? humanizeIdentifier(id))
|
|
74
|
+
.join(', ');
|
|
75
|
+
}
|
|
6
76
|
const interactiveCardContent = 'a, button, input, select, textarea, label, summary, details, [contenteditable="true"]';
|
|
7
77
|
function selectFromCard(event, select) {
|
|
8
78
|
if (event.defaultPrevented || event.button !== 0)
|
|
@@ -28,7 +98,7 @@ function renderPrice(viewModel, price, unit) {
|
|
|
28
98
|
: nothing}
|
|
29
99
|
`;
|
|
30
100
|
}
|
|
31
|
-
function renderValue(viewModel, value, unit) {
|
|
101
|
+
function renderValue(viewModel, value, unit, addOnOnly = false) {
|
|
32
102
|
if (value === true) {
|
|
33
103
|
return html `<span class="pr-value pr-value--included"
|
|
34
104
|
><span aria-hidden="true">✓</span
|
|
@@ -36,6 +106,12 @@ function renderValue(viewModel, value, unit) {
|
|
|
36
106
|
>`;
|
|
37
107
|
}
|
|
38
108
|
if (value === false || value === null || value === undefined) {
|
|
109
|
+
if (addOnOnly) {
|
|
110
|
+
return html `<span class="pr-value pr-value--addon"
|
|
111
|
+
><span aria-hidden="true">${t(viewModel, 'pricing.addOnOnly')}</span
|
|
112
|
+
><span class="pr-sr-only">${t(viewModel, 'pricing.addOnOnlySr')}</span></span
|
|
113
|
+
>`;
|
|
114
|
+
}
|
|
39
115
|
return html `<span class="pr-value pr-value--excluded"
|
|
40
116
|
><span aria-hidden="true">—</span
|
|
41
117
|
><span class="pr-sr-only">${t(viewModel, 'pricing.notIncluded')}</span></span
|
|
@@ -49,7 +125,10 @@ function renderValue(viewModel, value, unit) {
|
|
|
49
125
|
}
|
|
50
126
|
if (typeof value === 'number') {
|
|
51
127
|
const formatted = new Intl.NumberFormat(viewModel.locale).format(value);
|
|
52
|
-
|
|
128
|
+
const label = unit
|
|
129
|
+
? ` ${formatUnit(unit, value, viewModel.locale, viewModel.presentation.unitLabels)}`
|
|
130
|
+
: '';
|
|
131
|
+
return html `<span class="pr-value">${formatted}${label}</span>`;
|
|
53
132
|
}
|
|
54
133
|
if (Array.isArray(value)) {
|
|
55
134
|
return html `<span class="pr-value">${value.map(String).join(', ')}</span>`;
|
|
@@ -63,6 +142,13 @@ function resolvedValuesDiffer(row) {
|
|
|
63
142
|
const values = row.values.map((cell) => cell.value);
|
|
64
143
|
return values.some((value) => !deepEqual(value, values[0]));
|
|
65
144
|
}
|
|
145
|
+
// A description that is empty or only repeats the row name adds nothing, so it is not rendered.
|
|
146
|
+
function rowDescription(row) {
|
|
147
|
+
const description = row.description?.trim();
|
|
148
|
+
if (!description)
|
|
149
|
+
return undefined;
|
|
150
|
+
return description.toLowerCase() === row.name.trim().toLowerCase() ? undefined : row.description;
|
|
151
|
+
}
|
|
66
152
|
function renderRowMetadata(viewModel, row) {
|
|
67
153
|
if (viewModel.mode !== 'catalog')
|
|
68
154
|
return nothing;
|
|
@@ -79,18 +165,30 @@ function renderRowMetadata(viewModel, row) {
|
|
|
79
165
|
? html `<span class="pr-row-metadata">${metadata.join(' · ')}</span>`
|
|
80
166
|
: nothing;
|
|
81
167
|
}
|
|
168
|
+
// also the text of the title that stays when the hero is hidden
|
|
169
|
+
export function heroTitle(viewModel) {
|
|
170
|
+
return (viewModel.presentation.title ??
|
|
171
|
+
viewModel.pricing.metadata.saasName ??
|
|
172
|
+
t(viewModel, 'pricing.title'));
|
|
173
|
+
}
|
|
82
174
|
export function renderHeader(state) {
|
|
83
175
|
const { viewModel } = state;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
t(viewModel, 'pricing.title');
|
|
176
|
+
const hidden = state.hiddenSections;
|
|
177
|
+
const title = heroTitle(viewModel);
|
|
87
178
|
const subtitle = viewModel.presentation.subtitle ?? t(viewModel, 'pricing.description');
|
|
179
|
+
// The title carries the id that labels the shell, so hiding the name keeps it for screen
|
|
180
|
+
// readers instead of dropping the element.
|
|
181
|
+
const titleHidden = hidden?.has('saasName') === true;
|
|
88
182
|
return html `
|
|
89
183
|
<header class="pr-hero" data-pr-part="hero">
|
|
90
|
-
|
|
91
|
-
<h2 class
|
|
92
|
-
|
|
93
|
-
|
|
184
|
+
${titleHidden ? nothing : html `<div class="pr-hero__eyebrow">${t(viewModel, 'pricing.title')}</div>`}
|
|
185
|
+
<h2 class=${titleHidden ? 'pr-sr-only' : 'pr-hero__title'} id=${instanceId(state, 'title')}>
|
|
186
|
+
${title}
|
|
187
|
+
</h2>
|
|
188
|
+
${subtitle && hidden?.has('description') !== true
|
|
189
|
+
? html `<p class="pr-hero__subtitle">${subtitle}</p>`
|
|
190
|
+
: nothing}
|
|
191
|
+
${viewModel.pricing.metadata.tags.length && hidden?.has('tags') !== true
|
|
94
192
|
? html `<ul class="pr-tag-list" aria-label="${t(viewModel, 'pricing.tags')}">
|
|
95
193
|
${viewModel.pricing.metadata.tags.map((tag) => html `<li class="pr-tag">${tag}</li>`)}
|
|
96
194
|
</ul>`
|
|
@@ -113,7 +211,7 @@ export function renderBilling(state) {
|
|
|
113
211
|
<label class="pr-segmented__option">
|
|
114
212
|
<input
|
|
115
213
|
type="radio"
|
|
116
|
-
name
|
|
214
|
+
name=${instanceId(state, 'billing')}
|
|
117
215
|
value=${period.id}
|
|
118
216
|
.checked=${selection.billingPeriod === period.id}
|
|
119
217
|
@change=${() => actions.selectBilling(period.id)}
|
|
@@ -129,14 +227,105 @@ export function renderBilling(state) {
|
|
|
129
227
|
</fieldset>
|
|
130
228
|
`;
|
|
131
229
|
}
|
|
230
|
+
// the pending text replaces the label of the busy control
|
|
231
|
+
function renderCtaLabel(viewModel, label, pending) {
|
|
232
|
+
return pending
|
|
233
|
+
? html `<span data-pr-part="cta-pending">${t(viewModel, 'pricing.pending')}</span>`
|
|
234
|
+
: label;
|
|
235
|
+
}
|
|
236
|
+
// aria-disabled replaces disabled while an action is pending, so
|
|
237
|
+
// the control that became pending keeps the focus
|
|
238
|
+
function renderPlanCta(state, plan) {
|
|
239
|
+
const { viewModel, actions } = state;
|
|
240
|
+
const cta = planCtaView(viewModel, plan, {
|
|
241
|
+
currentPlanId: state.currentPlanId,
|
|
242
|
+
pendingActionId: state.pendingActionId,
|
|
243
|
+
});
|
|
244
|
+
// data-kind agrees with the pr-button--<kind> class
|
|
245
|
+
const kind = cta.kind === 'secondary' ? 'secondary' : 'primary';
|
|
246
|
+
const origin = cta.relation
|
|
247
|
+
? { relation: cta.relation, source: 'plan' }
|
|
248
|
+
: undefined;
|
|
249
|
+
const content = renderCtaLabel(viewModel, cta.label, cta.pending);
|
|
250
|
+
// The plan the visitor is already on has nothing to press. A disabled button invites a click
|
|
251
|
+
// that will never work and repeats the "your plan" badge in the shape of an action, so the
|
|
252
|
+
// slot keeps its height with plain text instead.
|
|
253
|
+
if (cta.disabled && cta.relation === 'current') {
|
|
254
|
+
return html `<p
|
|
255
|
+
class="pr-plan-card__current"
|
|
256
|
+
data-pr-part="cta"
|
|
257
|
+
data-kind=${kind}
|
|
258
|
+
data-relation="current"
|
|
259
|
+
>
|
|
260
|
+
${content}
|
|
261
|
+
</p>`;
|
|
262
|
+
}
|
|
263
|
+
const trigger = () => actions.triggerAction(cta.actionId, plan.id, cta.eventHref, cta.metadata, origin);
|
|
264
|
+
return cta.href
|
|
265
|
+
? html `<a
|
|
266
|
+
class="pr-button pr-button--${kind}"
|
|
267
|
+
data-pr-part="cta"
|
|
268
|
+
data-kind=${kind}
|
|
269
|
+
data-relation=${cta.relation ?? nothing}
|
|
270
|
+
data-pending=${cta.pending ? 'true' : nothing}
|
|
271
|
+
aria-busy=${cta.pending ? 'true' : nothing}
|
|
272
|
+
aria-disabled=${cta.ariaDisabled ? 'true' : nothing}
|
|
273
|
+
aria-label=${cta.ariaLabel ?? nothing}
|
|
274
|
+
href=${cta.href}
|
|
275
|
+
target=${cta.target ?? '_self'}
|
|
276
|
+
rel=${cta.target === '_blank' ? 'noopener noreferrer' : nothing}
|
|
277
|
+
@click=${(event) => {
|
|
278
|
+
if (!trigger())
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
}}
|
|
281
|
+
>${content}</a
|
|
282
|
+
>`
|
|
283
|
+
: html `<button
|
|
284
|
+
class="pr-button pr-button--${kind}"
|
|
285
|
+
data-pr-part="cta"
|
|
286
|
+
data-kind=${kind}
|
|
287
|
+
data-relation=${cta.relation ?? nothing}
|
|
288
|
+
data-pending=${cta.pending ? 'true' : nothing}
|
|
289
|
+
aria-busy=${cta.pending ? 'true' : nothing}
|
|
290
|
+
aria-disabled=${cta.ariaDisabled ? 'true' : nothing}
|
|
291
|
+
aria-label=${cta.ariaLabel ?? nothing}
|
|
292
|
+
type="button"
|
|
293
|
+
?disabled=${cta.disabled}
|
|
294
|
+
@click=${trigger}
|
|
295
|
+
>
|
|
296
|
+
${content}
|
|
297
|
+
</button>`;
|
|
298
|
+
}
|
|
299
|
+
// An empty node the host fills; it carries the state of the built-in CTA and card as data-*. The
|
|
300
|
+
// keyed directive gives each plan its own node, so a node never moves to another plan.
|
|
301
|
+
function renderPlanCtaSlot(state, plan, selected, current) {
|
|
302
|
+
const cta = planCtaView(state.viewModel, plan, {
|
|
303
|
+
currentPlanId: state.currentPlanId,
|
|
304
|
+
pendingActionId: state.pendingActionId,
|
|
305
|
+
});
|
|
306
|
+
return keyedBy(plan.id, html `<div
|
|
307
|
+
data-pr-slot="plan-cta"
|
|
308
|
+
data-pr-part="plan-cta-slot"
|
|
309
|
+
data-plan-id=${plan.id}
|
|
310
|
+
data-plan-name=${plan.name}
|
|
311
|
+
data-label=${cta.label}
|
|
312
|
+
data-href=${cta.href ?? nothing}
|
|
313
|
+
data-relation=${cta.relation ?? nothing}
|
|
314
|
+
data-selected=${String(selected)}
|
|
315
|
+
data-current=${current ? 'true' : nothing}
|
|
316
|
+
data-pending=${cta.pending ? 'true' : nothing}
|
|
317
|
+
data-any-pending=${cta.ariaDisabled ? 'true' : nothing}
|
|
318
|
+
></div>`);
|
|
319
|
+
}
|
|
132
320
|
export function renderPlans(state) {
|
|
133
321
|
const { viewModel, selection, selectionEnabled, ctaEnabled, actions } = state;
|
|
322
|
+
const titleId = instanceId(state, 'plans-title');
|
|
134
323
|
return html `
|
|
135
|
-
<section class="pr-section" aria-labelledby
|
|
324
|
+
<section class="pr-section" aria-labelledby=${titleId} data-pr-part="plans">
|
|
136
325
|
<div class="pr-section__heading">
|
|
137
326
|
<div>
|
|
138
327
|
<p class="pr-section__eyebrow">${t(viewModel, 'pricing.plans')}</p>
|
|
139
|
-
<h3 id
|
|
328
|
+
<h3 id=${titleId}>${t(viewModel, 'pricing.plansTitle')}</h3>
|
|
140
329
|
</div>
|
|
141
330
|
</div>
|
|
142
331
|
${viewModel.plans.length === 0
|
|
@@ -144,28 +333,37 @@ export function renderPlans(state) {
|
|
|
144
333
|
: html `<div
|
|
145
334
|
class="pr-plan-grid"
|
|
146
335
|
role=${selectionEnabled ? 'radiogroup' : nothing}
|
|
147
|
-
aria-labelledby=${selectionEnabled ?
|
|
336
|
+
aria-labelledby=${selectionEnabled ? titleId : nothing}
|
|
148
337
|
>
|
|
149
338
|
${viewModel.plans.map((plan) => {
|
|
150
339
|
const selected = selection.planId === plan.id;
|
|
151
|
-
const
|
|
340
|
+
const current = state.currentPlanId === plan.id;
|
|
152
341
|
const highlightSummary = getPlanHighlightSummary(viewModel, plan.id);
|
|
153
342
|
const featured = highlightSummary.badges.some((badge) => badge.emphasize);
|
|
154
|
-
const actionId = cta?.id ?? `choose-${plan.id}`;
|
|
155
343
|
return html `
|
|
156
344
|
<article
|
|
157
345
|
class="pr-plan-card ${selectionEnabled ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${featured ? 'is-featured' : ''}"
|
|
158
346
|
data-pr-part="plan-card"
|
|
159
347
|
data-plan-id=${plan.id}
|
|
160
348
|
data-selected=${String(selected)}
|
|
349
|
+
data-current=${current ? 'true' : nothing}
|
|
350
|
+
data-featured=${String(featured)}
|
|
161
351
|
@click=${(event) => selectionEnabled
|
|
162
352
|
? selectFromCard(event, () => actions.selectPlan(plan.id))
|
|
163
353
|
: undefined}
|
|
164
354
|
>
|
|
165
355
|
<div class="pr-plan-card__top">
|
|
166
|
-
${highlightSummary.badges.length > 0 || selectionEnabled
|
|
356
|
+
${highlightSummary.badges.length > 0 || selectionEnabled || current
|
|
167
357
|
? html `<div class="pr-plan-card__status">
|
|
168
358
|
<div class="pr-plan-card__badges">
|
|
359
|
+
${current
|
|
360
|
+
? html `<span
|
|
361
|
+
class="pr-badge"
|
|
362
|
+
data-pr-part="current-badge"
|
|
363
|
+
data-tone="success"
|
|
364
|
+
>${t(viewModel, 'pricing.currentPlanBadge')}</span
|
|
365
|
+
>`
|
|
366
|
+
: nothing}
|
|
169
367
|
${highlightSummary.badges.map((badge) => html `<span
|
|
170
368
|
class="pr-badge"
|
|
171
369
|
data-pr-part="plan-badge"
|
|
@@ -174,19 +372,21 @@ export function renderPlans(state) {
|
|
|
174
372
|
>${badge.label}</span
|
|
175
373
|
>`)}
|
|
176
374
|
</div>
|
|
177
|
-
${
|
|
375
|
+
${
|
|
376
|
+
// live() resets the radio on cancel
|
|
377
|
+
selectionEnabled
|
|
178
378
|
? html `<label
|
|
179
379
|
class="pr-selection-control pr-plan-card__selector"
|
|
180
380
|
data-pr-part="selection-control"
|
|
181
381
|
>
|
|
182
382
|
<input
|
|
183
383
|
type="radio"
|
|
184
|
-
name
|
|
384
|
+
name=${instanceId(state, 'plan')}
|
|
185
385
|
value=${plan.id}
|
|
186
386
|
aria-label=${t(viewModel, 'pricing.choosePlan', {
|
|
187
387
|
plan: plan.name,
|
|
188
388
|
})}
|
|
189
|
-
.checked=${selected}
|
|
389
|
+
.checked=${live(selected)}
|
|
190
390
|
@change=${() => actions.selectPlan(plan.id)}
|
|
191
391
|
/>
|
|
192
392
|
<span
|
|
@@ -195,7 +395,13 @@ export function renderPlans(state) {
|
|
|
195
395
|
></span>
|
|
196
396
|
<span class="pr-selection-control__label" aria-hidden="true"
|
|
197
397
|
>${selected
|
|
198
|
-
?
|
|
398
|
+
? // On the contract's own plan the "your plan" badge
|
|
399
|
+
// already says it, and the radio is visibly checked:
|
|
400
|
+
// a third "Selected" adds nothing. It comes back as
|
|
401
|
+
// soon as the visitor selects a different plan.
|
|
402
|
+
current
|
|
403
|
+
? nothing
|
|
404
|
+
: t(viewModel, 'pricing.selected')
|
|
199
405
|
: t(viewModel, 'pricing.select')}</span
|
|
200
406
|
>
|
|
201
407
|
</label>`
|
|
@@ -225,7 +431,14 @@ export function renderPlans(state) {
|
|
|
225
431
|
<span class="pr-highlight-list__icon" aria-hidden="true">✓</span>
|
|
226
432
|
<span>${row.name}</span>
|
|
227
433
|
<span class="pr-highlight-list__value"
|
|
228
|
-
>${
|
|
434
|
+
>${
|
|
435
|
+
// The bullet already means "included", so a plain true adds
|
|
436
|
+
// no visible information; only its screen-reader text stays.
|
|
437
|
+
value === true
|
|
438
|
+
? html `<span class="pr-sr-only"
|
|
439
|
+
>${t(viewModel, 'pricing.included')}</span
|
|
440
|
+
>`
|
|
441
|
+
: renderValue(viewModel, value, row.unit)}</span
|
|
229
442
|
>
|
|
230
443
|
</li>`;
|
|
231
444
|
})}
|
|
@@ -233,32 +446,9 @@ export function renderPlans(state) {
|
|
|
233
446
|
: nothing}
|
|
234
447
|
${!ctaEnabled
|
|
235
448
|
? nothing
|
|
236
|
-
:
|
|
237
|
-
?
|
|
238
|
-
|
|
239
|
-
? 'pr-button--secondary'
|
|
240
|
-
: 'pr-button--primary'}"
|
|
241
|
-
data-pr-part="cta"
|
|
242
|
-
href=${cta.href}
|
|
243
|
-
target=${cta.target ?? '_self'}
|
|
244
|
-
rel=${cta.target === '_blank' ? 'noopener noreferrer' : nothing}
|
|
245
|
-
@click=${(event) => {
|
|
246
|
-
if (!actions.triggerAction(actionId, plan.id, cta.href, cta.metadata)) {
|
|
247
|
-
event.preventDefault();
|
|
248
|
-
}
|
|
249
|
-
}}
|
|
250
|
-
>${cta.label}</a
|
|
251
|
-
>`
|
|
252
|
-
: html `<button
|
|
253
|
-
class="pr-button ${cta?.kind === 'secondary'
|
|
254
|
-
? 'pr-button--secondary'
|
|
255
|
-
: 'pr-button--primary'}"
|
|
256
|
-
data-pr-part="cta"
|
|
257
|
-
type="button"
|
|
258
|
-
@click=${() => actions.triggerAction(actionId, plan.id, cta?.href, cta?.metadata)}
|
|
259
|
-
>
|
|
260
|
-
${cta?.label ?? t(viewModel, 'pricing.choosePlan', { plan: plan.name })}
|
|
261
|
-
</button>`}
|
|
449
|
+
: state.slots?.has('plan-cta')
|
|
450
|
+
? renderPlanCtaSlot(state, plan, selected, current)
|
|
451
|
+
: renderPlanCta(state, plan)}
|
|
262
452
|
</article>
|
|
263
453
|
`;
|
|
264
454
|
})}
|
|
@@ -269,12 +459,16 @@ export function renderPlans(state) {
|
|
|
269
459
|
function controlInput(state, control, invalid) {
|
|
270
460
|
const { selection, actions } = state;
|
|
271
461
|
const value = getAtPath(selection.variables, control.path);
|
|
272
|
-
const id =
|
|
462
|
+
const id = instanceId(state, 'variable', control.path);
|
|
463
|
+
const describedBy = [control.description ? `${id}-help` : '', invalid ? `${id}-error` : '']
|
|
464
|
+
.filter(Boolean)
|
|
465
|
+
.join(' ') || nothing;
|
|
273
466
|
if (control.type === 'boolean') {
|
|
274
467
|
return html `<label class="pr-switch" for=${id}>
|
|
275
468
|
<input
|
|
276
|
-
id
|
|
469
|
+
id="${id}"
|
|
277
470
|
type="checkbox"
|
|
471
|
+
aria-describedby="${describedBy}"
|
|
278
472
|
aria-invalid=${invalid}
|
|
279
473
|
.checked=${Boolean(value)}
|
|
280
474
|
@change=${(event) => actions.updateVariable(control.path, event.currentTarget.checked)}
|
|
@@ -284,7 +478,8 @@ function controlInput(state, control, invalid) {
|
|
|
284
478
|
}
|
|
285
479
|
if (control.type === 'select') {
|
|
286
480
|
return html `<select
|
|
287
|
-
id
|
|
481
|
+
id="${id}"
|
|
482
|
+
aria-describedby="${describedBy}"
|
|
288
483
|
aria-invalid=${invalid}
|
|
289
484
|
.value=${String(value ?? '')}
|
|
290
485
|
@change=${(event) => {
|
|
@@ -302,8 +497,9 @@ function controlInput(state, control, invalid) {
|
|
|
302
497
|
const numericValue = typeof value === 'number' ? value : Number(value) || 0;
|
|
303
498
|
return html `<div class="pr-slider">
|
|
304
499
|
<input
|
|
305
|
-
id
|
|
500
|
+
id="${id}"
|
|
306
501
|
type="range"
|
|
502
|
+
aria-describedby="${describedBy}"
|
|
307
503
|
aria-invalid=${invalid}
|
|
308
504
|
.value=${String(numericValue)}
|
|
309
505
|
min=${control.min ?? 0}
|
|
@@ -313,6 +509,7 @@ function controlInput(state, control, invalid) {
|
|
|
313
509
|
/>
|
|
314
510
|
<input
|
|
315
511
|
type="number"
|
|
512
|
+
aria-describedby="${describedBy}"
|
|
316
513
|
aria-invalid=${invalid}
|
|
317
514
|
aria-label=${control.label ?? control.path}
|
|
318
515
|
.value=${String(numericValue)}
|
|
@@ -325,7 +522,8 @@ function controlInput(state, control, invalid) {
|
|
|
325
522
|
}
|
|
326
523
|
const isNumber = control.type === 'number';
|
|
327
524
|
return html `<input
|
|
328
|
-
id
|
|
525
|
+
id="${id}"
|
|
526
|
+
aria-describedby="${describedBy}"
|
|
329
527
|
aria-invalid=${invalid}
|
|
330
528
|
type=${isNumber ? 'number' : 'text'}
|
|
331
529
|
.value=${String(value ?? '')}
|
|
@@ -342,17 +540,18 @@ export function renderVariables(state) {
|
|
|
342
540
|
const { viewModel, variablesEnabled, actions } = state;
|
|
343
541
|
if (!variablesEnabled || viewModel.variableControls.length === 0)
|
|
344
542
|
return nothing;
|
|
543
|
+
const titleId = instanceId(state, 'variables-title');
|
|
345
544
|
return html `
|
|
346
|
-
<section class="pr-configurator" aria-labelledby
|
|
545
|
+
<section class="pr-configurator" aria-labelledby=${titleId} data-pr-part="variables">
|
|
347
546
|
<div class="pr-section__heading">
|
|
348
|
-
<h3 id
|
|
547
|
+
<h3 id=${titleId}>${t(viewModel, 'pricing.variables')}</h3>
|
|
349
548
|
<button class="pr-button pr-button--quiet" type="button" @click=${actions.resetVariables}>
|
|
350
549
|
${t(viewModel, 'pricing.reset')}
|
|
351
550
|
</button>
|
|
352
551
|
</div>
|
|
353
552
|
<div class="pr-variable-grid">
|
|
354
553
|
${viewModel.variableControls.map((control) => {
|
|
355
|
-
const id =
|
|
554
|
+
const id = instanceId(state, 'variable', control.path);
|
|
356
555
|
const invalid = viewModel.resolved.diagnostics.some((item) => {
|
|
357
556
|
const planMatch = item.path?.match(/^plans\.([^.]+)\.price$/)?.[1];
|
|
358
557
|
const addOnMatch = item.path?.match(/^addOns\.([^.]+)\.price$/)?.[1];
|
|
@@ -369,7 +568,7 @@ export function renderVariables(state) {
|
|
|
369
568
|
${control.description ? html `<p id="${id}-help">${control.description}</p>` : nothing}
|
|
370
569
|
${controlInput(state, control, invalid)}
|
|
371
570
|
${invalid
|
|
372
|
-
? html `<p class="pr-field__error" role="alert">
|
|
571
|
+
? html `<p id="${id}-error" class="pr-field__error" role="alert">
|
|
373
572
|
${t(viewModel, 'pricing.variableError')}
|
|
374
573
|
</p>`
|
|
375
574
|
: nothing}
|
|
@@ -397,10 +596,24 @@ function filterGroups(state) {
|
|
|
397
596
|
function visibleRows(state, group) {
|
|
398
597
|
return state.revealedGroups.has(group.id) ? group.rows : group.rows.slice(0, 30);
|
|
399
598
|
}
|
|
400
|
-
function
|
|
401
|
-
|
|
402
|
-
|
|
599
|
+
function isHeaderless(group) {
|
|
600
|
+
return group.kind === 'untagged';
|
|
601
|
+
}
|
|
602
|
+
// a group with no header cannot be toggled
|
|
603
|
+
function isGroupExpanded(state, group) {
|
|
604
|
+
return isHeaderless(group) || state.expandedGroups.has(group.id) || Boolean(state.search);
|
|
605
|
+
}
|
|
606
|
+
// The rows that are on the page: those of expanded groups, after the cap per group.
|
|
607
|
+
function renderedRowCount(state, groups) {
|
|
608
|
+
return groups.reduce((count, group) => count + (isGroupExpanded(state, group) ? visibleRows(state, group).length : 0), 0);
|
|
609
|
+
}
|
|
610
|
+
function renderComparisonToolbar(state, groups) {
|
|
403
611
|
const { viewModel, actions } = state;
|
|
612
|
+
const total = comparisonRowTotal(viewModel);
|
|
613
|
+
// a short comparison gets no toolbar and looks as before
|
|
614
|
+
if (total <= toolbarThresholdOf(state.toolbarThreshold))
|
|
615
|
+
return nothing;
|
|
616
|
+
const shown = renderedRowCount(state, groups);
|
|
404
617
|
return html `
|
|
405
618
|
<div class="pr-comparison-toolbar" data-pr-part="comparison-toolbar">
|
|
406
619
|
<label class="pr-search">
|
|
@@ -420,15 +633,17 @@ function renderComparisonToolbar(state, totalRows) {
|
|
|
420
633
|
/>
|
|
421
634
|
<span>${t(viewModel, 'pricing.onlyDifferences')}</span>
|
|
422
635
|
</label>
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
636
|
+
${viewModel.comparisonGroups.every(isHeaderless)
|
|
637
|
+
? nothing
|
|
638
|
+
: html `<div class="pr-toolbar-actions">
|
|
639
|
+
<button type="button" @click=${() => actions.setAllGroups(true)}>
|
|
640
|
+
${t(viewModel, 'pricing.expandAll')}
|
|
641
|
+
</button>
|
|
642
|
+
<button type="button" @click=${() => actions.setAllGroups(false)}>
|
|
643
|
+
${t(viewModel, 'pricing.collapseAll')}
|
|
644
|
+
</button>
|
|
645
|
+
</div>`}
|
|
646
|
+
<span aria-live="polite">${t(viewModel, 'pricing.rowsShownOf', { shown, total })}</span>
|
|
432
647
|
</div>
|
|
433
648
|
`;
|
|
434
649
|
}
|
|
@@ -436,50 +651,58 @@ function renderCompactComparison(state, groups) {
|
|
|
436
651
|
const { viewModel, actions } = state;
|
|
437
652
|
return html `<div class="pr-compact-comparison">
|
|
438
653
|
${groups.map((group) => {
|
|
439
|
-
const expanded = state
|
|
654
|
+
const expanded = isGroupExpanded(state, group);
|
|
440
655
|
const rows = visibleRows(state, group);
|
|
441
656
|
return html `
|
|
442
657
|
<section class="pr-comparison-group">
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
658
|
+
${isHeaderless(group)
|
|
659
|
+
? nothing
|
|
660
|
+
: html `<h4>
|
|
661
|
+
<button
|
|
662
|
+
type="button"
|
|
663
|
+
aria-expanded=${expanded}
|
|
664
|
+
aria-controls=${expanded ? instanceId(state, 'group', group.id) : nothing}
|
|
665
|
+
@click=${() => actions.toggleGroup(group.id)}
|
|
666
|
+
>
|
|
667
|
+
<span>${group.name}</span
|
|
668
|
+
><span aria-hidden="true">${expanded ? '−' : '+'}</span>
|
|
669
|
+
</button>
|
|
670
|
+
</h4>`}
|
|
453
671
|
${expanded
|
|
454
|
-
? html `<div id="
|
|
455
|
-
${rows.map((row) =>
|
|
672
|
+
? html `<div id="${instanceId(state, 'group', group.id)}">
|
|
673
|
+
${rows.map((row) => {
|
|
674
|
+
const addOnOnly = isAddOnOnlyRow(viewModel, row);
|
|
675
|
+
return html `
|
|
456
676
|
<article class="pr-feature-card" data-pr-part="comparison-row">
|
|
457
677
|
<div>
|
|
458
678
|
<h5>${row.name}</h5>
|
|
459
|
-
${row
|
|
679
|
+
${rowDescription(row) ? html `<p>${rowDescription(row)}</p>` : nothing}
|
|
460
680
|
${renderRowMetadata(viewModel, row)}
|
|
461
681
|
${row.docUrl && isSafeLink(row.docUrl)
|
|
462
|
-
|
|
682
|
+
? html `<a href=${row.docUrl} target="_blank" rel="noopener noreferrer"
|
|
463
683
|
>${t(viewModel, 'pricing.documentation')}</a
|
|
464
684
|
>`
|
|
465
|
-
|
|
685
|
+
: nothing}
|
|
466
686
|
</div>
|
|
467
687
|
<dl>
|
|
468
688
|
${row.values.map((cell) => {
|
|
469
|
-
|
|
470
|
-
|
|
689
|
+
const plan = viewModel.plans.find((candidate) => candidate.id === cell.planId);
|
|
690
|
+
return html `<div>
|
|
471
691
|
<dt>${plan?.name ?? cell.planId}</dt>
|
|
472
|
-
<dd
|
|
692
|
+
<dd>
|
|
693
|
+
${renderValue(viewModel, cell.value, row.unit, addOnOnly && addOnProvidesForPlan(viewModel, row, cell.planId))}
|
|
694
|
+
</dd>
|
|
473
695
|
</div>`;
|
|
474
|
-
|
|
696
|
+
})}
|
|
475
697
|
</dl>
|
|
476
698
|
</article>
|
|
477
|
-
|
|
699
|
+
`;
|
|
700
|
+
})}
|
|
478
701
|
${group.rows.length > rows.length
|
|
479
702
|
? html `<button
|
|
480
703
|
class="pr-button pr-button--quiet"
|
|
481
704
|
type="button"
|
|
482
|
-
@click=${() => actions.revealGroup(group.id)}
|
|
705
|
+
@click=${(event) => actions.revealGroup(group.id, event.currentTarget, event.detail === 0)}
|
|
483
706
|
>
|
|
484
707
|
${t(viewModel, 'pricing.showRemaining', {
|
|
485
708
|
count: group.rows.length - rows.length,
|
|
@@ -513,33 +736,42 @@ function renderTableComparison(state, groups) {
|
|
|
513
736
|
</tr>
|
|
514
737
|
</thead>
|
|
515
738
|
${groups.map((group) => {
|
|
516
|
-
const expanded = state
|
|
739
|
+
const expanded = isGroupExpanded(state, group);
|
|
517
740
|
const rows = visibleRows(state, group);
|
|
518
741
|
return html `
|
|
519
742
|
<tbody>
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
743
|
+
${isHeaderless(group)
|
|
744
|
+
? nothing
|
|
745
|
+
: html `<tr class="pr-category-row">
|
|
746
|
+
<th colspan=${viewModel.plans.length + 1}>
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
aria-expanded=${expanded}
|
|
750
|
+
@click=${() => actions.toggleGroup(group.id)}
|
|
751
|
+
>
|
|
752
|
+
<span>${group.name}</span
|
|
753
|
+
><span aria-hidden="true">${expanded ? '−' : '+'}</span>
|
|
754
|
+
</button>
|
|
755
|
+
</th>
|
|
756
|
+
</tr>`}
|
|
532
757
|
${expanded
|
|
533
|
-
? rows.map((row) =>
|
|
758
|
+
? rows.map((row) => {
|
|
759
|
+
const addOnOnly = isAddOnOnlyRow(viewModel, row);
|
|
760
|
+
return html `
|
|
534
761
|
<tr data-pr-part="comparison-row">
|
|
535
762
|
<th scope="row">
|
|
536
763
|
<span>${row.name}</span>
|
|
537
|
-
${row
|
|
764
|
+
${rowDescription(row)
|
|
765
|
+
? html `<small>${rowDescription(row)}</small>`
|
|
766
|
+
: nothing}
|
|
538
767
|
${renderRowMetadata(viewModel, row)}
|
|
539
768
|
</th>
|
|
540
|
-
${row.values.map((cell) => html `<td
|
|
769
|
+
${row.values.map((cell) => html `<td>
|
|
770
|
+
${renderValue(viewModel, cell.value, row.unit, addOnOnly && addOnProvidesForPlan(viewModel, row, cell.planId))}
|
|
771
|
+
</td>`)}
|
|
541
772
|
</tr>
|
|
542
|
-
|
|
773
|
+
`;
|
|
774
|
+
})
|
|
543
775
|
: nothing}
|
|
544
776
|
${expanded && group.rows.length > rows.length
|
|
545
777
|
? html `<tr>
|
|
@@ -547,7 +779,7 @@ function renderTableComparison(state, groups) {
|
|
|
547
779
|
<button
|
|
548
780
|
class="pr-button pr-button--quiet"
|
|
549
781
|
type="button"
|
|
550
|
-
@click=${() => actions.revealGroup(group.id)}
|
|
782
|
+
@click=${(event) => actions.revealGroup(group.id, event.currentTarget, event.detail === 0)}
|
|
551
783
|
>
|
|
552
784
|
${t(viewModel, 'pricing.showRemaining', {
|
|
553
785
|
count: group.rows.length - rows.length,
|
|
@@ -566,32 +798,67 @@ function renderTableComparison(state, groups) {
|
|
|
566
798
|
export function renderComparison(state) {
|
|
567
799
|
const { viewModel } = state;
|
|
568
800
|
const groups = filterGroups(state);
|
|
569
|
-
const totalRows = groups.reduce((total, group) => total + group.rows.length, 0);
|
|
570
801
|
if (viewModel.comparisonGroups.length === 0)
|
|
571
802
|
return nothing;
|
|
803
|
+
const titleId = instanceId(state, 'comparison-title');
|
|
572
804
|
return html `
|
|
573
|
-
<section
|
|
574
|
-
class="pr-section pr-comparison"
|
|
575
|
-
aria-labelledby="pr-comparison-title"
|
|
576
|
-
data-pr-part="comparison"
|
|
577
|
-
>
|
|
805
|
+
<section class="pr-section pr-comparison" aria-labelledby=${titleId} data-pr-part="comparison">
|
|
578
806
|
<div class="pr-section__heading">
|
|
579
807
|
<div>
|
|
580
808
|
<p class="pr-section__eyebrow">${t(viewModel, 'pricing.details')}</p>
|
|
581
|
-
<h3 id
|
|
809
|
+
<h3 id=${titleId}>${t(viewModel, 'pricing.comparison')}</h3>
|
|
582
810
|
</div>
|
|
583
811
|
</div>
|
|
584
|
-
${
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
812
|
+
${viewModel.plans.length === 0
|
|
813
|
+
? html `<p class="pr-empty">${t(viewModel, 'pricing.noComparison')}</p>`
|
|
814
|
+
: html `${renderComparisonToolbar(state, groups)}
|
|
815
|
+
${groups.length === 0
|
|
816
|
+
? html `<p class="pr-empty" role="status" data-pr-part="comparison-empty">
|
|
817
|
+
${t(viewModel, 'pricing.noMatches')}
|
|
818
|
+
</p>`
|
|
819
|
+
: state.compact
|
|
820
|
+
? renderCompactComparison(state, groups)
|
|
821
|
+
: renderTableComparison(state, groups)}`}
|
|
588
822
|
</section>
|
|
589
823
|
`;
|
|
590
824
|
}
|
|
591
825
|
function addOnAvailability(addOn, planId) {
|
|
592
826
|
return (addOn.availableFor.length === 0 || (planId !== undefined && addOn.availableFor.includes(planId)));
|
|
593
827
|
}
|
|
594
|
-
function
|
|
828
|
+
function hasAddOnNotes(addOn) {
|
|
829
|
+
return addOn.availableFor.length > 0 || addOn.dependsOn.length > 0 || addOn.excludes.length > 0;
|
|
830
|
+
}
|
|
831
|
+
// The availability, dependency and exclusion paragraphs of an add-on card. The quantity relation
|
|
832
|
+
// is not here: it governs what the reader can pick, so it belongs on the face of the card, which
|
|
833
|
+
// renderAddOnCard draws, not behind the disclosure this prose sits in when the grid is dense.
|
|
834
|
+
function renderAddOnNotes(viewModel, addOn) {
|
|
835
|
+
return html `
|
|
836
|
+
${addOn.availableFor.length
|
|
837
|
+
? html `<p>
|
|
838
|
+
${t(viewModel, 'pricing.availableFor', {
|
|
839
|
+
plans: addOn.availableFor
|
|
840
|
+
.map((id) => viewModel.pricing.plans.find((plan) => plan.id === id)?.name ?? id)
|
|
841
|
+
.join(', '),
|
|
842
|
+
})}
|
|
843
|
+
</p>`
|
|
844
|
+
: nothing}
|
|
845
|
+
${addOn.dependsOn.length
|
|
846
|
+
? html `<p>
|
|
847
|
+
${t(viewModel, 'pricing.dependsOn', {
|
|
848
|
+
items: addOnNames(viewModel, addOn.dependsOn),
|
|
849
|
+
})}
|
|
850
|
+
</p>`
|
|
851
|
+
: nothing}
|
|
852
|
+
${addOn.excludes.length
|
|
853
|
+
? html `<p>
|
|
854
|
+
${t(viewModel, 'pricing.excludes', {
|
|
855
|
+
items: addOnNames(viewModel, addOn.excludes),
|
|
856
|
+
})}
|
|
857
|
+
</p>`
|
|
858
|
+
: nothing}
|
|
859
|
+
`;
|
|
860
|
+
}
|
|
861
|
+
function renderAddOnEffects(viewModel, addOn, dense = false) {
|
|
595
862
|
const effects = [
|
|
596
863
|
...Object.entries(addOn.features).map(([id, value]) => ({
|
|
597
864
|
kind: t(viewModel, 'pricing.featureEffect'),
|
|
@@ -609,6 +876,27 @@ function renderAddOnEffects(viewModel, addOn) {
|
|
|
609
876
|
value: unwrapValue(value),
|
|
610
877
|
})),
|
|
611
878
|
];
|
|
879
|
+
if (dense) {
|
|
880
|
+
// the paragraphs join the effects behind one disclosure
|
|
881
|
+
if (effects.length === 0 && !hasAddOnNotes(addOn))
|
|
882
|
+
return nothing;
|
|
883
|
+
return html `
|
|
884
|
+
<details class="pr-addon-effects">
|
|
885
|
+
<summary>${t(viewModel, 'pricing.moreDetails')}</summary>
|
|
886
|
+
${hasAddOnNotes(addOn)
|
|
887
|
+
? html `<div class="pr-addon-notes">${renderAddOnNotes(viewModel, addOn)}</div>`
|
|
888
|
+
: nothing}
|
|
889
|
+
${effects.length
|
|
890
|
+
? html `<dl>
|
|
891
|
+
${effects.map((effect) => html `<div>
|
|
892
|
+
<dt>${effect.kind}: ${humanizeIdentifier(effect.id)}</dt>
|
|
893
|
+
<dd>${renderValue(viewModel, effect.value)}</dd>
|
|
894
|
+
</div>`)}
|
|
895
|
+
</dl>`
|
|
896
|
+
: nothing}
|
|
897
|
+
</details>
|
|
898
|
+
`;
|
|
899
|
+
}
|
|
612
900
|
if (effects.length === 0)
|
|
613
901
|
return nothing;
|
|
614
902
|
return html `
|
|
@@ -623,157 +911,369 @@ function renderAddOnEffects(viewModel, addOn) {
|
|
|
623
911
|
</details>
|
|
624
912
|
`;
|
|
625
913
|
}
|
|
626
|
-
|
|
914
|
+
function renderAddOnCard(state, addOn, dense) {
|
|
627
915
|
const { viewModel, selection, selectionEnabled, actions } = state;
|
|
628
|
-
|
|
916
|
+
const selected = selection.addOns[addOn.id]?.selected ?? false;
|
|
917
|
+
const addOnSelection = selection.addOns[addOn.id];
|
|
918
|
+
const quantity = addOnSelection
|
|
919
|
+
? normalizeAddOnQuantity(viewModel.pricing, addOn.id, {
|
|
920
|
+
...addOnSelection,
|
|
921
|
+
selected: true,
|
|
922
|
+
})
|
|
923
|
+
: (addOn.subscriptionConstraints?.minQuantity ?? 1);
|
|
924
|
+
const availableForPlan = addOnAvailability(addOn, selection.planId);
|
|
925
|
+
// A selected add-on is never blocked: it has to stay operable, or there would be no way to
|
|
926
|
+
// remove the very thing that is blocking the others.
|
|
927
|
+
const blockedBy = selected ? [] : blockingAddOnIds(viewModel.pricing, selection, addOn.id);
|
|
928
|
+
const available = availableForPlan && blockedBy.length === 0;
|
|
929
|
+
const constraints = addOn.subscriptionConstraints;
|
|
930
|
+
const current = state.currentAddOnIds?.has(addOn.id) ?? false;
|
|
931
|
+
// data-available is the negation of is-unavailable
|
|
932
|
+
const helpId = instanceId(state, 'addon-help', addOn.id);
|
|
933
|
+
return html `
|
|
934
|
+
<article
|
|
935
|
+
class="pr-addon-card ${selectionEnabled && available ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${available ? '' : 'is-unavailable'}${dense ? ' pr-addon-card--dense' : ''}"
|
|
936
|
+
data-pr-part="add-on-card"
|
|
937
|
+
data-add-on-id=${addOn.id}
|
|
938
|
+
data-selected=${String(selected)}
|
|
939
|
+
data-current=${current ? 'true' : nothing}
|
|
940
|
+
data-available=${String(available)}
|
|
941
|
+
@click=${(event) => selectionEnabled && available
|
|
942
|
+
? selectFromCard(event, () => actions.toggleAddOn(addOn.id, !selected))
|
|
943
|
+
: undefined}
|
|
944
|
+
>
|
|
945
|
+
<div class="pr-addon-card__heading">
|
|
946
|
+
<div>
|
|
947
|
+
${current
|
|
948
|
+
? html `<span class="pr-badge" data-pr-part="current-add-on-badge" data-tone="success"
|
|
949
|
+
>${t(viewModel, 'pricing.currentAddOn')}</span
|
|
950
|
+
>`
|
|
951
|
+
: nothing}
|
|
952
|
+
<h4>${addOn.name}</h4>
|
|
953
|
+
${addOn.description ? html `<p>${addOn.description}</p>` : nothing}
|
|
954
|
+
</div>
|
|
955
|
+
${selectionEnabled
|
|
956
|
+
? html `<label
|
|
957
|
+
class="pr-selection-control pr-addon-card__check"
|
|
958
|
+
data-pr-part="selection-control"
|
|
959
|
+
>
|
|
960
|
+
<input
|
|
961
|
+
type="checkbox"
|
|
962
|
+
.checked=${selected}
|
|
963
|
+
?disabled=${!available}
|
|
964
|
+
aria-label=${t(viewModel, selected ? 'pricing.removeAddOn' : 'pricing.addAddOn', {
|
|
965
|
+
addOn: addOn.name,
|
|
966
|
+
})}
|
|
967
|
+
aria-describedby="${helpId}"
|
|
968
|
+
@change=${(event) => actions.toggleAddOn(addOn.id, event.currentTarget.checked)}
|
|
969
|
+
/>
|
|
970
|
+
<span class="pr-selection-control__icon" aria-hidden="true"></span>
|
|
971
|
+
<span class="pr-selection-control__label" aria-hidden="true"
|
|
972
|
+
>${selected ? t(viewModel, 'pricing.added') : t(viewModel, 'pricing.add')}</span
|
|
973
|
+
>
|
|
974
|
+
</label>`
|
|
975
|
+
: nothing}
|
|
976
|
+
</div>
|
|
977
|
+
<div class="pr-price pr-price--addon">
|
|
978
|
+
${renderPrice(viewModel, viewModel.resolved.addOnPrices[addOn.id], addOn.unit)}
|
|
979
|
+
</div>
|
|
980
|
+
<div id="${helpId}" class="pr-addon-card__details">
|
|
981
|
+
${!availableForPlan
|
|
982
|
+
? html `<p class="pr-status" data-pr-part="add-on-status" data-reason="plan">
|
|
983
|
+
${t(viewModel, 'pricing.notAvailable')}
|
|
984
|
+
</p>`
|
|
985
|
+
: blockedBy.length > 0
|
|
986
|
+
? // the add-ons that block it by name: the reader needs to know what to drop, not
|
|
987
|
+
// the whole list of what this one excludes
|
|
988
|
+
html `<p class="pr-status" data-pr-part="add-on-status" data-reason="excluded">
|
|
989
|
+
${t(viewModel, 'pricing.notAvailableWith', {
|
|
990
|
+
items: addOnNames(viewModel, blockedBy),
|
|
991
|
+
})}
|
|
992
|
+
</p>`
|
|
993
|
+
: nothing}
|
|
994
|
+
${renderQuantityConstraints(viewModel, constraints)}
|
|
995
|
+
${dense ? nothing : renderAddOnNotes(viewModel, addOn)}
|
|
996
|
+
</div>
|
|
997
|
+
${renderAddOnEffects(viewModel, addOn, dense)}
|
|
998
|
+
${selectionEnabled && selected && constraints
|
|
999
|
+
? html `<div class="pr-stepper" data-pr-part="add-on-quantity">
|
|
1000
|
+
<span>${t(viewModel, 'pricing.addOnQuantity', { addOn: addOn.name })}</span>
|
|
1001
|
+
<div>
|
|
1002
|
+
<button
|
|
1003
|
+
type="button"
|
|
1004
|
+
aria-label=${t(viewModel, 'pricing.decrease', { item: addOn.name })}
|
|
1005
|
+
@click=${() => actions.updateAddOnQuantity(addOn.id, quantity - constraints.quantityStep)}
|
|
1006
|
+
>
|
|
1007
|
+
−
|
|
1008
|
+
</button>
|
|
1009
|
+
<input
|
|
1010
|
+
type="number"
|
|
1011
|
+
aria-label=${t(viewModel, 'pricing.addOnQuantity', { addOn: addOn.name })}
|
|
1012
|
+
.value=${String(quantity)}
|
|
1013
|
+
min=${constraints.minQuantity}
|
|
1014
|
+
max=${maxQuantityOnGrid(constraints) ?? nothing}
|
|
1015
|
+
step=${constraints.quantityStep}
|
|
1016
|
+
@change=${(event) => actions.updateAddOnQuantity(addOn.id, Number(event.currentTarget.value))}
|
|
1017
|
+
/>
|
|
1018
|
+
<button
|
|
1019
|
+
type="button"
|
|
1020
|
+
aria-label=${t(viewModel, 'pricing.increase', { item: addOn.name })}
|
|
1021
|
+
@click=${() => actions.updateAddOnQuantity(addOn.id, quantity + constraints.quantityStep)}
|
|
1022
|
+
>
|
|
1023
|
+
+
|
|
1024
|
+
</button>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>`
|
|
1027
|
+
: nothing}
|
|
1028
|
+
</article>
|
|
1029
|
+
`;
|
|
1030
|
+
}
|
|
1031
|
+
function renderAddOnGrid(state, addOns, dense, keyed = false) {
|
|
1032
|
+
// The visible cards of the filter mode change with the search and show-more, so they are keyed
|
|
1033
|
+
// by id: an open disclosure or the focus stays with its add-on.
|
|
1034
|
+
return html `<div class="pr-addon-grid${dense ? ' pr-addon-grid--dense' : ''}">
|
|
1035
|
+
${keyed
|
|
1036
|
+
? repeat(addOns, (addOn) => addOn.id, (addOn) => renderAddOnCard(state, addOn, dense))
|
|
1037
|
+
: addOns.map((addOn) => renderAddOnCard(state, addOn, dense))}
|
|
1038
|
+
</div>`;
|
|
1039
|
+
}
|
|
1040
|
+
function renderFilteredAddOns(state, dense) {
|
|
1041
|
+
const { viewModel, selection, actions } = state;
|
|
1042
|
+
const all = viewModel.addOns;
|
|
1043
|
+
const search = (state.addOnSearch ?? '').trim().toLowerCase();
|
|
1044
|
+
const isSelected = (addOn) => selection.addOns[addOn.id]?.selected ?? false;
|
|
1045
|
+
let visible;
|
|
1046
|
+
if (search) {
|
|
1047
|
+
visible = all.filter((addOn) => isSelected(addOn) ||
|
|
1048
|
+
addOn.name.toLowerCase().includes(search) ||
|
|
1049
|
+
(addOn.description ?? '').toLowerCase().includes(search));
|
|
1050
|
+
}
|
|
1051
|
+
else if (state.addOnsRevealed) {
|
|
1052
|
+
visible = [...all];
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
visible = all.filter((addOn, index) => index < ADDON_VISIBLE_CAP || isSelected(addOn));
|
|
1056
|
+
}
|
|
1057
|
+
const hidden = all.length - visible.length;
|
|
1058
|
+
const label = t(viewModel, 'pricing.searchAddOns');
|
|
1059
|
+
const counter = t(viewModel, 'pricing.rowsShownOf', { shown: visible.length, total: all.length });
|
|
1060
|
+
return html `<div class="pr-addon-main">
|
|
1061
|
+
<div class="pr-addon-toolbar" data-pr-part="addon-toolbar">
|
|
1062
|
+
<label class="pr-search">
|
|
1063
|
+
<span class="pr-sr-only">${label}</span>
|
|
1064
|
+
<input
|
|
1065
|
+
type="search"
|
|
1066
|
+
placeholder=${label}
|
|
1067
|
+
.value=${state.addOnSearch ?? ''}
|
|
1068
|
+
@input=${(event) => actions.updateAddOnSearch?.(event.currentTarget.value)}
|
|
1069
|
+
/>
|
|
1070
|
+
</label>
|
|
1071
|
+
<span aria-live="polite">${counter}</span>
|
|
1072
|
+
</div>
|
|
1073
|
+
${visible.length === 0
|
|
1074
|
+
? html `<p class="pr-empty" role="status" data-pr-part="addon-empty">
|
|
1075
|
+
${t(viewModel, 'pricing.noMatches')}
|
|
1076
|
+
</p>`
|
|
1077
|
+
: renderAddOnGrid(state, visible, dense, true)}
|
|
1078
|
+
${!search && hidden > 0
|
|
1079
|
+
? html `<button
|
|
1080
|
+
class="pr-button pr-button--quiet"
|
|
1081
|
+
type="button"
|
|
1082
|
+
@click=${(event) => actions.revealAddOns?.(event?.detail === 0)}
|
|
1083
|
+
>
|
|
1084
|
+
${t(viewModel, 'pricing.showRemaining', { count: hidden })}
|
|
1085
|
+
</button>`
|
|
1086
|
+
: nothing}
|
|
1087
|
+
</div>`;
|
|
1088
|
+
}
|
|
1089
|
+
// Only the parts that actually constrain. A minimum of one and a step of one say nothing, and
|
|
1090
|
+
// a line reading "Quantity 1-100000000, in steps of 1" is noise around a single useful number.
|
|
1091
|
+
// A missing maximum is already unbounded: normalizePricing drops a YAML `.inf` as non-finite.
|
|
1092
|
+
function renderQuantityConstraints(viewModel, constraints) {
|
|
1093
|
+
if (!constraints)
|
|
1094
|
+
return nothing;
|
|
1095
|
+
const { minQuantity, maxQuantity, quantityStep } = constraints;
|
|
1096
|
+
const plain = minQuantity <= 1 && quantityStep <= 1;
|
|
1097
|
+
if (plain && maxQuantity === undefined)
|
|
629
1098
|
return nothing;
|
|
1099
|
+
const num = (value) => new Intl.NumberFormat(viewModel.locale).format(value);
|
|
1100
|
+
const parts = [];
|
|
1101
|
+
if (plain) {
|
|
1102
|
+
parts.push(t(viewModel, 'pricing.quantityUpTo', { max: num(maxQuantity) }));
|
|
1103
|
+
}
|
|
1104
|
+
else {
|
|
1105
|
+
parts.push(t(viewModel, 'pricing.quantityConstraints', {
|
|
1106
|
+
min: num(minQuantity),
|
|
1107
|
+
max: maxQuantity === undefined ? t(viewModel, 'pricing.unbounded') : num(maxQuantity),
|
|
1108
|
+
}));
|
|
1109
|
+
}
|
|
1110
|
+
if (quantityStep > 1) {
|
|
1111
|
+
parts.push(t(viewModel, 'pricing.quantityInSteps', { step: num(quantityStep) }));
|
|
1112
|
+
}
|
|
1113
|
+
return html `<p>${parts.join(', ')}</p>`;
|
|
1114
|
+
}
|
|
1115
|
+
export function renderAddOns(state) {
|
|
1116
|
+
const { viewModel } = state;
|
|
1117
|
+
if (viewModel.addOns.length === 0 || state.hiddenSections?.has('addOns'))
|
|
1118
|
+
return nothing;
|
|
1119
|
+
const dense = viewModel.addOns.length > ADDON_DENSE_THRESHOLD;
|
|
1120
|
+
const filtered = viewModel.addOns.length > addOnFilterThresholdOf(state.addOnFilterThreshold);
|
|
1121
|
+
const titleId = instanceId(state, 'addons-title');
|
|
630
1122
|
return html `
|
|
631
|
-
<section class="pr-section" aria-labelledby
|
|
1123
|
+
<section class="pr-section" aria-labelledby=${titleId} data-pr-part="add-ons">
|
|
632
1124
|
<div class="pr-section__heading">
|
|
633
1125
|
<div>
|
|
634
1126
|
<p class="pr-section__eyebrow">${t(viewModel, 'pricing.addOns')}</p>
|
|
635
|
-
<h3 id
|
|
1127
|
+
<h3 id=${titleId}>${t(viewModel, 'pricing.addOnsTitle')}</h3>
|
|
636
1128
|
</div>
|
|
637
1129
|
</div>
|
|
638
|
-
<div
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
<article
|
|
649
|
-
class="pr-addon-card ${selectionEnabled && available ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${available ? '' : 'is-unavailable'}"
|
|
650
|
-
data-pr-part="add-on-card"
|
|
651
|
-
data-add-on-id=${addOn.id}
|
|
652
|
-
data-selected=${String(selected)}
|
|
653
|
-
@click=${(event) => selectionEnabled && available
|
|
654
|
-
? selectFromCard(event, () => actions.toggleAddOn(addOn.id, !selected))
|
|
655
|
-
: undefined}
|
|
656
|
-
>
|
|
657
|
-
<div class="pr-addon-card__heading">
|
|
658
|
-
<div>
|
|
659
|
-
<h4>${addOn.name}</h4>
|
|
660
|
-
${addOn.description ? html `<p>${addOn.description}</p>` : nothing}
|
|
661
|
-
</div>
|
|
662
|
-
${selectionEnabled
|
|
663
|
-
? html `<label
|
|
664
|
-
class="pr-selection-control pr-addon-card__check"
|
|
665
|
-
data-pr-part="selection-control"
|
|
666
|
-
>
|
|
667
|
-
<input
|
|
668
|
-
type="checkbox"
|
|
669
|
-
.checked=${selected}
|
|
670
|
-
?disabled=${!available}
|
|
671
|
-
aria-label=${t(viewModel, selected ? 'pricing.removeAddOn' : 'pricing.addAddOn', { addOn: addOn.name })}
|
|
672
|
-
aria-describedby="pr-addon-help-${addOn.id}"
|
|
673
|
-
@change=${(event) => actions.toggleAddOn(addOn.id, event.currentTarget.checked)}
|
|
674
|
-
/>
|
|
675
|
-
<span class="pr-selection-control__icon" aria-hidden="true"></span>
|
|
676
|
-
<span class="pr-selection-control__label" aria-hidden="true"
|
|
677
|
-
>${selected ? t(viewModel, 'pricing.added') : t(viewModel, 'pricing.add')}</span
|
|
678
|
-
>
|
|
679
|
-
</label>`
|
|
680
|
-
: nothing}
|
|
681
|
-
</div>
|
|
682
|
-
<div class="pr-price pr-price--addon">
|
|
683
|
-
${renderPrice(viewModel, viewModel.resolved.addOnPrices[addOn.id], addOn.unit)}
|
|
684
|
-
</div>
|
|
685
|
-
<div id="pr-addon-help-${addOn.id}" class="pr-addon-card__details">
|
|
686
|
-
${!available
|
|
687
|
-
? html `<p class="pr-status">${t(viewModel, 'pricing.notAvailable')}</p>`
|
|
688
|
-
: nothing}
|
|
689
|
-
${addOn.availableFor.length
|
|
690
|
-
? html `<p>
|
|
691
|
-
${t(viewModel, 'pricing.availableFor', {
|
|
692
|
-
plans: addOn.availableFor
|
|
693
|
-
.map((id) => viewModel.pricing.plans.find((plan) => plan.id === id)?.name ??
|
|
694
|
-
id)
|
|
695
|
-
.join(', '),
|
|
696
|
-
})}
|
|
697
|
-
</p>`
|
|
698
|
-
: nothing}
|
|
699
|
-
${addOn.dependsOn.length
|
|
700
|
-
? html `<p>
|
|
701
|
-
${t(viewModel, 'pricing.dependsOn', {
|
|
702
|
-
items: addOn.dependsOn.join(', '),
|
|
703
|
-
})}
|
|
704
|
-
</p>`
|
|
705
|
-
: nothing}
|
|
706
|
-
${addOn.excludes.length
|
|
707
|
-
? html `<p>
|
|
708
|
-
${t(viewModel, 'pricing.excludes', {
|
|
709
|
-
items: addOn.excludes.join(', '),
|
|
710
|
-
})}
|
|
711
|
-
</p>`
|
|
712
|
-
: nothing}
|
|
713
|
-
${constraints
|
|
714
|
-
? html `<p>
|
|
715
|
-
${t(viewModel, 'pricing.quantityConstraints', {
|
|
716
|
-
min: constraints.minQuantity,
|
|
717
|
-
max: constraints.maxQuantity ?? t(viewModel, 'pricing.unbounded'),
|
|
718
|
-
step: constraints.quantityStep,
|
|
719
|
-
})}
|
|
720
|
-
</p>`
|
|
721
|
-
: nothing}
|
|
722
|
-
</div>
|
|
723
|
-
${renderAddOnEffects(viewModel, addOn)}
|
|
724
|
-
${selectionEnabled && selected && constraints
|
|
725
|
-
? html `<div class="pr-stepper" data-pr-part="add-on-quantity">
|
|
726
|
-
<span>${t(viewModel, 'pricing.addOnQuantity', { addOn: addOn.name })}</span>
|
|
727
|
-
<div>
|
|
728
|
-
<button
|
|
729
|
-
type="button"
|
|
730
|
-
aria-label=${t(viewModel, 'pricing.decrease', { item: addOn.name })}
|
|
731
|
-
@click=${() => actions.updateAddOnQuantity(addOn.id, quantity - constraints.quantityStep)}
|
|
732
|
-
>
|
|
733
|
-
−
|
|
734
|
-
</button>
|
|
735
|
-
<input
|
|
736
|
-
type="number"
|
|
737
|
-
aria-label=${t(viewModel, 'pricing.addOnQuantity', {
|
|
738
|
-
addOn: addOn.name,
|
|
739
|
-
})}
|
|
740
|
-
.value=${String(quantity)}
|
|
741
|
-
min=${constraints.minQuantity}
|
|
742
|
-
max=${constraints.maxQuantity ?? nothing}
|
|
743
|
-
step=${constraints.quantityStep}
|
|
744
|
-
@change=${(event) => actions.updateAddOnQuantity(addOn.id, Number(event.currentTarget.value))}
|
|
745
|
-
/>
|
|
746
|
-
<button
|
|
747
|
-
type="button"
|
|
748
|
-
aria-label=${t(viewModel, 'pricing.increase', { item: addOn.name })}
|
|
749
|
-
@click=${() => actions.updateAddOnQuantity(addOn.id, quantity + constraints.quantityStep)}
|
|
750
|
-
>
|
|
751
|
-
+
|
|
752
|
-
</button>
|
|
753
|
-
</div>
|
|
754
|
-
</div>`
|
|
755
|
-
: nothing}
|
|
756
|
-
</article>
|
|
757
|
-
`;
|
|
758
|
-
})}
|
|
759
|
-
</div>
|
|
760
|
-
${renderSummary(state)}
|
|
1130
|
+
<div
|
|
1131
|
+
class="pr-addon-layout"
|
|
1132
|
+
data-pr-summary=${state.summaryOutsideAddOns ? 'outside' : nothing}
|
|
1133
|
+
>
|
|
1134
|
+
${filtered
|
|
1135
|
+
? renderFilteredAddOns(state, dense)
|
|
1136
|
+
: renderAddOnGrid(state, viewModel.addOns, dense)}
|
|
1137
|
+
${state.summaryOutsideAddOns || state.hiddenSections?.has('summary')
|
|
1138
|
+
? nothing
|
|
1139
|
+
: renderSummary(state)}
|
|
761
1140
|
</div>
|
|
762
1141
|
</section>
|
|
763
1142
|
`;
|
|
764
1143
|
}
|
|
1144
|
+
// The formula of a priced line, or the label or the quote note of a line that needs a quote.
|
|
1145
|
+
function breakdownDetail(viewModel, line) {
|
|
1146
|
+
const { currency } = viewModel.resolved.breakdown;
|
|
1147
|
+
if (line.requiresQuote || line.unitAmount === undefined || line.amount === undefined) {
|
|
1148
|
+
return line.label ?? t(viewModel, 'pricing.requiresQuote');
|
|
1149
|
+
}
|
|
1150
|
+
const numbers = new Intl.NumberFormat(viewModel.locale);
|
|
1151
|
+
return t(viewModel, 'pricing.breakdownFormula', {
|
|
1152
|
+
unitPrice: formatCurrency(line.unitAmount, currency, viewModel.locale),
|
|
1153
|
+
quantity: numbers.format(line.quantity),
|
|
1154
|
+
multiplier: numbers.format(line.billingMultiplier),
|
|
1155
|
+
amount: formatCurrency(line.amount, currency, viewModel.locale),
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
function renderBreakdownLine(viewModel, line) {
|
|
1159
|
+
return html `<li
|
|
1160
|
+
class="pr-breakdown__line"
|
|
1161
|
+
data-pr-part="breakdown-line"
|
|
1162
|
+
data-line-kind=${line.kind}
|
|
1163
|
+
data-line-id=${line.id}
|
|
1164
|
+
>
|
|
1165
|
+
<div class="pr-breakdown__name">${line.name}</div>
|
|
1166
|
+
<div class="pr-breakdown__detail">${breakdownDetail(viewModel, line)}</div>
|
|
1167
|
+
</li>`;
|
|
1168
|
+
}
|
|
1169
|
+
// The panel is rendered from the element's open state whatever the selection, as a labelled group
|
|
1170
|
+
// of a list and a definition list. The closed panel is a template without bindings in its content, so it has no child nodes.
|
|
1171
|
+
function renderBreakdownPanel(state) {
|
|
1172
|
+
const { viewModel, breakdownOpen, breakdownId } = state;
|
|
1173
|
+
if (!breakdownOpen) {
|
|
1174
|
+
return html `<div id=${breakdownId} data-pr-part="breakdown" hidden></div>`;
|
|
1175
|
+
}
|
|
1176
|
+
const { breakdown } = viewModel.resolved;
|
|
1177
|
+
const lines = [...(breakdown.plan ? [breakdown.plan] : []), ...breakdown.addOns];
|
|
1178
|
+
const period = viewModel.pricing.billing.length > 1 ? breakdown.billingPeriod : undefined;
|
|
1179
|
+
const periodLabel = period
|
|
1180
|
+
? (viewModel.presentation.billingLabels?.[period.id] ?? period.label)
|
|
1181
|
+
: undefined;
|
|
1182
|
+
return html `
|
|
1183
|
+
<div
|
|
1184
|
+
class="pr-breakdown"
|
|
1185
|
+
id=${breakdownId}
|
|
1186
|
+
data-pr-part="breakdown"
|
|
1187
|
+
role="group"
|
|
1188
|
+
aria-labelledby="${breakdownId}-title"
|
|
1189
|
+
>
|
|
1190
|
+
<p class="pr-breakdown__title" id="${breakdownId}-title">
|
|
1191
|
+
${t(viewModel, 'pricing.breakdownTitle')}
|
|
1192
|
+
</p>
|
|
1193
|
+
<ul class="pr-breakdown__lines">
|
|
1194
|
+
${lines.map((line) => renderBreakdownLine(viewModel, line))}
|
|
1195
|
+
</ul>
|
|
1196
|
+
<dl class="pr-breakdown__totals">
|
|
1197
|
+
${periodLabel !== undefined
|
|
1198
|
+
? html `<div>
|
|
1199
|
+
<dt>${t(viewModel, 'pricing.billing')}</dt>
|
|
1200
|
+
<dd>${periodLabel}</dd>
|
|
1201
|
+
</div>`
|
|
1202
|
+
: nothing}
|
|
1203
|
+
<div>
|
|
1204
|
+
<dt>${t(viewModel, 'pricing.currency')}</dt>
|
|
1205
|
+
<dd>${breakdown.currency}</dd>
|
|
1206
|
+
</div>
|
|
1207
|
+
<div class="pr-breakdown__total">
|
|
1208
|
+
<dt>
|
|
1209
|
+
${breakdown.requiresQuote ? t(viewModel, 'pricing.subtotal') : t(viewModel, 'pricing.total')}
|
|
1210
|
+
</dt>
|
|
1211
|
+
<dd>${formatCurrency(breakdown.subtotal, breakdown.currency, viewModel.locale)}</dd>
|
|
1212
|
+
</div>
|
|
1213
|
+
</dl>
|
|
1214
|
+
${breakdown.requiresQuote
|
|
1215
|
+
? html `<p class="pr-breakdown__quote">${t(viewModel, 'pricing.requiresQuote')}</p>`
|
|
1216
|
+
: nothing}
|
|
1217
|
+
</div>
|
|
1218
|
+
`;
|
|
1219
|
+
}
|
|
1220
|
+
// Rendered outside the live region, so a change of label is not announced by the summary.
|
|
1221
|
+
function renderSummaryAction(state) {
|
|
1222
|
+
const { viewModel, summaryAction, pendingActionId, selection, actions } = state;
|
|
1223
|
+
// The slot node stands in for the summary action with or without a current plan; data-pending
|
|
1224
|
+
// follows aria-disabled of the built-in action (any pending action), not its own-id state.
|
|
1225
|
+
if (state.ctaEnabled && state.slots?.has('summary-actions')) {
|
|
1226
|
+
return html `<div
|
|
1227
|
+
data-pr-slot="summary-actions"
|
|
1228
|
+
data-pr-part="summary-actions-slot"
|
|
1229
|
+
data-relation=${summaryAction?.relation ?? nothing}
|
|
1230
|
+
data-pending=${pendingActionId !== undefined ? 'true' : nothing}
|
|
1231
|
+
></div>`;
|
|
1232
|
+
}
|
|
1233
|
+
if (!state.ctaEnabled || !summaryAction)
|
|
1234
|
+
return nothing;
|
|
1235
|
+
const pending = isPendingActionId(pendingActionId, summaryAction.actionId);
|
|
1236
|
+
const origin = { relation: summaryAction.relation, source: 'summary' };
|
|
1237
|
+
const trigger = () => actions.triggerAction(summaryAction.actionId, selection.planId, undefined, undefined, origin);
|
|
1238
|
+
return html `<button
|
|
1239
|
+
class="pr-button pr-button--primary"
|
|
1240
|
+
data-pr-part="summary-action"
|
|
1241
|
+
data-relation=${summaryAction.relation}
|
|
1242
|
+
data-pending=${pending ? 'true' : nothing}
|
|
1243
|
+
aria-busy=${pending ? 'true' : nothing}
|
|
1244
|
+
aria-disabled=${pendingActionId !== undefined ? 'true' : nothing}
|
|
1245
|
+
type="button"
|
|
1246
|
+
?disabled=${summaryAction.disabled}
|
|
1247
|
+
@click=${trigger}
|
|
1248
|
+
>
|
|
1249
|
+
${renderCtaLabel(viewModel, summaryAction.label, pending)}
|
|
1250
|
+
</button>`;
|
|
1251
|
+
}
|
|
765
1252
|
export function renderSummary(state) {
|
|
766
|
-
const { viewModel } = state;
|
|
1253
|
+
const { viewModel, breakdownOpen, breakdownId, actions } = state;
|
|
767
1254
|
const subtotal = formatCurrency(viewModel.resolved.subtotal, viewModel.pricing.metadata.currency, viewModel.locale);
|
|
768
1255
|
return html `
|
|
769
|
-
<aside class="pr-summary" data-pr-part="summary"
|
|
770
|
-
<
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1256
|
+
<aside class="pr-summary" data-pr-part="summary">
|
|
1257
|
+
<div class="pr-summary__live" aria-live="polite">
|
|
1258
|
+
<p>
|
|
1259
|
+
${viewModel.resolved.requiresQuote ? t(viewModel, 'pricing.subtotal') : t(viewModel, 'pricing.total')}
|
|
1260
|
+
</p>
|
|
1261
|
+
<strong>${subtotal}</strong>
|
|
1262
|
+
${viewModel.resolved.requiresQuote
|
|
775
1263
|
? html `<span>${t(viewModel, 'pricing.requiresQuote')}</span>`
|
|
776
1264
|
: nothing}
|
|
1265
|
+
</div>
|
|
1266
|
+
${renderSummaryAction(state)}
|
|
1267
|
+
<button
|
|
1268
|
+
class="pr-breakdown-toggle"
|
|
1269
|
+
data-pr-part="breakdown-toggle"
|
|
1270
|
+
type="button"
|
|
1271
|
+
aria-label=${t(viewModel, 'pricing.breakdownToggle')}
|
|
1272
|
+
aria-expanded=${String(breakdownOpen)}
|
|
1273
|
+
aria-controls=${breakdownId}
|
|
1274
|
+
@click=${actions.toggleBreakdown}
|
|
1275
|
+
></button>
|
|
1276
|
+
${renderBreakdownPanel(state)}
|
|
777
1277
|
</aside>
|
|
778
1278
|
`;
|
|
779
1279
|
}
|
|
@@ -782,9 +1282,10 @@ export function renderCatalogMetadata(state) {
|
|
|
782
1282
|
if (viewModel.mode !== 'catalog')
|
|
783
1283
|
return nothing;
|
|
784
1284
|
const metadata = viewModel.pricing.metadata;
|
|
1285
|
+
const titleId = instanceId(state, 'metadata-title');
|
|
785
1286
|
return html `
|
|
786
|
-
<section class="pr-metadata" aria-labelledby
|
|
787
|
-
<h3 id
|
|
1287
|
+
<section class="pr-metadata" aria-labelledby=${titleId} data-pr-part="metadata">
|
|
1288
|
+
<h3 id=${titleId}>${t(viewModel, 'pricing.details')}</h3>
|
|
788
1289
|
<dl>
|
|
789
1290
|
<div>
|
|
790
1291
|
<dt>${t(viewModel, 'pricing.version')}</dt>
|
|
@@ -814,32 +1315,63 @@ export function renderCatalogMetadata(state) {
|
|
|
814
1315
|
</section>
|
|
815
1316
|
`;
|
|
816
1317
|
}
|
|
1318
|
+
// the shell keeps its labelled heading; the slot node replaces the skeleton
|
|
1319
|
+
export function renderLoadingSlotShell(titleId, label) {
|
|
1320
|
+
return html `
|
|
1321
|
+
<section class="pr-shell pr-shell--loading" aria-labelledby=${titleId}>
|
|
1322
|
+
<h2 id=${titleId} class="pr-sr-only">${label}</h2>
|
|
1323
|
+
<div data-pr-slot="loading" data-pr-part="loading-slot"></div>
|
|
1324
|
+
</section>
|
|
1325
|
+
`;
|
|
1326
|
+
}
|
|
1327
|
+
// The slot node is the only child; the diagnostics travel as JSON in data-diagnostics.
|
|
1328
|
+
export function renderErrorSlotShell(diagnostics) {
|
|
1329
|
+
const entries = diagnostics.map((item) => ({
|
|
1330
|
+
code: item.code,
|
|
1331
|
+
severity: item.severity,
|
|
1332
|
+
message: item.message,
|
|
1333
|
+
...(item.path !== undefined ? { path: item.path } : {}),
|
|
1334
|
+
}));
|
|
1335
|
+
return html `<section class="pr-shell pr-error" role="alert">
|
|
1336
|
+
<div
|
|
1337
|
+
data-pr-slot="error"
|
|
1338
|
+
data-pr-part="error-slot"
|
|
1339
|
+
data-diagnostics=${JSON.stringify(entries)}
|
|
1340
|
+
></div>
|
|
1341
|
+
</section>`;
|
|
1342
|
+
}
|
|
817
1343
|
export function renderPendingDialog(state) {
|
|
818
1344
|
const pending = state.pendingAddOnChange;
|
|
819
1345
|
if (!pending)
|
|
820
1346
|
return nothing;
|
|
821
1347
|
const { viewModel, actions } = state;
|
|
1348
|
+
const titleId = instanceId(state, 'dialog-title');
|
|
1349
|
+
const descriptionId = instanceId(state, 'dialog-description');
|
|
822
1350
|
return html `
|
|
823
1351
|
<div class="pr-dialog-backdrop">
|
|
824
1352
|
<div
|
|
825
1353
|
class="pr-dialog"
|
|
826
1354
|
role="dialog"
|
|
827
1355
|
aria-modal="true"
|
|
828
|
-
aria-labelledby
|
|
829
|
-
aria-describedby
|
|
1356
|
+
aria-labelledby=${titleId}
|
|
1357
|
+
aria-describedby=${descriptionId}
|
|
830
1358
|
@keydown=${(event) => {
|
|
831
1359
|
if (event.key === 'Escape')
|
|
832
1360
|
actions.cancelAddOnChange();
|
|
833
1361
|
}}
|
|
834
1362
|
>
|
|
835
|
-
<h3 id
|
|
836
|
-
<p id
|
|
1363
|
+
<h3 id=${titleId}>${t(viewModel, 'pricing.confirmChanges')}</h3>
|
|
1364
|
+
<p id=${descriptionId}>${t(viewModel, 'pricing.confirmChangesDescription')}</p>
|
|
837
1365
|
${pending.addIds.length
|
|
838
|
-
? html `<p
|
|
1366
|
+
? html `<p>
|
|
1367
|
+
${t(viewModel, 'pricing.willAdd', { items: addOnNames(viewModel, pending.addIds) })}
|
|
1368
|
+
</p>`
|
|
839
1369
|
: nothing}
|
|
840
1370
|
${pending.removeIds.length
|
|
841
1371
|
? html `<p>
|
|
842
|
-
${t(viewModel, 'pricing.willRemove', {
|
|
1372
|
+
${t(viewModel, 'pricing.willRemove', {
|
|
1373
|
+
items: addOnNames(viewModel, pending.removeIds),
|
|
1374
|
+
})}
|
|
843
1375
|
</p>`
|
|
844
1376
|
: nothing}
|
|
845
1377
|
<div class="pr-dialog__actions">
|