Skip to content

Commit

Permalink
feat(cc-pricing-product)!: adapt to support priceSystem currency
Browse files Browse the repository at this point in the history
Smart components:
- The `addonFeatures` context parameter of the `cc-pricing-product.smart-addon`
component is now optional as it should always have been
(default value: `[]` meaning that all features are displayed by default).
- The `zoneId` context parameter of both `cc-pricing-product.smart-addon` &
`cc-pricing-product.smart-runtime` 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 payload type of the `cc-pricing-product:add-plan` dispatched event has changed.
Plans must now include a `priceId` property to be handled by the
`cc-pricing-estimation` correctly. This also means that custom products
/ plans can no longer be added to the estimation because they are not
part of the priceSystem and have no `priceId`.

The payload type of the `cc-pricing-product:add-plan` dispatched event has changed.
Plans must now include a `priceId` property to be handled by the
`cc-pricing-estimation` correctly. This also means that custom products
/ plans can no longer be added to the estimation because they are not
part of the priceSystem and have no `priceId`.

Fixes #1167
Fixes #1109
Fixes #1174
Fixes #1176
  • Loading branch information
florian-sanders-cc committed Dec 4, 2024
1 parent 125e9e7 commit 1104902
Show file tree
Hide file tree
Showing 15 changed files with 7,283 additions and 5,096 deletions.
120 changes: 60 additions & 60 deletions src/components/cc-pricing-product/cc-pricing-product.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import '../cc-notice/cc-notice.js';

// 800 seems like a good arbitrary value for the content we need to display.
const BREAKPOINT = 800;
/** @type {Record<FormattedFeature['code'], () => string>} */
const FEATURES_I18N = {
'connection-limit': () => i18n('cc-pricing-product.feature.connection-limit'),
cpu: () => i18n('cc-pricing-product.feature.cpu'),
Expand All @@ -28,19 +29,18 @@ const FEATURES_I18N = {
const AVAILABLE_FEATURES = Object.keys(FEATURES_I18N);
const NUMBER_FEATURE_TYPES = ['bytes', 'number', 'number-cpu-runtime'];

/** @type {Currency} */
// 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';

/** @type {Temporality[]} */
const DEFAULT_TEMPORALITY_LIST = [{ type: '30-days', digits: 2 }];

/**
* @typedef {import('../common.types.js').ActionType} ActionType
* @typedef {import('../common.types.js').Currency} Currency
* @typedef {import('./cc-pricing-product.types.js').PricingProductState} PricingProductState
* @typedef {import('../common.types.js').Temporality} Temporality
* @typedef {import('../common.types.js').Plan} Plan
* @typedef {import('../common.types.js').FormattedFeature} FormattedFeature
*/

/**
Expand Down Expand Up @@ -73,8 +73,8 @@ export class CcPricingProduct extends LitElement {
static get properties() {
return {
action: { type: String },
currency: { type: Object },
product: { type: Object },
currency: { type: String },
state: { type: Object },
temporalities: { type: Array },
};
}
Expand All @@ -85,11 +85,11 @@ export class CcPricingProduct extends LitElement {
/** @type {ActionType} Sets the type of action: "add" to display add buttons for each plan 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 `EUR`). */
this.currency = CURRENCY_EUR;

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

/**
* @type {Temporality[]} Sets the time window(s) you want to display the prices in (defaults to 30 days with 2 fraction digits).
Expand All @@ -104,15 +104,15 @@ export class CcPricingProduct extends LitElement {
/**
* Returns the translated string corresponding to a feature code.
*
* @param {Feature} feature - the feature to translate
* @return {string|undefined} the translated feature name if a translation exists or nothing if the translation does not exist
* @param {FormattedFeature} feature - the feature to translate
* @return {string|void} the translated feature name if a translation exists or nothing if the translation does not exist
*/
_getFeatureName(feature) {
if (feature == null) {
return '';
}

if (FEATURES_I18N[feature.code] != null) {
if (feature.code in FEATURES_I18N) {
return FEATURES_I18N[feature.code]();
}

Expand All @@ -124,13 +124,14 @@ export class CcPricingProduct extends LitElement {
/**
* Returns the formatted value corresponding to a feature
*
* @param {feature} feature - the feature to get the formatted value from
* @return {string} the formatted value for the given feature or the feature value itself if it does not require any formatting
* @param {FormattedFeature} feature - the feature to get the formatted value from
* @return {string|Node|void} the formatted value for the given feature or the feature value itself if it does not require any formatting
*/
_getFeatureValue(feature) {
if (feature == null) {
return '';
}

switch (feature.type) {
case 'boolean':
return i18n('cc-pricing-product.type.boolean', { boolean: feature.value === 'true' });
Expand All @@ -146,11 +147,17 @@ export class CcPricingProduct extends LitElement {
: i18n('cc-pricing-product.type.number', { number: Number(feature.value) });
case 'number-cpu-runtime':
return i18n('cc-pricing-product.type.number-cpu-runtime', {
/**
* Narrowing the type would make the code less readable for no gain, improving the type to separate
* `number-cpu-runtime` from other types makes the code a lot more complex for almost no type safety gain
*/
// @ts-ignore
cpu: feature.value.cpu,
// @ts-ignore
shared: feature.value.shared,
});
case 'string':
return feature.value;
return feature.value.toString();
}
}

Expand All @@ -162,50 +169,42 @@ export class CcPricingProduct extends LitElement {
* @return {number} the computed price based on the given temporality
*/
_getPrice(type, hourlyPrice) {
if (type === 'second') {
return (hourlyPrice / 60 / 60) * this.currency.changeRate;
}
if (type === 'minute') {
return (hourlyPrice / 60) * this.currency.changeRate;
}
if (type === 'hour') {
return hourlyPrice * this.currency.changeRate;
}
if (type === '1000-minutes') {
return (hourlyPrice / 60) * 1000 * this.currency.changeRate;
}
if (type === 'day') {
return hourlyPrice * 24 * this.currency.changeRate;
}
if (type === '30-days') {
return hourlyPrice * 24 * 30 * this.currency.changeRate;
switch (type) {
case 'second':
return hourlyPrice / 60 / 60;
case 'minute':
return hourlyPrice / 60;
case 'hour':
return hourlyPrice;
case '1000-minutes':
return (hourlyPrice / 60) * 1000;
case 'day':
return hourlyPrice * 24;
case '30-days':
return hourlyPrice * 24 * 30;
}
}

/**
* Returns the translated price label corresponding to a temporality
*
* @param {Temporality['type']} type - the temporality type
* @return {string} the translated label corresponding to the given temporality
* @return {string|Node} the translated label corresponding to the given temporality
*/
_getPriceLabel(type) {
if (type === 'second') {
return i18n('cc-pricing-product.price-name.second');
}
if (type === 'minute') {
return i18n('cc-pricing-product.price-name.minute');
}
if (type === 'hour') {
return i18n('cc-pricing-product.price-name.hour');
}
if (type === '1000-minutes') {
return i18n('cc-pricing-product.price-name.1000-minutes');
}
if (type === 'day') {
return i18n('cc-pricing-product.price-name.day');
}
if (type === '30-days') {
return i18n('cc-pricing-product.price-name.30-days');
switch (type) {
case 'second':
return i18n('cc-pricing-product.price-name.second');
case 'minute':
return i18n('cc-pricing-product.price-name.minute');
case 'hour':
return i18n('cc-pricing-product.price-name.hour');
case '1000-minutes':
return i18n('cc-pricing-product.price-name.1000-minutes');
case 'day':
return i18n('cc-pricing-product.price-name.day');
case '30-days':
return i18n('cc-pricing-product.price-name.30-days');
}
}

Expand All @@ -216,11 +215,12 @@ export class CcPricingProduct extends LitElement {
* @param {Temporality['type']} type - the temporality type
* @param {number} hourlyPrice - the price to base the calculations on
* @param {number} digits - the number of digits to be used for price rounding
* @returns {string|void}
*/
_getPriceValue(type, hourlyPrice, digits) {
const price = this._getPrice(type, hourlyPrice);
if (price != null) {
return i18n('cc-pricing-product.price', { price, code: this.currency.code, digits });
return i18n('cc-pricing-product.price', { price, currency: this.currency, digits });
}
}

Expand All @@ -237,20 +237,20 @@ export class CcPricingProduct extends LitElement {

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

/**
* @param {string} productName - the name of the product
* @param {Plan[]} productPlans - the list of plans attached to this product
* @param {Feature[]} productFeatures - the list of features to display
* @param {Array<FormattedFeature>} productFeatures - the list of features to display
*/
_renderProductPlans(productName, productPlans, productFeatures) {
// this component is not rerendering very often so we consider we can afford to sort plans and filter the features here.
Expand All @@ -269,7 +269,7 @@ export class CcPricingProduct extends LitElement {
/**
* @param {string} productName
* @param {Plan[]} sortedPlans
* @param {Feature[]} productFeatures
* @param {Array<FormattedFeature>} productFeatures
*/
_renderBigPlans(productName, sortedPlans, productFeatures) {
const temporality = this.temporalities ?? DEFAULT_TEMPORALITY_LIST;
Expand Down Expand Up @@ -329,8 +329,8 @@ export class CcPricingProduct extends LitElement {
}

/**
* @param {Feature[]} planFeatures
* @param {Feature[]} productFeatures
* @param {Array<FormattedFeature>} planFeatures
* @param {Array<FormattedFeature>} productFeatures
*/
_renderBigPlanFeatures(planFeatures, productFeatures) {
return productFeatures.map((feature) => {
Expand All @@ -346,7 +346,7 @@ export class CcPricingProduct extends LitElement {
/**
* @param {string} productName
* @param {Plan[]} sortedPlans
* @param {Feature[]} productFeatures
* @param {Array<FormattedFeature>} productFeatures
*/
_renderSmallPlans(productName, sortedPlans, productFeatures) {
const temporality = this.temporalities ?? DEFAULT_TEMPORALITY_LIST;
Expand Down Expand Up @@ -394,8 +394,8 @@ export class CcPricingProduct extends LitElement {
}

/**
* @param {Feature[]} planFeatures
* @param {Feature[]} productFeatures
* @param {Array<FormattedFeature>} planFeatures
* @param {Array<FormattedFeature>} productFeatures
*/
_renderSmallPlanFeatures(planFeatures, productFeatures) {
return productFeatures.map((feature) => {
Expand Down
91 changes: 70 additions & 21 deletions src/components/cc-pricing-product/cc-pricing-product.smart-addon.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @ts-expect-error FIXME: remove when clever-client exports types
import { getAllAddonProviders } from '@clevercloud/client/esm/api/v2/product.js';
// @ts-expect-error FIXME: remove when clever-client exports types
import { ONE_DAY } from '@clevercloud/client/esm/with-cache.js';
import { fetchPriceSystem } from '../../lib/api-helpers.js';
import { defineSmartComponent } from '../../lib/define-smart-component.js';
Expand All @@ -7,49 +9,96 @@ import { sendToApi } from '../../lib/send-to-api.js';
import '../cc-smart-container/cc-smart-container.js';
import './cc-pricing-product.js';

/**
* @typedef {import('./cc-pricing-product.js').CcPricingProduct} CcPricingProduct
* @typedef {import('./cc-pricing-product.types.js').PricingProductStateLoaded} PricingProductStateLoaded
* @typedef {import('../../lib/send-to-api.types.js').ApiConfig} ApiConfig
* @typedef {import('../common.types.js').FormattedFeature} FormattedFeature
* @typedef {import('../common.types.js').RawAddonProvider} RawAddonProvider
* @typedef {import('../common.types.js').Zone} Zone
* @typedef {import('../common.types.js').Instance} Instance
*/

defineSmartComponent({
selector: 'cc-pricing-product[mode="addon"]',
params: {
addonFeatures: { type: Array },
apiConfig: { type: Object },
addonFeatures: { type: Array, optional: true },
productId: { type: String },
zoneId: { type: String },
zoneId: { type: String, optional: true },
currency: { type: String, optional: true },
},
/**
* @param {Object} settings
* @param {CcPricingProduct} settings.component
* @param {{apiConfig: ApiConfig, productId: string, zoneId?: string, currency?: string, addonFeatures?: Array<FormattedFeature['code']> }} 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 type with generics
onContextUpdate({ context, updateComponent, signal }) {
const { productId, zoneId, addonFeatures } = context;
const { apiConfig, productId, zoneId = 'par', addonFeatures, currency = 'EUR' } = context;

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

fetchAddonProduct({ zoneId, productId, addonFeatures, signal })
fetchAddonProduct({ apiConfig, zoneId, productId, addonFeatures, currency, signal })
.then((productDetails) => {
updateComponent('product', {
state: 'loaded',
updateComponent('state', {
type: 'loaded',
name: productDetails.name,
productFeatures: productDetails.productFeatures,
plans: productDetails.plans,
});
})
.catch((error) => {
console.error(error);
updateComponent('product', { state: 'error' });
updateComponent('state', { type: 'error' });
});
},
});

function fetchAddonProduct({ productId, zoneId, addonFeatures, signal }) {
return Promise.all([fetchAddonProvider({ productId, signal }), fetchPriceSystem({ zoneId, signal })]).then(
([addonProvider, priceSystem]) => formatAddonProduct(addonProvider, priceSystem, addonFeatures),
);
/**
* Fetches addon product information by combining addon provider and price system data.
*
* @param {Object} options - The options for fetching addon product.
* @param {ApiConfig} options.apiConfig - The API configuration.
* @param {string} options.productId - The ID of the product.
* @param {string} options.zoneId - The ID of the zone.
* @param {Array<FormattedFeature['code']>} options.addonFeatures - The features of the addon.
* @param {string} options.currency - The currency for pricing.
* @param {AbortSignal} options.signal - The abort signal for the fetch operation.
* @returns {Promise<Omit<PricingProductStateLoaded, 'type'>>} A promise that resolves to the formatted addon product.
*/
function fetchAddonProduct({ apiConfig, productId, zoneId, addonFeatures, currency, signal }) {
return Promise.all([
fetchAddonProvider({ apiConfig, productId, signal }),
fetchPriceSystem({ apiConfig, zoneId, currency, signal }),
]).then(([addonProvider, priceSystem]) => formatAddonProduct(addonProvider, priceSystem, addonFeatures));
}

function fetchAddonProvider({ signal, productId }) {
/**
* Fetches an addon provider based on the given product ID.
*
* @param {Object} options - The options for fetching the addon provider.
* @param {ApiConfig} options.apiConfig - The API configuration.
* @param {AbortSignal} options.signal - The abort signal for the fetch operation.
* @param {string} options.productId - The ID of the product to fetch the addon provider for.
* @returns {Promise<RawAddonProvider>} A promise that resolves to the addon provider object.
* @throws {Error} Throws an error if the addon provider is not found.
*/
function fetchAddonProvider({ apiConfig, signal, productId }) {
return getAllAddonProviders()
.then(sendToApi({ cacheDelay: ONE_DAY, signal }))
.then((allAddonProviders) => {
const addonProvider = allAddonProviders.find((ap) => ap.id === productId);
if (addonProvider == null) {
throw new Error(`Unknown add-on provider ID: ${productId}`);
}
return addonProvider;
});
.then(sendToApi({ apiConfig, cacheDelay: ONE_DAY, signal }))
.then(
/** @param {Array<RawAddonProvider>} allAddonProviders */
(allAddonProviders) => {
const addonProvider = allAddonProviders.find((ap) => ap.id === productId);
if (addonProvider == null) {
throw new Error(`Unknown add-on provider ID: ${productId}`);
}
return addonProvider;
},
);
}
Loading

0 comments on commit 1104902

Please sign in to comment.