Skip to content

Commit 6544924

Browse files
authored
feat: product links block (#53)
* feat: add links to product * feat: product-links block * fix: allow livesearch for sku lookup * cleanup * fix: skip links block for empty array * fix: correct link types * fix: query for link prices * chore: fix post deploy
1 parent 6cb10ab commit 6544924

File tree

8 files changed

+361
-49
lines changed

8 files changed

+361
-49
lines changed

src/content/adobe-commerce.js

+70-9
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import { errorResponse, errorWithResponse, ffetch } from '../utils/http.js';
1414
import getProductQuery, { adapter as productAdapter } from './queries/cs-product.js';
1515
import getVariantsQuery, { adapter as variantsAdapter } from './queries/cs-variants.js';
16-
import getProductSKUQuery from './queries/core-product-sku.js';
16+
import getProductSKUQueryCore from './queries/core-product-sku.js';
17+
import getProductSKUQueryCS from './queries/cs-product-sku.js';
1718
import htmlTemplateFromContext from '../templates/html/index.js';
1819

1920
/**
@@ -22,7 +23,11 @@ import htmlTemplateFromContext from '../templates/html/index.js';
2223
*/
2324
async function fetchProduct(sku, config) {
2425
const { catalogEndpoint = 'https://catalog-service.adobe.io/graphql' } = config;
25-
const query = getProductQuery({ sku, imageRoles: config.imageRoles });
26+
const query = getProductQuery({
27+
sku,
28+
imageRoles: config.imageRoles,
29+
linkTypes: config.linkTypes,
30+
});
2631
console.debug(query);
2732

2833
const resp = await ffetch(`${catalogEndpoint}?query=${encodeURIComponent(query)}&view=${config.storeViewCode}`, {
@@ -111,8 +116,53 @@ async function fetchVariants(sku, config) {
111116
* @param {string} urlkey
112117
* @param {Config} config
113118
*/
114-
async function lookupProductSKU(urlkey, config) {
115-
const query = getProductSKUQuery({ urlkey });
119+
async function lookupProductSKUCS(urlkey, config) {
120+
const { catalogEndpoint = 'https://catalog-service.adobe.io/graphql' } = config;
121+
const query = getProductSKUQueryCS({ urlkey });
122+
console.debug(query);
123+
124+
const resp = await ffetch(`${catalogEndpoint}?query=${encodeURIComponent(query)}`, {
125+
headers: {
126+
origin: config.origin ?? 'https://api.adobecommerce.live',
127+
'x-api-key': config.apiKey,
128+
'Magento-Environment-Id': config.magentoEnvironmentId,
129+
'Magento-Website-Code': config.magentoWebsiteCode,
130+
'Magento-Store-View-Code': config.storeViewCode,
131+
'Magento-Store-Code': config.storeCode,
132+
...config.headers,
133+
},
134+
// don't disable cache, since it's unlikely to change
135+
});
136+
if (!resp.ok) {
137+
console.warn('failed to fetch product sku (cs): ', resp.status, resp.statusText);
138+
try {
139+
console.info('body: ', await resp.text());
140+
} catch { /* noop */ }
141+
throw errorWithResponse(resp.status, 'failed to fetch product sku (cs)');
142+
}
143+
144+
try {
145+
const json = await resp.json();
146+
const [product] = json?.data?.productSearch.items ?? [];
147+
if (!product?.product?.sku) {
148+
throw errorWithResponse(404, 'could not find product sku (cs)', json.errors);
149+
}
150+
return product.product.sku;
151+
} catch (e) {
152+
console.error('failed to parse product sku (cs): ', e);
153+
if (e.response) {
154+
throw errorWithResponse(e.response.status, e.message);
155+
}
156+
throw errorWithResponse(500, 'failed to parse product sku response (cs)');
157+
}
158+
}
159+
160+
/**
161+
* @param {string} urlkey
162+
* @param {Config} config
163+
*/
164+
async function lookupProductSKUCore(urlkey, config) {
165+
const query = getProductSKUQueryCore({ urlkey });
116166
if (!config.coreEndpoint) {
117167
throw errorResponse(400, 'missing coreEndpoint');
118168
}
@@ -127,27 +177,38 @@ async function lookupProductSKU(urlkey, config) {
127177
// don't disable cache, since it's unlikely to change
128178
});
129179
if (!resp.ok) {
130-
console.warn('failed to fetch product sku: ', resp.status, resp.statusText);
180+
console.warn('failed to fetch product sku (core): ', resp.status, resp.statusText);
131181
try {
132182
console.info('body: ', await resp.text());
133183
} catch { /* noop */ }
134-
throw errorWithResponse(resp.status, 'failed to fetch product sku');
184+
throw errorWithResponse(resp.status, 'failed to fetch product sku (core)');
135185
}
136186

137187
try {
138188
const json = await resp.json();
139189
const [product] = json?.data?.products?.items ?? [];
140190
if (!product?.sku) {
141-
throw errorWithResponse(404, 'could not find product sku', json.errors);
191+
throw errorWithResponse(404, 'could not find product sku (core)', json.errors);
142192
}
143193
return product.sku;
144194
} catch (e) {
145-
console.error('failed to parse product sku: ', e);
195+
console.error('failed to parse product sku (core): ', e);
146196
if (e.response) {
147197
throw errorWithResponse(e.response.status, e.message);
148198
}
149-
throw errorWithResponse(500, 'failed to parse product sku response');
199+
throw errorWithResponse(500, 'failed to parse product sku response (core)');
200+
}
201+
}
202+
203+
/**
204+
* @param {string} urlkey
205+
* @param {Config} config
206+
*/
207+
function lookupProductSKU(urlkey, config) {
208+
if (config.liveSearchEnabled) {
209+
return lookupProductSKUCS(urlkey, config);
150210
}
211+
return lookupProductSKUCore(urlkey, config);
151212
}
152213

153214
/**

src/content/queries/cs-product-sku.js

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import { gql } from '../../utils/product.js';
14+
15+
/**
16+
* @param {{ urlkey: string; }} param0
17+
*/
18+
// @ts-ignore
19+
export default ({ urlkey }) => gql`{
20+
productSearch (
21+
phrase:""
22+
page_size: 1
23+
filter: {
24+
attribute: "url_key"
25+
eq: "${urlkey}"
26+
}
27+
) {
28+
items {
29+
product {
30+
sku
31+
uid
32+
}
33+
}
34+
}
35+
}`;

src/content/queries/cs-product.js

+95-8
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,26 @@
1313
import { forceImagesHTTPS } from '../../utils/http.js';
1414
import { gql, parseRating, parseSpecialToDate } from '../../utils/product.js';
1515

16+
function extractMinMaxPrice(data) {
17+
let minPrice = data.priceRange?.minimum ?? data.price;
18+
let maxPrice = data.priceRange?.maximum ?? data.price;
19+
20+
if (minPrice == null) {
21+
minPrice = maxPrice;
22+
} else if (maxPrice == null) {
23+
maxPrice = minPrice;
24+
}
25+
return { minPrice, maxPrice };
26+
}
27+
1628
/**
1729
* @param {Config} config
1830
* @param {any} productData
1931
* @returns {Product}
2032
*/
2133
export const adapter = (config, productData) => {
22-
let minPrice = productData.priceRange?.minimum ?? productData.price;
23-
let maxPrice = productData.priceRange?.maximum ?? productData.price;
34+
const { minPrice, maxPrice } = extractMinMaxPrice(productData);
2435

25-
if (minPrice == null) {
26-
minPrice = maxPrice;
27-
} else if (maxPrice == null) {
28-
maxPrice = minPrice;
29-
}
3036
/** @type {Product} */
3137
const product = {
3238
sku: productData.sku,
@@ -41,6 +47,28 @@ export const adapter = (config, productData) => {
4147
addToCartAllowed: productData.addToCartAllowed,
4248
inStock: productData.inStock,
4349
externalId: productData.externalId,
50+
links: (productData.links ?? []).map((l) => {
51+
const { minPrice: lMinPrice, maxPrice: lMaxPrice } = extractMinMaxPrice(l.product);
52+
return {
53+
sku: l.product.sku,
54+
urlKey: l.product.urlKey,
55+
types: l.linkTypes,
56+
prices: {
57+
regular: {
58+
amount: lMinPrice.regular.amount.value,
59+
currency: lMinPrice.regular.amount.currency,
60+
maximumAmount: lMaxPrice.regular.amount.value,
61+
minimumAmount: lMinPrice.regular.amount.value,
62+
},
63+
final: {
64+
amount: lMinPrice.final.amount.value,
65+
currency: lMinPrice.final.amount.currency,
66+
maximumAmount: lMaxPrice.final.amount.value,
67+
minimumAmount: lMinPrice.final.amount.value,
68+
},
69+
},
70+
};
71+
}),
4472
images: forceImagesHTTPS(productData.images) ?? [],
4573
attributes: productData.attributes ?? [],
4674
attributeMap: Object.fromEntries((productData.attributes ?? [])
@@ -115,9 +143,10 @@ export const adapter = (config, productData) => {
115143
* @param {{
116144
* sku: string;
117145
* imageRoles?: string[];
146+
* linkTypes?: string[];
118147
* }} opts
119148
*/
120-
export default ({ sku, imageRoles = [] }) => gql`{
149+
export default ({ sku, imageRoles = [], linkTypes = [] }) => gql`{
121150
products(
122151
skus: ["${sku}"]
123152
) {
@@ -139,6 +168,64 @@ export default ({ sku, imageRoles = [] }) => gql`{
139168
url
140169
label
141170
}
171+
links(linkTypes: [${linkTypes.map((s) => `"${s}"`).join(',')}]) {
172+
product {
173+
sku
174+
urlKey
175+
... on SimpleProductView {
176+
price {
177+
final {
178+
amount {
179+
value
180+
currency
181+
}
182+
}
183+
regular {
184+
amount {
185+
value
186+
currency
187+
}
188+
}
189+
roles
190+
}
191+
}
192+
... on ComplexProductView {
193+
priceRange {
194+
maximum {
195+
final {
196+
amount {
197+
value
198+
currency
199+
}
200+
}
201+
regular {
202+
amount {
203+
value
204+
currency
205+
}
206+
}
207+
roles
208+
}
209+
minimum {
210+
final {
211+
amount {
212+
value
213+
currency
214+
}
215+
}
216+
regular {
217+
amount {
218+
value
219+
currency
220+
}
221+
}
222+
roles
223+
}
224+
}
225+
}
226+
}
227+
linkTypes
228+
}
142229
attributes(roles: ["visible_in_pdp"]) {
143230
name
144231
label

src/templates/html/HTMLTemplate.js

+45-2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export class HTMLTemplate {
5959
/** @type {Image} */
6060
image = undefined;
6161

62+
/** @type {import('../json/JSONTemplate.js').JSONTemplate} */
63+
jsonTemplate = undefined;
64+
6265
/**
6366
* @param {Context} ctx
6467
* @param {Product} product
@@ -68,6 +71,7 @@ export class HTMLTemplate {
6871
this.ctx = ctx;
6972
this.product = product;
7073
this.variants = variants;
74+
this.jsonTemplate = jsonTemplateFromContext(this.ctx, this.product, this.variants);
7175
this.image = this.constructImage(findProductImage(product, variants));
7276
}
7377

@@ -146,10 +150,9 @@ ${HTMLTemplate.metaProperty('product:price.currency', product.prices?.final?.cur
146150
* @returns {string}
147151
*/
148152
renderJSONLD() {
149-
const jsonTemplate = jsonTemplateFromContext(this.ctx, this.product, this.variants);
150153
return /* html */ `\
151154
<script type="application/ld+json">
152-
${jsonTemplate.render()}
155+
${this.jsonTemplate.render()}
153156
</script>`;
154157
}
155158

@@ -310,6 +313,19 @@ ${HTMLTemplate.indent(this.renderProductItems(opt.items), 2)}`).join('\n')}
310313
<div>Final: ${prices.final?.amount} ${prices.final?.currency}${HTMLTemplate.priceRange(prices.final?.minimumAmount, prices.final?.maximumAmount)}</div>`;
311314
}
312315

316+
/**
317+
* @param {Pick<Prices, 'regular' | 'final'>} prices
318+
* @returns {string}
319+
*/
320+
renderLinkPrices(prices) {
321+
return /* html */ `
322+
<ul>
323+
<li>Regular: ${prices.regular?.amount} ${prices.regular?.currency}${HTMLTemplate.priceRange(prices.regular?.minimumAmount, prices.regular?.maximumAmount)}</li>
324+
<li>Final: ${prices.final?.amount} ${prices.final?.currency}${HTMLTemplate.priceRange(prices.final?.minimumAmount, prices.final?.maximumAmount)}</li>
325+
</ul>
326+
`;
327+
}
328+
313329
/**
314330
* Create the product variants
315331
* @returns {string}
@@ -365,6 +381,32 @@ ${this.variants?.map((v) => /* html */`\
365381
</div>`;
366382
}
367383

384+
/**
385+
* @returns {string}
386+
*/
387+
renderProductLinks() {
388+
const { links } = this.product;
389+
if (!links || !links.length) {
390+
return '';
391+
}
392+
393+
return /* html */ `\
394+
<div class="product-links">
395+
${links.map((link) => {
396+
const url = this.jsonTemplate.constructProductURL(undefined, link);
397+
return /* html */`\
398+
<div>
399+
<div>${link.sku}</div>
400+
<div><a href="${url}">${url}</a></div>
401+
<div>${(link.types ?? []).join(', ')}</div>
402+
<div>
403+
${HTMLTemplate.indent(this.renderLinkPrices(link.prices), 6)}
404+
</div>
405+
</div>`;
406+
}).join('\n')
407+
}`;
408+
}
409+
368410
/**
369411
* @returns {string}
370412
*/
@@ -392,6 +434,7 @@ ${HTMLTemplate.indent(this.renderProductAttributes(attributes), 8)}
392434
${HTMLTemplate.indent(this.renderProductOptions(options), 8)}
393435
${HTMLTemplate.indent(this.renderProductVariants(), 8)}
394436
${HTMLTemplate.indent(this.renderProductVariantsAttributes(), 8)}
437+
${HTMLTemplate.indent(this.renderProductLinks(), 8)}
395438
</div>
396439
</main>
397440
<footer></footer>

0 commit comments

Comments
 (0)