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:
parent
0aa1d12cc4
commit
261e9d1a0c
23 changed files with 483 additions and 43 deletions
5
.changeset/quiet-cups-kneel.md
Normal file
5
.changeset/quiet-cups-kneel.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"saleor-app-products-feed": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added additional images attribute to the feed for media uploaded to the product.
|
6
.changeset/sharp-buses-reflect.md
Normal file
6
.changeset/sharp-buses-reflect.md
Normal 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.
|
|
@ -32,6 +32,21 @@ fragment GoogleFeedProductVariant on ProductVariant {
|
||||||
slug
|
slug
|
||||||
description
|
description
|
||||||
seoDescription
|
seoDescription
|
||||||
|
media{
|
||||||
|
id
|
||||||
|
alt
|
||||||
|
url(size: $imageSize)
|
||||||
|
type
|
||||||
|
}
|
||||||
|
variants{
|
||||||
|
id
|
||||||
|
media{
|
||||||
|
id
|
||||||
|
alt
|
||||||
|
url(size: $imageSize)
|
||||||
|
type
|
||||||
|
}
|
||||||
|
}
|
||||||
attributes{
|
attributes{
|
||||||
attribute{
|
attribute{
|
||||||
id
|
id
|
||||||
|
@ -41,7 +56,7 @@ fragment GoogleFeedProductVariant on ProductVariant {
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thumbnail {
|
thumbnail(size: $imageSize) {
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
category {
|
category {
|
||||||
|
|
|
@ -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){
|
productVariants(first:$first, after: $after, channel: $channel){
|
||||||
pageInfo{
|
pageInfo{
|
||||||
hasNextPage
|
hasNextPage
|
||||||
|
|
|
@ -28,11 +28,14 @@ const exampleAttributeMappingConfig: RootConfig["attributeMapping"] = {
|
||||||
const exampleTitleTemplate: RootConfig["titleTemplate"] =
|
const exampleTitleTemplate: RootConfig["titleTemplate"] =
|
||||||
"Example {{ variant.product.name }} - {{ variant.name }}";
|
"Example {{ variant.product.name }} - {{ variant.name }}";
|
||||||
|
|
||||||
|
const exampleImageSize: RootConfig["imageSize"] = 1024;
|
||||||
|
|
||||||
const exampleConfiguration: RootConfig = {
|
const exampleConfiguration: RootConfig = {
|
||||||
channelConfig: exampleChannelConfig,
|
channelConfig: exampleChannelConfig,
|
||||||
s3: exampleS3Config,
|
s3: exampleS3Config,
|
||||||
attributeMapping: exampleAttributeMappingConfig,
|
attributeMapping: exampleAttributeMappingConfig,
|
||||||
titleTemplate: exampleTitleTemplate,
|
titleTemplate: exampleTitleTemplate,
|
||||||
|
imageSize: exampleImageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("AppConfig", function () {
|
describe("AppConfig", function () {
|
||||||
|
@ -51,6 +54,7 @@ describe("AppConfig", function () {
|
||||||
sizeAttributeIds: [],
|
sizeAttributeIds: [],
|
||||||
},
|
},
|
||||||
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
|
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -60,13 +64,15 @@ describe("AppConfig", function () {
|
||||||
expect(instance.getRootConfig()).toEqual(exampleConfiguration);
|
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);
|
const configurationWithoutMapping = structuredClone(exampleConfiguration);
|
||||||
|
|
||||||
// @ts-expect-error: Simulating data before the migration
|
// @ts-expect-error: Simulating data before the migration
|
||||||
delete configurationWithoutMapping.attributeMapping;
|
delete configurationWithoutMapping.attributeMapping;
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
delete configurationWithoutMapping.titleTemplate;
|
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
|
const instance = new AppConfig(configurationWithoutMapping as any); // Casting used to prevent TS from reporting an error
|
||||||
|
|
||||||
|
@ -80,6 +86,7 @@ describe("AppConfig", function () {
|
||||||
sizeAttributeIds: [],
|
sizeAttributeIds: [],
|
||||||
},
|
},
|
||||||
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
|
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -110,6 +117,7 @@ describe("AppConfig", function () {
|
||||||
sizeAttributeIds: [],
|
sizeAttributeIds: [],
|
||||||
},
|
},
|
||||||
titleTemplate: "{{ variant.name }}",
|
titleTemplate: "{{ variant.name }}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
const serialized = instance1.serialize();
|
const serialized = instance1.serialize();
|
||||||
|
@ -132,6 +140,7 @@ describe("AppConfig", function () {
|
||||||
sizeAttributeIds: [],
|
sizeAttributeIds: [],
|
||||||
},
|
},
|
||||||
titleTemplate: "{{ variant.name }}",
|
titleTemplate: "{{ variant.name }}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -160,6 +169,7 @@ describe("AppConfig", function () {
|
||||||
sizeAttributeIds: ["size-id"],
|
sizeAttributeIds: ["size-id"],
|
||||||
},
|
},
|
||||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getRootConfig returns root config data", () => {
|
it("getRootConfig returns root config data", () => {
|
||||||
|
@ -186,6 +196,7 @@ describe("AppConfig", function () {
|
||||||
sizeAttributeIds: ["size-id"],
|
sizeAttributeIds: ["size-id"],
|
||||||
},
|
},
|
||||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,14 @@
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
import { z } from "zod";
|
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}}");
|
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
|
||||||
|
|
||||||
export const titleTemplateInputSchema = z.object({
|
export const titleTemplateInputSchema = z.object({
|
||||||
|
@ -34,6 +42,7 @@ const rootAppConfigSchema = z.object({
|
||||||
titleTemplate: titleTemplateFieldSchema
|
titleTemplate: titleTemplateFieldSchema
|
||||||
.optional()
|
.optional()
|
||||||
.default(titleTemplateFieldSchema.parse(undefined)),
|
.default(titleTemplateFieldSchema.parse(undefined)),
|
||||||
|
imageSize: imageSizeFieldSchema.optional().default(imageSizeFieldSchema.parse(undefined)),
|
||||||
attributeMapping: attributeMappingSchema
|
attributeMapping: attributeMappingSchema
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
|
@ -60,6 +69,7 @@ export class AppConfig {
|
||||||
s3: null,
|
s3: null,
|
||||||
attributeMapping: attributeMappingSchema.parse({}),
|
attributeMapping: attributeMappingSchema.parse({}),
|
||||||
titleTemplate: titleTemplateFieldSchema.parse(undefined),
|
titleTemplate: titleTemplateFieldSchema.parse(undefined),
|
||||||
|
imageSize: imageSizeFieldSchema.parse(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(initialData?: RootConfig) {
|
constructor(initialData?: RootConfig) {
|
||||||
|
@ -147,4 +157,14 @@ export class AppConfig {
|
||||||
getTitleTemplate() {
|
getTitleTemplate() {
|
||||||
return this.rootData.titleTemplate;
|
return this.rootData.titleTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setImageSize(imageSize: z.infer<typeof imageSizeFieldSchema>) {
|
||||||
|
this.rootData.imageSize = imageSize;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageSize() {
|
||||||
|
return this.rootData.imageSize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
|
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 { z } from "zod";
|
||||||
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
|
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
|
||||||
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
|
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
|
||||||
|
@ -158,6 +158,21 @@ export const appConfigurationRouter = router({
|
||||||
|
|
||||||
return result;
|
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
|
setTitleTemplate: protectedClientProcedure
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||||
.input(titleTemplateInputSchema)
|
.input(titleTemplateInputSchema)
|
||||||
|
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -40,10 +40,12 @@ const fetchVariants = async ({
|
||||||
client,
|
client,
|
||||||
after,
|
after,
|
||||||
channel,
|
channel,
|
||||||
|
imageSize,
|
||||||
}: {
|
}: {
|
||||||
client: Client;
|
client: Client;
|
||||||
after?: string;
|
after?: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
|
imageSize?: number;
|
||||||
}): Promise<GoogleFeedProductVariantFragment[]> => {
|
}): Promise<GoogleFeedProductVariantFragment[]> => {
|
||||||
const logger = createLogger({ saleorApiUrl: url, channel, fn: "fetchVariants" });
|
const logger = createLogger({ saleorApiUrl: url, channel, fn: "fetchVariants" });
|
||||||
|
|
||||||
|
@ -54,6 +56,7 @@ const fetchVariants = async ({
|
||||||
channel: channel,
|
channel: channel,
|
||||||
first: 100,
|
first: 100,
|
||||||
after,
|
after,
|
||||||
|
imageSize,
|
||||||
})
|
})
|
||||||
.toPromise();
|
.toPromise();
|
||||||
|
|
||||||
|
@ -69,9 +72,15 @@ interface FetchProductDataArgs {
|
||||||
client: Client;
|
client: Client;
|
||||||
channel: string;
|
channel: string;
|
||||||
cursors?: Array<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 logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });
|
||||||
|
|
||||||
const cachedCursors = cursors || (await getCursors({ client, channel }));
|
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`);
|
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);
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { priceMapping } from "./price-mapping";
|
||||||
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
|
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
|
||||||
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
|
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
|
||||||
import { EditorJsPlaintextRenderer } from "@saleor/apps-shared";
|
import { EditorJsPlaintextRenderer } from "@saleor/apps-shared";
|
||||||
|
import { getRelatedMedia, getVariantMediaMap } from "./get-related-media";
|
||||||
|
|
||||||
interface GenerateGoogleXmlFeedArgs {
|
interface GenerateGoogleXmlFeedArgs {
|
||||||
productVariants: GoogleFeedProductVariantFragment[];
|
productVariants: GoogleFeedProductVariantFragment[];
|
||||||
|
@ -50,6 +51,12 @@ export const generateGoogleXmlFeed = ({
|
||||||
|
|
||||||
let link = undefined;
|
let link = undefined;
|
||||||
|
|
||||||
|
const { additionalImages, thumbnailUrl } = getRelatedMedia({
|
||||||
|
productMedia: variant.product.media || [],
|
||||||
|
productVariantId: variant.id,
|
||||||
|
variantMediaMap: getVariantMediaMap({ variant }) || [],
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
link = renderHandlebarsTemplate({
|
link = renderHandlebarsTemplate({
|
||||||
data: {
|
data: {
|
||||||
|
@ -72,7 +79,8 @@ export const generateGoogleXmlFeed = ({
|
||||||
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
|
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
|
||||||
category: variant.product.category?.name || "unknown",
|
category: variant.product.category?.name || "unknown",
|
||||||
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
||||||
imageUrl: variant.product.thumbnail?.url || "",
|
imageUrl: thumbnailUrl,
|
||||||
|
additionalImageLinks: additionalImages,
|
||||||
material: attributes?.material,
|
material: attributes?.material,
|
||||||
color: attributes?.color,
|
color: attributes?.color,
|
||||||
brand: attributes?.brand,
|
brand: attributes?.brand,
|
||||||
|
@ -115,7 +123,7 @@ export const generateGoogleXmlFeed = ({
|
||||||
{
|
{
|
||||||
rss: [
|
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),
|
channel: channelData.concat(items),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -30,6 +30,7 @@ describe("GoogleFeedSettingsFetcher", () => {
|
||||||
},
|
},
|
||||||
attributeMapping: null,
|
attributeMapping: null,
|
||||||
titleTemplate: "{{ variant.name }}",
|
titleTemplate: "{{ variant.name }}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
return appConfig.serialize();
|
return appConfig.serialize();
|
||||||
|
@ -43,7 +44,7 @@ describe("GoogleFeedSettingsFetcher", () => {
|
||||||
|
|
||||||
const result = await instance.fetch("testChannel");
|
const result = await instance.fetch("testChannel");
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toStrictEqual({
|
||||||
storefrontUrl: "https://example.com",
|
storefrontUrl: "https://example.com",
|
||||||
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
|
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
|
||||||
s3BucketConfiguration: {
|
s3BucketConfiguration: {
|
||||||
|
@ -54,6 +55,7 @@ describe("GoogleFeedSettingsFetcher", () => {
|
||||||
},
|
},
|
||||||
attributeMapping: null,
|
attributeMapping: null,
|
||||||
titleTemplate: "{{ variant.name }}",
|
titleTemplate: "{{ variant.name }}",
|
||||||
|
imageSize: 1024,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { AuthData } from "@saleor/app-sdk/APL";
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
|
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 { createSettingsManager } from "../../lib/metadata-manager";
|
||||||
import { AppConfig } from "../app-configuration/app-config";
|
import { AppConfig } from "../app-configuration/app-config";
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ export class GoogleFeedSettingsFetcher {
|
||||||
static createFromAuthData(authData: AuthData) {
|
static createFromAuthData(authData: AuthData) {
|
||||||
return new GoogleFeedSettingsFetcher({
|
return new GoogleFeedSettingsFetcher({
|
||||||
settingsManager: new AppConfigMetadataManager(
|
settingsManager: new AppConfigMetadataManager(
|
||||||
createSettingsManager(GraphqlClientFactory.fromAuthData(authData))
|
createSettingsManager(GraphqlClientFactory.fromAuthData(authData)),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ export class GoogleFeedSettingsFetcher {
|
||||||
s3BucketConfiguration: appConfig.getS3Config(),
|
s3BucketConfiguration: appConfig.getS3Config(),
|
||||||
attributeMapping: appConfig.getAttributeMapping(),
|
attributeMapping: appConfig.getAttributeMapping(),
|
||||||
titleTemplate: appConfig.getTitleTemplate(),
|
titleTemplate: appConfig.getTitleTemplate(),
|
||||||
|
imageSize: appConfig.getImageSize(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}, {}) || {}
|
||||||
|
);
|
||||||
|
};
|
|
@ -11,6 +11,7 @@ describe("productToProxy", () => {
|
||||||
id: "product-id",
|
id: "product-id",
|
||||||
title: "title",
|
title: "title",
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -18,7 +19,7 @@ describe("productToProxy", () => {
|
||||||
{
|
{
|
||||||
"g:id": expect.arrayContaining([{ "#text": "variant-id" }]),
|
"g:id": expect.arrayContaining([{ "#text": "variant-id" }]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -34,6 +35,7 @@ describe("productToProxy", () => {
|
||||||
id: "product-id",
|
id: "product-id",
|
||||||
title: "title",
|
title: "title",
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -41,7 +43,7 @@ describe("productToProxy", () => {
|
||||||
{
|
{
|
||||||
"g:condition": expect.arrayContaining([{ "#text": "new" }]),
|
"g:condition": expect.arrayContaining([{ "#text": "new" }]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -55,6 +57,7 @@ describe("productToProxy", () => {
|
||||||
title: "title",
|
title: "title",
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
description: "Product description",
|
description: "Product description",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -62,7 +65,7 @@ describe("productToProxy", () => {
|
||||||
{
|
{
|
||||||
"g:description": expect.arrayContaining([{ "#text": "Product description" }]),
|
"g:description": expect.arrayContaining([{ "#text": "Product description" }]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -76,6 +79,7 @@ describe("productToProxy", () => {
|
||||||
id: "product-id",
|
id: "product-id",
|
||||||
title: "title",
|
title: "title",
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -83,7 +87,7 @@ describe("productToProxy", () => {
|
||||||
{
|
{
|
||||||
"g:google_product_category": expect.arrayContaining([{ "#text": "1" }]),
|
"g:google_product_category": expect.arrayContaining([{ "#text": "1" }]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -98,6 +102,7 @@ describe("productToProxy", () => {
|
||||||
title: "title",
|
title: "title",
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
link: "https://example.com/p/product-id",
|
link: "https://example.com/p/product-id",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -109,7 +114,7 @@ describe("productToProxy", () => {
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -124,6 +129,7 @@ describe("productToProxy", () => {
|
||||||
title: "title",
|
title: "title",
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
imageUrl: "https://image.example.com",
|
imageUrl: "https://image.example.com",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -131,7 +137,7 @@ describe("productToProxy", () => {
|
||||||
{
|
{
|
||||||
"g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]),
|
"g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -147,6 +153,7 @@ describe("productToProxy", () => {
|
||||||
variantId: "variant-id",
|
variantId: "variant-id",
|
||||||
imageUrl: "https://image.example.com",
|
imageUrl: "https://image.example.com",
|
||||||
price: "50.00 USD",
|
price: "50.00 USD",
|
||||||
|
additionalImageLinks: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.item).toEqual(
|
expect(result.item).toEqual(
|
||||||
|
@ -154,7 +161,7 @@ describe("productToProxy", () => {
|
||||||
{
|
{
|
||||||
"g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]),
|
"g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]),
|
||||||
},
|
},
|
||||||
])
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) {
|
if (p.price?.length) {
|
||||||
item.push({
|
item.push({
|
||||||
"g:price": [
|
"g:price": [
|
||||||
|
|
|
@ -7,6 +7,7 @@ export type ProductEntry = {
|
||||||
description?: string;
|
description?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
additionalImageLinks: string[];
|
||||||
condition?: "new" | "refurbished" | "used";
|
condition?: "new" | "refurbished" | "used";
|
||||||
price?: string;
|
price?: string;
|
||||||
salePrice?: string;
|
salePrice?: string;
|
||||||
|
|
|
@ -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 { updateCacheForConfigurations } from "./update-cache-for-configurations";
|
||||||
import { AuthData } from "@saleor/app-sdk/APL";
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { TRPCError } from "@trpc/server";
|
||||||
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
||||||
import { saleorApp } from "../../saleor-app";
|
import { saleorApp } from "../../saleor-app";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
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 { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
|
||||||
import { createSettingsManager } from "../../lib/metadata-manager";
|
import { createSettingsManager } from "../../lib/metadata-manager";
|
||||||
import { AppConfig } from "../app-configuration/app-config";
|
import { AppConfig } from "../app-configuration/app-config";
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { generateGoogleXmlFeed } from "../../../../../modules/google-feed/genera
|
||||||
import { fetchShopData } from "../../../../../modules/google-feed/fetch-shop-data";
|
import { fetchShopData } from "../../../../../modules/google-feed/fetch-shop-data";
|
||||||
import { CacheConfigurator } from "../../../../../modules/metadata-cache/cache-configurator";
|
import { CacheConfigurator } from "../../../../../modules/metadata-cache/cache-configurator";
|
||||||
import { createSettingsManager } from "../../../../../lib/metadata-manager";
|
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 { uploadFile } from "../../../../../modules/file-storage/s3/upload-file";
|
||||||
import { createS3ClientFromConfiguration } from "../../../../../modules/file-storage/s3/create-s3-client-from-configuration";
|
import { createS3ClientFromConfiguration } from "../../../../../modules/file-storage/s3/create-s3-client-from-configuration";
|
||||||
import { getFileDetails } from "../../../../../modules/file-storage/s3/get-file-details";
|
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 bucketConfiguration: RootConfig["s3"] | undefined;
|
||||||
let attributeMapping: RootConfig["attributeMapping"] | undefined;
|
let attributeMapping: RootConfig["attributeMapping"] | undefined;
|
||||||
let titleTemplate: RootConfig["titleTemplate"] | undefined;
|
let titleTemplate: RootConfig["titleTemplate"] | undefined;
|
||||||
|
let imageSize: RootConfig["imageSize"] | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
|
const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
|
||||||
|
@ -92,6 +93,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
bucketConfiguration = settings.s3BucketConfiguration;
|
bucketConfiguration = settings.s3BucketConfiguration;
|
||||||
attributeMapping = settings.attributeMapping;
|
attributeMapping = settings.attributeMapping;
|
||||||
titleTemplate = settings.titleTemplate;
|
titleTemplate = settings.titleTemplate;
|
||||||
|
imageSize = settings.imageSize;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn("The application has not been configured");
|
logger.warn("The application has not been configured");
|
||||||
|
|
||||||
|
@ -171,7 +173,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
let productVariants: GoogleFeedProductVariantFragment[] = [];
|
let productVariants: GoogleFeedProductVariantFragment[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
productVariants = await fetchProductData({ client, channel, cursors });
|
productVariants = await fetchProductData({ client, channel, cursors, imageSize });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return res.status(400).end();
|
return res.status(400).end();
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { useRouter } from "next/router";
|
||||||
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
|
import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
|
||||||
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
|
import { ConnectedAttributeMappingForm } from "../modules/app-configuration/attribute-mapping-form";
|
||||||
import { ConnectedTitleFormattingForm } from "../modules/app-configuration/title-formatting-form";
|
import { ConnectedTitleFormattingForm } from "../modules/app-configuration/title-formatting-form";
|
||||||
|
import { ConnectedImageConfigurationForm } from "../modules/app-configuration/image-configuration-form";
|
||||||
|
|
||||||
const ConfigurationPage: NextPage = () => {
|
const ConfigurationPage: NextPage = () => {
|
||||||
useChannelsExistenceChecking();
|
useChannelsExistenceChecking();
|
||||||
|
@ -214,6 +215,28 @@ const ConfigurationPage: NextPage = () => {
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
56
cspell.json
56
cspell.json
|
@ -1,36 +1,50 @@
|
||||||
{
|
{
|
||||||
"words": [
|
"words": [
|
||||||
"Adyen",
|
"Adyen",
|
||||||
|
"Afterpay",
|
||||||
"Algolia",
|
"Algolia",
|
||||||
|
"Appstore",
|
||||||
"Avalara",
|
"Avalara",
|
||||||
"AvaTax",
|
"AvaTax",
|
||||||
|
"backorder",
|
||||||
|
"Braintree",
|
||||||
|
"Builder.io",
|
||||||
|
"Clearpay",
|
||||||
|
"clsx",
|
||||||
|
"codegen",
|
||||||
|
"Contentful",
|
||||||
|
"DataDog",
|
||||||
|
"DatoCMS",
|
||||||
|
"hookform",
|
||||||
|
"Hygraph",
|
||||||
|
"jwks",
|
||||||
|
"Klarna",
|
||||||
"Klaviyo",
|
"Klaviyo",
|
||||||
|
"Mailchimp",
|
||||||
|
"maxage",
|
||||||
|
"metafield",
|
||||||
|
"metafields",
|
||||||
|
"Mollie",
|
||||||
|
"Nuvo",
|
||||||
|
"PayloadCMS",
|
||||||
|
"PayPal",
|
||||||
|
"preorder",
|
||||||
|
"RudderStack",
|
||||||
"Saleor",
|
"Saleor",
|
||||||
"SendGrid",
|
"SendGrid",
|
||||||
"Upstash",
|
|
||||||
"Vercel",
|
|
||||||
"Strapi",
|
|
||||||
"Contentful",
|
|
||||||
"DatoCMS",
|
|
||||||
"Builder.io",
|
|
||||||
"PayloadCMS",
|
|
||||||
"Storyblok",
|
|
||||||
"Hygraph",
|
|
||||||
"SMTP",
|
"SMTP",
|
||||||
"TaxJar",
|
"Storyblok",
|
||||||
|
"Strapi",
|
||||||
"Stripe",
|
"Stripe",
|
||||||
"Mollie",
|
"syncpack",
|
||||||
"PayPal",
|
"TaxJar",
|
||||||
"Klarna",
|
"testid",
|
||||||
"Braintree",
|
"trpc",
|
||||||
"Mailchimp",
|
|
||||||
"Twilio",
|
"Twilio",
|
||||||
"RudderStack",
|
"Undiscounted",
|
||||||
"DataDog",
|
"Upstash",
|
||||||
"Clearpay",
|
"urql",
|
||||||
"Afterpay",
|
"Vercel"
|
||||||
"Nuvo",
|
|
||||||
"Appstore"
|
|
||||||
],
|
],
|
||||||
"ignorePaths": ["node_modules", "package.json", "pnpm-lock.yaml", ".gitignore"]
|
"ignorePaths": ["node_modules", "package.json", "pnpm-lock.yaml", ".gitignore"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue