From a260cb1a2e79e4ffd01835e33c0347097f75447b Mon Sep 17 00:00:00 2001 From: Lucis Date: Mon, 8 May 2023 21:34:49 -0300 Subject: [PATCH 1/2] Passing facet for price using toggle --- commerce/vtex/transform.ts | 556 ++++++++++++++++++++++++++++++++++ packs/vtex/types.ts | 4 + packs/vtex/utils/transform.ts | 79 ++++- 3 files changed, 626 insertions(+), 13 deletions(-) create mode 100644 commerce/vtex/transform.ts diff --git a/commerce/vtex/transform.ts b/commerce/vtex/transform.ts new file mode 100644 index 00000000..be699128 --- /dev/null +++ b/commerce/vtex/transform.ts @@ -0,0 +1,556 @@ +import type { + BreadcrumbList, + Filter, + Navbar, + Offer, + Product, + ProductDetailsPage, + PropertyValue, + UnitPriceSpecification, +} from "./../types.ts"; + +import { slugify } from "./utils/slugify.ts"; +import type { + Category, + Facet as FacetVTEX, + FacetValueBoolean, + FacetValueRange, + Item as SkuVTEX, + LegacyFacet, + LegacyItem as LegacySkuVTEX, + LegacyProduct as LegacyProductVTEX, + Product as ProductVTEX, + Seller as SellerVTEX, +} from "./types.ts"; + +const isLegacySku = ( + sku: LegacySkuVTEX | SkuVTEX, +): sku is LegacySkuVTEX => + typeof (sku as LegacySkuVTEX).variations?.[0] === "string"; + +const isLegacyProduct = ( + product: ProductVTEX | LegacyProductVTEX, +): product is LegacyProductVTEX => product.origin !== "intelligent-search"; + +const getCanonicalURL = (url: URL, { linkText }: { linkText: string }) => + new URL(`/${linkText}/p`, url.origin); + +const getProductURL = ( + url: URL, + product: { linkText: string }, + skuId?: string, +) => { + const canonicalUrl = getCanonicalURL(url, product); + + if (skuId) { + canonicalUrl.searchParams.set("skuId", skuId); + } + + return canonicalUrl; +}; + +const nonEmptyArray = (array: T[] | null | undefined) => + Array.isArray(array) && array.length > 0 ? array : null; + +const DEFAULT_IMAGE = { + imageText: "image", + imageUrl: + "https://storecomponents.vtexassets.com/assets/faststore/images/image___117a6d3e229a96ad0e0d0876352566e2.svg", +}; + +interface ProductOptions { + url: URL; + /** Price coded currency, e.g.: USD, BRL */ + priceCurrency: string; +} + +export const pickSku = ( + product: T, + maybeSkuId: string | undefined, +): T["items"][number] => { + const skuId = maybeSkuId ?? product.items[0]?.itemId; + + for (const item of product.items) { + if (item.itemId === skuId) { + return item; + } + } + + throw new Error(`Missing sku ${skuId} on product ${product.productName}`); +}; + +const toAccessoryOrSparePartFor = ( + sku: T["items"][number], + kitItems: T[], + options: ProductOptions, +) => { + const productBySkuId = kitItems.reduce((map, product) => { + product.items.forEach((item) => map.set(item.itemId, product)); + + return map; + }, new Map()); + + return sku.kitItems?.map(({ itemId }) => { + const product = productBySkuId.get(itemId); + + if (!product) { + throw new Error( + `Expected product for skuId ${itemId} but it was not returned by the search engine`, + ); + } + + const sku = pickSku(product, itemId); + + return toProduct(product, sku, 0, options); + }); +}; + +export const toProductPage = ( + product: T, + sku: T["items"][number], + kitItems: T[], + options: ProductOptions, +): ProductDetailsPage => { + const partialProduct = toProduct(product, sku, 0, options); + // Get accessories in ProductPage only. I don't see where it's necessary outside this page for now + const isAccessoryOrSparePartFor = toAccessoryOrSparePartFor( + sku, + kitItems, + options, + ); + + return { + "@type": "ProductDetailsPage", + breadcrumbList: toBreadcrumbList(product, options), + product: { ...partialProduct, isAccessoryOrSparePartFor }, + }; +}; + +export const inStock = (offer: Offer) => + offer.availability === "https://schema.org/InStock"; + +// Smallest Available Spot Price First +export const bestOfferFirst = (a: Offer, b: Offer) => { + if (inStock(a) && !inStock(b)) { + return -1; + } + + if (!inStock(a) && inStock(b)) { + return 1; + } + + return a.price - b.price; +}; + +const getHighPriceIndex = (offers: Offer[]) => { + let it = offers.length - 1; + for (; it > 0 && !inStock(offers[it]); it--); + return it; +}; + +const splitCategory = (firstCategory: string) => + firstCategory.split("/").filter(Boolean); + +const toAdditionalPropertyCategories = < + P extends LegacyProductVTEX | ProductVTEX, +>(product: P): Product["additionalProperty"] => { + const categories = splitCategory(product.categories[0]); + const categoryIds = splitCategory(product.categoriesIds[0]); + + return [ + ...categories.map((category, index) => ({ + "@type": "PropertyValue" as const, + name: "category", + propertyID: categoryIds[index], + value: category, + })), + ]; +}; + +const toAdditionalPropertyClusters = < + P extends LegacyProductVTEX | ProductVTEX, +>(product: P): Product["additionalProperty"] => { + const mapEntriesToIdName = ([id, name]: [string, unknown]) => ({ + id, + name: name as string, + }); + + const allClusters = isLegacyProduct(product) + ? Object.entries(product.productClusters).map(mapEntriesToIdName) + : product.productClusters; + + const highlightsSet = isLegacyProduct(product) + ? new Set(Object.keys(product.clusterHighlights)) + : new Set(product.clusterHighlights.map(({ id }) => id)); + + return allClusters.map(({ id, name }) => ({ + "@type": "PropertyValue" as const, + name: "cluster", + value: name || "", + propertyID: id, + description: highlightsSet.has(id) ? "highlight" : undefined, + })); +}; + +export const toProduct =

( + product: P, + sku: P["items"][number], + level = 0, // prevent inifinte loop while self referencing the product + options: ProductOptions, +): Product => { + const { url, priceCurrency } = options; + const { + brand, + productId, + productReference, + description, + releaseDate, + items, + } = product; + const { name, ean, itemId: skuId } = sku; + + const groupAdditionalProperty = isLegacyProduct(product) + ? legacyToProductGroupAdditionalProperties(product) + : toProductGroupAdditionalProperties(product); + const specificationsAdditionalProperty = isLegacySku(sku) + ? toAdditionalPropertiesLegacy(sku) + : toAdditionalProperties(sku); + const images = nonEmptyArray(sku.images) ?? [DEFAULT_IMAGE]; + const offers = sku.sellers.map(toOffer).sort(bestOfferFirst); + const hasVariant = level < 1 && + items.map((sku) => toProduct(product, sku, 1, options)); + const highPriceIndex = getHighPriceIndex(offers); + const lowPriceIndex = 0; + + // From schema.org: A category for the item. Greater signs or slashes can be used to informally indicate a category hierarchy + const categoriesString = splitCategory(product.categories[0]).join(">"); + + const categoryAdditionalProperties = toAdditionalPropertyCategories(product); + const clusterAdditionalProperties = toAdditionalPropertyClusters(product); + + const additionalProperty = specificationsAdditionalProperty.concat( + categoryAdditionalProperties ?? [], + ).concat(clusterAdditionalProperties ?? []); + + return { + "@type": "Product", + category: categoriesString, + productID: skuId, + url: getProductURL(url, product, sku.itemId).href, + name, + description, + brand, + sku: skuId, + gtin: ean, + releaseDate, + additionalProperty, + isVariantOf: { + "@type": "ProductGroup", + productGroupID: productId, + hasVariant: hasVariant || [], + url: getProductURL(url, product, sku.itemId).href, + name: product.productName, + additionalProperty: groupAdditionalProperty, + model: productReference, + }, + image: images.map(({ imageUrl, imageText }) => ({ + "@type": "ImageObject" as const, + alternateName: imageText ?? "", + url: imageUrl, + })), + offers: offers.length > 0 + ? { + "@type": "AggregateOffer", + priceCurrency, + highPrice: offers[highPriceIndex]?.price ?? null, + lowPrice: offers[lowPriceIndex]?.price ?? null, + offerCount: offers.length, + offers, + } + : undefined, + }; +}; + +const toBreadcrumbList = ( + product: ProductVTEX | LegacyProductVTEX, + { url }: ProductOptions, +): BreadcrumbList => { + const { categories, productName } = product; + + return { + "@type": "BreadcrumbList", + itemListElement: [ + ...categories.reverse().map((categoryPath, index) => { + const splitted = categoryPath.split("/").filter(Boolean); + const name = splitted[splitted.length - 1]; + const item = splitted.map(slugify).join("/"); + + return { + "@type": "ListItem" as const, + name, + item: new URL(`/${item}`, url.origin).href, + position: index + 1, + }; + }), + { + "@type": "ListItem", + name: productName, + item: getCanonicalURL(url, product).href, + position: categories.length + 1, + }, + ], + numberOfItems: categories.length + 1, + }; +}; + +const legacyToProductGroupAdditionalProperties = ( + product: LegacyProductVTEX, +) => + product.allSpecifications?.flatMap((name) => { + const values = (product as unknown as Record)[name]; + + return values.map((value) => + ({ + "@type": "PropertyValue", + name, + value, + valueReference: "SPECIFICATION", + }) as const + ); + }) ?? []; + +const toProductGroupAdditionalProperties = ({ properties = [] }: ProductVTEX) => + properties.flatMap(({ name, values }) => + values.map((value) => + ({ + "@type": "PropertyValue", + name, + value, + valueReference: "PROPERTY" as string, + }) as const + ) + ); + +const toAdditionalProperties = ( + sku: SkuVTEX, +): PropertyValue[] => + sku.variations?.flatMap(({ name, values }) => + values.map((value) => + ({ + "@type": "PropertyValue", + name, + value, + valueReference: "SPECIFICATION", + }) as const + ) + ) ?? []; + +const toAdditionalPropertiesLegacy = (sku: LegacySkuVTEX): PropertyValue[] => { + const { variations = [], attachments = [] } = sku; + + const specificationProperties = variations.flatMap((variation) => + sku[variation].map((value) => + ({ + "@type": "PropertyValue", + name: variation, + value, + valueReference: "SPECIFICATION", + }) as const + ) + ); + + const attachmentProperties = attachments.map((attachment) => + ({ + "@type": "PropertyValue", + propertyID: `${attachment.id}`, + name: attachment.name, + value: attachment.domainValues, + required: attachment.required, + valueReference: "ATTACHMENT", + }) as const + ); + + return [...specificationProperties, ...attachmentProperties]; +}; + +const toOffer = ({ + commertialOffer: offer, + sellerId, +}: SellerVTEX): Offer => ({ + "@type": "Offer", + price: offer.spotPrice ?? offer.Price, + seller: sellerId, + priceValidUntil: offer.PriceValidUntil, + inventoryLevel: { value: offer.AvailableQuantity }, + priceSpecification: [ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: offer.ListPrice, + }, + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: offer.Price, + }, + ...offer.Installments.map((installment): UnitPriceSpecification => ({ + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + priceComponentType: "https://schema.org/Installment", + name: installment.PaymentSystemName, + description: installment.Name, + billingDuration: installment.NumberOfInstallments, + billingIncrement: installment.Value, + price: installment.TotalValuePlusInterestRate, + })), + ], + availability: offer.AvailableQuantity > 0 + ? "https://schema.org/InStock" + : "https://schema.org/OutOfStock", +}); + +const unselect = (facet: LegacyFacet, url: URL, map: string) => { + const mapSegments = map.split(","); + + // Do not allow removing root facet to avoid going back to home page + if (mapSegments.length === 1) { + return `${url.pathname}${url.search}`; + } + + const index = mapSegments.findIndex((segment) => segment === facet.Map); + mapSegments.splice(index, index > -1 ? 1 : 0); + const newUrl = new URL( + url.pathname.replace(`/${facet.Value}`, ""), + url.origin, + ); + newUrl.search = url.search; + if (mapSegments.length > 0) { + newUrl.searchParams.set("map", mapSegments.join(",")); + } + + return `${newUrl.pathname}${newUrl.search}`; +}; + +export const legacyFacetToFilter = ( + name: string, + facets: LegacyFacet[], + url: URL, + map: string, +): Filter | null => { + const mapSegments = new Set(map.split(",")); + const pathSegments = new Set( + url.pathname.split("/").slice(0, mapSegments.size + 1), + ); + + return { + "@type": "FilterToggle", + quantity: facets.length, + label: name, + key: name, + values: facets.map((facet) => { + const selected = mapSegments.has(facet.Map) && + pathSegments.has(facet.Value); + const href = selected ? unselect(facet, url, map) : facet.LinkEncoded; + + return ({ + value: facet.Value, + quantity: facet.Quantity, + url: href, + label: facet.Name, + selected, + }); + }), + }; +}; + +export const filtersToSearchParams = ( + selectedFacets: { key: string; value: string }[], +) => { + const searchParams = new URLSearchParams(); + + for (const { key, value } of selectedFacets) { + searchParams.append(`filter.${key}`, value); + } + + return searchParams; +}; + +export const filtersFromSearchParams = (params: URLSearchParams) => { + const selectedFacets: { key: string; value: string }[] = []; + + params.forEach((value, name) => { + const [filter, key] = name.split("."); + + if (filter === "filter" && typeof key === "string") { + selectedFacets.push({ key, value }); + } + }); + + return selectedFacets; +}; + +export const toFilter = ( + facet: FacetVTEX, + selectedFacets: { key: string; value: string }[], +): Filter | null => { + if (facet.hidden) { + return null; + } + + if (facet.type === "PRICERANGE") { + console.log(facet); + return { + "@type": "FilterRange", + label: facet.name, + key: facet.key, + values: { + min: (facet.values as FacetValueRange[]).reduce( + (acc, curr) => acc > curr.range.from ? curr.range.from : acc, + Infinity, + ), + max: (facet.values as FacetValueRange[]).reduce( + (acc, curr) => acc < curr.range.to ? curr.range.to : acc, + 0, + ), + }, + }; + } + + return { + "@type": "FilterToggle", + key: facet.key, + label: facet.name, + quantity: facet.quantity, + values: (facet.values as FacetValueBoolean[]).map(( + { quantity, name, value, selected }, + ) => { + const newFacet = { key: facet.key, value }; + const filters = selected + ? selectedFacets.filter((facet) => + facet.key !== newFacet.key && facet.value !== newFacet.value + ) + : [...selectedFacets, newFacet]; + + return { + value, + quantity, + selected, + url: `?${filtersToSearchParams(filters).toString()}`, + label: name, + }; + }), + }; +}; + +function nodeToNavbar(node: Category): Navbar { + const url = new URL(node.url, "https://example.com"); + + return { + href: `${url.pathname}${url.search}`, + label: node.name, + children: node.children.map(nodeToNavbar), + }; +} + +export const categoryTreeToNavbar = (tree: Category[]): Navbar[] => + tree.map(nodeToNavbar); diff --git a/packs/vtex/types.ts b/packs/vtex/types.ts index 6664cd8e..fdfe5193 100644 --- a/packs/vtex/types.ts +++ b/packs/vtex/types.ts @@ -853,6 +853,10 @@ export interface FacetValueBoolean { } export interface FacetValueRange { + quantity: number; + name: string; + key: string; + selected: boolean; range: { from: number; to: number; diff --git a/packs/vtex/utils/transform.ts b/packs/vtex/utils/transform.ts index 46f56a2a..82b7847a 100644 --- a/packs/vtex/utils/transform.ts +++ b/packs/vtex/utils/transform.ts @@ -497,21 +497,74 @@ export const toFilter = ( return null; } - if (facet.type === "PRICERANGE") { + /** + * Example of facet for price + */ + // { + // values: [ + // { + // quantity: 27, + // name: "", + // key: "price", + // selected: false, + // range: { from: 330, to: 350 } + // }, + // { + // quantity: 24, + // name: "", + // key: "price", + // selected: false, + // range: { from: 299, to: 330 } + // }, + // { + // quantity: 8, + // name: "", + // key: "price", + // selected: false, + // range: { from: 350, to: 389 } + // } + // ], + // type: "PRICERANGE", + // name: "Preço", + // hidden: false, + // key: "price", + // quantity: 3 + // } + + if (facet.type === "PRICERANGE" || false) { + console.log(facet); + /** + * If the store wants to display a range UI, this should be changed + * to return `"@type": "FilterRange"`. + */ + return { - "@type": "FilterRange", - label: facet.name, + "@type": "FilterToggle", key: facet.key, - values: { - min: (facet.values as FacetValueRange[]).reduce( - (acc, curr) => acc > curr.range.from ? curr.range.from : acc, - Infinity, - ), - max: (facet.values as FacetValueRange[]).reduce( - (acc, curr) => acc < curr.range.to ? curr.range.to : acc, - 0, - ), - }, + label: facet.name, + quantity: facet.quantity, + values: (facet.values as FacetValueRange[]).map(( + { quantity, selected, range }, + ) => { + // TODO: Figure out how to to send new facet + const filters: { key: string; value: string }[] = []; + // const newFacet = { key: facet.key, }; + // const filters = selected + // ? selectedFacets.filter((facet) => + // facet.key !== newFacet.key && facet.value !== newFacet.value + // ) + // : [...selectedFacets, newFacet]; + + return { + quantity, + selected, + range, + url: `?${filtersToSearchParams(filters).toString()}`, + // TODO: Figure out how to not pass this + label: "", + value: "", + }; + }), }; } From cac15adcda0c4fa490a90ed5d6d3f2d3834015d8 Mon Sep 17 00:00:00 2001 From: Lucis Date: Mon, 8 May 2023 21:35:53 -0300 Subject: [PATCH 2/2] Removing file from nowhere --- commerce/vtex/transform.ts | 556 ------------------------------------- 1 file changed, 556 deletions(-) delete mode 100644 commerce/vtex/transform.ts diff --git a/commerce/vtex/transform.ts b/commerce/vtex/transform.ts deleted file mode 100644 index be699128..00000000 --- a/commerce/vtex/transform.ts +++ /dev/null @@ -1,556 +0,0 @@ -import type { - BreadcrumbList, - Filter, - Navbar, - Offer, - Product, - ProductDetailsPage, - PropertyValue, - UnitPriceSpecification, -} from "./../types.ts"; - -import { slugify } from "./utils/slugify.ts"; -import type { - Category, - Facet as FacetVTEX, - FacetValueBoolean, - FacetValueRange, - Item as SkuVTEX, - LegacyFacet, - LegacyItem as LegacySkuVTEX, - LegacyProduct as LegacyProductVTEX, - Product as ProductVTEX, - Seller as SellerVTEX, -} from "./types.ts"; - -const isLegacySku = ( - sku: LegacySkuVTEX | SkuVTEX, -): sku is LegacySkuVTEX => - typeof (sku as LegacySkuVTEX).variations?.[0] === "string"; - -const isLegacyProduct = ( - product: ProductVTEX | LegacyProductVTEX, -): product is LegacyProductVTEX => product.origin !== "intelligent-search"; - -const getCanonicalURL = (url: URL, { linkText }: { linkText: string }) => - new URL(`/${linkText}/p`, url.origin); - -const getProductURL = ( - url: URL, - product: { linkText: string }, - skuId?: string, -) => { - const canonicalUrl = getCanonicalURL(url, product); - - if (skuId) { - canonicalUrl.searchParams.set("skuId", skuId); - } - - return canonicalUrl; -}; - -const nonEmptyArray = (array: T[] | null | undefined) => - Array.isArray(array) && array.length > 0 ? array : null; - -const DEFAULT_IMAGE = { - imageText: "image", - imageUrl: - "https://storecomponents.vtexassets.com/assets/faststore/images/image___117a6d3e229a96ad0e0d0876352566e2.svg", -}; - -interface ProductOptions { - url: URL; - /** Price coded currency, e.g.: USD, BRL */ - priceCurrency: string; -} - -export const pickSku = ( - product: T, - maybeSkuId: string | undefined, -): T["items"][number] => { - const skuId = maybeSkuId ?? product.items[0]?.itemId; - - for (const item of product.items) { - if (item.itemId === skuId) { - return item; - } - } - - throw new Error(`Missing sku ${skuId} on product ${product.productName}`); -}; - -const toAccessoryOrSparePartFor = ( - sku: T["items"][number], - kitItems: T[], - options: ProductOptions, -) => { - const productBySkuId = kitItems.reduce((map, product) => { - product.items.forEach((item) => map.set(item.itemId, product)); - - return map; - }, new Map()); - - return sku.kitItems?.map(({ itemId }) => { - const product = productBySkuId.get(itemId); - - if (!product) { - throw new Error( - `Expected product for skuId ${itemId} but it was not returned by the search engine`, - ); - } - - const sku = pickSku(product, itemId); - - return toProduct(product, sku, 0, options); - }); -}; - -export const toProductPage = ( - product: T, - sku: T["items"][number], - kitItems: T[], - options: ProductOptions, -): ProductDetailsPage => { - const partialProduct = toProduct(product, sku, 0, options); - // Get accessories in ProductPage only. I don't see where it's necessary outside this page for now - const isAccessoryOrSparePartFor = toAccessoryOrSparePartFor( - sku, - kitItems, - options, - ); - - return { - "@type": "ProductDetailsPage", - breadcrumbList: toBreadcrumbList(product, options), - product: { ...partialProduct, isAccessoryOrSparePartFor }, - }; -}; - -export const inStock = (offer: Offer) => - offer.availability === "https://schema.org/InStock"; - -// Smallest Available Spot Price First -export const bestOfferFirst = (a: Offer, b: Offer) => { - if (inStock(a) && !inStock(b)) { - return -1; - } - - if (!inStock(a) && inStock(b)) { - return 1; - } - - return a.price - b.price; -}; - -const getHighPriceIndex = (offers: Offer[]) => { - let it = offers.length - 1; - for (; it > 0 && !inStock(offers[it]); it--); - return it; -}; - -const splitCategory = (firstCategory: string) => - firstCategory.split("/").filter(Boolean); - -const toAdditionalPropertyCategories = < - P extends LegacyProductVTEX | ProductVTEX, ->(product: P): Product["additionalProperty"] => { - const categories = splitCategory(product.categories[0]); - const categoryIds = splitCategory(product.categoriesIds[0]); - - return [ - ...categories.map((category, index) => ({ - "@type": "PropertyValue" as const, - name: "category", - propertyID: categoryIds[index], - value: category, - })), - ]; -}; - -const toAdditionalPropertyClusters = < - P extends LegacyProductVTEX | ProductVTEX, ->(product: P): Product["additionalProperty"] => { - const mapEntriesToIdName = ([id, name]: [string, unknown]) => ({ - id, - name: name as string, - }); - - const allClusters = isLegacyProduct(product) - ? Object.entries(product.productClusters).map(mapEntriesToIdName) - : product.productClusters; - - const highlightsSet = isLegacyProduct(product) - ? new Set(Object.keys(product.clusterHighlights)) - : new Set(product.clusterHighlights.map(({ id }) => id)); - - return allClusters.map(({ id, name }) => ({ - "@type": "PropertyValue" as const, - name: "cluster", - value: name || "", - propertyID: id, - description: highlightsSet.has(id) ? "highlight" : undefined, - })); -}; - -export const toProduct =

( - product: P, - sku: P["items"][number], - level = 0, // prevent inifinte loop while self referencing the product - options: ProductOptions, -): Product => { - const { url, priceCurrency } = options; - const { - brand, - productId, - productReference, - description, - releaseDate, - items, - } = product; - const { name, ean, itemId: skuId } = sku; - - const groupAdditionalProperty = isLegacyProduct(product) - ? legacyToProductGroupAdditionalProperties(product) - : toProductGroupAdditionalProperties(product); - const specificationsAdditionalProperty = isLegacySku(sku) - ? toAdditionalPropertiesLegacy(sku) - : toAdditionalProperties(sku); - const images = nonEmptyArray(sku.images) ?? [DEFAULT_IMAGE]; - const offers = sku.sellers.map(toOffer).sort(bestOfferFirst); - const hasVariant = level < 1 && - items.map((sku) => toProduct(product, sku, 1, options)); - const highPriceIndex = getHighPriceIndex(offers); - const lowPriceIndex = 0; - - // From schema.org: A category for the item. Greater signs or slashes can be used to informally indicate a category hierarchy - const categoriesString = splitCategory(product.categories[0]).join(">"); - - const categoryAdditionalProperties = toAdditionalPropertyCategories(product); - const clusterAdditionalProperties = toAdditionalPropertyClusters(product); - - const additionalProperty = specificationsAdditionalProperty.concat( - categoryAdditionalProperties ?? [], - ).concat(clusterAdditionalProperties ?? []); - - return { - "@type": "Product", - category: categoriesString, - productID: skuId, - url: getProductURL(url, product, sku.itemId).href, - name, - description, - brand, - sku: skuId, - gtin: ean, - releaseDate, - additionalProperty, - isVariantOf: { - "@type": "ProductGroup", - productGroupID: productId, - hasVariant: hasVariant || [], - url: getProductURL(url, product, sku.itemId).href, - name: product.productName, - additionalProperty: groupAdditionalProperty, - model: productReference, - }, - image: images.map(({ imageUrl, imageText }) => ({ - "@type": "ImageObject" as const, - alternateName: imageText ?? "", - url: imageUrl, - })), - offers: offers.length > 0 - ? { - "@type": "AggregateOffer", - priceCurrency, - highPrice: offers[highPriceIndex]?.price ?? null, - lowPrice: offers[lowPriceIndex]?.price ?? null, - offerCount: offers.length, - offers, - } - : undefined, - }; -}; - -const toBreadcrumbList = ( - product: ProductVTEX | LegacyProductVTEX, - { url }: ProductOptions, -): BreadcrumbList => { - const { categories, productName } = product; - - return { - "@type": "BreadcrumbList", - itemListElement: [ - ...categories.reverse().map((categoryPath, index) => { - const splitted = categoryPath.split("/").filter(Boolean); - const name = splitted[splitted.length - 1]; - const item = splitted.map(slugify).join("/"); - - return { - "@type": "ListItem" as const, - name, - item: new URL(`/${item}`, url.origin).href, - position: index + 1, - }; - }), - { - "@type": "ListItem", - name: productName, - item: getCanonicalURL(url, product).href, - position: categories.length + 1, - }, - ], - numberOfItems: categories.length + 1, - }; -}; - -const legacyToProductGroupAdditionalProperties = ( - product: LegacyProductVTEX, -) => - product.allSpecifications?.flatMap((name) => { - const values = (product as unknown as Record)[name]; - - return values.map((value) => - ({ - "@type": "PropertyValue", - name, - value, - valueReference: "SPECIFICATION", - }) as const - ); - }) ?? []; - -const toProductGroupAdditionalProperties = ({ properties = [] }: ProductVTEX) => - properties.flatMap(({ name, values }) => - values.map((value) => - ({ - "@type": "PropertyValue", - name, - value, - valueReference: "PROPERTY" as string, - }) as const - ) - ); - -const toAdditionalProperties = ( - sku: SkuVTEX, -): PropertyValue[] => - sku.variations?.flatMap(({ name, values }) => - values.map((value) => - ({ - "@type": "PropertyValue", - name, - value, - valueReference: "SPECIFICATION", - }) as const - ) - ) ?? []; - -const toAdditionalPropertiesLegacy = (sku: LegacySkuVTEX): PropertyValue[] => { - const { variations = [], attachments = [] } = sku; - - const specificationProperties = variations.flatMap((variation) => - sku[variation].map((value) => - ({ - "@type": "PropertyValue", - name: variation, - value, - valueReference: "SPECIFICATION", - }) as const - ) - ); - - const attachmentProperties = attachments.map((attachment) => - ({ - "@type": "PropertyValue", - propertyID: `${attachment.id}`, - name: attachment.name, - value: attachment.domainValues, - required: attachment.required, - valueReference: "ATTACHMENT", - }) as const - ); - - return [...specificationProperties, ...attachmentProperties]; -}; - -const toOffer = ({ - commertialOffer: offer, - sellerId, -}: SellerVTEX): Offer => ({ - "@type": "Offer", - price: offer.spotPrice ?? offer.Price, - seller: sellerId, - priceValidUntil: offer.PriceValidUntil, - inventoryLevel: { value: offer.AvailableQuantity }, - priceSpecification: [ - { - "@type": "UnitPriceSpecification", - priceType: "https://schema.org/ListPrice", - price: offer.ListPrice, - }, - { - "@type": "UnitPriceSpecification", - priceType: "https://schema.org/SalePrice", - price: offer.Price, - }, - ...offer.Installments.map((installment): UnitPriceSpecification => ({ - "@type": "UnitPriceSpecification", - priceType: "https://schema.org/SalePrice", - priceComponentType: "https://schema.org/Installment", - name: installment.PaymentSystemName, - description: installment.Name, - billingDuration: installment.NumberOfInstallments, - billingIncrement: installment.Value, - price: installment.TotalValuePlusInterestRate, - })), - ], - availability: offer.AvailableQuantity > 0 - ? "https://schema.org/InStock" - : "https://schema.org/OutOfStock", -}); - -const unselect = (facet: LegacyFacet, url: URL, map: string) => { - const mapSegments = map.split(","); - - // Do not allow removing root facet to avoid going back to home page - if (mapSegments.length === 1) { - return `${url.pathname}${url.search}`; - } - - const index = mapSegments.findIndex((segment) => segment === facet.Map); - mapSegments.splice(index, index > -1 ? 1 : 0); - const newUrl = new URL( - url.pathname.replace(`/${facet.Value}`, ""), - url.origin, - ); - newUrl.search = url.search; - if (mapSegments.length > 0) { - newUrl.searchParams.set("map", mapSegments.join(",")); - } - - return `${newUrl.pathname}${newUrl.search}`; -}; - -export const legacyFacetToFilter = ( - name: string, - facets: LegacyFacet[], - url: URL, - map: string, -): Filter | null => { - const mapSegments = new Set(map.split(",")); - const pathSegments = new Set( - url.pathname.split("/").slice(0, mapSegments.size + 1), - ); - - return { - "@type": "FilterToggle", - quantity: facets.length, - label: name, - key: name, - values: facets.map((facet) => { - const selected = mapSegments.has(facet.Map) && - pathSegments.has(facet.Value); - const href = selected ? unselect(facet, url, map) : facet.LinkEncoded; - - return ({ - value: facet.Value, - quantity: facet.Quantity, - url: href, - label: facet.Name, - selected, - }); - }), - }; -}; - -export const filtersToSearchParams = ( - selectedFacets: { key: string; value: string }[], -) => { - const searchParams = new URLSearchParams(); - - for (const { key, value } of selectedFacets) { - searchParams.append(`filter.${key}`, value); - } - - return searchParams; -}; - -export const filtersFromSearchParams = (params: URLSearchParams) => { - const selectedFacets: { key: string; value: string }[] = []; - - params.forEach((value, name) => { - const [filter, key] = name.split("."); - - if (filter === "filter" && typeof key === "string") { - selectedFacets.push({ key, value }); - } - }); - - return selectedFacets; -}; - -export const toFilter = ( - facet: FacetVTEX, - selectedFacets: { key: string; value: string }[], -): Filter | null => { - if (facet.hidden) { - return null; - } - - if (facet.type === "PRICERANGE") { - console.log(facet); - return { - "@type": "FilterRange", - label: facet.name, - key: facet.key, - values: { - min: (facet.values as FacetValueRange[]).reduce( - (acc, curr) => acc > curr.range.from ? curr.range.from : acc, - Infinity, - ), - max: (facet.values as FacetValueRange[]).reduce( - (acc, curr) => acc < curr.range.to ? curr.range.to : acc, - 0, - ), - }, - }; - } - - return { - "@type": "FilterToggle", - key: facet.key, - label: facet.name, - quantity: facet.quantity, - values: (facet.values as FacetValueBoolean[]).map(( - { quantity, name, value, selected }, - ) => { - const newFacet = { key: facet.key, value }; - const filters = selected - ? selectedFacets.filter((facet) => - facet.key !== newFacet.key && facet.value !== newFacet.value - ) - : [...selectedFacets, newFacet]; - - return { - value, - quantity, - selected, - url: `?${filtersToSearchParams(filters).toString()}`, - label: name, - }; - }), - }; -}; - -function nodeToNavbar(node: Category): Navbar { - const url = new URL(node.url, "https://example.com"); - - return { - href: `${url.pathname}${url.search}`, - label: node.name, - children: node.children.map(nodeToNavbar), - }; -} - -export const categoryTreeToNavbar = (tree: Category[]): Navbar[] => - tree.map(nodeToNavbar);