From d868f2d46b39d7ad75ed4f049111ea0480ca117b Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Wed, 2 Aug 2023 12:41:35 +0200 Subject: [PATCH] Add attribute mapping --- .changeset/nine-dryers-wink.md | 5 + .../AttributeWithMappingFragment.graphql | 5 + .../GoogleFeedProductVariantFragment.graphql | 18 ++ .../FetchAttributesWithMapping.graphql | 13 + .../modules/app-configuration/app-config.ts | 26 ++ .../app-configuration.router.ts | 35 +++ .../app-configuration/attribute-fetcher.ts | 49 ++++ .../attribute-mapping-form.tsx | 131 +++++++++ .../google-feed/attribute-mapping.test.ts | 249 ++++++++++++++++++ .../modules/google-feed/attribute-mapping.ts | 63 +++++ .../google-feed/generate-google-xml-feed.ts | 13 + .../get-google-feed-settings.test.ts | 2 + .../google-feed/get-google-feed-settings.ts | 1 + .../modules/google-feed/product-to-proxy.ts | 41 +++ .../src/modules/google-feed/types.ts | 4 + .../api/feed/[url]/[channel]/google.xml.ts | 3 + .../products-feed/src/pages/configuration.tsx | 39 +++ 17 files changed, 697 insertions(+) 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 0000000000..691ceed6df --- /dev/null +++ b/.changeset/nine-dryers-wink.md @@ -0,0 +1,5 @@ +--- +"saleor-app-products-feed": minor +--- + +Added new feature: Attributes required by the Google Merchant can now be mapped to product attributes. diff --git a/apps/products-feed/graphql/fragments/AttributeWithMappingFragment.graphql b/apps/products-feed/graphql/fragments/AttributeWithMappingFragment.graphql new file mode 100644 index 0000000000..6fbef00c54 --- /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 f00cc4e6af..e6107a160d 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 0000000000..d02d666f25 --- /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.ts b/apps/products-feed/src/modules/app-configuration/app-config.ts index 9dc1929b5d..028dfcc19a 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,12 @@ 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()), +}); + const s3ConfigSchema = z.object({ bucketName: z.string().min(1), secretAccessKey: z.string().min(1), @@ -14,6 +21,7 @@ const urlConfigurationSchema = z.object({ const rootAppConfigSchema = z.object({ s3: s3ConfigSchema.nullable(), + attributeMapping: attributeMappingSchema.nullable(), channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })), }); @@ -21,6 +29,7 @@ export const AppConfigSchema = { root: rootAppConfigSchema, s3Bucket: s3ConfigSchema, channelUrls: urlConfigurationSchema, + attributeMapping: attributeMappingSchema, }; export type RootConfig = z.infer; @@ -31,6 +40,7 @@ export class AppConfig { private rootData: RootConfig = { channelConfig: {}, s3: null, + attributeMapping: null, }; constructor(initialData?: RootConfig) { @@ -63,6 +73,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 +110,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 68ed6cb091..c2093bfb9b 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,38 @@ 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) => { + 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 0000000000..9b63c06725 --- /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 0000000000..9dd3ab5406 --- /dev/null +++ b/apps/products-feed/src/modules/app-configuration/attribute-mapping-form.tsx @@ -0,0 +1,131 @@ +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: [], + 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 0000000000..89d42fd3d0 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/attribute-mapping.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; +import { generateGoogleXmlFeed } from "./generate-google-xml-feed"; +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" }], + }, + ], +}; + +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"], + materialAttributeIds: ["material-id"], + sizeAttributeIds: ["size-id"], + }, + }) + ).toStrictEqual({ + material: undefined, + color: undefined, + size: undefined, + brand: 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", + }, + ], + }, + ], + }, + attributeMapping: { + brandAttributeIds: ["brand-id"], + colorAttributeIds: ["color-base-id", "color-secondary-id"], + materialAttributeIds: ["material-id"], + sizeAttributeIds: ["size-id"], + }, + }) + ).toStrictEqual({ + material: "Cotton", + color: "Red/Black", + size: "XL", + brand: "Saleor", + }); + }); + }); +}); 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 0000000000..871a6cddc4 --- /dev/null +++ b/apps/products-feed/src/modules/google-feed/attribute-mapping.ts @@ -0,0 +1,63 @@ +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 sizeAttributes = attributes.filter((a) => + mapping.sizeAttributeIds.includes(a.attribute.id) + ); + const sizeValue = attributeArrayToValueString(sizeAttributes); + + return { + material: materialValue, + brand: brandValue, + color: colorValue, + size: sizeValue, + }; +}; 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 642b882696..1b2ba9a821 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,10 @@ export const generateGoogleXmlFeed = ({ googleProductCategory: variant.product.category?.googleCategoryId || "", price: price, imageUrl: variant.product.thumbnail?.url || "", + material: attributes?.material, + color: attributes?.color, + brand: attributes?.brand, + 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 6665f85ece..a83318aa2b 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 c4dd1665cb..dfd99c3d23 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 415821f6a2..003b9535d4 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,47 @@ 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, + }, + ], + }); + } + 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 21e5409cb8..b2cb6d6472 100644 --- a/apps/products-feed/src/modules/google-feed/types.ts +++ b/apps/products-feed/src/modules/google-feed/types.ts @@ -12,6 +12,10 @@ export type ProductEntry = { googleProductCategory?: string; availability: "in_stock" | "out_of_stock" | "preorder" | "backorder"; category: string; + material?: string; + color?: string; + size?: string; + brand?: 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 6331f834bc..cb9970e9e7 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 c96098bd60..c255823cd7 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,44 @@ 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: + +
    +
  • + + Material + +
  • +
  • + + Size + +
  • +
  • + + Brand + +
  • +
  • + + Color + +
  • +
+
+ } + /> ); };