From aece07338e6e67ba9d05140bdd1e8c2f4a24aefc Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Thu, 3 Aug 2023 10:27:14 +0200 Subject: [PATCH] Product Feed: Add attribute mapping (#838) * Add attribute mapping * Improve release note * Log the error * Add pattern attribute --- .changeset/nine-dryers-wink.md | 5 + .../AttributeWithMappingFragment.graphql | 5 + .../GoogleFeedProductVariantFragment.graphql | 18 ++ .../FetchAttributesWithMapping.graphql | 13 + .../app-configuration/app-config.test.ts | 58 +++- .../modules/app-configuration/app-config.ts | 27 ++ .../app-configuration.router.ts | 36 +++ .../app-configuration/attribute-fetcher.ts | 49 ++++ .../attribute-mapping-form.tsx | 137 +++++++++ .../google-feed/attribute-mapping.test.ts | 268 ++++++++++++++++++ .../modules/google-feed/attribute-mapping.ts | 69 +++++ .../google-feed/generate-google-xml-feed.ts | 14 + .../get-google-feed-settings.test.ts | 2 + .../google-feed/get-google-feed-settings.ts | 1 + .../modules/google-feed/product-to-proxy.ts | 51 ++++ .../src/modules/google-feed/types.ts | 5 + .../api/feed/[url]/[channel]/google.xml.ts | 3 + .../products-feed/src/pages/configuration.tsx | 44 +++ 18 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 .changeset/nine-dryers-wink.md create mode 100644 apps/products-feed/graphql/fragments/AttributeWithMappingFragment.graphql create mode 100644 apps/products-feed/graphql/queries/FetchAttributesWithMapping.graphql create mode 100644 apps/products-feed/src/modules/app-configuration/attribute-fetcher.ts create mode 100644 apps/products-feed/src/modules/app-configuration/attribute-mapping-form.tsx create mode 100644 apps/products-feed/src/modules/google-feed/attribute-mapping.test.ts create mode 100644 apps/products-feed/src/modules/google-feed/attribute-mapping.ts diff --git a/.changeset/nine-dryers-wink.md b/.changeset/nine-dryers-wink.md new file mode 100644 index 0000000..f43a69f --- /dev/null +++ b/.changeset/nine-dryers-wink.md @@ -0,0 +1,5 @@ +--- +"saleor-app-products-feed": minor +--- + +Added configuration for choosing which product attributes should be used for generating Google Product Feed. Supported feed attributes: Brand, Color, Size, Material, Pattern. diff --git a/apps/products-feed/graphql/fragments/AttributeWithMappingFragment.graphql b/apps/products-feed/graphql/fragments/AttributeWithMappingFragment.graphql new file mode 100644 index 0000000..6fbef00 --- /dev/null +++ b/apps/products-feed/graphql/fragments/AttributeWithMappingFragment.graphql @@ -0,0 +1,5 @@ +fragment AttributeWithMappingFragment on Attribute { + id + name + slug +} diff --git a/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql b/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql index f00cc4e..e6107a1 100644 --- a/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql +++ b/apps/products-feed/graphql/fragments/GoogleFeedProductVariantFragment.graphql @@ -11,12 +11,30 @@ fragment GoogleFeedProductVariant on ProductVariant { } } quantityAvailable + attributes { + attribute { + id + } + values { + value + name + } + } product { id name slug description seoDescription + attributes{ + attribute{ + id + } + values{ + value + name + } + } thumbnail { url } diff --git a/apps/products-feed/graphql/queries/FetchAttributesWithMapping.graphql b/apps/products-feed/graphql/queries/FetchAttributesWithMapping.graphql new file mode 100644 index 0000000..d02d666 --- /dev/null +++ b/apps/products-feed/graphql/queries/FetchAttributesWithMapping.graphql @@ -0,0 +1,13 @@ +query FetchAttributesWithMapping($cursor: String){ + attributes(first: 100, after: $cursor){ + pageInfo{ + hasNextPage + endCursor + } + edges{ + node{ + ...AttributeWithMappingFragment + } + } + } +} diff --git a/apps/products-feed/src/modules/app-configuration/app-config.test.ts b/apps/products-feed/src/modules/app-configuration/app-config.test.ts index df06334..55a1994 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.test.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.test.ts @@ -6,7 +6,11 @@ describe("AppConfig", function () { it("Constructs empty state", () => { const instance = new AppConfig(); - expect(instance.getRootConfig()).toEqual({ channelConfig: {}, s3: null }); + expect(instance.getRootConfig()).toEqual({ + channelConfig: {}, + s3: null, + attributeMapping: null, + }); }); it("Constructs from initial state", () => { @@ -25,6 +29,13 @@ describe("AppConfig", function () { }, }, }, + attributeMapping: { + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: [], + }, }); expect(instance.getRootConfig()).toEqual({ @@ -42,6 +53,13 @@ describe("AppConfig", function () { }, }, }, + attributeMapping: { + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: [], + }, }); }); @@ -64,6 +82,13 @@ describe("AppConfig", function () { secretAccessKey: "secret", }, channelConfig: {}, + attributeMapping: { + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: [], + }, }); const serialized = instance1.serialize(); @@ -78,6 +103,13 @@ describe("AppConfig", function () { secretAccessKey: "secret", }, channelConfig: {}, + attributeMapping: { + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: [], + }, }); }); }); @@ -98,6 +130,13 @@ describe("AppConfig", function () { }, }, }, + attributeMapping: { + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: ["size-id"], + }, }); it("getRootConfig returns root config data", () => { @@ -116,6 +155,13 @@ describe("AppConfig", function () { }, }, }, + attributeMapping: { + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: ["size-id"], + }, }); }); @@ -136,6 +182,16 @@ describe("AppConfig", function () { secretAccessKey: "secret", }); }); + + it("getAttributeMapping gets attribute data", () => { + expect(instance.getAttributeMapping()).toEqual({ + brandAttributeIds: [], + colorAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + sizeAttributeIds: ["size-id"], + }); + }); }); describe("setters", () => { diff --git a/apps/products-feed/src/modules/app-configuration/app-config.ts b/apps/products-feed/src/modules/app-configuration/app-config.ts index 9dc1929..edc9504 100644 --- a/apps/products-feed/src/modules/app-configuration/app-config.ts +++ b/apps/products-feed/src/modules/app-configuration/app-config.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +const attributeMappingSchema = z.object({ + brandAttributeIds: z.array(z.string()), + colorAttributeIds: z.array(z.string()), + sizeAttributeIds: z.array(z.string()), + materialAttributeIds: z.array(z.string()), + patternAttributeIds: z.array(z.string()), +}); + const s3ConfigSchema = z.object({ bucketName: z.string().min(1), secretAccessKey: z.string().min(1), @@ -14,6 +22,7 @@ const urlConfigurationSchema = z.object({ const rootAppConfigSchema = z.object({ s3: s3ConfigSchema.nullable(), + attributeMapping: attributeMappingSchema.nullable(), channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })), }); @@ -21,6 +30,7 @@ export const AppConfigSchema = { root: rootAppConfigSchema, s3Bucket: s3ConfigSchema, channelUrls: urlConfigurationSchema, + attributeMapping: attributeMappingSchema, }; export type RootConfig = z.infer; @@ -31,6 +41,7 @@ export class AppConfig { private rootData: RootConfig = { channelConfig: {}, s3: null, + attributeMapping: null, }; constructor(initialData?: RootConfig) { @@ -63,6 +74,18 @@ export class AppConfig { } } + setAttributeMapping(attributeMapping: z.infer) { + try { + this.rootData.attributeMapping = attributeMappingSchema.parse(attributeMapping); + + return this; + } catch (e) { + console.error(e); + + throw new Error("Invalid mapping config provided"); + } + } + setChannelUrls(channelSlug: string, urlsConfig: z.infer) { try { const parsedConfig = urlConfigurationSchema.parse(urlsConfig); @@ -88,4 +111,8 @@ export class AppConfig { getS3Config() { return this.rootData.s3; } + + getAttributeMapping() { + return this.rootData.attributeMapping; + } } diff --git a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts index 68ed6cb..0969baa 100644 --- a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts +++ b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration"; import { checkBucketAccess } from "../file-storage/s3/check-bucket-access"; import { TRPCError } from "@trpc/server"; +import { AttributeFetcher } from "./attribute-fetcher"; export const appConfigurationRouter = router({ /** @@ -116,4 +117,39 @@ export const appConfigurationRouter = router({ return null; } ), + setAttributeMapping: protectedClientProcedure + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(AppConfigSchema.attributeMapping) + .mutation( + async ({ + ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger }, + input, + }) => { + const config = await getConfig(); + + config.setAttributeMapping(input); + + await appConfigMetadataManager.set(config.serialize()); + + return null; + } + ), + + getAttributes: protectedClientProcedure + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .query(async ({ ctx: { logger, apiClient } }) => { + const fetcher = new AttributeFetcher(apiClient); + + const result = await fetcher.fetchAllAttributes().catch((e) => { + logger.error(e, "Can't fetch the attributes"); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Can't fetch the attributes", + }); + }); + + logger.debug("Returning attributes"); + + return result; + }), }); diff --git a/apps/products-feed/src/modules/app-configuration/attribute-fetcher.ts b/apps/products-feed/src/modules/app-configuration/attribute-fetcher.ts new file mode 100644 index 0000000..9b63c06 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/attribute-fetcher.ts @@ -0,0 +1,49 @@ +import { Client } from "urql"; +import { + AttributeWithMappingFragmentFragment, + FetchAttributesWithMappingDocument, +} from "../../../generated/graphql"; + +export class AttributeFetcher { + constructor(private apiClient: Pick) {} + + private async fetchRecursivePage( + accumulator: AttributeWithMappingFragmentFragment[], + cursor?: string + ): Promise { + const result = await this.apiClient + .query(FetchAttributesWithMappingDocument, { + cursor, + }) + .toPromise(); + + if (result.error) { + throw new Error(result.error.message); + } + + if (!result.data) { + // todo sentry + throw new Error("Empty attributes data"); + } + + accumulator = [...accumulator, ...(result.data.attributes?.edges.map((c) => c.node) ?? [])]; + + const hasNextPage = result.data.attributes?.pageInfo.hasNextPage; + const endCursor = result.data.attributes?.pageInfo.endCursor; + + if (hasNextPage && endCursor) { + return this.fetchRecursivePage(accumulator, endCursor); + } else { + return accumulator; + } + } + + /** + * Fetches all attribute pages - standard page is max 100 items + */ + async fetchAllAttributes(): Promise { + let attributes: AttributeWithMappingFragmentFragment[] = []; + + return this.fetchRecursivePage(attributes, undefined); + } +} diff --git a/apps/products-feed/src/modules/app-configuration/attribute-mapping-form.tsx b/apps/products-feed/src/modules/app-configuration/attribute-mapping-form.tsx new file mode 100644 index 0000000..67f2f4c --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/attribute-mapping-form.tsx @@ -0,0 +1,137 @@ +import { AppConfigSchema, RootConfig } from "./app-config"; +import { useForm } from "react-hook-form"; + +import { Box, Button, Text } from "@saleor/macaw-ui/next"; + +import React, { useCallback, useMemo } from "react"; +import { Multiselect } from "@saleor/react-hook-form-macaw"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { trpcClient } from "../trpc/trpc-client"; +import { useDashboardNotification } from "@saleor/apps-shared"; +import { AttributeWithMappingFragmentFragment } from "../../../generated/graphql"; + +type AttributeMappingConfiguration = Exclude; + +type Props = { + initialData: AttributeMappingConfiguration; + attributes: AttributeWithMappingFragmentFragment[]; + onSubmit(data: AttributeMappingConfiguration): Promise; +}; + +export const AttributeMappingConfigurationForm = (props: Props) => { + const { handleSubmit, control } = useForm({ + defaultValues: props.initialData, + resolver: zodResolver(AppConfigSchema.attributeMapping), + }); + + const options = props.attributes.map((a) => ({ value: a.id, label: a.name || a.id })) || []; + + return ( + { + props.onSubmit(data); + })} + > + + + + + + + + + + ); +}; + +export const ConnectedAttributeMappingForm = () => { + const { notifyError, notifySuccess } = useDashboardNotification(); + + const { data: attributes, isLoading: isAttributesLoading } = + trpcClient.appConfiguration.getAttributes.useQuery(); + + const { data, isLoading: isConfigurationLoading } = trpcClient.appConfiguration.fetch.useQuery(); + + const isLoading = isAttributesLoading || isConfigurationLoading; + + const { mutate } = trpcClient.appConfiguration.setAttributeMapping.useMutation({ + onSuccess() { + notifySuccess("Success", "Updated attribute mapping"); + }, + onError() { + notifyError("Error", "Failed to update, please refresh and try again"); + }, + }); + + const handleSubmit = useCallback( + async (data: AttributeMappingConfiguration) => { + mutate(data); + }, + [mutate] + ); + + const formData: AttributeMappingConfiguration = useMemo(() => { + if (data?.attributeMapping) { + return data.attributeMapping; + } + + return { + colorAttributeIds: [], + sizeAttributeIds: [], + brandAttributeIds: [], + patternAttributeIds: [], + materialAttributeIds: [], + }; + }, [data]); + + if (isLoading) { + return Loading...; + } + + const showForm = !isLoading && attributes?.length; + + return ( + <> + {showForm ? ( + + ) : ( + Loading + )} + + ); +}; diff --git a/apps/products-feed/src/modules/google-feed/attribute-mapping.test.ts b/apps/products-feed/src/modules/google-feed/attribute-mapping.test.ts new file mode 100644 index 0000000..a74b5af --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/attribute-mapping.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; +import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; +import { attributeArrayToValueString, getMappedAttributes } from "./attribute-mapping"; + +const productBase: GoogleFeedProductVariantFragment["product"] = { + name: "Product", + __typename: "Product", + id: "product-id", + category: { + id: "cat-id", + __typename: "Category", + name: "Category Name", + googleCategoryId: "1", + }, + description: "Product description", + seoDescription: "Seo description", + slug: "product-slug", + thumbnail: { __typename: "Image", url: "" }, + attributes: [ + { + attribute: { + id: "main-color", + }, + values: [{ name: "Black" }], + }, + { + attribute: { + id: "accent-color", + }, + values: [{ name: "Red" }], + }, + { + attribute: { + id: "size", + }, + values: [{ name: "XL" }], + }, + { + attribute: { + id: "pattern", + }, + values: [{ name: "plain" }], + }, + ], +}; + +const priceBase: GoogleFeedProductVariantFragment["pricing"] = { + __typename: "VariantPricingInfo", + price: { + __typename: "TaxedMoney", + gross: { + __typename: "Money", + amount: 1, + currency: "USD", + }, + }, +}; + +describe("attribute-mapping", () => { + describe("attributeArrayToValueString", () => { + it("Return undefined, when no attributes", () => { + expect(attributeArrayToValueString([])).toStrictEqual(undefined); + }); + + it("Return value, when attribute have value assigned", () => { + expect( + attributeArrayToValueString([ + { + attribute: { + id: "1", + }, + values: [ + { + name: "Red", + }, + ], + }, + { + attribute: { + id: "2", + }, + values: [], + }, + ]) + ).toStrictEqual("Red"); + }); + + it("Return all values, when attribute have multiple value assigned", () => { + expect( + attributeArrayToValueString([ + { + attribute: { + id: "1", + }, + values: [ + { + name: "Red", + }, + { + name: "Blue", + }, + ], + }, + { + attribute: { + id: "2", + }, + values: [ + { + name: "Yellow", + }, + ], + }, + ]) + ).toStrictEqual("Red/Blue/Yellow"); + }); + }); + + describe("getMappedAttributes", () => { + it("Return undefined, when no mapping is passed", () => { + expect( + getMappedAttributes({ + variant: { + id: "id1", + __typename: "ProductVariant", + sku: "sku1", + quantityAvailable: 1, + pricing: priceBase, + name: "Product variant", + product: productBase, + attributes: [], + }, + }) + ).toStrictEqual(undefined); + }); + + it("Return empty values, when variant has no related attributes", () => { + expect( + getMappedAttributes({ + variant: { + id: "id1", + __typename: "ProductVariant", + sku: "sku1", + quantityAvailable: 1, + pricing: priceBase, + name: "Product variant", + product: productBase, + attributes: [], + }, + attributeMapping: { + brandAttributeIds: ["brand-id"], + colorAttributeIds: ["color-id"], + patternAttributeIds: ["pattern-id"], + materialAttributeIds: ["material-id"], + sizeAttributeIds: ["size-id"], + }, + }) + ).toStrictEqual({ + material: undefined, + color: undefined, + size: undefined, + brand: undefined, + pattern: undefined, + }); + }); + + it("Return attribute values, when variant has attributes used by mapping", () => { + expect( + getMappedAttributes({ + variant: { + id: "id1", + __typename: "ProductVariant", + sku: "sku1", + quantityAvailable: 1, + pricing: priceBase, + name: "Product variant", + product: productBase, + attributes: [ + { + attribute: { + id: "should be ignored", + }, + values: [ + { + name: "ignored", + }, + ], + }, + { + attribute: { + id: "brand-id", + }, + values: [ + { + name: "Saleor", + }, + ], + }, + { + attribute: { + id: "size-id", + }, + values: [ + { + name: "XL", + }, + ], + }, + { + attribute: { + id: "color-base-id", + }, + values: [ + { + name: "Red", + }, + ], + }, + { + attribute: { + id: "color-secondary-id", + }, + values: [ + { + name: "Black", + }, + ], + }, + { + attribute: { + id: "material-id", + }, + values: [ + { + name: "Cotton", + }, + ], + }, + { + attribute: { + id: "pattern-id", + }, + values: [ + { + name: "Plain", + }, + ], + }, + ], + }, + attributeMapping: { + brandAttributeIds: ["brand-id"], + colorAttributeIds: ["color-base-id", "color-secondary-id"], + materialAttributeIds: ["material-id"], + sizeAttributeIds: ["size-id"], + patternAttributeIds: ["pattern-id"], + }, + }) + ).toStrictEqual({ + material: "Cotton", + color: "Red/Black", + size: "XL", + brand: "Saleor", + pattern: "Plain", + }); + }); + }); +}); diff --git a/apps/products-feed/src/modules/google-feed/attribute-mapping.ts b/apps/products-feed/src/modules/google-feed/attribute-mapping.ts new file mode 100644 index 0000000..e27d5b5 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/attribute-mapping.ts @@ -0,0 +1,69 @@ +import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; +import { RootConfig } from "../app-configuration/app-config"; + +interface GetMappedAttributesArgs { + variant: GoogleFeedProductVariantFragment; + attributeMapping?: RootConfig["attributeMapping"]; +} + +export const attributeArrayToValueString = ( + attributes?: GoogleFeedProductVariantFragment["attributes"] +) => { + if (!attributes?.length) { + return; + } + + return attributes + .map((a) => a.values) + .flat() // Multiple values can be assigned to the attribute + .map((v) => v.name) // get value to display + .filter((v) => !!v) // filter out empty values + .join("/"); // Format of multi value attribute recommended by Google +}; + +export const getMappedAttributes = ({ + variant, + attributeMapping: mapping, +}: GetMappedAttributesArgs) => { + /* + * We have to take in account both product and variant attributes since we use flat + * model in the feed + */ + if (!mapping) { + return; + } + const attributes = variant.attributes.concat(variant.product.attributes); + + const materialAttributes = attributes.filter((a) => + mapping.materialAttributeIds.includes(a.attribute.id) + ); + const materialValue = attributeArrayToValueString(materialAttributes); + + const brandAttributes = attributes.filter((a) => + mapping.brandAttributeIds.includes(a.attribute.id) + ); + const brandValue = attributeArrayToValueString(brandAttributes); + + const colorAttributes = attributes.filter((a) => + mapping.colorAttributeIds.includes(a.attribute.id) + ); + const colorValue = attributeArrayToValueString(colorAttributes); + + const patternAttributes = attributes.filter((a) => + mapping.patternAttributeIds.includes(a.attribute.id) + ); + const patternValue = attributeArrayToValueString(patternAttributes); + + const sizeAttributes = attributes.filter((a) => + mapping.sizeAttributeIds.includes(a.attribute.id) + ); + const sizeValue = attributeArrayToValueString(sizeAttributes); + + return { + material: materialValue, + brand: brandValue, + color: colorValue, + size: sizeValue, + pattern: patternValue, + }; +}; diff --git a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts index 642b882..283792e 100644 --- a/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts +++ b/apps/products-feed/src/modules/google-feed/generate-google-xml-feed.ts @@ -3,11 +3,14 @@ import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; import { productToProxy } from "./product-to-proxy"; import { shopDetailsToProxy } from "./shop-details-to-proxy"; import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer"; +import { RootConfig } from "../app-configuration/app-config"; +import { getMappedAttributes } from "./attribute-mapping"; interface GenerateGoogleXmlFeedArgs { productVariants: GoogleFeedProductVariantFragment[]; storefrontUrl: string; productStorefrontUrl: string; + attributeMapping?: RootConfig["attributeMapping"]; shopName: string; shopDescription?: string; } @@ -29,6 +32,7 @@ const formatCurrency = (currency: string, amount: number) => { }; export const generateGoogleXmlFeed = ({ + attributeMapping, productVariants, storefrontUrl, productStorefrontUrl, @@ -36,6 +40,11 @@ export const generateGoogleXmlFeed = ({ shopDescription, }: GenerateGoogleXmlFeedArgs) => { const items = productVariants.map((variant) => { + const attributes = getMappedAttributes({ + attributeMapping: attributeMapping, + variant, + }); + const currency = variant.pricing?.price?.gross.currency; const amount = variant.pricing?.price?.gross.amount; @@ -55,6 +64,11 @@ export const generateGoogleXmlFeed = ({ googleProductCategory: variant.product.category?.googleCategoryId || "", price: price, imageUrl: variant.product.thumbnail?.url || "", + material: attributes?.material, + color: attributes?.color, + brand: attributes?.brand, + pattern: attributes?.pattern, + size: attributes?.size, }); }); diff --git a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts index 6665f85..a83318a 100644 --- a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts +++ b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.test.ts @@ -26,6 +26,7 @@ describe("GoogleFeedSettingsFetcher", () => { region: "region", secretAccessKey: "secretAccessKey", }, + attributeMapping: null, }); return appConfig.serialize(); @@ -48,6 +49,7 @@ describe("GoogleFeedSettingsFetcher", () => { accessKeyId: "accessKeyId", region: "region", }, + attributeMapping: null, }); }); }); diff --git a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts index c4dd166..dfd99c3 100644 --- a/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts +++ b/apps/products-feed/src/modules/google-feed/get-google-feed-settings.ts @@ -84,6 +84,7 @@ export class GoogleFeedSettingsFetcher { storefrontUrl, productStorefrontUrl, s3BucketConfiguration: appConfig.getS3Config(), + attributeMapping: appConfig.getAttributeMapping(), }; } } diff --git a/apps/products-feed/src/modules/google-feed/product-to-proxy.ts b/apps/products-feed/src/modules/google-feed/product-to-proxy.ts index 415821f..f4dd8c3 100644 --- a/apps/products-feed/src/modules/google-feed/product-to-proxy.ts +++ b/apps/products-feed/src/modules/google-feed/product-to-proxy.ts @@ -104,6 +104,57 @@ export const productToProxy = (p: ProductEntry) => { ], }); } + + if (p.material) { + item.push({ + "g:material": [ + { + "#text": p.material, + }, + ], + }); + } + + if (p.brand) { + item.push({ + "g:brand": [ + { + "#text": p.brand, + }, + ], + }); + } + + if (p.color) { + item.push({ + "g:color": [ + { + "#text": p.color, + }, + ], + }); + } + + if (p.size) { + item.push({ + "g:size": [ + { + "#text": p.size, + }, + ], + }); + } + + if (p.pattern) { + item.push({ + "g:pattern": [ + { + "#text": p.pattern, + }, + ], + }); + } + return { item, }; diff --git a/apps/products-feed/src/modules/google-feed/types.ts b/apps/products-feed/src/modules/google-feed/types.ts index 21e5409..c316844 100644 --- a/apps/products-feed/src/modules/google-feed/types.ts +++ b/apps/products-feed/src/modules/google-feed/types.ts @@ -12,6 +12,11 @@ export type ProductEntry = { googleProductCategory?: string; availability: "in_stock" | "out_of_stock" | "preorder" | "backorder"; category: string; + material?: string; + color?: string; + size?: string; + brand?: string; + pattern?: string; }; export type ShopDetailsEntry = { diff --git a/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts b/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts index 6331f83..cb9970e 100644 --- a/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts +++ b/apps/products-feed/src/pages/api/feed/[url]/[channel]/google.xml.ts @@ -80,6 +80,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { let storefrontUrl: string; let productStorefrontUrl: string; let bucketConfiguration: RootConfig["s3"] | undefined; + let attributeMapping: RootConfig["attributeMapping"] | undefined; try { const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData); @@ -88,6 +89,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { storefrontUrl = settings.storefrontUrl; productStorefrontUrl = settings.productStorefrontUrl; bucketConfiguration = settings.s3BucketConfiguration; + attributeMapping = settings.attributeMapping; } catch (error) { logger.warn("The application has not been configured"); @@ -181,6 +183,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { storefrontUrl, productStorefrontUrl, productVariants, + attributeMapping, }); logger.debug("Feed generated. Returning formatted XML"); diff --git a/apps/products-feed/src/pages/configuration.tsx b/apps/products-feed/src/pages/configuration.tsx index c96098b..e0d9e30 100644 --- a/apps/products-feed/src/pages/configuration.tsx +++ b/apps/products-feed/src/pages/configuration.tsx @@ -10,6 +10,7 @@ import { ConnectedS3ConfigurationForm } from "../modules/app-configuration/s3-co import { ChannelsConfigAccordion } from "../modules/app-configuration/channels-config-accordion"; import { useRouter } from "next/router"; import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview"; +import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form"; const ConfigurationPage: NextPage = () => { useChannelsExistenceChecking(); @@ -145,6 +146,49 @@ const ConfigurationPage: NextPage = () => { } /> + } + sideContent={ + + + Choose which product attributes should be used for the feed. If product has multiple + attribute values, for example "Primary color" and "Secondary + color", both values will be used according to Google guidelines: + +
    +
  • + + Brand + +
  • +
  • + + Color + +
  • +
  • + + Material + +
  • +
  • + + Pattern + +
  • +
  • + + Size + +
  • +
+
+ } + /> ); };