Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fix offer urls and add variant overrides #35

Merged
merged 18 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions src/content/adobe-commerce.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ async function fetchVariants(sku, config) {
try {
const json = await resp.json();
const { variants } = json?.data?.variants ?? {};
return variantsAdapter(variants);
return variantsAdapter(config, variants);
} catch (e) {
console.error('failed to parse variants: ', e);
throw errorWithResponse(500, 'failed to parse variants response');
Expand Down Expand Up @@ -173,7 +173,7 @@ export async function handle(ctx, config) {
fetchProduct(sku.toUpperCase(), config),
fetchVariants(sku.toUpperCase(), config),
]);
const html = HTML_TEMPLATE(product, variants);
const html = HTML_TEMPLATE(config, product, variants);
return new Response(html, {
status: 200,
headers: {
Expand Down
22 changes: 21 additions & 1 deletion src/content/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

import { matchConfigPath } from '../utils/product.js';
import { errorResponse } from '../utils/http.js';
import { handle as handleAdobeCommerce } from './adobe-commerce.js';
import { handle as handleHelixCommerce } from './helix-commerce.js';
Expand All @@ -30,9 +31,28 @@ export default async function contentHandler(ctx, config) {
return errorResponse(400, 'invalid config for tenant site (missing pageType)');
}

const { pathname } = ctx.url;
const productPath = pathname.includes('/content/product')
? pathname.split('/content/product')[1]
: null;

if (productPath) {
const matchedPath = matchConfigPath(config, productPath);
const matchedPathConfig = config.confMap && config.confMap[matchedPath]
? config.confMap[matchedPath]
: null;
if (matchedPathConfig) {
config.matchedPath = matchedPath;
config.matchedPathConfig = matchedPathConfig;
} else {
return errorResponse(400, 'No matching configuration for product path');
}
} else {
return errorResponse(400, 'invalid product path');
}

if (config.catalogSource === 'helix') {
return handleHelixCommerce(ctx, config);
}

return handleAdobeCommerce(ctx, config);
}
9 changes: 8 additions & 1 deletion src/content/queries/cs-variants.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { gql } from '../../utils/product.js';
* @param {any} variants
* @returns {Variant[]}
*/
export const adapter = (variants) => variants.map(({ selections, product }) => {
export const adapter = (config, variants) => variants.map(({ selections, product }) => {
const minPrice = product.priceRange?.minimum ?? product.price;
const maxPrice = product.priceRange?.maximum ?? product.price;

Expand Down Expand Up @@ -47,6 +47,13 @@ export const adapter = (variants) => variants.map(({ selections, product }) => {
},
selections: selections ?? [],
};

if (config.attributeOverrides?.variant) {
Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => {
variant[key] = product.attributes?.find((attr) => attr.name === value)?.value;
});
}

return variant;
});

Expand Down
13 changes: 8 additions & 5 deletions src/templates/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : '');
* @returns {string}
*/
export const renderDocumentMetaTags = (product) => /* html */ `
<meta charset="UTF-8">
<title>${product.metaTitle || product.name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
${metaProperty('description', product.metaDescription)}
Expand Down Expand Up @@ -111,20 +112,22 @@ export const renderHelixDependencies = () => /* html */ `
* @param {Variant[]} variants
* @returns {string}
*/
export const renderJSONLD = (product, variants) => /* html */ `
export const renderJSONLD = (config, product, variants) => /* html */ `
<script type="application/ld+json">
${JSON_LD_TEMPLATE(product, variants)}
${JSON_LD_TEMPLATE(config, product, variants)}
</script>
`;

/**
* Create the head tags
* @param {Config} config
* @param {Product} product
* @param {Variant[]} variants
* @param {Object} [options]
* @returns {string}
*/
export const renderHead = (
config,
product,
variants,
{
Expand All @@ -145,7 +148,7 @@ export const renderHead = (
${twitterMetaTags(product, image)}
${commerceMetaTags(product)}
${helixDependencies()}
${JSONLD(product, variants)}
${JSONLD(config, product, variants)}
</head>
`;
};
Expand Down Expand Up @@ -302,7 +305,7 @@ const renderProductVariantsAttributes = (variants) => /* html */ `
* @param {Variant[]} variants
* @returns {string}
*/
export default (product, variants) => {
export default (config, product, variants) => {
const {
name,
description,
Expand All @@ -314,7 +317,7 @@ export default (product, variants) => {
return /* html */`
<!DOCTYPE html>
<html>
${renderHead(product, variants)}
${renderHead(config, product, variants)}
<body>
<header></header>
<main>
Expand Down
37 changes: 23 additions & 14 deletions src/templates/json-ld.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,16 @@
* governing permissions and limitations under the License.
*/

import { findProductImage, pruneUndefined } from '../utils/product.js';
import { constructProductUrl, findProductImage, pruneUndefined } from '../utils/product.js';

/**
* @param {Product} product
* @param {Variant[]} variants
* @returns {string}
*/
export default (product, variants) => {
export default (config, product, variants) => {
const {
sku,
url,
name,
metaDescription,
images,
Expand All @@ -31,12 +30,13 @@ export default (product, variants) => {
prices,
} = product;

const productUrl = constructProductUrl(config, product);
const image = images?.[0]?.url ?? findProductImage(product, variants)?.url;
const brandName = attributes?.find((attr) => attr.name === 'brand')?.value;
return JSON.stringify(pruneUndefined({
'@context': 'http://schema.org',
'@type': 'Product',
'@id': url,
'@id': productUrl,
name,
sku,
description: metaDescription,
Expand All @@ -46,22 +46,31 @@ export default (product, variants) => {
prices ? ({
'@type': 'Offer',
sku,
url,
url: productUrl,
image,
availability: inStock ? 'InStock' : 'OutOfStock',
price: prices?.final?.amount,
priceCurrency: prices?.final?.currency,
}) : undefined,
...variants.map((v) => ({
'@type': 'Offer',
sku: v.sku,
url: v.url,
image: v.images?.[0]?.url ?? image,
availability: v.inStock ? 'InStock' : 'OutOfStock',
price: v.prices?.final?.amount,
priceCurrency: v.prices?.final?.currency,
...variants.map((v) => {
const offerUrl = constructProductUrl(config, product, v);

const offer = {
'@type': 'Offer',
sku: v.sku,
url: offerUrl,
image: v.images?.[0]?.url ?? image,
availability: v.inStock ? 'InStock' : 'OutOfStock',
price: v.prices?.final?.amount,
priceCurrency: v.prices?.final?.currency,
};

if (v.gtin) {
offer.gtin = v.gtin;
}

})).filter(Boolean),
return offer;
}).filter(Boolean),
],
...(brandName
? {
Expand Down
12 changes: 12 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ declare global {
* { pathPattern => Config }
*/
export type ConfigMap = Record<string, Config>;

export interface AttributeOverrides {
variant: {
[key: string]: string;
};
}

/**
* Resolved config object
Expand All @@ -27,6 +33,11 @@ declare global {
confMap: ConfigMap;
params: Record<string, string>;
headers: Record<string, string>;
host: string;
offerPattern: string;
dylandepass marked this conversation as resolved.
Show resolved Hide resolved
matchedPath: string;
matchedPathConfig: Config;
attributeOverrides: AttributeOverrides;
}

export interface Env {
Expand Down Expand Up @@ -86,6 +97,7 @@ declare global {
prices: Pick<Prices, 'regular' | 'final'>;
selections: string[];
attributes: Attribute[];
gtin?: string;
}

interface Image {
Expand Down
57 changes: 57 additions & 0 deletions src/utils/product.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,60 @@ export function assertValidProduct(product) {
throw new Error('Invalid product');
}
}

/**
* @param {Config} config
* @param {string} path
* @returns {string} matched path key
*/
export function matchConfigPath(config, path) {
// Filter out any keys that are not paths
const pathEntries = Object.entries(config.confMap).filter(([key]) => key !== 'base');

for (const [key] of pathEntries) {
// Replace `{{urlkey}}` and `{{sku}}` with regex patterns
const pattern = key
.replace('{{urlkey}}', '([^/]+)')
dylandepass marked this conversation as resolved.
Show resolved Hide resolved
.replace('{{sku}}', '([^/]+)');

// Convert to regex and test against the path
const regex = new RegExp(`^${pattern}$`);
const match = path.match(regex);

if (match) {
return key;
}
}
console.warn('No match found for path:', path);
return null;
}

/**
* Constructs product url
* @param {Config} config
* @param {Product} product
* @param {Variant} [variant]
* @returns {string}
*/
export function constructProductUrl(config, product, variant) {
const { host, matchedPath } = config;
const productPath = matchedPath
.replace('{{urlkey}}', product.urlKey)
.replace('{{sku}}', encodeURIComponent(product.sku));

const productUrl = `${host}${productPath}`;

if (variant) {
const offerPattern = config.matchedPathConfig?.offerPattern;
if (!offerPattern) {
return `${productUrl}/?optionsUIDs=${variant.selections.join(encodeURIComponent('=,'))}`;
dylandepass marked this conversation as resolved.
Show resolved Hide resolved
}

const variantPath = offerPattern
.replace('{{urlkey}}', product.urlKey)
.replace('{{sku}}', encodeURIComponent(variant.sku));
return `${config.host}${variantPath}`;
}

return productUrl;
}
2 changes: 1 addition & 1 deletion test/catalog/handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import { strict as assert } from 'assert';
import assert from 'node:assert';
import sinon from 'sinon';
import esmock from 'esmock';

Expand Down
2 changes: 1 addition & 1 deletion test/catalog/lookup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import { strict as assert } from 'assert';
import assert from 'node:assert';
import sinon from 'sinon';
import esmock from 'esmock';
import { ResponseError } from '../../src/utils/http.js';
Expand Down
2 changes: 1 addition & 1 deletion test/catalog/update.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

// @ts-nocheck

import { strict as assert } from 'assert';
import assert from 'node:assert';
import sinon from 'sinon';
import esmock from 'esmock';

Expand Down
4 changes: 2 additions & 2 deletions test/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

import assert from 'node:assert';
import { resolveConfig } from '../src/config.js';
import { TEST_CONTEXT } from './utils/context.js';
import { defaultTenantConfigs } from './utils/kv.js';
import { TEST_CONTEXT } from './fixtures/context.js';
import { defaultTenantConfigs } from './fixtures/kv.js';
dylandepass marked this conversation as resolved.
Show resolved Hide resolved

describe('config tests', () => {
it('should extract path params', async () => {
Expand Down
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions test/fixtures/variant.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function createProductVariationFixture(overrides = {}) {
{ name: 'criteria_5', label: 'Criteria 5', value: 'Canopy: 4.5" Round' },
{ name: 'criteria_6', label: 'Criteria 6', value: 'Socket: E26 Keyless' },
{ name: 'criteria_7', label: 'Criteria 7', value: 'Wattage: 40 T10' },
{ name: 'barcode', label: 'Barcode', value: '123456789012' },
{ name: 'weight', label: 'Weight', value: 219 },
],
prices: {
Expand Down
Loading