🔥 Product Feed - New macaw & production prep (#512)

* Remove utils and use shared ones

* [skip ci]

* Remove legacy Macaw and mui, replace components so app builds

* Channels existence fetch hook

* Remove shop info fetching

* Remove legacy layout and merge pages

* Index page sections

* re-Implemented s3 form

* s3 Form validation

* New config logic

* wi

* Remove old config, add router to update s3

* Channels list with form

* Update cache configurator

* Feed preview card

* Flat structure, channels router

* Connect channels urls config

* Fix fetching webhook config

* Refactor lib folder

* Category mapping

* Categories mapping preview

* Improve buttons

* Enable cache on channel config saving

* wip webhooks

* Add webhooks

* Webhook refactor

* Refactor createClient

* Tests

* Clean imports

* Add logs

* Review fixes

* Changeset

* Small xml improvement

* Add tests batch

* Add tests

* Hide amazon secret with input password

* CR fixes

* CategoriesFetcher for recursive calls

* Implement categories fetcher

* Update lockfile after rebase with main

* Change category map to override instead conifg
This commit is contained in:
Lukasz Ostrowski 2023-06-01 13:33:07 +02:00 committed by GitHub
parent 0c2fc65ac6
commit 182bdd6b32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 2340 additions and 1458 deletions

View file

@ -0,0 +1,12 @@
---
"saleor-app-products-feed": minor
---
This version introduces major update of the App UI:
- Added Macaw UI / next
- Rebuilt app's UI to match modern Saleor guidelines
- Moved S3 form to be configured once, globally
- Added tests and refactored App configuration/metadata (incompatible with previous version)
- Product-related webhooks are now refreshing cache responsible on indexing large databases
App was not officially released yet, hence only minor update is introduced

View file

@ -0,0 +1 @@
APP_DEBUG=info

View file

@ -0,0 +1,8 @@
fragment ProductVariantWebhookPayload on ProductVariant {
channel
channelListings {
channel {
slug
}
}
}

View file

@ -0,0 +1,8 @@
fragment ProductWebhookPayload on Product {
channel
channelListings {
channel {
slug
}
}
}

View file

@ -1,5 +1,5 @@
query FetchCategoriesWithMapping{ query FetchCategoriesWithMapping($cursor: String){
categories(first: 10){ categories(first: 100, after: $cursor){
pageInfo{ pageInfo{
hasNextPage hasNextPage
endCursor endCursor

View file

@ -0,0 +1,9 @@
subscription ProductCreated {
event {
... on ProductCreated {
product {
...ProductWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductDeleted {
event {
... on ProductDeleted {
product {
...ProductWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductUpdated {
event {
... on ProductUpdated {
product {
...ProductWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductVariantCreated {
event {
... on ProductVariantCreated {
productVariant {
...ProductVariantWebhookPayload
}
}
}
}

View file

@ -0,0 +1,10 @@
subscription ProductVariantDeleted {
event {
... on ProductVariantDeleted {
productVariant {
...ProductVariantWebhookPayload
}
}
}
}

View file

@ -0,0 +1,9 @@
subscription ProductVariantUpdated {
event {
... on ProductVariantUpdated {
productVariant {
...ProductVariantWebhookPayload
}
}
}
}

View file

@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
module.exports = { module.exports = {
reactStrictMode: true, reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared"], transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
}; };
const isSentryEnvAvailable = const isSentryEnvAvailable =

View file

@ -17,13 +17,11 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.332.0", "@aws-sdk/client-s3": "^3.332.0",
"@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",
"@saleor/app-sdk": "0.39.1", "@saleor/app-sdk": "0.39.1",
"@saleor/apps-shared": "workspace:*", "@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "^0.7.2", "@saleor/react-hook-form-macaw": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.84",
"@sentry/nextjs": "^7.52.1", "@sentry/nextjs": "^7.52.1",
"@tanstack/react-query": "^4.24.2", "@tanstack/react-query": "^4.24.2",
"@trpc/client": "^10.9.0", "@trpc/client": "^10.9.0",
@ -43,13 +41,15 @@
"pino-pretty": "^10.0.0", "pino-pretty": "^10.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.43.0", "react-hook-form": "^7.43.9",
"react-is": "^18.2.0", "react-is": "^18.2.0",
"react-query": "^3.39.3",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"vite": "4.3.9", "vite": "4.3.9",
"vitest": "0.31.3", "vitest": "0.31.3",
"zod": "^3.20.2" "zod": "^3.20.2",
"@hookform/resolvers": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "3.2.2", "@graphql-codegen/cli": "3.2.2",

View file

@ -5,6 +5,7 @@ import {
dedupExchange, dedupExchange,
fetchExchange, fetchExchange,
} from "urql"; } from "urql";
import { AuthData } from "@saleor/app-sdk/APL";
interface IAuthState { interface IAuthState {
token: string; token: string;
@ -46,3 +47,11 @@ export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAu
fetchExchange, fetchExchange,
], ],
}); });
export const GraphqlClientFactory = {
fromAuthData(authData: Pick<AuthData, "token" | "saleorApiUrl">) {
return createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
},
};

View file

@ -1,34 +0,0 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { appRouter } from "../../modules/trpc/trpc-app-router";
interface GetGoogleFeedSettingsArgs {
authData: AuthData;
channel: string;
}
export const getGoogleFeedSettings = async ({ authData, channel }: GetGoogleFeedSettingsArgs) => {
const caller = appRouter.createCaller({
appId: authData.appId,
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
ssr: true,
});
const configurations = await caller.appConfiguration.fetch();
const configuration = configurations.shopConfigPerChannel[channel];
const storefrontUrl = configuration.urlConfiguration.storefrontUrl;
const productStorefrontUrl = configuration.urlConfiguration.productStorefrontUrl;
if (!storefrontUrl.length || !productStorefrontUrl.length) {
throw new Error("The application has not been configured");
}
return {
storefrontUrl,
productStorefrontUrl,
s3BucketConfiguration: configuration.s3BucketConfiguration,
};
};

View file

@ -1,7 +0,0 @@
export function isInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}

View file

@ -1,4 +1,4 @@
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql"; import { Client, gql } from "urql";
import { import {
FetchAppDetailsDocument, FetchAppDetailsDocument,

View file

@ -1,19 +0,0 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
/**
* Saleor App can be rendered only as a Saleor Dashboard iframe.
* All content is rendered after Dashboard exchanges auth with the app.
* Hence, there is no reason to render app server side.
*
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
* of using dynamic() calls
*
* You can use this wrapper selectively for some pages or remove it completely.
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
*/
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -1,33 +1,25 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui"; import { useTheme } from "@saleor/macaw-ui/next";
import { memo, useEffect } from "react"; import { useEffect } from "react";
/** // todo move to shared
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard, export function ThemeSynchronizer() {
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge(); const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme(); const { setTheme } = useTheme();
useEffect(() => { useEffect(() => {
if (!setTheme || !appBridgeState?.theme) { if (!setTheme || !appBridgeState?.theme) {
return; return;
} }
if (themeType !== appBridgeState?.theme) { if (appBridgeState.theme === "light") {
setTheme(appBridgeState.theme); setTheme("defaultLight");
/**
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
* TODO Fix me when Macaw 2.0 is shipped
*/
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
} }
}, [appBridgeState?.theme, setTheme, themeType]);
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null; return null;
} }
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

@ -1,62 +0,0 @@
import { AppConfig, SellerShopConfig } from "./app-config";
const getDefaultEmptyUrlConfiguration = (): SellerShopConfig["urlConfiguration"] => ({
storefrontUrl: "",
productStorefrontUrl: "",
});
const getDefaultEmptyPerChannelConfiguration = (): SellerShopConfig => ({
urlConfiguration: getDefaultEmptyUrlConfiguration(),
s3BucketConfiguration: undefined, //getDefaultEmptyS3BucketConfiguration(),
});
const getChannelUrlConfiguration =
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
try {
return appConfig?.shopConfigPerChannel[channelSlug].urlConfiguration ?? null;
} catch (e) {
return null;
}
};
const setChannelUrlConfiguration =
(appConfig: AppConfig | null | undefined) =>
(channelSlug: string) =>
(urlConfiguration: SellerShopConfig["urlConfiguration"]) => {
const appConfigNormalized = structuredClone(appConfig) ?? { shopConfigPerChannel: {} };
appConfigNormalized.shopConfigPerChannel[channelSlug] ??=
getDefaultEmptyPerChannelConfiguration();
appConfigNormalized.shopConfigPerChannel[channelSlug].urlConfiguration = urlConfiguration;
return appConfigNormalized;
};
const getChannelS3BucketConfiguration =
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
try {
return appConfig?.shopConfigPerChannel[channelSlug].s3BucketConfiguration ?? null;
} catch (e) {
return null;
}
};
const setChannelS3BucketConfiguration =
(appConfig: AppConfig | null | undefined) =>
(channelSlug: string) =>
(s3BucketConfiguration: SellerShopConfig["s3BucketConfiguration"]) => {
const appConfigNormalized = structuredClone(appConfig) ?? { shopConfigPerChannel: {} };
appConfigNormalized.shopConfigPerChannel[channelSlug].s3BucketConfiguration =
s3BucketConfiguration;
return appConfigNormalized;
};
export const AppConfigContainer = {
getChannelUrlConfiguration,
setChannelUrlConfiguration,
getChannelS3BucketConfiguration,
setChannelS3BucketConfiguration,
};

View file

@ -1,6 +0,0 @@
import { z } from "zod";
import { sellerShopConfigSchema } from "./app-config";
export const appConfigInputSchema = z.object({
shopConfigPerChannel: z.record(sellerShopConfigSchema),
});

View file

@ -0,0 +1,18 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export class AppConfigMetadataManager {
private readonly metadataKey = "app-config-v1";
constructor(private settingsManager: SettingsManager) {}
get() {
return this.settingsManager.get(this.metadataKey);
}
set(stringMetadata: string) {
return this.settingsManager.set({
key: this.metadataKey,
value: stringMetadata,
});
}
}

View file

@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import { AppConfig } from "./app-config";
describe("AppConfig", function () {
describe("Construction", () => {
it("Constructs empty state", () => {
const instance = new AppConfig();
expect(instance.getRootConfig()).toEqual({ channelConfig: {}, s3: null });
});
it("Constructs from initial state", () => {
const instance = new AppConfig({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
},
},
},
});
expect(instance.getRootConfig()).toEqual({
s3: {
bucketName: "bucket",
secretAccessKey: "secret",
accessKeyId: "access",
region: "region",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
},
},
},
});
});
it("Fails construction if invalid state provided", () => {
expect(
() =>
new AppConfig({
// @ts-expect-error
foo: "bar",
})
).toThrow();
});
it("Parses from string", () => {
const instance1 = new AppConfig({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {},
});
const serialized = instance1.serialize();
const instance2 = AppConfig.parse(serialized);
expect(instance2.getRootConfig()).toEqual({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {},
});
});
});
describe("getters", () => {
const instance = new AppConfig({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
},
},
},
});
it("getRootConfig returns root config data", () => {
expect(instance.getRootConfig()).toEqual({
s3: {
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
},
channelConfig: {
test: {
storefrontUrls: {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
},
},
},
});
});
it("getUrlsForChannel gets data for given channel or undefined if doesnt exist", () => {
expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
});
expect(instance.getUrlsForChannel("not-existing")).toBeUndefined();
});
it("getS3Config gets s3 data", () => {
expect(instance.getS3Config()).toEqual({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
});
});
describe("setters", () => {
it("setS3 sets valid config to s3 key and rejects invalid config", () => {
const instance = new AppConfig();
instance.setS3({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
expect(instance.getS3Config()).toEqual({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
// @ts-expect-error
expect(() => instance.setS3({ foo: "bar" })).toThrow();
});
it("setChannelUrls sets valid config to channelConfig[channelSlug] and rejects invalid config", () => {
const instance = new AppConfig();
instance.setChannelUrls("test", {
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
});
expect(instance.getUrlsForChannel("test")).toEqual({
productStorefrontUrl: "https://example.com",
storefrontUrl: "https://example.com/p/{productFeed}",
});
// @ts-expect-error
expect(() => instance.setChannelUrls("channel", "foo")).toThrow();
});
});
it("Serializes to string", () => {
const instance = new AppConfig();
instance.setS3({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
const serialized = instance.serialize();
/**
* Only way to check if serialization works is to deserialize. Order of serialized fields is not given so string cant be asserted.
* JSON.parse can be used but its testing implementation details
*/
expect(AppConfig.parse(serialized).getS3Config()).toEqual({
region: "region",
bucketName: "bucket",
accessKeyId: "access",
secretAccessKey: "secret",
});
});
});

View file

@ -1,34 +1,91 @@
import { z } from "zod"; import { z } from "zod";
import { UrlConfiguration } from "./url-configuration";
export const s3BucketConfigurationSchema = z.object({ const s3ConfigSchema = z.object({
bucketName: z.string(), bucketName: z.string().min(1),
secretAccessKey: z.string(), secretAccessKey: z.string().min(1),
accessKeyId: z.string(), accessKeyId: z.string().min(1),
region: z.string(), region: z.string().min(1),
}); });
export type S3BucketConfiguration = z.infer<typeof s3BucketConfigurationSchema>; const urlConfigurationSchema = z.object({
storefrontUrl: z.string().min(1).url(),
export const urlConfigurationSchema = z.object({ productStorefrontUrl: z.string().min(1).url(),
/**
* min() to allow empty strings
*/
storefrontUrl: z.string().min(0),
productStorefrontUrl: z.string().min(0),
}); });
export type UrlConfiguration = z.infer<typeof urlConfigurationSchema>; const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(),
export const sellerShopConfigSchema = z.object({ channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
urlConfiguration: urlConfigurationSchema,
s3BucketConfiguration: s3BucketConfigurationSchema.optional(),
}); });
export type SellerShopConfig = z.infer<typeof sellerShopConfigSchema>; export const AppConfigSchema = {
root: rootAppConfigSchema,
export type ShopConfigPerChannelSlug = Record<string, SellerShopConfig>; s3Bucket: s3ConfigSchema,
channelUrls: urlConfigurationSchema,
export type AppConfig = {
shopConfigPerChannel: ShopConfigPerChannelSlug;
}; };
export type RootConfig = z.infer<typeof rootAppConfigSchema>;
export type ChannelUrlsConfig = z.infer<typeof AppConfigSchema.channelUrls>;
export class AppConfig {
private rootData: RootConfig = {
channelConfig: {},
s3: null,
};
constructor(initialData?: RootConfig) {
if (initialData) {
this.rootData = rootAppConfigSchema.parse(initialData);
}
}
static parse(serializedSchema: string) {
return new AppConfig(JSON.parse(serializedSchema));
}
getRootConfig() {
return this.rootData;
}
serialize() {
return JSON.stringify(this.rootData);
}
setS3(s3Config: z.infer<typeof s3ConfigSchema>) {
try {
this.rootData.s3 = s3ConfigSchema.parse(s3Config);
return this;
} catch (e) {
console.error(e);
throw new Error("Invalid S3 config provided");
}
}
setChannelUrls(channelSlug: string, urlsConfig: z.infer<typeof urlConfigurationSchema>) {
try {
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
this.rootData.channelConfig[channelSlug] = {
storefrontUrls: parsedConfig,
};
} catch (e) {
console.error(e);
throw new Error("Invalid payload");
}
}
getUrlsForChannel(channelSlug: string) {
try {
return this.rootData.channelConfig[channelSlug].storefrontUrls;
} catch (e) {
return undefined;
}
}
getS3Config() {
return this.rootData.s3;
}
}

View file

@ -1,44 +1,75 @@
import { router } from "../trpc/trpc-server"; import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure"; import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { PrivateMetadataAppConfigurator } from "./app-configurator";
import { createSettingsManager } from "../../lib/metadata-manager";
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { appConfigInputSchema } from "./app-config-input-schema";
import { GetAppConfigurationService } from "./get-app-configuration.service";
import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations"; import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
import { AppConfigSchema } from "./app-config";
import { z } from "zod";
export const appConfigurationRouter = router({ export const appConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => { /**
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); * Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query
*/
fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig }, input }) => {
return getConfig().then((c) => {
logger.debug("Fetched config");
logger.debug("appConfigurationRouter.fetch called"); return c.getRootConfig();
});
return new GetAppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}), }),
setAndReplace: protectedClientProcedure setS3BucketConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appConfigInputSchema) .input(AppConfigSchema.s3Bucket)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); const logger = createLogger({ saleorApiUrl: saleorApiUrl });
logger.debug(input, "appConfigurationRouter.setAndReplace called with input"); logger.debug(input, "Input");
const appConfigurator = new PrivateMetadataAppConfigurator( const config = await getConfig();
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
await updateCacheForConfigurations({ config.setS3(input);
client: ctx.apiClient,
configurations: input,
saleorApiUrl: ctx.saleorApiUrl,
});
await appConfigurator.setConfig(input); await appConfigMetadataManager.set(config.serialize());
logger.debug("Config saved");
return null; return null;
}), }),
setChannelsUrls: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(
z.object({
channelSlug: z.string(),
urls: AppConfigSchema.channelUrls,
})
)
.mutation(
async ({
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
input,
}) => {
const config = await getConfig();
/**
* TODO Check if this has to run, once its cached, it should be invalidated by webhooks only.
*
* But this operation isnt expensive and users will not continously save this form
*/
await updateCacheForConfigurations({
client: apiClient,
channelsSlugs: [input.channelSlug],
saleorApiUrl: saleorApiUrl,
});
logger.debug({ channel: input.channelSlug }, "Updated cache for channel");
config.setChannelUrls(input.channelSlug, input.urls);
await appConfigMetadataManager.set(config.serialize());
logger.debug("Saved config");
return null;
}
),
}); });

View file

@ -1,35 +0,0 @@
import { AppConfig } from "./app-config";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface AppConfigurator {
setConfig(config: AppConfig): Promise<void>;
getConfig(): Promise<AppConfig | undefined>;
}
export class PrivateMetadataAppConfigurator implements AppConfigurator {
private metadataKey = "app-config";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<AppConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return undefined;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, can't be parsed");
}
});
}
setConfig(config: AppConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,105 @@
import { Accordion, Box, Button, Divider, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import { trpcClient } from "../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AppConfigSchema, ChannelUrlsConfig } from "./app-config";
import { FeedPreviewCard } from "./feed-preview-card";
import { useDashboardNotification } from "@saleor/apps-shared";
const ChannelConfigForm = ({ channelSlug, ...props }: PropsWithBox<{ channelSlug: string }>) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: appConfig } = trpcClient.appConfiguration.fetch.useQuery();
const channelConfig = appConfig?.channelConfig[channelSlug];
const { mutate } = trpcClient.appConfiguration.setChannelsUrls.useMutation({
onSuccess() {
notifySuccess("Success");
},
onError() {
notifyError("Failed saving configuration.", "Refresh the page and try again");
},
});
const { control, handleSubmit } = useForm<ChannelUrlsConfig>({
resolver: zodResolver(AppConfigSchema.channelUrls),
defaultValues: {
productStorefrontUrl: channelConfig?.storefrontUrls.productStorefrontUrl ?? "",
storefrontUrl: channelConfig?.storefrontUrls.storefrontUrl ?? "",
},
});
return (
<Box
onSubmit={handleSubmit((data) => {
mutate({
urls: data,
channelSlug,
});
})}
as={"form"}
display={"grid"}
gap={6}
{...props}
>
<Text variant={"heading"} as={"h2"} marginBottom={4}>
Configure channel URLs
</Text>
<Input
label={"Storefront URL"}
placeholder={"https://myshop.com"}
helperText={"Public address of your storefront"}
name={"storefrontUrl"}
control={control}
/>
<Input
label={"Storefront product URL"}
placeholder={"https://myshop.com/product/{productSlug}"}
name={"productStorefrontUrl"}
control={control}
helperText={
"Public address of your storefront product page. Use placeholder tags to inject dynamic product data"
}
/>
<Box display={"flex"} justifyContent={"flex-end"}>
<Button type={"submit"} __width={"fit-content"}>
Save channel settings
</Button>
</Box>
</Box>
);
};
export const ChannelsConfigAccordion = () => {
const { data, isLoading } = trpcClient.channels.fetch.useQuery();
if (isLoading) {
return <Text>Loading...</Text>;
}
return (
<Accordion display={"grid"} gap={8}>
{data?.map((channel) => (
<Accordion.Item
key={channel.id}
value={channel.id}
borderColor={"neutralHighlight"}
borderWidth={1}
borderBottomStyle={"solid"}
paddingBottom={8}
>
<Accordion.Trigger>
<Text>{channel.name}</Text>
</Accordion.Trigger>
<Accordion.Content>
<ChannelConfigForm margin={8} channelSlug={channel.slug} />
<Divider />
<FeedPreviewCard channelSlug={channel.slug} margin={8} marginTop={12} />
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
);
};

View file

@ -1,5 +1,5 @@
import { Client, gql } from "urql"; import { Client, gql } from "urql";
import { FetchChannelsDocument } from "../../../generated/graphql"; import { FetchChannelsDocument } from "../../../../generated/graphql";
gql` gql`
fragment Channel on Channel { fragment Channel on Channel {

View file

@ -0,0 +1,16 @@
import { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { ChannelsFetcher } from "./channels-fetcher";
import { ChannelFragment } from "../../../../generated/graphql";
export const channelsRouter = router({
fetch: protectedClientProcedure.query(
async ({ ctx: { logger, apiClient }, input }): Promise<ChannelFragment[]> => {
const fetcher = new ChannelsFetcher(apiClient);
logger.debug("Will fetch channels");
return fetcher.fetchChannels().then((channels) => channels ?? []);
}
),
});

View file

@ -0,0 +1,17 @@
import { trpcClient } from "../../trpc/trpc-client";
import { useRouter } from "next/router";
import { useEffect } from "react";
/**
* This app requires channels to exist, so redirect to error page if channels don't exist
*/
export const useChannelsExistenceChecking = () => {
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, router]);
};

View file

@ -1,23 +0,0 @@
import { AppConfig } from "./app-config";
import { AppConfigContainer } from "./app-config-container";
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
/**
* TODO Test
*/
export const FallbackAppConfig = {
createFallbackConfigFromExistingShopAndChannels(
channels: ChannelFragment[],
shopUrlConfiguration: ShopInfoFragment | null
) {
return (channels ?? []).reduce<AppConfig>(
(state, channel) => {
return AppConfigContainer.setChannelUrlConfiguration(state)(channel.slug)({
storefrontUrl: "",
productStorefrontUrl: "",
});
},
{ shopConfigPerChannel: {} }
);
},
};

View file

@ -0,0 +1,44 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Box, Button, Input, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { useGetFeedApiUrl } from "../feed-url/use-get-feed-api-url";
interface FeedPreviewCardProps {
channelSlug: string;
}
export const FeedPreviewCard = ({ channelSlug, ...props }: PropsWithBox<FeedPreviewCardProps>) => {
const { appBridge } = useAppBridge();
const googleFeedUrl = useGetFeedApiUrl(channelSlug);
if (!googleFeedUrl) {
// Should never happen
return null;
}
const openUrlInNewTab = async (url: string) => {
await appBridge?.dispatch(actions.Redirect({ to: url, newContext: true }));
};
return (
<Box {...props}>
<Text variant={"heading"} as={"h2"} marginBottom={4}>
Test your feed
</Text>
<Input
label="Google feed URL"
value={googleFeedUrl}
onFocus={(e) => {
e.target.select();
}}
helperText="Dedicated URL for your Google Merchant Feed. Click to select and copy."
/>
<Box display={"flex"} justifyContent={"flex-end"}>
<Button variant="secondary" onClick={() => openUrlInNewTab(googleFeedUrl)} marginTop={6}>
Open feed in a new tab
</Button>
</Box>
</Box>
);
};

View file

@ -1,64 +0,0 @@
import { PrivateMetadataAppConfigurator } from "./app-configurator";
import { createSettingsManager } from "../../lib/metadata-manager";
import { ChannelsFetcher } from "../channels/channels-fetcher";
import { ShopInfoFetcher } from "../shop-info/shop-info-fetcher";
import { FallbackAppConfig } from "./fallback-app-config";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
export class GetAppConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = createLogger({
service: "GetAppConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const appConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedAppConfig = (await appConfigurator.getConfig()) ?? null;
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
if (savedAppConfig) {
return savedAppConfig;
}
logger.info("App config not found in metadata. Will create default config now.");
const channelsFetcher = new ChannelsFetcher(apiClient);
const shopInfoFetcher = new ShopInfoFetcher(apiClient);
const [channels, shopUrlConfiguration] = await Promise.all([
channelsFetcher.fetchChannels(),
shopInfoFetcher.fetchShopInfo(),
]);
logger.debug(channels, "Fetched channels");
logger.debug(shopUrlConfiguration, "Fetched shop url configuration");
const appConfig = FallbackAppConfig.createFallbackConfigFromExistingShopAndChannels(
channels ?? [],
shopUrlConfiguration
);
logger.debug(appConfig, "Created a fallback AppConfig. Will save it.");
await appConfigurator.setConfig(appConfig);
logger.info("Saved initial AppConfig");
return appConfig;
}
}

View file

@ -0,0 +1,94 @@
import { AppConfigSchema, RootConfig } from "./app-config";
import { useForm } from "react-hook-form";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { Input } 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 S3BucketConfiguration = Exclude<RootConfig["s3"], null>;
type Props = {
initialData: S3BucketConfiguration;
onSubmit(data: S3BucketConfiguration): Promise<void>;
};
export const S3ConfigurationForm = (props: Props) => {
const { handleSubmit, control } = useForm<S3BucketConfiguration>({
defaultValues: props.initialData,
resolver: zodResolver(AppConfigSchema.s3Bucket),
});
return (
<Box
as={"form"}
display={"flex"}
gap={8}
flexDirection={"column"}
onSubmit={handleSubmit((data) => {
props.onSubmit(data);
})}
>
<Input size={"small"} name={"accessKeyId"} control={control} label="Amazon access key ID" />
<Input
type={"password"}
size={"small"}
name={"secretAccessKey"}
control={control}
label="Amazon secret access key"
/>
<Input size={"small"} name={"bucketName"} control={control} label="Bucket name" />
<Input size={"small"} name={"region"} control={control} label="Bucket region" />
<Button type="submit" variant="primary" alignSelf={"end"}>
Save bucket configuration
</Button>
</Box>
);
};
export const ConnectedS3ConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const { mutate } = trpcClient.appConfiguration.setS3BucketConfiguration.useMutation({
onSuccess() {
notifySuccess("Success", "Updated S3 configration");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
const handleSubmit = useCallback(
async (data: S3BucketConfiguration) => {
mutate(data);
},
[mutate]
);
const formData: S3BucketConfiguration = useMemo(() => {
if (data?.s3) {
return data.s3;
}
return {
accessKeyId: "",
bucketName: "",
region: "",
secretAccessKey: "",
};
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
}
return <S3ConfigurationForm onSubmit={handleSubmit} initialData={formData} />;
};

View file

@ -1,142 +0,0 @@
import { trpcClient } from "../../trpc/trpc-client";
import { LinearProgress, Paper } from "@material-ui/core";
import React, { useEffect, useMemo, useState } from "react";
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
import { AppConfigContainer } from "../app-config-container";
import { UrlConfigurationForm } from "./url-configuration-form";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppColumnsLayout } from "../../ui/app-columns-layout";
import { FeedPreviewCard } from "./feed-preview-card";
import { Instructions } from "./instructions";
import { SideMenu } from "./side-menu";
import { useDashboardNotification } from "@saleor/apps-shared";
import { S3ConfigurationForm } from "./s3-configuration-form";
const useStyles = makeStyles((theme) => {
return {
grid: {
display: "grid",
gridTemplateColumns: "1fr 1fr",
alignItems: "start",
gap: 40,
},
instructionsContainer: {
padding: 15,
},
configurationColumn: {
display: "flex",
flexDirection: "column",
gap: 20,
},
};
});
export const ChannelsConfiguration = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const { notifySuccess } = useDashboardNotification();
const { data: configurationData, refetch: refetchConfig } =
trpcClient.appConfiguration.fetch.useQuery();
const channels = trpcClient.channels.fetch.useQuery();
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
const { mutate, error: saveError } = trpcClient.appConfiguration.setAndReplace.useMutation({
onSuccess() {
refetchConfig();
notifySuccess("Success", "Saved app configuration");
},
});
useEffect(() => {
if (channels.isSuccess) {
setActiveChannelSlug(channels.data![0]?.slug ?? null);
}
}, [channels.isSuccess, channels.data]);
const activeChannel = useMemo(() => {
try {
return channels.data!.find((c) => c.slug === activeChannelSlug)!;
} catch (e) {
return null;
}
}, [channels.data, activeChannelSlug]);
if (channels.isLoading || !channels.data) {
return <LinearProgress />;
}
if (!activeChannel) {
return <div>Error. No channel available</div>;
}
return (
<AppColumnsLayout>
<SideMenu
title="Channels"
selectedItemId={activeChannel?.slug}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/`,
})
);
}}
>
<EditIcon />
</IconButton>
}
onClick={(id) => setActiveChannelSlug(id)}
items={channels.data.map((c) => ({ label: c.name, id: c.slug })) || []}
/>
{activeChannel ? (
<div className={styles.configurationColumn}>
<Paper elevation={0}>
<UrlConfigurationForm
channelID={activeChannel.id}
key={activeChannelSlug + "url"}
channelSlug={activeChannel.slug}
onSubmit={async (data) => {
const newConfig = AppConfigContainer.setChannelUrlConfiguration(configurationData)(
activeChannel.slug
)(data);
mutate(newConfig);
}}
initialData={AppConfigContainer.getChannelUrlConfiguration(configurationData)(
activeChannel.slug
)}
channelName={activeChannel?.name ?? activeChannelSlug}
/>
<S3ConfigurationForm
channelID={activeChannel.id}
key={activeChannelSlug + "s3"}
channelSlug={activeChannel.slug}
onSubmit={async (data) => {
const newConfig = AppConfigContainer.setChannelS3BucketConfiguration(
configurationData
)(activeChannel.slug)(data);
mutate(newConfig);
}}
initialData={AppConfigContainer.getChannelS3BucketConfiguration(configurationData)(
activeChannel.slug
)}
channelName={activeChannel?.name ?? activeChannelSlug}
/>
{saveError && <span>{saveError.message}</span>}
</Paper>
<FeedPreviewCard channelSlug={activeChannel.slug} />
</div>
) : null}
<Instructions />
</AppColumnsLayout>
);
};

View file

@ -1,64 +0,0 @@
import { Paper, TextField, Typography } from "@material-ui/core";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Button, makeStyles } from "@saleor/macaw-ui";
const useStyles = makeStyles((theme) => {
return {
header: { marginBottom: 20 },
grid: { display: "grid", gridTemplateColumns: "1fr 1fr", alignItems: "start", gap: 40 },
formContainer: {
top: 0,
position: "sticky",
},
instructionsContainer: {
padding: 15,
},
field: {
marginBottom: 20,
},
};
});
interface FeedPreviewCardProps {
channelSlug: string;
}
export const FeedPreviewCard = ({ channelSlug }: FeedPreviewCardProps) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const { saleorApiUrl } = appBridge?.getState() || {};
const googleFeedUrl = `${window.location.origin}/api/feed/${encodeURIComponent(
saleorApiUrl as string
)}/${channelSlug}/google.xml`;
const openUrlInNewTab = async (url: string) => {
await appBridge?.dispatch(actions.Redirect({ to: url, newContext: true }));
};
return (
<Paper elevation={0} className={styles.instructionsContainer}>
<Typography paragraph variant="h3">
Your Google Merchant Feed preview
</Typography>
<TextField
label="Google feed URL"
fullWidth
value={googleFeedUrl}
disabled={true}
className={styles.field}
helperText="Dedicated URL for your Google Merchant Feed"
/>
<Button
type="submit"
variant="secondary"
fullWidth
onClick={() => openUrlInNewTab(googleFeedUrl)}
>
Open feed in a new tab
</Button>
</Paper>
);
};

View file

@ -1,55 +0,0 @@
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="#"
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,68 +0,0 @@
import { S3BucketConfiguration, SellerShopConfig } from "../app-config";
import { useForm } from "react-hook-form";
import { TextField, 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";
const useStyles = makeStyles({
field: {
marginBottom: 20,
},
form: {
padding: 20,
},
channelName: {
fontFamily: "monospace",
cursor: "pointer",
},
});
type Props = {
channelSlug: string;
channelName: string;
channelID: string;
onSubmit(data: S3BucketConfiguration): Promise<void>;
initialData?: S3BucketConfiguration | null;
};
export const S3ConfigurationForm = (props: Props) => {
const { register, handleSubmit } = useForm<S3BucketConfiguration>({
defaultValues: props.initialData ?? undefined,
});
const styles = useStyles();
const { appBridge } = useAppBridge();
const CommonFieldProps: TextFieldProps = {
className: styles.field,
fullWidth: true,
};
return (
<form
onSubmit={handleSubmit((data, event) => {
props.onSubmit(data);
})}
className={styles.form}
>
<Typography variant="h3" paragraph>
S3 storage
</Typography>
<TextField label="Amazon access key ID" {...CommonFieldProps} {...register("accessKeyId")} />
<TextField
label="Amazon secret access key"
{...CommonFieldProps}
{...register("secretAccessKey")}
/>
<TextField label="Bucket name" {...CommonFieldProps} {...register("bucketName")} />
<TextField label="Bucket region" {...CommonFieldProps} {...register("region")} />
<Button type="submit" fullWidth variant="primary">
Save bucket configuration
</Button>
</form>
);
};

View file

@ -1,110 +0,0 @@
import { Card, CardContent, CardHeader, Divider } from "@material-ui/core";
("@material-ui/icons");
import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
import { makeStyles } from "@saleor/macaw-ui";
import { Skeleton } from "@material-ui/lab";
export const useStyles = makeStyles((theme) => ({
menu: {
height: "fit-content",
},
clickable: {
cursor: "pointer",
},
selected: {
"&&&&::before": {
position: "absolute",
left: 0,
width: "4px",
height: "100%",
backgroundColor: theme.palette.saleor.active[1],
},
},
spaceBetween: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
tableRow: {
minHeight: "48px",
"&::after": {
display: "none",
},
},
greyText: {
color: theme.palette.text.hint,
},
link: {
all: "inherit",
display: "contents",
},
}));
interface SideMenuProps {
title: string;
noItemsText?: string;
items: { id: string; label: string }[];
selectedItemId?: string;
headerToolbar?: React.ReactNode;
onDelete?: (itemId: string) => void;
onClick: (itemId: string) => void;
}
export const SideMenu: React.FC<SideMenuProps> = ({
title,
items,
headerToolbar,
selectedItemId,
noItemsText,
onDelete,
onClick,
}) => {
const classes = useStyles();
const isNoItems = !items || !items.length;
return (
<Card className={classes.menu}>
<CardHeader title={title} action={headerToolbar} />
{isNoItems ? (
!!noItemsText && <CardContent className={classes.greyText}>{noItemsText}</CardContent>
) : (
<List gridTemplate={["1fr"]}>
{items.map((item) => (
<React.Fragment key={item.id}>
<Divider />
<ListItem
className={clsx(classes.clickable, classes.tableRow, {
[classes.selected]: item.id === selectedItemId,
})}
onClick={() => onClick(item.id)}
>
<ListItemCell>
<div className={classes.spaceBetween}>
{item.label}
{!!onDelete && (
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
onDelete(item.id);
}}
>
<DeleteIcon />
</IconButton>
)}
</div>
</ListItemCell>
</ListItem>
</React.Fragment>
)) ?? <Skeleton />}
<Divider />
</List>
)}
</Card>
);
};

View file

@ -1,74 +0,0 @@
import { SellerShopConfig } from "../app-config";
import { useForm } from "react-hook-form";
import { TextField, 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";
const useStyles = makeStyles({
field: {
marginBottom: 20,
},
form: {
padding: 20,
},
channelName: {
fontFamily: "monospace",
cursor: "pointer",
},
});
type Props = {
channelSlug: string;
channelName: string;
channelID: string;
onSubmit(data: SellerShopConfig["urlConfiguration"]): Promise<void>;
initialData?: SellerShopConfig["urlConfiguration"] | null;
};
export const UrlConfigurationForm = (props: Props) => {
const { register, handleSubmit } = useForm<SellerShopConfig["urlConfiguration"]>({
defaultValues: props.initialData ?? undefined,
});
const styles = useStyles();
const { appBridge } = useAppBridge();
const CommonFieldProps: TextFieldProps = {
className: styles.field,
fullWidth: true,
};
const handleChannelNameClick = () => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/${props.channelID}`,
})
);
};
return (
<form
onSubmit={handleSubmit((data, event) => {
props.onSubmit(data);
})}
className={styles.form}
>
<Typography variant="h3" paragraph>
Configure
<strong onClick={handleChannelNameClick} className={styles.channelName}>
{` ${props.channelName} `}
</strong>
channel
</Typography>
<TextField label="Storefront home URL" {...CommonFieldProps} {...register("storefrontUrl")} />
<TextField
label="Storefront product URL template"
{...CommonFieldProps}
{...register("productStorefrontUrl")}
/>
<Button type="submit" fullWidth variant="primary">
Save channel configuration
</Button>
</form>
);
};

View file

@ -1,10 +0,0 @@
import { SellerShopConfig } from "./app-config";
export const UrlConfiguration = {
createEmpty(): SellerShopConfig["urlConfiguration"] {
return {
storefrontUrl: "",
productStorefrontUrl: "",
};
},
};

View file

@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CategoriesFetcher } from "./categories-fetcher";
import { Client, OperationResult } from "urql";
import { FetchCategoriesWithMappingQuery } from "../../../generated/graphql";
type FetchResult = OperationResult<FetchCategoriesWithMappingQuery, { cursor: string | undefined }>;
const generateCategoryEdgeMock = (uniqueIncrement: number) => {
return {
node: {
name: `Category ${uniqueIncrement}`,
id: `cat-${uniqueIncrement}`,
googleCategoryId: `${uniqueIncrement * 2}`,
},
};
};
const generateArr = (length: number) => new Array(length).fill(null).map((_, index) => index);
const wait = () => new Promise((res) => setTimeout(res, 100));
describe("CategoriesFetcher", () => {
const mockQueryPromise = vi.fn<any, FetchResult>();
const mockClient: Pick<Client, "query"> = {
// @ts-ignore - It's hard to mock urql mocks - but it can be improved
query() {
return {
toPromise: mockQueryPromise,
};
},
};
const instance = new CategoriesFetcher(mockClient);
beforeEach(() => {
vi.resetAllMocks();
});
it("Fetches single page of categories correctly", async () => {
mockQueryPromise.mockImplementationOnce(async () => {
const data: FetchCategoriesWithMappingQuery = {
categories: {
pageInfo: {
endCursor: undefined,
hasNextPage: false,
},
edges: [generateCategoryEdgeMock(1), generateCategoryEdgeMock(2)],
},
};
return {
error: undefined,
data: data,
} as FetchResult;
});
const result = await instance.fetchAllCategories();
expect(result).toEqual([
{
googleCategoryId: "2",
id: "cat-1",
name: "Category 1",
},
{
googleCategoryId: "4",
id: "cat-2",
name: "Category 2",
},
]);
});
it("Fetches 3 pages correctly and merges them", async () => {
mockQueryPromise.mockImplementationOnce(async () => {
await wait();
return {
error: undefined,
data: {
categories: {
edges: generateArr(100).map((index) => generateCategoryEdgeMock(index)),
pageInfo: {
hasNextPage: true,
endCursor: "cat-99",
},
},
},
} as FetchResult;
});
mockQueryPromise.mockImplementationOnce(async () => {
await wait();
return {
error: undefined,
data: {
categories: {
edges: generateArr(100)
.map((index) => index + 100)
.map((index) => generateCategoryEdgeMock(index)),
pageInfo: {
hasNextPage: true,
endCursor: "cat-199",
},
},
},
} as FetchResult;
});
mockQueryPromise.mockImplementationOnce(async () => {
await wait();
return {
error: undefined,
data: {
categories: {
edges: generateArr(100)
.map((index) => index + 200)
.map((index) => generateCategoryEdgeMock(index)),
pageInfo: {
hasNextPage: false,
endCursor: undefined,
},
},
},
} as FetchResult;
});
const result = await instance.fetchAllCategories();
expect(result).toHaveLength(300);
/**
* Some indexes assertions
*/
[0, 5, 99, 299].forEach((index) => {
expect(result[index]).toEqual({
googleCategoryId: `${index * 2}`,
id: `cat-${index}`,
name: `Category ${index}`,
});
});
});
it("Returns empty array if not categories returned from the API", async () => {
mockQueryPromise.mockImplementationOnce(async () => {
const data: FetchCategoriesWithMappingQuery = {
categories: {
pageInfo: {
endCursor: undefined,
hasNextPage: false,
},
edges: [],
},
};
return {
error: undefined,
data: data,
} as FetchResult;
});
const result = await instance.fetchAllCategories();
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,49 @@
import { Client } from "urql";
import {
CategoryWithMappingFragmentFragment,
FetchCategoriesWithMappingDocument,
} from "../../../generated/graphql";
export class CategoriesFetcher {
constructor(private apiClient: Pick<Client, "query">) {}
private async fetchRecursivePage(
accumulator: CategoryWithMappingFragmentFragment[],
cursor?: string
): Promise<CategoryWithMappingFragmentFragment[]> {
const result = await this.apiClient
.query(FetchCategoriesWithMappingDocument, {
cursor,
})
.toPromise();
if (result.error) {
throw new Error(result.error.message);
}
if (!result.data) {
// todo sentry
throw new Error("Empty categories data");
}
accumulator = [...accumulator, ...(result.data.categories?.edges.map((c) => c.node) ?? [])];
const hasNextPage = result.data.categories?.pageInfo.hasNextPage;
const endCursor = result.data.categories?.pageInfo.endCursor;
if (hasNextPage && endCursor) {
return this.fetchRecursivePage(accumulator, endCursor);
} else {
return accumulator;
}
}
/**
* Fetches all categories pages - standard page is max 100 items
*/
async fetchAllCategories(): Promise<CategoryWithMappingFragmentFragment[]> {
let categories: CategoryWithMappingFragmentFragment[] = [];
return this.fetchRecursivePage(categories, undefined);
}
}

View file

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

View file

@ -0,0 +1,27 @@
import { CategoryWithMappingFragmentFragment } from "../../../generated/graphql";
import React from "react";
import { Box, ChevronRightIcon, Text } from "@saleor/macaw-ui/next";
export const constructCategoryBreadcrumbs = (category: CategoryWithMappingFragmentFragment) =>
[category.parent?.parent?.name, category.parent?.name, category.name].filter((segment) =>
Boolean(segment)
);
export const CategoryBreadcrumbs = (props: { category: CategoryWithMappingFragmentFragment }) => {
const breadcrumbs = constructCategoryBreadcrumbs(props.category);
return (
<Box display={"flex"} marginBottom={3}>
{breadcrumbs.map((category, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<Box display={"flex"} key={category}>
<Text variant={isLast ? "bodyStrong" : "body"}>{category}</Text>
{!isLast && <ChevronRightIcon />}
</Box>
);
})}
</Box>
);
};

View file

@ -1,6 +1,5 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { FormControl, Grid, InputLabel, MenuItem, Select, Typography } from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import {
@ -8,29 +7,24 @@ import {
SetCategoryMappingInputType, SetCategoryMappingInputType,
} from "../category-mapping-input-schema"; } from "../category-mapping-input-schema";
import { CategoryWithMappingFragmentFragment } from "../../../../generated/graphql"; import { CategoryWithMappingFragmentFragment } from "../../../../generated/graphql";
import { GoogleProductCategories } from "../google-product-categories";
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, PropsWithBox } from "@saleor/macaw-ui/next";
const useStyles = makeStyles({ import { GoogleProductCategories } from "../google-product-categories";
field: { import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs";
marginBottom: 20,
},
form: {
padding: 20,
},
channelName: {
fontFamily: "monospace",
cursor: "pointer",
},
});
type CategoryMappingFormProps = { type CategoryMappingFormProps = {
category: CategoryWithMappingFragmentFragment; category: CategoryWithMappingFragmentFragment;
}; };
export const CategoryMappingForm = ({ category }: CategoryMappingFormProps) => { /**
const styles = useStyles(); * Use Combobox, when virtualized. For such amount of data it almost crashes app due to slow rendering
* https://github.com/saleor/macaw-ui/issues/452
*/
export const CategoryMappingForm = ({
category,
...props
}: PropsWithBox<CategoryMappingFormProps>) => {
const { notifySuccess, notifyError } = useDashboardNotification(); const { notifySuccess, notifyError } = useDashboardNotification();
const { control, handleSubmit, formState } = useForm<SetCategoryMappingInputType>({ const { control, handleSubmit, formState } = useForm<SetCategoryMappingInputType>({
@ -49,61 +43,42 @@ export const CategoryMappingForm = ({ category }: CategoryMappingFormProps) => {
}, },
}); });
const categoryBreadcrumbs = [category.parent?.parent?.name, category.parent?.name, category.name]
.filter((segment) => segment)
.join(" > ");
return ( return (
<form <Box
as={"form"}
{...props}
onSubmit={handleSubmit((data, event) => { onSubmit={handleSubmit((data, event) => {
mutate(data); mutate(data);
})} })}
className={styles.form}
> >
<Typography variant="h4" paragraph> <CategoryBreadcrumbs category={category} />
{categoryBreadcrumbs} <Box display={"flex"} gap={8} __width={"100%"}>
</Typography> <Controller
<Grid container spacing={2}> control={control}
<Grid item xs={10}> name="googleCategoryId"
<Controller render={({ field: { value, onChange } }) => {
control={control} return (
name="googleCategoryId" <select
render={({ field: { value, onChange } }) => { style={{ width: "100%" }}
return ( value={value}
<FormControl className={styles.field} fullWidth> onChange={(event) => {
<InputLabel>Google Product Category</InputLabel> onChange(event.target.value);
<Select }}
variant="outlined" >
value={value} <option value={undefined}>No override</option>
onChange={(event, val) => { {GoogleProductCategories.map((choice) => (
onChange(event.target.value); <option key={choice.id} value={choice.id.toString()}>
}} {choice.name}
> </option>
<MenuItem key="none" value={undefined}> ))}
No configuration </select>
</MenuItem> );
{GoogleProductCategories.map((choice) => ( }}
<MenuItem key={choice.id} value={choice.id.toString()}> />
{choice.name} <Button type="submit" variant="primary" disabled={isLoading || !formState.isDirty}>
</MenuItem> Save
))} </Button>
</Select> </Box>
</FormControl> </Box>
);
}}
/>
</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,50 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { trpcClient } from "../../trpc/trpc-client";
import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs";
import { GoogleProductCategories } from "../google-product-categories";
import { useMemo } from "react";
const GoogleCategory = (props: { categoryId: string }) => {
const googleCategory = useMemo(() => {
return GoogleProductCategories.find((cat) => cat.id === props.categoryId);
}, [props.categoryId]);
return <Text size={"small"}>{googleCategory?.name}</Text>;
};
export const CategoryMappingPreview = () => {
const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery();
if (isLoading) {
return <Text>Loading</Text>;
}
if (categories?.length === 0) {
return <Text>No categories</Text>;
}
return (
<Box>
<Text marginBottom={8} as={"h2"} variant={"heading"}>
Mapped categories
</Text>
{categories!
.filter((c) => c.googleCategoryId)
.map((category) => {
return (
<Box
key={category.id}
marginBottom={4}
borderBottomStyle={"solid"}
borderColor={"neutralHighlight"}
borderWidth={1}
paddingBottom={4}
>
<CategoryBreadcrumbs category={category} />
<GoogleCategory categoryId={category.googleCategoryId!} />
</Box>
);
})}
</Box>
);
};

View file

@ -1,18 +1,25 @@
import { trpcClient } from "../../trpc/trpc-client"; import { trpcClient } from "../../trpc/trpc-client";
import { Paper } from "@material-ui/core";
import React from "react"; import React from "react";
import { CategoryMappingForm } from "./category-mapping-form"; import { CategoryMappingForm } from "./category-mapping-form";
import { Box, Text } from "@saleor/macaw-ui/next";
export const CategoryMapping = () => { export const CategoryMapping = () => {
const categories = trpcClient.categoryMapping.getCategoryMappings.useQuery(); const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery();
if (isLoading) {
return <Text>Loading</Text>;
}
if (categories && categories.length === 0) {
return <Text>No categories to map</Text>;
}
return ( return (
<Paper elevation={0}> <Box>
{categories.data?.length {categories!.map((category) => (
? categories.data.map((category) => ( <CategoryMappingForm category={category} key={category.id} marginBottom={8} />
<CategoryMappingForm category={category} key={category.id} /> ))}
)) </Box>
: null}
</Paper>
); );
}; };

View file

@ -1,55 +0,0 @@
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,17 +0,0 @@
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { ChannelsFetcher } from "./channels-fetcher";
import { ChannelFragment } from "../../../generated/graphql";
import { createClient } from "../../lib/create-graphq-client";
export const channelsRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }): Promise<ChannelFragment[]> => {
const client = createClient(ctx.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.appToken })
);
const fetcher = new ChannelsFetcher(client);
return fetcher.fetchChannels().then((channels) => channels ?? []);
}),
});

View file

@ -0,0 +1,3 @@
Editor.js is used in Dashboard for product description. Feed description should be rendered to standard text.
This module contains logic that transforms Editor.js to plain text

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { fillUrlTemplate } from "./fill-url-template";
import { z } from "zod";
describe("fillUrlTemplate", () => {
it("Replaces template strings in url", () => {
expect(
fillUrlTemplate({
urlTemplate: `https://example.com/p/{productSlug}/{productId}/{variantId}`,
productId: "PRODUCT_ID",
productSlug: "PRODUCT_SLUG",
variantId: "VARIANT_ID",
})
).toEqual("https://example.com/p/PRODUCT_SLUG/PRODUCT_ID/VARIANT_ID");
});
/**
* Not likely to happen, but better safe than sorry
*/
it("Encodes components so special characters are not passed to URL", () => {
const resultUrl = fillUrlTemplate({
urlTemplate: `https://example.com/p/{productSlug}/{productId}/{variantId}`,
productId: "productId < ",
productSlug: "product/slug",
variantId: "variantId%12!",
});
/**
* Validate URL with URL api
*/
expect(() => new URL(resultUrl)).not.toThrow();
expect(resultUrl).toEqual(
"https://example.com/p/product%2Fslug/productId%20%3C%20%20%20/variantId%2512!"
);
});
});

View file

@ -12,6 +12,6 @@ export const fillUrlTemplate = ({
variantId, variantId,
}: FillUrlTemplateArgs) => }: FillUrlTemplateArgs) =>
urlTemplate urlTemplate
.replace("{productId}", productId) .replace("{productId}", encodeURIComponent(productId))
.replace("{productSlug}", productSlug) .replace("{productSlug}", encodeURIComponent(productSlug))
.replace("{variantId}", variantId); .replace("{variantId}", encodeURIComponent(variantId));

View file

@ -0,0 +1,23 @@
import React, { PropsWithChildren } from "react";
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useGetFeedApiUrl } from "./use-get-feed-api-url";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
describe("useGetFeedApiUrl", function () {
const appBridge = new AppBridge({ saleorApiUrl: "https://example.com/graphql/" });
const HookWrapper = ({ children }: PropsWithChildren<{}>) => {
return <AppBridgeProvider appBridgeInstance={appBridge}>{children}</AppBridgeProvider>;
};
it("Constructs valid URL from window origin and channel slug", () => {
const { result } = renderHook(() => useGetFeedApiUrl("test-slug"), {
wrapper: HookWrapper,
});
expect(result.current).toEqual(
"http://localhost:3000/api/feed/https%3A%2F%2Fexample.com%2Fgraphql%2F/test-slug/google.xml"
);
});
});

View file

@ -0,0 +1,13 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
export const useGetFeedApiUrl = (channelSlug: string) => {
const { appBridgeState } = useAppBridge();
if (!appBridgeState || !window.location.origin) {
return null;
}
return `${window.location.origin}/api/feed/${encodeURIComponent(
appBridgeState.saleorApiUrl as string
)}/${channelSlug}/google.xml`;
};

View file

@ -1,11 +1,11 @@
import { S3Client } from "@aws-sdk/client-s3"; import { S3Client } from "@aws-sdk/client-s3";
import { S3BucketConfiguration } from "../../app-configuration/app-config"; import { RootConfig } from "../../app-configuration/app-config";
export const createS3ClientFromConfiguration = ({ export const createS3ClientFromConfiguration = ({
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
region, region,
}: S3BucketConfiguration) => { }: Exclude<RootConfig["s3"], null>) => {
return new S3Client({ return new S3Client({
credentials: { credentials: {
accessKeyId: accessKeyId, accessKeyId: accessKeyId,

View file

@ -1,7 +1,7 @@
import { S3BucketConfiguration } from "../../app-configuration/app-config"; import { RootConfig } from "../../app-configuration/app-config";
interface GetDownloadUrlArgs { interface GetDownloadUrlArgs {
s3BucketConfiguration: S3BucketConfiguration; s3BucketConfiguration: Exclude<RootConfig["s3"], null>;
saleorApiUrl: string; saleorApiUrl: string;
channel: string; channel: string;
} }

View file

@ -13,7 +13,7 @@ export const getCursors = async ({ client, channel }: { client: Client; channel:
logger.debug(`Fetching cursors for channel ${channel}`); logger.debug(`Fetching cursors for channel ${channel}`);
let result = await client let result = await client
.query(FetchProductCursorsDocument, { channel: channel as string, first: 100 }) .query(FetchProductCursorsDocument, { channel: channel, first: 100 })
.toPromise(); .toPromise();
const cursors: Array<string> = []; const cursors: Array<string> = [];
@ -21,7 +21,7 @@ export const getCursors = async ({ client, channel }: { client: Client; channel:
while (result.data?.productVariants?.pageInfo.hasNextPage) { while (result.data?.productVariants?.pageInfo.hasNextPage) {
result = await client result = await client
.query(FetchProductCursorsDocument, { .query(FetchProductCursorsDocument, {
channel: channel as string, channel: channel,
first: 100, first: 100,
after: result.data.productVariants.pageInfo.endCursor, after: result.data.productVariants.pageInfo.endCursor,
}) })
@ -51,7 +51,7 @@ const fetchVariants = async ({
const result = await client const result = await client
.query(FetchProductDataForFeedDocument, { .query(FetchProductDataForFeedDocument, {
channel: channel as string, channel: channel,
first: 100, first: 100,
after, after,
}) })

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
import { generateGoogleXmlFeed } from "./generate-google-xml-feed";
const productBase: GoogleFeedProductVariantFragment["product"] = {
name: "Product",
__typename: "Product",
id: "product-id",
category: {
id: "cat-id",
__typename: "Category",
name: "Category Name",
googleCategoryId: "1",
},
description: "Product description",
seoDescription: "Seo description",
slug: "product-slug",
thumbnail: { __typename: "Image", url: "" },
};
const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
__typename: "VariantPricingInfo",
price: {
__typename: "TaxedMoney",
gross: {
__typename: "Money",
amount: 1,
currency: "USD",
},
},
};
describe("generateGoogleXmlFeed", () => {
it("Generates feed", () => {
const result = generateGoogleXmlFeed({
productStorefrontUrl: "https://example.com/p/{productSlug}",
shopDescription: "Description",
shopName: "Example",
storefrontUrl: "https://example.com",
productVariants: [
{
id: "id1",
__typename: "ProductVariant",
sku: "sku1",
quantityAvailable: 1,
pricing: priceBase,
name: "Product variant",
product: productBase,
},
{
id: "id2",
__typename: "ProductVariant",
sku: "sku2",
quantityAvailable: 0,
pricing: priceBase,
name: "Product variant 2",
product: productBase,
},
],
});
expect(result).toMatchInlineSnapshot(`
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
<channel>
<title>Example</title>
<link>https://example.com</link>
<description>Description</description>
<item>
<g:id>sku1</g:id>
<title>Product - Product variant</title>
<g:condition>new</g:condition>
<g:availability>in_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>1.00 USD</g:price>
</item>
<item>
<g:id>sku2</g:id>
<title>Product - Product variant 2</title>
<g:condition>new</g:condition>
<g:availability>out_of_stock</g:availability>
<g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link>
<g:price>1.00 USD</g:price>
</item>
</channel>
</rss>"
`);
});
});

View file

@ -2,7 +2,7 @@ import { XMLBuilder } from "fast-xml-parser";
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql"; import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
import { productToProxy } from "./product-to-proxy"; import { productToProxy } from "./product-to-proxy";
import { shopDetailsToProxy } from "./shop-details-to-proxy"; import { shopDetailsToProxy } from "./shop-details-to-proxy";
import { EditorJsPlaintextRenderer } from "../editor-js-plaintext-renderer"; import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
interface GenerateGoogleXmlFeedArgs { interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[]; productVariants: GoogleFeedProductVariantFragment[];
@ -12,6 +12,22 @@ interface GenerateGoogleXmlFeedArgs {
shopDescription?: string; shopDescription?: string;
} }
/**
* Price format has to be altered from the en format to the one expected by Google
* eg. 1.00 USD, 5.00 PLN
*/
const formatCurrency = (currency: string, amount: number) => {
return (
new Intl.NumberFormat("en-EN", {
useGrouping: false,
minimumFractionDigits: 2,
style: "decimal",
currencyDisplay: "code",
currency: currency,
}).format(amount) + ` ${currency}`
);
};
export const generateGoogleXmlFeed = ({ export const generateGoogleXmlFeed = ({
productVariants, productVariants,
storefrontUrl, storefrontUrl,
@ -19,33 +35,29 @@ export const generateGoogleXmlFeed = ({
shopName, shopName,
shopDescription, shopDescription,
}: GenerateGoogleXmlFeedArgs) => { }: GenerateGoogleXmlFeedArgs) => {
const items = productVariants.map((v) => { const items = productVariants.map((variant) => {
// Price format has to be altered from the en format to the one expected by Google const currency = variant.pricing?.price?.gross.currency;
const price = v.pricing?.price?.gross.currency const amount = variant.pricing?.price?.gross.amount;
? new Intl.NumberFormat("en-EN", {
useGrouping: false, const price = currency ? formatCurrency(currency, amount!) : undefined;
minimumFractionDigits: 2,
style: "decimal",
currencyDisplay: "code",
currency: v.pricing?.price?.gross.currency,
}).format(v.pricing?.price?.gross.amount) + ` ${v.pricing?.price?.gross.currency}`
: undefined;
return productToProxy({ return productToProxy({
storefrontUrlTemplate: productStorefrontUrl, storefrontUrlTemplate: productStorefrontUrl,
id: v.product.id, id: variant.product.id,
name: `${v.product.name} - ${v.name}`, name: `${variant.product.name} - ${variant.name}`,
slug: v.product.slug, slug: variant.product.slug,
variantId: v.id, variantId: variant.id,
sku: v.sku || undefined, sku: variant.sku || undefined,
description: EditorJsPlaintextRenderer({ stringData: v.product.description }), description: EditorJsPlaintextRenderer({ stringData: variant.product.description }),
availability: v.quantityAvailable && v.quantityAvailable > 0 ? "in_stock" : "out_of_stock", availability:
category: v.product.category?.name || "unknown", variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
googleProductCategory: v.product.category?.googleCategoryId || "", category: variant.product.category?.name || "unknown",
googleProductCategory: variant.product.category?.googleCategoryId || "",
price: price, price: price,
imageUrl: v.product.thumbnail?.url || "", imageUrl: variant.product.thumbnail?.url || "",
}); });
}); });
const builder = new XMLBuilder({ const builder = new XMLBuilder({
attributeNamePrefix: "@_", attributeNamePrefix: "@_",
attributesGroupName: "@", attributesGroupName: "@",
@ -79,6 +91,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"
channel: channelData.concat(items), channel: channelData.concat(items),
}, },
], ],

View file

@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { GoogleFeedSettingsFetcher } from "./get-google-feed-settings";
import { AppConfig } from "../app-configuration/app-config";
describe("GoogleFeedSettingsFetcher", () => {
it("Fetches channel-specific config with S3 config from Metadata", async () => {
const getMetadataMock = vi.fn().mockImplementation(async () => {
const appConfig = new AppConfig({
channelConfig: {
testChannel: {
storefrontUrls: {
storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}",
},
},
anotherChannel: {
storefrontUrls: {
storefrontUrl: "https://another.example.com",
productStorefrontUrl: "https://another.example.com/p/{productSlug}/v/{variantId}",
},
},
},
s3: {
accessKeyId: "accessKeyId",
bucketName: "bucketName",
region: "region",
secretAccessKey: "secretAccessKey",
},
});
return appConfig.serialize();
});
const instance = new GoogleFeedSettingsFetcher({
settingsManager: {
get: getMetadataMock,
},
});
const result = await instance.fetch("testChannel");
expect(result).toEqual({
storefrontUrl: "https://example.com",
productStorefrontUrl: "https://example.com/p/{productSlug}/v/{variantId}",
s3BucketConfiguration: {
bucketName: "bucketName",
secretAccessKey: "secretAccessKey",
accessKeyId: "accessKeyId",
region: "region",
},
});
});
});

View file

@ -0,0 +1,89 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config";
interface GetGoogleFeedSettingsArgs {
authData: AuthData;
channel: string;
}
/**
* @deprecated replace with class
*/
export const getGoogleFeedSettings = async ({ authData, channel }: GetGoogleFeedSettingsArgs) => {
const client = GraphqlClientFactory.fromAuthData(authData);
const metadataManager = new AppConfigMetadataManager(createSettingsManager(client));
const configString = await metadataManager.get();
if (!configString) {
throw new Error("App is not configured");
}
const appConfig = AppConfig.parse(configString);
const channelConfig = appConfig.getUrlsForChannel(channel);
if (!channelConfig) {
throw new Error("App is not configured");
}
const storefrontUrl = channelConfig.storefrontUrl;
const productStorefrontUrl = channelConfig.productStorefrontUrl;
if (!storefrontUrl.length || !productStorefrontUrl.length) {
throw new Error("The application has not been configured");
}
return {
storefrontUrl,
productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(),
};
};
export class GoogleFeedSettingsFetcher {
static createFromAuthData(authData: AuthData) {
return new GoogleFeedSettingsFetcher({
settingsManager: new AppConfigMetadataManager(
createSettingsManager(GraphqlClientFactory.fromAuthData(authData))
),
});
}
settingsManager: Pick<AppConfigMetadataManager, "get">;
constructor(params: { settingsManager: Pick<AppConfigMetadataManager, "get"> }) {
this.settingsManager = params.settingsManager;
}
async fetch(channelSlug: string) {
const configString = await this.settingsManager.get();
if (!configString) {
throw new Error("App is not configured");
}
const appConfig = AppConfig.parse(configString);
const channelConfig = appConfig.getUrlsForChannel(channelSlug);
if (!channelConfig) {
throw new Error("App is not configured");
}
const storefrontUrl = channelConfig.storefrontUrl;
const productStorefrontUrl = channelConfig.productStorefrontUrl;
if (!storefrontUrl.length || !productStorefrontUrl.length) {
throw new Error("The application has not been configured");
}
return {
storefrontUrl,
productStorefrontUrl,
s3BucketConfiguration: appConfig.getS3Config(),
};
}
}

View file

@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest";
import { productToProxy } from "./product-to-proxy";
describe("productToProxy", () => {
it("Falls back product ID, if product SKU doesnt exist", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
id: "id",
name: "Name",
variantId: "variant-id",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:id": expect.arrayContaining([{ "#text": "id" }]),
},
])
);
});
it('Falls back g:condition to "new" if product condition doesnt exist', () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
/*
* Missing condition field:
* condition: "new",
*/
id: "id",
name: "Name",
variantId: "variant-id",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:condition": expect.arrayContaining([{ "#text": "new" }]),
},
])
);
});
it("Fills product description if exist", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
id: "id",
name: "Name",
variantId: "variant-id",
description: "Product description",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:description": expect.arrayContaining([{ "#text": "Product description" }]),
},
])
);
});
it("Fills google product category if exist", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "id",
name: "Name",
variantId: "variant-id",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:google_product_category": expect.arrayContaining([{ "#text": "1" }]),
},
])
);
});
it("Adds link section with filled product url template", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "id",
name: "Name",
variantId: "variant-id",
storefrontUrlTemplate: "https://example.com/p/{productSlug}/{productId}/{variantId}",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
link: expect.arrayContaining([
{
"#text": "https://example.com/p/slug/id/variant-id",
},
]),
},
])
);
});
it("Adds g:image_link if imageUrl exist in product", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "id",
name: "Name",
variantId: "variant-id",
imageUrl: "https://image.example.com",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]),
},
])
);
});
it("Adds g:price if product price exists", () => {
const result = productToProxy({
slug: "slug",
availability: "in_stock",
category: "1",
condition: "new",
googleProductCategory: "1",
id: "id",
name: "Name",
variantId: "variant-id",
imageUrl: "https://image.example.com",
price: "50.00 USD",
});
expect(result.item).toEqual(
expect.arrayContaining([
{
"g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]),
},
])
);
});
});

View file

@ -1,8 +1,11 @@
import { fillUrlTemplate } from "../fill-url-template"; import { fillUrlTemplate } from "../feed-url/fill-url-template";
import { ProductEntry } from "./types"; import { GoogleProxyItem, ProductEntry } from "./types";
/**
* TODO Test
*/
export const productToProxy = (p: ProductEntry) => { export const productToProxy = (p: ProductEntry) => {
const item: any[] = [ const item: GoogleProxyItem[] = [
{ {
"g:id": [ "g:id": [
{ {

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { shopDetailsToProxy } from "./shop-details-to-proxy";
describe("shopDetailsToProxy", () => {
it("Renders title and storefront url", () => {
expect(
shopDetailsToProxy({
description: "Shop description",
storefrontUrl: "https://example.com",
title: "Shop title",
})
).toMatchInlineSnapshot(`
[
{
"title": [
{
"#text": "Shop title",
},
],
},
{
"link": [
{
"#text": "https://example.com",
},
],
},
{
"description": [
{
"#text": "Shop description",
},
],
},
]
`);
});
it("Renders without description if not provided", () => {
expect(
shopDetailsToProxy({
storefrontUrl: "https://example.com",
title: "Shop title",
})
).toMatchInlineSnapshot(`
[
{
"title": [
{
"#text": "Shop title",
},
],
},
{
"link": [
{
"#text": "https://example.com",
},
],
},
]
`);
});
});

View file

@ -1,7 +1,7 @@
import { ShopDetailsEntry } from "./types"; import { GoogleProxyItem, ShopDetailsEntry } from "./types";
export const shopDetailsToProxy = ({ title, storefrontUrl, description }: ShopDetailsEntry) => { export const shopDetailsToProxy = ({ title, storefrontUrl, description }: ShopDetailsEntry) => {
const data: any[] = [ const data: GoogleProxyItem[] = [
{ {
title: [ title: [
{ {

View file

@ -9,7 +9,7 @@ export type ProductEntry = {
imageUrl?: string; imageUrl?: string;
condition?: "new" | "refurbished" | "used"; condition?: "new" | "refurbished" | "used";
price?: string; price?: string;
googleProductCategory: string; googleProductCategory?: string;
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder"; availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
category: string; category: string;
}; };
@ -19,3 +19,5 @@ export type ShopDetailsEntry = {
storefrontUrl: string; storefrontUrl: string;
description?: string; description?: string;
}; };
export type GoogleProxyItem = Record<string, Array<Record<string, string>>>;

View file

@ -5,25 +5,27 @@ export class CacheConfigurator {
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {} constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
get({ channel }: { channel: string }): Promise<string[] | undefined> { private constructKey(channel: string) {
return this.metadataManager return this.metadataKeyPrefix + channel;
.get(this.metadataKeyPrefix + channel, this.saleorApiUrl) }
.then((data) => {
if (!data) {
return undefined;
}
try { get({ channel }: { channel: string }): Promise<string[] | undefined> {
return JSON.parse(data); return this.metadataManager.get(this.constructKey(channel), this.saleorApiUrl).then((data) => {
} catch (e) { if (!data) {
throw new Error("Invalid metadata value, can't be parsed"); return undefined;
} }
});
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, can't be parsed");
}
});
} }
set({ channel, value }: { channel: string; value: string[] }): Promise<void> { set({ channel, value }: { channel: string; value: string[] }): Promise<void> {
return this.metadataManager.set({ return this.metadataManager.set({
key: this.metadataKeyPrefix + channel, key: this.constructKey(channel),
value: JSON.stringify(value), value: JSON.stringify(value),
domain: this.saleorApiUrl, domain: this.saleorApiUrl,
}); });

View file

@ -1,35 +1,33 @@
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { CacheConfigurator } from "./cache-configurator"; import { CacheConfigurator } from "./cache-configurator";
import { createSettingsManager } from "../../lib/metadata-manager"; import { createSettingsManager } from "../../lib/metadata-manager";
import { getCursors } from "../../lib/google-feed/fetch-product-data"; import { getCursors } from "../google-feed/fetch-product-data";
import { Client } from "urql"; import { Client } from "urql";
import { z } from "zod";
import { appConfigInputSchema } from "../app-configuration/app-config-input-schema";
interface UpdateCacheForConfigurationsArgs { interface UpdateCacheForConfigurationsArgs {
client: Client; client: Client;
saleorApiUrl: string; saleorApiUrl: string;
configurations: z.infer<typeof appConfigInputSchema>; channelsSlugs: string[];
} }
export const updateCacheForConfigurations = async ({ export const updateCacheForConfigurations = async ({
client, client,
configurations, channelsSlugs,
saleorApiUrl, saleorApiUrl,
}: UpdateCacheForConfigurationsArgs) => { }: UpdateCacheForConfigurationsArgs) => {
const logger = createLogger({ saleorApiUrl: saleorApiUrl }); const logger = createLogger({ saleorApiUrl: saleorApiUrl });
logger.debug("Updating the cursor cache"); logger.debug("Updating the cursor cache");
const cache = new CacheConfigurator(createSettingsManager(client), saleorApiUrl); const cache = new CacheConfigurator(createSettingsManager(client), saleorApiUrl);
const channelsToUpdate = Object.keys(configurations.shopConfigPerChannel); const cacheUpdatePromises = channelsSlugs.map(async (channel) => {
const cacheUpdatePromises = channelsToUpdate.map(async (channel) => {
const cursors = await getCursors({ client, channel }); const cursors = await getCursors({ client, channel });
await cache.set({ channel, value: cursors }); await cache.set({ channel, value: cursors });
}); });
await Promise.all(cacheUpdatePromises); await Promise.all(cacheUpdatePromises);
logger.debug("Cursor cache updated"); logger.debug("Cursor cache updated");
}; };

View file

@ -0,0 +1,41 @@
import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { updateCacheForConfigurations } from "./update-cache-for-configurations";
import { AuthData } from "@saleor/app-sdk/APL";
import {
ProductVariantWebhookPayloadFragment,
ProductWebhookPayloadFragment,
} from "../../../generated/graphql";
import { NextApiResponse } from "next";
type ChannelFragment =
| Pick<ProductWebhookPayloadFragment, "channel" | "channelListings">
| Pick<ProductVariantWebhookPayloadFragment, "channel" | "channelListings">;
export const updateCacheOnWebhook = async ({
channels,
authData,
res,
}: {
authData: AuthData;
channels: ChannelFragment;
res: NextApiResponse;
}) => {
const client = GraphqlClientFactory.fromAuthData(authData);
const channelsSlugs = [
channels.channel,
...(channels.channelListings?.map((cl) => cl.channel.slug) ?? []),
].filter((c) => c) as string[];
if (channelsSlugs.length === 0) {
return res.status(200).end();
}
await updateCacheForConfigurations({
channelsSlugs,
client,
saleorApiUrl: authData.saleorApiUrl,
});
return res.status(200).end();
};

View file

@ -1,43 +0,0 @@
import { Client, gql } from "urql";
import { ShopInfoDocument, ShopInfoFragment } from "../../../generated/graphql";
gql`
fragment ShopInfo on Shop {
companyAddress {
country {
country
code
}
city
firstName
lastName
streetAddress1
streetAddress2
companyName
phone
postalCode
countryArea
cityArea
}
}
query ShopInfo {
shop {
...ShopInfo
}
}
`;
export interface IShopInfoFetcher {
fetchShopInfo(): Promise<ShopInfoFragment | null>;
}
export class ShopInfoFetcher implements IShopInfoFetcher {
constructor(private client: Client) {}
fetchShopInfo(): Promise<ShopInfoFragment | null> {
return this.client
.query(ShopInfoDocument, {})
.toPromise()
.then((resp) => resp.data?.shop ?? null);
}
}

View file

@ -3,8 +3,11 @@ import { middleware, procedure } from "./trpc-server";
import { TRPCError } from "@trpc/server"; 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 { logger } from "@saleor/apps-shared"; import { createLogger, logger } from "@saleor/apps-shared";
import { createClient } from "../../lib/create-graphq-client"; import { GraphqlClientFactory } from "../../lib/create-graphq-client";
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
import { createSettingsManager } from "../../lib/metadata-manager";
import { AppConfig } from "../app-configuration/app-config";
const attachAppToken = middleware(async ({ ctx, next }) => { const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware"); logger.debug("attachAppToken middleware");
@ -98,22 +101,36 @@ const validateClientToken = middleware(async ({ ctx, next, meta }) => {
* *
* Can be used only if called from the frontend (react-query), * Can be used only if called from the frontend (react-query),
* otherwise jwks validation will fail (if createCaller used) * otherwise jwks validation will fail (if createCaller used)
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/ */
export const protectedClientProcedure = procedure export const protectedClientProcedure = procedure
.use(attachAppToken) .use(attachAppToken)
.use(validateClientToken) .use(validateClientToken)
.use(async ({ ctx, next }) => { .use(async ({ ctx, next, path, type }) => {
const client = createClient(ctx.saleorApiUrl, async () => const client = GraphqlClientFactory.fromAuthData({
Promise.resolve({ token: ctx.appToken }) token: ctx.appToken!,
); saleorApiUrl: ctx.saleorApiUrl,
});
const metadataManager = new AppConfigMetadataManager(createSettingsManager(client));
return next({ return next({
ctx: { ctx: {
apiClient: client, apiClient: client,
appToken: ctx.appToken, appToken: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl, saleorApiUrl: ctx.saleorApiUrl,
appId: ctx.appId,
appConfigMetadataManager: metadataManager,
getConfig: async () => {
const metadata = await metadataManager.get();
return metadata ? AppConfig.parse(metadata) : new AppConfig();
},
logger: createLogger({
appId: ctx.appId,
apiUrl: ctx.saleorApiUrl,
type,
path,
}),
}, },
}); });
}); });

View file

@ -1,4 +1,4 @@
import { channelsRouter } from "../channels/channels.router"; import { channelsRouter } from "../app-configuration/channels/channels.router";
import { router } from "./trpc-server"; import { router } from "./trpc-server";
import { appConfigurationRouter } from "../app-configuration/app-configuration.router"; import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
import { categoryMappingRouter } from "../category-mapping/category-mapping.router"; import { categoryMappingRouter } from "../category-mapping/category-mapping.router";

View file

@ -1,5 +1,5 @@
import * as trpcNext from "@trpc/server/adapters/next"; import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server"; import { inferAsyncReturnType } from "@trpc/server";
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => { export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {

View file

@ -1,21 +0,0 @@
import { makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
const useStyles = makeStyles((theme) => ({
root: {
display: "grid",
gridTemplateColumns: "280px auto 400px",
alignItems: "start",
gap: theme.spacing(3),
marginTop: theme.spacing(3),
marginLeft: theme.spacing(3),
},
}));
type AppColumnsLayoutProps = PropsWithChildren<{}>;
export const AppColumnsLayout = ({ children }: AppColumnsLayoutProps) => {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
};

View file

@ -0,0 +1,36 @@
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { ReactNode } from "react";
// todo move to shared
export const AppSection = ({
heading,
sideContent,
mainContent,
includePadding = false,
...props
}: PropsWithBox<{
heading: string;
sideContent?: ReactNode;
mainContent: ReactNode;
includePadding?: boolean;
}>) => {
return (
<Box as="section" __gridTemplateColumns={"400px auto"} display={"grid"} gap={13} {...props}>
<Box>
<Text as="h2" variant={"heading"} size={"large"} marginBottom={4}>
{heading}
</Text>
{sideContent}
</Box>
<Box
borderStyle={"solid"}
borderColor={"neutralPlain"}
borderWidth={1}
padding={includePadding ? 8 : 0}
borderRadius={4}
>
{mainContent}
</Box>
</Box>
);
};

View file

@ -1,47 +0,0 @@
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

@ -0,0 +1,3 @@
import { Text, TextProps } from "@saleor/macaw-ui/next";
export const Paragraph = (props: TextProps) => <Text as={"p"} marginBottom={4} {...props} />;

View file

@ -1,97 +1,39 @@
import "../styles/globals.css"; import "@saleor/macaw-ui/next/style";
import { createGenerateClassName, Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; import React from "react";
import {
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import { QueryClient, QueryClientProvider } from "react-query";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { ThemeSynchronizer } from "../lib/theme-synchronizer"; import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper"; import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
import { NoSSRWrapper } from "@saleor/apps-shared";
import { trpcClient } from "../modules/trpc/trpc-client"; import { trpcClient } from "../modules/trpc/trpc-client";
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
/**
* Temporary override of colors, to match new dashboard palette.
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
*/
const palettes: PalettesOverride = {
light: {
...light,
background: {
default: "#fff",
paper: "#fff",
},
},
dark: {
...dark,
background: {
default: "hsla(211, 42%, 14%, 1)",
paper: "hsla(211, 42%, 14%, 1)",
},
},
};
const themeOverrides: Partial<Theme> = {
overrides: {
MuiTableCell: {
body: {
paddingBottom: 8,
paddingTop: 8,
},
root: {
height: 56,
paddingBottom: 4,
paddingTop: 4,
},
},
},
};
const generateClassName = createGenerateClassName({
productionPrefix: "c",
disableGlobal: true,
});
/** /**
* Ensure instance is a singleton. * Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/ */
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined; export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
/** const queryClient = new QueryClient({
* That's a hack required by Macaw-UI incompatibility with React@18 defaultOptions: {
*/ queries: {
const ThemeProvider = MacawUIThemeProvider as React.FC< refetchOnWindowFocus: false,
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }> },
>; },
});
function NextApp({ Component, pageProps }: AppProps) { function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return ( return (
<NoSSRWrapper> <NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}> <AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr={false}> <ThemeProvider>
<ThemeSynchronizer /> <ThemeSynchronizer />
<RoutePropagator /> <RoutePropagator />
<Component {...pageProps} /> <QueryClientProvider client={queryClient}>
<Box padding={13}>
<Component {...pageProps} />
</Box>
</QueryClientProvider>
</ThemeProvider> </ThemeProvider>
</AppBridgeProvider> </AppBridgeProvider>
</NoSSRWrapper> </NoSSRWrapper>

View file

@ -3,24 +3,37 @@ import { initUrqlClient } from "next-urql";
import { GoogleFeedProductVariantFragment } from "../../../../../../generated/graphql"; import { GoogleFeedProductVariantFragment } from "../../../../../../generated/graphql";
import { apl } from "../../../../../saleor-app"; import { apl } from "../../../../../saleor-app";
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { fetchProductData } from "../../../../../lib/google-feed/fetch-product-data"; import { fetchProductData } from "../../../../../modules/google-feed/fetch-product-data";
import { getGoogleFeedSettings } from "../../../../../lib/google-feed/get-google-feed-settings"; import { GoogleFeedSettingsFetcher } from "../../../../../modules/google-feed/get-google-feed-settings";
import { generateGoogleXmlFeed } from "../../../../../lib/google-feed/generate-google-xml-feed"; import { generateGoogleXmlFeed } from "../../../../../modules/google-feed/generate-google-xml-feed";
import { fetchShopData } from "../../../../../lib/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 { createClient } from "../../../../../lib/create-graphq-client"; import { GraphqlClientFactory } from "../../../../../lib/create-graphq-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 { S3BucketConfiguration } from "../../../../../modules/app-configuration/app-config";
import { getFileDetails } from "../../../../../modules/file-storage/s3/get-file-details"; import { getFileDetails } from "../../../../../modules/file-storage/s3/get-file-details";
import { getDownloadUrl, getFileName } from "../../../../../modules/file-storage/s3/urls-and-names"; import { getDownloadUrl, getFileName } from "../../../../../modules/file-storage/s3/urls-and-names";
import { RootConfig } from "../../../../../modules/app-configuration/app-config";
import { z, ZodError } from "zod";
// By default we cache the feed for 5 minutes. This can be changed by setting the FEED_CACHE_MAX_AGE // By default we cache the feed for 5 minutes. This can be changed by setting the FEED_CACHE_MAX_AGE
const FEED_CACHE_MAX_AGE = process.env.FEED_CACHE_MAX_AGE const FEED_CACHE_MAX_AGE = process.env.FEED_CACHE_MAX_AGE
? parseInt(process.env.FEED_CACHE_MAX_AGE, 10) ? parseInt(process.env.FEED_CACHE_MAX_AGE, 10)
: 60 * 5; : 60 * 5;
const validateRequestParams = (req: NextApiRequest) => {
const queryShape = z.object({
url: z.string().url("Valid API URL must be provided"),
channel: z.string().min(1, "Provide valid channel slug"),
});
queryShape.parse(req.query);
};
/**
* TODO Refactor and test
*/
export const handler = async (req: NextApiRequest, res: NextApiResponse) => { export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const url = req.query.url as string; const url = req.query.url as string;
const channel = req.query.channel as string; const channel = req.query.channel as string;
@ -33,14 +46,12 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
logger.debug("Feed route visited"); logger.debug("Feed route visited");
if (!url.length) { try {
logger.error("Missing URL param"); validateRequestParams(req);
return res.status(400).json({ error: "No url parameter" }); } catch (e) {
} const error = e as ZodError;
if (!channel.length) { return res.status(400).json({ error: error.flatten().fieldErrors });
logger.error("Missing channel param");
return res.status(400).json({ error: "No channel parameter" });
} }
logger.debug("Checking if app is installed in the given env"); logger.debug("Checking if app is installed in the given env");
@ -53,31 +64,36 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
logger.debug("The app is registered for the given URL, checking the configuration"); logger.debug("The app is registered for the given URL, checking the configuration");
// use unauthorized client to eliminate possibility of spilling the non-public data /**
* use unauthorized client to eliminate possibility of spilling the non-public data
*/
const client = initUrqlClient( const client = initUrqlClient(
{ {
url: authData.saleorApiUrl, url: authData.saleorApiUrl,
}, },
false /* set to false to disable suspense */ false
); );
if (!client) { if (!client) {
logger.error("Can't create the gql client"); logger.error("Can't create the gql client");
return res.status(500).end();
return res.status(500).send("Error creating feed");
} }
let storefrontUrl: string; let storefrontUrl: string;
let productStorefrontUrl: string; let productStorefrontUrl: string;
let bucketConfiguration: S3BucketConfiguration | undefined; let bucketConfiguration: RootConfig["s3"] | undefined;
try { try {
const settings = await getGoogleFeedSettings({ authData, channel }); const settingsFetcher = GoogleFeedSettingsFetcher.createFromAuthData(authData);
const settings = await settingsFetcher.fetch(channel);
storefrontUrl = settings.storefrontUrl; storefrontUrl = settings.storefrontUrl;
productStorefrontUrl = settings.productStorefrontUrl; productStorefrontUrl = settings.productStorefrontUrl;
bucketConfiguration = settings.s3BucketConfiguration; bucketConfiguration = settings.s3BucketConfiguration;
} catch (error) { } catch (error) {
logger.warn("The application has not been configured"); logger.warn("The application has not been configured");
return res return res
.status(400) .status(400)
.json({ error: "Please configure the Google Feed settings at the dashboard" }); .json({ error: "Please configure the Google Feed settings at the dashboard" });
@ -93,11 +109,13 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
shopDescription = shopDetails.shopDescription; shopDescription = shopDetails.shopDescription;
} catch (error) { } catch (error) {
logger.error("Could not fetch the shop details"); logger.error("Could not fetch the shop details");
return res.status(500).json({ error: "Could not fetch the shop details" }); return res.status(500).json({ error: "Could not fetch the shop details" });
} }
if (bucketConfiguration) { if (bucketConfiguration) {
logger.debug("Bucket configuration found, checking if the feed has been generated recently"); logger.debug("Bucket configuration found, checking if the feed has been generated recently");
const s3Client = createS3ClientFromConfiguration(bucketConfiguration); const s3Client = createS3ClientFromConfiguration(bucketConfiguration);
const fileName = getFileName({ const fileName = getFileName({
saleorApiUrl: authData.saleorApiUrl, saleorApiUrl: authData.saleorApiUrl,
@ -136,9 +154,7 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
logger.debug("Generating a new feed"); logger.debug("Generating a new feed");
const cacheClient = createClient(authData.saleorApiUrl, async () => const cacheClient = GraphqlClientFactory.fromAuthData(authData);
Promise.resolve({ token: authData.token })
);
if (!cacheClient) { if (!cacheClient) {
logger.error("Can't create the gql client"); logger.error("Can't create the gql client");

View file

@ -2,6 +2,11 @@ import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppManifest } from "@saleor/app-sdk/types"; import { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { webhookProductCreated } from "./webhooks/product_created";
import { webhookProductDeleted } from "./webhooks/product_deleted";
import { webhookProductVariantCreated } from "./webhooks/product_variant_created";
import { webhookProductVariantDeleted } from "./webhooks/product_variant_deleted";
import { webhookProductVariantUpdated } from "./webhooks/product_variant_updated";
export default createManifestHandler({ export default createManifestHandler({
async manifestFactory(context) { async manifestFactory(context) {
@ -10,9 +15,15 @@ export default createManifestHandler({
tokenTargetUrl: `${context.appBaseUrl}/api/register`, tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: context.appBaseUrl, appUrl: context.appBaseUrl,
permissions: ["MANAGE_PRODUCTS"], permissions: ["MANAGE_PRODUCTS"],
id: "saleor.app.products-feed", id: "saleor.app.product-feed",
version: packageJson.version, version: packageJson.version,
webhooks: [], webhooks: [
webhookProductCreated.getWebhookManifest(context.appBaseUrl),
webhookProductDeleted.getWebhookManifest(context.appBaseUrl),
webhookProductVariantCreated.getWebhookManifest(context.appBaseUrl),
webhookProductVariantDeleted.getWebhookManifest(context.appBaseUrl),
webhookProductVariantUpdated.getWebhookManifest(context.appBaseUrl),
],
extensions: [], extensions: [],
author: "Saleor Commerce", author: "Saleor Commerce",
supportUrl: "https://github.com/saleor/apps/discussions", supportUrl: "https://github.com/saleor/apps/discussions",

View file

@ -0,0 +1,41 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { createLogger } from "@saleor/apps-shared";
import { saleorApp } from "../../../saleor-app";
import {
ProductCreatedDocument,
ProductWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export const webhookProductCreated = new SaleorAsyncWebhook<ProductWebhookPayloadFragment>({
webhookPath: "api/webhooks/product_created",
event: "PRODUCT_CREATED",
apl: saleorApp.apl,
query: ProductCreatedDocument,
// todo make it disabled by default, enable when app is configured
isActive: true,
});
const logger = createLogger({
service: "webhook-product_created",
});
export const handler: NextWebhookApiHandler<ProductWebhookPayloadFragment> = async (
req,
res,
context
) => {
await updateCacheOnWebhook({
authData: context.authData,
channels: context.payload,
res,
});
};
export default webhookProductCreated.createHandler(handler);

View file

@ -0,0 +1,40 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
ProductDeletedDocument,
ProductWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../saleor-app";
import { createLogger } from "@saleor/apps-shared";
import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export const webhookProductDeleted = new SaleorAsyncWebhook<ProductWebhookPayloadFragment>({
webhookPath: "api/webhooks/product_deleted",
event: "PRODUCT_DELETED",
apl: saleorApp.apl,
query: ProductDeletedDocument,
isActive: true,
});
const logger = createLogger({
service: "webhook_product_deleted",
});
export const handler: NextWebhookApiHandler<ProductWebhookPayloadFragment> = async (
req,
res,
context
) => {
await updateCacheOnWebhook({
authData: context.authData,
channels: context.payload,
res,
});
};
export default webhookProductDeleted.createHandler(handler);

View file

@ -0,0 +1,40 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
ProductUpdatedDocument,
ProductWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../saleor-app";
import { createLogger } from "@saleor/apps-shared";
import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export const webhookProductUpdated = new SaleorAsyncWebhook<ProductWebhookPayloadFragment>({
webhookPath: "api/webhooks/product_updated",
event: "PRODUCT_UPDATED",
apl: saleorApp.apl,
query: ProductUpdatedDocument,
isActive: true,
});
const logger = createLogger({
service: "webhookProductUpdatedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductWebhookPayloadFragment> = async (
req,
res,
context
) => {
await updateCacheOnWebhook({
authData: context.authData,
channels: context.payload,
res,
});
};
export default webhookProductUpdated.createHandler(handler);

View file

@ -0,0 +1,41 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
ProductVariantCreatedDocument,
ProductVariantWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../saleor-app";
import { createLogger } from "@saleor/apps-shared";
import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export const webhookProductVariantCreated =
new SaleorAsyncWebhook<ProductVariantWebhookPayloadFragment>({
webhookPath: "api/webhooks/product_variant_created",
event: "PRODUCT_VARIANT_CREATED",
apl: saleorApp.apl,
query: ProductVariantCreatedDocument,
isActive: true,
});
const logger = createLogger({
service: "PRODUCT_VARIANT_CREATED webhook",
});
export const handler: NextWebhookApiHandler<ProductVariantWebhookPayloadFragment> = async (
req,
res,
context
) => {
await updateCacheOnWebhook({
authData: context.authData,
channels: context.payload,
res,
});
};
export default webhookProductVariantCreated.createHandler(handler);

View file

@ -0,0 +1,41 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { createLogger } from "@saleor/apps-shared";
import {
ProductVariantDeletedDocument,
ProductVariantWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../saleor-app";
import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export const webhookProductVariantDeleted =
new SaleorAsyncWebhook<ProductVariantWebhookPayloadFragment>({
webhookPath: "api/webhooks/product_variant_deleted",
event: "PRODUCT_VARIANT_DELETED",
apl: saleorApp.apl,
query: ProductVariantDeletedDocument,
isActive: true,
});
const logger = createLogger({
service: "PRODUCT_VARIANT_DELETED",
});
export const handler: NextWebhookApiHandler<ProductVariantWebhookPayloadFragment> = async (
req,
res,
context
) => {
await updateCacheOnWebhook({
authData: context.authData,
channels: context.payload,
res,
});
};
export default webhookProductVariantDeleted.createHandler(handler);

View file

@ -0,0 +1,41 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { createLogger } from "@saleor/apps-shared";
import {
ProductVariantUpdatedDocument,
ProductVariantWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../saleor-app";
import { updateCacheOnWebhook } from "../../../modules/metadata-cache/update-cache-on-webhook";
export const config = {
api: {
bodyParser: false,
},
};
export const webhookProductVariantUpdated =
new SaleorAsyncWebhook<ProductVariantWebhookPayloadFragment>({
webhookPath: "api/webhooks/product_variant_updated",
event: "PRODUCT_VARIANT_UPDATED",
apl: saleorApp.apl,
query: ProductVariantUpdatedDocument,
isActive: true,
});
const logger = createLogger({
service: "webhookProductVariantUpdatedWebhookHandler",
});
export const handler: NextWebhookApiHandler<ProductVariantWebhookPayloadFragment> = async (
req,
res,
context
) => {
await updateCacheOnWebhook({
authData: context.authData,
channels: context.payload,
res,
});
};
export default webhookProductVariantUpdated.createHandler(handler);

View file

@ -1,25 +1,18 @@
import { NextPage } from "next"; 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"; import { CategoryMapping } from "../modules/category-mapping/ui/category-mapping";
import { Breadcrumbs } from "@saleor/apps-ui";
import { Box } from "@saleor/macaw-ui/next";
const ConfigurationPage: NextPage = () => { const CategoriesPage: 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 ( return (
<ConfigurationPageBaseLayout> <Box>
<Breadcrumbs marginBottom={8}>
<Breadcrumbs.Item href={"/"}>Configuration</Breadcrumbs.Item>
<Breadcrumbs.Item>Categories Mapping</Breadcrumbs.Item>
</Breadcrumbs>
<CategoryMapping /> <CategoryMapping />
</ConfigurationPageBaseLayout> </Box>
); );
}; };
export default ConfigurationPage; export default CategoriesPage;

View file

@ -1,24 +1,142 @@
import { NextPage } from "next"; import { NextPage } from "next";
import React, { useEffect } from "react"; import React from "react";
import { ChannelsConfiguration } from "../modules/app-configuration/ui/channels-configuration";
import { trpcClient } from "../modules/trpc/trpc-client"; import { useChannelsExistenceChecking } from "../modules/app-configuration/channels/use-channels-existence-checking";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { AppSection } from "../modules/ui/app-section";
import { Paragraph } from "../modules/ui/paragraph";
import { TextLink } from "@saleor/apps-ui";
import { ConnectedS3ConfigurationForm } from "../modules/app-configuration/s3-configuration-form";
import { ChannelsConfigAccordion } from "../modules/app-configuration/channels-config-accordion";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ConfigurationPageBaseLayout } from "../modules/ui/configuration-page-base-layout"; import { CategoryMappingPreview } from "../modules/category-mapping/ui/category-mapping-preview";
const ConfigurationPage: NextPage = () => { const ConfigurationPage: NextPage = () => {
const channels = trpcClient.channels.fetch.useQuery(); useChannelsExistenceChecking();
const router = useRouter(); const { push } = useRouter();
useEffect(() => {
if (channels.isSuccess && channels.data.length === 0) {
router.push("/not-ready");
}
}, [channels.data, channels.isSuccess]);
return ( return (
<ConfigurationPageBaseLayout> <Box>
<ChannelsConfiguration /> <Box __marginBottom="100px">
</ConfigurationPageBaseLayout> <Text variant={"hero"} size={"medium"} as={"h1"} marginBottom={8}>
Configuration
</Text>
<Paragraph>
Configure app to enable Product Feed, that{" "}
<TextLink
newTab
href={"https://www.google.com/intl/en_en/retail/solutions/merchant-center/"}
>
Google Merchant Center
</TextLink>{" "}
can consume
</Paragraph>
<Text>
Check{" "}
<TextLink href={"https://support.google.com/merchants/answer/1219255"} newTab>
this article how to configure feed
</TextLink>
</Text>
</Box>
<AppSection
__marginBottom="100px"
includePadding
heading={"AWS S3 Bucket"}
mainContent={<ConnectedS3ConfigurationForm />}
sideContent={
<Box>
<Paragraph size={"small"}>
Your product database can be quite large. To generate a big XML file, we need to put
it somewhere so Google can consume it. You need to generate an S3 bucket with public
read access and provide its credentials to the app.
</Paragraph>
<Paragraph size={"small"}>
Please use this bucket only for XMLs. They meant to be public - but other files may
leak if you put them to the same bucket.
</Paragraph>
<Paragraph size={"small"}>
Read about generating AWS credentials{" "}
<TextLink
href={
"https://docs.aws.amazon.com/powershell/latest/userguide/pstools-appendix-sign-up.html"
}
newTab
>
here
</TextLink>
</Paragraph>
</Box>
}
/>
<AppSection
__marginBottom="100px"
includePadding
heading={"Channels configuration"}
mainContent={
<Box>
<ChannelsConfigAccordion />
</Box>
}
sideContent={
<Box>
<Paragraph size={"small"}>App will generate separate feed for each channel</Paragraph>
<Paragraph size={"small"}>
Provide your storefront homepage URL and product template. Use following placeholders:
</Paragraph>
<ul>
<li>
<Text size={"small"}>
<code>{"{productSlug}"}</code> - to inject slug of the product
</Text>
</li>
<li>
<Text size={"small"}>
<code>{"{variantId}"}</code> - to inject id of the variant
</Text>
</li>
<li>
<Text size={"small"}>
<code>{"{productId}"}</code> - to inject id of the product
</Text>
</li>
</ul>
<Paragraph size={"small"}>For example following pattern:</Paragraph>
<Paragraph size={"small"}>
<code>{"https://my-shop.com/p/{productSlug}/{variantId}"}</code>
</Paragraph>
<Paragraph size={"small"}>Will produce:</Paragraph>
<Paragraph size={"small"}>
<code>{"https://my-shop.com/p/t-shirt/Xyp2asZ"}</code>
</Paragraph>
</Box>
}
/>
<AppSection
__marginBottom="100px"
includePadding
heading={"Categories mapping"}
mainContent={
<Box>
<CategoryMappingPreview />
<Box display={"flex"} justifyContent={"flex-end"}>
<Button marginTop={8} onClick={() => push("/categories")}>
Map categories
</Button>
</Box>
</Box>
}
sideContent={
<Box>
<Paragraph size={"small"}>
Map Saleor categories to pre-defined Google categories. It is not required.{" "}
<TextLink newTab href={"https://support.google.com/merchants/answer/6324436?hl=en"}>
Read more
</TextLink>
</Paragraph>
</Box>
}
/>
</Box>
); );
}; };

View file

@ -3,8 +3,8 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect } from "react"; import { useEffect } from "react";
import { useIsMounted } from "usehooks-ts"; import { useIsMounted } from "usehooks-ts";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { LinearProgress } from "@material-ui/core"; import { isInIframe } from "@saleor/apps-shared";
import { isInIframe } from "../lib/is-in-iframe"; import { Text } from "@saleor/macaw-ui/next";
const IndexPage: NextPage = () => { const IndexPage: NextPage = () => {
const { appBridgeState } = useAppBridge(); const { appBridgeState } = useAppBridge();
@ -18,14 +18,16 @@ const IndexPage: NextPage = () => {
}, [isMounted, appBridgeState?.ready]); }, [isMounted, appBridgeState?.ready]);
if (isInIframe()) { if (isInIframe()) {
return <LinearProgress />; return <Text color={"textNeutralSubdued"}>Loading...</Text>;
} }
return ( return (
<div> <div>
<h1>Saleor Product Feed</h1> <Text variant={"hero"} as={"h1"} marginBottom={8}>
<p>This is Saleor App that allows product feed generation</p> Saleor Product Feed
<p>Install app in your Saleor instance and open in with Dashboard</p> </Text>
<Text as={"p"}>This is Saleor App that allows product feed generation</Text>
<Text as={"p"}>Install app in your Saleor instance and open in with Dashboard</Text>
</div> </div>
); );
}; };

View file

@ -1,6 +1,5 @@
import { AlertBase, Button } from "@saleor/macaw-ui"; import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { Typography } from "@material-ui/core";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
const NotReadyPage = () => { const NotReadyPage = () => {
@ -8,14 +7,10 @@ const NotReadyPage = () => {
return ( return (
<div> <div>
<h1>Saleor Invoices App</h1> <h1>Saleor Product Feed App</h1>
<AlertBase variant="error"> <Box>
<Typography variant="h3" paragraph> <Text>App can not be used</Text>
App can not be used <Text as={"p"}>To configure Product Feed App you need to create at least 1 channel</Text>
</Typography>
<Typography paragraph>
To configure Invoices App you need to create at least 1 channel
</Typography>
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
@ -28,7 +23,7 @@ const NotReadyPage = () => {
> >
Set up channel Set up channel
</Button> </Button>
</AlertBase> </Box>
</div> </div>
); );
}; };

View file

@ -1,21 +0,0 @@
body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
color: #111;
}
code {
background: #f6f8fa;
border: 1px solid #eaeaea;
border-radius: 5px;
display: inline-block;
margin-top: 10px;
padding: 0.75rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
code::before {
content: "$ ";
opacity: 0.6;
}

View file

@ -1,4 +1,4 @@
import { Accordion, Box, Chip, Text } from "@saleor/macaw-ui/next"; import { Accordion, Box, Text } from "@saleor/macaw-ui/next";
import { EventDeliveryStatusEnum } from "../../generated/graphql"; import { EventDeliveryStatusEnum } from "../../generated/graphql";
import { useWebhooksStatus } from "../lib/useWebhooksStatus"; import { useWebhooksStatus } from "../lib/useWebhooksStatus";
import { SemanticChip } from "@saleor/apps-ui"; import { SemanticChip } from "@saleor/apps-ui";

View file

@ -25,17 +25,6 @@ const queryClient = new QueryClient({
}); });
function NextApp({ Component, pageProps }: AppProps) { function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return ( return (
<NoSSRWrapper> <NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}> <AppBridgeProvider appBridgeInstance={appBridgeInstance}>

View file

@ -1072,26 +1072,23 @@ importers:
specifier: ^3.332.0 specifier: ^3.332.0
version: 3.332.0 version: 3.332.0
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^2.9.10 specifier: ^3.1.0
version: 2.9.11(react-hook-form@7.43.1) version: 3.1.0(react-hook-form@7.43.9)
'@material-ui/core':
specifier: ^4.12.4
version: 4.12.4(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
'@material-ui/icons':
specifier: ^4.11.3
version: 4.11.3(@material-ui/core@4.12.4)(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
'@material-ui/lab':
specifier: 4.0.0-alpha.61
version: 4.0.0-alpha.61(@material-ui/core@4.12.4)(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
'@saleor/app-sdk': '@saleor/app-sdk':
specifier: 0.39.1 specifier: 0.39.1
version: 0.39.1(next@13.3.0)(react-dom@18.2.0)(react@18.2.0) version: 0.39.1(next@13.3.0)(react-dom@18.2.0)(react@18.2.0)
'@saleor/apps-shared': '@saleor/apps-shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@saleor/apps-ui':
specifier: workspace:*
version: link:../../packages/ui
'@saleor/macaw-ui': '@saleor/macaw-ui':
specifier: ^0.7.2 specifier: 0.8.0-pre.84
version: 0.7.2(@material-ui/core@4.12.4)(@material-ui/icons@4.11.3)(@material-ui/lab@4.0.0-alpha.61)(@types/react@18.0.27)(react-dom@18.2.0)(react-helmet@6.1.0)(react@18.2.0) version: 0.8.0-pre.84(@types/react@18.0.27)(react-dom@18.2.0)(react@18.2.0)
'@saleor/react-hook-form-macaw':
specifier: workspace:*
version: link:../../packages/react-hook-form-macaw
'@sentry/nextjs': '@sentry/nextjs':
specifier: ^7.52.1 specifier: ^7.52.1
version: 7.52.1(next@13.3.0)(react@18.2.0) version: 7.52.1(next@13.3.0)(react@18.2.0)
@ -1150,11 +1147,14 @@ importers:
specifier: 18.2.0 specifier: 18.2.0
version: 18.2.0(react@18.2.0) version: 18.2.0(react@18.2.0)
react-hook-form: react-hook-form:
specifier: ^7.43.0 specifier: ^7.43.9
version: 7.43.1(react@18.2.0) version: 7.43.9(react@18.2.0)
react-is: react-is:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.2.0 version: 18.2.0
react-query:
specifier: ^3.39.3
version: 3.39.3(react-dom@18.2.0)(react@18.2.0)
urql: urql:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3(graphql@16.6.0)(react@18.2.0) version: 3.0.3(graphql@16.6.0)(react@18.2.0)