Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@
return response.json();
}

async _fetchGraphql(query) {
let params = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query })
};

let response = await this._fetch(this.endpoint, params);
if (response.errors) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember @cjelger saying something about the response.errors not being the best way to check for errors since a response can contain both errors and data.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored the code here into a separate function and didn't change existing functionality. I'll take care of that in a follow up PR :)

throw new Error(JSON.stringify(response.errors));
}

return response;
}

/**
* Retrieves the URL of the images for an array of product data
* @param productData a dictionary object with the following structure {productName:productSku}.
Expand Down Expand Up @@ -73,18 +90,7 @@
}`;
console.log(query);

let params = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query })
};

let response = await this._fetch(this.endpoint, params);
if (response.errors) {
throw new Error(JSON.stringify(response.errors));
}
let response = await this._fetchGraphql(query);
let items = response.data.products.items;

let productsMedia = {};
Expand All @@ -104,6 +110,63 @@
});
return productsMedia;
}

/**
* Retrieves the prices of the products with the given SKUs and their variants.
*
* @param {array} skus Array of product SKUs.
* @returns {Promise<any[]>} Returns a map of skus mapped to their prices. The price is an object containing the currency and value.
*/
async getProductPrices(skus) {
let skuQuery = '"' + skus.join('", "') + '"';

// prettier-ignore
const query = `query {
products(filter: { sku: { in: [${skuQuery}] }} ) {
items {
sku
price {
regularPrice {
amount {
currency
value
}
}
}
... on ConfigurableProduct {
variants {
product {
sku
price {
regularPrice {
amount {
currency
value
}
}
}
}
}
}
}
}
}`;
let response = await this._fetchGraphql(query);

// Transform response in a SKU to price map
let items = response.data.products.items;
let dict = {};
for (let item of items) {
dict[item.sku] = item.price.regularPrice.amount;

// Go through variants
if (!item.variants) continue;
for (let variant of item.variants) {
dict[variant.product.sku] = variant.product.price.regularPrice.amount;
}
}
return dict;
}
}

function onDocumentReady() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,52 @@ let productCtx = (function(document) {
// Local state
this._state = {
// Current sku, either from the base product or from a variant
sku: null,
sku: this._element.querySelector(Product.selectors.sku).innerHTML,

// True if this product is configurable and has variants
configurable: false
configurable: false,

// Map with client-side fetched prices
prices: {},

// Intl.NumberFormat instance for formatting prices
formatter: null
};
this._state.configurable = this._element.dataset.configurable !== undefined;
this._state.sku = !this._state.configurable
? this._element.querySelector(Product.selectors.sku).innerHTML
: null;

// Update product data
this._element.addEventListener(Product.events.variantChanged, this._onUpdateVariant.bind(this));

this._initPrices();
}

_initPrices() {
// Retrieve current prices
if (!window.CIF || !window.CIF.CommerceGraphqlApi) return;
return window.CIF.CommerceGraphqlApi.getProductPrices([this._state.sku])
.then(prices => {
this._state.prices = prices;

// Update price
if (!(this._state.sku in prices)) return;
this._element.querySelector(Product.selectors.price).innerText = this._formatPrice(
prices[this._state.sku]
);
})
.catch(err => {
console.error('Could not fetch prices', err);
});
}

_formatPrice(price) {
if (!this._state.formatter) {
this._state.formatter = new Intl.NumberFormat(this._element.dataset.locale, {
style: 'currency',
currency: price.currency
});
}

return this._state.formatter.format(price.value);
}

/**
Expand All @@ -50,8 +84,17 @@ let productCtx = (function(document) {
// Update values and enable add to cart button
this._element.querySelector(Product.selectors.sku).innerText = variant.sku;
this._element.querySelector(Product.selectors.name).innerText = variant.name;
this._element.querySelector(Product.selectors.price).innerText = variant.formattedPrice;
this._element.querySelector(Product.selectors.description).innerHTML = variant.description;

// Use client-side fetched price
if (this._state.sku in this._state.prices) {
this._element.querySelector(Product.selectors.price).innerText = this._formatPrice(
this._state.prices[this._state.sku]
);
} else {
// or server-side price as a backup
this._element.querySelector(Product.selectors.price).innerText = variant.formattedPrice;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
data-sly-use.priceTpl="price.html"
data-sly-use.actionsTpl="actions.html"
data-sly-use.quantityTpl="quantity.html"
data-sly-use.product="com.adobe.cq.commerce.core.components.models.product.Product">
data-sly-use.product="com.adobe.cq.commerce.core.components.models.product.Product"
data-sly-use.page="com.adobe.cq.wcm.core.components.models.Page">

<sly data-sly-call="${clientlib.all @ categories='core.cif.components.product.v1'}"/>
<form class="productFullDetail__root"
data-configurable="${product.configurable}"
data-cmp-is="product">
data-cmp-is="product"
data-locale="${page.language}">
<sly data-sly-test.found="${product.found}">
<section class="productFullDetail__title"><h1 class="productFullDetail__productName"><span role="name">${product.name}</span></h1>
<sly data-sly-call="${priceTpl.price @ product=product}"></sly>
Expand Down
63 changes: 59 additions & 4 deletions ui.apps/src/test/components/commerce/product/productTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('Product', () => {
<span role="name"></span>
</div>
<div class="productFullDetail__details">
<span role="sku"></span>
<span role="sku">sample-sku</span>
</div>
<div class="productFullDetail__productPrice">
<span role="price"></span>
Expand All @@ -42,17 +42,37 @@ describe('Product', () => {

let product = productCtx.factory({ element: productRoot });
assert.isTrue(product._state.configurable);
assert.isNull(product._state.sku);
assert.equal(product._state.sku, 'sample-sku');
});

it('initializes a simple product component', () => {
productRoot.querySelector(productCtx.Product.selectors.sku).innerHTML = 'sample-sku';

let product = productCtx.factory({ element: productRoot });
assert.isFalse(product._state.configurable);
assert.equal(product._state.sku, 'sample-sku');
});

it('retrieves prices via GraphQL', done => {
window.CIF = window.CIF || {};
window.CIF.CommerceGraphqlApi = window.CIF.CommerceGraphqlApi || {
getProductPrices: () => {}
};

let prices = { 'sample-sku': { currency: 'USD', value: '156.89' } };
let stub = sinon.stub(window.CIF.CommerceGraphqlApi, 'getProductPrices').resolves(prices);

let product = productCtx.factory({ element: productRoot });
product
._initPrices()
.then(() => {
assert.isTrue(stub.called);
assert.deepEqual(product._state.prices, prices);

let price = productRoot.querySelector(productCtx.Product.selectors.price).innerText;
assert.include(price, '156.89');
})
.finally(done);
});

it('changes variant when receiving variantchanged event', () => {
let product = productCtx.factory({ element: productRoot });

Expand Down Expand Up @@ -85,5 +105,40 @@ describe('Product', () => {
assert.equal(price, variant.formattedPrice);
assert.equal(description, variant.description);
});

it('changes variant with client-side price when receiving variantchanged event', () => {
let product = productCtx.factory({ element: productRoot });
product._state.prices = {
'variant-sku': {
currency: 'USD',
value: 130.42
}
};

// Send event
let variant = { sku: 'variant-sku' };
let changeEvent = new CustomEvent(productCtx.Product.events.variantChanged, {
bubbles: true,
detail: {
variant: variant
}
});
productRoot.dispatchEvent(changeEvent);

// Check fields
let price = productRoot.querySelector(productCtx.Product.selectors.price).innerText;
assert.include(price, '130.42');
});

it('formats a currency', () => {
productRoot.dataset.locale = 'de-DE';

let product = productCtx.factory({ element: productRoot });
assert.isNull(product._state.formatter);

let formattedPrice = product._formatPrice({ currency: 'EUR', value: 100.13 });
assert.isNotNull(product._state.formatter);
assert.equal(formattedPrice, '100,13 €');
});
});
});