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:
Lukasz Ostrowski 2023-04-06 09:26:56 +02:00 committed by GitHub
parent eca52ad058
commit 3acc1553a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 40421 additions and 22 deletions

1
.github/labeler.yml vendored
View file

@ -8,3 +8,4 @@
"App: Taxes": "apps/taxes/**/*" "App: Taxes": "apps/taxes/**/*"
"App: Emails & Messages": "apps/emails-and-messages/**/*" "App: Emails & Messages": "apps/emails-and-messages/**/*"
"App: CMS": "apps/cms/**/*" "App: CMS": "apps/cms/**/*"
"App: CRM": "apps/crm/**/*"

12
apps/crm/.env.example Normal file
View 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
View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

20
apps/crm/.graphqlrc.yml Normal file
View 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
View file

@ -0,0 +1,7 @@
![Hero image](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>Saleor App CRM</h1>
</div>
TODO - link docs page

View file

View file

@ -0,0 +1,10 @@
fragment Customer on User {
id
email
lastName
firstName
privateMetadata {
key
value
}
}

View file

@ -0,0 +1,5 @@
fragment CustomerCreatedPayload on CustomerCreated {
user {
...Customer
}
}

View file

@ -0,0 +1,5 @@
fragment CustomerUpdatedPayload on CustomerUpdated {
user {
...Customer
}
}

View file

View file

View file

@ -0,0 +1,16 @@
query FetchCustomers($cursor: String) {
customers(first: 100, after: $cursor) {
totalCount
pageInfo {
endCursor
hasNextPage
}
edges {
node {
... on User {
...Customer
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,7 @@
subscription CustomerCreated {
event {
... on CustomerCreated {
...CustomerCreatedPayload
}
}
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

1
apps/crm/src/const.ts Normal file
View file

@ -0,0 +1 @@
export const appName = "CRM";

View 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;
};

View 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,
],
});

View 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} />;
}

View 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);

View 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),
});
};

View 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]
),
};
};

View file

@ -0,0 +1,5 @@
.frame {
width: 100%;
border: none;
height: 100%;
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);

View file

@ -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: [&quot;Tag1&quot;, &quot;Tag2&quot;]</code>
</Text>
</Paragraph>
<Paragraph>
Check{" "}
<TextLink href={getGraphiqlExampleQueryPlaygroundUrl(appBridgeState?.saleorApiUrl)}>
example query
</TextLink>
</Paragraph>
</Box>
</Section>
);
};

View file

@ -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>
);
}
}
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View 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,
},
};

View 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 ?? [])],
})),
});
}
}

View file

@ -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",
})
);
});
});

View file

@ -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;

View 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,
},
};

View file

@ -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>
);
};

View file

@ -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} />;
};

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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>
);
};

View 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);
};

View file

@ -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([]);
});
});

View file

@ -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 [];
}
};

View file

@ -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>
);
};

View file

@ -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,
};
};

View 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,
},
});
});

View 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;

View 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,
});

View 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>;

View 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;

View file

@ -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%;
}

View file

@ -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>
);
};

View 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}
/>
);
};

View file

@ -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>
);
};

View 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}
/>
);
};

View 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}
/>
);
};

View 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);

View 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;

View 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;

View 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;
},
});

View 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;
},
],
});

View 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,
});

View 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,
},
};

View 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,
},
};

View 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;

View 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;

View file

@ -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;

View 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;

View 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,
});

View file

@ -0,0 +1,6 @@
/**
* Add test setup logic here
*
* https://vitest.dev/config/#setupfiles
*/
export {};

View 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
View 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
View 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,
},
});

View file

@ -11,6 +11,7 @@
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev", "dev": "turbo run dev",
"lint": "turbo run lint", "lint": "turbo run lint",
"start": "turbo run start",
"test": "turbo run test", "test": "turbo run test",
"test:ci": "turbo run test:ci", "test:ci": "turbo run test:ci",
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,md}\"",

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,15 @@
{ {
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local" "globalEnv": ["VERCEL_ENV"],
],
"globalEnv": [
"VERCEL_ENV"
],
"pipeline": { "pipeline": {
"build": { "build": {
"env": [ "env": ["NEXT_PUBLIC_VERCEL_ENV"],
"NEXT_PUBLIC_VERCEL_ENV" "dependsOn": ["^build"],
], "outputs": ["dist/**", ".next/**"]
"dependsOn": [ },
"^build" "start": {
], "dependsOn": ["^build"]
"outputs": [
"dist/**",
".next/**"
]
}, },
"build#saleor-app-data-importer": { "build#saleor-app-data-importer": {
"env": [ "env": [
@ -151,6 +143,21 @@
"ALLOWED_DOMAIN_PATTERN" "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": { "lint": {
"outputs": [] "outputs": []
}, },
@ -160,9 +167,7 @@
"test": {}, "test": {},
"test:ci": {}, "test:ci": {},
"generate": { "generate": {
"outputs": [ "outputs": ["generated/"]
"generated/"
]
} }
} }
} }