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.
|
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
|
||||||
- [invoices](./apps/invoices) - generate invoice PDF for each order.
|
- [invoices](./apps/invoices) - generate invoice PDF for each order.
|
||||||
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
|
- [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.
|
- [search](./apps/search) - connect Saleor with search engines.
|
||||||
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
|
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
|
||||||
- [taxes](./apps/taxes) - calculate order and checkout taxes using external services.
|
- [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