Product Feed: Add configurable image size and additional photos (#915)

* Add configurable image size and additional photos

* CR fixes

* CSpell fixes
This commit is contained in:
Krzysztof Wolski 2023-09-01 13:35:02 +02:00 committed by GitHub
parent 0aa1d12cc4
commit 261e9d1a0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 483 additions and 43 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-products-feed": minor
---
Added additional images attribute to the feed for media uploaded to the product.

View file

@ -0,0 +1,6 @@
---
"saleor-app-products-feed": patch
---
Improved default resolution of the submitted images. Was: 500px, now it's 1024px.
Users can now configure the size in the app configuration.

View file

@ -32,6 +32,21 @@ fragment GoogleFeedProductVariant on ProductVariant {
slug
description
seoDescription
media{
id
alt
url(size: $imageSize)
type
}
variants{
id
media{
id
alt
url(size: $imageSize)
type
}
}
attributes{
attribute{
id
@ -41,7 +56,7 @@ fragment GoogleFeedProductVariant on ProductVariant {
name
}
}
thumbnail {
thumbnail(size: $imageSize) {
url
}
category {

View file

@ -1,4 +1,4 @@
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!){
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $imageSize: Int = 1024){
productVariants(first:$first, after: $after, channel: $channel){
pageInfo{
hasNextPage

View file

@ -28,11 +28,14 @@ const exampleAttributeMappingConfig: RootConfig["attributeMapping"] = {
const exampleTitleTemplate: RootConfig["titleTemplate"] =
"Example {{ variant.product.name }} - {{ variant.name }}";
const exampleImageSize: RootConfig["imageSize"] = 1024;
const exampleConfiguration: RootConfig = {
channelConfig: exampleChannelConfig,
s3: exampleS3Config,
attributeMapping: exampleAttributeMappingConfig,
titleTemplate: exampleTitleTemplate,
imageSize: exampleImageSize,
};
describe("AppConfig", function () {
@ -51,6 +54,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
imageSize: 1024,
});
});
@ -60,13 +64,15 @@ describe("AppConfig", function () {
expect(instance.getRootConfig()).toEqual(exampleConfiguration);
});
it("Fill attribute mapping and title template with default values, when initial data are lacking those fields", () => {
it("Fill attribute mapping, image size and title template with default values, when initial data are lacking those fields", () => {
const configurationWithoutMapping = structuredClone(exampleConfiguration);
// @ts-expect-error: Simulating data before the migration
delete configurationWithoutMapping.attributeMapping;
// @ts-expect-error
delete configurationWithoutMapping.titleTemplate;
// @ts-expect-error
delete configurationWithoutMapping.imageSize;
const instance = new AppConfig(configurationWithoutMapping as any); // Casting used to prevent TS from reporting an error
@ -80,6 +86,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
imageSize: 1024,
});
});
@ -110,6 +117,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
const serialized = instance1.serialize();
@ -132,6 +140,7 @@ describe("AppConfig", function () {
sizeAttributeIds: [],
},
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
});
});
@ -160,6 +169,7 @@ describe("AppConfig", function () {
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
imageSize: 1024,
});
it("getRootConfig returns root config data", () => {
@ -186,6 +196,7 @@ describe("AppConfig", function () {
sizeAttributeIds: ["size-id"],
},
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
imageSize: 1024,
});
});

View file

@ -1,6 +1,14 @@
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod";
const imageSizeFieldSchema = z.coerce.number().gte(256).default(1024);
export const imageSizeInputSchema = z.object({
imageSize: imageSizeFieldSchema,
});
export type ImageSizeInput = z.infer<typeof imageSizeInputSchema>;
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
export const titleTemplateInputSchema = z.object({
@ -34,6 +42,7 @@ const rootAppConfigSchema = z.object({
titleTemplate: titleTemplateFieldSchema
.optional()
.default(titleTemplateFieldSchema.parse(undefined)),
imageSize: imageSizeFieldSchema.optional().default(imageSizeFieldSchema.parse(undefined)),
attributeMapping: attributeMappingSchema
.nullable()
.optional()
@ -60,6 +69,7 @@ export class AppConfig {
s3: null,
attributeMapping: attributeMappingSchema.parse({}),
titleTemplate: titleTemplateFieldSchema.parse(undefined),
imageSize: imageSizeFieldSchema.parse(undefined),
};
constructor(initialData?: RootConfig) {
@ -147,4 +157,14 @@ export class AppConfig {
getTitleTemplate() {
return this.rootData.titleTemplate;
}
setImageSize(imageSize: z.infer<typeof imageSizeFieldSchema>) {
this.rootData.imageSize = imageSize;
return this;
}
getImageSize() {
return this.rootData.imageSize;
}
}

View file

@ -3,7 +3,7 @@ import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { createLogger } from "@saleor/apps-shared";
import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
import { AppConfigSchema, titleTemplateInputSchema } from "./app-config";
import { AppConfigSchema, imageSizeInputSchema, titleTemplateInputSchema } from "./app-config";
import { z } from "zod";
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
@ -158,6 +158,21 @@ export const appConfigurationRouter = router({
return result;
}),
setImageSize: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(imageSizeInputSchema)
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
logger.debug("Setting image size");
const config = await getConfig();
config.setImageSize(input.imageSize);
await appConfigMetadataManager.set(config.serialize());
logger.debug("image size set");
return null;
}),
setTitleTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(titleTemplateInputSchema)

View file

@ -0,0 +1,98 @@
import { ImageSizeInput, imageSizeInputSchema } 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 { Select } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
type Props = {
initialData: ImageSizeInput;
onSubmit(data: ImageSizeInput): Promise<void>;
};
const imageSizeOptions = [
{ value: "256", label: "256px" },
{ value: "512", label: "512px" },
{ value: "1024", label: "1024px" },
{ value: "2048", label: "2048px" },
{ value: "4096", label: "4096px" },
];
export const ImageConfigurationForm = (props: Props) => {
const { handleSubmit, control, formState } = useForm<ImageSizeInput>({
defaultValues: props.initialData,
resolver: zodResolver(imageSizeInputSchema),
});
return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit(props.onSubmit)}
>
<Select control={control} name="imageSize" label="Image size" options={imageSizeOptions} />
{!!formState.errors.imageSize?.message && (
<Text variant="caption" color={"textCriticalSubdued"}>
{formState.errors.imageSize?.message}
</Text>
)}
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button type="submit" variant="primary">
Save {props.initialData.imageSize}
</Button>
</Box>
</Box>
);
};
export const ConnectedImageConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
const { mutate } = trpcClient.appConfiguration.setImageSize.useMutation({
onSuccess() {
notifySuccess("Success", "Updated image size");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const handleSubmit = useCallback(
async (data: ImageSizeInput) => {
mutate(data);
},
[mutate],
);
const formData: ImageSizeInput = useMemo(() => {
if (data?.imageSize) {
return {
imageSize: data.imageSize,
};
}
return imageSizeInputSchema.parse({});
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
return (
<>
{!isLoading ? (
<ImageConfigurationForm onSubmit={handleSubmit} initialData={formData} />
) : (
<Box>Loading</Box>
)}
</>
);
};

View file

@ -40,10 +40,12 @@ const fetchVariants = async ({
client,
after,
channel,
imageSize,
}: {
client: Client;
after?: string;
channel: string;
imageSize?: number;
}): Promise<GoogleFeedProductVariantFragment[]> => {
const logger = createLogger({ saleorApiUrl: url, channel, fn: "fetchVariants" });
@ -54,6 +56,7 @@ const fetchVariants = async ({
channel: channel,
first: 100,
after,
imageSize,
})
.toPromise();
@ -69,9 +72,15 @@ interface FetchProductDataArgs {
client: Client;
channel: string;
cursors?: Array<string>;
imageSize?: number;
}
export const fetchProductData = async ({ client, channel, cursors }: FetchProductDataArgs) => {
export const fetchProductData = async ({
client,
channel,
cursors,
imageSize,
}: FetchProductDataArgs) => {
const logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });
const cachedCursors = cursors || (await getCursors({ client, channel }));
@ -80,7 +89,9 @@ export const fetchProductData = async ({ client, channel, cursors }: FetchProduc
logger.debug(`Query generated ${pageCursors.length} cursors`);
const promises = pageCursors.map((cursor) => fetchVariants({ client, after: cursor, channel }));
const promises = pageCursors.map((cursor) =>
fetchVariants({ client, after: cursor, channel, imageSize }),
);
const results = await Promise.all(promises);

View file

@ -8,6 +8,7 @@ import { priceMapping } from "./price-mapping";
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
import { EditorJsPlaintextRenderer } from "@saleor/apps-shared";
import { getRelatedMedia, getVariantMediaMap } from "./get-related-media";
interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[];
@ -50,6 +51,12 @@ export const generateGoogleXmlFeed = ({
let link = undefined;
const { additionalImages, thumbnailUrl } = getRelatedMedia({
productMedia: variant.product.media || [],
productVariantId: variant.id,
variantMediaMap: getVariantMediaMap({ variant }) || [],
});
try {
link = renderHandlebarsTemplate({
data: {
@ -72,7 +79,8 @@ export const generateGoogleXmlFeed = ({
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
category: variant.product.category?.name || "unknown",
googleProductCategory: variant.product.category?.googleCategoryId || "",
imageUrl: variant.product.thumbnail?.url || "",
imageUrl: thumbnailUrl,
additionalImageLinks: additionalImages,
material: attributes?.material,
color: attributes?.color,
brand: attributes?.brand,
@ -115,7 +123,7 @@ export const generateGoogleXmlFeed = ({
{
rss: [
{
// @ts-ignore - This is "just an object" that is transformed to XML. I dont see good way to type it, other than "any"
// @ts-ignore - This is "just an object" that is transformed to XML. I don't see good way to type it, other than "any"
channel: channelData.concat(items),
},
],

View file

@ -30,6 +30,7 @@ describe("GoogleFeedSettingsFetcher", () => {
},
attributeMapping: null,
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
return appConfig.serialize();
@ -43,7 +44,7 @@ describe("GoogleFeedSettingsFetcher", () => {
const result = await instance.fetch("testChannel");
expect(result).toEqual({
expect(result).toStrictEqual({
storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
s3BucketConfiguration: {
@ -54,6 +55,7 @@ describe("GoogleFeedSettingsFetcher", () => {
},
attributeMapping: null,
titleTemplate: "{{ variant.name }}",
imageSize: 1024,
});
});
});

View file

@ -1,6 +1,6 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config";
@ -8,7 +8,7 @@ export class GoogleFeedSettingsFetcher {
static createFromAuthData(authData: AuthData) {
return new GoogleFeedSettingsFetcher({
settingsManager: new AppConfigMetadataManager(
createSettingsManager(GraphqlClientFactory.fromAuthData(authData))
createSettingsManager(GraphqlClientFactory.fromAuthData(authData)),
),
});
}
@ -46,6 +46,7 @@ export class GoogleFeedSettingsFetcher {
s3BucketConfiguration: appConfig.getS3Config(),
attributeMapping: appConfig.getAttributeMapping(),
titleTemplate: appConfig.getTitleTemplate(),
imageSize: appConfig.getImageSize(),
};
}
}

View file

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { getRelatedMedia } from "./get-related-media";
import { ProductMediaType } from "../../../generated/graphql";
describe("getRelatedMedia", () => {
it("Returns thumbnail, when set to the product", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/1.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/1.jpg",
additionalImages: [],
});
});
it("Returns undefined as thumbnail URL, when product has no media", () => {
expect(
getRelatedMedia({
productMedia: [],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: undefined,
additionalImages: [],
});
});
it("Returns thumbnail and additional images, when all images are set to the product", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/1.jpg",
},
{
id: "id-2",
type: ProductMediaType.Image,
url: "http://example.com/2.jpg",
},
{
id: "id-3",
type: ProductMediaType.Image,
url: "http://example.com/3.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/1.jpg",
additionalImages: ["http://example.com/2.jpg", "http://example.com/3.jpg"],
});
});
it("Returns filtered list of images, when some of the images are assigned to the other variants", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/1.jpg",
},
{
id: "id-other-variant",
type: ProductMediaType.Image,
url: "http://example.com/2-other-variant.jpg",
},
{
id: "id-3",
type: ProductMediaType.Image,
url: "http://example.com/3.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {
"variant-2": [
{
id: "id-other-variant",
type: ProductMediaType.Image,
url: "http://example.com/2-other-variant.jpg",
},
],
},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/1.jpg",
additionalImages: ["http://example.com/3.jpg"],
});
});
it("Returns only URLs to photos, when product has both Image and Video media types", () => {
expect(
getRelatedMedia({
productMedia: [
{
id: "id-1",
type: ProductMediaType.Image,
url: "http://example.com/image-1.jpg",
},
{
id: "id-2",
type: ProductMediaType.Video,
url: "http://example.com/video-2.mp4",
},
{
id: "id-3",
type: ProductMediaType.Image,
url: "http://example.com/image-3.jpg",
},
],
productVariantId: "variant-1",
variantMediaMap: {},
}),
).toStrictEqual({
thumbnailUrl: "http://example.com/image-1.jpg",
additionalImages: ["http://example.com/image-3.jpg"],
});
});
});

View file

@ -0,0 +1,63 @@
import { GoogleFeedProductVariantFragment, ProductMediaType } from "../../../generated/graphql";
type Media = {
id: string;
url: string;
type: ProductMediaType;
};
interface getRelatedMediaArgs {
productVariantId: string;
productMedia: Media[];
variantMediaMap: Record<string, Media[]>;
}
/*
* Filters media related to the product variant and returns:
* - thumbnailUrl: Product thumbnail (since Saleor has no dedicated field to the variant thumbnail)
* - additionalImages: Url list of photos assigned to particular variant and product photos not associated with any other variant
*/
export const getRelatedMedia = ({
productVariantId,
variantMediaMap,
productMedia,
}: getRelatedMediaArgs) => {
// Saleor always uses the first photo as thumbnail - even if it's assigned to the variant
const productThumbnailUrl = productMedia[0]?.url;
const mediaAssignedToAnyVariant = Object.values(variantMediaMap).flat() || [];
const mediaAssignedToNoVariant =
productMedia?.filter((m) => !mediaAssignedToAnyVariant.find((vm) => vm.id === m.id)) || [];
const mediaAssignedToVariant = variantMediaMap[productVariantId] || [];
const additionalImages =
[...mediaAssignedToVariant, ...mediaAssignedToNoVariant]
?.filter((media) => media.type === ProductMediaType.Image) // Videos are not supported by the field
.map((media) => media.url)
.filter((url) => url !== productThumbnailUrl) || []; // Exclude image used as thumbnail
return {
thumbnailUrl: productThumbnailUrl,
additionalImages,
};
};
interface GetVariantMediaMapArgs {
variant: GoogleFeedProductVariantFragment;
}
export const getVariantMediaMap = ({ variant }: GetVariantMediaMapArgs) => {
return (
variant.product.variants?.reduce((accumulator: Record<string, Array<Media>>, currentValue) => {
const id = currentValue?.id;
if (!id) {
return accumulator;
}
accumulator[id] = currentValue.media?.filter((m) => !!m) || [];
return accumulator;
}, {}) || {}
);
};

View file

@ -11,6 +11,7 @@ describe("productToProxy", () => {
id: "product-id",
title: "title",
variantId: "variant-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -18,7 +19,7 @@ describe("productToProxy", () => {
{
"g:id": expect.arrayContaining([{ "#text": "variant-id" }]),
},
])
]),
);
});
@ -34,6 +35,7 @@ describe("productToProxy", () => {
id: "product-id",
title: "title",
variantId: "variant-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -41,7 +43,7 @@ describe("productToProxy", () => {
{
"g:condition": expect.arrayContaining([{ "#text": "new" }]),
},
])
]),
);
});
@ -55,6 +57,7 @@ describe("productToProxy", () => {
title: "title",
variantId: "variant-id",
description: "Product description",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -62,7 +65,7 @@ describe("productToProxy", () => {
{
"g:description": expect.arrayContaining([{ "#text": "Product description" }]),
},
])
]),
);
});
@ -76,6 +79,7 @@ describe("productToProxy", () => {
id: "product-id",
title: "title",
variantId: "variant-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -83,7 +87,7 @@ describe("productToProxy", () => {
{
"g:google_product_category": expect.arrayContaining([{ "#text": "1" }]),
},
])
]),
);
});
@ -98,6 +102,7 @@ describe("productToProxy", () => {
title: "title",
variantId: "variant-id",
link: "https://example.com/p/product-id",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -109,7 +114,7 @@ describe("productToProxy", () => {
},
]),
},
])
]),
);
});
@ -124,6 +129,7 @@ describe("productToProxy", () => {
title: "title",
variantId: "variant-id",
imageUrl: "https://image.example.com",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -131,7 +137,7 @@ describe("productToProxy", () => {
{
"g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]),
},
])
]),
);
});
@ -147,6 +153,7 @@ describe("productToProxy", () => {
variantId: "variant-id",
imageUrl: "https://image.example.com",
price: "50.00 USD",
additionalImageLinks: [],
});
expect(result.item).toEqual(
@ -154,7 +161,7 @@ describe("productToProxy", () => {
{
"g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]),
},
])
]),
);
});
});

View file

@ -93,6 +93,16 @@ export const productToProxy = (p: ProductEntry) => {
});
}
for (const additional_image of p.additionalImageLinks) {
item.push({
"g:additional_image_link": [
{
"#text": additional_image,
},
],
});
}
if (p.price?.length) {
item.push({
"g:price": [

View file

@ -7,6 +7,7 @@ export type ProductEntry = {
description?: string;
link?: string;
imageUrl?: string;
additionalImageLinks: string[];
condition?: "new" | "refurbished" | "used";
price?: string;
salePrice?: string;

View file

@ -1,4 +1,4 @@
import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
import { updateCacheForConfigurations } from "./update-cache-for-configurations";
import { AuthData } from "@saleor/app-sdk/APL";
import {

View file

@ -4,7 +4,7 @@ import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
import { createLogger } from "@saleor/apps-shared";
import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config";

View file

@ -8,7 +8,7 @@ import { generateGoogleXmlFeed } from "../../../../../modules/google-feed/genera
import { fetchShopData } from "../../../../../modules/google-feed/fetch-shop-data";
import { CacheConfigurator } from "../../../../../modules/metadata-cache/cache-configurator";
import { createSettingsManager } from "../../../../../lib/metadata-manager";
import { GraphqlClientFactory } from "../../../../../lib/create-graphq-client";
import { GraphqlClientFactory } from "../../../../../lib/create-graphql-client";
import { uploadFile } from "../../../../../modules/file-storage/s3/upload-file";
import { createS3ClientFromConfiguration } from "../../../../../modules/file-storage/s3/create-s3-client-from-configuration";
import { getFileDetails } from "../../../../../modules/file-storage/s3/get-file-details";
@ -82,6 +82,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let bucketConfiguration: RootConfig["s3"] | undefined;
let attributeMapping: RootConfig["attributeMapping"] | undefined;
let titleTemplate: RootConfig["titleTemplate"] | undefined;
let imageSize: RootConfig["imageSize"] | undefined;
try {
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
@ -92,6 +93,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
bucketConfiguration = settings.s3BucketConfiguration;
attributeMapping = settings.attributeMapping;
titleTemplate = settings.titleTemplate;
imageSize = settings.imageSize;
} catch (error) {
logger.warn("The application has not been configured");
@ -171,7 +173,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let productVariants: GoogleFeedProductVariantFragment[] = [];
try {
productVariants = await fetchProductData({ client, channel, cursors });
productVariants = await fetchProductData({ client, channel, cursors, imageSize });
} catch (error) {
logger.error(error);
return res.status(400).end();

View file

@ -12,6 +12,7 @@ import { useRouter } from "next/router";
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
import { ConnectedTitleFormattingForm } from "../modules/app-configuration/title-formatting-form";
import { ConnectedImageConfigurationForm } from "../modules/app-configuration/image-configuration-form";
const ConfigurationPage: NextPage = () => {
useChannelsExistenceChecking();
@ -214,6 +215,28 @@ const ConfigurationPage: NextPage = () => {
</Box>
}
/>
<AppSection
data-testid={"image-configuration-section"}
__marginBottom="100px"
includePadding
heading={"Image configuration"}
mainContent={<ConnectedImageConfigurationForm />}
sideContent={
<Box>
<Paragraph size={"small"}>
Configure size of the images submitted in the feed.
</Paragraph>
<ul>
<li>
<TextLink href="https://support.google.com/merchants/answer/6324350?hl=en" newTab>
Image link documentation
</TextLink>
</li>
</ul>
</Box>
}
/>
</Box>
);
};

View file

@ -1,36 +1,50 @@
{
"words": [
"Adyen",
"Afterpay",
"Algolia",
"Appstore",
"Avalara",
"AvaTax",
"backorder",
"Braintree",
"Builder.io",
"Clearpay",
"clsx",
"codegen",
"Contentful",
"DataDog",
"DatoCMS",
"hookform",
"Hygraph",
"jwks",
"Klarna",
"Klaviyo",
"Mailchimp",
"maxage",
"metafield",
"metafields",
"Mollie",
"Nuvo",
"PayloadCMS",
"PayPal",
"preorder",
"RudderStack",
"Saleor",
"SendGrid",
"Upstash",
"Vercel",
"Strapi",
"Contentful",
"DatoCMS",
"Builder.io",
"PayloadCMS",
"Storyblok",
"Hygraph",
"SMTP",
"TaxJar",
"Storyblok",
"Strapi",
"Stripe",
"Mollie",
"PayPal",
"Klarna",
"Braintree",
"Mailchimp",
"syncpack",
"TaxJar",
"testid",
"trpc",
"Twilio",
"RudderStack",
"DataDog",
"Clearpay",
"Afterpay",
"Nuvo",
"Appstore"
"Undiscounted",
"Upstash",
"urql",
"Vercel"
],
"ignorePaths": ["node_modules", "package.json", "pnpm-lock.yaml", ".gitignore"]
}