Add Google Category Mapping for the feed (#325)
* Add Google Category Mapping for the feed * Update the link href
This commit is contained in:
parent
5fad97c6d2
commit
f58043f72b
21 changed files with 14600 additions and 17 deletions
5
.changeset/small-parrots-listen.md
Normal file
5
.changeset/small-parrots-listen.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-products-feed": patch
|
||||
---
|
||||
|
||||
Add Google Products Category mapping
|
|
@ -0,0 +1,11 @@
|
|||
fragment CategoryWithMappingFragment on Category {
|
||||
id
|
||||
parent{
|
||||
name
|
||||
parent{
|
||||
name
|
||||
}
|
||||
}
|
||||
name
|
||||
googleCategoryId: metafield(key: "google_category_id")
|
||||
}
|
|
@ -23,6 +23,7 @@ fragment GoogleFeedProductVariant on ProductVariant {
|
|||
category {
|
||||
id
|
||||
name
|
||||
googleCategoryId: metafield(key: "google_category_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
mutation UpdateCategoryMapping($id: ID!, $googleCategoryId: String!) {
|
||||
updateMetadata(id: $id, input: {key: "google_category_id", value: $googleCategoryId}) {
|
||||
errors {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
query FetchCategoriesWithMapping{
|
||||
categories(first: 10){
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges{
|
||||
node{
|
||||
...CategoryWithMappingFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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 || "",
|
||||
});
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>;
|
|
@ -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
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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: [],
|
||||
|
|
25
apps/products-feed/src/pages/categories.tsx
Normal file
25
apps/products-feed/src/pages/categories.tsx
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue