Introduce CRM App (#292)
* Add template Cleanup Update queries Basic mailchimp client Add Oauth flow remove next auth * Auth flow, mailchimp button * Basic layouts * session saver for appbridge * ideas drop * basic iframe flow * iframe flow - with post message * saving token in backend * saving token in backend * Extracted settings manager * Refactor audience router * Remove old macaw ui * Nav and layout * display lists and wip add customer * Metadata updater * Allow iframe to NOT have app layout * Add segment coming soon * App layout * Add removing config button * Add iframe * Fix postmessages * Display lists * webhook settings * Connect webhook * remove comment * batch customer adding client * Update macaw * Fetching customers component * Add bulk sync * add temp ts-ignore until macaw is fixed * Improve ui * redesign * Extract sections * Redesign mailchimp login * Refactor sections * Extract mailchimp list picker * Add name mapping * WIP tags, extracted colocated queries to folders * Wip - not working CustomerUpdated subs * Add instructions * Fix webhook config state * Add external links * envs * Protected Oauth handler * Fix instructions * Squash some todos * Instructions update with gql * empty trygger * Add env validation * Fix error message * Update macaw and remove todos * Add metadata manager test * Replace Mailchimp enum to lowercase * Update oauth routes * Fix typo * Add loader to removing config box * Update labeler to include CRM app * Apply suggestions from CR * Fix linter
This commit is contained in:
parent
eca52ad058
commit
3acc1553a8
84 changed files with 40421 additions and 22 deletions
1
.github/labeler.yml
vendored
1
.github/labeler.yml
vendored
|
@ -8,3 +8,4 @@
|
|||
"App: Taxes": "apps/taxes/**/*"
|
||||
"App: Emails & Messages": "apps/emails-and-messages/**/*"
|
||||
"App: CMS": "apps/cms/**/*"
|
||||
"App: CRM": "apps/crm/**/*"
|
||||
|
|
12
apps/crm/.env.example
Normal file
12
apps/crm/.env.example
Normal file
|
@ -0,0 +1,12 @@
|
|||
# The key used for metadata encryption. Required for production builds
|
||||
SECRET_KEY=
|
||||
|
||||
# APL Config. Production keys are required
|
||||
APL=
|
||||
REST_APL_ENDPOINT=
|
||||
REST_APL_TOKEN=
|
||||
|
||||
MAILCHIMP_CLIENT_ID=
|
||||
MAILCHIMP_CLIENT_SECRET=
|
||||
|
||||
APP_DEBUG=info
|
4
apps/crm/.eslintrc
Normal file
4
apps/crm/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
20
apps/crm/.graphqlrc.yml
Normal file
20
apps/crm/.graphqlrc.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
schema: graphql/schema.graphql
|
||||
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
|
||||
extensions:
|
||||
codegen:
|
||||
overwrite: true
|
||||
generates:
|
||||
generated/graphql.ts:
|
||||
config:
|
||||
dedupeFragments: true
|
||||
plugins:
|
||||
- typescript
|
||||
- typescript-operations
|
||||
- urql-introspection
|
||||
- typescript-urql:
|
||||
documentVariablePrefix: "Untyped"
|
||||
fragmentVariablePrefix: "Untyped"
|
||||
- typed-document-node
|
||||
generated/schema.graphql:
|
||||
plugins:
|
||||
- schema-ast
|
7
apps/crm/README.md
Normal file
7
apps/crm/README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||

|
||||
|
||||
<div align="center">
|
||||
<h1>Saleor App CRM</h1>
|
||||
</div>
|
||||
|
||||
TODO - link docs page
|
0
apps/crm/graphql/fragments/.gitkeep
Normal file
0
apps/crm/graphql/fragments/.gitkeep
Normal file
10
apps/crm/graphql/fragments/Customer.graphql
Normal file
10
apps/crm/graphql/fragments/Customer.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
fragment Customer on User {
|
||||
id
|
||||
email
|
||||
lastName
|
||||
firstName
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
fragment CustomerCreatedPayload on CustomerCreated {
|
||||
user {
|
||||
...Customer
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
fragment CustomerUpdatedPayload on CustomerUpdated {
|
||||
user {
|
||||
...Customer
|
||||
}
|
||||
}
|
0
apps/crm/graphql/mutations/.gitkeep
Normal file
0
apps/crm/graphql/mutations/.gitkeep
Normal file
0
apps/crm/graphql/queries/.gitkeep
Normal file
0
apps/crm/graphql/queries/.gitkeep
Normal file
16
apps/crm/graphql/queries/FetchCustomers.graphql
Normal file
16
apps/crm/graphql/queries/FetchCustomers.graphql
Normal file
|
@ -0,0 +1,16 @@
|
|||
query FetchCustomers($cursor: String) {
|
||||
customers(first: 100, after: $cursor) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
... on User {
|
||||
...Customer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29625
apps/crm/graphql/schema.graphql
Normal file
29625
apps/crm/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/crm/graphql/subscriptions/.gitkeep
Normal file
0
apps/crm/graphql/subscriptions/.gitkeep
Normal file
7
apps/crm/graphql/subscriptions/CustomerCreated.graphql
Normal file
7
apps/crm/graphql/subscriptions/CustomerCreated.graphql
Normal file
|
@ -0,0 +1,7 @@
|
|||
subscription CustomerCreated {
|
||||
event {
|
||||
... on CustomerCreated {
|
||||
...CustomerCreatedPayload
|
||||
}
|
||||
}
|
||||
}
|
8
apps/crm/graphql/subscriptions/CustomerUpdated.graphql
Normal file
8
apps/crm/graphql/subscriptions/CustomerUpdated.graphql
Normal file
|
@ -0,0 +1,8 @@
|
|||
# todo Probably should be CustomerMetadataUpdated but codegen fails
|
||||
subscription CustomerUpdated {
|
||||
event {
|
||||
... on CustomerUpdated {
|
||||
...CustomerUpdatedPayload
|
||||
}
|
||||
}
|
||||
}
|
5
apps/crm/next-env.d.ts
vendored
Normal file
5
apps/crm/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
22
apps/crm/next.config.js
Normal file
22
apps/crm/next.config.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
const { z } = require("zod");
|
||||
|
||||
const RequiredEnvs = z.object({
|
||||
MAILCHIMP_CLIENT_ID: z.string().min(5),
|
||||
MAILCHIMP_CLIENT_SECRET: z.string().min(5),
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = () => {
|
||||
try {
|
||||
RequiredEnvs.parse(process.env);
|
||||
} catch (e) {
|
||||
console.error("🚫 Missing required env variables, see message below");
|
||||
console.error(e.issues);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@saleor/apps-shared"],
|
||||
};
|
||||
};
|
73
apps/crm/package.json
Normal file
73
apps/crm/package.json
Normal file
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"name": "saleor-app-crm",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||
"build": "pnpm generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||
"generate": "graphql-codegen",
|
||||
"test": "vitest"
|
||||
},
|
||||
"saleor": {
|
||||
"schemaVersion": "3.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mailchimp/mailchimp_marketing": "^3.0.80",
|
||||
"@saleor/app-sdk": "0.37.1",
|
||||
"@saleor/apps-shared": "workspace:*",
|
||||
"@saleor/macaw-ui": "0.8.0-pre.64",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"@trpc/client": "^10.18.0",
|
||||
"@trpc/next": "^10.18.0",
|
||||
"@trpc/react-query": "^10.18.0",
|
||||
"@trpc/server": "^10.18.0",
|
||||
"@urql/exchange-auth": "^1.0.0",
|
||||
"@vitejs/plugin-react": "^3.0.1",
|
||||
"clsx": "^1.2.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jsdom": "^20.0.3",
|
||||
"next": "13.2.4",
|
||||
"next-urql": "^4.0.2",
|
||||
"pino": "^8.8.0",
|
||||
"pino-pretty": "^9.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.43.0",
|
||||
"react-is": "^18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.0.4",
|
||||
"vitest": "^0.27.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "3.2.2",
|
||||
"@graphql-codegen/introspection": "3.0.1",
|
||||
"@graphql-codegen/typed-document-node": "^3.0.2",
|
||||
"@graphql-codegen/typescript": "3.0.2",
|
||||
"@graphql-codegen/typescript-operations": "3.0.2",
|
||||
"@graphql-codegen/typescript-urql": "^3.7.3",
|
||||
"@graphql-codegen/urql-introspection": "2.2.1",
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/mailchimp__mailchimp_marketing": "^3.0.7",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "13.1.2",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-config-saleor": "workspace:*",
|
||||
"prettier": "^2.8.2",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||
"*.{js,ts,tsx,css,md,json}": "prettier --write"
|
||||
}
|
||||
}
|
7092
apps/crm/pnpm-lock.yaml
Normal file
7092
apps/crm/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
1
apps/crm/src/assets/mailchimp.svg
Normal file
1
apps/crm/src/assets/mailchimp.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.6 KiB |
BIN
apps/crm/src/assets/rudderstack.png
Normal file
BIN
apps/crm/src/assets/rudderstack.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4 KiB |
BIN
apps/crm/src/assets/segment.png
Normal file
BIN
apps/crm/src/assets/segment.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
1
apps/crm/src/const.ts
Normal file
1
apps/crm/src/const.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const appName = "CRM";
|
55
apps/crm/src/lib/app-bridge-persistence.ts
Normal file
55
apps/crm/src/lib/app-bridge-persistence.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type AppBridgeStorageState = {
|
||||
token: string;
|
||||
saleorApiUrl: string;
|
||||
};
|
||||
|
||||
const storageKey = "app-bridge-state";
|
||||
|
||||
/**
|
||||
* Saves token and API url to pass them to the iframe
|
||||
*/
|
||||
export const AppBridgePersistence = {
|
||||
set(requiredState: AppBridgeStorageState) {
|
||||
window.sessionStorage.setItem(storageKey, JSON.stringify(requiredState));
|
||||
},
|
||||
get(): AppBridgeStorageState | null {
|
||||
const storageItem = window.sessionStorage.getItem(storageKey);
|
||||
|
||||
if (!storageItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(storageItem as string);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Set cookie automatically each time AppBridge received token and API URL
|
||||
*/
|
||||
export const AppBridgeStorageSetter = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
useEffect(() => {
|
||||
if (appBridgeState?.saleorApiUrl && appBridgeState?.token) {
|
||||
AppBridgePersistence.set({
|
||||
token: appBridgeState.token,
|
||||
saleorApiUrl: appBridgeState.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}, [appBridgeState?.saleorApiUrl, appBridgeState?.token]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useAppBridgePersistence = () => {
|
||||
const value = useRef(AppBridgePersistence.get());
|
||||
|
||||
return value.current;
|
||||
};
|
48
apps/crm/src/lib/create-graphq-client.ts
Normal file
48
apps/crm/src/lib/create-graphq-client.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||
import {
|
||||
cacheExchange,
|
||||
createClient as urqlCreateClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
} from "urql";
|
||||
|
||||
interface IAuthState {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [
|
||||
dedupExchange,
|
||||
cacheExchange,
|
||||
authExchange<IAuthState>({
|
||||
addAuthToOperation: ({ authState, operation }) => {
|
||||
if (!authState || !authState?.token) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
const fetchOptions =
|
||||
typeof operation.context.fetchOptions === "function"
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {};
|
||||
|
||||
return {
|
||||
...operation,
|
||||
context: {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
"Authorization-Bearer": authState.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
getAuth,
|
||||
}),
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
19
apps/crm/src/lib/graphql-provider.tsx
Normal file
19
apps/crm/src/lib/graphql-provider.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Provider } from "urql";
|
||||
import { createClient } from "./create-graphq-client";
|
||||
|
||||
export function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
|
||||
|
||||
if (!appBridgeState?.saleorApiUrl) {
|
||||
return <div {...props}></div>;
|
||||
}
|
||||
|
||||
const client = createClient(saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: appBridgeState?.token! })
|
||||
);
|
||||
|
||||
return <Provider value={client} {...props} />;
|
||||
}
|
17
apps/crm/src/lib/logger.ts
Normal file
17
apps/crm/src/lib/logger.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: "debug",
|
||||
redact: ["token"],
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const createLogger = logger.child.bind(logger);
|
102
apps/crm/src/lib/metadata-manager.ts
Normal file
102
apps/crm/src/lib/metadata-manager.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
EncryptedMetadataManager,
|
||||
MetadataEntry,
|
||||
SettingsManager,
|
||||
} from "@saleor/app-sdk/settings-manager";
|
||||
import { Client, gql } from "urql";
|
||||
import {
|
||||
FetchAppDetailsDocument,
|
||||
FetchAppDetailsQuery,
|
||||
UpdateAppMetadataDocument,
|
||||
} from "../../generated/graphql";
|
||||
|
||||
gql`
|
||||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
gql`
|
||||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function fetchAllMetadata(client: Pick<Client, "query">): Promise<MetadataEntry[]> {
|
||||
const { error, data } = await client
|
||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
||||
}
|
||||
|
||||
export async function mutateMetadata(
|
||||
client: Pick<Client, "query" | "mutation">,
|
||||
metadata: MetadataEntry[]
|
||||
) {
|
||||
// to update the metadata, ID is required
|
||||
const { error: idQueryError, data: idQueryData } = await client
|
||||
.query(FetchAppDetailsDocument, {})
|
||||
.toPromise();
|
||||
|
||||
if (idQueryError) {
|
||||
throw new Error(
|
||||
"Could not fetch the app id. Please check if auth data for the client are valid."
|
||||
);
|
||||
}
|
||||
|
||||
const appId = idQueryData?.app?.id;
|
||||
|
||||
if (!appId) {
|
||||
throw new Error("Could not fetch the app ID");
|
||||
}
|
||||
|
||||
const { error: mutationError, data: mutationData } = await client
|
||||
.mutation(UpdateAppMetadataDocument, {
|
||||
id: appId,
|
||||
input: metadata,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (mutationError) {
|
||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
||||
}
|
||||
|
||||
return (
|
||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
||||
key: md.key,
|
||||
value: md.value,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
export const createSettingsManager = (
|
||||
client: Pick<Client, "query" | "mutation">
|
||||
): SettingsManager => {
|
||||
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
||||
// We recommend it for production, because all values are encrypted.
|
||||
// If your use case require plain text values, you can use MetadataManager.
|
||||
return new EncryptedMetadataManager({
|
||||
// Secret key should be randomly created for production and set as environment variable
|
||||
encryptionKey: process.env.SECRET_KEY!,
|
||||
fetchMetadata: () => fetchAllMetadata(client),
|
||||
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
|
||||
});
|
||||
};
|
21
apps/crm/src/lib/use-dashboard-notification.ts
Normal file
21
apps/crm/src/lib/use-dashboard-notification.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { useCallback } from "react";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
export const useDashboardNotification = () => {
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
return {
|
||||
notifySuccess: useCallback(
|
||||
(title: string, text: string) => {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
status: "success",
|
||||
title,
|
||||
text,
|
||||
})
|
||||
);
|
||||
},
|
||||
[appBridge]
|
||||
),
|
||||
};
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
.frame {
|
||||
width: 100%;
|
||||
border: none;
|
||||
height: 100%;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Box } from "@saleor/macaw-ui/next";
|
||||
import React, { ComponentProps } from "react";
|
||||
import styles from "./mailchimp-auth-frame.module.css";
|
||||
|
||||
export const MailchimpAuthFrame = (props: ComponentProps<typeof Box>) => {
|
||||
return (
|
||||
<Box {...props}>
|
||||
<iframe src="/configuration/mailchimp/auth" className={styles.frame} />
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
import { LoginWithMailchimpButton } from "../../../ui/login-with-mailchimp-button/login-with-mailchimp-button";
|
||||
import React from "react";
|
||||
import { Text } from "@saleor/macaw-ui/next";
|
||||
import { Section } from "../../../ui/section/section";
|
||||
import { useAppBridgePersistence } from "../../../../lib/app-bridge-persistence";
|
||||
|
||||
export const MailchimpAuthSection = () => {
|
||||
const appBridgeContext = useAppBridgePersistence();
|
||||
|
||||
if (!appBridgeContext) {
|
||||
throw new Error("Iframe can only work if AppBridge state was previously set in SessionStorage");
|
||||
}
|
||||
|
||||
return (
|
||||
<Section display="flex" flexDirection="column">
|
||||
<Text as="p" marginBottom={8}>
|
||||
You need to connect Mailchimp with Saleor CRM App. Click button below and authorize the App.
|
||||
</Text>
|
||||
<form method="POST" action="/api/auth/mailchimp/oauth">
|
||||
<input hidden readOnly name="token" value={appBridgeContext.token} />
|
||||
<input hidden readOnly name="saleorApiUrl" value={appBridgeContext.saleorApiUrl} />
|
||||
<LoginWithMailchimpButton type="submit" />
|
||||
</form>
|
||||
</Section>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
import { PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
||||
import { Section } from "../../../ui/section/section";
|
||||
|
||||
export const ConnectionStatus = (props: PropsWithBox<{ status: "error" | "ok" }>) => (
|
||||
<Section {...props}>
|
||||
<Text variant="title" size="small" as="p" marginBottom={4}>
|
||||
Connection status
|
||||
</Text>
|
||||
{props.status === "ok" && <Text color="textBrandDefault">Connected</Text>}
|
||||
{props.status === "error" && (
|
||||
<Text color="textCriticalDefault">
|
||||
Error connecting to Mailchimp. Please refresh the page or reinstall the app.
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
);
|
|
@ -0,0 +1,102 @@
|
|||
import { Section } from "../../../ui/section/section";
|
||||
import React from "react";
|
||||
import { Box, Button, Chip, PropsWithBox, Text, TextProps } from "@saleor/macaw-ui/next";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import { TextLink } from "../../../ui/text-link/text-link";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
const Paragraph = (props: TextProps) => <Text marginBottom={5} as="p" {...props} />;
|
||||
|
||||
const Heading = (props: TextProps) => (
|
||||
<Text as="h2" variant="heading" marginBottom={4} marginTop={12} {...props} />
|
||||
);
|
||||
|
||||
const getGraphiqlExampleQueryPlaygroundUrl = (apiUrl = "https://demo.saleor.io/graphql/") =>
|
||||
`https://graphiql.cloud/?endpoint=${apiUrl}#gql/N4IgjiBcILYK4BcCGCCWB7AdgAgMoFMEBZJVAGwGMALVGABwBUkBzACgBIK4BnBdGfACcAkgBFI2MQEIAlNmAAdHNjh0AJinwAFQagBumooSQbkrJdkvZUaiZx58BI0RaupMdRBOABrfAE8JBVhSShp6AH1kZm5ggBpsAzI4fCCQAG0FLJAmZmwARiz47CKclmwAJlKAXWCAX1c5RWVLIUF0QW55VytsAW5uFnwe7AblMbqQOJB0KBACYlDqWkYWEDqgA`;
|
||||
|
||||
export const Instructions = (props: PropsWithBox<{}>) => {
|
||||
const [instructionsVisible, setInstructionsVisible] = useLocalStorage(
|
||||
"instructions-visible",
|
||||
true
|
||||
);
|
||||
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
return (
|
||||
<Section {...props}>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
onClick={() => setInstructionsVisible((v) => !v)}
|
||||
>
|
||||
<Text as="h1" variant="title" size="small" marginBottom={12}>
|
||||
Instructions
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInstructionsVisible((v) => !v);
|
||||
}}
|
||||
>
|
||||
<Text color="textNeutralSubdued">
|
||||
{instructionsVisible ? "Hide instructions" : "Show instructions"}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
<Box hidden={!instructionsVisible}>
|
||||
<Paragraph>
|
||||
Follow these guidelines to learn how to use the app. Useful resources:
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<TextLink href="https://docs.saleor.io/docs/3.x/category/overview" size="small">
|
||||
- Saleor Docs
|
||||
</TextLink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<TextLink
|
||||
/* TODO link to actual readme in docs*/
|
||||
href="https://github.com/saleor/apps"
|
||||
size="small"
|
||||
>
|
||||
- App Docs
|
||||
</TextLink>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<TextLink href="https://github.com/saleor/apps/discussions" size="small">
|
||||
- Support
|
||||
</TextLink>
|
||||
</Paragraph>
|
||||
|
||||
<Heading>Segment Tags</Heading>
|
||||
<Paragraph>
|
||||
Customer will be added to contacts list with{" "}
|
||||
<Box as="span" __display="inline-block">
|
||||
<Chip as="span">Saleor Import</Chip>
|
||||
</Box>{" "}
|
||||
tag. To create customs segments, you can use{" "}
|
||||
<TextLink href="https://docs.saleor.io/docs/3.x/developer/metadata">
|
||||
Private Metadata
|
||||
</TextLink>{" "}
|
||||
for the customer.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Add{" "}
|
||||
<Text variant="bodyEmp">
|
||||
<code>mailchimp_tags</code> Private Metadata key with tags as a stringified array.
|
||||
<br />
|
||||
For example <code>mailchimp_tags: ["Tag1", "Tag2"]</code>
|
||||
</Text>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Check{" "}
|
||||
<TextLink href={getGraphiqlExampleQueryPlaygroundUrl(appBridgeState?.saleorApiUrl)}>
|
||||
example query
|
||||
</TextLink>
|
||||
</Paragraph>
|
||||
</Box>
|
||||
</Section>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,65 @@
|
|||
import { Box, Chip, List, PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { Section } from "../../../ui/section/section";
|
||||
|
||||
const Wrapper = ({ children, ...props }: PropsWithBox<{}>) => {
|
||||
return (
|
||||
<Section {...props}>
|
||||
<Text variant="title" size="small" as="h1">
|
||||
Available lists
|
||||
</Text>
|
||||
{children}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export const MailchimpLists = (props: PropsWithBox<{}>) => {
|
||||
const { data, error, status } = trpcClient.mailchimp.audience.getLists.useQuery();
|
||||
|
||||
switch (status) {
|
||||
case "error": {
|
||||
return (
|
||||
<Wrapper {...props}>
|
||||
<Text color="textCriticalDefault">Failed fetching Mailchimp lists</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
case "loading": {
|
||||
return (
|
||||
<Wrapper {...props}>
|
||||
<Text as="p" marginTop={4}>
|
||||
Loading lists...
|
||||
</Text>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
case "success": {
|
||||
return (
|
||||
<Wrapper {...props}>
|
||||
<List>
|
||||
{data.map((listItem) => (
|
||||
<List.Item
|
||||
disabled
|
||||
key={listItem.id}
|
||||
paddingY={4}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text>{listItem.name}</Text>
|
||||
<Text variant="caption">
|
||||
<Box __display="inline-block" marginRight={2}>
|
||||
<Chip size="small">ID</Chip>
|
||||
</Box>
|
||||
{listItem.id}
|
||||
</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import { ComponentProps } from "react";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { DangerSection } from "../../../ui/danger-section/danger-section";
|
||||
import { TextLink } from "../../../ui/text-link/text-link";
|
||||
|
||||
export const RemoveMailchimpConfig = (props: ComponentProps<typeof Box>) => {
|
||||
const { mutateAsync, isLoading, isSuccess } =
|
||||
trpcClient.mailchimp.config.removeToken.useMutation();
|
||||
const { refetch, data } = trpcClient.mailchimp.config.getMailchimpConfigured.useQuery();
|
||||
|
||||
if (!data || !data.configured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DangerSection>
|
||||
<Text color="textCriticalDefault" variant="title" size="small">
|
||||
Remove configuration
|
||||
</Text>
|
||||
<Text as="p" marginY={8}>
|
||||
This operation will remove saved Mailchimp token from App database. You will be able to
|
||||
connect it again. <br />
|
||||
It will not disconnect CRM App in Mailchimp - you can do it in the{" "}
|
||||
<TextLink
|
||||
href={`https://${data.dc}.admin.mailchimp.com/account/connected-sites/app-selection/`}
|
||||
>
|
||||
Mailchimp Dashboard
|
||||
</TextLink>
|
||||
</Text>
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
<Button
|
||||
onClick={() => mutateAsync().then(() => refetch())}
|
||||
variant="secondary"
|
||||
borderColor="criticalDefault"
|
||||
color="textCriticalDefault"
|
||||
disabled={isLoading ?? isSuccess}
|
||||
>
|
||||
{isLoading ?? isSuccess ? "Disconnecting..." : "Disconnect Mailchimp"}
|
||||
</Button>
|
||||
</Box>
|
||||
</DangerSection>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,141 @@
|
|||
import { Box, Button, Checkbox, Text } from "@saleor/macaw-ui/next";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { ComponentProps, useEffect, useState } from "react";
|
||||
import { Section } from "../../../ui/section/section";
|
||||
import { useDashboardNotification } from "../../../../lib/use-dashboard-notification";
|
||||
import { MailchimpListPicker } from "../../mailchimp-list-picker/mailchimp-list-picker";
|
||||
|
||||
type EnabledState = {
|
||||
selected: true;
|
||||
listId: string;
|
||||
};
|
||||
|
||||
type DisabledState = {
|
||||
selected: false;
|
||||
};
|
||||
|
||||
type InitialState = null;
|
||||
|
||||
type LocalState = EnabledState | DisabledState | InitialState;
|
||||
|
||||
const useRemoteData = () => {
|
||||
const remoteMailchimpConfig = trpcClient.mailchimp.config.getMailchimpConfigured.useQuery();
|
||||
const remoteWebhookConfigMutation = trpcClient.mailchimp.config.setWebhookConfig.useMutation();
|
||||
const remoteMailchimpLists = trpcClient.mailchimp.audience.getLists.useQuery();
|
||||
|
||||
return {
|
||||
mailchimpConfig: remoteMailchimpConfig,
|
||||
webhookConfigMutation: remoteWebhookConfigMutation,
|
||||
mailchimpLists: remoteMailchimpLists,
|
||||
allFetchedSuccess: remoteMailchimpLists.isSuccess && remoteMailchimpConfig.isSuccess,
|
||||
listsData: remoteMailchimpLists.data,
|
||||
mailchimpConfigData: remoteMailchimpConfig.data,
|
||||
};
|
||||
};
|
||||
|
||||
export const WebhookConfiguration = (props: ComponentProps<typeof Box>) => {
|
||||
const { notifySuccess } = useDashboardNotification();
|
||||
|
||||
const { allFetchedSuccess, listsData, mailchimpConfigData, webhookConfigMutation } =
|
||||
useRemoteData();
|
||||
|
||||
const [localState, setLocalState] = useState<LocalState>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!(
|
||||
allFetchedSuccess &&
|
||||
mailchimpConfigData?.configured &&
|
||||
mailchimpConfigData?.customerCreateEvent
|
||||
)
|
||||
) {
|
||||
// Something is wrong with configuration here, handle, throw or fallback
|
||||
return;
|
||||
}
|
||||
|
||||
if (mailchimpConfigData.customerCreateEvent.enabled) {
|
||||
setLocalState({
|
||||
selected: true,
|
||||
listId: mailchimpConfigData.customerCreateEvent.listId,
|
||||
});
|
||||
} else {
|
||||
setLocalState({
|
||||
selected: false,
|
||||
});
|
||||
}
|
||||
}, [allFetchedSuccess, mailchimpConfigData?.configured, mailchimpConfigData]);
|
||||
|
||||
if (!mailchimpConfigData?.configured || !localState || !listsData || !listsData.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
checked
|
||||
? setLocalState({
|
||||
selected: true,
|
||||
listId: listsData[0].id,
|
||||
})
|
||||
: setLocalState({
|
||||
selected: false,
|
||||
});
|
||||
};
|
||||
|
||||
function handleSaveButton() {
|
||||
webhookConfigMutation
|
||||
.mutateAsync(
|
||||
localState?.selected
|
||||
? {
|
||||
enabled: true,
|
||||
listId: localState.listId,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
notifySuccess("Success", "Config Saved");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Section {...props}>
|
||||
<Text as="h1" variant="title" size="small" marginBottom={4}>
|
||||
Configure webhooks
|
||||
</Text>
|
||||
<Text color="textNeutralSubdued" as="p" marginBottom={8}>
|
||||
Each time customer is created, it will be added to selected audience list in Mailchimp
|
||||
</Text>
|
||||
<Box display="flex" gap={4} flexDirection="column">
|
||||
<Checkbox onCheckedChange={handleCheckboxChange} checked={localState.selected}>
|
||||
<Text marginRight="auto">Enable customers sync</Text>
|
||||
</Checkbox>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Text
|
||||
color={localState?.selected ? "textNeutralDefault" : "textNeutralDisabled"}
|
||||
marginRight={4}
|
||||
variant="caption"
|
||||
>
|
||||
Sync to the Mailchimp list:
|
||||
</Text>
|
||||
|
||||
<MailchimpListPicker
|
||||
disabled={!localState.selected}
|
||||
onChange={(_, value) => {
|
||||
if (localState?.selected) {
|
||||
setLocalState({
|
||||
selected: true,
|
||||
listId: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={8} display="flex" justifyContent="flex-end">
|
||||
<Button disabled={webhookConfigMutation.status === "loading"} onClick={handleSaveButton}>
|
||||
{webhookConfigMutation.isLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Section>
|
||||
);
|
||||
};
|
99
apps/crm/src/modules/mailchimp/mailchimp-audience.router.ts
Normal file
99
apps/crm/src/modules/mailchimp/mailchimp-audience.router.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { router } from "../trpc/trpc-server";
|
||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||
import { createLogger } from "../../lib/logger";
|
||||
import { MailchimpClientOAuth } from "./mailchimp-client";
|
||||
import { MailchimpConfigSettingsManager } from "./mailchimp-config-settings-manager";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddContactSchema = z.object({
|
||||
listId: z.string().min(1),
|
||||
contact: z.object({
|
||||
email: z.string().email().min(2),
|
||||
}),
|
||||
});
|
||||
|
||||
const BulkAddContactsSchema = z.object({
|
||||
contacts: z.array(
|
||||
z.object({
|
||||
email: z.string().email().min(2),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
),
|
||||
listId: z.string().min(1),
|
||||
});
|
||||
|
||||
const mailchimpAudienceRouter = router({
|
||||
getLists: protectedClientProcedure.query(async ({ ctx }) => {
|
||||
const config = await new MailchimpConfigSettingsManager(ctx.apiClient).getConfig();
|
||||
|
||||
/**
|
||||
* TODO extract mailchimp API readiness shared class
|
||||
*/
|
||||
if (!config) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
cause: "MAILCHIMP_CONFIG_NOT_FOUND",
|
||||
message: "Couldnt restore saved Mailchimp config",
|
||||
});
|
||||
}
|
||||
|
||||
const logger = createLogger({
|
||||
context: "mailchimpConfigRouter",
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
const mailchimpClient = new MailchimpClientOAuth(config.dc, config.token);
|
||||
|
||||
const listsResponseOrError = await mailchimpClient.fetchLists();
|
||||
|
||||
logger.trace(listsResponseOrError, "Fetched lists");
|
||||
|
||||
if ("lists" in listsResponseOrError) {
|
||||
return listsResponseOrError.lists.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
members: l.stats.member_count,
|
||||
}));
|
||||
}
|
||||
|
||||
throw new Error("Failed fetching lists from Mailchimp");
|
||||
}),
|
||||
bulkAddContacts: protectedClientProcedure
|
||||
.input(BulkAddContactsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = createLogger({
|
||||
context: "mailchimpConfigRouter.bulkAddContacts",
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
const config = await new MailchimpConfigSettingsManager(ctx.apiClient).getConfig();
|
||||
|
||||
logger.debug("Fetched config from metadata");
|
||||
|
||||
if (!config) {
|
||||
logger.warn("Config not found");
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
cause: "MAILCHIMP_CONFIG_NOT_FOUND",
|
||||
message: "Couldnt restore saved Mailchimp config",
|
||||
});
|
||||
}
|
||||
|
||||
const mailchimpClient = new MailchimpClientOAuth(config.dc, config.token);
|
||||
|
||||
logger.debug(input, "Will bulk add contacts to Mailchimp");
|
||||
|
||||
await mailchimpClient.batchAddContacts(input.listId, input.contacts);
|
||||
}),
|
||||
});
|
||||
|
||||
export const MailchimpAudienceRouter = {
|
||||
router: mailchimpAudienceRouter,
|
||||
input: {
|
||||
AddContactSchema,
|
||||
},
|
||||
};
|
67
apps/crm/src/modules/mailchimp/mailchimp-client.ts
Normal file
67
apps/crm/src/modules/mailchimp/mailchimp-client.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import mailchimp_marketing from "@mailchimp/mailchimp_marketing";
|
||||
|
||||
export class MailchimpClientOAuth {
|
||||
client = mailchimp_marketing;
|
||||
|
||||
constructor(server: string, accessToken: string) {
|
||||
this.client.setConfig({
|
||||
accessToken,
|
||||
server,
|
||||
});
|
||||
}
|
||||
|
||||
async ping() {
|
||||
return this.client.ping.get();
|
||||
}
|
||||
|
||||
async fetchLists() {
|
||||
return this.client.lists.getAllLists();
|
||||
}
|
||||
|
||||
async addContact(
|
||||
listID: string,
|
||||
email: string,
|
||||
meta: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
extraTags?: string[];
|
||||
}
|
||||
) {
|
||||
return this.client.lists.addListMember(listID, {
|
||||
// TODO - add mapping on frontend?
|
||||
status: "transactional",
|
||||
email_address: email,
|
||||
tags: ["Saleor Import", ...(meta.extraTags ?? [])],
|
||||
merge_fields: {
|
||||
FNAME: meta.firstName,
|
||||
LNAME: meta.lastName,
|
||||
},
|
||||
// todo - add address
|
||||
});
|
||||
}
|
||||
|
||||
// TODO - map address
|
||||
async batchAddContacts(
|
||||
listID: string,
|
||||
contacts: Array<{ email: string; lastName?: string; firstName?: string; tags?: string[] }>
|
||||
) {
|
||||
/**
|
||||
* method "batchListMembers" exist in Mailchimp SDK
|
||||
* https://mailchimp.com/developer/marketing/api/list-members/
|
||||
*
|
||||
* Its not available in typings, hence ts-ignore
|
||||
*/
|
||||
// @ts-ignore
|
||||
return this.client.lists.batchListMembers(listID, {
|
||||
members: contacts.map((c) => ({
|
||||
status: "transactional",
|
||||
email_address: c.email,
|
||||
merge_fields: {
|
||||
FNAME: c.firstName,
|
||||
LNAME: c.lastName,
|
||||
},
|
||||
tags: ["Saleor Import", ...(c.tags ?? [])],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MailchimpConfigSettingsManagerV1 } from "./mailchimp-config-settings-manager";
|
||||
import { Client } from "urql";
|
||||
import { SettingsManager, SettingsValue } from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
describe("MailchimpConfigSettingsManagerV1", () => {
|
||||
let mockSettingsManager: SettingsManager = {
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
let service: MailchimpConfigSettingsManagerV1;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSettingsManager = {
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
};
|
||||
|
||||
service = new MailchimpConfigSettingsManagerV1(null as unknown as Client, () => {
|
||||
return mockSettingsManager;
|
||||
});
|
||||
});
|
||||
|
||||
it("Calls settings manager with passed oauth config", async () => {
|
||||
let valueHasBeenSet: string;
|
||||
|
||||
vi.mocked(mockSettingsManager.set).mockImplementation(
|
||||
async (values: SettingsValue[] | SettingsValue) => {
|
||||
// @ts-ignore
|
||||
valueHasBeenSet = values.value;
|
||||
}
|
||||
);
|
||||
|
||||
await service.setConfig({
|
||||
token: "mailchimp-token",
|
||||
dc: "us41",
|
||||
});
|
||||
|
||||
const parsedSetValue = JSON.parse(valueHasBeenSet!);
|
||||
|
||||
expect(parsedSetValue).toMatchInlineSnapshot(`
|
||||
{
|
||||
"config": {
|
||||
"customerCreateEvent": {
|
||||
"enabled": false,
|
||||
},
|
||||
"dc": "us41",
|
||||
"token": "mailchimp-token",
|
||||
},
|
||||
"configVersion": "v1",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("Calls settings manager with default customerCreateEvent setting to be disabled", async () => {
|
||||
let valueHasBeenSet: string;
|
||||
|
||||
vi.mocked(mockSettingsManager.set).mockImplementation(
|
||||
async (values: SettingsValue[] | SettingsValue) => {
|
||||
// @ts-ignore
|
||||
valueHasBeenSet = values.value;
|
||||
}
|
||||
);
|
||||
|
||||
await service.setConfig({
|
||||
token: "mailchimp-token",
|
||||
dc: "us41",
|
||||
});
|
||||
|
||||
const parsedSetValue = JSON.parse(valueHasBeenSet!);
|
||||
|
||||
expect(parsedSetValue.config.customerCreateEvent.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("Calls settings manager with default customerCreateEvent setting to be disabled", async () => {
|
||||
let valueHasBeenSet: string;
|
||||
|
||||
vi.mocked(mockSettingsManager.set).mockImplementation(
|
||||
async (values: SettingsValue[] | SettingsValue) => {
|
||||
// @ts-ignore
|
||||
valueHasBeenSet = values.value;
|
||||
}
|
||||
);
|
||||
|
||||
await service.setConfig({
|
||||
token: "mailchimp-token",
|
||||
dc: "us41",
|
||||
});
|
||||
|
||||
const parsedSetValue = JSON.parse(valueHasBeenSet!);
|
||||
|
||||
expect(parsedSetValue.config.customerCreateEvent.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it(".get returns null if data doesnt match schema", async () => {
|
||||
vi.mocked(mockSettingsManager.get).mockImplementationOnce(async (key: string) => {
|
||||
return "undefined";
|
||||
});
|
||||
|
||||
await expect(service.getConfig()).resolves.toBeNull();
|
||||
|
||||
vi.mocked(mockSettingsManager.get).mockImplementationOnce(async (key: string) => {
|
||||
return JSON.stringify({ foo: "bar" });
|
||||
});
|
||||
|
||||
await expect(service.getConfig()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('.removeConfig method sets value to be "undefined" string', async () => {
|
||||
await service.removeConfig();
|
||||
|
||||
return expect(mockSettingsManager.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: expect.any(String),
|
||||
value: "undefined",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,114 @@
|
|||
import { Client } from "urql";
|
||||
import { createSettingsManager } from "../../lib/metadata-manager";
|
||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
import { z } from "zod";
|
||||
import { createLogger } from "../../lib/logger";
|
||||
|
||||
export const CustomerCreatedEventConfig = z
|
||||
.object({
|
||||
enabled: z.literal(true),
|
||||
listId: z.string(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
enabled: z.literal(false),
|
||||
})
|
||||
);
|
||||
|
||||
const ConfigV1 = z.object({
|
||||
token: z.string().min(1).describe("OAuth config from Mailchimp API"),
|
||||
dc: z.string().min(1).describe("Prefix for mailchimp API, received from Mailchimp metadata"),
|
||||
customerCreateEvent: CustomerCreatedEventConfig.optional(),
|
||||
});
|
||||
|
||||
const MetadataSchemaV1 = z.object({
|
||||
configVersion: z.literal("v1"),
|
||||
config: ConfigV1,
|
||||
});
|
||||
|
||||
/**
|
||||
* V1 config. In case of changing config, create another instance and perform migration
|
||||
*
|
||||
* todo save domain?
|
||||
* todo add test
|
||||
*/
|
||||
export class MailchimpConfigSettingsManagerV1 {
|
||||
private settingsManager: SettingsManager;
|
||||
private readonly metadataKey = "mailchimp_config_v1";
|
||||
private logger = createLogger({
|
||||
context: "MailchimpConfigSettingsManagerV1",
|
||||
});
|
||||
|
||||
constructor(
|
||||
private apiClient: Pick<Client, "query" | "mutation">,
|
||||
metadataManagerFactory = createSettingsManager
|
||||
) {
|
||||
this.settingsManager = metadataManagerFactory(apiClient);
|
||||
}
|
||||
|
||||
private parseEmptyResponse = (value?: string) => {
|
||||
return value === "undefined" ? undefined : value;
|
||||
};
|
||||
|
||||
setConfig(config: z.infer<typeof ConfigV1>) {
|
||||
const configSchema = MetadataSchemaV1.parse({
|
||||
config,
|
||||
configVersion: "v1",
|
||||
});
|
||||
|
||||
if (!configSchema.config.customerCreateEvent) {
|
||||
configSchema.config.customerCreateEvent = {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.debug(configSchema, "Will set config");
|
||||
|
||||
return this.settingsManager.set({
|
||||
key: this.metadataKey,
|
||||
value: JSON.stringify(configSchema),
|
||||
});
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof ConfigV1> | null> {
|
||||
this.logger.debug(`Will fetched metadata key: ${this.metadataKey}`);
|
||||
const rawMetadata = await this.settingsManager
|
||||
.get(this.metadataKey)
|
||||
.then(this.parseEmptyResponse);
|
||||
|
||||
this.logger.debug({ rawMetadata }, "Received raw metadata");
|
||||
|
||||
/**
|
||||
* Check for "undefined" string because after config is deleted, its actually set to "undefined" instead removing
|
||||
* TODO remove config instead setting it to "undefined"
|
||||
*/
|
||||
if (!rawMetadata) {
|
||||
this.logger.debug("Raw metadata is nullable");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedMetadata = MetadataSchemaV1.parse(JSON.parse(rawMetadata));
|
||||
|
||||
if (!parsedMetadata.config.customerCreateEvent) {
|
||||
parsedMetadata.config.customerCreateEvent = {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
return parsedMetadata.config;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removeConfig() {
|
||||
// todo = implement settingsManager.delete
|
||||
return this.settingsManager.set({ key: this.metadataKey, value: "undefined" });
|
||||
}
|
||||
}
|
||||
|
||||
export const MailchimpConfigSettingsManager = MailchimpConfigSettingsManagerV1;
|
||||
export const MailchimpConfig = ConfigV1;
|
121
apps/crm/src/modules/mailchimp/mailchimp-config.router.ts
Normal file
121
apps/crm/src/modules/mailchimp/mailchimp-config.router.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { router } from "../trpc/trpc-server";
|
||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||
import { createLogger } from "../../lib/logger";
|
||||
import { MailchimpClientOAuth } from "./mailchimp-client";
|
||||
import {
|
||||
CustomerCreatedEventConfig,
|
||||
MailchimpConfig,
|
||||
MailchimpConfigSettingsManager,
|
||||
} from "./mailchimp-config-settings-manager";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const setTokenInput = MailchimpConfig;
|
||||
|
||||
type ConfiguredResponse =
|
||||
| {
|
||||
configured: false;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
configured: true;
|
||||
dc: string;
|
||||
customerCreateEvent: z.infer<typeof MailchimpConfig>["customerCreateEvent"];
|
||||
};
|
||||
|
||||
// todo extract settings manager
|
||||
const mailchimpConfigRouter = router({
|
||||
setToken: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(setTokenInput)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const logger = createLogger({
|
||||
context: "mailchimpConfigRouter",
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
logger.info("Saving Mailchimp token");
|
||||
|
||||
return new MailchimpConfigSettingsManager(ctx.apiClient).setConfig(input);
|
||||
}),
|
||||
setWebhookConfig: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(CustomerCreatedEventConfig)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = createLogger({
|
||||
context: "mailchimpConfigRouter",
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
logger.info("Saving Mailchimp token");
|
||||
|
||||
const mm = new MailchimpConfigSettingsManager(ctx.apiClient);
|
||||
|
||||
const currentConfig = await mm.getConfig();
|
||||
|
||||
if (!currentConfig) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
|
||||
currentConfig.customerCreateEvent = input;
|
||||
|
||||
return mm.setConfig(currentConfig);
|
||||
}),
|
||||
getMailchimpConfigured: protectedClientProcedure.query(
|
||||
async ({ ctx }): Promise<ConfiguredResponse> => {
|
||||
const logger = createLogger({
|
||||
context: "mailchimpConfigRouter",
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
const config = await new MailchimpConfigSettingsManager(ctx.apiClient).getConfig();
|
||||
|
||||
logger.debug(config, "Received config from metadata");
|
||||
|
||||
// todo consider TRPCError?
|
||||
if (!config) {
|
||||
logger.debug("No config - will return NO_TOKEN");
|
||||
|
||||
return {
|
||||
configured: false,
|
||||
reason: "NO_TOKEN",
|
||||
};
|
||||
}
|
||||
|
||||
const mailchimpClient = new MailchimpClientOAuth(config.dc, config.token);
|
||||
|
||||
try {
|
||||
logger.debug("Will ping Mailchimp");
|
||||
|
||||
await mailchimpClient.ping();
|
||||
|
||||
logger.debug("Mailchimp seems to be fine");
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
customerCreateEvent: config.customerCreateEvent,
|
||||
dc: config.dc,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.debug("Ping to mailchimp failed, will return CANT_PING");
|
||||
|
||||
return {
|
||||
configured: false,
|
||||
reason: "CANT_PING",
|
||||
};
|
||||
}
|
||||
}
|
||||
),
|
||||
removeToken: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.mutation(({ ctx }) => {
|
||||
return new MailchimpConfigSettingsManager(ctx.apiClient).removeConfig();
|
||||
}),
|
||||
});
|
||||
|
||||
export const MailchimpConfigRouter = {
|
||||
router: mailchimpConfigRouter,
|
||||
input: {
|
||||
setToken: setTokenInput,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import { Box, PropsWithBox } from "@saleor/macaw-ui/next";
|
||||
import { ChangeEvent, useEffect } from "react";
|
||||
import { trpcClient } from "../../trpc/trpc-client";
|
||||
|
||||
type Props = PropsWithBox<{
|
||||
disabled?: boolean;
|
||||
onChange(e: ChangeEvent<HTMLSelectElement> | null, listId: string): void;
|
||||
}>;
|
||||
|
||||
export const MailchimpListPicker = ({ disabled, onChange, ...props }: Props) => {
|
||||
const { isSuccess, data, isLoading } = trpcClient.mailchimp.audience.getLists.useQuery(
|
||||
undefined,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data?.length) {
|
||||
onChange(null, data[0].id);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isSuccess && !data) {
|
||||
console.error("Fetched empty audiences list, should not happen");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box {...props}>
|
||||
<select
|
||||
disabled={disabled || isLoading}
|
||||
onChange={(e) => {
|
||||
onChange(e, e.currentTarget.value);
|
||||
}}
|
||||
>
|
||||
{data?.map((list) => (
|
||||
<option value={list.id} key={list.id}>
|
||||
{list.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
import { MailchimpAuthFrame } from "../../auth/mailchimp-auth-frame/mailchimp-auth-frame";
|
||||
import React, { useEffect } from "react";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { createLogger } from "../../../../lib/logger";
|
||||
|
||||
const logger = createLogger({});
|
||||
|
||||
export const MailchimpAuthorizeView = (props: { onSuccess(): void }) => {
|
||||
const { mutateAsync } = trpcClient.mailchimp.config.setToken.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (message: MessageEvent) => {
|
||||
if (message.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = message.data as {
|
||||
token: string;
|
||||
type: "mailchimp_token";
|
||||
dc: string;
|
||||
};
|
||||
|
||||
if (payload.type !== "mailchimp_token") {
|
||||
return;
|
||||
}
|
||||
|
||||
mutateAsync({ token: payload.token, dc: payload.dc }).then(() => {
|
||||
logger.debug("Saved token in metadata");
|
||||
|
||||
props.onSuccess();
|
||||
});
|
||||
|
||||
// todo - save config in private metadata and show different UI
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
});
|
||||
|
||||
// todo calculate dynamically
|
||||
return <MailchimpAuthFrame __height={900} />;
|
||||
};
|
|
@ -0,0 +1,75 @@
|
|||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import { RemoveMailchimpConfig } from "../../config-sections/remove-mailchimp-config/remove-mailchimp-config";
|
||||
import { MailchimpAuthorizeView } from "../mailchimp-authorize-view/mailchimp-authorize-view";
|
||||
import { MailchimpLists } from "../../config-sections/mailchimp-lists/mailchimp-lists";
|
||||
import { WebhookConfiguration } from "../../config-sections/webhook-configuration/webhook-configuration";
|
||||
import { SaleorCustomersSync } from "../../../saleor-customers-sync/saleor-customers-sync";
|
||||
import { ConnectionStatus } from "../../config-sections/connection-status/connection-status";
|
||||
import { Instructions } from "../../config-sections/instructions/instructions";
|
||||
|
||||
const header = (
|
||||
<Box marginBottom={12}>
|
||||
<Text as="h1" variant="title" size="large">
|
||||
Mailchimp
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const MailchimpConfigView = () => {
|
||||
const {
|
||||
data: mailchimpConfigured,
|
||||
refetch,
|
||||
isFetched,
|
||||
isLoading,
|
||||
error,
|
||||
} = trpcClient.mailchimp.config.getMailchimpConfigured.useQuery();
|
||||
|
||||
const isMailchimpConfigured = mailchimpConfigured?.configured;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<Text>Checking Mailchimp config status...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<Box marginBottom={12} display="flex" justifyContent="space-between" gap={12}>
|
||||
<ConnectionStatus status="error" __flex="0 1 50%" />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMailchimpConfigured && isFetched) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<Instructions marginBottom={12} />
|
||||
<Box marginBottom={12} display="flex" justifyContent="space-between" gap={12}>
|
||||
<ConnectionStatus status="ok" __flex="0 1 50%" />
|
||||
<MailchimpLists __flex="0 1 50%" />
|
||||
</Box>
|
||||
|
||||
<WebhookConfiguration marginBottom={12} />
|
||||
<SaleorCustomersSync marginBottom={12} />
|
||||
<RemoveMailchimpConfig />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box>
|
||||
{header}
|
||||
<MailchimpAuthorizeView onSuccess={refetch} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
.list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--mu-border-radius-4);
|
||||
margin-bottom: var(--mu-space-6);
|
||||
}
|
||||
|
||||
.item.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.activeItem {
|
||||
font-weight: 600;
|
||||
color: var(--mu-colors-foreground-text-brand-default);
|
||||
border: 1px solid var(--mu-colors-border-neutral-default);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { ProviderType } from "../providers-types";
|
||||
import MailchimpLogo from "../../../assets/mailchimp.svg";
|
||||
import SegmentLogo from "../../../assets/segment.png";
|
||||
import RudderstackLogo from "../../../assets/rudderstack.png";
|
||||
import { HTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./providers-list.module.css";
|
||||
import { Box, MarketplaceIcon, Text } from "@saleor/macaw-ui/next";
|
||||
import { TextLink } from "../../ui/text-link/text-link";
|
||||
|
||||
type Props = {
|
||||
onProviderClick(provider: ProviderType): void;
|
||||
activeProvider?: ProviderType;
|
||||
} & HTMLAttributes<HTMLUListElement>;
|
||||
|
||||
export const ProvidersList = ({ className, onProviderClick, activeProvider, ...props }: Props) => {
|
||||
return (
|
||||
<ul className={clsx(className, styles.list)}>
|
||||
<li
|
||||
className={clsx(styles.item, {
|
||||
[styles.activeItem]: activeProvider === "mailchimp",
|
||||
})}
|
||||
onClick={() => {
|
||||
onProviderClick("mailchimp");
|
||||
}}
|
||||
>
|
||||
<img alt="Mailchimp logo" className={styles.logo} src={MailchimpLogo.src} />
|
||||
<Text>Mailchimp</Text>
|
||||
</li>
|
||||
<li className={clsx(styles.item, styles.disabled)}>
|
||||
<img alt="Segment.io Logo" className={styles.logo} src={SegmentLogo.src} />
|
||||
<Box>
|
||||
<Text as="p" variant="caption" color="textNeutralDisabled">
|
||||
Coming soon
|
||||
</Text>
|
||||
<Text color="textNeutralDisabled">Segment.io</Text>
|
||||
</Box>
|
||||
</li>
|
||||
<li className={clsx(styles.item, styles.disabled)}>
|
||||
<img alt="Rudderstack logo" className={styles.logo} src={RudderstackLogo.src} />
|
||||
<Box>
|
||||
<Text as="p" variant="caption" color="textNeutralDisabled">
|
||||
Coming soon
|
||||
</Text>
|
||||
<Text color="textNeutralDisabled">Rudderstack</Text>
|
||||
</Box>
|
||||
</li>
|
||||
<li className={styles.item}>
|
||||
<Box>
|
||||
<TextLink href="https://github.com/saleor/apps/discussions/categories/integrations-features">
|
||||
<Box display="flex" gap={4}>
|
||||
<MarketplaceIcon />
|
||||
<Text color="inherit">Request integration</Text>
|
||||
</Box>
|
||||
</TextLink>
|
||||
</Box>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
9
apps/crm/src/modules/providers/providers-types.ts
Normal file
9
apps/crm/src/modules/providers/providers-types.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export const ProvidersTypes = {
|
||||
mailchimp: "mailchimp",
|
||||
} as const;
|
||||
|
||||
export type ProviderType = keyof typeof ProvidersTypes;
|
||||
|
||||
export const isValidProviderType = (value: string | undefined): value is ProviderType => {
|
||||
return Object.values(ProvidersTypes).includes(value as ProviderType);
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { metadataToMailchimpTags } from "./metadata-to-mailchimp-tags";
|
||||
import { CustomerFragment } from "../../../generated/graphql";
|
||||
|
||||
const fragmentBase: CustomerFragment = {
|
||||
id: "",
|
||||
email: "",
|
||||
__typename: "User",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
privateMetadata: [],
|
||||
};
|
||||
|
||||
describe("metadata-to-mailchimp-tags", () => {
|
||||
it("Returns [] if desired metadata doesnt exist", () => {
|
||||
expect(
|
||||
metadataToMailchimpTags({
|
||||
...fragmentBase,
|
||||
privateMetadata: [],
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("Returns parsed array of tags if exist in mailchimp_tags metadata key", () => {
|
||||
expect(
|
||||
metadataToMailchimpTags({
|
||||
...fragmentBase,
|
||||
privateMetadata: [
|
||||
{
|
||||
key: "mailchimp_tags",
|
||||
value: JSON.stringify(["foo", "bar"]),
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual(["foo", "bar"]);
|
||||
});
|
||||
|
||||
it("Returns [] if metadata is {} (wrong value)", () => {
|
||||
expect(
|
||||
metadataToMailchimpTags({
|
||||
...fragmentBase,
|
||||
privateMetadata: [
|
||||
{
|
||||
key: "mailchimp_tags",
|
||||
value: JSON.stringify({}),
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import { CustomerFragment } from "../../../generated/graphql";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Metadata with this will be used to parse tags
|
||||
* Value should be JSON.stringify(Array<string>)
|
||||
*/
|
||||
const METADATA_KEY = "mailchimp_tags";
|
||||
|
||||
const TagsSchema = z.array(z.string());
|
||||
|
||||
export const metadataToMailchimpTags = (customerFragment: CustomerFragment): string[] => {
|
||||
const metadataItem = customerFragment.privateMetadata.find((m) => m.key === METADATA_KEY);
|
||||
|
||||
if (!metadataItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return TagsSchema.parse(JSON.parse(metadataItem.value));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
|
@ -0,0 +1,108 @@
|
|||
import { ComponentProps, useEffect, useState } from "react";
|
||||
import { Box, Button, Text, useTheme, WarningIcon } from "@saleor/macaw-ui/next";
|
||||
import { trpcClient } from "../trpc/trpc-client";
|
||||
import { useDashboardNotification } from "../../lib/use-dashboard-notification";
|
||||
import { useFetchAllCustomers } from "./use-fetch-all-customers";
|
||||
import { Section } from "../ui/section/section";
|
||||
import { MailchimpListPicker } from "../mailchimp/mailchimp-list-picker/mailchimp-list-picker";
|
||||
|
||||
const RootSection = ({ children, ...props }: ComponentProps<typeof Box>) => {
|
||||
return (
|
||||
<Section {...props}>
|
||||
<Text as="h1" variant="title" size="small" marginBottom={4}>
|
||||
Bulk sync
|
||||
</Text>
|
||||
<Text color="textNeutralSubdued" as="p" marginBottom={8}>
|
||||
Scan Saleor customers and send them to Mailchimp
|
||||
</Text>
|
||||
{children}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
export const SaleorCustomersSync = (props: ComponentProps<typeof Box>) => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const { customers, totalCustomersCount, done } = useFetchAllCustomers(enabled);
|
||||
const theme = useTheme().themeValues;
|
||||
const { mutateAsync, status } = trpcClient.mailchimp.audience.bulkAddContacts.useMutation();
|
||||
const { notifySuccess } = useDashboardNotification();
|
||||
const [selectedList, setSelectedList] = useState<null | string>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (done) {
|
||||
mutateAsync({
|
||||
listId: selectedList!,
|
||||
contacts: customers,
|
||||
}).then(() => {
|
||||
notifySuccess("Sync successful", "Contacts sent to Mailchimp");
|
||||
});
|
||||
}
|
||||
}, [done]);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<RootSection {...props}>
|
||||
<Box display="flex" marginBottom={6} gap={4}>
|
||||
<WarningIcon />
|
||||
<Text as="p">Do not leave the app while indexing</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="flex-end" gap={4} alignItems="center">
|
||||
<Text variant="caption">Sync to the Mailchimp list:</Text>
|
||||
<MailchimpListPicker
|
||||
onChange={(_, value) => {
|
||||
setSelectedList(value);
|
||||
}}
|
||||
/>
|
||||
<Button marginLeft={"auto"} disabled={!selectedList} onClick={() => setEnabled(true)}>
|
||||
Start
|
||||
</Button>
|
||||
</Box>
|
||||
</RootSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
// todo add link to the dashboard
|
||||
return (
|
||||
<RootSection {...props}>
|
||||
<Text color="textBrandDefault">Contacts synchronized, check your Mailchimp Dashboard</Text>
|
||||
</RootSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<RootSection {...props}>
|
||||
<Text color="textCriticalSubdued">
|
||||
Error synchronizing contacts with Mailchimp, please try again
|
||||
</Text>
|
||||
</RootSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RootSection {...props}>
|
||||
{totalCustomersCount && (
|
||||
<Box display="flex" gap={4} alignItems="center" marginBottom={8}>
|
||||
<progress
|
||||
style={{
|
||||
height: 30,
|
||||
}}
|
||||
color={theme.colors.foreground.iconBrandSubdued}
|
||||
max={totalCustomersCount}
|
||||
value={customers.length}
|
||||
/>
|
||||
<Text>Synchronizing total {totalCustomersCount} accounts...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{done && (
|
||||
<Box>
|
||||
<Text as="p" marginBottom={4}>
|
||||
Fetched customers from Saleor
|
||||
</Text>
|
||||
{status === "loading" && <Text as="p">Sending customer to Mailchimp in progress...</Text>}
|
||||
</Box>
|
||||
)}
|
||||
</RootSection>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "../../lib/create-graphq-client";
|
||||
import { FetchCustomersDocument, FetchCustomersQuery } from "../../../generated/graphql";
|
||||
import { OperationResult } from "urql";
|
||||
import { metadataToMailchimpTags } from "./metadata-to-mailchimp-tags";
|
||||
|
||||
type CustomerCollectionItem = {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export const useFetchAllCustomers = (enabled: boolean) => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const [customers, setCustomers] = useState<Array<CustomerCollectionItem>>([]);
|
||||
const [totalCustomersCount, setTotalCustomersCount] = useState<number | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
if (!appBridgeState) {
|
||||
throw new Error("Must be used withing AppBridgeProvider");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!appBridgeState.token || !appBridgeState.saleorApiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = createClient(appBridgeState.saleorApiUrl, async () => ({
|
||||
token: appBridgeState.token!,
|
||||
}));
|
||||
|
||||
const fetchPage = (cursor?: string) =>
|
||||
client.query(FetchCustomersDocument, { cursor }).toPromise();
|
||||
|
||||
const fetchAll = async () => {
|
||||
let allFetched = false;
|
||||
let lastCursor: string | undefined = undefined;
|
||||
|
||||
while (!allFetched) {
|
||||
const results: OperationResult<FetchCustomersQuery> = await fetchPage(lastCursor);
|
||||
|
||||
if (!results.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!totalCustomersCount) {
|
||||
setTotalCustomersCount(results.data?.customers?.totalCount ?? null);
|
||||
}
|
||||
|
||||
allFetched = !Boolean(results.data?.customers?.pageInfo.hasNextPage);
|
||||
lastCursor = results.data?.customers?.pageInfo.endCursor ?? undefined;
|
||||
|
||||
setCustomers((current) => {
|
||||
const newCustomers = results.data!.customers!.edges.map((c): CustomerCollectionItem => {
|
||||
const tags = metadataToMailchimpTags(c.node);
|
||||
|
||||
return {
|
||||
email: c.node.email,
|
||||
lastName: c.node.lastName,
|
||||
firstName: c.node.firstName,
|
||||
tags,
|
||||
};
|
||||
});
|
||||
|
||||
return [...current, ...newCustomers];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
fetchAll().then(() => {
|
||||
setDone(true);
|
||||
});
|
||||
}
|
||||
}, [appBridgeState, enabled, totalCustomersCount]);
|
||||
|
||||
return {
|
||||
customers,
|
||||
totalCustomersCount,
|
||||
done,
|
||||
};
|
||||
};
|
119
apps/crm/src/modules/trpc/protected-client-procedure.ts
Normal file
119
apps/crm/src/modules/trpc/protected-client-procedure.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
|
||||
import { middleware, procedure } from "./trpc-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
||||
import { saleorApp } from "../../saleor-app";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { createClient } from "../../lib/create-graphq-client";
|
||||
|
||||
const attachAppToken = middleware(async ({ ctx, next }) => {
|
||||
logger.debug("attachAppToken middleware");
|
||||
|
||||
if (!ctx.saleorApiUrl) {
|
||||
logger.debug("ctx.saleorApiUrl not found, throwing");
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Missing saleorApiUrl in request",
|
||||
});
|
||||
}
|
||||
|
||||
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
|
||||
|
||||
if (!authData) {
|
||||
logger.debug("authData not found, throwing 401");
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Missing auth data",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
appToken: authData.token,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
appId: authData.appId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
|
||||
logger.debug(
|
||||
{
|
||||
permissions: meta?.requiredClientPermissions,
|
||||
},
|
||||
"Calling validateClientToken middleware with permissions required"
|
||||
);
|
||||
|
||||
if (!ctx.token) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Missing token in request. This middleware can be used only in frontend",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.appId) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Missing appId in request. This middleware can be used after auth is attached",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.saleorApiUrl) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.ssr) {
|
||||
try {
|
||||
logger.debug("trying to verify JWT token from frontend");
|
||||
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
|
||||
|
||||
await verifyJWT({
|
||||
appId: ctx.appId,
|
||||
token: ctx.token,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
requiredPermissions: meta?.requiredClientPermissions ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug("JWT verification failed, throwing");
|
||||
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Construct common graphQL client and attach it to the context
|
||||
*
|
||||
* Can be used only if called from the frontend (react-query),
|
||||
* otherwise jwks validation will fail (if createCaller used)
|
||||
*
|
||||
* TODO Rethink middleware composition to enable safe server-side router calls
|
||||
*/
|
||||
export const protectedClientProcedure = procedure
|
||||
.use(attachAppToken)
|
||||
.use(validateClientToken)
|
||||
.use(async ({ ctx, next }) => {
|
||||
const client = createClient(ctx.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: ctx.appToken })
|
||||
);
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
apiClient: client,
|
||||
appToken: ctx.appToken,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
},
|
||||
});
|
||||
});
|
12
apps/crm/src/modules/trpc/trpc-app-router.ts
Normal file
12
apps/crm/src/modules/trpc/trpc-app-router.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { router } from "./trpc-server";
|
||||
import { MailchimpConfigRouter } from "../mailchimp/mailchimp-config.router";
|
||||
import { MailchimpAudienceRouter } from "../mailchimp/mailchimp-audience.router";
|
||||
|
||||
export const appRouter = router({
|
||||
mailchimp: router({
|
||||
config: MailchimpConfigRouter.router,
|
||||
audience: MailchimpAudienceRouter.router,
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
36
apps/crm/src/modules/trpc/trpc-client.ts
Normal file
36
apps/crm/src/modules/trpc/trpc-client.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { httpBatchLink } from "@trpc/client";
|
||||
import { createTRPCNext } from "@trpc/next";
|
||||
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { appBridgeInstance } from "../../pages/_app";
|
||||
import { AppRouter } from "./trpc-app-router";
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return "";
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
export const trpcClient = createTRPCNext<AppRouter>({
|
||||
config({ ctx }) {
|
||||
return {
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
return {
|
||||
/**
|
||||
* Attach headers from app to client requests, so tRPC can add them to context
|
||||
*/
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
|
||||
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
// queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } },
|
||||
};
|
||||
},
|
||||
ssr: false,
|
||||
});
|
14
apps/crm/src/modules/trpc/trpc-context.ts
Normal file
14
apps/crm/src/modules/trpc/trpc-context.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { inferAsyncReturnType } from "@trpc/server";
|
||||
|
||||
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
|
||||
return {
|
||||
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
|
||||
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
|
||||
appId: undefined as undefined | string,
|
||||
ssr: undefined as undefined | boolean,
|
||||
};
|
||||
};
|
||||
|
||||
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;
|
13
apps/crm/src/modules/trpc/trpc-server.ts
Normal file
13
apps/crm/src/modules/trpc/trpc-server.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { TrpcContext } from "./trpc-context";
|
||||
import { Permission } from "@saleor/app-sdk/types";
|
||||
|
||||
interface Meta {
|
||||
requiredClientPermissions?: Permission[];
|
||||
}
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().meta<Meta>().create();
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const middleware = t.middleware;
|
|
@ -0,0 +1,8 @@
|
|||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: 280px auto 280px;
|
||||
align-items: start;
|
||||
gap: var(--mu-space-12);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { Box } from "@saleor/macaw-ui/next";
|
||||
import { ComponentProps } from "react";
|
||||
import styles from "./app-columns-layout.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const AppColumnsLayout = ({ children, className, ...props }: ComponentProps<typeof Box>) => {
|
||||
return (
|
||||
<Box className={clsx(styles.root, className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
14
apps/crm/src/modules/ui/danger-section/danger-section.tsx
Normal file
14
apps/crm/src/modules/ui/danger-section/danger-section.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { PropsWithBox } from "@saleor/macaw-ui/next";
|
||||
import { Section } from "../section/section";
|
||||
|
||||
type Props = PropsWithBox<{}>;
|
||||
|
||||
export const DangerSection = (props: Props) => {
|
||||
return (
|
||||
<Section
|
||||
backgroundColor="surfaceCriticalHighlight"
|
||||
borderColor="criticalHighlight"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import MailchimpLogo from "../../../assets/mailchimp.svg";
|
||||
import { Button, ButtonProps } from "@saleor/macaw-ui/next";
|
||||
|
||||
export const LoginWithMailchimpButton = ({ children, ...props }: ButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<img alt="Mailchimp Logo" width={25} src={MailchimpLogo.src} />}
|
||||
size="large"
|
||||
{...props}
|
||||
>
|
||||
Log in with Mailchimp
|
||||
</Button>
|
||||
);
|
||||
};
|
16
apps/crm/src/modules/ui/section/section.tsx
Normal file
16
apps/crm/src/modules/ui/section/section.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Box, PropsWithBox } from "@saleor/macaw-ui/next";
|
||||
|
||||
type Props = PropsWithBox<{}>;
|
||||
|
||||
export const Section = (props: Props) => {
|
||||
return (
|
||||
<Box
|
||||
padding={8}
|
||||
borderColor="neutralHighlight"
|
||||
borderWidth={1}
|
||||
borderStyle="solid"
|
||||
borderRadius={4}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
25
apps/crm/src/modules/ui/text-link/text-link.tsx
Normal file
25
apps/crm/src/modules/ui/text-link/text-link.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Text, TextProps } from "@saleor/macaw-ui/next";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
export const TextLink = ({ href, ...props }: TextProps & { href: string }) => {
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
return (
|
||||
<Text
|
||||
as="a"
|
||||
href={href}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: href,
|
||||
newContext: true,
|
||||
})
|
||||
);
|
||||
}}
|
||||
cursor="pointer"
|
||||
color="text3Decorative"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
64
apps/crm/src/pages/_app.tsx
Normal file
64
apps/crm/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import "@saleor/macaw-ui/next/style";
|
||||
import "../styles/globals.css";
|
||||
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import React, { ReactElement } from "react";
|
||||
import { AppProps } from "next/app";
|
||||
|
||||
import { NoSSRWrapper } from "@saleor/apps-shared";
|
||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||
import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
|
||||
import { NextPage } from "next";
|
||||
import { GraphQLProvider } from "../lib/graphql-provider";
|
||||
import { AppBridgeStorageSetter } from "../lib/app-bridge-persistence";
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
export let appBridgeInstance: AppBridge | undefined;
|
||||
|
||||
if (typeof window !== "undefined" && !appBridgeInstance) {
|
||||
appBridgeInstance = new AppBridge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of layout pattern
|
||||
* https://nextjs.org/docs/basic-features/layouts#per-page-layouts
|
||||
*
|
||||
* In this app, there are pages inside the iframe, which will not use AppBridge etc, so they need
|
||||
* to provider custom tree of wrappers
|
||||
*/
|
||||
export type NextPageWithLayoutOverwrite<P = {}, IP = P> = NextPage<P, IP> & {
|
||||
overwriteLayout?: (page: ReactElement) => ReactElement;
|
||||
};
|
||||
|
||||
type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayoutOverwrite;
|
||||
};
|
||||
|
||||
function NextApp({ Component, pageProps: { session, ...pageProps } }: AppPropsWithLayout) {
|
||||
if (Component.overwriteLayout) {
|
||||
return Component.overwriteLayout(<Component {...pageProps} />);
|
||||
}
|
||||
|
||||
return (
|
||||
<NoSSRWrapper>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<GraphQLProvider>
|
||||
<ThemeProvider defaultTheme="defaultLight">
|
||||
<RoutePropagator />
|
||||
<AppBridgeStorageSetter />
|
||||
<Box padding={8}>
|
||||
<Component {...pageProps} />
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
</GraphQLProvider>
|
||||
</AppBridgeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpcClient.withTRPC(NextApp);
|
51
apps/crm/src/pages/api/auth/mailchimp/callback.ts
Normal file
51
apps/crm/src/pages/api/auth/mailchimp/callback.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { NextApiHandler } from "next";
|
||||
import { MailchimpClientOAuth } from "../../../../modules/mailchimp/mailchimp-client";
|
||||
import { createLogger } from "../../../../lib/logger";
|
||||
|
||||
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
|
||||
const { host, "x-forwarded-proto": protocol = "http" } = headers;
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
const baseUrl = getBaseUrl(req.headers);
|
||||
|
||||
const logger = createLogger({ url: req.url });
|
||||
|
||||
const code = req.query.code as string;
|
||||
|
||||
logger.debug({ baseUrl, code }, "auth/mailchimp/callback called");
|
||||
|
||||
const tokenResponse = await fetch("https://login.mailchimp.com/oauth2/token", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: process.env.MAILCHIMP_CLIENT_ID as string,
|
||||
client_secret: process.env.MAILCHIMP_CLIENT_SECRET as string,
|
||||
redirect_uri: `${baseUrl}/api/auth/mailchimp/callback`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
|
||||
const { access_token } = await tokenResponse.json();
|
||||
|
||||
logger.debug({ access_token }, "Received mailchimp access_token");
|
||||
|
||||
const metadataResponse = await fetch("https://login.mailchimp.com/oauth2/metadata", {
|
||||
headers: {
|
||||
Authorization: `OAuth ${access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const metadata = await metadataResponse.json();
|
||||
|
||||
const mc = new MailchimpClientOAuth(metadata.dc, access_token);
|
||||
|
||||
await mc.ping();
|
||||
|
||||
return res.redirect(
|
||||
`/configuration/mailchimp/oauth-success?token=${access_token}&email=${metadata.login.email}&dc=${metadata.dc}`
|
||||
); // todo maybe move to cookie
|
||||
};
|
||||
|
||||
export default handler;
|
53
apps/crm/src/pages/api/auth/mailchimp/oauth.ts
Normal file
53
apps/crm/src/pages/api/auth/mailchimp/oauth.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { NextApiHandler } from "next";
|
||||
import { createLogger } from "../../../../lib/logger";
|
||||
import { processSaleorProtectedHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { saleorApp } from "../../../../saleor-app";
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
|
||||
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
|
||||
const { host, "x-forwarded-proto": protocol = "http" } = headers;
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
const logger = createLogger({});
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).send("Should be POST request");
|
||||
}
|
||||
|
||||
const appBridgeContext = req.body;
|
||||
|
||||
if (!appBridgeContext.token || !appBridgeContext.saleorApiUrl) {
|
||||
return res.status(400).send("Request must container token & saleorApiUrl body params");
|
||||
}
|
||||
|
||||
await processSaleorProtectedHandler({
|
||||
apl: saleorApp.apl,
|
||||
requiredPermissions: ["MANAGE_APPS"],
|
||||
// @ts-ignore - todo - allow this in app-sdk, only these headers are required, not entire request
|
||||
req: {
|
||||
headers: {
|
||||
[SALEOR_API_URL_HEADER]: appBridgeContext.saleorApiUrl,
|
||||
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeContext.token,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
logger.fatal(e);
|
||||
|
||||
return res.status(401).send("Failed request validation");
|
||||
});
|
||||
|
||||
const redirectUri = `${getBaseUrl(req.headers)}/api/auth/mailchimp/callback`;
|
||||
logger.debug({ redirectUri }, "Resolved redirect uri");
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: process.env.MAILCHIMP_CLIENT_ID as string,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
return res.redirect(`https://login.mailchimp.com/oauth2/authorize?${qs.toString()}`);
|
||||
};
|
||||
|
||||
export default handler;
|
37
apps/crm/src/pages/api/manifest.ts
Normal file
37
apps/crm/src/pages/api/manifest.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { customerCreatedWebhook } from "./webhooks/customer-created";
|
||||
import { customerMetadataUpdatedWebhook } from "./webhooks/customer-updated";
|
||||
|
||||
export default createManifestHandler({
|
||||
async manifestFactory(context) {
|
||||
const manifest: AppManifest = {
|
||||
name: "CRM",
|
||||
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
|
||||
appUrl: context.appBaseUrl,
|
||||
permissions: [
|
||||
"MANAGE_USERS",
|
||||
/**
|
||||
* Set permissions for app if needed
|
||||
* https://docs.saleor.io/docs/3.x/developer/permissions
|
||||
*/
|
||||
],
|
||||
id: "saleor.app.crm",
|
||||
version: packageJson.version,
|
||||
webhooks: [
|
||||
customerCreatedWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
customerMetadataUpdatedWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
],
|
||||
extensions: [
|
||||
/**
|
||||
* Optionally, extend Dashboard with custom UIs
|
||||
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
|
||||
*/
|
||||
],
|
||||
};
|
||||
|
||||
return manifest;
|
||||
},
|
||||
});
|
24
apps/crm/src/pages/api/register.ts
Normal file
24
apps/crm/src/pages/api/register.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||
|
||||
import { saleorApp } from "../../saleor-app";
|
||||
|
||||
const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN;
|
||||
|
||||
/**
|
||||
* Required endpoint, called by Saleor to install app.
|
||||
* It will exchange tokens with app, so saleorApp.apl will contain token
|
||||
*/
|
||||
export default createAppRegisterHandler({
|
||||
apl: saleorApp.apl,
|
||||
allowedSaleorUrls: [
|
||||
(url) => {
|
||||
if (allowedUrlsPattern) {
|
||||
const regex = new RegExp(allowedUrlsPattern);
|
||||
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
],
|
||||
});
|
8
apps/crm/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/crm/src/pages/api/trpc/[trpc].ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
|
||||
import { appRouter } from "../../../modules/trpc/trpc-app-router";
|
||||
|
||||
export default trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTrpcContext,
|
||||
});
|
71
apps/crm/src/pages/api/webhooks/customer-created.ts
Normal file
71
apps/crm/src/pages/api/webhooks/customer-created.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
CustomerCreatedDocument,
|
||||
CustomerCreatedPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/create-graphq-client";
|
||||
import { MailchimpConfigSettingsManager } from "../../../modules/mailchimp/mailchimp-config-settings-manager";
|
||||
import { MailchimpClientOAuth } from "../../../modules/mailchimp/mailchimp-client";
|
||||
import { metadataToMailchimpTags } from "../../../modules/saleor-customers-sync/metadata-to-mailchimp-tags";
|
||||
|
||||
export const customerCreatedWebhook = new SaleorAsyncWebhook<CustomerCreatedPayloadFragment>({
|
||||
name: "Customer Created in Saleor",
|
||||
webhookPath: "api/webhooks/customer-created",
|
||||
asyncEvent: "CUSTOMER_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
query: CustomerCreatedDocument,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<CustomerCreatedPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: customerCreatedWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
|
||||
const { user } = payload;
|
||||
|
||||
if (!user) {
|
||||
logger.error("Invalid payload from webhook");
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
const settingsManager = new MailchimpConfigSettingsManager(client);
|
||||
|
||||
const config = await settingsManager.getConfig();
|
||||
|
||||
if (config?.customerCreateEvent?.enabled) {
|
||||
const mailchimpClient = new MailchimpClientOAuth(config.dc, config.token);
|
||||
|
||||
const tags = metadataToMailchimpTags(user);
|
||||
|
||||
await mailchimpClient.addContact(config.customerCreateEvent.listId, user.email, {
|
||||
lastName: user.lastName,
|
||||
firstName: user.firstName,
|
||||
extraTags: tags,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default customerCreatedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
80
apps/crm/src/pages/api/webhooks/customer-updated.ts
Normal file
80
apps/crm/src/pages/api/webhooks/customer-updated.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
CustomerUpdatedDocument,
|
||||
CustomerUpdatedPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { createClient } from "../../../lib/create-graphq-client";
|
||||
import { MailchimpConfigSettingsManager } from "../../../modules/mailchimp/mailchimp-config-settings-manager";
|
||||
import { MailchimpClientOAuth } from "../../../modules/mailchimp/mailchimp-client";
|
||||
import { metadataToMailchimpTags } from "../../../modules/saleor-customers-sync/metadata-to-mailchimp-tags";
|
||||
|
||||
export const customerMetadataUpdatedWebhook =
|
||||
new SaleorAsyncWebhook<CustomerUpdatedPayloadFragment>({
|
||||
name: "Customer updated in Saleor",
|
||||
webhookPath: "api/webhooks/customer-updated",
|
||||
asyncEvent: "CUSTOMER_UPDATED",
|
||||
apl: saleorApp.apl,
|
||||
query: CustomerUpdatedDocument,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<CustomerUpdatedPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: customerMetadataUpdatedWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
|
||||
const { user } = payload;
|
||||
|
||||
if (!user) {
|
||||
logger.error("Invalid payload from webhook");
|
||||
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
const settingsManager = new MailchimpConfigSettingsManager(client);
|
||||
|
||||
const config = await settingsManager.getConfig();
|
||||
|
||||
logger.info(config, "webhook");
|
||||
|
||||
if (config?.customerCreateEvent?.enabled) {
|
||||
const mailchimpClient = new MailchimpClientOAuth(config.dc, config.token);
|
||||
|
||||
const tags = metadataToMailchimpTags(user);
|
||||
|
||||
try {
|
||||
await mailchimpClient.addContact(config.customerCreateEvent.listId, user.email, {
|
||||
lastName: user.lastName,
|
||||
firstName: user.firstName,
|
||||
extraTags: tags,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
|
||||
return res.status(500).end("Error saving customer in Mailchimp");
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default customerMetadataUpdatedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
17
apps/crm/src/pages/configuration/mailchimp/auth.tsx
Normal file
17
apps/crm/src/pages/configuration/mailchimp/auth.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from "react";
|
||||
import { MailchimpAuthSection } from "../../../modules/mailchimp/auth/mailchimp-auth-section/mailchimp-auth-section";
|
||||
import { NextPageWithLayoutOverwrite } from "../../_app";
|
||||
import { ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
import { NoSSRWrapper } from "@saleor/apps-shared";
|
||||
|
||||
const MailchimpAuthPage: NextPageWithLayoutOverwrite = () => {
|
||||
return <MailchimpAuthSection />;
|
||||
};
|
||||
|
||||
MailchimpAuthPage.overwriteLayout = (page) => (
|
||||
<NoSSRWrapper>
|
||||
<ThemeProvider>{page}</ThemeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
|
||||
export default MailchimpAuthPage;
|
39
apps/crm/src/pages/configuration/mailchimp/oauth-success.tsx
Normal file
39
apps/crm/src/pages/configuration/mailchimp/oauth-success.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Box, Text, ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
import { NextPageWithLayoutOverwrite } from "../../_app";
|
||||
import { NoSSRWrapper } from "@saleor/apps-shared";
|
||||
|
||||
const MailchimpOauthSuccessPage: NextPageWithLayoutOverwrite = () => {
|
||||
const token = useRouter().query.token;
|
||||
const email = useRouter().query.email;
|
||||
const dc = useRouter().query.dc;
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
const payload = { type: "mailchimp_token", token, dc };
|
||||
|
||||
window.parent.postMessage(payload, window.location.origin);
|
||||
}
|
||||
}, [token, dc]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="title" as="h1" marginBottom={4}>
|
||||
Success
|
||||
</Text>
|
||||
<Text as="p" marginBottom={4}>
|
||||
Successfully authorized Mailchimp as {email}
|
||||
</Text>
|
||||
<Text>Will redirect soon...</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
MailchimpOauthSuccessPage.overwriteLayout = (page) => (
|
||||
<NoSSRWrapper>
|
||||
<ThemeProvider>{page}</ThemeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
|
||||
export default MailchimpOauthSuccessPage;
|
|
@ -0,0 +1,49 @@
|
|||
import { NextPage } from "next";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { AppColumnsLayout } from "../../../modules/ui/app-columns-layout/app-columns-layout";
|
||||
import { ProvidersList } from "../../../modules/providers/providers-list/providers-list";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
isValidProviderType,
|
||||
ProvidersTypes,
|
||||
ProviderType,
|
||||
} from "../../../modules/providers/providers-types";
|
||||
import { MailchimpConfigView } from "../../../modules/mailchimp/views/mailchimp-config-view/mailchimp-config-view";
|
||||
|
||||
const views = {
|
||||
mailchimp: MailchimpConfigView,
|
||||
} satisfies Record<ProviderType, React.ComponentType>;
|
||||
|
||||
const ProvidersPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const selectedProviderQuery = router.query.provider && router.query.provider[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidProviderType(selectedProviderQuery)) {
|
||||
router.push(`/configuration/providers/${ProvidersTypes.mailchimp}`);
|
||||
}
|
||||
// todo show 404?
|
||||
}, [selectedProviderQuery, router]);
|
||||
|
||||
const selectedProvider = selectedProviderQuery as ProviderType;
|
||||
|
||||
const ProviderView = views[selectedProvider] ?? (() => null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Connect Saleor clients database with your favourite CRM platform.</p>
|
||||
<AppColumnsLayout marginTop={12}>
|
||||
<ProvidersList
|
||||
onProviderClick={(provider) => {
|
||||
router.push(`/configuration/providers/${provider}`);
|
||||
}}
|
||||
activeProvider="mailchimp"
|
||||
/>
|
||||
<ProviderView />
|
||||
</AppColumnsLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProvidersPage;
|
20
apps/crm/src/pages/index.tsx
Normal file
20
apps/crm/src/pages/index.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { NextPage } from "next";
|
||||
import React, { useEffect } from "react";
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const IndexPage: NextPage = () => {
|
||||
const { appBridgeState, appBridge } = useAppBridge();
|
||||
const { replace } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (appBridgeState?.ready) {
|
||||
replace("/configuration/providers/mailchimp");
|
||||
}
|
||||
}, [appBridgeState, appBridge]);
|
||||
|
||||
return <p>Loading</p>;
|
||||
};
|
||||
|
||||
export default IndexPage;
|
35
apps/crm/src/saleor-app.ts
Normal file
35
apps/crm/src/saleor-app.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL } from "@saleor/app-sdk/APL";
|
||||
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
|
||||
const aplType = process.env.APL ?? "file";
|
||||
|
||||
export let apl: APL;
|
||||
|
||||
switch (aplType) {
|
||||
case "upstash":
|
||||
apl = new UpstashAPL();
|
||||
|
||||
break;
|
||||
case "file":
|
||||
apl = new FileAPL();
|
||||
|
||||
break;
|
||||
case "saleor-cloud": {
|
||||
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
|
||||
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
|
||||
}
|
||||
|
||||
apl = new SaleorCloudAPL({
|
||||
resourceUrl: process.env.REST_APL_ENDPOINT,
|
||||
token: process.env.REST_APL_TOKEN,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid APL config, ");
|
||||
}
|
||||
}
|
||||
export const saleorApp = new SaleorApp({
|
||||
apl,
|
||||
});
|
6
apps/crm/src/setup-tests.ts
Normal file
6
apps/crm/src/setup-tests.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Add test setup logic here
|
||||
*
|
||||
* https://vitest.dev/config/#setupfiles
|
||||
*/
|
||||
export {};
|
15
apps/crm/src/styles/globals.css
Normal file
15
apps/crm/src/styles/globals.css
Normal file
|
@ -0,0 +1,15 @@
|
|||
body {
|
||||
color: var(--mu-colors-foreground-text-neutral-plain);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--mu-colors-foreground-text-neutral-subdued);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
20
apps/crm/tsconfig.json
Normal file
20
apps/crm/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
13
apps/crm/vitest.config.ts
Normal file
13
apps/crm/vitest.config.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/setup-tests.ts",
|
||||
css: false,
|
||||
},
|
||||
});
|
|
@ -11,6 +11,7 @@
|
|||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"start": "turbo run start",
|
||||
"test": "turbo run test",
|
||||
"test:ci": "turbo run test:ci",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
|
|
875
pnpm-lock.yaml
875
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
43
turbo.json
43
turbo.json
|
@ -1,23 +1,15 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": [
|
||||
"**/.env.*local"
|
||||
],
|
||||
"globalEnv": [
|
||||
"VERCEL_ENV"
|
||||
],
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"globalEnv": ["VERCEL_ENV"],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"env": [
|
||||
"NEXT_PUBLIC_VERCEL_ENV"
|
||||
],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**",
|
||||
".next/**"
|
||||
]
|
||||
"env": ["NEXT_PUBLIC_VERCEL_ENV"],
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"]
|
||||
},
|
||||
"start": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"build#saleor-app-data-importer": {
|
||||
"env": [
|
||||
|
@ -151,6 +143,21 @@
|
|||
"ALLOWED_DOMAIN_PATTERN"
|
||||
]
|
||||
},
|
||||
"build#saleor-app-crm": {
|
||||
"env": [
|
||||
"APL",
|
||||
"APP_DEBUG",
|
||||
"NODE_ENV",
|
||||
"SECRET_KEY",
|
||||
"PORT",
|
||||
"VERCEL_URL",
|
||||
"REST_APL_ENDPOINT",
|
||||
"REST_APL_TOKEN",
|
||||
"ALLOWED_DOMAIN_PATTERN",
|
||||
"MAILCHIMP_CLIENT_SECRET",
|
||||
"MAILCHIMP_CLIENT_ID"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
|
@ -160,9 +167,7 @@
|
|||
"test": {},
|
||||
"test:ci": {},
|
||||
"generate": {
|
||||
"outputs": [
|
||||
"generated/"
|
||||
]
|
||||
"outputs": ["generated/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue