Skip to content

Commit

Permalink
feat(cc-pricing-product-consumption)!: adapt to support priceSystem c…
Browse files Browse the repository at this point in the history
…urrency

Smart components:
- The `zoneId` context parameter of `cc-pricing-product-consumption.smart`
is now optional (default value: `'par'`).

BREAKING CHANGE: the component no longer supports hard coded change rate
Prices displayed in pricing components are no longer computed based on
hard coded change rate.
Prices related to the selected currency come from the API priceSytem
endpoint.
The component also relies on the new `state` prop for data coming from
the API.

This means that some types for the component properties have changed:
- `currency` is now a `string` (ISO 4217 currency code),
- `product` has been removed,
- `state` has been added to replace `product`,
  - `product.state` is now `state.type`.

The type of the payload dispatched with the event
`cc-pricing-product:add-plan` has changed:
- `sections` (`Array<PricingSection>`) are now part of the payload so
  that prices can be computed by the `cc-pricing-estimation` component
  everytime the currency changes.

Fixes #1167 #1176 #1109
  • Loading branch information
florian-sanders-cc committed Oct 30, 2024
1 parent b12060a commit 6ace6eb
Show file tree
Hide file tree
Showing 12 changed files with 531 additions and 369 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ import '../cc-toggle/cc-toggle.js';
const BREAKPOINT = 600;

// FIXME: this code is duplicated across all pricing components (see issue #732 for more details)
const CURRENCY_EUR = { code: 'EUR', changeRate: 1 };
const CURRENCY_EUR = 'EUR';

const INFINITY = '∞';
const THIRTY_DAYS_IN_HOURS = 24 * 30;
const ONE_GIGABYTE = 1e9;
const THIRTY_DAYS_IN_HOURS = 24 * 30;

const ICONS = {
storage: iconDisk,
Expand All @@ -41,10 +41,14 @@ const ICONS = {

/**
* @typedef {import('../common.types.js').ActionType} ActionType
* @typedef {import('../common.types.js').Currency} Currency
* @typedef {import('../common.types.js').SectionType} SectionType
* @typedef {import('../common.types.js').PricingSection} PricingSection
* @typedef {import('../common.types.js').PricingInterval} PricingInterval
* @typedef {import('../common.types.js').Plan} Plan
* @typedef {import('../common.types.js').ConsumptionPlan} ConsumptionPlan
* @typedef {import('./cc-pricing-product-consumption.types.js').PricingProductConsumptionState} PricingProductConsumptionState
* @typedef {import('./cc-pricing-product-consumption.types.js').SectionStates} SectionStates
* @typedef {import('lit').PropertyValues<CcPricingProductConsumption>} CcPricingProductConsumptionPropertyValues
*/

/**
Expand All @@ -60,16 +64,16 @@ const ICONS = {
*
* @cssdisplay block
*
* @fires {CustomEvent<Plan>} cc-pricing-product:add-plan - Fires the plan whenever the "add" button is clicked.
* @fires {CustomEvent<ConsumptionPlan>} cc-pricing-product:add-plan - Fires the plan whenever the "add" button is clicked.
*
* @cssprop {Color} --cc-pricing-hovered-color - Sets the text color used on hover (defaults: `purple`).
*/
export class CcPricingProductConsumption extends LitElement {
static get properties() {
return {
action: { type: String },
currency: { type: Object },
product: { type: Object },
currency: { type: String },
state: { type: Object },
};
}

Expand All @@ -79,11 +83,11 @@ export class CcPricingProductConsumption extends LitElement {
/** @type {ActionType} Sets the type of action: "add" to display the "add" button for the product and "none" for no actions (defaults to "add") */
this.action = 'add';

/** @type {Currency} Sets the currency used to display the prices (defaults to euros) */
/** @type {string} Sets the currency used to display the prices (defaults to euros) */
this.currency = CURRENCY_EUR;

/** @type {PricingProductConsumptionState} Sets the state of the product */
this.product = { state: 'loading' };
this.state = { type: 'loading' };

this._simulator = new PricingConsumptionSimulator();

Expand Down Expand Up @@ -142,12 +146,12 @@ export class CcPricingProductConsumption extends LitElement {
* Returns the currency code and the computed price factored with the currency changerate
*
* @param {number} amount - the amount to base the calculation on
* @return {Object} An object containing the computed price and the currency code
* @return {{ price: number, currency: string }} An object containing the computed price and the currency
*/
_getCurrencyValue(amount) {
return {
price: amount * this.currency.changeRate,
code: this.currency.code,
price: amount,
currency: this.currency,
};
}

Expand Down Expand Up @@ -186,10 +190,15 @@ export class CcPricingProductConsumption extends LitElement {
/**
* Returns the localized interval price depending on the given section type.
*
* @param {SectionPrice} type - the section type related to the interval price to process
* @param {SectionType} type - the section type related to the interval price to process
* @param {number} intervalPrice - the interval price to localize
* @returns {string}
*/
_getIntervalPrice(type, intervalPrice) {
if (this.state.type !== 'loaded') {
return null;
}

if (intervalPrice === 0) {
return i18n('cc-pricing-product-consumption.price-interval.free');
}
Expand All @@ -202,14 +211,14 @@ export class CcPricingProductConsumption extends LitElement {
)
: i18n('cc-pricing-product-consumption.price-interval.users', {
...this._getCurrencyValue(intervalPrice),
userCount: this.product.sections.find((s) => s.type === type).secability ?? 1,
userCount: this.state.sections.find((s) => s.type === type).secability ?? 1,
});
}

/**
* Returns the localized and formatted units based with their corresponding value in bytes.
*
* @return {Array} An array containing objects with a label (localized unit) and a value (the corresponding value in bytes).
* @return {Array<{ label: string, value: string }>} An array containing objects with a label (localized unit) and a value (the corresponding value in bytes).
*/
_getUnits() {
return [
Expand Down Expand Up @@ -254,7 +263,11 @@ export class CcPricingProductConsumption extends LitElement {
* Dispatches a `cc-pricing-product:add-plan` event with the plan as its payload.
*/
_onAddPlan() {
const name = (this.product?.sections ?? [])
if (this.state.type !== 'loaded') {
return;
}

const name = (this.state?.sections ?? [])
.map(({ type }) => {
const title = this._getTitle(type);
const quantity = this._isTypeBytes(type)
Expand All @@ -264,12 +277,15 @@ export class CcPricingProductConsumption extends LitElement {
})
.join(', ');

/** @type {ConsumptionPlan} */
const plan = {
productName: this.product.name,
productName: this.state.name,
name,
// As explained above, interval prices are expected to be in "euros / byte / 30 days" or just "euros / byte" for timeless sections like traffic
// To comply with `<cc-pricing-product>`, the price in this event is in "euros / 1 hour"
price: this._simulator.getTotalPrice() / THIRTY_DAYS_IN_HOURS,
sections: this.state.sections.map((section) => ({
...section,
quantity: this._simulator.getQuantity(section.type),
})),
};

dispatchCustomEvent(this, 'cc-pricing-product:add-plan', plan);
Expand All @@ -280,25 +296,29 @@ export class CcPricingProductConsumption extends LitElement {
* Triggers an update of the simulator so that it retrieves the new quantity.
*
* @param {SectionType} type - the type of the section to update
* @param {number} quantity - the quantity to set
*/
_onInputValue(type, quantity) {
this._sectionStates[type].quantity = isNaN(quantity) ? 0 : quantity;
this._updateSimulatorQuantity(type);
this.requestUpdate();
_onInputValue(type) {
/** @param {CustomEvent<number>} e */
return (e) => {
this._sectionStates[type].quantity = isNaN(e.detail) ? 0 : e.detail;
this._updateSimulatorQuantity(type);
this.requestUpdate();
};
}

/**
* Updates the selected unit.
* Triggers an update of the simulator so that it retrieves the new unit.
*
* @param {SectionType} type - the type of the section to update
* @param {number} unitValue - the unit to set
*/
_onToggleUnit(type, unitValue) {
this._sectionStates[type].unitValue = unitValue;
this._updateSimulatorQuantity(type);
this.requestUpdate();
_onToggleUnit(type) {
/** @param {CustomEvent<string>} e */
return (e) => {
this._sectionStates[type].unitValue = e.detail;
this._updateSimulatorQuantity(type);
this.requestUpdate();
};
}

/**
Expand All @@ -311,12 +331,13 @@ export class CcPricingProductConsumption extends LitElement {
this.requestUpdate();
}

/** @param {CcPricingProductConsumptionPropertyValues} changedProperties */
willUpdate(changedProperties) {
// This is not done within the `render` function because we only want to reset this value in specific cases.
// We reset the simulator & section states only if the product has changed.
if (changedProperties.has('product') && Array.isArray(this.product.sections)) {
if (changedProperties.has('state') && this.state.type === 'loaded' && Array.isArray(this.state.sections)) {
this._sectionStates = {};
this.product.sections.forEach(({ type }) => {
this.state.sections.forEach(({ type }) => {
this._sectionStates[type] = {
// In small mode, sections are closed by default
isClosed: true,
Expand All @@ -327,23 +348,21 @@ export class CcPricingProductConsumption extends LitElement {
};
});

this._simulator = new PricingConsumptionSimulator(this.product.sections);
this._simulator = new PricingConsumptionSimulator(this.state.sections);
}
}

render() {
return html`
${this.product.state === 'error'
${this.state.type === 'error'
? html` <cc-notice intent="warning" message=${i18n('cc-pricing-product-consumption.error')}></cc-notice> `
: ''}
${this.product.state === 'loading' ? html` <cc-loader></cc-loader> ` : ''}
${this.product.state === 'loaded' ? this._renderLoaded(this.product.sections) : ''}
${this.state.type === 'loading' ? html` <cc-loader></cc-loader> ` : ''}
${this.state.type === 'loaded' ? this._renderLoaded(this.state.sections) : ''}
`;
}

/**
* @param {PricingSection[]} sections
*/
/** @param {Array<PricingSection>} sections */
_renderLoaded(sections) {
const { width } = this._resizeController;
const bodyClasses = {
Expand Down Expand Up @@ -390,9 +409,7 @@ export class CcPricingProductConsumption extends LitElement {
`;
}

/**
* @param {PricingSection} section
*/
/** @param {PricingSection} section */
_renderSection(section) {
const intervals = section.intervals;
const progressive = section.progressive;
Expand Down Expand Up @@ -441,24 +458,24 @@ export class CcPricingProductConsumption extends LitElement {
* @param {Object} plan
* @param {SectionType} plan.type
* @param {number} plan.quantity
* @param {number} plan.unitValue
* @param {string} plan.unitValue
*/
_renderInput({ type, quantity, unitValue }) {
if (this._isTypeBytes(type)) {
return html`
<cc-input-number
label=${i18n('cc-pricing-product-consumption.size', { bytes: unitValue })}
label=${i18n('cc-pricing-product-consumption.size', { bytes: Number(unitValue) })}
class="input-quantity"
value=${quantity}
min="0"
@cc-input-number:input=${(e) => this._onInputValue(type, e.detail)}
@cc-input-number:input=${this._onInputValue(type)}
></cc-input-number>
<cc-toggle
legend=${i18n('cc-pricing-product-consumption.unit')}
class="input-unit"
value=${unitValue}
.choices=${this._getUnits()}
@cc-toggle:input=${(e) => this._onToggleUnit(type, e.detail)}
@cc-toggle:input=${this._onToggleUnit(type)}
></cc-toggle>
`;
} else {
Expand All @@ -468,17 +485,18 @@ export class CcPricingProductConsumption extends LitElement {
class="input-quantity"
value=${quantity}
min="0"
@cc-input-number:input=${(e) => this._onInputValue(type, e.detail)}
@cc-input-number:input=${this._onInputValue(type)}
></cc-input-number>
`;
}
}

/**
* @param {SectionType} type
* @param {boolean} progressive
* @param {Interval[]} intervals
* @param {Interval} maxInterval
* @param {Object} params
* @param {SectionType} params.type
* @param {boolean} params.progressive
* @param {Array<PricingInterval>} params.intervals
* @param {PricingInterval} params.maxInterval
*/
_renderIntervalList({ type, progressive, intervals, maxInterval }) {
return intervals.map((interval, intervalIndex) => {
Expand Down Expand Up @@ -534,10 +552,10 @@ export class CcPricingProductConsumption extends LitElement {
white-space: nowrap;
}
/*
these elements could be removed but they help the readability of the whole template
in source code and browser devtools
*/
/*
* these elements could be removed but they help the readability of the whole template
* in source code and browser devtools
*/
.section,
.section-header,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,47 +12,51 @@ import './cc-pricing-product-consumption.js';
defineSmartComponent({
selector: 'cc-pricing-product-consumption',
params: {
apiConfig: { type: Object, optional: true },
productId: { type: String },
zoneId: { type: String },
zoneId: { type: String, optional: true },
currency: { type: String, optional: true },
},
/**
* @param {Object} settings
* @param {CcPricingProductConsumption} settings.component
* @param {{ apiConfig: ApiConfig, productId: string, zoneId: string }} settings.context
* @param {{ apiConfig?: ApiConfig, productId: string, zoneId?: string, currency?: string }} settings.context
* @param {(type: string, listener: (detail: any) => void) => void} settings.onEvent
* @param {function} settings.updateComponent
* @param {AbortSignal} settings.signal
*/
// @ts-expect-error FIXME: remove once `onContextUpdate` is typed with generics
onContextUpdate({ updateComponent, context, signal }) {
const { productId, zoneId } = context;
const { apiConfig, productId, zoneId = 'par', currency = 'EUR' } = context;

// Reset the component before loading
updateComponent('product', { state: 'loading' });
updateComponent('state', { state: 'loading' });

fetchProduct({ productId, zoneId, signal })
fetchProduct({ apiConfig, productId, zoneId, currency, signal })
.then((product) => {
updateComponent('product', {
updateComponent('state', {
name: product.name,
state: 'loaded',
type: 'loaded',
sections: product.sections,
});
})
.catch((error) => {
console.error(error);
updateComponent('product', { state: 'error' });
updateComponent('state', { state: 'error' });
});
},
});

/**
* @param {object} params
* @param {ApiConfig} [params.apiConfig]
* @param {string} params.productId
* @param {string} params.zoneId
* @param {string} params.currency
* @param {AbortSignal} params.signal
*/
function fetchProduct({ productId, zoneId, signal }) {
return fetchPriceSystem({ zoneId, signal }).then((priceSystem) => {
function fetchProduct({ apiConfig, productId, zoneId, currency, signal }) {
return fetchPriceSystem({ apiConfig, zoneId, currency, signal }).then((priceSystem) => {
if (productId === 'cellar') {
return {
name: 'Cellar',
Expand Down
Loading

0 comments on commit 6ace6eb

Please sign in to comment.