🔥 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:
parent
0c2fc65ac6
commit
182bdd6b32
99 changed files with 2340 additions and 1458 deletions
12
.changeset/plenty-dolls-arrive.md
Normal file
12
.changeset/plenty-dolls-arrive.md
Normal 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
|
1
apps/products-feed/.env.example
Normal file
1
apps/products-feed/.env.example
Normal file
|
@ -0,0 +1 @@
|
||||||
|
APP_DEBUG=info
|
|
@ -0,0 +1,8 @@
|
||||||
|
fragment ProductVariantWebhookPayload on ProductVariant {
|
||||||
|
channel
|
||||||
|
channelListings {
|
||||||
|
channel {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
fragment ProductWebhookPayload on Product {
|
||||||
|
channel
|
||||||
|
channelListings {
|
||||||
|
channel {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
query FetchCategoriesWithMapping{
|
query FetchCategoriesWithMapping($cursor: String){
|
||||||
categories(first: 10){
|
categories(first: 100, after: $cursor){
|
||||||
pageInfo{
|
pageInfo{
|
||||||
hasNextPage
|
hasNextPage
|
||||||
endCursor
|
endCursor
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
subscription ProductCreated {
|
||||||
|
event {
|
||||||
|
... on ProductCreated {
|
||||||
|
product {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
subscription ProductDeleted {
|
||||||
|
event {
|
||||||
|
... on ProductDeleted {
|
||||||
|
product {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
subscription ProductUpdated {
|
||||||
|
event {
|
||||||
|
... on ProductUpdated {
|
||||||
|
product {
|
||||||
|
...ProductWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
subscription ProductVariantCreated {
|
||||||
|
event {
|
||||||
|
... on ProductVariantCreated {
|
||||||
|
productVariant {
|
||||||
|
...ProductVariantWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
subscription ProductVariantDeleted {
|
||||||
|
event {
|
||||||
|
... on ProductVariantDeleted {
|
||||||
|
productVariant {
|
||||||
|
...ProductVariantWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
subscription ProductVariantUpdated {
|
||||||
|
event {
|
||||||
|
... on ProductVariantUpdated {
|
||||||
|
productVariant {
|
||||||
|
...ProductVariantWebhookPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 =
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,7 +0,0 @@
|
||||||
export function isInIframe() {
|
|
||||||
try {
|
|
||||||
return window.self !== window.top;
|
|
||||||
} catch (e) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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);
|
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { sellerShopConfigSchema } from "./app-config";
|
|
||||||
|
|
||||||
export const appConfigInputSchema = z.object({
|
|
||||||
shopConfigPerChannel: z.record(sellerShopConfigSchema),
|
|
||||||
});
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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 {
|
|
@ -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 ?? []);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
|
@ -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]);
|
||||||
|
};
|
|
@ -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: {} }
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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} />;
|
||||||
|
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { SellerShopConfig } from "./app-config";
|
|
||||||
|
|
||||||
export const UrlConfiguration = {
|
|
||||||
createEmpty(): SellerShopConfig["urlConfiguration"] {
|
|
||||||
return {
|
|
||||||
storefrontUrl: "",
|
|
||||||
productStorefrontUrl: "",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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 ?? []);
|
|
||||||
}),
|
|
||||||
});
|
|
3
apps/products-feed/src/modules/editor-js/readme.md
Normal file
3
apps/products-feed/src/modules/editor-js/readme.md
Normal 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
|
|
@ -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!"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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));
|
|
@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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`;
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
|
@ -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>"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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),
|
||||||
},
|
},
|
||||||
],
|
],
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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" }]),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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": [
|
||||||
{
|
{
|
|
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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: [
|
||||||
{
|
{
|
|
@ -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>>>;
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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");
|
||||||
};
|
};
|
||||||
|
|
|
@ -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();
|
||||||
|
};
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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>;
|
|
||||||
};
|
|
36
apps/products-feed/src/modules/ui/app-section.tsx
Normal file
36
apps/products-feed/src/modules/ui/app-section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
3
apps/products-feed/src/modules/ui/paragraph.tsx
Normal file
3
apps/products-feed/src/modules/ui/paragraph.tsx
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { Text, TextProps } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
export const Paragraph = (props: TextProps) => <Text as={"p"} marginBottom={4} {...props} />;
|
|
@ -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>
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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",
|
||||||
|
|
41
apps/products-feed/src/pages/api/webhooks/product_created.ts
Normal file
41
apps/products-feed/src/pages/api/webhooks/product_created.ts
Normal 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);
|
40
apps/products-feed/src/pages/api/webhooks/product_deleted.ts
Normal file
40
apps/products-feed/src/pages/api/webhooks/product_deleted.ts
Normal 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);
|
40
apps/products-feed/src/pages/api/webhooks/product_updated.ts
Normal file
40
apps/products-feed/src/pages/api/webhooks/product_updated.ts
Normal 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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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";
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue