Compare commits
12 commits
main
...
update-sen
Author | SHA1 | Date | |
---|---|---|---|
![]() |
850bd05c59 | ||
![]() |
80d54bf456 | ||
![]() |
eb9bd700ca | ||
![]() |
d23e85a850 | ||
![]() |
49866e8942 | ||
![]() |
ffdc4932e8 | ||
![]() |
d21631892f | ||
![]() |
3f4eb03124 | ||
![]() |
876591a8c8 | ||
![]() |
03d479f348 | ||
![]() |
097b028657 | ||
![]() |
807b809940 |
112 changed files with 43038 additions and 12 deletions
|
@ -41,6 +41,7 @@ In the `apps` folder, you will find the following applications:
|
|||
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
|
||||
- [invoices](./apps/invoices) - generate invoice PDF for each order.
|
||||
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
|
||||
- [emails-and-messages](./apps/emails-and-messages) - notifications and email communication with customers.
|
||||
- [search](./apps/search) - connect Saleor with search engines.
|
||||
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
|
||||
- [taxes](./apps/taxes) - calculate order and checkout taxes using external services.
|
||||
|
|
9
apps/emails-and-messages/.env.example
Normal file
9
apps/emails-and-messages/.env.example
Normal file
|
@ -0,0 +1,9 @@
|
|||
# The key used for metadata encryption. Required for production builds
|
||||
SECRET_KEY=
|
||||
|
||||
# APL Config
|
||||
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
|
||||
APL=
|
||||
REST_APL_ENDPOINT=
|
||||
REST_APL_TOKEN=
|
||||
|
4
apps/emails-and-messages/.eslintrc
Normal file
4
apps/emails-and-messages/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
20
apps/emails-and-messages/.graphqlrc.yml
Normal file
20
apps/emails-and-messages/.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
|
1
apps/emails-and-messages/.npmrc
Normal file
1
apps/emails-and-messages/.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
strict-peer-dependencies=false
|
96
apps/emails-and-messages/README.md
Normal file
96
apps/emails-and-messages/README.md
Normal file
|
@ -0,0 +1,96 @@
|
|||

|
||||
|
||||
<div align="center">
|
||||
<h1>Emails and messages</h1>
|
||||
</div>
|
||||
|
||||
### What's included?
|
||||
|
||||
- sending emails via SMTP
|
||||
- MJML template support
|
||||
- supported messages:
|
||||
- order
|
||||
- created
|
||||
- confirmed
|
||||
- cancelled
|
||||
- fully fulfilled
|
||||
- fully paid
|
||||
- invoice
|
||||
- sent
|
||||
|
||||
### How to install
|
||||
|
||||
- start local dev server or deploy the application
|
||||
- install the application in your Saleor Instance using manifest URL
|
||||
- configure the application in the dashboard
|
||||
|
||||
### Learn more about Apps
|
||||
|
||||
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
|
||||
|
||||
[Configuring apps in dashboard](https://docs.saleor.io/docs/3.x/dashboard/apps)
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
Before you start, make sure you have installed:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
- [Saleor CLI](https://docs.saleor.io/docs/3.x/cli) - optional, but recommended
|
||||
|
||||
1. Install the dependencies by running:
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Start the local server with:
|
||||
|
||||
```
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
3. Expose local environment using tunnel:
|
||||
|
||||
- Use Saleor CLI `saleor app tunnel`
|
||||
- Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
|
||||
|
||||
4. Install application at your dashboard:
|
||||
|
||||
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
|
||||
|
||||
```
|
||||
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
|
||||
```
|
||||
|
||||
This template host manifest at `/api/manifest`
|
||||
|
||||
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
|
||||
|
||||
5. Start the dev SMTP server
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
All emails will be captured by the MailHog service. To inspect emails, open `http://localhost:8025/` in your browser.
|
||||
|
||||
### Generated schema and typings
|
||||
|
||||
Commands `build` and `dev` would generate schema and typed functions using Saleor's GraphQL endpoint. Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the schema changes.
|
||||
|
||||
[Learn more](https://www.graphql-code-generator.com/) about GraphQL code generation.
|
||||
|
||||
### Storing registration data - APL
|
||||
|
||||
During registration process Saleor API pass the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
|
||||
To store this data, app-template use a different [APL interfaces](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
|
||||
|
||||
The choice of the APL is done using `APL` environment variable. If value is not set, FileAPL is used. Available choices:
|
||||
|
||||
- `file`: no additional setup is required. Good choice for local development. Can't be used for multi tenant-apps or be deployed (not intended for production)
|
||||
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. Can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
|
||||
|
||||
If you want to use your own database, you can implement your own APL. [Check the documentation to read more.](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
8
apps/emails-and-messages/docker-compose.yml
Normal file
8
apps/emails-and-messages/docker-compose.yml
Normal file
|
@ -0,0 +1,8 @@
|
|||
version: "3.4"
|
||||
|
||||
services:
|
||||
mailhog:
|
||||
image: mailhog/mailhog
|
||||
ports:
|
||||
- 1025:1025 # smtp server
|
||||
- 8025:8025 # web ui. Visit http://localhost:8025/ to check emails
|
0
apps/emails-and-messages/graphql/fragments/.gitkeep
Normal file
0
apps/emails-and-messages/graphql/fragments/.gitkeep
Normal file
|
@ -0,0 +1,69 @@
|
|||
fragment OrderDetails on Order {
|
||||
id
|
||||
number
|
||||
userEmail
|
||||
channel {
|
||||
slug
|
||||
}
|
||||
user {
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
billingAddress {
|
||||
streetAddress1
|
||||
city
|
||||
postalCode
|
||||
country {
|
||||
country
|
||||
}
|
||||
}
|
||||
shippingAddress {
|
||||
streetAddress1
|
||||
city
|
||||
postalCode
|
||||
country {
|
||||
country
|
||||
}
|
||||
}
|
||||
lines {
|
||||
id
|
||||
productName
|
||||
variantName
|
||||
quantity
|
||||
thumbnail {
|
||||
url
|
||||
alt
|
||||
}
|
||||
unitPrice {
|
||||
gross {
|
||||
currency
|
||||
amount
|
||||
}
|
||||
}
|
||||
totalPrice {
|
||||
gross {
|
||||
currency
|
||||
amount
|
||||
}
|
||||
}
|
||||
}
|
||||
subtotal {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
shippingPrice {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
total {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
0
apps/emails-and-messages/graphql/mutations/.gitkeep
Normal file
0
apps/emails-and-messages/graphql/mutations/.gitkeep
Normal file
0
apps/emails-and-messages/graphql/queries/.gitkeep
Normal file
0
apps/emails-and-messages/graphql/queries/.gitkeep
Normal file
|
@ -0,0 +1,9 @@
|
|||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
query ShopDetails {
|
||||
shop {
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
27049
apps/emails-and-messages/graphql/schema.graphql
Normal file
27049
apps/emails-and-messages/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/emails-and-messages/graphql/subscriptions/.gitkeep
Normal file
0
apps/emails-and-messages/graphql/subscriptions/.gitkeep
Normal file
5
apps/emails-and-messages/next-env.d.ts
vendored
Normal file
5
apps/emails-and-messages/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.
|
4
apps/emails-and-messages/next.config.js
Normal file
4
apps/emails-and-messages/next.config.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
};
|
85
apps/emails-and-messages/package.json
Normal file
85
apps/emails-and-messages/package.json
Normal file
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"name": "saleor-app-emails-and-messages",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||
"build": "pnpm generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "pnpm generate && prettier --loglevel warn --write . && eslint --fix .",
|
||||
"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.11.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@monaco-editor/react": "^4.4.6",
|
||||
"@saleor/app-sdk": "0.30.0",
|
||||
"@saleor/apps-shared": "workspace:*",
|
||||
"@saleor/macaw-ui": "^0.7.2",
|
||||
"@sendgrid/client": "^7.7.0",
|
||||
"@sendgrid/mail": "^7.7.0",
|
||||
"@tanstack/react-query": "^4.24.4",
|
||||
"@trpc/client": "^10.13.0",
|
||||
"@trpc/next": "^10.13.0",
|
||||
"@trpc/react-query": "^10.13.0",
|
||||
"@trpc/server": "^10.13.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",
|
||||
"handlebars": "^4.7.7",
|
||||
"html-to-text": "^9.0.3",
|
||||
"jsdom": "^20.0.3",
|
||||
"mjml": "^4.13.0",
|
||||
"next": "13.2.1",
|
||||
"next-urql": "^4.0.3",
|
||||
"nodemailer": "^6.9.1",
|
||||
"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",
|
||||
"react-query": "^3.39.3",
|
||||
"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": "2.13.3",
|
||||
"@graphql-codegen/introspection": "2.2.1",
|
||||
"@graphql-codegen/typed-document-node": "^2.3.3",
|
||||
"@graphql-codegen/typescript": "2.7.3",
|
||||
"@graphql-codegen/typescript-operations": "2.5.3",
|
||||
"@graphql-codegen/typescript-urql": "^3.7.0",
|
||||
"@graphql-codegen/urql-introspection": "2.2.1",
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/html-to-text": "^9.0.0",
|
||||
"@types/mjml": "^4.7.0",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
"@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"
|
||||
}
|
||||
}
|
8660
apps/emails-and-messages/pnpm-lock.yaml
Normal file
8660
apps/emails-and-messages/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
1
apps/emails-and-messages/src/const.ts
Normal file
1
apps/emails-and-messages/src/const.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const appName = "Emails and messages";
|
48
apps/emails-and-messages/src/lib/create-graphql-client.ts
Normal file
48
apps/emails-and-messages/src/lib/create-graphql-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,
|
||||
],
|
||||
});
|
7
apps/emails-and-messages/src/lib/is-in-iframe.ts
Normal file
7
apps/emails-and-messages/src/lib/is-in-iframe.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function isInIframe() {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
19
apps/emails-and-messages/src/lib/logger.ts
Normal file
19
apps/emails-and-messages/src/lib/logger.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import pino from "pino";
|
||||
|
||||
/**
|
||||
* TODO Set up log drain etc
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: "debug",
|
||||
transport:
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const createLogger = logger.child.bind(logger);
|
93
apps/emails-and-messages/src/lib/metadata-manager.ts
Normal file
93
apps/emails-and-messages/src/lib/metadata-manager.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { MetadataEntry, EncryptedMetadataManager } 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: Client): 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: Client, 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: Client) => {
|
||||
// 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),
|
||||
});
|
||||
};
|
19
apps/emails-and-messages/src/lib/no-ssr-wrapper.tsx
Normal file
19
apps/emails-and-messages/src/lib/no-ssr-wrapper.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
||||
|
||||
/**
|
||||
* Saleor App can be rendered only as a Saleor Dashboard iframe.
|
||||
* All content is rendered after Dashboard exchanges auth with the app.
|
||||
* Hence, there is no reason to render app server side.
|
||||
*
|
||||
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
|
||||
* of using dynamic() calls
|
||||
*
|
||||
* You can use this wrapper selectively for some pages or remove it completely.
|
||||
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
|
||||
*/
|
||||
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
||||
ssr: false,
|
||||
});
|
48
apps/emails-and-messages/src/lib/theme-synchronizer.test.tsx
Normal file
48
apps/emails-and-messages/src/lib/theme-synchronizer.test.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { ThemeSynchronizer } from "./theme-synchronizer";
|
||||
|
||||
const appBridgeState: AppBridgeState = {
|
||||
ready: true,
|
||||
token: "token",
|
||||
domain: "some-domain.saleor.cloud",
|
||||
theme: "dark",
|
||||
path: "/",
|
||||
locale: "en",
|
||||
id: "app-id",
|
||||
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
|
||||
};
|
||||
|
||||
const mockThemeChange = vi.fn();
|
||||
|
||||
vi.mock("@saleor/app-sdk/app-bridge", () => {
|
||||
return {
|
||||
useAppBridge() {
|
||||
return {
|
||||
appBridgeState: appBridgeState,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@saleor/macaw-ui", () => {
|
||||
return {
|
||||
useTheme() {
|
||||
return {
|
||||
setTheme: mockThemeChange,
|
||||
themeType: "light",
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("ThemeSynchronizer", () => {
|
||||
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
|
||||
render(<ThemeSynchronizer />);
|
||||
|
||||
return waitFor(() => {
|
||||
expect(mockThemeChange).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
});
|
||||
});
|
33
apps/emails-and-messages/src/lib/theme-synchronizer.tsx
Normal file
33
apps/emails-and-messages/src/lib/theme-synchronizer.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
||||
* Macaw must be informed about this change from AppBridge.
|
||||
*
|
||||
* If you are not using Macaw, you can remove this.
|
||||
*/
|
||||
function _ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme, themeType } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeType !== appBridgeState?.theme) {
|
||||
setTheme(appBridgeState.theme);
|
||||
/**
|
||||
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
|
||||
* TODO Fix me when Macaw 2.0 is shipped
|
||||
*/
|
||||
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
|
@ -0,0 +1,33 @@
|
|||
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
|
||||
|
||||
export const getDefaultEmptyAppConfiguration = (): AppConfigurationPerChannel => ({
|
||||
active: false,
|
||||
mjmlConfigurationId: undefined,
|
||||
sendgridConfigurationId: undefined,
|
||||
});
|
||||
|
||||
const getChannelAppConfiguration =
|
||||
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
|
||||
try {
|
||||
return appConfig?.configurationsPerChannel[channelSlug] ?? null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setChannelAppConfiguration =
|
||||
(appConfig: AppConfig | null | undefined) =>
|
||||
(channelSlug: string) =>
|
||||
(appConfiguration: AppConfigurationPerChannel) => {
|
||||
const appConfigNormalized = structuredClone(appConfig) ?? { configurationsPerChannel: {} };
|
||||
|
||||
appConfigNormalized.configurationsPerChannel[channelSlug] ??= getDefaultEmptyAppConfiguration();
|
||||
appConfigNormalized.configurationsPerChannel[channelSlug] = appConfiguration;
|
||||
|
||||
return appConfigNormalized;
|
||||
};
|
||||
|
||||
export const AppConfigContainer = {
|
||||
getChannelAppConfiguration,
|
||||
setChannelAppConfiguration,
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const appConfigInputSchema = z.object({
|
||||
configurationsPerChannel: z.record(
|
||||
z.object({
|
||||
active: z.boolean(),
|
||||
mjmlConfigurationId: z.string().optional(),
|
||||
sendgridConfigurationId: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const appChannelConfigurationInputSchema = z.object({
|
||||
channel: z.string(),
|
||||
configuration: z.object({
|
||||
active: z.boolean(),
|
||||
mjmlConfigurationId: z.string().optional(),
|
||||
sendgridConfigurationId: z.string().optional(),
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
export interface AppConfigurationPerChannel {
|
||||
active: boolean;
|
||||
mjmlConfigurationId?: string;
|
||||
sendgridConfigurationId?: string;
|
||||
}
|
||||
|
||||
export type AppConfigurationsChannelMap = Record<string, AppConfigurationPerChannel>;
|
||||
|
||||
export type AppConfig = {
|
||||
configurationsPerChannel: AppConfigurationsChannelMap;
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
import {
|
||||
appChannelConfigurationInputSchema,
|
||||
appConfigInputSchema,
|
||||
} from "./app-config-input-schema";
|
||||
import { AppConfigurationService } from "./get-app-configuration.service";
|
||||
import { router } from "../trpc/trpc-server";
|
||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||
import { z } from "zod";
|
||||
|
||||
// Allow access only for the dashboard users and attaches the
|
||||
// configuration service to the context
|
||||
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||
next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
configurationService: new AppConfigurationService({
|
||||
apiClient: ctx.apiClient,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const appConfigurationRouter = router({
|
||||
getChannelConfiguration: protectedWithConfigurationService
|
||||
.input(z.object({ channelSlug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug("Get Channel Configuration called");
|
||||
|
||||
return await ctx.configurationService.getChannelConfiguration(input.channelSlug);
|
||||
}),
|
||||
|
||||
setChannelConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(appChannelConfigurationInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug("Set channel configuration called");
|
||||
|
||||
await ctx.configurationService.setChannelConfiguration(input);
|
||||
}),
|
||||
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
|
||||
logger.debug("appConfigurationRouter.fetch called");
|
||||
|
||||
return new AppConfigurationService({
|
||||
apiClient: ctx.apiClient,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
}).getConfiguration();
|
||||
}),
|
||||
setAndReplace: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(appConfigInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
|
||||
logger.debug(input, "appConfigurationRouter.setAndReplace called with input");
|
||||
|
||||
await ctx.configurationService.setConfigurationRoot(input);
|
||||
|
||||
return null;
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { AppConfig } from "./app-config";
|
||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
export interface AppConfigurator {
|
||||
setConfig(config: AppConfig): Promise<void>;
|
||||
getConfig(): Promise<AppConfig | undefined>;
|
||||
}
|
||||
|
||||
export class PrivateMetadataAppConfigurator implements AppConfigurator {
|
||||
private metadataKey = "app-config";
|
||||
|
||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
||||
|
||||
getConfig(): Promise<AppConfig | undefined> {
|
||||
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid metadata value, cant be parsed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConfig(config: AppConfig): Promise<void> {
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: JSON.stringify(config),
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { AppConfig } from "./app-config";
|
||||
import { AppConfigContainer, getDefaultEmptyAppConfiguration } from "./app-config-container";
|
||||
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
|
||||
|
||||
/**
|
||||
* TODO Test
|
||||
*/
|
||||
export const FallbackAppConfig = {
|
||||
createFallbackConfigFromExistingShopAndChannels(
|
||||
channels: ChannelFragment[],
|
||||
shopAppConfiguration: ShopInfoFragment | null
|
||||
) {
|
||||
return (channels ?? []).reduce<AppConfig>(
|
||||
(state, channel) => {
|
||||
return AppConfigContainer.setChannelAppConfiguration(state)(channel.slug)(
|
||||
getDefaultEmptyAppConfiguration()
|
||||
);
|
||||
},
|
||||
{ configurationsPerChannel: {} }
|
||||
);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,91 @@
|
|||
import { PrivateMetadataAppConfigurator } from "./app-configurator";
|
||||
import { createSettingsManager } from "./metadata-manager";
|
||||
import { Client } from "urql";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
|
||||
import { getDefaultEmptyAppConfiguration } from "./app-config-container";
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
service: "AppConfigurationService",
|
||||
});
|
||||
|
||||
export class AppConfigurationService {
|
||||
private configurationData?: AppConfig;
|
||||
private metadataConfigurator: PrivateMetadataAppConfigurator;
|
||||
|
||||
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: AppConfig }) {
|
||||
this.metadataConfigurator = new PrivateMetadataAppConfigurator(
|
||||
createSettingsManager(args.apiClient),
|
||||
args.saleorApiUrl
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch configuration from Saleor API and cache it
|
||||
private async pullConfiguration() {
|
||||
logger.debug("Fetch configuration from Saleor API");
|
||||
|
||||
const config = await this.metadataConfigurator.getConfig();
|
||||
this.configurationData = config;
|
||||
}
|
||||
|
||||
// Push configuration to Saleor API
|
||||
private async pushConfiguration() {
|
||||
logger.debug("Push configuration to Saleor API");
|
||||
|
||||
await this.metadataConfigurator.setConfig(this.configurationData!);
|
||||
}
|
||||
|
||||
async getConfiguration() {
|
||||
logger.debug("Get configuration");
|
||||
|
||||
if (!this.configurationData) {
|
||||
logger.debug("No configuration found in cache. Will fetch it from Saleor API");
|
||||
await this.pullConfiguration();
|
||||
}
|
||||
|
||||
const savedAppConfig = this.configurationData ?? null;
|
||||
|
||||
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
|
||||
|
||||
if (savedAppConfig) {
|
||||
return savedAppConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Saves configuration to Saleor API and cache it
|
||||
async setConfigurationRoot(config: AppConfig) {
|
||||
logger.debug("Set configuration");
|
||||
|
||||
this.configurationData = config;
|
||||
await this.pushConfiguration();
|
||||
}
|
||||
|
||||
// Returns channel configuration if existing. Otherwise returns default empty one
|
||||
async getChannelConfiguration(channel: string) {
|
||||
logger.debug("Get channel configuration");
|
||||
const configurations = await this.getConfiguration();
|
||||
if (!configurations) {
|
||||
return getDefaultEmptyAppConfiguration();
|
||||
}
|
||||
|
||||
const channelConfiguration = configurations.configurationsPerChannel[channel];
|
||||
return channelConfiguration || getDefaultEmptyAppConfiguration();
|
||||
}
|
||||
|
||||
async setChannelConfiguration({
|
||||
channel,
|
||||
configuration,
|
||||
}: {
|
||||
channel: string;
|
||||
configuration: AppConfigurationPerChannel;
|
||||
}) {
|
||||
logger.debug("Set channel configuration");
|
||||
let configurations = await this.getConfiguration();
|
||||
if (!configurations) {
|
||||
configurations = { configurationsPerChannel: {} };
|
||||
}
|
||||
|
||||
configurations.configurationsPerChannel[channel] = configuration;
|
||||
await this.setConfigurationRoot(configurations);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
import { MetadataEntry, EncryptedMetadataManager } 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: Client): 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: Client, 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: Client) => {
|
||||
// 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),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,181 @@
|
|||
import { AppConfigurationPerChannel } from "../app-config";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { FormControl, InputLabel, Link, MenuItem, Select, Typography } from "@material-ui/core";
|
||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
||||
import React, { useEffect } from "react";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useRouter } from "next/router";
|
||||
import { mjmlUrls } from "../../mjml/urls";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
field: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
channelName: {
|
||||
cursor: "pointer",
|
||||
borderBottom: `2px solid ${theme.palette.secondary.main}`,
|
||||
},
|
||||
}));
|
||||
|
||||
type AppConfigurationFormProps = {
|
||||
channelSlug: string;
|
||||
channelName: string;
|
||||
channelID: string;
|
||||
mjmlConfigurationChoices: { label: string; value: string }[];
|
||||
sendgridConfigurationChoices: { label: string; value: string }[];
|
||||
onSubmit(data: AppConfigurationPerChannel): Promise<void>;
|
||||
initialData?: AppConfigurationPerChannel | null;
|
||||
};
|
||||
|
||||
export const AppConfigurationForm = (props: AppConfigurationFormProps) => {
|
||||
const styles = useStyles();
|
||||
const { appBridge } = useAppBridge();
|
||||
const router = useRouter();
|
||||
|
||||
const { handleSubmit, getValues, setValue, control, reset } = useForm<AppConfigurationPerChannel>(
|
||||
{
|
||||
defaultValues: props.initialData ?? undefined,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(props.initialData || undefined);
|
||||
}, [props.initialData, reset]);
|
||||
|
||||
const handleChannelNameClick = () => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: `/channels/${props.channelID}`,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const isNoSendgridConfigurations = !props.sendgridConfigurationChoices.length;
|
||||
const isNoMjmlConfigurations = !props.mjmlConfigurationChoices.length;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((data, event) => {
|
||||
props.onSubmit(data);
|
||||
})}
|
||||
>
|
||||
<Typography variant="h2" paragraph>
|
||||
Configure
|
||||
<span onClick={handleChannelNameClick} className={styles.channelName}>
|
||||
{` ${props.channelName} `}
|
||||
</span>
|
||||
channel:
|
||||
</Typography>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="active"
|
||||
render={({ field: { value, name, onChange } }) => (
|
||||
<div className={styles.field}>
|
||||
{/* TODO: fix types in the MacawUI */}
|
||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
||||
<SwitchSelector key={name} className={styles.field}>
|
||||
{[
|
||||
{ label: "Active", value: true },
|
||||
{ label: "Disabled", value: false },
|
||||
].map((button) => (
|
||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
||||
<SwitchSelectorButton
|
||||
value={button.value.toString()}
|
||||
onClick={() => onChange(button.value)}
|
||||
activeTab={value?.toString() || "false"}
|
||||
key={button.label}
|
||||
>
|
||||
{button.label}
|
||||
</SwitchSelectorButton>
|
||||
))}
|
||||
</SwitchSelector>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="mjmlConfigurationId"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl disabled={isNoMjmlConfigurations} className={styles.field} fullWidth>
|
||||
<InputLabel>MJML Configuration</InputLabel>
|
||||
<Select
|
||||
variant="outlined"
|
||||
value={value}
|
||||
onChange={(event, val) => {
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem key="none" value={undefined}>
|
||||
No configuration
|
||||
</MenuItem>
|
||||
{props.mjmlConfigurationChoices.map((choice) => (
|
||||
<MenuItem key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{isNoMjmlConfigurations && (
|
||||
<Link
|
||||
href="#"
|
||||
onClick={() => {
|
||||
router.push(mjmlUrls.configuration());
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Currently theres no MJML configuration available. Click here to create one.
|
||||
</Typography>
|
||||
</Link>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="sendgridConfigurationId"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl disabled={isNoSendgridConfigurations} className={styles.field} fullWidth>
|
||||
<InputLabel>Sendgrid Configuration</InputLabel>
|
||||
<Select
|
||||
variant="outlined"
|
||||
value={value}
|
||||
onChange={(event, val) => {
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
>
|
||||
<MenuItem key="none" value={undefined}>
|
||||
No configuration
|
||||
</MenuItem>
|
||||
{props.sendgridConfigurationChoices.map((choice) => (
|
||||
<MenuItem key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{isNoSendgridConfigurations && (
|
||||
<Link
|
||||
href="#"
|
||||
onClick={() => {
|
||||
router.push("");
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Currently theres no Sendgrid configuration available. Click here to create one.
|
||||
</Typography>
|
||||
</Link>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="primary">
|
||||
Save configuration
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
|
||||
import { AppConfigurationForm } from "./app-configuration-form";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { AppColumnsLayout } from "../../ui/app-columns-layout";
|
||||
import { trpcClient } from "../../trpc/trpc-client";
|
||||
import SideMenu from "./side-menu";
|
||||
import { LoadingIndicator } from "../../ui/loading-indicator";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
formContainer: {
|
||||
top: 0,
|
||||
},
|
||||
instructionsContainer: {
|
||||
padding: 15,
|
||||
},
|
||||
configurationColumn: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 20,
|
||||
maxWidth: 600,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const ChannelsConfigurationTab = () => {
|
||||
const styles = useStyles();
|
||||
const { appBridge } = useAppBridge();
|
||||
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
|
||||
|
||||
const { data: channelsData, isLoading: isChannelsDataLoading } =
|
||||
trpcClient.channels.fetch.useQuery(undefined, {
|
||||
onSuccess: (data) => {
|
||||
if (data?.length) {
|
||||
setActiveChannelSlug(data[0].slug);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: configurationData,
|
||||
refetch: refetchConfig,
|
||||
isLoading: isConfigurationDataLoading,
|
||||
} = trpcClient.appConfiguration.getChannelConfiguration.useQuery(
|
||||
{
|
||||
channelSlug: activeChannelSlug!,
|
||||
},
|
||||
{ enabled: !!activeChannelSlug }
|
||||
);
|
||||
|
||||
const { data: mjmlConfigurations, isLoading: isMjmlQueryLoading } =
|
||||
trpcClient.mjmlConfiguration.getConfigurations.useQuery({});
|
||||
|
||||
const mjmlConfigurationsListData = useMemo(() => {
|
||||
return (
|
||||
mjmlConfigurations?.map((configuration) => ({
|
||||
value: configuration.id,
|
||||
label: configuration.configurationName,
|
||||
})) ?? []
|
||||
);
|
||||
}, [mjmlConfigurations]);
|
||||
|
||||
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
|
||||
trpcClient.sendgridConfiguration.fetch.useQuery();
|
||||
|
||||
const sendgridConfigurationsListData = useMemo(() => {
|
||||
if (!sendgridConfigurations) {
|
||||
return [];
|
||||
}
|
||||
const keys = Object.keys(sendgridConfigurations.availableConfigurations ?? {}) || [];
|
||||
|
||||
return (
|
||||
keys.map((key) => ({
|
||||
value: key,
|
||||
label: sendgridConfigurations.availableConfigurations[key].configurationName,
|
||||
})) ?? []
|
||||
);
|
||||
}, [sendgridConfigurations]);
|
||||
|
||||
const { mutate: mutateAppChannelConfiguration, error: saveError } =
|
||||
trpcClient.appConfiguration.setChannelConfiguration.useMutation({
|
||||
onSuccess() {
|
||||
refetchConfig();
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
text: "Saved app configuration",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const activeChannel = channelsData?.find((c) => c.slug === activeChannelSlug);
|
||||
|
||||
if (isChannelsDataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
if (!channelsData?.length) {
|
||||
return <div>NO CHANNELS</div>;
|
||||
}
|
||||
|
||||
const isFormDataLoading =
|
||||
isConfigurationDataLoading || isMjmlQueryLoading || isSendgridQueryLoading;
|
||||
|
||||
return (
|
||||
<AppColumnsLayout>
|
||||
<SideMenu
|
||||
title="Channels"
|
||||
selectedItemId={activeChannel?.slug}
|
||||
headerToolbar={
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: `/channels/`,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
}
|
||||
onClick={(id) => setActiveChannelSlug(id)}
|
||||
items={channelsData.map((c) => ({ label: c.name, id: c.slug })) || []}
|
||||
/>
|
||||
<div className={styles.configurationColumn}>
|
||||
{!activeChannel || isFormDataLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
<AppConfigurationForm
|
||||
channelID={activeChannel.id}
|
||||
key={activeChannelSlug}
|
||||
channelSlug={activeChannel.slug}
|
||||
mjmlConfigurationChoices={mjmlConfigurationsListData}
|
||||
sendgridConfigurationChoices={sendgridConfigurationsListData}
|
||||
onSubmit={async (data) => {
|
||||
mutateAppChannelConfiguration({
|
||||
channel: activeChannel.slug,
|
||||
configuration: data,
|
||||
});
|
||||
}}
|
||||
initialData={configurationData}
|
||||
channelName={activeChannel?.name ?? activeChannelSlug}
|
||||
/>
|
||||
{saveError && <span>{saveError.message}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppColumnsLayout>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
makeStyles,
|
||||
OffsettedList,
|
||||
OffsettedListBody,
|
||||
OffsettedListHeader,
|
||||
OffsettedListItem,
|
||||
OffsettedListItemCell,
|
||||
} from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import { Typography } from "@material-ui/core";
|
||||
import React from "react";
|
||||
import { ChannelFragment } from "../../../../generated/graphql";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
listItem: {
|
||||
cursor: "pointer",
|
||||
height: "auto !important",
|
||||
},
|
||||
listItemActive: {
|
||||
background: "#f4f4f4",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
},
|
||||
channelSlug: {
|
||||
fontFamily: "monospace",
|
||||
opacity: 0.8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
channels: ChannelFragment[];
|
||||
activeChannelSlug: string;
|
||||
onChannelClick(channelSlug: string): void;
|
||||
};
|
||||
|
||||
export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<OffsettedList gridTemplate={["1fr"]}>
|
||||
<OffsettedListHeader>
|
||||
<Typography variant="h3" paragraph>
|
||||
Available channels
|
||||
</Typography>
|
||||
</OffsettedListHeader>
|
||||
<OffsettedListBody>
|
||||
{channels.map((c) => {
|
||||
return (
|
||||
<OffsettedListItem
|
||||
className={clsx(styles.listItem, {
|
||||
[styles.listItemActive]: c.slug === activeChannelSlug,
|
||||
})}
|
||||
key={c.slug}
|
||||
onClick={() => {
|
||||
onChannelClick(c.slug);
|
||||
}}
|
||||
>
|
||||
<OffsettedListItemCell>
|
||||
{c.name}
|
||||
<Typography variant="caption" className={styles.channelSlug}>
|
||||
{c.slug}
|
||||
</Typography>
|
||||
</OffsettedListItemCell>
|
||||
</OffsettedListItem>
|
||||
);
|
||||
})}
|
||||
</OffsettedListBody>
|
||||
</OffsettedList>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
DeleteIcon,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
OffsettedList,
|
||||
OffsettedListBody,
|
||||
OffsettedListItem,
|
||||
OffsettedListItemCell,
|
||||
} from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
listItem: {
|
||||
cursor: "pointer",
|
||||
height: "auto !important",
|
||||
},
|
||||
listItemActive: {
|
||||
background: "#f4f4f4",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
},
|
||||
channelSlug: {
|
||||
fontFamily: "monospace",
|
||||
opacity: 0.8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type ListItem = {
|
||||
label: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
listItems: ListItem[];
|
||||
activeItemId?: string;
|
||||
onItemClick(itemId?: string): void;
|
||||
};
|
||||
|
||||
export const ConfigurationsList = ({ listItems, activeItemId, onItemClick }: Props) => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<OffsettedList gridTemplate={["1fr"]}>
|
||||
<OffsettedListBody>
|
||||
{listItems.map((c) => {
|
||||
return (
|
||||
<OffsettedListItem
|
||||
className={clsx(styles.listItem, {
|
||||
[styles.listItemActive]: c.id === activeItemId,
|
||||
})}
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
onItemClick(c.id);
|
||||
}}
|
||||
>
|
||||
<OffsettedListItemCell>
|
||||
{c.label}
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</OffsettedListItemCell>
|
||||
</OffsettedListItem>
|
||||
);
|
||||
})}
|
||||
<OffsettedListItem
|
||||
className={clsx(styles.listItem, {
|
||||
[styles.listItemActive]: activeItemId === undefined,
|
||||
})}
|
||||
key="new"
|
||||
onClick={() => {
|
||||
onItemClick();
|
||||
}}
|
||||
>
|
||||
<OffsettedListItemCell>Create new</OffsettedListItemCell>
|
||||
</OffsettedListItem>
|
||||
</OffsettedListBody>
|
||||
</OffsettedList>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import { Link, Paper, Typography } from "@material-ui/core";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
instructionsContainer: {
|
||||
padding: 15,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const Instructions = () => {
|
||||
const styles = useStyles();
|
||||
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
return (
|
||||
<Paper elevation={0} className={styles.instructionsContainer}>
|
||||
<Typography paragraph variant="h4">
|
||||
Usage instructions
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
Example of the external link to the documentation{" "}
|
||||
<Link
|
||||
href="#"
|
||||
onClick={() => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: "https://saleor.io/",
|
||||
newContext: true,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,111 @@
|
|||
import { Card, CardContent, CardHeader, Divider } from "@material-ui/core";
|
||||
("@material-ui/icons");
|
||||
import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { Skeleton } from "@material-ui/lab";
|
||||
|
||||
export const useStyles = makeStyles((theme) => ({
|
||||
menu: {
|
||||
height: "fit-content",
|
||||
},
|
||||
clickable: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
selected: {
|
||||
"&&&&::before": {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
width: "4px",
|
||||
height: "100%",
|
||||
backgroundColor: theme.palette.saleor.active[1],
|
||||
},
|
||||
},
|
||||
spaceBetween: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
tableRow: {
|
||||
minHeight: "48px",
|
||||
"&::after": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
greyText: {
|
||||
color: theme.palette.text.hint,
|
||||
},
|
||||
link: {
|
||||
all: "inherit",
|
||||
display: "contents",
|
||||
},
|
||||
}));
|
||||
|
||||
interface SideMenuProps {
|
||||
title: string;
|
||||
noItemsText?: string;
|
||||
items: { id: string; label: string }[];
|
||||
selectedItemId?: string;
|
||||
headerToolbar?: React.ReactNode;
|
||||
onDelete?: (itemId: string) => void;
|
||||
onClick: (itemId: string) => void;
|
||||
}
|
||||
|
||||
export const SideMenu: React.FC<SideMenuProps> = ({
|
||||
title,
|
||||
items,
|
||||
headerToolbar,
|
||||
selectedItemId,
|
||||
noItemsText,
|
||||
onDelete,
|
||||
onClick,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const isNoItems = !items || !items.length;
|
||||
return (
|
||||
<Card className={classes.menu}>
|
||||
<CardHeader title={title} action={headerToolbar} />
|
||||
{isNoItems ? (
|
||||
!!noItemsText && <CardContent className={classes.greyText}>{noItemsText}</CardContent>
|
||||
) : (
|
||||
<List gridTemplate={["1fr"]}>
|
||||
{items.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
<Divider />
|
||||
<ListItem
|
||||
className={clsx(classes.clickable, classes.tableRow, {
|
||||
[classes.selected]: item.id === selectedItemId,
|
||||
})}
|
||||
onClick={() => onClick(item.id)}
|
||||
>
|
||||
<ListItemCell>
|
||||
<div className={classes.spaceBetween}>
|
||||
{item.label}
|
||||
{!!onDelete && (
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</ListItemCell>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
)) ?? <Skeleton />}
|
||||
<Divider />
|
||||
</List>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SideMenu;
|
|
@ -0,0 +1,4 @@
|
|||
export const channelUrls = {
|
||||
configuration: (id?: string) =>
|
||||
id ? `/configuration/channels/${id}` : "/configuration/channels",
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { Client, gql } from "urql";
|
||||
import { FetchChannelsDocument } from "../../../generated/graphql";
|
||||
|
||||
gql`
|
||||
fragment Channel on Channel {
|
||||
name
|
||||
id
|
||||
slug
|
||||
}
|
||||
|
||||
query FetchChannels {
|
||||
channels {
|
||||
...Channel
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class ChannelsFetcher {
|
||||
constructor(private client: Client) {}
|
||||
|
||||
fetchChannels() {
|
||||
return this.client
|
||||
.query(FetchChannelsDocument, {})
|
||||
.toPromise()
|
||||
.then((r) => r.data?.channels ?? null);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { ChannelsFetcher } from "./channels-fetcher";
|
||||
import { createClient } from "../../lib/create-graphql-client";
|
||||
import { router } from "../trpc/trpc-server";
|
||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||
|
||||
export const channelsRouter = router({
|
||||
fetch: protectedClientProcedure.query(async ({ ctx }) => {
|
||||
const client = createClient(ctx.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: ctx.appToken })
|
||||
);
|
||||
|
||||
const fetcher = new ChannelsFetcher(client);
|
||||
|
||||
return await fetcher.fetchChannels().then((channels) => channels ?? []);
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,146 @@
|
|||
import { MessageEventTypes } from "./message-event-types";
|
||||
import {
|
||||
OrderDetailsFragment,
|
||||
OrderCreatedWebhookPayloadFragment,
|
||||
OrderConfirmedWebhookPayloadFragment,
|
||||
OrderCancelledWebhookPayloadFragment,
|
||||
OrderFulfilledWebhookPayloadFragment,
|
||||
OrderFullyPaidWebhookPayloadFragment,
|
||||
InvoiceSentWebhookPayloadFragment,
|
||||
} from "../../../generated/graphql";
|
||||
|
||||
const exampleOrderPayload: OrderDetailsFragment = {
|
||||
id: "T3JkZXI6NTdiNTBhNDAtYzRmYi00YjQzLWIxODgtM2JhZmRlMTc3MGQ5",
|
||||
number: "198",
|
||||
userEmail: "adrian.king@example.com",
|
||||
channel: {
|
||||
slug: "default-channel",
|
||||
},
|
||||
user: {
|
||||
email: "adrian.king@example.com",
|
||||
firstName: "Adrian",
|
||||
lastName: "King",
|
||||
},
|
||||
billingAddress: {
|
||||
streetAddress1: "59314 Mary Well Suite 281",
|
||||
city: "METROPOLIS",
|
||||
postalCode: "71653",
|
||||
country: {
|
||||
country: "United States of America",
|
||||
},
|
||||
},
|
||||
shippingAddress: {
|
||||
streetAddress1: "59314 Mary Well Suite 281",
|
||||
city: "METROPOLIS",
|
||||
postalCode: "71653",
|
||||
country: {
|
||||
country: "United States of America",
|
||||
},
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
id: "T3JkZXJMaW5lOjNkNjc4OWE3LWUyNWItNDBlMi1iNjk2LTdmMzA0ZWFjOWI2OQ==",
|
||||
productName: "Black Hoodie",
|
||||
variantName: "XL",
|
||||
quantity: 1,
|
||||
thumbnail: {
|
||||
url: "https://placehold.jp/150x150.png",
|
||||
alt: "",
|
||||
},
|
||||
unitPrice: {
|
||||
gross: {
|
||||
currency: "USD",
|
||||
amount: 5,
|
||||
},
|
||||
},
|
||||
totalPrice: {
|
||||
gross: {
|
||||
currency: "USD",
|
||||
amount: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "T3JkZXJMaW5lOjVhYmEzMTBkLTZkMzEtNDNlNy1hZjAyLTdlNGUwM2UzYmI4ZA==",
|
||||
productName: "Code Division T-shirt",
|
||||
variantName: "L",
|
||||
quantity: 1,
|
||||
thumbnail: {
|
||||
url: "https://placehold.jp/150x150.png",
|
||||
alt: "",
|
||||
},
|
||||
unitPrice: {
|
||||
gross: {
|
||||
currency: "USD",
|
||||
amount: 5,
|
||||
},
|
||||
},
|
||||
totalPrice: {
|
||||
gross: {
|
||||
currency: "USD",
|
||||
amount: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
subtotal: {
|
||||
gross: {
|
||||
amount: 10,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
shippingPrice: {
|
||||
gross: {
|
||||
amount: 61.62,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
total: {
|
||||
gross: {
|
||||
amount: 71.62,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const orderCreatedPayload: OrderCreatedWebhookPayloadFragment = {
|
||||
order: exampleOrderPayload,
|
||||
};
|
||||
|
||||
const orderConfirmedPayload: OrderConfirmedWebhookPayloadFragment = {
|
||||
order: exampleOrderPayload,
|
||||
};
|
||||
|
||||
const orderCancelledPayload: OrderCancelledWebhookPayloadFragment = {
|
||||
order: exampleOrderPayload,
|
||||
};
|
||||
|
||||
const orderFulfilledPayload: OrderFulfilledWebhookPayloadFragment = {
|
||||
order: exampleOrderPayload,
|
||||
};
|
||||
|
||||
const orderFullyPaidPayload: OrderFullyPaidWebhookPayloadFragment = {
|
||||
order: exampleOrderPayload,
|
||||
};
|
||||
|
||||
const invoiceSentPayload: InvoiceSentWebhookPayloadFragment = {
|
||||
invoice: {
|
||||
id: "SW52b2ljZToxMDE=",
|
||||
message: null,
|
||||
externalUrl: null,
|
||||
url: "https://example.com/media/invoices/invoice-1032023-order-57b50a40-c4fb-4b43-b188-3bafde1770d9-fa968541-02fa-4317-b121-7205.pdf",
|
||||
order: {
|
||||
id: "T3JkZXI6NTdiNTBhNDAtYzRmYi00YjQzLWIxODgtM2JhZmRlMTc3MGQ5",
|
||||
},
|
||||
},
|
||||
order: exampleOrderPayload,
|
||||
};
|
||||
|
||||
export const examplePayloads: Record<MessageEventTypes, any> = {
|
||||
ORDER_CREATED: orderCreatedPayload,
|
||||
ORDER_CONFIRMED: orderConfirmedPayload,
|
||||
ORDER_CANCELLED: orderCancelledPayload,
|
||||
ORDER_FULFILLED: orderFulfilledPayload,
|
||||
ORDER_FULLY_PAID: orderFullyPaidPayload,
|
||||
INVOICE_SENT: invoiceSentPayload,
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
import { AsyncWebhookEventType } from "@saleor/app-sdk/types";
|
||||
|
||||
export const messageEventTypes = [
|
||||
"ORDER_CREATED",
|
||||
"ORDER_FULFILLED",
|
||||
"ORDER_CONFIRMED",
|
||||
"ORDER_CANCELLED",
|
||||
"ORDER_FULLY_PAID",
|
||||
"INVOICE_SENT",
|
||||
] as const;
|
||||
|
||||
type Subset<K, T extends K> = T;
|
||||
|
||||
export type MessageEventTypes = Subset<AsyncWebhookEventType, (typeof messageEventTypes)[number]>;
|
||||
|
||||
export const messageEventTypesLabels: Record<MessageEventTypes, string> = {
|
||||
ORDER_CREATED: "Order created",
|
||||
ORDER_FULFILLED: "Order fulfilled",
|
||||
ORDER_CONFIRMED: "Order confirmed",
|
||||
ORDER_CANCELLED: "Order cancelled",
|
||||
ORDER_FULLY_PAID: "Order fully paid",
|
||||
INVOICE_SENT: "Invoice sent",
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
import { MessageEventTypes, messageEventTypes } from "./message-event-types";
|
||||
|
||||
/**
|
||||
* Returns the event type if it is valid, otherwise undefined.
|
||||
*/
|
||||
export const parseMessageEventType = (eventType?: string): MessageEventTypes | undefined => {
|
||||
if (!eventType) {
|
||||
return;
|
||||
}
|
||||
if (messageEventTypes.includes(eventType as MessageEventTypes)) {
|
||||
return eventType as MessageEventTypes;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import { Client } from "urql";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
|
||||
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
|
||||
import { sendMjml } from "../mjml/send-mjml";
|
||||
import { sendSendgrid } from "../sendgrid/send-sendgrid";
|
||||
import { MessageEventTypes } from "./message-event-types";
|
||||
|
||||
interface SendEventMessagesArgs {
|
||||
recipientEmail: string;
|
||||
channel: string;
|
||||
event: MessageEventTypes;
|
||||
authData: AuthData;
|
||||
payload: any;
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export const sendEventMessages = async ({
|
||||
recipientEmail,
|
||||
channel,
|
||||
event,
|
||||
authData,
|
||||
payload,
|
||||
client,
|
||||
}: SendEventMessagesArgs) => {
|
||||
const logger = pinoLogger.child({
|
||||
fn: "sendEventMessages",
|
||||
});
|
||||
|
||||
logger.debug("Function called");
|
||||
|
||||
const appConfigurationService = new AppConfigurationService({
|
||||
apiClient: client,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
});
|
||||
|
||||
const channelAppConfiguration = await appConfigurationService.getChannelConfiguration(channel);
|
||||
|
||||
if (!channelAppConfiguration) {
|
||||
logger.warn("App has no configuration for this channel");
|
||||
return;
|
||||
}
|
||||
logger.debug("Channel has assigned app configuration");
|
||||
|
||||
if (!channelAppConfiguration.active) {
|
||||
logger.warn("App configuration is not active for this channel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelAppConfiguration.mjmlConfigurationId) {
|
||||
logger.debug("Channel has assigned MJML configuration");
|
||||
|
||||
const mjmlConfigurationService = new MjmlConfigurationService({
|
||||
apiClient: client,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
});
|
||||
|
||||
const mjmlConfiguration = await mjmlConfigurationService.getConfiguration({
|
||||
id: channelAppConfiguration.mjmlConfigurationId,
|
||||
});
|
||||
if (mjmlConfiguration) {
|
||||
const mjmlStatus = await sendMjml({
|
||||
event,
|
||||
payload,
|
||||
recipientEmail,
|
||||
mjmlConfiguration,
|
||||
});
|
||||
|
||||
if (mjmlStatus?.errors.length) {
|
||||
logger.error("MJML errors");
|
||||
logger.error(mjmlStatus?.errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sendgridStatus = await sendSendgrid({
|
||||
authData,
|
||||
channel,
|
||||
event,
|
||||
payload,
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
if (sendgridStatus?.errors.length) {
|
||||
logger.error("Sending message with Sendgrid has failed");
|
||||
logger.error(sendgridStatus?.errors);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
import Handlebars from "handlebars";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
fn: "compileHandlebarsTemplate",
|
||||
});
|
||||
|
||||
export const compileHandlebarsTemplate = (template: string, variables: any) => {
|
||||
logger.debug("Compiling handlebars template");
|
||||
try {
|
||||
const templateDelegate = Handlebars.compile(template);
|
||||
const htmlTemplate = templateDelegate(variables);
|
||||
logger.debug("Template successfully compiled");
|
||||
return {
|
||||
template: htmlTemplate,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return {
|
||||
errors: [{ message: "Error during the using the handlebars template" }],
|
||||
};
|
||||
}
|
||||
};
|
19
apps/emails-and-messages/src/modules/mjml/compile-mjml.ts
Normal file
19
apps/emails-and-messages/src/modules/mjml/compile-mjml.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import mjml2html from "mjml";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
fn: "compileMjml",
|
||||
});
|
||||
|
||||
export const compileMjml = (mjml: string) => {
|
||||
logger.debug("Converting MJML template to HTML");
|
||||
try {
|
||||
return mjml2html(mjml);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return {
|
||||
html: undefined,
|
||||
errors: [{ message: "Critical error during the mjml to html compilation" }],
|
||||
};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,107 @@
|
|||
import { MjmlConfigurator, PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
|
||||
import { Client } from "urql";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createSettingsManager } from "../../app-configuration/metadata-manager";
|
||||
import { MjmlConfig, MjmlConfiguration } from "./mjml-config";
|
||||
import { FilterConfigurationsArgs, MjmlConfigContainer } from "./mjml-config-container";
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
service: "MjmlConfigurationService",
|
||||
});
|
||||
|
||||
export class MjmlConfigurationService {
|
||||
private configurationData?: MjmlConfig;
|
||||
private metadataConfigurator: MjmlConfigurator;
|
||||
|
||||
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: MjmlConfig }) {
|
||||
this.metadataConfigurator = new PrivateMetadataMjmlConfigurator(
|
||||
createSettingsManager(args.apiClient),
|
||||
args.saleorApiUrl
|
||||
);
|
||||
|
||||
if (args.initialData) {
|
||||
this.configurationData = args.initialData;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch configuration from Saleor API and cache it
|
||||
private async pullConfiguration() {
|
||||
logger.debug("Fetch configuration from Saleor API");
|
||||
|
||||
const config = await this.metadataConfigurator.getConfig();
|
||||
this.configurationData = config;
|
||||
}
|
||||
|
||||
// Push configuration to Saleor API
|
||||
private async pushConfiguration() {
|
||||
logger.debug("Push configuration to Saleor API");
|
||||
|
||||
await this.metadataConfigurator.setConfig(this.configurationData!);
|
||||
}
|
||||
|
||||
// Returns configuration from cache or fetches it from Saleor API
|
||||
async getConfigurationRoot() {
|
||||
logger.debug("Get configuration root");
|
||||
|
||||
if (this.configurationData) {
|
||||
logger.debug("Using cached configuration");
|
||||
return this.configurationData;
|
||||
}
|
||||
|
||||
// No cached data, fetch it from Saleor API
|
||||
await this.pullConfiguration();
|
||||
|
||||
if (!this.configurationData) {
|
||||
logger.warn("No configuration found in Saleor API");
|
||||
return;
|
||||
}
|
||||
|
||||
return this.configurationData;
|
||||
}
|
||||
|
||||
// Saves configuration to Saleor API and cache it
|
||||
async setConfigurationRoot(config: MjmlConfig) {
|
||||
logger.debug("Set configuration root");
|
||||
|
||||
this.configurationData = config;
|
||||
await this.pushConfiguration();
|
||||
}
|
||||
|
||||
async getConfiguration({ id }: { id: string }) {
|
||||
logger.debug("Get configuration");
|
||||
return MjmlConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
|
||||
}
|
||||
|
||||
async getConfigurations(filter: FilterConfigurationsArgs) {
|
||||
logger.debug("Get configuration");
|
||||
return MjmlConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
|
||||
}
|
||||
|
||||
async createConfiguration(config: Omit<MjmlConfiguration, "id" | "events">) {
|
||||
logger.debug("Create configuration");
|
||||
const updatedConfigurationRoot = MjmlConfigContainer.createConfiguration(
|
||||
await this.getConfigurationRoot()
|
||||
)(config);
|
||||
await this.setConfigurationRoot(updatedConfigurationRoot);
|
||||
|
||||
return updatedConfigurationRoot.configurations[
|
||||
updatedConfigurationRoot.configurations.length - 1
|
||||
];
|
||||
}
|
||||
|
||||
async updateConfiguration(config: MjmlConfiguration) {
|
||||
logger.debug("Update configuration");
|
||||
const updatedConfigurationRoot = MjmlConfigContainer.updateConfiguration(
|
||||
await this.getConfigurationRoot()
|
||||
)(config);
|
||||
this.setConfigurationRoot(updatedConfigurationRoot);
|
||||
}
|
||||
|
||||
async deleteConfiguration({ id }: { id: string }) {
|
||||
logger.debug("Delete configuration");
|
||||
const updatedConfigurationRoot = MjmlConfigContainer.deleteConfiguration(
|
||||
await this.getConfigurationRoot()
|
||||
)({ id });
|
||||
this.setConfigurationRoot(updatedConfigurationRoot);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
|
||||
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
|
||||
|
||||
export const generateMjmlConfigurationId = () => Date.now().toString();
|
||||
|
||||
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
|
||||
messageEventTypes.map((eventType) => ({
|
||||
active: true,
|
||||
eventType: eventType,
|
||||
template: defaultMjmlTemplates[eventType],
|
||||
subject: defaultMjmlSubjectTemplates[eventType],
|
||||
}));
|
||||
|
||||
export const getDefaultEmptyConfiguration = (): MjmlConfiguration => {
|
||||
const defaultConfig: MjmlConfiguration = {
|
||||
id: "",
|
||||
active: true,
|
||||
configurationName: "",
|
||||
senderName: "",
|
||||
senderEmail: "",
|
||||
smtpHost: "",
|
||||
smtpPort: "",
|
||||
smtpUser: "",
|
||||
encryption: "NONE",
|
||||
events: getDefaultEventsConfiguration(),
|
||||
};
|
||||
|
||||
return defaultConfig;
|
||||
};
|
||||
|
||||
interface GetConfigurationArgs {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const getConfiguration =
|
||||
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
|
||||
({ id }: GetConfigurationArgs) => {
|
||||
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
|
||||
return;
|
||||
}
|
||||
|
||||
return mjmlConfigRoot.configurations.find((c) => c.id === id);
|
||||
};
|
||||
|
||||
export interface FilterConfigurationsArgs {
|
||||
ids?: string[];
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const getConfigurations =
|
||||
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
|
||||
({ ids, active }: FilterConfigurationsArgs): MjmlConfiguration[] => {
|
||||
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filtered = mjmlConfigRoot.configurations;
|
||||
|
||||
if (ids?.length) {
|
||||
filtered = filtered.filter((c) => ids.includes(c.id));
|
||||
}
|
||||
|
||||
if (active !== undefined) {
|
||||
filtered = filtered.filter((c) => c.active === active);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const createConfiguration =
|
||||
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
|
||||
(mjmlConfiguration: Omit<MjmlConfiguration, "id" | "events">) => {
|
||||
const mjmlConfigNormalized = structuredClone(mjmlConfigRoot) ?? { configurations: [] };
|
||||
|
||||
// for creating a new configurations, the ID has to be generated
|
||||
const newConfiguration = {
|
||||
...mjmlConfiguration,
|
||||
id: generateMjmlConfigurationId(),
|
||||
events: getDefaultEventsConfiguration(),
|
||||
};
|
||||
mjmlConfigNormalized.configurations.unshift(newConfiguration);
|
||||
return mjmlConfigNormalized;
|
||||
};
|
||||
|
||||
const updateConfiguration =
|
||||
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
|
||||
(mjmlConfiguration: MjmlConfiguration) => {
|
||||
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
|
||||
|
||||
const configurationIndex = mjmlConfigNormalized.configurations.findIndex(
|
||||
(configuration) => configuration.id === mjmlConfiguration.id
|
||||
);
|
||||
|
||||
mjmlConfigNormalized.configurations[configurationIndex] = mjmlConfiguration;
|
||||
return mjmlConfigNormalized;
|
||||
};
|
||||
|
||||
interface DeleteConfigurationArgs {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const deleteConfiguration =
|
||||
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
|
||||
({ id }: DeleteConfigurationArgs) => {
|
||||
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
|
||||
|
||||
mjmlConfigNormalized.configurations = mjmlConfigNormalized.configurations.filter(
|
||||
(configuration) => configuration.id !== id
|
||||
);
|
||||
|
||||
return mjmlConfigNormalized;
|
||||
};
|
||||
|
||||
export const MjmlConfigContainer = {
|
||||
createConfiguration,
|
||||
getConfiguration,
|
||||
updateConfiguration,
|
||||
deleteConfiguration,
|
||||
getConfigurations,
|
||||
};
|
|
@ -0,0 +1,77 @@
|
|||
import { z } from "zod";
|
||||
import { messageEventTypes } from "../../event-handlers/message-event-types";
|
||||
import { smtpEncryptionTypes } from "./mjml-config";
|
||||
|
||||
export const mjmlConfigInputSchema = z.object({
|
||||
configurations: z.array(
|
||||
z.object({
|
||||
active: z.boolean(),
|
||||
configurationName: z.string(),
|
||||
senderName: z.string(),
|
||||
senderEmail: z.string().email(),
|
||||
smtpHost: z.string(),
|
||||
smtpPort: z.string(),
|
||||
smtpUser: z.string().min(0),
|
||||
useTls: z.boolean(),
|
||||
useSsl: z.boolean(),
|
||||
templateInvoiceSentSubject: z.string(),
|
||||
templateInvoiceSentTemplate: z.string(),
|
||||
templateOrderCancelledSubject: z.string(),
|
||||
templateOrderCancelledTemplate: z.string(),
|
||||
templateOrderConfirmedSubject: z.string(),
|
||||
templateOrderConfirmedTemplate: z.string(),
|
||||
templateOrderFullyPaidSubject: z.string(),
|
||||
templateOrderFullyPaidTemplate: z.string(),
|
||||
templateOrderCreatedSubject: z.string(),
|
||||
templateOrderCreatedTemplate: z.string(),
|
||||
templateOrderFulfilledSubject: z.string(),
|
||||
templateOrderFulfilledTemplate: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const mjmlConfigurationEventObjectSchema = z.object({
|
||||
active: z.boolean(),
|
||||
eventType: z.enum(messageEventTypes),
|
||||
template: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
});
|
||||
|
||||
export const mjmlConfigurationBaseObjectSchema = z.object({
|
||||
active: z.boolean(),
|
||||
configurationName: z.string().min(1),
|
||||
senderName: z.string().min(1),
|
||||
senderEmail: z.string().email().min(5),
|
||||
smtpHost: z.string().min(1),
|
||||
smtpPort: z.string(),
|
||||
smtpUser: z.string(),
|
||||
encryption: z.enum(smtpEncryptionTypes),
|
||||
});
|
||||
|
||||
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;
|
||||
export const mjmlUpdateOrCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema.merge(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
})
|
||||
);
|
||||
export const mjmlGetConfigurationInputSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
export const mjmlDeleteConfigurationInputSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
export const mjmlGetConfigurationsInputSchema = z.object({
|
||||
ids: z.array(z.string()).optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const mjmlUpdateEventConfigurationInputSchema = z
|
||||
.object({
|
||||
configurationId: z.string(),
|
||||
})
|
||||
.merge(mjmlConfigurationEventObjectSchema);
|
||||
|
||||
export const mjmlGetEventConfigurationInputSchema = z.object({
|
||||
configurationId: z.string(),
|
||||
eventType: z.enum(messageEventTypes),
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import { MessageEventTypes } from "../../event-handlers/message-event-types";
|
||||
|
||||
export interface MjmlEventConfiguration {
|
||||
active: boolean;
|
||||
eventType: MessageEventTypes;
|
||||
template: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
export const smtpEncryptionTypes = ["NONE", "TLS", "SSL"] as const;
|
||||
|
||||
export type SmtpEncryptionType = (typeof smtpEncryptionTypes)[number];
|
||||
|
||||
export interface MjmlConfiguration {
|
||||
id: string;
|
||||
active: boolean;
|
||||
configurationName: string;
|
||||
senderName: string;
|
||||
senderEmail: string;
|
||||
smtpHost: string;
|
||||
smtpPort: string;
|
||||
smtpUser: string;
|
||||
encryption: SmtpEncryptionType;
|
||||
events: MjmlEventConfiguration[];
|
||||
}
|
||||
|
||||
export type MjmlConfig = {
|
||||
configurations: MjmlConfiguration[];
|
||||
};
|
|
@ -0,0 +1,197 @@
|
|||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
mjmlCreateConfigurationSchema,
|
||||
mjmlDeleteConfigurationInputSchema,
|
||||
mjmlGetConfigurationInputSchema,
|
||||
mjmlGetConfigurationsInputSchema,
|
||||
mjmlGetEventConfigurationInputSchema,
|
||||
mjmlUpdateEventConfigurationInputSchema,
|
||||
mjmlUpdateOrCreateConfigurationSchema,
|
||||
} from "./mjml-config-input-schema";
|
||||
import { MjmlConfigurationService } from "./get-mjml-configuration.service";
|
||||
import { router } from "../../trpc/trpc-server";
|
||||
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||
import { z } from "zod";
|
||||
import { compileMjml } from "../compile-mjml";
|
||||
import Handlebars from "handlebars";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
// Allow access only for the dashboard users and attaches the
|
||||
// configuration service to the context
|
||||
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
|
||||
next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
configurationService: new MjmlConfigurationService({
|
||||
apiClient: ctx.apiClient,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const mjmlConfigurationRouter = router({
|
||||
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug("mjmlConfigurationRouter.fetch called");
|
||||
return ctx.configurationService.getConfigurationRoot();
|
||||
}),
|
||||
getConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlGetConfigurationInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug(input, "mjmlConfigurationRouter.get called");
|
||||
return ctx.configurationService.getConfiguration(input);
|
||||
}),
|
||||
getConfigurations: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlGetConfigurationsInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug(input, "mjmlConfigurationRouter.getConfigurations called");
|
||||
return ctx.configurationService.getConfigurations(input);
|
||||
}),
|
||||
createConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlCreateConfigurationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug(input, "mjmlConfigurationRouter.create called");
|
||||
return await ctx.configurationService.createConfiguration(input);
|
||||
}),
|
||||
deleteConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlDeleteConfigurationInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug(input, "mjmlConfigurationRouter.delete called");
|
||||
|
||||
await ctx.configurationService.deleteConfiguration(input);
|
||||
return null;
|
||||
}),
|
||||
updateOrCreateConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlUpdateOrCreateConfigurationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug(input, "mjmlConfigurationRouter.update or create called");
|
||||
|
||||
const { id } = input;
|
||||
if (!id) {
|
||||
return await ctx.configurationService.createConfiguration(input);
|
||||
} else {
|
||||
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
|
||||
if (!existingConfiguration) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Configuration not found",
|
||||
});
|
||||
}
|
||||
const configuration = {
|
||||
id,
|
||||
...input,
|
||||
events: existingConfiguration.events,
|
||||
};
|
||||
await ctx.configurationService.updateConfiguration(configuration);
|
||||
return configuration;
|
||||
}
|
||||
}),
|
||||
getEventConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlGetEventConfigurationInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
|
||||
logger.debug(input, "mjmlConfigurationRouter.getEventConfiguration or create called");
|
||||
|
||||
const configuration = await ctx.configurationService.getConfiguration({
|
||||
id: input.configurationId,
|
||||
});
|
||||
|
||||
if (!configuration) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Configuration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const event = configuration.events.find((e) => e.eventType === input.eventType);
|
||||
if (!event) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Event configuration not found",
|
||||
});
|
||||
}
|
||||
return event;
|
||||
}),
|
||||
updateEventConfiguration: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(mjmlUpdateEventConfigurationInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
|
||||
logger.debug(input, "mjmlConfigurationRouter.updateEventConfiguration or create called");
|
||||
|
||||
const configuration = await ctx.configurationService.getConfiguration({
|
||||
id: input.configurationId,
|
||||
});
|
||||
|
||||
if (!configuration) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Configuration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const eventIndex = configuration.events.findIndex((e) => e.eventType === input.eventType);
|
||||
configuration.events[eventIndex] = {
|
||||
active: input.active,
|
||||
eventType: input.eventType,
|
||||
template: input.template,
|
||||
subject: input.subject,
|
||||
};
|
||||
await ctx.configurationService.updateConfiguration(configuration);
|
||||
return configuration;
|
||||
}),
|
||||
|
||||
renderTemplate: protectedWithConfigurationService
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(
|
||||
z.object({
|
||||
template: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
payload: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
logger.debug(input, "mjmlConfigurationRouter.renderTemplate called");
|
||||
|
||||
let renderedSubject = "";
|
||||
|
||||
const payload = JSON.parse(input.payload);
|
||||
|
||||
if (input.subject) {
|
||||
const compiledSubjectTemplate = Handlebars.compile(input.subject);
|
||||
logger.warn("subject part");
|
||||
renderedSubject = compiledSubjectTemplate(payload);
|
||||
}
|
||||
|
||||
let renderedEmail = "";
|
||||
if (input.template) {
|
||||
const compiledSubjectTemplate = Handlebars.compile(input.template);
|
||||
const templatedEmail = compiledSubjectTemplate(payload);
|
||||
|
||||
const { html: rawHtml } = compileMjml(templatedEmail);
|
||||
if (rawHtml) {
|
||||
renderedEmail = rawHtml;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
renderedSubject,
|
||||
renderedEmailBody: renderedEmail,
|
||||
};
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { MjmlConfig } from "./mjml-config";
|
||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
export interface MjmlConfigurator {
|
||||
setConfig(config: MjmlConfig): Promise<void>;
|
||||
getConfig(): Promise<MjmlConfig | undefined>;
|
||||
}
|
||||
|
||||
export class PrivateMetadataMjmlConfigurator implements MjmlConfigurator {
|
||||
private metadataKey = "mjml-config";
|
||||
|
||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
||||
|
||||
getConfig(): Promise<MjmlConfig | undefined> {
|
||||
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid metadata value, can't be parsed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConfig(config: MjmlConfig): Promise<void> {
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: JSON.stringify(config),
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Divider, TextField, TextFieldProps, Typography } from "@material-ui/core";
|
||||
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
|
||||
import React, { useEffect } from "react";
|
||||
import { MjmlConfiguration, smtpEncryptionTypes } from "../mjml-config";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
||||
import { useRouter } from "next/router";
|
||||
import { mjmlUrls } from "../../urls";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
field: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
editor: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
preview: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionHeader: {
|
||||
marginTop: 20,
|
||||
},
|
||||
form: {},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
onConfigurationSaved: () => void;
|
||||
initialData: MjmlConfiguration;
|
||||
configurationId?: string;
|
||||
};
|
||||
|
||||
export const MjmlConfigurationForm = (props: Props) => {
|
||||
const styles = useStyles();
|
||||
const router = useRouter();
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
const { handleSubmit, control, setValue, getValues, reset, setError } =
|
||||
useForm<MjmlConfiguration>({
|
||||
defaultValues: props.initialData,
|
||||
});
|
||||
|
||||
const { mutate: createOrUpdateConfiguration, error: createOrUpdateError } =
|
||||
trpcClient.mjmlConfiguration.updateOrCreateConfiguration.useMutation({
|
||||
onSuccess(data, variables) {
|
||||
router.replace(mjmlUrls.configuration(data.id));
|
||||
props.onConfigurationSaved();
|
||||
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Configuration saved",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
onError(error) {
|
||||
let isFieldErrorSet = false;
|
||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
||||
for (const fieldName in fieldErrors) {
|
||||
for (const message of fieldErrors[fieldName] || []) {
|
||||
isFieldErrorSet = true;
|
||||
setError(fieldName as keyof MjmlConfiguration, {
|
||||
type: "manual",
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
const formErrors = error.data?.zodError?.formErrors || [];
|
||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Could not save the configuration",
|
||||
text: isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
||||
apiMessage: formErrorMessage,
|
||||
status: "error",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// when the configuration tab is changed, initialData change and form has to be updated
|
||||
useEffect(() => {
|
||||
reset(props.initialData);
|
||||
}, [props.initialData, props.configurationId, reset]);
|
||||
|
||||
const CommonFieldProps: TextFieldProps = {
|
||||
className: styles.field,
|
||||
fullWidth: true,
|
||||
};
|
||||
|
||||
const isNewConfiguration = !props.configurationId;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((data, event) => {
|
||||
createOrUpdateConfiguration({
|
||||
...data,
|
||||
});
|
||||
})}
|
||||
className={styles.form}
|
||||
>
|
||||
{isNewConfiguration ? (
|
||||
<Typography variant="h2" paragraph>
|
||||
Create a new configuration
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="h2" paragraph>
|
||||
Configuration
|
||||
<strong>{` ${props.initialData.configurationName} `}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="configurationName"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
|
||||
<TextField
|
||||
label="Configuration name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={!!error}
|
||||
helperText={error?.message}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="active"
|
||||
render={({ field: { value, name, onChange } }) => (
|
||||
<div className={styles.field}>
|
||||
{/* TODO: fix types in the MacawUI */}
|
||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
||||
<SwitchSelector key={name} className={styles.field}>
|
||||
{[
|
||||
{ label: "Active", value: true },
|
||||
{ label: "Disabled", value: false },
|
||||
].map((button) => (
|
||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
||||
<SwitchSelectorButton
|
||||
value={button.value.toString()}
|
||||
onClick={() => onChange(button.value)}
|
||||
activeTab={value.toString()}
|
||||
key={button.label}
|
||||
>
|
||||
{button.label}
|
||||
</SwitchSelectorButton>
|
||||
))}
|
||||
</SwitchSelector>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
||||
Sender details
|
||||
</Typography>
|
||||
|
||||
<Controller
|
||||
name="senderName"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextField
|
||||
label="Sender name"
|
||||
error={!!error}
|
||||
helperText={error?.message}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="senderEmail"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label="Sender email"
|
||||
value={value}
|
||||
helperText={error?.message}
|
||||
error={!!error}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
||||
SMTP server configuration
|
||||
</Typography>
|
||||
|
||||
<Controller
|
||||
name="smtpHost"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextField
|
||||
label="SMTP server host"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
helperText={error?.message}
|
||||
error={!!error}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="smtpPort"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextField
|
||||
label="SMTP server port"
|
||||
helperText={error?.message}
|
||||
error={!!error}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="smtpUser"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextField
|
||||
label="SMTP server user"
|
||||
value={value}
|
||||
helperText={error?.message}
|
||||
error={!!error}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="encryption"
|
||||
render={({ field: { value, name, onChange } }) => (
|
||||
<div className={styles.field}>
|
||||
{/* TODO: fix types in the MacawUI */}
|
||||
{/* @ts-ignore: MacawUI use wrong type for SwitchSelector */}
|
||||
<SwitchSelector key={name}>
|
||||
{smtpEncryptionTypes.map((encryptionType) => (
|
||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
||||
<SwitchSelectorButton
|
||||
value={encryptionType}
|
||||
onClick={() => onChange(encryptionType)}
|
||||
activeTab={value}
|
||||
key={encryptionType}
|
||||
>
|
||||
{encryptionType === "NONE" ? "No encryption" : encryptionType}
|
||||
</SwitchSelectorButton>
|
||||
))}
|
||||
</SwitchSelector>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth variant="primary">
|
||||
Save configuration
|
||||
</Button>
|
||||
|
||||
{createOrUpdateError && <span>{createOrUpdateError.message}</span>}
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,142 @@
|
|||
import React from "react";
|
||||
import { IconButton, makeStyles } from "@saleor/macaw-ui";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { MjmlConfigurationForm } from "./mjml-configuration-form";
|
||||
import { getDefaultEmptyConfiguration } from "../mjml-config-container";
|
||||
import { NextRouter, useRouter } from "next/router";
|
||||
import { mjmlUrls } from "../../urls";
|
||||
import { MjmlTemplatesCard } from "./mjml-templates-card";
|
||||
import SideMenu from "../../../app-configuration/ui/side-menu";
|
||||
import { MjmlConfiguration } from "../mjml-config";
|
||||
import { LoadingIndicator } from "../../../ui/loading-indicator";
|
||||
import { Add } from "@material-ui/icons";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
formContainer: {
|
||||
top: 0,
|
||||
},
|
||||
instructionsContainer: {
|
||||
padding: 15,
|
||||
},
|
||||
configurationColumn: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 20,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
interface MjmlConfigurationTabProps {
|
||||
configurationId?: string;
|
||||
}
|
||||
|
||||
const navigateToFirstConfiguration = (router: NextRouter, configurations?: MjmlConfiguration[]) => {
|
||||
if (!configurations || !configurations?.length) {
|
||||
router.replace(mjmlUrls.configuration());
|
||||
return;
|
||||
}
|
||||
const firstConfigurationId = configurations[0]?.id;
|
||||
if (firstConfigurationId) {
|
||||
router.replace(mjmlUrls.configuration(firstConfigurationId));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabProps) => {
|
||||
const styles = useStyles();
|
||||
const { appBridge } = useAppBridge();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
data: configurations,
|
||||
refetch: refetchConfigurations,
|
||||
isLoading: configurationsIsLoading,
|
||||
} = trpcClient.mjmlConfiguration.getConfigurations.useQuery(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
if (!configurationId) {
|
||||
navigateToFirstConfiguration(router, data);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: deleteConfiguration, error: deleteError } =
|
||||
trpcClient.mjmlConfiguration.deleteConfiguration.useMutation({
|
||||
onSuccess(data, variables) {
|
||||
refetchConfigurations();
|
||||
// if we just deleted the configuration that was selected
|
||||
// we have to navigate to the first configuration
|
||||
if (variables.id === configurationId) {
|
||||
navigateToFirstConfiguration(router, configurations);
|
||||
}
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
text: "Removed successfully",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (configurationsIsLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
|
||||
|
||||
if (configurationId && !configuration) {
|
||||
return <div>Configuration not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppColumnsLayout>
|
||||
<SideMenu
|
||||
title="Configurations"
|
||||
selectedItemId={configurationId}
|
||||
headerToolbar={
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.replace(mjmlUrls.configuration());
|
||||
}}
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
}
|
||||
onClick={(id) => router.replace(mjmlUrls.configuration(id))}
|
||||
onDelete={(id) => {
|
||||
deleteConfiguration({ id });
|
||||
}}
|
||||
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
|
||||
/>
|
||||
<div className={styles.configurationColumn}>
|
||||
{configurationsIsLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
<MjmlConfigurationForm
|
||||
onConfigurationSaved={() => refetchConfigurations()}
|
||||
initialData={configuration || getDefaultEmptyConfiguration()}
|
||||
configurationId={configurationId}
|
||||
/>
|
||||
{!!configurationId && !!configuration && (
|
||||
<MjmlTemplatesCard
|
||||
configurationId={configurationId}
|
||||
configuration={configuration}
|
||||
onEventChanged={() => {
|
||||
refetchConfigurations();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppColumnsLayout>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,277 @@
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
import { CircularProgress, TextField, TextFieldProps, Typography } from "@material-ui/core";
|
||||
import {
|
||||
BackSmallIcon,
|
||||
Button,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
SwitchSelector,
|
||||
SwitchSelectorButton,
|
||||
} from "@saleor/macaw-ui";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { MjmlEventConfiguration } from "../mjml-config";
|
||||
import { CodeEditor } from "../../../ui/code-editor";
|
||||
import { MjmlPreview } from "./mjml-preview";
|
||||
import {
|
||||
MessageEventTypes,
|
||||
messageEventTypesLabels,
|
||||
} from "../../../event-handlers/message-event-types";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { useDebounce } from "usehooks-ts";
|
||||
import { useRouter } from "next/router";
|
||||
import { mjmlUrls } from "../../urls";
|
||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
||||
import { examplePayloads } from "../../../event-handlers/default-payloads";
|
||||
|
||||
const PREVIEW_DEBOUNCE_DELAY = 500;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
header: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(3),
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
},
|
||||
previewHeader: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(1),
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
|
||||
field: {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
editor: {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
preview: {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
form: {
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
formAndPreviewSection: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
alignItems: "start",
|
||||
gap: 32,
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
},
|
||||
}));
|
||||
|
||||
type EventConfigurationFormProps = {
|
||||
initialData: MjmlEventConfiguration;
|
||||
configurationId: string;
|
||||
eventType: MessageEventTypes;
|
||||
};
|
||||
|
||||
export const EventConfigurationForm = ({
|
||||
initialData,
|
||||
configurationId,
|
||||
eventType,
|
||||
}: EventConfigurationFormProps) => {
|
||||
const router = useRouter();
|
||||
const { appBridge } = useAppBridge();
|
||||
const { handleSubmit, control, getValues, setError } = useForm<MjmlEventConfiguration>({
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
const [lastValidRenderedTemplate, setLastValidRenderedTemplate] = useState("");
|
||||
|
||||
const [lastValidRenderedSubject, setLastValidRenderedSubject] = useState("");
|
||||
|
||||
const [payload, setPayload] = useState<string>(
|
||||
JSON.stringify(examplePayloads[eventType], undefined, 2)
|
||||
);
|
||||
|
||||
const { template, subject } = getValues();
|
||||
const debouncedMutationVariables = useDebounce(
|
||||
{ template, subject, payload },
|
||||
PREVIEW_DEBOUNCE_DELAY
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const CommonFieldProps: TextFieldProps = {
|
||||
className: styles.field,
|
||||
fullWidth: true,
|
||||
};
|
||||
|
||||
const { mutate: fetchTemplatePreview, isLoading: isFetchingTemplatePreview } =
|
||||
trpcClient.mjmlConfiguration.renderTemplate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.renderedEmailBody) {
|
||||
setLastValidRenderedTemplate(data.renderedEmailBody);
|
||||
}
|
||||
if (data.renderedSubject) {
|
||||
setLastValidRenderedSubject(data.renderedSubject);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateEventConfiguration, isLoading: isFetchingUpdateEvent } =
|
||||
trpcClient.mjmlConfiguration.updateEventConfiguration.useMutation({
|
||||
onSuccess: (data) => {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Configuration saved",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
let isFieldErrorSet = false;
|
||||
const fieldErrors = error.data?.zodError?.fieldErrors || {};
|
||||
for (const fieldName in fieldErrors) {
|
||||
for (const message of fieldErrors[fieldName] || []) {
|
||||
isFieldErrorSet = true;
|
||||
setError(fieldName as keyof MjmlEventConfiguration, {
|
||||
type: "manual",
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
const formErrors = error.data?.zodError?.formErrors || [];
|
||||
const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined;
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Could not save the configuration",
|
||||
text: isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
|
||||
apiMessage: formErrorMessage,
|
||||
status: "error",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
template: debouncedTemplate,
|
||||
subject: debouncedSubject,
|
||||
payload: debouncedPayload,
|
||||
} = debouncedMutationVariables;
|
||||
useEffect(() => {
|
||||
fetchTemplatePreview({
|
||||
template: debouncedTemplate,
|
||||
subject: debouncedSubject,
|
||||
payload: debouncedPayload,
|
||||
});
|
||||
}, [debouncedPayload, debouncedSubject, debouncedTemplate, fetchTemplatePreview]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(mjmlUrls.configuration(configurationId));
|
||||
}}
|
||||
>
|
||||
<BackSmallIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h2">
|
||||
{messageEventTypesLabels[eventType]} event configuration
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.formAndPreviewSection}>
|
||||
<form
|
||||
onSubmit={handleSubmit((data, event) => {
|
||||
updateEventConfiguration({ ...data, configurationId });
|
||||
})}
|
||||
className={styles.form}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="active"
|
||||
render={({ field: { value, name, onChange } }) => (
|
||||
<div className={styles.field}>
|
||||
{/* TODO: fix types in the MacawUI */}
|
||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
||||
<SwitchSelector key={name} className={styles.field}>
|
||||
{[
|
||||
{ label: "Active", value: true },
|
||||
{ label: "Disabled", value: false },
|
||||
].map((button) => (
|
||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
||||
<SwitchSelectorButton
|
||||
value={button.value.toString()}
|
||||
onClick={() => onChange(button.value)}
|
||||
activeTab={value.toString()}
|
||||
key={button.label}
|
||||
>
|
||||
{button.label}
|
||||
</SwitchSelectorButton>
|
||||
))}
|
||||
</SwitchSelector>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="subject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextField
|
||||
label="Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={!!error}
|
||||
helperText={
|
||||
!error
|
||||
? "You can use variables like {{ order.number }} or {{ order.userEmail }}"
|
||||
: error.message
|
||||
}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="template"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.editor}>
|
||||
<CodeEditor
|
||||
initialTemplate={value}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
language="xml"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="primary">
|
||||
Save configuration
|
||||
</Button>
|
||||
</form>
|
||||
<div>
|
||||
<div className={styles.previewHeader}>
|
||||
<Typography variant="h2">Preview</Typography>
|
||||
{isFetchingTemplatePreview && <CircularProgress size="3rem" color="primary" />}
|
||||
</div>
|
||||
<Typography variant="h3" paragraph>
|
||||
Subject: {lastValidRenderedSubject}
|
||||
</Typography>
|
||||
<div className={styles.preview}>
|
||||
<MjmlPreview value={lastValidRenderedTemplate} />
|
||||
</div>
|
||||
<CodeEditor
|
||||
initialTemplate={payload}
|
||||
value={payload}
|
||||
onChange={setPayload}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import { Card } from "@material-ui/core";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export const MjmlPreview = ({ value }: Props) => {
|
||||
return (
|
||||
<Card style={{ padding: "2rem", width: "100%" }}>
|
||||
{value?.length ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: value }} />
|
||||
) : (
|
||||
<p>No template preview</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,132 @@
|
|||
import { Divider, Paper, Typography } from "@material-ui/core";
|
||||
import React from "react";
|
||||
import {
|
||||
EditIcon,
|
||||
IconButton,
|
||||
List,
|
||||
ListHeader,
|
||||
ListItem,
|
||||
ListItemCell,
|
||||
makeStyles,
|
||||
SwitchSelector,
|
||||
SwitchSelectorButton,
|
||||
} from "@saleor/macaw-ui";
|
||||
import { useRouter } from "next/router";
|
||||
import { mjmlUrls } from "../../urls";
|
||||
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
|
||||
import { MjmlConfiguration } from "../mjml-config";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
spaceBetween: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
rowActions: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
tableRow: {
|
||||
minHeight: "48px",
|
||||
"&::after": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
interface MjmlTemplatesCardProps {
|
||||
configurationId: string;
|
||||
configuration: MjmlConfiguration;
|
||||
onEventChanged: () => void;
|
||||
}
|
||||
|
||||
export const MjmlTemplatesCard = ({
|
||||
configurationId,
|
||||
configuration,
|
||||
onEventChanged,
|
||||
}: MjmlTemplatesCardProps) => {
|
||||
const classes = useStyles();
|
||||
const router = useRouter();
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
const { mutate: updateEventConfiguration } =
|
||||
trpcClient.mjmlConfiguration.updateEventConfiguration.useMutation({
|
||||
onSuccess(data, variables) {
|
||||
onEventChanged();
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: variables.active ? "Event enabled" : "Event disabled",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper elevation={0}>
|
||||
<ListHeader>
|
||||
<ListItem className={classes.tableRow}>
|
||||
<ListItemCell>Supported events and templates</ListItemCell>
|
||||
</ListItem>
|
||||
</ListHeader>
|
||||
<List gridTemplate={["1fr"]}>
|
||||
<Divider />
|
||||
{configuration.events.map((eventConfiguration) => (
|
||||
<React.Fragment key={eventConfiguration.eventType}>
|
||||
<ListItem className={classes.tableRow}>
|
||||
<ListItemCell>
|
||||
<div className={classes.spaceBetween}>
|
||||
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
|
||||
<div className={classes.rowActions}>
|
||||
{/* TODO: fix types in the MacawUI */}
|
||||
{/* @ts-ignore: MacawUI use wrong type for */}
|
||||
<SwitchSelector key={eventConfiguration.eventType}>
|
||||
{[
|
||||
{ label: "Active", value: true },
|
||||
{ label: "Disabled", value: false },
|
||||
].map((button) => (
|
||||
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
|
||||
<SwitchSelectorButton
|
||||
value={button.value.toString()}
|
||||
onClick={() => {
|
||||
updateEventConfiguration({
|
||||
configurationId,
|
||||
...eventConfiguration,
|
||||
active: button.value,
|
||||
});
|
||||
}}
|
||||
activeTab={eventConfiguration.active.toString()}
|
||||
key={button.label}
|
||||
>
|
||||
{button.label}
|
||||
</SwitchSelectorButton>
|
||||
))}
|
||||
</SwitchSelector>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
router.push(
|
||||
mjmlUrls.eventConfiguration(configurationId, eventConfiguration.eventType)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</ListItemCell>
|
||||
</ListItem>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
);
|
||||
};
|
197
apps/emails-and-messages/src/modules/mjml/default-templates.ts
Normal file
197
apps/emails-and-messages/src/modules/mjml/default-templates.ts
Normal file
|
@ -0,0 +1,197 @@
|
|||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||
|
||||
const addressSection = `
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Billing address
|
||||
</th>
|
||||
<th>
|
||||
Shipping address
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{#if order.billingAddress}}
|
||||
{{ order.billingAddress.streetAddress1 }}
|
||||
{{else}}
|
||||
No billing address
|
||||
{{/if}}
|
||||
</td>
|
||||
<td>
|
||||
{{#if order.shippingAddress}}
|
||||
{{ order.shippingAddress.streetAddress1}}
|
||||
{{else}}
|
||||
No shipping required
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`;
|
||||
|
||||
const orderLinesSection = `
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-table>
|
||||
<tbody>
|
||||
{{#each order.lines }}
|
||||
<tr>
|
||||
<td>
|
||||
{{ this.quantity }} x {{ this.productName }} - {{ this.variantName }}
|
||||
</td>
|
||||
<td align="right">
|
||||
{{ this.totalPrice.gross.amount }} {{ this.totalPrice.gross.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr>
|
||||
<td>
|
||||
</td>
|
||||
<td align="right">
|
||||
Shipping: {{ order.shippingPrice.gross.amount }} {{ order.shippingPrice.gross.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
</td>
|
||||
<td align="right">
|
||||
Total: {{ order.total.gross.amount }} {{ order.total.gross.currency }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
`;
|
||||
|
||||
const defaultOrderCreatedMjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="16px">
|
||||
Hello!
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
Order {{ order.number }} has been created.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
${addressSection}
|
||||
${orderLinesSection}
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
const defaultOrderFulfilledMjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="16px">
|
||||
Hello!
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
Order {{ order.number}} has been fulfilled.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
${addressSection}
|
||||
${orderLinesSection}
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
const defaultOrderConfirmedMjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="16px">
|
||||
Hello!
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
Order {{ order.number}} has been confirmed.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
${addressSection}
|
||||
${orderLinesSection}
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
const defaultOrderFullyPaidMjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="16px">
|
||||
Hello!
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
Order {{ order.number}} has been fully paid.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
${addressSection}
|
||||
${orderLinesSection}
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
const defaultOrderCancelledMjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="16px">
|
||||
Hello!
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
Order {{ order.number}} has been cancelled.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
${addressSection}
|
||||
${orderLinesSection}
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
const defaultInvoiceSentMjmlTemplate = `
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text font-size="16px">
|
||||
Hi!
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
New invoice has been created
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>`;
|
||||
|
||||
export const defaultMjmlTemplates: Record<MessageEventTypes, string> = {
|
||||
ORDER_CREATED: defaultOrderCreatedMjmlTemplate,
|
||||
ORDER_FULFILLED: defaultOrderFulfilledMjmlTemplate,
|
||||
ORDER_CONFIRMED: defaultOrderConfirmedMjmlTemplate,
|
||||
ORDER_FULLY_PAID: defaultOrderFullyPaidMjmlTemplate,
|
||||
ORDER_CANCELLED: defaultOrderCancelledMjmlTemplate,
|
||||
INVOICE_SENT: defaultInvoiceSentMjmlTemplate,
|
||||
};
|
||||
|
||||
export const defaultMjmlSubjectTemplates: Record<MessageEventTypes, string> = {
|
||||
ORDER_CREATED: "Order {{ order.number }} has been created",
|
||||
ORDER_FULFILLED: "Order {{ order.number }} has been fulfilled",
|
||||
ORDER_CONFIRMED: "Order {{ order.number }} has been confirmed",
|
||||
ORDER_FULLY_PAID: "Order {{ order.number }} has been fully paid",
|
||||
ORDER_CANCELLED: "Order {{ order.number }} has been cancelled",
|
||||
INVOICE_SENT: "New invoice has been created",
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { convert } from "html-to-text";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
fn: "htmlToPlaintext",
|
||||
});
|
||||
|
||||
export const htmlToPlaintext = (html: string) => {
|
||||
logger.debug("Converting HTML template to plaintext");
|
||||
try {
|
||||
const plaintext = convert(html);
|
||||
logger.debug("Converted successfully");
|
||||
return {
|
||||
plaintext,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return {
|
||||
errors: [{ message: "Could not convert html to plaintext" }],
|
||||
};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import nodemailer from "nodemailer";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
|
||||
const logger = pinoLogger.child({
|
||||
fn: "sendEmailWithSmtp",
|
||||
});
|
||||
|
||||
interface SendMailArgs {
|
||||
smtpSettings: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
mailData: {
|
||||
from: string;
|
||||
to: string;
|
||||
text: string;
|
||||
html: string;
|
||||
subject: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const sendEmailWithSmtp = async ({ smtpSettings, mailData }: SendMailArgs) => {
|
||||
logger.debug("Sending an email with SMTP");
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
...smtpSettings,
|
||||
});
|
||||
|
||||
const response = await transporter.sendMail({
|
||||
...mailData,
|
||||
});
|
||||
logger.debug("An email has been sent");
|
||||
return { response };
|
||||
} catch (error) {
|
||||
logger.error("Error during sending the email");
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message);
|
||||
return { errors: [{ message: error.message }] };
|
||||
}
|
||||
return { errors: [{ message: "SMTP error" }] };
|
||||
}
|
||||
};
|
149
apps/emails-and-messages/src/modules/mjml/send-mjml.ts
Normal file
149
apps/emails-and-messages/src/modules/mjml/send-mjml.ts
Normal file
|
@ -0,0 +1,149 @@
|
|||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
import { compileMjml } from "./compile-mjml";
|
||||
import { compileHandlebarsTemplate } from "./compile-handlebars-template";
|
||||
import { sendEmailWithSmtp } from "./send-email-with-smtp";
|
||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||
import { htmlToPlaintext } from "./html-to-plaintext";
|
||||
import { MjmlConfiguration } from "./configuration/mjml-config";
|
||||
|
||||
interface SendMjmlArgs {
|
||||
mjmlConfiguration: MjmlConfiguration;
|
||||
recipientEmail: string;
|
||||
event: MessageEventTypes;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface EmailServiceResponse {
|
||||
errors?: {
|
||||
code: number;
|
||||
message: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const sendMjml = async ({
|
||||
payload,
|
||||
recipientEmail,
|
||||
event,
|
||||
mjmlConfiguration,
|
||||
}: SendMjmlArgs) => {
|
||||
const logger = pinoLogger.child({
|
||||
fn: "sendMjml",
|
||||
event,
|
||||
});
|
||||
|
||||
const eventSettings = mjmlConfiguration.events.find((e) => e.eventType === event);
|
||||
if (!eventSettings) {
|
||||
logger.debug("No active settings for this event, skipping");
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: "No active settings for this event",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!eventSettings.active) {
|
||||
logger.debug("Event settings are not active, skipping");
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: "Event settings are not active",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("Sending an email using MJML");
|
||||
|
||||
const { template: rawTemplate, subject } = eventSettings;
|
||||
|
||||
const { template: emailSubject, errors: handlebarsSubjectErrors } = compileHandlebarsTemplate(
|
||||
subject,
|
||||
payload
|
||||
);
|
||||
|
||||
logger.warn(`email subject ${emailSubject} ${subject}`);
|
||||
|
||||
if (handlebarsSubjectErrors?.length) {
|
||||
logger.error("Error during the handlebars subject template compilation");
|
||||
return {
|
||||
errors: [{ message: "Error during the handlebars subject template compilation" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (!emailSubject || !emailSubject?.length) {
|
||||
logger.error("Mjml subject message is empty, skipping");
|
||||
return {
|
||||
errors: [{ message: "Mjml subject message is empty, skipping" }],
|
||||
};
|
||||
}
|
||||
|
||||
const { template: mjmlTemplate, errors: handlebarsErrors } = compileHandlebarsTemplate(
|
||||
rawTemplate,
|
||||
payload
|
||||
);
|
||||
|
||||
if (handlebarsErrors?.length) {
|
||||
logger.error("Error during the handlebars template compilation");
|
||||
return {
|
||||
errors: [{ message: "Error during the handlebars template compilation" }],
|
||||
};
|
||||
}
|
||||
|
||||
if (!mjmlTemplate || !mjmlTemplate?.length) {
|
||||
logger.error("Mjml template message is empty, skipping");
|
||||
return {
|
||||
errors: [{ message: "Mjml template message is empty, skipping" }],
|
||||
};
|
||||
}
|
||||
|
||||
const { html: emailBodyHtml, errors: mjmlCompilationErrors } = compileMjml(mjmlTemplate);
|
||||
|
||||
if (mjmlCompilationErrors.length) {
|
||||
logger.error("Error during the MJML compilation");
|
||||
logger.error(mjmlCompilationErrors);
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: "Error during the MJML compilation. Please Validate your MJML template",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!emailBodyHtml || !emailBodyHtml?.length) {
|
||||
logger.error("No MJML template returned after the compilation");
|
||||
return {
|
||||
errors: [{ message: "No MJML template returned after the compilation" }],
|
||||
};
|
||||
}
|
||||
|
||||
const { plaintext: emailBodyPlaintext } = htmlToPlaintext(emailBodyHtml);
|
||||
|
||||
if (!emailBodyPlaintext || !emailBodyPlaintext?.length) {
|
||||
logger.error("Email body could not be converted to plaintext");
|
||||
return {
|
||||
errors: [{ message: "Email body could not be converted to plaintext" }],
|
||||
};
|
||||
}
|
||||
|
||||
const { response, errors: smtpErrors } = await sendEmailWithSmtp({
|
||||
mailData: {
|
||||
text: emailBodyPlaintext,
|
||||
html: emailBodyHtml,
|
||||
from: `${mjmlConfiguration.senderName} <${mjmlConfiguration.senderEmail}>`,
|
||||
to: recipientEmail,
|
||||
subject: emailSubject,
|
||||
},
|
||||
smtpSettings: {
|
||||
host: mjmlConfiguration.smtpHost,
|
||||
port: parseInt(mjmlConfiguration.smtpPort, 10),
|
||||
},
|
||||
});
|
||||
|
||||
if (smtpErrors?.length) {
|
||||
return { errors: smtpErrors };
|
||||
}
|
||||
logger.debug(response?.response);
|
||||
};
|
7
apps/emails-and-messages/src/modules/mjml/urls.ts
Normal file
7
apps/emails-and-messages/src/modules/mjml/urls.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||
|
||||
export const mjmlUrls = {
|
||||
configuration: (id?: string) => (!id ? "/configuration/mjml" : `/configuration/mjml/${id}`),
|
||||
eventConfiguration: (id: string, event: MessageEventTypes) =>
|
||||
`/configuration/mjml/${id}/event/${event}`,
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
|
||||
import { Client } from "urql";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createSettingsManager } from "../../app-configuration/metadata-manager";
|
||||
|
||||
// todo test
|
||||
export class GetSendgridConfigurationService {
|
||||
constructor(
|
||||
private settings: {
|
||||
apiClient: Client;
|
||||
saleorApiUrl: string;
|
||||
}
|
||||
) {}
|
||||
|
||||
async getConfiguration() {
|
||||
const logger = pinoLogger.child({
|
||||
service: "GetSendgridConfigurationService",
|
||||
saleorApiUrl: this.settings.saleorApiUrl,
|
||||
});
|
||||
|
||||
const { saleorApiUrl, apiClient } = this.settings;
|
||||
|
||||
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator(
|
||||
createSettingsManager(apiClient),
|
||||
saleorApiUrl
|
||||
);
|
||||
|
||||
const savedSendgridConfig = (await sendgridConfigurator.getConfig()) ?? null;
|
||||
|
||||
logger.debug(savedSendgridConfig, "Retrieved sendgrid config from Metadata. Will return it");
|
||||
|
||||
if (savedSendgridConfig) {
|
||||
return savedSendgridConfig;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { SendgridConfig as SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
|
||||
|
||||
export const getDefaultEmptySendgridConfiguration = (): SendgridConfiguration => {
|
||||
const defaultConfig = {
|
||||
active: false,
|
||||
configurationName: "",
|
||||
sandboxMode: false,
|
||||
senderName: "",
|
||||
senderEmail: "",
|
||||
apiKey: "",
|
||||
templateInvoiceSentSubject: "Invoice sent",
|
||||
templateInvoiceSentTemplate: "",
|
||||
templateOrderCancelledSubject: "Order Cancelled",
|
||||
templateOrderCancelledTemplate: "",
|
||||
templateOrderConfirmedSubject: "Order Confirmed",
|
||||
templateOrderConfirmedTemplate: "",
|
||||
templateOrderFullyPaidSubject: "Order Fully Paid",
|
||||
templateOrderFullyPaidTemplate: "",
|
||||
templateOrderCreatedSubject: "Order created",
|
||||
templateOrderCreatedTemplate: "",
|
||||
templateOrderFulfilledSubject: "Order fulfilled",
|
||||
templateOrderFulfilledTemplate: "",
|
||||
};
|
||||
|
||||
return defaultConfig;
|
||||
};
|
||||
|
||||
const getSendgridConfigurationById =
|
||||
(sendgridConfig: SendgridConfig | null | undefined) => (configurationId?: string) => {
|
||||
if (!configurationId?.length) {
|
||||
return getDefaultEmptySendgridConfiguration();
|
||||
}
|
||||
const existingConfig = sendgridConfig?.availableConfigurations[configurationId];
|
||||
if (!existingConfig) {
|
||||
return getDefaultEmptySendgridConfiguration();
|
||||
}
|
||||
return existingConfig;
|
||||
};
|
||||
|
||||
const setSendgridConfigurationById =
|
||||
(sendgridConfig: SendgridConfig | null | undefined) =>
|
||||
(configurationId: string | undefined) =>
|
||||
(sendgridConfiguration: SendgridConfiguration) => {
|
||||
const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? {
|
||||
availableConfigurations: {},
|
||||
};
|
||||
|
||||
// for creating a new configurations, the ID has to be generated
|
||||
const id = configurationId || Date.now();
|
||||
sendgridConfigNormalized.availableConfigurations[id] ??= getDefaultEmptySendgridConfiguration();
|
||||
|
||||
sendgridConfigNormalized.availableConfigurations[id] = sendgridConfiguration;
|
||||
|
||||
return sendgridConfigNormalized;
|
||||
};
|
||||
|
||||
export const SendgridConfigContainer = {
|
||||
getSendgridConfigurationById,
|
||||
setSendgridConfigurationById,
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const sendgridConfigInputSchema = z.object({
|
||||
availableConfigurations: z.record(
|
||||
z.object({
|
||||
active: z.boolean(),
|
||||
configurationName: z.string().min(1),
|
||||
sandboxMode: z.boolean(),
|
||||
senderName: z.string().min(0),
|
||||
senderEmail: z.string().email(),
|
||||
apiKey: z.string().min(0),
|
||||
templateInvoiceSentSubject: z.string(),
|
||||
templateInvoiceSentTemplate: z.string(),
|
||||
templateOrderCancelledSubject: z.string(),
|
||||
templateOrderCancelledTemplate: z.string(),
|
||||
templateOrderConfirmedSubject: z.string(),
|
||||
templateOrderConfirmedTemplate: z.string(),
|
||||
templateOrderFullyPaidSubject: z.string(),
|
||||
templateOrderFullyPaidTemplate: z.string(),
|
||||
templateOrderCreatedSubject: z.string(),
|
||||
templateOrderCreatedTemplate: z.string(),
|
||||
templateOrderFulfilledSubject: z.string(),
|
||||
templateOrderFulfilledTemplate: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
export interface SendgridConfiguration {
|
||||
active: boolean;
|
||||
configurationName: string;
|
||||
sandboxMode: boolean;
|
||||
senderName: string;
|
||||
senderEmail: string;
|
||||
apiKey: string;
|
||||
templateInvoiceSentSubject: string;
|
||||
templateInvoiceSentTemplate: string;
|
||||
templateOrderCancelledSubject: string;
|
||||
templateOrderCancelledTemplate: string;
|
||||
templateOrderConfirmedSubject: string;
|
||||
templateOrderConfirmedTemplate: string;
|
||||
templateOrderFullyPaidSubject: string;
|
||||
templateOrderFullyPaidTemplate: string;
|
||||
templateOrderCreatedSubject: string;
|
||||
templateOrderCreatedTemplate: string;
|
||||
templateOrderFulfilledSubject: string;
|
||||
templateOrderFulfilledTemplate: string;
|
||||
}
|
||||
|
||||
export type SendgridConfigurationsIdMap = Record<string, SendgridConfiguration>;
|
||||
|
||||
export type SendgridConfig = {
|
||||
availableConfigurations: SendgridConfigurationsIdMap;
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { sendgridConfigInputSchema } from "./sendgrid-config-input-schema";
|
||||
import { GetSendgridConfigurationService } from "./get-sendgrid-configuration.service";
|
||||
import { router } from "../../trpc/trpc-server";
|
||||
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||
import { createSettingsManager } from "../../app-configuration/metadata-manager";
|
||||
|
||||
export const sendgridConfigurationRouter = router({
|
||||
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
|
||||
logger.debug("sendgridConfigurationRouter.fetch called");
|
||||
|
||||
return new GetSendgridConfigurationService({
|
||||
apiClient: ctx.apiClient,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
}).getConfiguration();
|
||||
}),
|
||||
setAndReplace: protectedClientProcedure
|
||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
||||
.input(sendgridConfigInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||
|
||||
logger.debug(input, "sendgridConfigurationRouter.setAndReplace called with input");
|
||||
|
||||
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator(
|
||||
createSettingsManager(ctx.apiClient),
|
||||
ctx.saleorApiUrl
|
||||
);
|
||||
|
||||
await sendgridConfigurator.setConfig(input);
|
||||
|
||||
return null;
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import { SendgridConfig } from "./sendgrid-config";
|
||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
export interface SendgridConfigurator {
|
||||
setConfig(config: SendgridConfig): Promise<void>;
|
||||
getConfig(): Promise<SendgridConfig | undefined>;
|
||||
}
|
||||
|
||||
export class PrivateMetadataSendgridConfigurator implements SendgridConfigurator {
|
||||
private metadataKey = "sendgrid-config";
|
||||
|
||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
||||
|
||||
getConfig(): Promise<SendgridConfig | undefined> {
|
||||
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid metadata value, cant be parsed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setConfig(config: SendgridConfig): Promise<void> {
|
||||
return this.metadataManager.set({
|
||||
key: this.metadataKey,
|
||||
value: JSON.stringify(config),
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
interface FetchTemplatesArgs {
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export const fetchTemplates =
|
||||
({ apiKey }: FetchTemplatesArgs) =>
|
||||
async () => {
|
||||
if (!apiKey) {
|
||||
console.warn(
|
||||
"The Sendgrid API key has not been set up yet. Skipping fetching available templates."
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const response = await fetch(
|
||||
"https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=18",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error("Could not fetch available Sendgrid templates");
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resJson = (await response.json()) as {
|
||||
result?: { id: string; name: string }[];
|
||||
};
|
||||
const templates =
|
||||
resJson.result?.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
})) || [];
|
||||
return templates;
|
||||
} catch (e) {
|
||||
console.error("Could not parse the response from Sendgrid", e);
|
||||
return [];
|
||||
}
|
||||
};
|
|
@ -0,0 +1,320 @@
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
Switch,
|
||||
TextField,
|
||||
TextFieldProps,
|
||||
Typography,
|
||||
} from "@material-ui/core";
|
||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||
import React, { useEffect } from "react";
|
||||
import { SendgridConfiguration } from "../sendgrid-config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TemplateSelectionField } from "./template-selection-field";
|
||||
import { fetchTemplates } from "./fetch-templates";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
field: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
form: {
|
||||
padding: 20,
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
onSubmit(data: SendgridConfiguration): Promise<void>;
|
||||
initialData: SendgridConfiguration;
|
||||
configurationId?: string;
|
||||
};
|
||||
|
||||
export const SendgridConfigurationForm = (props: Props) => {
|
||||
const { handleSubmit, control, reset } = useForm<SendgridConfiguration>({
|
||||
defaultValues: props.initialData,
|
||||
});
|
||||
|
||||
// when the configuration tab is changed, initialData change and form has to be updated
|
||||
useEffect(() => {
|
||||
reset(props.initialData);
|
||||
}, [props.initialData, reset]);
|
||||
|
||||
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({
|
||||
queryKey: ["sendgridTemplates"],
|
||||
queryFn: fetchTemplates({ apiKey: props.initialData.apiKey }),
|
||||
enabled: !!props.initialData?.apiKey.length,
|
||||
});
|
||||
|
||||
const styles = useStyles();
|
||||
|
||||
const CommonFieldProps: TextFieldProps = {
|
||||
className: styles.field,
|
||||
fullWidth: true,
|
||||
};
|
||||
|
||||
const isNewConfiguration = !props.configurationId;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit((data, event) => {
|
||||
props.onSubmit(data);
|
||||
})}
|
||||
className={styles.form}
|
||||
>
|
||||
{isNewConfiguration ? (
|
||||
<Typography variant="h4" paragraph>
|
||||
Create a new configuration
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="h4" paragraph>
|
||||
Configuration {props.initialData?.configurationName}
|
||||
</Typography>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="active"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch value={value} checked={value} onChange={(event, val) => onChange(val)} />
|
||||
}
|
||||
label="Active"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sandboxMode"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch value={value} checked={value} onChange={(event, val) => onChange(val)} />
|
||||
}
|
||||
label="Sandbox mode"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="configurationName"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Configuration name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="senderName"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField label="Sender name" value={value} onChange={onChange} {...CommonFieldProps} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="senderEmail"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField label="Sender email" value={value} onChange={onChange} {...CommonFieldProps} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="apiKey"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField label="API key" value={value} onChange={onChange} {...CommonFieldProps} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="templateOrderCreatedSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Created Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateOrderCreatedTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Created</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateOrderFulfilledSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Fulfilled Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateOrderFulfilledTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Fulfilled</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateOrderConfirmedSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Confirmed Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateOrderConfirmedTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Confirmed</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateOrderCancelledSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Cancelled Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateOrderCancelledTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Cancelled</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateOrderFullyPaidSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Fully Paid Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateOrderFullyPaidTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Fully Paid</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateInvoiceSentSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Invoice sent Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateInvoiceSentTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Invoice Sent</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth variant="primary">
|
||||
Save configuration
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,125 @@
|
|||
import { CircularProgress, Paper } from "@material-ui/core";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { ConfigurationsList } from "../../../app-configuration/ui/configurations-list";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { AppColumnsLayout } from "../../../ui/app-columns-layout";
|
||||
import { trpcClient } from "../../../trpc/trpc-client";
|
||||
import { SendgridConfiguration } from "../sendgrid-config";
|
||||
import {
|
||||
getDefaultEmptySendgridConfiguration,
|
||||
SendgridConfigContainer,
|
||||
} from "../sendgrid-config-container";
|
||||
import { SendgridConfigurationForm } from "./sendgrid-configuration-form";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
formContainer: {
|
||||
top: 0,
|
||||
},
|
||||
instructionsContainer: {
|
||||
padding: 15,
|
||||
},
|
||||
configurationColumn: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 20,
|
||||
},
|
||||
loaderContainer: {
|
||||
margin: "50px auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Configurations = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const SendgridConfigurationTab = () => {
|
||||
const styles = useStyles();
|
||||
const { appBridge } = useAppBridge();
|
||||
const [configurationsListData, setConfigurationsListData] = useState<Configurations[]>([]);
|
||||
const [activeConfigurationId, setActiveConfigurationId] = useState<string>();
|
||||
const [initialData, setInitialData] = useState<SendgridConfiguration>();
|
||||
|
||||
const {
|
||||
data: configurationData,
|
||||
refetch: refetchConfig,
|
||||
isLoading,
|
||||
} = trpcClient.sendgridConfiguration.fetch.useQuery(undefined, {
|
||||
onSuccess(data) {
|
||||
if (!data.availableConfigurations) {
|
||||
return;
|
||||
}
|
||||
const keys = Object.keys(data.availableConfigurations);
|
||||
setConfigurationsListData(
|
||||
keys.map((key) => ({ id: key, name: data.availableConfigurations[key].configurationName }))
|
||||
);
|
||||
setActiveConfigurationId(keys[0]);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate, error: saveError } = trpcClient.sendgridConfiguration.setAndReplace.useMutation({
|
||||
onSuccess() {
|
||||
refetchConfig();
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
text: "Saved configuration",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setInitialData(
|
||||
activeConfigurationId
|
||||
? SendgridConfigContainer.getSendgridConfigurationById(configurationData)(
|
||||
activeConfigurationId
|
||||
)
|
||||
: getDefaultEmptySendgridConfiguration()
|
||||
);
|
||||
}, [activeConfigurationId, configurationData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loaderContainer}>
|
||||
<CircularProgress color="primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppColumnsLayout>
|
||||
<ConfigurationsList
|
||||
// TODO: FIXME
|
||||
listItems={[]}
|
||||
activeItemId={activeConfigurationId}
|
||||
onItemClick={setActiveConfigurationId}
|
||||
/>
|
||||
<div className={styles.configurationColumn}>
|
||||
<Paper elevation={0} className={styles.formContainer}>
|
||||
{!!initialData && (
|
||||
<SendgridConfigurationForm
|
||||
onSubmit={async (data) => {
|
||||
const newConfig =
|
||||
SendgridConfigContainer.setSendgridConfigurationById(configurationData)(
|
||||
activeConfigurationId
|
||||
)(data);
|
||||
mutate(newConfig);
|
||||
}}
|
||||
initialData={initialData}
|
||||
configurationId={activeConfigurationId}
|
||||
/>
|
||||
)}
|
||||
{saveError && <span>{saveError.message}</span>}
|
||||
</Paper>
|
||||
</div>
|
||||
</AppColumnsLayout>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import { MenuItem, Select } from "@material-ui/core";
|
||||
|
||||
interface TemplateSelectionFieldProps {
|
||||
templateChoices?: { label: string; value: string }[];
|
||||
value?: string;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
export const TemplateSelectionField = ({
|
||||
value,
|
||||
onChange,
|
||||
templateChoices,
|
||||
}: TemplateSelectionFieldProps) => {
|
||||
return (
|
||||
<Select
|
||||
variant="outlined"
|
||||
value={value}
|
||||
onChange={(event, val) => onChange(event.target.value)}
|
||||
>
|
||||
{!templateChoices?.length ? (
|
||||
<MenuItem value="">No templates available</MenuItem>
|
||||
) : (
|
||||
templateChoices.map((choice) => (
|
||||
<MenuItem key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import { appRouter } from "../trpc/trpc-app-router";
|
||||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
|
||||
interface GetSendgridSettingsArgs {
|
||||
authData: AuthData;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export const getSendgridSettings = async ({ authData, channel }: GetSendgridSettingsArgs) => {
|
||||
const logger = pinoLogger.child({
|
||||
fn: "getMjmlSettings",
|
||||
channel,
|
||||
});
|
||||
const caller = appRouter.createCaller({
|
||||
appId: authData.appId,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
token: authData.token,
|
||||
ssr: true,
|
||||
});
|
||||
|
||||
const sendgridConfigurations = await caller.sendgridConfiguration.fetch();
|
||||
const appConfigurations = await caller.appConfiguration.fetch();
|
||||
|
||||
const channelAppConfiguration = appConfigurations?.configurationsPerChannel[channel];
|
||||
if (!channelAppConfiguration) {
|
||||
logger.warn("App has no configuration for this channel");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channelAppConfiguration.active) {
|
||||
logger.warn("App configuration is not active for this channel");
|
||||
return;
|
||||
}
|
||||
|
||||
const sendgridConfigurationId = channelAppConfiguration.sendgridConfigurationId;
|
||||
if (!sendgridConfigurationId?.length) {
|
||||
logger.warn("Sendgrid configuration has not been chosen for this channel");
|
||||
return;
|
||||
}
|
||||
|
||||
const configuration = sendgridConfigurations?.availableConfigurations[sendgridConfigurationId];
|
||||
if (!configuration) {
|
||||
logger.warn(`The Sendgrid configuration with id ${sendgridConfigurationId} does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!configuration.active) {
|
||||
logger.warn(`The Sendgrid configuration ${configuration.configurationName} is not active`);
|
||||
return;
|
||||
}
|
||||
|
||||
return configuration;
|
||||
};
|
128
apps/emails-and-messages/src/modules/sendgrid/send-sendgrid.ts
Normal file
128
apps/emails-and-messages/src/modules/sendgrid/send-sendgrid.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { logger as pinoLogger } from "../../lib/logger";
|
||||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import { SendgridConfiguration } from "./configuration/sendgrid-config";
|
||||
import { getSendgridSettings } from "./get-sendgrid-settings";
|
||||
import { MailService } from "@sendgrid/mail";
|
||||
import { MessageEventTypes } from "../event-handlers/message-event-types";
|
||||
|
||||
interface SendSendgridArgs {
|
||||
authData: AuthData;
|
||||
channel: string;
|
||||
recipientEmail: string;
|
||||
event: MessageEventTypes;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface EmailServiceResponse {
|
||||
errors?: {
|
||||
code: number;
|
||||
message: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const eventMapping = (event: SendSendgridArgs["event"], settings: SendgridConfiguration) => {
|
||||
switch (event) {
|
||||
case "ORDER_CREATED":
|
||||
return {
|
||||
templateId: settings.templateOrderCreatedTemplate,
|
||||
subject: settings.templateOrderCreatedSubject || "Order created",
|
||||
};
|
||||
case "ORDER_FULFILLED":
|
||||
return {
|
||||
templateId: settings.templateOrderFulfilledTemplate,
|
||||
subject: settings.templateOrderFulfilledSubject || "Order fulfilled",
|
||||
};
|
||||
case "ORDER_CONFIRMED":
|
||||
return {
|
||||
template: settings.templateOrderConfirmedTemplate,
|
||||
subject: settings.templateOrderConfirmedSubject || "Order confirmed",
|
||||
};
|
||||
case "ORDER_CANCELLED":
|
||||
return {
|
||||
template: settings.templateOrderCancelledTemplate,
|
||||
subject: settings.templateOrderCancelledSubject || "Order cancelled",
|
||||
};
|
||||
case "ORDER_FULLY_PAID":
|
||||
return {
|
||||
template: settings.templateOrderFullyPaidTemplate,
|
||||
subject: settings.templateOrderFullyPaidSubject || "Order fully paid",
|
||||
};
|
||||
case "INVOICE_SENT":
|
||||
return {
|
||||
template: settings.templateInvoiceSentTemplate,
|
||||
subject: settings.templateInvoiceSentSubject || "Invoice sent",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const sendSendgrid = async ({
|
||||
authData,
|
||||
channel,
|
||||
payload,
|
||||
recipientEmail,
|
||||
event,
|
||||
}: SendSendgridArgs) => {
|
||||
const logger = pinoLogger.child({
|
||||
fn: "sendSendgrid",
|
||||
event,
|
||||
});
|
||||
|
||||
const settings = await getSendgridSettings({ authData, channel });
|
||||
|
||||
if (!settings?.active) {
|
||||
logger.debug("Sendgrid is not active, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Sending an email using Sendgrid");
|
||||
|
||||
const { templateId, subject } = eventMapping(event, settings);
|
||||
|
||||
if (!templateId) {
|
||||
logger.error("No template defined in the settings");
|
||||
return {
|
||||
errors: [{ message: `No template specified for the event ${event}` }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const mailService = new MailService();
|
||||
mailService.setApiKey(settings.apiKey);
|
||||
|
||||
await mailService.send({
|
||||
mailSettings: {
|
||||
sandboxMode: {
|
||||
enable: settings.sandboxMode,
|
||||
},
|
||||
},
|
||||
from: {
|
||||
email: settings.senderEmail,
|
||||
},
|
||||
to: {
|
||||
email: recipientEmail,
|
||||
},
|
||||
personalizations: [
|
||||
{
|
||||
from: {
|
||||
email: settings.senderEmail,
|
||||
},
|
||||
to: [
|
||||
{
|
||||
email: recipientEmail,
|
||||
},
|
||||
],
|
||||
subject,
|
||||
dynamicTemplateData: payload,
|
||||
},
|
||||
],
|
||||
templateId,
|
||||
});
|
||||
logger.debug("Email has been send");
|
||||
} catch (error) {
|
||||
logger.error("The Sendgrid API returned an error");
|
||||
logger.error(error);
|
||||
if (error instanceof Error) {
|
||||
return { errors: [{ message: error.message }] };
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
import { Client, gql } from "urql";
|
||||
import { ShopInfoDocument, ShopInfoFragment } from "../../../generated/graphql";
|
||||
|
||||
gql`
|
||||
fragment ShopInfo on Shop {
|
||||
companyAddress {
|
||||
country {
|
||||
country
|
||||
code
|
||||
}
|
||||
city
|
||||
firstName
|
||||
lastName
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
companyName
|
||||
phone
|
||||
postalCode
|
||||
countryArea
|
||||
cityArea
|
||||
}
|
||||
}
|
||||
query ShopInfo {
|
||||
shop {
|
||||
...ShopInfo
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export interface IShopInfoFetcher {
|
||||
fetchShopInfo(): Promise<ShopInfoFragment | null>;
|
||||
}
|
||||
|
||||
export class ShopInfoFetcher implements IShopInfoFetcher {
|
||||
constructor(private client: Client) {}
|
||||
|
||||
fetchShopInfo(): Promise<ShopInfoFragment | null> {
|
||||
return this.client
|
||||
.query(ShopInfoDocument, {})
|
||||
.toPromise()
|
||||
.then((resp) => resp.data?.shop ?? null);
|
||||
}
|
||||
}
|
|
@ -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-graphql-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,
|
||||
},
|
||||
});
|
||||
});
|
14
apps/emails-and-messages/src/modules/trpc/trpc-app-router.ts
Normal file
14
apps/emails-and-messages/src/modules/trpc/trpc-app-router.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { channelsRouter } from "../channels/channels.router";
|
||||
import { router } from "./trpc-server";
|
||||
import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
|
||||
import { mjmlConfigurationRouter } from "../mjml/configuration/mjml-configuration.router";
|
||||
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
|
||||
|
||||
export const appRouter = router({
|
||||
channels: channelsRouter,
|
||||
appConfiguration: appConfigurationRouter,
|
||||
mjmlConfiguration: mjmlConfigurationRouter,
|
||||
sendgridConfiguration: sendgridConfigurationRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
43
apps/emails-and-messages/src/modules/trpc/trpc-client.ts
Normal file
43
apps/emails-and-messages/src/modules/trpc/trpc-client.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
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() {
|
||||
const { token, saleorApiUrl } = appBridgeInstance?.getState() || {};
|
||||
if (!token || !saleorApiUrl) {
|
||||
console.error(
|
||||
"Can't initialize tRPC client before establishing the App Bridge connection"
|
||||
);
|
||||
throw new Error("Token and Saleor API URL unknown");
|
||||
}
|
||||
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: { refetchOnWindowFocus: false } } },
|
||||
};
|
||||
},
|
||||
ssr: false,
|
||||
});
|
14
apps/emails-and-messages/src/modules/trpc/trpc-context.ts
Normal file
14
apps/emails-and-messages/src/modules/trpc/trpc-context.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_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>;
|
30
apps/emails-and-messages/src/modules/trpc/trpc-server.ts
Normal file
30
apps/emails-and-messages/src/modules/trpc/trpc-server.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { initTRPC } from "@trpc/server";
|
||||
import { TrpcContext } from "./trpc-context";
|
||||
import { AppPermission } from "@saleor/app-sdk/types";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
interface Meta {
|
||||
requiredClientPermissions?: AppPermission[];
|
||||
}
|
||||
|
||||
const t = initTRPC
|
||||
.context<TrpcContext>()
|
||||
.meta<Meta>()
|
||||
.create({
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.code === "BAD_REQUEST" && error.cause instanceof ZodError
|
||||
? error.cause.flatten()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const middleware = t.middleware;
|
|
@ -0,0 +1,22 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px auto",
|
||||
alignItems: "start",
|
||||
gap: 32,
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
padding: "20px 0",
|
||||
},
|
||||
});
|
||||
|
||||
type AppColumnsLayoutProps = PropsWithChildren<{}>;
|
||||
|
||||
export const AppColumnsLayout = ({ children }: AppColumnsLayoutProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return <div className={styles.root}>{children}</div>;
|
||||
};
|
43
apps/emails-and-messages/src/modules/ui/code-editor.tsx
Normal file
43
apps/emails-and-messages/src/modules/ui/code-editor.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import React, { useCallback, useRef } from "react";
|
||||
|
||||
import Editor from "@monaco-editor/react";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
|
||||
type Props = {
|
||||
onChange(value: string): void;
|
||||
initialTemplate: string;
|
||||
value: string;
|
||||
language: string;
|
||||
};
|
||||
|
||||
export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props) => {
|
||||
const { themeType } = useTheme();
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// @ts-ignore
|
||||
function handleEditorDidMount(editor, monaco) {
|
||||
editorRef.current = editor;
|
||||
}
|
||||
|
||||
const handleOnChange = useCallback(
|
||||
(value?: string) => {
|
||||
onChange(value ?? "");
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor
|
||||
height="40vh"
|
||||
width="100%"
|
||||
value={value}
|
||||
theme={themeType === "dark" ? "vs-dark" : "vs-light"}
|
||||
defaultLanguage={language}
|
||||
defaultValue={initialTemplate}
|
||||
onMount={handleEditorDidMount}
|
||||
onChange={handleOnChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
appContainer: { marginTop: 20 },
|
||||
});
|
||||
|
||||
type Props = PropsWithChildren<{}>;
|
||||
|
||||
export const ConfigurationPageBaseLayout = ({ children }: Props) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const router = useRouter();
|
||||
const tabs = [
|
||||
{
|
||||
key: "channels",
|
||||
label: "Channels",
|
||||
url: "/configuration/channels",
|
||||
},
|
||||
{ key: "mjml", label: "MJML", url: "/configuration/mjml" },
|
||||
{
|
||||
key: "sendgrid",
|
||||
label: "Sendgrid (Coming soon!)",
|
||||
url: "/configuration/sendgrid",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const activePath = tabs.find((tab) => router.pathname.startsWith(tab.url))?.key;
|
||||
|
||||
const navigateToTab = (value: string) => {
|
||||
const redirectionUrl = tabs.find((tab) => tab.key === value)?.url;
|
||||
if (redirectionUrl) {
|
||||
router.push(redirectionUrl);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.appContainer}>
|
||||
<PageTabs
|
||||
value={activePath}
|
||||
onChange={navigateToTab}
|
||||
style={{ maxWidth: 1180, margin: "0 auto" }}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<PageTab key={tab.key} value={tab.key} label={tab.label} disabled={tab.disabled} />
|
||||
))}
|
||||
</PageTabs>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,22 @@
|
|||
import { CircularProgress } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
loaderContainer: {
|
||||
margin: "50px auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const LoadingIndicator = () => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<div className={styles.loaderContainer}>
|
||||
<CircularProgress color="primary" />
|
||||
</div>
|
||||
);
|
||||
};
|
37
apps/emails-and-messages/src/not-ready.tsx
Normal file
37
apps/emails-and-messages/src/not-ready.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { AlertBase, Button } from "@saleor/macaw-ui";
|
||||
import React from "react";
|
||||
import { Typography } from "@material-ui/core";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { appName } from "./const";
|
||||
|
||||
const NotReadyPage = () => {
|
||||
const { appBridge } = useAppBridge();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{appName}</h1>
|
||||
<AlertBase variant="error">
|
||||
<Typography variant="h3" paragraph>
|
||||
App can not be used
|
||||
</Typography>
|
||||
<Typography paragraph>
|
||||
To configure the app you need to create at least 1 channel
|
||||
</Typography>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
to: `/channels/add`,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Set up channel
|
||||
</Button>
|
||||
</AlertBase>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotReadyPage;
|
103
apps/emails-and-messages/src/pages/_app.tsx
Normal file
103
apps/emails-and-messages/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import "../styles/globals.css";
|
||||
|
||||
import { Theme } from "@material-ui/core/styles";
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import {
|
||||
dark,
|
||||
light,
|
||||
SaleorThemeColors,
|
||||
ThemeProvider as MacawUIThemeProvider,
|
||||
} from "@saleor/macaw-ui";
|
||||
import React, { PropsWithChildren, useEffect } from "react";
|
||||
import { AppProps } from "next/app";
|
||||
|
||||
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
||||
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
||||
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||
import { createGenerateClassName, StylesProvider } from "@material-ui/core";
|
||||
|
||||
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
|
||||
|
||||
/**
|
||||
* Temporary override of colors, to match new dashboard palette.
|
||||
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
|
||||
*/
|
||||
const palettes: PalettesOverride = {
|
||||
light: {
|
||||
...light,
|
||||
background: {
|
||||
default: "#fff",
|
||||
paper: "#fff",
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
...dark,
|
||||
background: {
|
||||
default: "hsla(211, 42%, 14%, 1)",
|
||||
paper: "hsla(211, 42%, 14%, 1)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const themeOverrides: Partial<Theme> = {
|
||||
overrides: {
|
||||
MuiTableCell: {
|
||||
body: {
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
root: {
|
||||
height: 56,
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const generateClassName = createGenerateClassName({
|
||||
productionPrefix: "c",
|
||||
disableGlobal: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensure instance is a singleton.
|
||||
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
|
||||
*/
|
||||
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
|
||||
|
||||
/**
|
||||
* That's a hack required by Macaw-UI incompatibility with React@18
|
||||
*/
|
||||
const ThemeProvider = MacawUIThemeProvider as React.FC<
|
||||
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
|
||||
>;
|
||||
|
||||
function NextApp({ Component, pageProps }: AppProps) {
|
||||
/**
|
||||
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector("#jss-server-side");
|
||||
if (jssStyles) {
|
||||
jssStyles?.parentElement?.removeChild(jssStyles);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<NoSSRWrapper>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<StylesProvider generateClassName={generateClassName}>
|
||||
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
|
||||
<ThemeSynchronizer />
|
||||
<RoutePropagator />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</StylesProvider>
|
||||
</AppBridgeProvider>
|
||||
</NoSSRWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpcClient.withTRPC(NextApp);
|
41
apps/emails-and-messages/src/pages/api/manifest.ts
Normal file
41
apps/emails-and-messages/src/pages/api/manifest.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { invoiceSentWebhook } from "./webhooks/invoice-sent";
|
||||
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
|
||||
import { orderConfirmedWebhook } from "./webhooks/order-confirmed";
|
||||
import { orderCreatedWebhook } from "./webhooks/order-created";
|
||||
import { orderFulfilledWebhook } from "./webhooks/order-fulfilled";
|
||||
import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid";
|
||||
|
||||
export default createManifestHandler({
|
||||
async manifestFactory(context) {
|
||||
const manifest: AppManifest = {
|
||||
name: "Emails & Messages",
|
||||
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
|
||||
appUrl: context.appBaseUrl,
|
||||
permissions: ["MANAGE_ORDERS"],
|
||||
id: "saleor.app.emails-and-messages",
|
||||
version: packageJson.version,
|
||||
webhooks: [
|
||||
orderCreatedWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
orderFulfilledWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
orderConfirmedWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
orderCancelledWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
orderFullyPaidWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
invoiceSentWebhook.getWebhookManifest(context.appBaseUrl),
|
||||
],
|
||||
extensions: [
|
||||
/**
|
||||
* Optionally, extend Dashboard with custom UIs
|
||||
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
|
||||
*/
|
||||
],
|
||||
homepageUrl: "https://github.com/saleor/apps",
|
||||
supportUrl: "https://github.com/saleor/apps/discussions",
|
||||
};
|
||||
|
||||
return manifest;
|
||||
},
|
||||
});
|
24
apps/emails-and-messages/src/pages/api/register.ts
Normal file
24
apps/emails-and-messages/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/emails-and-messages/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/emails-and-messages/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,
|
||||
});
|
|
@ -0,0 +1,97 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
InvoiceSentWebhookPayloadFragment,
|
||||
OrderDetailsFragmentDoc,
|
||||
} from "../../../../generated/graphql";
|
||||
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
|
||||
import { createClient } from "../../../lib/create-graphql-client";
|
||||
|
||||
const InvoiceSentWebhookPayload = gql`
|
||||
${OrderDetailsFragmentDoc}
|
||||
fragment InvoiceSentWebhookPayload on InvoiceSent {
|
||||
invoice {
|
||||
id
|
||||
message
|
||||
externalUrl
|
||||
url
|
||||
order {
|
||||
id
|
||||
}
|
||||
}
|
||||
order {
|
||||
...OrderDetails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const InvoiceSentGraphqlSubscription = gql`
|
||||
${InvoiceSentWebhookPayload}
|
||||
subscription InvoiceSent {
|
||||
event {
|
||||
...InvoiceSentWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const invoiceSentWebhook = new SaleorAsyncWebhook<InvoiceSentWebhookPayloadFragment>({
|
||||
name: "Invoice sent in Saleor",
|
||||
webhookPath: "api/webhooks/invoice-sent",
|
||||
asyncEvent: "INVOICE_SENT",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: InvoiceSentGraphqlSubscription,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<InvoiceSentWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: invoiceSentWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { order } = payload;
|
||||
|
||||
if (!order) {
|
||||
logger.error("No order data payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const recipientEmail = order.userEmail || order.user?.email;
|
||||
if (!recipientEmail?.length) {
|
||||
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ error: "Email recipient has not been specified in the event payload." });
|
||||
}
|
||||
|
||||
const channel = order.channel.slug;
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
await sendEventMessages({
|
||||
authData,
|
||||
channel,
|
||||
client,
|
||||
event: "INVOICE_SENT",
|
||||
payload: { order: payload.order },
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default invoiceSentWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
OrderCancelledWebhookPayloadFragment,
|
||||
OrderDetailsFragmentDoc,
|
||||
} from "../../../../generated/graphql";
|
||||
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
|
||||
import { createClient } from "../../../lib/create-graphql-client";
|
||||
|
||||
const OrderCancelledWebhookPayload = gql`
|
||||
${OrderDetailsFragmentDoc}
|
||||
fragment OrderCancelledWebhookPayload on OrderCancelled {
|
||||
order {
|
||||
...OrderDetails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OrderCancelledGraphqlSubscription = gql`
|
||||
${OrderCancelledWebhookPayload}
|
||||
subscription OrderCancelled {
|
||||
event {
|
||||
...OrderCancelledWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
|
||||
name: "Order Cancelled in Saleor",
|
||||
webhookPath: "api/webhooks/order-cancelled",
|
||||
asyncEvent: "ORDER_CANCELLED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: OrderCancelledGraphqlSubscription,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderCancelledWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: orderCancelledWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { order } = payload;
|
||||
|
||||
if (!order) {
|
||||
logger.error("No order data payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const recipientEmail = order.userEmail || order.user?.email;
|
||||
if (!recipientEmail?.length) {
|
||||
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ error: "Email recipient has not been specified in the event payload." });
|
||||
}
|
||||
|
||||
const channel = order.channel.slug;
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
await sendEventMessages({
|
||||
authData,
|
||||
channel,
|
||||
client,
|
||||
event: "ORDER_CANCELLED",
|
||||
payload: { order: payload.order },
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default orderCancelledWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,89 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
OrderConfirmedWebhookPayloadFragment,
|
||||
OrderDetailsFragmentDoc,
|
||||
} from "../../../../generated/graphql";
|
||||
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
|
||||
import { createClient } from "../../../lib/create-graphql-client";
|
||||
|
||||
const OrderConfirmedWebhookPayload = gql`
|
||||
${OrderDetailsFragmentDoc}
|
||||
|
||||
fragment OrderConfirmedWebhookPayload on OrderConfirmed {
|
||||
order {
|
||||
...OrderDetails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OrderConfirmedGraphqlSubscription = gql`
|
||||
${OrderConfirmedWebhookPayload}
|
||||
subscription OrderConfirmed {
|
||||
event {
|
||||
...OrderConfirmedWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderConfirmedWebhook = new SaleorAsyncWebhook<OrderConfirmedWebhookPayloadFragment>({
|
||||
name: "Order Confirmed in Saleor",
|
||||
webhookPath: "api/webhooks/order-confirmed",
|
||||
asyncEvent: "ORDER_CONFIRMED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: OrderConfirmedGraphqlSubscription,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderConfirmedWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: orderConfirmedWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { order } = payload;
|
||||
|
||||
if (!order) {
|
||||
logger.error("No order data payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const recipientEmail = order.userEmail || order.user?.email;
|
||||
if (!recipientEmail?.length) {
|
||||
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ error: "Email recipient has not been specified in the event payload." });
|
||||
}
|
||||
|
||||
const channel = order.channel.slug;
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
await sendEventMessages({
|
||||
authData,
|
||||
channel,
|
||||
client,
|
||||
event: "ORDER_CONFIRMED",
|
||||
payload: { order: payload.order },
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default orderConfirmedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
import { OrderDetailsFragmentDoc } from "./../../../../generated/graphql";
|
||||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { OrderCreatedWebhookPayloadFragment } from "../../../../generated/graphql";
|
||||
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
|
||||
import { createClient } from "../../../lib/create-graphql-client";
|
||||
|
||||
const OrderCreatedWebhookPayload = gql`
|
||||
${OrderDetailsFragmentDoc}
|
||||
fragment OrderCreatedWebhookPayload on OrderCreated {
|
||||
order {
|
||||
...OrderDetails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OrderCreatedGraphqlSubscription = gql`
|
||||
${OrderCreatedWebhookPayload}
|
||||
subscription OrderCreated {
|
||||
event {
|
||||
...OrderCreatedWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
|
||||
name: "Order Created in Saleor",
|
||||
webhookPath: "api/webhooks/order-created",
|
||||
asyncEvent: "ORDER_CREATED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: OrderCreatedGraphqlSubscription,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderCreatedWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: orderCreatedWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { order } = payload;
|
||||
|
||||
if (!order) {
|
||||
logger.error("No order data payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const recipientEmail = order.userEmail || order.user?.email;
|
||||
if (!recipientEmail?.length) {
|
||||
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ error: "Email recipient has not been specified in the event payload." });
|
||||
}
|
||||
|
||||
const channel = order.channel.slug;
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
await sendEventMessages({
|
||||
authData,
|
||||
channel,
|
||||
client,
|
||||
event: "ORDER_CREATED",
|
||||
payload: { order: payload.order },
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default orderCreatedWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,89 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
OrderDetailsFragmentDoc,
|
||||
OrderFulfilledWebhookPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
|
||||
import { createClient } from "../../../lib/create-graphql-client";
|
||||
|
||||
const OrderFulfilledWebhookPayload = gql`
|
||||
${OrderDetailsFragmentDoc}
|
||||
|
||||
fragment OrderFulfilledWebhookPayload on OrderFulfilled {
|
||||
order {
|
||||
...OrderDetails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OrderFulfilledGraphqlSubscription = gql`
|
||||
${OrderFulfilledWebhookPayload}
|
||||
subscription OrderFulfilled {
|
||||
event {
|
||||
...OrderFulfilledWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
|
||||
name: "Order Fulfilled in Saleor",
|
||||
webhookPath: "api/webhooks/order-fulfilled",
|
||||
asyncEvent: "ORDER_FULFILLED",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: OrderFulfilledGraphqlSubscription,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderFulfilledWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: orderFulfilledWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { order } = payload;
|
||||
|
||||
if (!order) {
|
||||
logger.error("No order data payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const recipientEmail = order.userEmail || order.user?.email;
|
||||
if (!recipientEmail?.length) {
|
||||
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ error: "Email recipient has not been specified in the event payload." });
|
||||
}
|
||||
|
||||
const channel = order.channel.slug;
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
await sendEventMessages({
|
||||
authData,
|
||||
client,
|
||||
channel,
|
||||
event: "ORDER_FULFILLED",
|
||||
payload: { order: payload.order },
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default orderFulfilledWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,89 @@
|
|||
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import { gql } from "urql";
|
||||
import { saleorApp } from "../../../saleor-app";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import {
|
||||
OrderDetailsFragmentDoc,
|
||||
OrderFullyPaidWebhookPayloadFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
|
||||
import { createClient } from "../../../lib/create-graphql-client";
|
||||
|
||||
const OrderFullyPaidWebhookPayload = gql`
|
||||
${OrderDetailsFragmentDoc}
|
||||
|
||||
fragment OrderFullyPaidWebhookPayload on OrderFullyPaid {
|
||||
order {
|
||||
...OrderDetails
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OrderFullyPaidGraphqlSubscription = gql`
|
||||
${OrderFullyPaidWebhookPayload}
|
||||
subscription OrderFullyPaid {
|
||||
event {
|
||||
...OrderFullyPaidWebhookPayload
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const orderFullyPaidWebhook = new SaleorAsyncWebhook<OrderFullyPaidWebhookPayloadFragment>({
|
||||
name: "Order Fully Paid in Saleor",
|
||||
webhookPath: "api/webhooks/order-fully-paid",
|
||||
asyncEvent: "ORDER_FULLY_PAID",
|
||||
apl: saleorApp.apl,
|
||||
subscriptionQueryAst: OrderFullyPaidGraphqlSubscription,
|
||||
});
|
||||
|
||||
const handler: NextWebhookApiHandler<OrderFullyPaidWebhookPayloadFragment> = async (
|
||||
req,
|
||||
res,
|
||||
context
|
||||
) => {
|
||||
const logger = pinoLogger.child({
|
||||
webhook: orderFullyPaidWebhook.name,
|
||||
});
|
||||
|
||||
logger.debug("Webhook received");
|
||||
|
||||
const { payload, authData } = context;
|
||||
const { order } = payload;
|
||||
|
||||
if (!order) {
|
||||
logger.error("No order data payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
const recipientEmail = order.userEmail || order.user?.email;
|
||||
if (!recipientEmail?.length) {
|
||||
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ error: "Email recipient has not been specified in the event payload." });
|
||||
}
|
||||
|
||||
const channel = order.channel.slug;
|
||||
const client = createClient(authData.saleorApiUrl, async () =>
|
||||
Promise.resolve({ token: authData.token })
|
||||
);
|
||||
|
||||
await sendEventMessages({
|
||||
authData,
|
||||
channel,
|
||||
client,
|
||||
event: "ORDER_FULLY_PAID",
|
||||
payload: { order: payload.order },
|
||||
recipientEmail,
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "The event has been handled" });
|
||||
};
|
||||
|
||||
export default orderFullyPaidWebhook.createHandler(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,24 @@
|
|||
import { NextPage } from "next";
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { trpcClient } from "../../../modules/trpc/trpc-client";
|
||||
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
|
||||
import { ChannelsConfigurationTab } from "../../../modules/app-configuration/ui/channels-configuration-tab";
|
||||
|
||||
const ChannelsConfigurationPage: NextPage = () => {
|
||||
const channels = trpcClient.channels.fetch.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (router && channels.isSuccess && channels.data.length === 0) {
|
||||
router.push("/not-ready");
|
||||
}
|
||||
}, [channels.data, channels.isSuccess, router]);
|
||||
return (
|
||||
<ConfigurationPageBaseLayout>
|
||||
<ChannelsConfigurationTab />
|
||||
</ConfigurationPageBaseLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelsConfigurationPage;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue