Skip to content

Commit 23248bf

Browse files
authored
CIF-743 - AEM core component - Product Detail with Price (CSR) (#62)
* CIF-743 - Add client-side price fetching API * CIF-743 - Add client-side price fetching to product component * CIF-743 - Add client-side price formatting * CIF-743 - Add page locale to product component * CIF-743 - Add unit tests
1 parent 8d75e4b commit 23248bf

File tree

4 files changed

+187
-24
lines changed

4 files changed

+187
-24
lines changed

ui.apps/src/main/content/jcr_root/apps/core/cif/clientlibs/common/js/CommerceGraphqlApi.js

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@
3737
return response.json();
3838
}
3939

40+
async _fetchGraphql(query) {
41+
let params = {
42+
method: 'POST',
43+
headers: {
44+
'Content-Type': 'application/json'
45+
},
46+
body: JSON.stringify({ query })
47+
};
48+
49+
let response = await this._fetch(this.endpoint, params);
50+
if (response.errors) {
51+
throw new Error(JSON.stringify(response.errors));
52+
}
53+
54+
return response;
55+
}
56+
4057
/**
4158
* Retrieves the URL of the images for an array of product data
4259
* @param productData a dictionary object with the following structure {productName:productSku}.
@@ -73,18 +90,7 @@
7390
}`;
7491
console.log(query);
7592

76-
let params = {
77-
method: 'POST',
78-
headers: {
79-
'Content-Type': 'application/json'
80-
},
81-
body: JSON.stringify({ query })
82-
};
83-
84-
let response = await this._fetch(this.endpoint, params);
85-
if (response.errors) {
86-
throw new Error(JSON.stringify(response.errors));
87-
}
93+
let response = await this._fetchGraphql(query);
8894
let items = response.data.products.items;
8995

9096
let productsMedia = {};
@@ -104,6 +110,63 @@
104110
});
105111
return productsMedia;
106112
}
113+
114+
/**
115+
* Retrieves the prices of the products with the given SKUs and their variants.
116+
*
117+
* @param {array} skus Array of product SKUs.
118+
* @returns {Promise<any[]>} Returns a map of skus mapped to their prices. The price is an object containing the currency and value.
119+
*/
120+
async getProductPrices(skus) {
121+
let skuQuery = '"' + skus.join('", "') + '"';
122+
123+
// prettier-ignore
124+
const query = `query {
125+
products(filter: { sku: { in: [${skuQuery}] }} ) {
126+
items {
127+
sku
128+
price {
129+
regularPrice {
130+
amount {
131+
currency
132+
value
133+
}
134+
}
135+
}
136+
... on ConfigurableProduct {
137+
variants {
138+
product {
139+
sku
140+
price {
141+
regularPrice {
142+
amount {
143+
currency
144+
value
145+
}
146+
}
147+
}
148+
}
149+
}
150+
}
151+
}
152+
}
153+
}`;
154+
let response = await this._fetchGraphql(query);
155+
156+
// Transform response in a SKU to price map
157+
let items = response.data.products.items;
158+
let dict = {};
159+
for (let item of items) {
160+
dict[item.sku] = item.price.regularPrice.amount;
161+
162+
// Go through variants
163+
if (!item.variants) continue;
164+
for (let variant of item.variants) {
165+
dict[variant.product.sku] = variant.product.price.regularPrice.amount;
166+
}
167+
}
168+
return dict;
169+
}
107170
}
108171

109172
function onDocumentReady() {

ui.apps/src/main/content/jcr_root/apps/core/cif/components/commerce/product/v1/product/clientlib/js/product.js

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,52 @@ let productCtx = (function(document) {
2222
// Local state
2323
this._state = {
2424
// Current sku, either from the base product or from a variant
25-
sku: null,
25+
sku: this._element.querySelector(Product.selectors.sku).innerHTML,
2626

2727
// True if this product is configurable and has variants
28-
configurable: false
28+
configurable: false,
29+
30+
// Map with client-side fetched prices
31+
prices: {},
32+
33+
// Intl.NumberFormat instance for formatting prices
34+
formatter: null
2935
};
3036
this._state.configurable = this._element.dataset.configurable !== undefined;
31-
this._state.sku = !this._state.configurable
32-
? this._element.querySelector(Product.selectors.sku).innerHTML
33-
: null;
3437

3538
// Update product data
3639
this._element.addEventListener(Product.events.variantChanged, this._onUpdateVariant.bind(this));
40+
41+
this._initPrices();
42+
}
43+
44+
_initPrices() {
45+
// Retrieve current prices
46+
if (!window.CIF || !window.CIF.CommerceGraphqlApi) return;
47+
return window.CIF.CommerceGraphqlApi.getProductPrices([this._state.sku])
48+
.then(prices => {
49+
this._state.prices = prices;
50+
51+
// Update price
52+
if (!(this._state.sku in prices)) return;
53+
this._element.querySelector(Product.selectors.price).innerText = this._formatPrice(
54+
prices[this._state.sku]
55+
);
56+
})
57+
.catch(err => {
58+
console.error('Could not fetch prices', err);
59+
});
60+
}
61+
62+
_formatPrice(price) {
63+
if (!this._state.formatter) {
64+
this._state.formatter = new Intl.NumberFormat(this._element.dataset.locale, {
65+
style: 'currency',
66+
currency: price.currency
67+
});
68+
}
69+
70+
return this._state.formatter.format(price.value);
3771
}
3872

3973
/**
@@ -50,8 +84,17 @@ let productCtx = (function(document) {
5084
// Update values and enable add to cart button
5185
this._element.querySelector(Product.selectors.sku).innerText = variant.sku;
5286
this._element.querySelector(Product.selectors.name).innerText = variant.name;
53-
this._element.querySelector(Product.selectors.price).innerText = variant.formattedPrice;
5487
this._element.querySelector(Product.selectors.description).innerHTML = variant.description;
88+
89+
// Use client-side fetched price
90+
if (this._state.sku in this._state.prices) {
91+
this._element.querySelector(Product.selectors.price).innerText = this._formatPrice(
92+
this._state.prices[this._state.sku]
93+
);
94+
} else {
95+
// or server-side price as a backup
96+
this._element.querySelector(Product.selectors.price).innerText = variant.formattedPrice;
97+
}
5598
}
5699
}
57100

ui.apps/src/main/content/jcr_root/apps/core/cif/components/commerce/product/v1/product/product.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
data-sly-use.priceTpl="price.html"
1818
data-sly-use.actionsTpl="actions.html"
1919
data-sly-use.quantityTpl="quantity.html"
20-
data-sly-use.product="com.adobe.cq.commerce.core.components.models.product.Product">
20+
data-sly-use.product="com.adobe.cq.commerce.core.components.models.product.Product"
21+
data-sly-use.page="com.adobe.cq.wcm.core.components.models.Page">
2122

2223
<sly data-sly-call="${clientlib.all @ categories='core.cif.components.product.v1'}"/>
2324
<form class="productFullDetail__root"
2425
data-configurable="${product.configurable}"
25-
data-cmp-is="product">
26+
data-cmp-is="product"
27+
data-locale="${page.language}">
2628
<sly data-sly-test.found="${product.found}">
2729
<section class="productFullDetail__title"><h1 class="productFullDetail__productName"><span role="name">${product.name}</span></h1>
2830
<sly data-sly-call="${priceTpl.price @ product=product}"></sly>

ui.apps/src/test/components/commerce/product/productTest.js

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('Product', () => {
2626
<span role="name"></span>
2727
</div>
2828
<div class="productFullDetail__details">
29-
<span role="sku"></span>
29+
<span role="sku">sample-sku</span>
3030
</div>
3131
<div class="productFullDetail__productPrice">
3232
<span role="price"></span>
@@ -42,17 +42,37 @@ describe('Product', () => {
4242

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

4848
it('initializes a simple product component', () => {
49-
productRoot.querySelector(productCtx.Product.selectors.sku).innerHTML = 'sample-sku';
50-
5149
let product = productCtx.factory({ element: productRoot });
5250
assert.isFalse(product._state.configurable);
5351
assert.equal(product._state.sku, 'sample-sku');
5452
});
5553

54+
it('retrieves prices via GraphQL', done => {
55+
window.CIF = window.CIF || {};
56+
window.CIF.CommerceGraphqlApi = window.CIF.CommerceGraphqlApi || {
57+
getProductPrices: () => {}
58+
};
59+
60+
let prices = { 'sample-sku': { currency: 'USD', value: '156.89' } };
61+
let stub = sinon.stub(window.CIF.CommerceGraphqlApi, 'getProductPrices').resolves(prices);
62+
63+
let product = productCtx.factory({ element: productRoot });
64+
product
65+
._initPrices()
66+
.then(() => {
67+
assert.isTrue(stub.called);
68+
assert.deepEqual(product._state.prices, prices);
69+
70+
let price = productRoot.querySelector(productCtx.Product.selectors.price).innerText;
71+
assert.include(price, '156.89');
72+
})
73+
.finally(done);
74+
});
75+
5676
it('changes variant when receiving variantchanged event', () => {
5777
let product = productCtx.factory({ element: productRoot });
5878

@@ -85,5 +105,40 @@ describe('Product', () => {
85105
assert.equal(price, variant.formattedPrice);
86106
assert.equal(description, variant.description);
87107
});
108+
109+
it('changes variant with client-side price when receiving variantchanged event', () => {
110+
let product = productCtx.factory({ element: productRoot });
111+
product._state.prices = {
112+
'variant-sku': {
113+
currency: 'USD',
114+
value: 130.42
115+
}
116+
};
117+
118+
// Send event
119+
let variant = { sku: 'variant-sku' };
120+
let changeEvent = new CustomEvent(productCtx.Product.events.variantChanged, {
121+
bubbles: true,
122+
detail: {
123+
variant: variant
124+
}
125+
});
126+
productRoot.dispatchEvent(changeEvent);
127+
128+
// Check fields
129+
let price = productRoot.querySelector(productCtx.Product.selectors.price).innerText;
130+
assert.include(price, '130.42');
131+
});
132+
133+
it('formats a currency', () => {
134+
productRoot.dataset.locale = 'de-DE';
135+
136+
let product = productCtx.factory({ element: productRoot });
137+
assert.isNull(product._state.formatter);
138+
139+
let formattedPrice = product._formatPrice({ currency: 'EUR', value: 100.13 });
140+
assert.isNotNull(product._state.formatter);
141+
assert.equal(formattedPrice, '100,13 €');
142+
});
88143
});
89144
});

0 commit comments

Comments
 (0)