Alokai
ReferenceSalesforce Commerce CloudNormalizers

Product normalizers

Product includes two normalizers:

  • normalizeProduct: This function is used to map SFCC Product into SfProduct, which includes a full product details
  • normalizeProductCatalogItem: This function is used to map SFCC Product into SfProductCatalogItem, which includes only basic product details, needed to display a product in a product catalog

Parameters

normalizeProduct

NameTypeDefault valueDescription
contextNormalizerContextContext needed for the normalizer. Context contain a currency field that contains a currency code
productProductSFCC Product

normalizeProductCatalogItem

NameTypeDefault valueDescription
contextNormalizerContextContext needed for the normalizer. Context contain a currency field that contains a currency code
productProductSearchHit or ProductSFCC ProductSearchHit or Product

Extending

The SfProduct model is returned from GetProductDetails method. The SfProductCatalogItem model is returned from GetProducts method. If any of these models don't contain the information you need for your Storefront, you can extend its logic using the addCustomFields API. In the following example we extend the normalizeProduct with manufacturerSku field which is available on SFCC Product and normalizeProductCatalogItem with productType field.

import { normalizers } from "@vsf-enterprise/unified-api-sfcc";

export const unifiedApiExtension = createUnifiedExtension({
  normalizers: {
    addCustomFields: [
      {
        normalizeProduct: (context, product) => {
          const { quantityLimit } = normalizers.normalizeProduct(context, product);
          
          return {
            manufacturerSku: product.manufacturerSku,
            isSmallQuantityLimit: quantityLimit && quantityLimit <= 10,
          }
        },
        normalizeProductCatalogItem: (context, product) => ({
          productType: product.productType,
        }),
      },
    ],
  },
  config: {
    ...
  },
});

Since both normalizers accept SFCC Product as a second argument, you may also use the same approach to use same representation for both SfProduct and SfProductCatalogItem models.

However, in this case you should be aware that SfProductCatalogItem will contain all the fields from SfProduct model and, it will affect the performance of the catalog page.

Source

The normalizeProduct and normalizeProductCatalogItem function consists of several smaller normalizers such as normalizeImage, normalizeDiscountablePrice, and more, which you can override as well.

import type { SfAttribute, SfImage } from "@alokai/connect";
import type { ImageGroup, Product } from "@internal";
import sanitizeHtml from "sanitize-html";

import { getImageGroupByViewType } from "@vsf-enterprise/unified-api-sfcc";

import { defineNormalizer } from "../defineNormalizer";
import type { NormalizerContext } from "../types";
import { normalizeProductCatalogItemFromProduct } from "./productCatalog";
import { normalizeProductVariant } from "./variant";

export const normalizeProduct = defineNormalizer.normalizeProduct((context, product) => {
const baseFields = normalizeProductCatalogItemFromProduct(context, product);
const unsanitizedDescription = product.longDescription ?? product.shortDescription;
const description = unsanitizedDescription ? sanitizeHtml(unsanitizedDescription) : null;

let attributes: SfAttribute[] = [];
// variationValues exists only for type.variant or type.item
if (product.variationValues && product.variationAttributes) {
  attributes = getAttributes(context, product);
}

return {
  ...baseFields,
  attributes,
  description,
  gallery: getGallery(context, product.imageGroups ?? []),
  variants:
    product.variants?.map((variant) =>
      normalizeProductVariant(context, {
        variant,
        variationAttributes: product.variationAttributes ?? [],
      }),
    ) ?? [],
};
});

function getGallery(context: NormalizerContext, images: ImageGroup[]): SfImage[] {
if (!images?.length) {
  return [];
}
const imageGroup = getImageGroupByViewType(images, "large") ?? images[0];
return imageGroup?.images.map((image) => context.normalizers.normalizeImage(image)) ?? [];
}

function getAttributes(context: NormalizerContext, product: Product): SfAttribute[] {
const { variationAttributes, variationValues } = product;
if (!(variationValues && variationAttributes)) {
  return [];
}
return Object.entries(variationValues)
  .map(([key, value]) => {
    const attribute = { key, value };
    return context.normalizers.normalizeAttribute({ attribute, variationAttributes });
  })
  .filter(Boolean);
}
import type { Maybe, SfProductCatalogItem } from "@alokai/connect";
import type { Inventory, Product, ProductSearchHit, Variant } from "@internal";
import sanitizeHtml from "sanitize-html";

import { maybe, slugify } from "@vsf-enterprise/unified-api-sfcc";
import { getProductPrimaryImage } from "@vsf-enterprise/unified-api-sfcc";
import type { NormalizerContext } from "@vsf-enterprise/unified-api-sfcc";

import { defineNormalizer } from "../defineNormalizer";

export const normalizeProductCatalogItem = defineNormalizer.normalizeProductCatalogItem(
(context, product) => {
  if (isProductSearchHit(product)) {
    return normalizeProductCatalogItemFromProductSearchHit(context, product);
  }
  return normalizeProductCatalogItemFromProduct(context, product);
},
);

function isProductSearchHit(product: Product | ProductSearchHit): product is ProductSearchHit {
return Reflect.has(product, "hitType") || Reflect.has(product, "productId");
}

function normalizeProductCatalogItemFromProductSearchHit(
context: NormalizerContext,
product: ProductSearchHit,
): SfProductCatalogItem {
const { normalizeDiscountablePrice, normalizeImage } = context.normalizers;

return {
  id: product.productId,
  name: product.productName ? sanitizeHtml(product.productName) : null,
  price: normalizeDiscountablePrice(product),
  primaryImage: product.image ? normalizeImage(product.image) : null,
  quantityLimit: getProductQuantityLimit(product),
  rating: null,
  sku: maybe(product.representedProduct?.id),
  slug: slugify(product.productId),
};
}

/*
* User can search by product id, both master or its variants. Response may slightly differ depending on response product type.
*
* The idea behind this method is:
* - if returned product is type.master, then get first variant and use its values for pricing or cart features (afaik master is not purchasable and returns aggregated prices values from its variants - e.g. min/max),
* - if returned product is type.variant of type.item, then product itself is purchasable and should contain fields that are related to selected variants, e.g. tieredPrices or variationValues.
* Reference: https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-products?meta=getProducts
*/
export function normalizeProductCatalogItemFromProduct(
context: NormalizerContext,
product: Product,
): SfProductCatalogItem {
const productImage = getProductPrimaryImage(product.imageGroups);
const currentVariant = getCurrentVariant(product);
const { normalizeDiscountablePrice, normalizeImage } = context.normalizers;

return {
  id: product.master?.masterId ?? product.id,
  name: maybe(product.name),
  price: normalizeDiscountablePrice(product),
  primaryImage: productImage ? normalizeImage(productImage) : null,
  quantityLimit: getProductQuantityLimit(product.inventory),
  rating: null,
  sku: currentVariant.productId ?? currentVariant.id,
  slug: slugify(product.id),
};
}

function getProductQuantityLimit<TOrderable extends Pick<Inventory, "ats" | "orderable">>(
orderableItem?: TOrderable,
): Maybe<number> {
if (!orderableItem) return null;

const { ats, orderable } = orderableItem;
if (ats != null) {
  return ats;
}
if (orderable === false) {
  return 0;
}
return null;
}

function isMasterProduct(product: Product): boolean {
return product.type?.master === true;
}

function getCurrentVariant(product: Product): Product | Variant {
if (isMasterProduct(product) && !!product.variants?.[0]) {
  return product.variants[0];
}
return product;
}
import type { Image } from "@internal";

import { maybe } from "@vsf-enterprise/unified-api-sfcc";

import { defineNormalizer } from "../defineNormalizer";

function isImageObject(image: unknown): image is Image {
return !!image && typeof image === "object" && "link" in image;
}

export const normalizeImage = defineNormalizer.normalizeImage((_context, image) => {
if (isImageObject(image)) {
  return {
    alt: maybe(image.alt),
    url: image.link,
  };
}

return {
  alt: null,
  url: image,
};
});
import type { SfAttribute, SfProductVariant } from "@alokai/connect";

import { slugify } from "@vsf-enterprise/unified-api-sfcc";

import type { NormalizeProductVariantInput, NormalizerContext } from "../types";

export function normalizeProductVariant(
context: NormalizerContext,
input: NormalizeProductVariantInput,
): SfProductVariant {
const { variant } = input;

return {
  attributes: variant.variationValues ? getAttributes(context, input) : [],
  id: variant.productId,
  name: null,
  quantityLimit: null,
  sku: variant.productId,
  slug: slugify(variant.productId),
};
}

function getAttributes(
context: NormalizerContext,
input: NormalizeProductVariantInput,
): SfAttribute[] {
const { variant, variationAttributes } = input;
const variationValues = variant.variationValues;

if (!variationValues) {
  return [];
}

return Object.entries(variationValues ?? [])
  .map(([key, value]) => {
    const attribute = { key, value };
    return context.normalizers.normalizeAttribute({ attribute, variationAttributes });
  })
  .filter(Boolean);
}
import type { Maybe, SfDiscountablePrice } from "@alokai/connect";
import type { ProductPriceTable } from "@internal";

import type { NormalizerContext } from "@vsf-enterprise/unified-api-sfcc";

import { defineNormalizer } from "../defineNormalizer";

export const normalizeDiscountablePrice = defineNormalizer.normalizeDiscountablePrice(
(context, input) => {
  const { price, pricebookQuantity, tieredPrices } = input;

  if (!price) {
    return null;
  }

  if (isTieredPrices(tieredPrices) && typeof price === "number") {
    return normalizeTieredPrices(context, {
      price,
      pricebookQuantity: pricebookQuantity ?? 1,
      tieredPrices,
    });
  }

  return normalizePrice(context, price);
},
);

function isTieredPrices(value: unknown): value is ProductPriceTable[] {
return (
  Array.isArray(value) && value.every((price) => isProductPriceTable(price)) && value.length > 1
);
}

function isProductPriceTable(value: unknown): value is ProductPriceTable {
return typeof value === "object" && value !== null && "price" in value && "pricebook" in value;
}

function normalizePrice(ctx: NormalizerContext, input: number) {
const regularPrice = ctx.normalizers.normalizeMoney(input);
const value = regularPrice;

return {
  isDiscounted: false,
  regularPrice,
  value,
};
}

function normalizeTieredPrices(
ctx: NormalizerContext,
data: {
  price: number;
  pricebookQuantity: number;
  tieredPrices: ProductPriceTable[];
},
): Maybe<SfDiscountablePrice> {
const { price, pricebookQuantity, tieredPrices } = data;
const { normalizeMoney } = ctx.normalizers;

const currentPriceList = tieredPrices.find(
  (priceTable) => priceTable.price === price && (priceTable?.quantity ?? 1) <= pricebookQuantity,
);

if (!currentPriceList?.price) {
  return null;
}

const regularPrice = tieredPrices.reduce((acc, priceTable) => {
  if (
    (priceTable.price ?? 0) > currentPriceList.price! &&
    (priceTable?.quantity ?? 1) <= pricebookQuantity
  ) {
    return priceTable;
  }

  return acc;
}, currentPriceList);

const promotionalPrice = tieredPrices.reduce((acc, priceTable) => {
  if (
    (priceTable.price ?? 0) < currentPriceList.price! &&
    (priceTable?.quantity ?? 1) <= pricebookQuantity
  ) {
    return priceTable;
  }

  return acc;
}, currentPriceList);

const normalizedRegularPrice = normalizeMoney(regularPrice.price!);
const isDiscounted = regularPrice.pricebook !== promotionalPrice.pricebook;

return {
  isDiscounted,
  regularPrice: normalizedRegularPrice,
  value: isDiscounted ? normalizeMoney(promotionalPrice.price!) : normalizedRegularPrice,
};
}

On this page