Skip to content

Commit

Permalink
feat: product links block (#53)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
maxakuru authored Dec 11, 2024
1 parent 6cb10ab commit 6544924
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 49 deletions.
79 changes: 70 additions & 9 deletions src/content/adobe-commerce.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
import { errorResponse, errorWithResponse, ffetch } from '../utils/http.js';
import getProductQuery, { adapter as productAdapter } from './queries/cs-product.js';
import getVariantsQuery, { adapter as variantsAdapter } from './queries/cs-variants.js';
import getProductSKUQuery from './queries/core-product-sku.js';
import getProductSKUQueryCore from './queries/core-product-sku.js';
import getProductSKUQueryCS from './queries/cs-product-sku.js';
import htmlTemplateFromContext from '../templates/html/index.js';

/**
Expand All @@ -22,7 +23,11 @@ import htmlTemplateFromContext from '../templates/html/index.js';
*/
async function fetchProduct(sku, config) {
const { catalogEndpoint = 'https://catalog-service.adobe.io/graphql' } = config;
const query = getProductQuery({ sku, imageRoles: config.imageRoles });
const query = getProductQuery({
sku,
imageRoles: config.imageRoles,
linkTypes: config.linkTypes,
});
console.debug(query);

const resp = await ffetch(`${catalogEndpoint}?query=${encodeURIComponent(query)}&view=${config.storeViewCode}`, {
Expand Down Expand Up @@ -111,8 +116,53 @@ async function fetchVariants(sku, config) {
* @param {string} urlkey
* @param {Config} config
*/
async function lookupProductSKU(urlkey, config) {
const query = getProductSKUQuery({ urlkey });
async function lookupProductSKUCS(urlkey, config) {
const { catalogEndpoint = 'https://catalog-service.adobe.io/graphql' } = config;
const query = getProductSKUQueryCS({ urlkey });
console.debug(query);

const resp = await ffetch(`${catalogEndpoint}?query=${encodeURIComponent(query)}`, {
headers: {
origin: config.origin ?? 'https://api.adobecommerce.live',
'x-api-key': config.apiKey,
'Magento-Environment-Id': config.magentoEnvironmentId,
'Magento-Website-Code': config.magentoWebsiteCode,
'Magento-Store-View-Code': config.storeViewCode,
'Magento-Store-Code': config.storeCode,
...config.headers,
},
// don't disable cache, since it's unlikely to change
});
if (!resp.ok) {
console.warn('failed to fetch product sku (cs): ', resp.status, resp.statusText);
try {
console.info('body: ', await resp.text());
} catch { /* noop */ }
throw errorWithResponse(resp.status, 'failed to fetch product sku (cs)');
}

try {
const json = await resp.json();
const [product] = json?.data?.productSearch.items ?? [];
if (!product?.product?.sku) {
throw errorWithResponse(404, 'could not find product sku (cs)', json.errors);
}
return product.product.sku;
} catch (e) {
console.error('failed to parse product sku (cs): ', e);
if (e.response) {
throw errorWithResponse(e.response.status, e.message);
}
throw errorWithResponse(500, 'failed to parse product sku response (cs)');
}
}

/**
* @param {string} urlkey
* @param {Config} config
*/
async function lookupProductSKUCore(urlkey, config) {
const query = getProductSKUQueryCore({ urlkey });
if (!config.coreEndpoint) {
throw errorResponse(400, 'missing coreEndpoint');
}
Expand All @@ -127,27 +177,38 @@ async function lookupProductSKU(urlkey, config) {
// don't disable cache, since it's unlikely to change
});
if (!resp.ok) {
console.warn('failed to fetch product sku: ', resp.status, resp.statusText);
console.warn('failed to fetch product sku (core): ', resp.status, resp.statusText);
try {
console.info('body: ', await resp.text());
} catch { /* noop */ }
throw errorWithResponse(resp.status, 'failed to fetch product sku');
throw errorWithResponse(resp.status, 'failed to fetch product sku (core)');
}

try {
const json = await resp.json();
const [product] = json?.data?.products?.items ?? [];
if (!product?.sku) {
throw errorWithResponse(404, 'could not find product sku', json.errors);
throw errorWithResponse(404, 'could not find product sku (core)', json.errors);
}
return product.sku;
} catch (e) {
console.error('failed to parse product sku: ', e);
console.error('failed to parse product sku (core): ', e);
if (e.response) {
throw errorWithResponse(e.response.status, e.message);
}
throw errorWithResponse(500, 'failed to parse product sku response');
throw errorWithResponse(500, 'failed to parse product sku response (core)');
}
}

/**
* @param {string} urlkey
* @param {Config} config
*/
function lookupProductSKU(urlkey, config) {
if (config.liveSearchEnabled) {
return lookupProductSKUCS(urlkey, config);
}
return lookupProductSKUCore(urlkey, config);
}

/**
Expand Down
35 changes: 35 additions & 0 deletions src/content/queries/cs-product-sku.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { gql } from '../../utils/product.js';

/**
* @param {{ urlkey: string; }} param0
*/
// @ts-ignore
export default ({ urlkey }) => gql`{
productSearch (
phrase:""
page_size: 1
filter: {
attribute: "url_key"
eq: "${urlkey}"
}
) {
items {
product {
sku
uid
}
}
}
}`;
103 changes: 95 additions & 8 deletions src/content/queries/cs-product.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,26 @@
import { forceImagesHTTPS } from '../../utils/http.js';
import { gql, parseRating, parseSpecialToDate } from '../../utils/product.js';

function extractMinMaxPrice(data) {
let minPrice = data.priceRange?.minimum ?? data.price;
let maxPrice = data.priceRange?.maximum ?? data.price;

if (minPrice == null) {
minPrice = maxPrice;
} else if (maxPrice == null) {
maxPrice = minPrice;
}
return { minPrice, maxPrice };
}

/**
* @param {Config} config
* @param {any} productData
* @returns {Product}
*/
export const adapter = (config, productData) => {
let minPrice = productData.priceRange?.minimum ?? productData.price;
let maxPrice = productData.priceRange?.maximum ?? productData.price;
const { minPrice, maxPrice } = extractMinMaxPrice(productData);

if (minPrice == null) {
minPrice = maxPrice;
} else if (maxPrice == null) {
maxPrice = minPrice;
}
/** @type {Product} */
const product = {
sku: productData.sku,
Expand All @@ -41,6 +47,28 @@ export const adapter = (config, productData) => {
addToCartAllowed: productData.addToCartAllowed,
inStock: productData.inStock,
externalId: productData.externalId,
links: (productData.links ?? []).map((l) => {
const { minPrice: lMinPrice, maxPrice: lMaxPrice } = extractMinMaxPrice(l.product);
return {
sku: l.product.sku,
urlKey: l.product.urlKey,
types: l.linkTypes,
prices: {
regular: {
amount: lMinPrice.regular.amount.value,
currency: lMinPrice.regular.amount.currency,
maximumAmount: lMaxPrice.regular.amount.value,
minimumAmount: lMinPrice.regular.amount.value,
},
final: {
amount: lMinPrice.final.amount.value,
currency: lMinPrice.final.amount.currency,
maximumAmount: lMaxPrice.final.amount.value,
minimumAmount: lMinPrice.final.amount.value,
},
},
};
}),
images: forceImagesHTTPS(productData.images) ?? [],
attributes: productData.attributes ?? [],
attributeMap: Object.fromEntries((productData.attributes ?? [])
Expand Down Expand Up @@ -115,9 +143,10 @@ export const adapter = (config, productData) => {
* @param {{
* sku: string;
* imageRoles?: string[];
* linkTypes?: string[];
* }} opts
*/
export default ({ sku, imageRoles = [] }) => gql`{
export default ({ sku, imageRoles = [], linkTypes = [] }) => gql`{
products(
skus: ["${sku}"]
) {
Expand All @@ -139,6 +168,64 @@ export default ({ sku, imageRoles = [] }) => gql`{
url
label
}
links(linkTypes: [${linkTypes.map((s) => `"${s}"`).join(',')}]) {
product {
sku
urlKey
... on SimpleProductView {
price {
final {
amount {
value
currency
}
}
regular {
amount {
value
currency
}
}
roles
}
}
... on ComplexProductView {
priceRange {
maximum {
final {
amount {
value
currency
}
}
regular {
amount {
value
currency
}
}
roles
}
minimum {
final {
amount {
value
currency
}
}
regular {
amount {
value
currency
}
}
roles
}
}
}
}
linkTypes
}
attributes(roles: ["visible_in_pdp"]) {
name
label
Expand Down
47 changes: 45 additions & 2 deletions src/templates/html/HTMLTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export class HTMLTemplate {
/** @type {Image} */
image = undefined;

/** @type {import('../json/JSONTemplate.js').JSONTemplate} */
jsonTemplate = undefined;

/**
* @param {Context} ctx
* @param {Product} product
Expand All @@ -68,6 +71,7 @@ export class HTMLTemplate {
this.ctx = ctx;
this.product = product;
this.variants = variants;
this.jsonTemplate = jsonTemplateFromContext(this.ctx, this.product, this.variants);
this.image = this.constructImage(findProductImage(product, variants));
}

Expand Down Expand Up @@ -146,10 +150,9 @@ ${HTMLTemplate.metaProperty('product:price.currency', product.prices?.final?.cur
* @returns {string}
*/
renderJSONLD() {
const jsonTemplate = jsonTemplateFromContext(this.ctx, this.product, this.variants);
return /* html */ `\
<script type="application/ld+json">
${jsonTemplate.render()}
${this.jsonTemplate.render()}
</script>`;
}

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

/**
* @param {Pick<Prices, 'regular' | 'final'>} prices
* @returns {string}
*/
renderLinkPrices(prices) {
return /* html */ `
<ul>
<li>Regular: ${prices.regular?.amount} ${prices.regular?.currency}${HTMLTemplate.priceRange(prices.regular?.minimumAmount, prices.regular?.maximumAmount)}</li>
<li>Final: ${prices.final?.amount} ${prices.final?.currency}${HTMLTemplate.priceRange(prices.final?.minimumAmount, prices.final?.maximumAmount)}</li>
</ul>
`;
}

/**
* Create the product variants
* @returns {string}
Expand Down Expand Up @@ -365,6 +381,32 @@ ${this.variants?.map((v) => /* html */`\
</div>`;
}

/**
* @returns {string}
*/
renderProductLinks() {
const { links } = this.product;
if (!links || !links.length) {
return '';
}

return /* html */ `\
<div class="product-links">
${links.map((link) => {
const url = this.jsonTemplate.constructProductURL(undefined, link);
return /* html */`\
<div>
<div>${link.sku}</div>
<div><a href="${url}">${url}</a></div>
<div>${(link.types ?? []).join(', ')}</div>
<div>
${HTMLTemplate.indent(this.renderLinkPrices(link.prices), 6)}
</div>
</div>`;
}).join('\n')
}`;
}

/**
* @returns {string}
*/
Expand Down Expand Up @@ -392,6 +434,7 @@ ${HTMLTemplate.indent(this.renderProductAttributes(attributes), 8)}
${HTMLTemplate.indent(this.renderProductOptions(options), 8)}
${HTMLTemplate.indent(this.renderProductVariants(), 8)}
${HTMLTemplate.indent(this.renderProductVariantsAttributes(), 8)}
${HTMLTemplate.indent(this.renderProductLinks(), 8)}
</div>
</main>
<footer></footer>
Expand Down
Loading

0 comments on commit 6544924

Please sign in to comment.