pricing-renderer 0.1.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +419 -0
  4. package/THIRD_PARTY_NOTICES.md +10 -0
  5. package/custom-elements.json +122 -0
  6. package/dist/core/config.d.ts +6 -0
  7. package/dist/core/config.d.ts.map +1 -0
  8. package/dist/core/config.js +62 -0
  9. package/dist/core/config.js.map +1 -0
  10. package/dist/core/expression.d.ts +4 -0
  11. package/dist/core/expression.d.ts.map +1 -0
  12. package/dist/core/expression.js +583 -0
  13. package/dist/core/expression.js.map +1 -0
  14. package/dist/core/i18n.d.ts +10 -0
  15. package/dist/core/i18n.d.ts.map +1 -0
  16. package/dist/core/i18n.js +207 -0
  17. package/dist/core/i18n.js.map +1 -0
  18. package/dist/core/index.d.ts +9 -0
  19. package/dist/core/index.d.ts.map +1 -0
  20. package/dist/core/index.js +8 -0
  21. package/dist/core/index.js.map +1 -0
  22. package/dist/core/normalize.d.ts +4 -0
  23. package/dist/core/normalize.d.ts.map +1 -0
  24. package/dist/core/normalize.js +301 -0
  25. package/dist/core/normalize.js.map +1 -0
  26. package/dist/core/resolve.d.ts +6 -0
  27. package/dist/core/resolve.d.ts.map +1 -0
  28. package/dist/core/resolve.js +123 -0
  29. package/dist/core/resolve.js.map +1 -0
  30. package/dist/core/types.d.ts +308 -0
  31. package/dist/core/types.d.ts.map +1 -0
  32. package/dist/core/types.js +2 -0
  33. package/dist/core/types.js.map +1 -0
  34. package/dist/core/utils.d.ts +16 -0
  35. package/dist/core/utils.d.ts.map +1 -0
  36. package/dist/core/utils.js +125 -0
  37. package/dist/core/utils.js.map +1 -0
  38. package/dist/core/view-model.d.ts +9 -0
  39. package/dist/core/view-model.d.ts.map +1 -0
  40. package/dist/core/view-model.js +427 -0
  41. package/dist/core/view-model.js.map +1 -0
  42. package/dist/element/define.d.ts +6 -0
  43. package/dist/element/define.d.ts.map +1 -0
  44. package/dist/element/define.js +20 -0
  45. package/dist/element/define.js.map +1 -0
  46. package/dist/element/events.d.ts +31 -0
  47. package/dist/element/events.d.ts.map +1 -0
  48. package/dist/element/events.js +2 -0
  49. package/dist/element/events.js.map +1 -0
  50. package/dist/element/index.d.ts +8 -0
  51. package/dist/element/index.d.ts.map +1 -0
  52. package/dist/element/index.js +2 -0
  53. package/dist/element/index.js.map +1 -0
  54. package/dist/element/pricing-renderer-element.d.ts +152 -0
  55. package/dist/element/pricing-renderer-element.d.ts.map +1 -0
  56. package/dist/element/pricing-renderer-element.js +598 -0
  57. package/dist/element/pricing-renderer-element.js.map +1 -0
  58. package/dist/element/renderers.d.ts +43 -0
  59. package/dist/element/renderers.d.ts.map +1 -0
  60. package/dist/element/renderers.js +866 -0
  61. package/dist/element/renderers.js.map +1 -0
  62. package/dist/react/index.d.ts +55 -0
  63. package/dist/react/index.d.ts.map +1 -0
  64. package/dist/react/index.js +23 -0
  65. package/dist/react/index.js.map +1 -0
  66. package/dist/styles/base.css +1110 -0
  67. package/dist/styles/styles.css +2 -0
  68. package/dist/styles/theme.css +84 -0
  69. package/dist/yaml/index.d.ts +4 -0
  70. package/dist/yaml/index.d.ts.map +1 -0
  71. package/dist/yaml/index.js +3 -0
  72. package/dist/yaml/index.js.map +1 -0
  73. package/dist/yaml/load.d.ts +3 -0
  74. package/dist/yaml/load.d.ts.map +1 -0
  75. package/dist/yaml/load.js +97 -0
  76. package/dist/yaml/load.js.map +1 -0
  77. package/dist/yaml/parser.d.ts +4 -0
  78. package/dist/yaml/parser.d.ts.map +1 -0
  79. package/dist/yaml/parser.js +80 -0
  80. package/dist/yaml/parser.js.map +1 -0
  81. package/dist/yaml/types.d.ts +27 -0
  82. package/dist/yaml/types.d.ts.map +1 -0
  83. package/dist/yaml/types.js +2 -0
  84. package/dist/yaml/types.js.map +1 -0
  85. package/package.json +132 -0
@@ -0,0 +1,866 @@
1
+ import { html, nothing } from 'lit';
2
+ import { deepEqual, formatCurrency, formatDate, getAtPath, getPlanCta, getPlanHighlightSummary, humanizeIdentifier, isSafeLink, translate, unwrapValue, } from '../core/index.js';
3
+ function t(viewModel, key, parameters) {
4
+ return translate(viewModel.messages, key, parameters);
5
+ }
6
+ const interactiveCardContent = 'a, button, input, select, textarea, label, summary, details, [contenteditable="true"]';
7
+ function selectFromCard(event, select) {
8
+ if (event.defaultPrevented || event.button !== 0)
9
+ return;
10
+ const target = event.target;
11
+ if (target instanceof Element && target.closest(interactiveCardContent))
12
+ return;
13
+ select();
14
+ }
15
+ function renderPrice(viewModel, price, unit) {
16
+ if (!price || price.kind === 'error') {
17
+ return html `<span class="pr-price__quote">${t(viewModel, 'pricing.requiresQuote')}</span>`;
18
+ }
19
+ if (price.kind === 'label') {
20
+ return html `<span class="pr-price__quote">${price.label}</span>`;
21
+ }
22
+ return html `
23
+ <span class="pr-price__amount"
24
+ >${formatCurrency(price.amount, viewModel.pricing.metadata.currency, viewModel.locale)}</span
25
+ >
26
+ ${unit
27
+ ? html `<span class="pr-price__unit">${t(viewModel, 'pricing.per', { unit })}</span>`
28
+ : nothing}
29
+ `;
30
+ }
31
+ function renderValue(viewModel, value, unit) {
32
+ if (value === true) {
33
+ return html `<span class="pr-value pr-value--included"
34
+ ><span aria-hidden="true">✓</span
35
+ ><span class="pr-sr-only">${t(viewModel, 'pricing.included')}</span></span
36
+ >`;
37
+ }
38
+ if (value === false || value === null || value === undefined) {
39
+ return html `<span class="pr-value pr-value--excluded"
40
+ ><span aria-hidden="true">—</span
41
+ ><span class="pr-sr-only">${t(viewModel, 'pricing.notIncluded')}</span></span
42
+ >`;
43
+ }
44
+ if (value === Number.POSITIVE_INFINITY ||
45
+ value === '.inf' ||
46
+ value === 'Infinity' ||
47
+ value === 'unlimited') {
48
+ return html `<span class="pr-value">${t(viewModel, 'pricing.unlimited')}</span>`;
49
+ }
50
+ if (typeof value === 'number') {
51
+ const formatted = new Intl.NumberFormat(viewModel.locale).format(value);
52
+ return html `<span class="pr-value">${formatted}${unit ? ` ${unit}` : ''}</span>`;
53
+ }
54
+ if (Array.isArray(value)) {
55
+ return html `<span class="pr-value">${value.map(String).join(', ')}</span>`;
56
+ }
57
+ if (typeof value === 'object') {
58
+ return html `<code class="pr-value pr-value--code">${JSON.stringify(value)}</code>`;
59
+ }
60
+ return html `<span class="pr-value">${String(value)}</span>`;
61
+ }
62
+ function resolvedValuesDiffer(row) {
63
+ const values = row.values.map((cell) => cell.value);
64
+ return values.some((value) => !deepEqual(value, values[0]));
65
+ }
66
+ function renderRowMetadata(viewModel, row) {
67
+ if (viewModel.mode !== 'catalog')
68
+ return nothing;
69
+ const metadata = [
70
+ row.kind === 'feature'
71
+ ? t(viewModel, 'pricing.featureEffect')
72
+ : t(viewModel, 'pricing.limitEffect'),
73
+ typeof row.raw.type === 'string' ? row.raw.type : undefined,
74
+ typeof row.raw.valueType === 'string' ? row.raw.valueType : undefined,
75
+ typeof row.raw.period === 'string' ? row.raw.period : undefined,
76
+ row.raw.trackable === true ? t(viewModel, 'pricing.trackable') : undefined,
77
+ ].filter((item) => Boolean(item));
78
+ return metadata.length
79
+ ? html `<span class="pr-row-metadata">${metadata.join(' · ')}</span>`
80
+ : nothing;
81
+ }
82
+ export function renderHeader(state) {
83
+ const { viewModel } = state;
84
+ const title = viewModel.presentation.title ??
85
+ viewModel.pricing.metadata.saasName ??
86
+ t(viewModel, 'pricing.title');
87
+ const subtitle = viewModel.presentation.subtitle ?? t(viewModel, 'pricing.description');
88
+ return html `
89
+ <header class="pr-hero" data-pr-part="hero">
90
+ <div class="pr-hero__eyebrow">${t(viewModel, 'pricing.title')}</div>
91
+ <h2 class="pr-hero__title" id="pr-title">${title}</h2>
92
+ ${subtitle ? html `<p class="pr-hero__subtitle">${subtitle}</p>` : nothing}
93
+ ${viewModel.pricing.metadata.tags.length
94
+ ? html `<ul class="pr-tag-list" aria-label="${t(viewModel, 'pricing.tags')}">
95
+ ${viewModel.pricing.metadata.tags.map((tag) => html `<li class="pr-tag">${tag}</li>`)}
96
+ </ul>`
97
+ : nothing}
98
+ </header>
99
+ `;
100
+ }
101
+ export function renderBilling(state) {
102
+ const { viewModel, selection, actions } = state;
103
+ if (viewModel.pricing.billing.length <= 1)
104
+ return nothing;
105
+ return html `
106
+ <fieldset class="pr-billing" data-pr-part="billing">
107
+ <legend>${t(viewModel, 'pricing.billing')}</legend>
108
+ <div class="pr-segmented">
109
+ ${viewModel.pricing.billing.map((period) => {
110
+ const label = viewModel.presentation.billingLabels?.[period.id] ?? period.label;
111
+ const discount = Math.max(0, Math.round((1 - period.multiplier) * 100));
112
+ return html `
113
+ <label class="pr-segmented__option">
114
+ <input
115
+ type="radio"
116
+ name="pr-billing"
117
+ value=${period.id}
118
+ .checked=${selection.billingPeriod === period.id}
119
+ @change=${() => actions.selectBilling(period.id)}
120
+ />
121
+ <span>${label}</span>
122
+ ${discount > 0
123
+ ? html `<small>${t(viewModel, 'pricing.discount', { percent: discount })}</small>`
124
+ : nothing}
125
+ </label>
126
+ `;
127
+ })}
128
+ </div>
129
+ </fieldset>
130
+ `;
131
+ }
132
+ export function renderPlans(state) {
133
+ const { viewModel, selection, selectionEnabled, ctaEnabled, actions } = state;
134
+ return html `
135
+ <section class="pr-section" aria-labelledby="pr-plans-title" data-pr-part="plans">
136
+ <div class="pr-section__heading">
137
+ <div>
138
+ <p class="pr-section__eyebrow">${t(viewModel, 'pricing.plans')}</p>
139
+ <h3 id="pr-plans-title">${t(viewModel, 'pricing.plans')}</h3>
140
+ </div>
141
+ </div>
142
+ ${viewModel.plans.length === 0
143
+ ? html `<p class="pr-empty">${t(viewModel, 'pricing.noPlans')}</p>`
144
+ : html `<div
145
+ class="pr-plan-grid"
146
+ role=${selectionEnabled ? 'radiogroup' : nothing}
147
+ aria-labelledby=${selectionEnabled ? 'pr-plans-title' : nothing}
148
+ >
149
+ ${viewModel.plans.map((plan) => {
150
+ const selected = selection.planId === plan.id;
151
+ const cta = getPlanCta(viewModel.presentation, plan.id);
152
+ const highlightSummary = getPlanHighlightSummary(viewModel, plan.id);
153
+ const featured = highlightSummary.badges.some((badge) => badge.emphasize);
154
+ const actionId = cta?.id ?? `choose-${plan.id}`;
155
+ return html `
156
+ <article
157
+ class="pr-plan-card ${selectionEnabled ? 'pr-selectable-card' : ''} ${selected ? 'is-selected' : ''} ${featured ? 'is-featured' : ''}"
158
+ data-pr-part="plan-card"
159
+ data-plan-id=${plan.id}
160
+ data-selected=${String(selected)}
161
+ @click=${(event) => selectionEnabled
162
+ ? selectFromCard(event, () => actions.selectPlan(plan.id))
163
+ : undefined}
164
+ >
165
+ <div class="pr-plan-card__top">
166
+ ${highlightSummary.badges.length > 0 || selectionEnabled
167
+ ? html `<div class="pr-plan-card__status">
168
+ <div class="pr-plan-card__badges">
169
+ ${highlightSummary.badges.map((badge) => html `<span
170
+ class="pr-badge"
171
+ data-pr-part="plan-badge"
172
+ data-badge-id=${badge.id}
173
+ data-tone=${badge.tone ?? 'neutral'}
174
+ >${badge.label}</span
175
+ >`)}
176
+ </div>
177
+ ${selectionEnabled
178
+ ? html `<label
179
+ class="pr-selection-control pr-plan-card__selector"
180
+ data-pr-part="selection-control"
181
+ >
182
+ <input
183
+ type="radio"
184
+ name="pr-plan"
185
+ value=${plan.id}
186
+ aria-label=${t(viewModel, 'pricing.choosePlan', {
187
+ plan: plan.name,
188
+ })}
189
+ .checked=${selected}
190
+ @change=${() => actions.selectPlan(plan.id)}
191
+ />
192
+ <span
193
+ class="pr-selection-control__icon"
194
+ aria-hidden="true"
195
+ ></span>
196
+ <span class="pr-selection-control__label" aria-hidden="true"
197
+ >${selected
198
+ ? t(viewModel, 'pricing.selected')
199
+ : t(viewModel, 'pricing.select')}</span
200
+ >
201
+ </label>`
202
+ : nothing}
203
+ </div>`
204
+ : nothing}
205
+ <h4>${plan.name}</h4>
206
+ ${plan.description ? html `<p>${plan.description}</p>` : nothing}
207
+ </div>
208
+ <div class="pr-price" data-pr-part="price">
209
+ ${renderPrice(viewModel, viewModel.resolved.planPrices[plan.id], plan.unit)}
210
+ </div>
211
+ ${highlightSummary.inheritedFrom
212
+ ? html `<p class="pr-plan-inheritance" data-pr-part="plan-inheritance">
213
+ ${highlightSummary.inheritedFrom.label}
214
+ </p>`
215
+ : nothing}
216
+ ${highlightSummary.rows.length
217
+ ? html `<ul class="pr-highlight-list">
218
+ ${highlightSummary.rows.map((row) => {
219
+ const value = row.values.find((cell) => cell.planId === plan.id)?.value;
220
+ return html `<li
221
+ data-pr-part="plan-highlight"
222
+ data-highlight-id=${row.id}
223
+ data-highlight-kind=${row.kind}
224
+ >
225
+ <span class="pr-highlight-list__icon" aria-hidden="true">✓</span>
226
+ <span>${row.name}</span>
227
+ <span class="pr-highlight-list__value"
228
+ >${renderValue(viewModel, value, row.unit)}</span
229
+ >
230
+ </li>`;
231
+ })}
232
+ </ul>`
233
+ : nothing}
234
+ ${!ctaEnabled
235
+ ? nothing
236
+ : cta?.href && isSafeLink(cta.href)
237
+ ? html `<a
238
+ class="pr-button ${cta.kind === 'secondary'
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>`}
262
+ </article>
263
+ `;
264
+ })}
265
+ </div>`}
266
+ </section>
267
+ `;
268
+ }
269
+ function controlInput(state, control, invalid) {
270
+ const { selection, actions } = state;
271
+ const value = getAtPath(selection.variables, control.path);
272
+ const id = `pr-variable-${control.path.replace(/[^\w-]+/g, '-')}`;
273
+ if (control.type === 'boolean') {
274
+ return html `<label class="pr-switch" for=${id}>
275
+ <input
276
+ id=${id}
277
+ type="checkbox"
278
+ aria-invalid=${invalid}
279
+ .checked=${Boolean(value)}
280
+ @change=${(event) => actions.updateVariable(control.path, event.currentTarget.checked)}
281
+ />
282
+ <span class="pr-switch__track" aria-hidden="true"></span>
283
+ </label>`;
284
+ }
285
+ if (control.type === 'select') {
286
+ return html `<select
287
+ id=${id}
288
+ aria-invalid=${invalid}
289
+ .value=${String(value ?? '')}
290
+ @change=${(event) => {
291
+ const raw = event.currentTarget.value;
292
+ const option = control.options?.find((candidate) => String(candidate.value) === raw);
293
+ actions.updateVariable(control.path, option?.value ?? raw);
294
+ }}
295
+ >
296
+ ${(control.options ?? []).map((option) => html `<option value=${String(option.value)} .selected=${deepEqual(option.value, value)}>
297
+ ${option.label}
298
+ </option>`)}
299
+ </select>`;
300
+ }
301
+ if (control.type === 'slider') {
302
+ const numericValue = typeof value === 'number' ? value : Number(value) || 0;
303
+ return html `<div class="pr-slider">
304
+ <input
305
+ id=${id}
306
+ type="range"
307
+ aria-invalid=${invalid}
308
+ .value=${String(numericValue)}
309
+ min=${control.min ?? 0}
310
+ max=${control.max ?? 100}
311
+ step=${control.step ?? 1}
312
+ @input=${(event) => actions.updateVariable(control.path, Number(event.currentTarget.value))}
313
+ />
314
+ <input
315
+ type="number"
316
+ aria-invalid=${invalid}
317
+ aria-label=${control.label ?? control.path}
318
+ .value=${String(numericValue)}
319
+ min=${control.min ?? nothing}
320
+ max=${control.max ?? nothing}
321
+ step=${control.step ?? 1}
322
+ @change=${(event) => actions.updateVariable(control.path, Number(event.currentTarget.value))}
323
+ />
324
+ </div>`;
325
+ }
326
+ const isNumber = control.type === 'number';
327
+ return html `<input
328
+ id=${id}
329
+ aria-invalid=${invalid}
330
+ type=${isNumber ? 'number' : 'text'}
331
+ .value=${String(value ?? '')}
332
+ min=${isNumber && control.min !== undefined ? control.min : nothing}
333
+ max=${isNumber && control.max !== undefined ? control.max : nothing}
334
+ step=${isNumber ? (control.step ?? 'any') : nothing}
335
+ @input=${(event) => {
336
+ const raw = event.currentTarget.value;
337
+ actions.updateVariable(control.path, isNumber ? Number(raw) : raw);
338
+ }}
339
+ />`;
340
+ }
341
+ export function renderVariables(state) {
342
+ const { viewModel, variablesEnabled, actions } = state;
343
+ if (!variablesEnabled || viewModel.variableControls.length === 0)
344
+ return nothing;
345
+ return html `
346
+ <section class="pr-configurator" aria-labelledby="pr-variables-title" data-pr-part="variables">
347
+ <div class="pr-section__heading">
348
+ <h3 id="pr-variables-title">${t(viewModel, 'pricing.variables')}</h3>
349
+ <button class="pr-button pr-button--quiet" type="button" @click=${actions.resetVariables}>
350
+ ${t(viewModel, 'pricing.reset')}
351
+ </button>
352
+ </div>
353
+ <div class="pr-variable-grid">
354
+ ${viewModel.variableControls.map((control) => {
355
+ const id = `pr-variable-${control.path.replace(/[^\w-]+/g, '-')}`;
356
+ const invalid = viewModel.resolved.diagnostics.some((item) => {
357
+ const planMatch = item.path?.match(/^plans\.([^.]+)\.price$/)?.[1];
358
+ const addOnMatch = item.path?.match(/^addOns\.([^.]+)\.price$/)?.[1];
359
+ const price = planMatch
360
+ ? viewModel.pricing.plans.find((plan) => plan.id === planMatch)?.price
361
+ : addOnMatch
362
+ ? viewModel.pricing.addOns.find((addOn) => addOn.id === addOnMatch)?.price
363
+ : undefined;
364
+ return price?.kind === 'expression' && price.dependencies.includes(control.path);
365
+ });
366
+ return html `
367
+ <div class="pr-field ${invalid ? 'is-invalid' : ''}">
368
+ <label for=${id}>${control.label ?? control.path}</label>
369
+ ${control.description ? html `<p id="${id}-help">${control.description}</p>` : nothing}
370
+ ${controlInput(state, control, invalid)}
371
+ ${invalid
372
+ ? html `<p class="pr-field__error" role="alert">
373
+ ${t(viewModel, 'pricing.variableError')}
374
+ </p>`
375
+ : nothing}
376
+ </div>
377
+ `;
378
+ })}
379
+ </div>
380
+ </section>
381
+ `;
382
+ }
383
+ function filterGroups(state) {
384
+ const query = state.search.trim().toLowerCase();
385
+ return state.viewModel.comparisonGroups
386
+ .map((group) => ({
387
+ ...group,
388
+ rows: group.rows.filter((row) => {
389
+ const matchesQuery = !query ||
390
+ row.name.toLowerCase().includes(query) ||
391
+ row.description?.toLowerCase().includes(query);
392
+ return matchesQuery && (!state.onlyDifferences || resolvedValuesDiffer(row));
393
+ }),
394
+ }))
395
+ .filter((group) => group.rows.length > 0);
396
+ }
397
+ function visibleRows(state, group) {
398
+ return state.revealedGroups.has(group.id) ? group.rows : group.rows.slice(0, 30);
399
+ }
400
+ function renderComparisonToolbar(state, totalRows) {
401
+ if (state.viewModel.comparisonGroups.flatMap((group) => group.rows).length <= 40)
402
+ return nothing;
403
+ const { viewModel, actions } = state;
404
+ return html `
405
+ <div class="pr-comparison-toolbar" data-pr-part="comparison-toolbar">
406
+ <label class="pr-search">
407
+ <span class="pr-sr-only">${t(viewModel, 'pricing.search')}</span>
408
+ <input
409
+ type="search"
410
+ placeholder=${t(viewModel, 'pricing.search')}
411
+ .value=${state.search}
412
+ @input=${(event) => actions.updateSearch(event.currentTarget.value)}
413
+ />
414
+ </label>
415
+ <label class="pr-check">
416
+ <input
417
+ type="checkbox"
418
+ .checked=${state.onlyDifferences}
419
+ @change=${(event) => actions.updateOnlyDifferences(event.currentTarget.checked)}
420
+ />
421
+ <span>${t(viewModel, 'pricing.onlyDifferences')}</span>
422
+ </label>
423
+ <div class="pr-toolbar-actions">
424
+ <button type="button" @click=${() => actions.setAllGroups(true)}>
425
+ ${t(viewModel, 'pricing.expandAll')}
426
+ </button>
427
+ <button type="button" @click=${() => actions.setAllGroups(false)}>
428
+ ${t(viewModel, 'pricing.collapseAll')}
429
+ </button>
430
+ </div>
431
+ <span aria-live="polite">${t(viewModel, 'pricing.rowsShown', { count: totalRows })}</span>
432
+ </div>
433
+ `;
434
+ }
435
+ function renderCompactComparison(state, groups) {
436
+ const { viewModel, actions } = state;
437
+ return html `<div class="pr-compact-comparison">
438
+ ${groups.map((group) => {
439
+ const expanded = state.expandedGroups.has(group.id) || Boolean(state.search);
440
+ const rows = visibleRows(state, group);
441
+ return html `
442
+ <section class="pr-comparison-group">
443
+ <h4>
444
+ <button
445
+ type="button"
446
+ aria-expanded=${expanded}
447
+ aria-controls="pr-group-${group.id}"
448
+ @click=${() => actions.toggleGroup(group.id)}
449
+ >
450
+ <span>${group.name}</span><span aria-hidden="true">${expanded ? '−' : '+'}</span>
451
+ </button>
452
+ </h4>
453
+ ${expanded
454
+ ? html `<div id="pr-group-${group.id}">
455
+ ${rows.map((row) => html `
456
+ <article class="pr-feature-card" data-pr-part="comparison-row">
457
+ <div>
458
+ <h5>${row.name}</h5>
459
+ ${row.description ? html `<p>${row.description}</p>` : nothing}
460
+ ${renderRowMetadata(viewModel, row)}
461
+ ${row.docUrl && isSafeLink(row.docUrl)
462
+ ? html `<a href=${row.docUrl} target="_blank" rel="noopener noreferrer"
463
+ >${t(viewModel, 'pricing.documentation')}</a
464
+ >`
465
+ : nothing}
466
+ </div>
467
+ <dl>
468
+ ${row.values.map((cell) => {
469
+ const plan = viewModel.plans.find((candidate) => candidate.id === cell.planId);
470
+ return html `<div>
471
+ <dt>${plan?.name ?? cell.planId}</dt>
472
+ <dd>${renderValue(viewModel, cell.value, row.unit)}</dd>
473
+ </div>`;
474
+ })}
475
+ </dl>
476
+ </article>
477
+ `)}
478
+ ${group.rows.length > rows.length
479
+ ? html `<button
480
+ class="pr-button pr-button--quiet"
481
+ type="button"
482
+ @click=${() => actions.revealGroup(group.id)}
483
+ >
484
+ ${t(viewModel, 'pricing.showRemaining', {
485
+ count: group.rows.length - rows.length,
486
+ })}
487
+ </button>`
488
+ : nothing}
489
+ </div>`
490
+ : nothing}
491
+ </section>
492
+ `;
493
+ })}
494
+ </div>`;
495
+ }
496
+ function renderTableComparison(state, groups) {
497
+ const { viewModel, actions } = state;
498
+ return html `
499
+ <div
500
+ class="pr-table-scroll"
501
+ tabindex="0"
502
+ role="region"
503
+ aria-label=${t(viewModel, 'pricing.comparison')}
504
+ >
505
+ <table class="pr-comparison-table" data-pr-part="comparison-table">
506
+ <caption class="pr-sr-only">
507
+ ${t(viewModel, 'pricing.comparison')}
508
+ </caption>
509
+ <thead>
510
+ <tr>
511
+ <th scope="col">${t(viewModel, 'pricing.features')}</th>
512
+ ${viewModel.plans.map((plan) => html `<th scope="col">${plan.name}</th>`)}
513
+ </tr>
514
+ </thead>
515
+ ${groups.map((group) => {
516
+ const expanded = state.expandedGroups.has(group.id) || Boolean(state.search);
517
+ const rows = visibleRows(state, group);
518
+ return html `
519
+ <tbody>
520
+ <tr class="pr-category-row">
521
+ <th colspan=${viewModel.plans.length + 1}>
522
+ <button
523
+ type="button"
524
+ aria-expanded=${expanded}
525
+ @click=${() => actions.toggleGroup(group.id)}
526
+ >
527
+ <span>${group.name}</span
528
+ ><span aria-hidden="true">${expanded ? '−' : '+'}</span>
529
+ </button>
530
+ </th>
531
+ </tr>
532
+ ${expanded
533
+ ? rows.map((row) => html `
534
+ <tr data-pr-part="comparison-row">
535
+ <th scope="row">
536
+ <span>${row.name}</span>
537
+ ${row.description ? html `<small>${row.description}</small>` : nothing}
538
+ ${renderRowMetadata(viewModel, row)}
539
+ </th>
540
+ ${row.values.map((cell) => html `<td>${renderValue(viewModel, cell.value, row.unit)}</td>`)}
541
+ </tr>
542
+ `)
543
+ : nothing}
544
+ ${expanded && group.rows.length > rows.length
545
+ ? html `<tr>
546
+ <td colspan=${viewModel.plans.length + 1}>
547
+ <button
548
+ class="pr-button pr-button--quiet"
549
+ type="button"
550
+ @click=${() => actions.revealGroup(group.id)}
551
+ >
552
+ ${t(viewModel, 'pricing.showRemaining', {
553
+ count: group.rows.length - rows.length,
554
+ })}
555
+ </button>
556
+ </td>
557
+ </tr>`
558
+ : nothing}
559
+ </tbody>
560
+ `;
561
+ })}
562
+ </table>
563
+ </div>
564
+ `;
565
+ }
566
+ export function renderComparison(state) {
567
+ const { viewModel } = state;
568
+ const groups = filterGroups(state);
569
+ const totalRows = groups.reduce((total, group) => total + group.rows.length, 0);
570
+ if (viewModel.comparisonGroups.length === 0)
571
+ return nothing;
572
+ return html `
573
+ <section
574
+ class="pr-section pr-comparison"
575
+ aria-labelledby="pr-comparison-title"
576
+ data-pr-part="comparison"
577
+ >
578
+ <div class="pr-section__heading">
579
+ <div>
580
+ <p class="pr-section__eyebrow">${t(viewModel, 'pricing.details')}</p>
581
+ <h3 id="pr-comparison-title">${t(viewModel, 'pricing.comparison')}</h3>
582
+ </div>
583
+ </div>
584
+ ${renderComparisonToolbar(state, totalRows)}
585
+ ${state.compact
586
+ ? renderCompactComparison(state, groups)
587
+ : renderTableComparison(state, groups)}
588
+ </section>
589
+ `;
590
+ }
591
+ function addOnAvailability(addOn, planId) {
592
+ return (addOn.availableFor.length === 0 || (planId !== undefined && addOn.availableFor.includes(planId)));
593
+ }
594
+ function renderAddOnEffects(viewModel, addOn) {
595
+ const effects = [
596
+ ...Object.entries(addOn.features).map(([id, value]) => ({
597
+ kind: t(viewModel, 'pricing.featureEffect'),
598
+ id,
599
+ value: unwrapValue(value),
600
+ })),
601
+ ...Object.entries(addOn.usageLimits).map(([id, value]) => ({
602
+ kind: t(viewModel, 'pricing.limitEffect'),
603
+ id,
604
+ value: unwrapValue(value),
605
+ })),
606
+ ...Object.entries(addOn.usageLimitsExtensions).map(([id, value]) => ({
607
+ kind: t(viewModel, 'pricing.limitExtension'),
608
+ id,
609
+ value: unwrapValue(value),
610
+ })),
611
+ ];
612
+ if (effects.length === 0)
613
+ return nothing;
614
+ return html `
615
+ <details class="pr-addon-effects">
616
+ <summary>${t(viewModel, 'pricing.effects')}</summary>
617
+ <dl>
618
+ ${effects.map((effect) => html `<div>
619
+ <dt>${effect.kind}: ${humanizeIdentifier(effect.id)}</dt>
620
+ <dd>${renderValue(viewModel, effect.value)}</dd>
621
+ </div>`)}
622
+ </dl>
623
+ </details>
624
+ `;
625
+ }
626
+ export function renderAddOns(state) {
627
+ const { viewModel, selection, selectionEnabled, actions } = state;
628
+ if (viewModel.addOns.length === 0)
629
+ return nothing;
630
+ return html `
631
+ <section class="pr-section" aria-labelledby="pr-addons-title" data-pr-part="add-ons">
632
+ <div class="pr-section__heading">
633
+ <div>
634
+ <p class="pr-section__eyebrow">${t(viewModel, 'pricing.addOns')}</p>
635
+ <h3 id="pr-addons-title">${t(viewModel, 'pricing.addOns')}</h3>
636
+ </div>
637
+ </div>
638
+ <div class="pr-addon-layout">
639
+ <div class="pr-addon-grid">
640
+ ${viewModel.addOns.map((addOn) => {
641
+ const selected = selection.addOns[addOn.id]?.selected ?? false;
642
+ const quantity = selection.addOns[addOn.id]?.quantity ??
643
+ addOn.subscriptionConstraints?.minQuantity ??
644
+ 1;
645
+ const available = addOnAvailability(addOn, selection.planId);
646
+ const constraints = addOn.subscriptionConstraints;
647
+ return html `
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)}
761
+ </div>
762
+ </section>
763
+ `;
764
+ }
765
+ export function renderSummary(state) {
766
+ const { viewModel } = state;
767
+ const subtotal = formatCurrency(viewModel.resolved.subtotal, viewModel.pricing.metadata.currency, viewModel.locale);
768
+ return html `
769
+ <aside class="pr-summary" data-pr-part="summary" aria-live="polite">
770
+ <p>
771
+ ${viewModel.resolved.requiresQuote ? t(viewModel, 'pricing.subtotal') : t(viewModel, 'pricing.total')}
772
+ </p>
773
+ <strong>${subtotal}</strong>
774
+ ${viewModel.resolved.requiresQuote
775
+ ? html `<span>${t(viewModel, 'pricing.requiresQuote')}</span>`
776
+ : nothing}
777
+ </aside>
778
+ `;
779
+ }
780
+ export function renderCatalogMetadata(state) {
781
+ const { viewModel } = state;
782
+ if (viewModel.mode !== 'catalog')
783
+ return nothing;
784
+ const metadata = viewModel.pricing.metadata;
785
+ return html `
786
+ <section class="pr-metadata" aria-labelledby="pr-metadata-title" data-pr-part="metadata">
787
+ <h3 id="pr-metadata-title">${t(viewModel, 'pricing.details')}</h3>
788
+ <dl>
789
+ <div>
790
+ <dt>${t(viewModel, 'pricing.version')}</dt>
791
+ <dd>${metadata.version ?? metadata.syntaxVersion}</dd>
792
+ </div>
793
+ ${metadata.createdAt
794
+ ? html `<div>
795
+ <dt>${t(viewModel, 'pricing.createdAt')}</dt>
796
+ <dd>${formatDate(metadata.createdAt, viewModel.locale)}</dd>
797
+ </div>`
798
+ : nothing}
799
+ <div>
800
+ <dt>${t(viewModel, 'pricing.currency')}</dt>
801
+ <dd>${metadata.currency}</dd>
802
+ </div>
803
+ ${metadata.url && isSafeLink(metadata.url)
804
+ ? html `<div>
805
+ <dt>URL</dt>
806
+ <dd>
807
+ <a href=${metadata.url} target="_blank" rel="noopener noreferrer"
808
+ >${metadata.url}</a
809
+ >
810
+ </dd>
811
+ </div>`
812
+ : nothing}
813
+ </dl>
814
+ </section>
815
+ `;
816
+ }
817
+ export function renderPendingDialog(state) {
818
+ const pending = state.pendingAddOnChange;
819
+ if (!pending)
820
+ return nothing;
821
+ const { viewModel, actions } = state;
822
+ return html `
823
+ <div class="pr-dialog-backdrop">
824
+ <div
825
+ class="pr-dialog"
826
+ role="dialog"
827
+ aria-modal="true"
828
+ aria-labelledby="pr-dialog-title"
829
+ aria-describedby="pr-dialog-description"
830
+ @keydown=${(event) => {
831
+ if (event.key === 'Escape')
832
+ actions.cancelAddOnChange();
833
+ }}
834
+ >
835
+ <h3 id="pr-dialog-title">${t(viewModel, 'pricing.confirmChanges')}</h3>
836
+ <p id="pr-dialog-description">${t(viewModel, 'pricing.confirmChangesDescription')}</p>
837
+ ${pending.addIds.length
838
+ ? html `<p>${t(viewModel, 'pricing.willAdd', { items: pending.addIds.join(', ') })}</p>`
839
+ : nothing}
840
+ ${pending.removeIds.length
841
+ ? html `<p>
842
+ ${t(viewModel, 'pricing.willRemove', { items: pending.removeIds.join(', ') })}
843
+ </p>`
844
+ : nothing}
845
+ <div class="pr-dialog__actions">
846
+ <button
847
+ class="pr-button pr-button--secondary"
848
+ type="button"
849
+ @click=${actions.cancelAddOnChange}
850
+ >
851
+ ${t(viewModel, 'pricing.cancel')}
852
+ </button>
853
+ <button
854
+ class="pr-button pr-button--primary"
855
+ type="button"
856
+ autofocus
857
+ @click=${actions.confirmAddOnChange}
858
+ >
859
+ ${t(viewModel, 'pricing.apply')}
860
+ </button>
861
+ </div>
862
+ </div>
863
+ </div>
864
+ `;
865
+ }
866
+ //# sourceMappingURL=renderers.js.map