Add Google Category Mapping for the feed (#325)

* Add Google Category Mapping for the feed

* Update the link href
This commit is contained in:
Krzysztof Wolski 2023-03-24 15:02:40 +01:00 committed by GitHub
parent 5fad97c6d2
commit f58043f72b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 14600 additions and 17 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-products-feed": patch
---
Add Google Products Category mapping

View file

@ -0,0 +1,11 @@
fragment CategoryWithMappingFragment on Category {
id
parent{
name
parent{
name
}
}
name
googleCategoryId: metafield(key: "google_category_id")
}

View file

@ -23,6 +23,7 @@ fragment GoogleFeedProductVariant on ProductVariant {
category {
id
name
googleCategoryId: metafield(key: "google_category_id")
}
}
}

View file

@ -0,0 +1,7 @@
mutation UpdateCategoryMapping($id: ID!, $googleCategoryId: String!) {
updateMetadata(id: $id, input: {key: "google_category_id", value: $googleCategoryId}) {
errors {
message
}
}
}

View file

@ -0,0 +1,13 @@
query FetchCategoriesWithMapping{
categories(first: 10){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...CategoryWithMappingFragment
}
}
}
}

View file

@ -15,6 +15,7 @@
"schemaVersion": "3.10"
},
"dependencies": {
"@hookform/resolvers": "^2.9.10",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",

View file

@ -39,8 +39,7 @@ export const generateGoogleXmlFeed = ({
description: v.product.seoDescription || v.product.description,
availability: v.quantityAvailable && v.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
category: v.product.category?.name || "unknown",
// TODO: prepare category mapping for google categories
googleProductCategory: "",
googleProductCategory: v.product.category?.googleCategoryId || "",
price: price,
imageUrl: v.product.thumbnail?.url || "",
});

View file

@ -24,19 +24,7 @@ export const productToProxy = (p: ProductEntry) => {
},
],
},
/**
* Consider implementing categories
* https://support.google.com/merchants/answer/6324436?hl=en
*
* However, this field is optional and google seems to automatically match category
*/
// {
// "g:google_product_category": [
// {
// "#text": p.googleProductCategory,
// },
// ],
// },
{
"g:availability": [
{
@ -63,6 +51,22 @@ export const productToProxy = (p: ProductEntry) => {
});
}
/**
* This field is optional and Google automatically match category if not has been provided
*
* https://support.google.com/merchants/answer/6324436?hl=en
*/
if (p.googleProductCategory?.length) {
item.push({
"g:google_product_category": [
{
"#text": p.googleProductCategory,
},
],
});
}
if (p.storefrontUrlTemplate?.length) {
item.push({
link: [

View file

@ -9,6 +9,7 @@ import { AppColumnsLayout } from "../../ui/app-columns-layout";
import { FeedPreviewCard } from "./feed-preview-card";
import { Instructions } from "./instructions";
import SideMenu from "./side-menu";
import { CategoryMappingForm } from "../../category-mapping/ui/category-mapping-form";
const useStyles = makeStyles((theme) => {
return {

View file

@ -0,0 +1,8 @@
import { z } from "zod";
export const SetCategoryMappingInputSchema = z.object({
categoryId: z.string().min(0),
googleCategoryId: z.string().optional(),
});
export type SetCategoryMappingInputType = z.infer<typeof SetCategoryMappingInputSchema>;

View file

@ -0,0 +1,59 @@
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { logger as pinoLogger } from "../../lib/logger";
import { SetCategoryMappingInputSchema } from "./category-mapping-input-schema";
import {
FetchCategoriesWithMappingDocument,
UpdateCategoryMappingDocument,
} from "../../../generated/graphql";
import { TRPCError } from "@trpc/server";
export const categoryMappingRouter = router({
/**
* Get all the category mappings to Google categories from its public metadata
*/
getCategoryMappings: protectedClientProcedure.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("categoriesRouter.getCategoryMappings called");
const result = await ctx.apiClient.query(FetchCategoriesWithMappingDocument, {}).toPromise();
const categories = result.data?.categories?.edges?.map((edge) => edge.node) || [];
if (result.error) {
logger.error(`Error during the GraphqlAPI call: ${result.error.message}`);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch the categories",
});
}
return categories;
}),
/**
* Sets the Google category mapping for a given category in its public metadata
*/
setCategoryMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(SetCategoryMappingInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("categoriesRouter.setCategoryMapping called");
const { error } = await ctx.apiClient
.mutation(UpdateCategoryMappingDocument, {
id: input.categoryId,
googleCategoryId: input.googleCategoryId || "",
})
.toPromise();
if (error) {
logger.error(`Error during the GraphqlAPI call: ${error.message}`);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't save the category",
});
}
return;
}),
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,127 @@
import { Controller, useForm } from "react-hook-form";
import {
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
TextFieldProps,
Typography,
} from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { zodResolver } from "@hookform/resolvers/zod";
import {
SetCategoryMappingInputSchema,
SetCategoryMappingInputType,
} from "../category-mapping-input-schema";
import { CategoryWithMappingFragmentFragment } from "../../../../generated/graphql";
import { GoogleProductCategories } from "../google-product-categories";
import { trpcClient } from "../../trpc/trpc-client";
const useStyles = makeStyles({
field: {
marginBottom: 20,
},
form: {
padding: 20,
},
channelName: {
fontFamily: "monospace",
cursor: "pointer",
},
});
type CategoryMappingFormProps = {
category: CategoryWithMappingFragmentFragment;
};
export const CategoryMappingForm = ({ category }: CategoryMappingFormProps) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const { control, handleSubmit, formState } = useForm<SetCategoryMappingInputType>({
defaultValues: {
categoryId: category.id,
googleCategoryId: category.googleCategoryId || undefined,
},
resolver: zodResolver(SetCategoryMappingInputSchema),
});
const { mutate, isLoading } = trpcClient.categoryMapping.setCategoryMapping.useMutation({
onError() {
appBridge?.dispatch(
actions.Notification({
title: "Could not save the category mapping",
status: "error",
})
);
},
onSuccess() {
appBridge?.dispatch(
actions.Notification({
title: "Success",
status: "success",
})
);
},
});
const categoryBreadcrumbs = [category.parent?.parent?.name, category.parent?.name, category.name]
.filter((segment) => segment)
.join(" > ");
return (
<form
onSubmit={handleSubmit((data, event) => {
mutate(data);
})}
className={styles.form}
>
<Typography variant="h4" paragraph>
{categoryBreadcrumbs}
</Typography>
<Grid container spacing={2}>
<Grid item xs={10}>
<Controller
control={control}
name="googleCategoryId"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} fullWidth>
<InputLabel>Google Product Category</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No configuration
</MenuItem>
{GoogleProductCategories.map((choice) => (
<MenuItem key={choice.id} value={choice.id.toString()}>
{choice.name}
</MenuItem>
))}
</Select>
</FormControl>
);
}}
/>
</Grid>
<Grid item xs={2}>
<Button
type="submit"
fullWidth
variant="primary"
disabled={isLoading || !formState.isDirty}
>
Save
</Button>
</Grid>
</Grid>
</form>
);
};

View file

@ -0,0 +1,18 @@
import { trpcClient } from "../../trpc/trpc-client";
import { Paper } from "@material-ui/core";
import React from "react";
import { CategoryMappingForm } from "./category-mapping-form";
export const CategoryMapping = () => {
const categories = trpcClient.categoryMapping.getCategoryMappings.useQuery();
return (
<Paper elevation={0}>
{categories.data?.length
? categories.data.map((category) => (
<CategoryMappingForm category={category} key={category.id} />
))
: null}
</Paper>
);
};

View file

@ -0,0 +1,55 @@
import { Link, Paper, Typography } from "@material-ui/core";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { makeStyles } from "@saleor/macaw-ui";
const useStyles = makeStyles((theme) => {
return {
instructionsContainer: {
padding: 15,
},
};
});
export const Instructions = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
return (
<Paper elevation={0} className={styles.instructionsContainer}>
<Typography paragraph variant="h4">
Using the feed
</Typography>
<Typography paragraph>
Configure your Google Merchant account to schedule fetches of the feed. Instructions can be
found{" "}
<Link
href="https://support.google.com/merchants/answer/1219255"
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
to: "https://support.google.com/merchants/answer/1219255",
newContext: true,
})
);
}}
>
here
</Link>
.
</Typography>
<Typography paragraph variant="h4">
URL templates
</Typography>
<Typography paragraph>
URLs to products in your storefront are generated dynamically, based on the product data.
For example, the template
</Typography>
<code>{"https://example.com/product/{productSlug}"}</code>
<Typography paragraph>Will produce</Typography>
<code>{"https://example.com/product/red-t-shirt"}</code>
<Typography paragraph>Available fields: productId, productSlug, variantId</Typography>
</Paper>
);
};

View file

@ -1,10 +1,12 @@
import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server";
import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
import { categoryMappingRouter } from "../category-mapping/category-mapping.router";
export const appRouter = router({
channels: channelsRouter,
appConfiguration: appConfigurationRouter,
categoryMapping: categoryMappingRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,45 @@
import React, { PropsWithChildren } from "react";
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
import { useRouter } from "next/router";
const useStyles = makeStyles((theme) => ({
appContainer: {
marginTop: theme.spacing(3),
marginLeft: theme.spacing(3),
},
}));
type Props = PropsWithChildren<{}>;
export const ConfigurationPageBaseLayout = ({ children }: Props) => {
const styles = useStyles();
const router = useRouter();
const tabs = [
{
key: "channels",
label: "Channels",
url: "/configuration",
},
{ key: "categories", label: "Category mapping", url: "/categories" },
];
const activePath = tabs.find((tab) => router.pathname === tab.url);
const navigateToTab = (value: string) => {
const redirectionUrl = tabs.find((tab) => tab.key === value)?.url;
if (redirectionUrl) {
router.push(redirectionUrl);
}
};
return (
<div className={styles.appContainer}>
<PageTabs value={activePath} onChange={navigateToTab}>
{tabs.map((tab) => (
<PageTab key={tab.key} value={tab.key} label={tab.label} />
))}
</PageTabs>
{children}
</div>
);
};

View file

@ -9,7 +9,7 @@ export default createManifestHandler({
name: "Product Feed",
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: context.appBaseUrl,
permissions: [],
permissions: ["MANAGE_PRODUCTS"],
id: "saleor.app.products-feed",
version: packageJson.version,
webhooks: [],

View file

@ -0,0 +1,25 @@
import { NextPage } from "next";
import React, { useEffect } from "react";
import { trpcClient } from "../modules/trpc/trpc-client";
import { useRouter } from "next/router";
import { ConfigurationPageBaseLayout } from "../modules/ui/configuration-page-base-layout";
import { CategoryMapping } from "../modules/category-mapping/ui/category-mapping";
const ConfigurationPage: NextPage = () => {
const channels = trpcClient.channels.fetch.useQuery();
const router = useRouter();
useEffect(() => {
if (channels.isSuccess && channels.data.length === 0) {
router.push("/not-ready");
}
}, [channels.data, channels.isSuccess]);
return (
<ConfigurationPageBaseLayout>
<CategoryMapping />
</ConfigurationPageBaseLayout>
);
};
export default ConfigurationPage;

View file

@ -3,6 +3,7 @@ import React, { useEffect } from "react";
import { ChannelsConfiguration } from "../modules/app-configuration/ui/channels-configuration";
import { trpcClient } from "../modules/trpc/trpc-client";
import { useRouter } from "next/router";
import { ConfigurationPageBaseLayout } from "../modules/ui/configuration-page-base-layout";
const ConfigurationPage: NextPage = () => {
const channels = trpcClient.channels.fetch.useQuery();
@ -14,7 +15,11 @@ const ConfigurationPage: NextPage = () => {
}
}, [channels.data, channels.isSuccess]);
return <ChannelsConfiguration />;
return (
<ConfigurationPageBaseLayout>
<ChannelsConfiguration />
</ConfigurationPageBaseLayout>
);
};
export default ConfigurationPage;

View file

@ -619,6 +619,7 @@ importers:
'@graphql-codegen/typescript-urql': ^3.7.3
'@graphql-codegen/urql-introspection': 2.2.1
'@graphql-typed-document-node/core': ^3.1.2
'@hookform/resolvers': ^2.9.10
'@material-ui/core': ^4.12.4
'@material-ui/icons': ^4.11.3
'@material-ui/lab': 4.0.0-alpha.61
@ -662,6 +663,7 @@ importers:
vitest: ^0.27.1
zod: ^3.20.2
dependencies:
'@hookform/resolvers': 2.9.11_react-hook-form@7.43.1
'@material-ui/core': 4.12.4_5ndqzdd6t4rivxsukjv3i3ak2q
'@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te
'@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te