Compare commits
6 commits
main
...
add-e2e-te
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fb68ded38f | ||
![]() |
54e77d495e | ||
![]() |
dae66beb59 | ||
![]() |
b9e3b5ca26 | ||
![]() |
1a6d39e2be | ||
![]() |
8a7371c3d2 |
159 changed files with 84485 additions and 0 deletions
10
.eslintrc.js
Normal file
10
.eslintrc.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
// This tells ESLint to load the config from the package `eslint-config-custom`
|
||||||
|
extends: ["custom"],
|
||||||
|
settings: {
|
||||||
|
next: {
|
||||||
|
rootDir: ["apps/*/"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
.sentryclirc
|
||||||
|
test-invoice.pdf
|
||||||
|
.env
|
||||||
|
# Sentry
|
||||||
|
.sentryclirc
|
||||||
|
sentry.properties
|
||||||
|
.idea
|
1
.npmrc
Normal file
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
auto-install-peers = true
|
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* @saleor/appstore
|
27
apps/e2e-tests/.github/workflows/playwright.yml
vendored
Normal file
27
apps/e2e-tests/.github/workflows/playwright.yml
vendored
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
name: Playwright Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master ]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: pnpm dlx playwright install --with-deps
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: pnpm dlx playwright test
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 30
|
4
apps/e2e-tests/.gitignore
vendored
Normal file
4
apps/e2e-tests/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
16
apps/e2e-tests/configuration.ts
Normal file
16
apps/e2e-tests/configuration.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import appUrls from "./urls/app-urls";
|
||||||
|
import saleorUrls from "./urls/saleor-urls";
|
||||||
|
|
||||||
|
export const appName = process.env.APP_NAME; // TODO: name should be taken from the manifest to eliminate possible footguns
|
||||||
|
export const appUrl = process.env.APP_URL;
|
||||||
|
export const instanceUrl = process.env.INSTANCE_URL;
|
||||||
|
export const dashboardUserEmail = process.env.DASHBOARD_USER_EMAIL;
|
||||||
|
export const dashboardUserPassword = process.env.DASHBOARD_USER_PASSWORD;
|
||||||
|
|
||||||
|
export const urls = {
|
||||||
|
app: {
|
||||||
|
baseUrl: appUrl,
|
||||||
|
...appUrls(appUrl)
|
||||||
|
},
|
||||||
|
saleor: saleorUrls(instanceUrl)
|
||||||
|
}
|
13
apps/e2e-tests/package.json
Normal file
13
apps/e2e-tests/package.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "e2e-tests",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.29.2"
|
||||||
|
}
|
||||||
|
}
|
100
apps/e2e-tests/playwright.config.ts
Normal file
100
apps/e2e-tests/playwright.config.ts
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||||
|
import { devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
const config: PlaywrightTestConfig = {
|
||||||
|
testDir: './tests',
|
||||||
|
/* Maximum time one test can run for. */
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
/**
|
||||||
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
|
* For example in `await expect(locator).toHaveText();`
|
||||||
|
*/
|
||||||
|
timeout: 5000
|
||||||
|
},
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
|
actionTimeout: 0,
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: 'firefox',
|
||||||
|
// use: {
|
||||||
|
// ...devices['Desktop Firefox'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: {
|
||||||
|
// ...devices['Pixel 5'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: {
|
||||||
|
// ...devices['iPhone 12'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: {
|
||||||
|
// channel: 'msedge',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: {
|
||||||
|
// channel: 'chrome',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
|
// outputDir: 'test-results/',
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// port: 3000,
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
53
apps/e2e-tests/tests/example.spec.ts
Normal file
53
apps/e2e-tests/tests/example.spec.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { appName, urls } from '../configuration';
|
||||||
|
import { checkIfAppIsAvailable } from '../utils/check-if-app-is-available';
|
||||||
|
import { installTheApp } from '../utils/install-the-app';
|
||||||
|
import { logInIntoDashboard } from '../utils/log-in-into-dashboard';
|
||||||
|
import { randomString } from '../utils/random-string';
|
||||||
|
|
||||||
|
test("The app can be installed", async ({page}) => {
|
||||||
|
// TODO: add function to completely remove all the installed apps in the test env
|
||||||
|
// alternative: make locators tighter to restrict it only to the tested app
|
||||||
|
await logInIntoDashboard({page})
|
||||||
|
await installTheApp({page})
|
||||||
|
await checkIfAppIsAvailable({page})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Smoke test of the configuration", async ({page}) => {
|
||||||
|
await logInIntoDashboard({page})
|
||||||
|
|
||||||
|
// open app configuration view
|
||||||
|
await page.goto(urls.saleor.dashboard.apps, {timeout: 20000, waitUntil: "load"});
|
||||||
|
await page.getByText(appName).first().click()
|
||||||
|
|
||||||
|
// generate unique marker to ensure values are updated during this test run
|
||||||
|
const marker = randomString()
|
||||||
|
|
||||||
|
// fill the configuration fields
|
||||||
|
await page.frameLocator('iframe').locator('input[name="secretKey"]').click();
|
||||||
|
await page.frameLocator('iframe').locator('input[name="secretKey"]').fill('secret-key-'+marker);
|
||||||
|
await page.frameLocator('iframe').locator('input[name="searchKey"]').click();
|
||||||
|
await page.frameLocator('iframe').locator('input[name="searchKey"]').fill('search-key-'+marker);
|
||||||
|
await page.frameLocator('iframe').locator('input[name="appId"]').click();
|
||||||
|
await page.frameLocator('iframe').locator('input[name="appId"]').fill('app-id-'+marker);
|
||||||
|
await page.frameLocator('iframe').locator('input[name="indexNamePrefix"]').click();
|
||||||
|
await page.frameLocator('iframe').locator('input[name="indexNamePrefix"]').fill('prefix-'+marker);
|
||||||
|
|
||||||
|
// submit and wait for the confirmation toast
|
||||||
|
await page.mouse.wheel(0, 20); // TODO: investigate how to automatically scroll
|
||||||
|
await page.frameLocator('iframe').getByRole('button', { name: 'Save' }).click();
|
||||||
|
await page.getByText('Configuration saved!').isVisible();
|
||||||
|
|
||||||
|
// check if the data persists
|
||||||
|
await page.goto(urls.saleor.dashboard.apps, {timeout: 20000, waitUntil: "load"});
|
||||||
|
await page.getByText(appName).first().click()
|
||||||
|
|
||||||
|
// check if the form data loaded
|
||||||
|
await page.frameLocator('iframe').getByRole('button', { name: 'Save' }).isEnabled();
|
||||||
|
|
||||||
|
// check field values
|
||||||
|
await expect(await page.frameLocator('iframe').locator('input[name="secretKey"]').inputValue()).toBe("secret-key-"+marker);
|
||||||
|
await expect(await page.frameLocator('iframe').locator('input[name="searchKey"]').inputValue()).toBe("search-key-"+marker);
|
||||||
|
await expect(await page.frameLocator('iframe').locator('input[name="appId"]').inputValue()).toBe("app-id-"+marker);
|
||||||
|
await expect(await page.frameLocator('iframe').locator('input[name="indexNamePrefix"]').inputValue()).toBe("prefix-"+marker);
|
||||||
|
})
|
17
apps/e2e-tests/types.ts
Normal file
17
apps/e2e-tests/types.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
export interface WebhookManifest {
|
||||||
|
name: string;
|
||||||
|
targetUrl: string;
|
||||||
|
asyncEvents: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Manifest {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
appUrl: string;
|
||||||
|
tokenTargetUrl: string;
|
||||||
|
webhooks: WebhookManifest[];
|
||||||
|
}
|
7
apps/e2e-tests/urls/app-urls.ts
Normal file
7
apps/e2e-tests/urls/app-urls.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
|
||||||
|
export const appUrls = (appUrl: string) => ({
|
||||||
|
manifest: new URL('/api/manifest', appUrl).href,
|
||||||
|
register: new URL('/api/register', appUrl).href
|
||||||
|
})
|
||||||
|
|
||||||
|
export default appUrls
|
9
apps/e2e-tests/urls/saleor-urls.ts
Normal file
9
apps/e2e-tests/urls/saleor-urls.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export const saleorUrls = (url: string) => ({
|
||||||
|
dashboard: {
|
||||||
|
homepage: new URL('/dashboard', url).href,
|
||||||
|
apps: new URL('/dashboard/apps', url).href
|
||||||
|
},
|
||||||
|
api: new URL('/graphql/', url).href
|
||||||
|
})
|
||||||
|
|
||||||
|
export default saleorUrls
|
17
apps/e2e-tests/utils/check-if-app-is-available.ts
Normal file
17
apps/e2e-tests/utils/check-if-app-is-available.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { expect, Page } from "@playwright/test";
|
||||||
|
import { appName, urls } from "../configuration";
|
||||||
|
|
||||||
|
interface checkIfAppIsAvailableArgs {
|
||||||
|
page: Page
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkIfAppIsAvailable = async ({page}: checkIfAppIsAvailableArgs) => {
|
||||||
|
// got to Apps page, assuming user is logged in
|
||||||
|
await page.goto(urls.saleor.dashboard.apps, {timeout: 20000, waitUntil: "load"});
|
||||||
|
|
||||||
|
// look for a entry with name of our app
|
||||||
|
await expect(await page.getByText(appName).first()).toBeVisible()
|
||||||
|
|
||||||
|
// and confirm its installed
|
||||||
|
await expect(await page.getByText('Problem occured during installation.')).toBeHidden()
|
||||||
|
}
|
5
apps/e2e-tests/utils/delay.ts
Normal file
5
apps/e2e-tests/utils/delay.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export const delay = (time) => {
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
setTimeout(resolve, time)
|
||||||
|
});
|
||||||
|
}
|
22
apps/e2e-tests/utils/install-the-app.ts
Normal file
22
apps/e2e-tests/utils/install-the-app.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Page } from "@playwright/test";
|
||||||
|
import { urls } from "../configuration";
|
||||||
|
|
||||||
|
interface InstallTheAppArgs {
|
||||||
|
page: Page
|
||||||
|
}
|
||||||
|
|
||||||
|
export const installTheApp = async ({page}: InstallTheAppArgs) => {
|
||||||
|
// got to Apps page, assuming user is logged in
|
||||||
|
await page.goto(urls.saleor.dashboard.apps, {timeout: 20000, waitUntil: "load"});
|
||||||
|
|
||||||
|
// Install the app via the manifest URL
|
||||||
|
await page.locator('[data-test-id="add-app-from-manifest"]').click();
|
||||||
|
await page.getByRole('textbox').click();
|
||||||
|
await page.getByRole('textbox').fill(urls.app.manifest);
|
||||||
|
await page.locator('[data-test-id="install-app-from-manifest"]').click();
|
||||||
|
await page.getByRole('button', { name: 'Install App' }).click();
|
||||||
|
|
||||||
|
// wait for the toast
|
||||||
|
await page.getByText('App installed').isVisible();
|
||||||
|
|
||||||
|
}
|
19
apps/e2e-tests/utils/log-in-into-dashboard.ts
Normal file
19
apps/e2e-tests/utils/log-in-into-dashboard.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Page, expect } from "@playwright/test";
|
||||||
|
import { dashboardUserEmail, dashboardUserPassword, urls } from "../configuration";
|
||||||
|
|
||||||
|
interface LogInIntoDashboardArgs {
|
||||||
|
page: Page
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logInIntoDashboard = async ({page}: LogInIntoDashboardArgs) => {
|
||||||
|
await page.goto(urls.saleor.dashboard.homepage, {timeout: 20000, waitUntil: "load"});
|
||||||
|
await page.locator('[data-test-id="email"]').click();
|
||||||
|
await page.locator('[data-test-id="email"]').fill(dashboardUserEmail);
|
||||||
|
await page.locator('[data-test-id="email"]').press('Tab');
|
||||||
|
await page.locator('[data-test-id="password"]').fill(dashboardUserPassword);
|
||||||
|
await page.locator('[data-test-id="submit"]').click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-test-id="welcome-header"]')).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default logInIntoDashboard
|
1
apps/e2e-tests/utils/random-string.ts
Normal file
1
apps/e2e-tests/utils/random-string.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const randomString = () => Math.random().toString(36).substr(2, 5)
|
11
apps/invoice-hub/.env.example
Normal file
11
apps/invoice-hub/.env.example
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# Set dir where temp PDF will be stored. For Vercel use tmp
|
||||||
|
# https://vercel.com/guides/how-can-i-use-files-in-serverless-functions
|
||||||
|
TEMP_PDF_STORAGE_DIR=
|
||||||
|
#"fatal" | "error" | "warn" | "info" | "debug" | "trace"
|
||||||
|
APP_DEBUG=info
|
||||||
|
# Optional
|
||||||
|
# Regex pattern consumed conditionally to restrcit app installation to specific urls.
|
||||||
|
# See api/register.tsx
|
||||||
|
# Leave empty to allow all domains
|
||||||
|
# Example: "https:\/\/.*.saleor.cloud\/graphql\/" to enable Saleor Cloud APIs
|
||||||
|
ALLOWED_DOMAIN_PATTERN=
|
3
apps/invoice-hub/.eslintrc
Normal file
3
apps/invoice-hub/.eslintrc
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": ["custom"]
|
||||||
|
}
|
47
apps/invoice-hub/.gitignore
vendored
Normal file
47
apps/invoice-hub/.gitignore
vendored
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.envfile
|
||||||
|
.saleor-app-auth.json
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.auth_token
|
||||||
|
|
||||||
|
#editor
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# GraphQL auto-generated
|
||||||
|
generated/
|
||||||
|
|
20
apps/invoice-hub/.graphqlrc.yml
Normal file
20
apps/invoice-hub/.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
|
5
apps/invoice-hub/.prettierignore
Normal file
5
apps/invoice-hub/.prettierignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.next
|
||||||
|
saleor/api.tsx
|
||||||
|
pnpm-lock.yaml
|
||||||
|
graphql/schema.graphql
|
||||||
|
generated
|
4
apps/invoice-hub/.prettierrc
Normal file
4
apps/invoice-hub/.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
37
apps/invoice-hub/LICENSE
Normal file
37
apps/invoice-hub/LICENSE
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2020-2022, Saleor Commerce
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
-------
|
||||||
|
|
||||||
|
Unless stated otherwise, artwork included in this distribution is licensed
|
||||||
|
under the Creative Commons Attribution 4.0 International License.
|
||||||
|
|
||||||
|
You can learn more about the permitted use by visiting
|
||||||
|
https://creativecommons.org/licenses/by/4.0/
|
114
apps/invoice-hub/README.md
Normal file
114
apps/invoice-hub/README.md
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
# Invoice app
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Saleor API 3.10 ❗️
|
||||||
|
|
||||||
|
## About Saleor Invoices app
|
||||||
|
|
||||||
|
- Generates invoice PDF for each order after invoice is requested (e.g. via dashboard/orders)
|
||||||
|
- Uploads PDF to Saleor built-in file storage
|
||||||
|
- Allows to configure shop billing data per each channel
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### With CLI
|
||||||
|
|
||||||
|
The easiest way to set up a Saleor app is by using the Saleor CLI.
|
||||||
|
|
||||||
|
[Saleor CLI](https://github.com/saleor/saleor-cli) is designed to save you from the repetitive chores around Saleor development, including creating Apps. It will take the burden of spawning new apps locally, connecting them with Saleor environments, and establishing a tunnel for local development in seconds.
|
||||||
|
|
||||||
|
[Full Saleor CLI reference](https://docs.saleor.io/docs/3.x/developer/cli)
|
||||||
|
|
||||||
|
If you don't have a (free developer) Saleor Cloud account, create one with the following command:
|
||||||
|
|
||||||
|
```
|
||||||
|
saleor register
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you're ready to create your first App:
|
||||||
|
|
||||||
|
```
|
||||||
|
saleor app create [your-app-name]
|
||||||
|
```
|
||||||
|
|
||||||
|
In this step, Saleor CLI will:
|
||||||
|
|
||||||
|
- clone this repository to the specified folder
|
||||||
|
- install dependencies
|
||||||
|
- ask you whether you'd like to install the app in the selected Saleor environment
|
||||||
|
- create `.env` file
|
||||||
|
- start the app in development mode
|
||||||
|
|
||||||
|
Having your app ready, the final thing you want to establish is a tunnel with your Saleor environment. Go to your app's directory first and run:
|
||||||
|
|
||||||
|
```
|
||||||
|
saleor app tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
Your local application should be available now to the outside world (Saleor instance) for accepting all the events via webhooks.
|
||||||
|
|
||||||
|
A quick note: the next time you come back to your project, it is enough to launch your app in a standard way (and then launch your tunnel as described earlier):
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without CLI
|
||||||
|
|
||||||
|
1. Install the dependencies by running:
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the local server with:
|
||||||
|
```
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Expose local environment using tunnel:
|
||||||
|
Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
|
||||||
|
|
||||||
|
4. Install aplication 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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- `vercel`: used by deployments from the Marketplace. It's single-tenant only and only supported by Vercel deployments done with Saleor CLI. Requires `SALEOR_REGISTER_APP_URL` and `SALEOR_DEPLOYMENT_TOKEN` environment variables to be set (handled automatically by the Saleor CLI)
|
||||||
|
|
||||||
|
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)
|
0
apps/invoice-hub/graphql/fragments/.gitkeep
Normal file
0
apps/invoice-hub/graphql/fragments/.gitkeep
Normal file
0
apps/invoice-hub/graphql/mutations/.gitkeep
Normal file
0
apps/invoice-hub/graphql/mutations/.gitkeep
Normal file
0
apps/invoice-hub/graphql/queries/.gitkeep
Normal file
0
apps/invoice-hub/graphql/queries/.gitkeep
Normal file
26531
apps/invoice-hub/graphql/schema.graphql
Normal file
26531
apps/invoice-hub/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/invoice-hub/graphql/subscriptions/.gitkeep
Normal file
0
apps/invoice-hub/graphql/subscriptions/.gitkeep
Normal file
5
apps/invoice-hub/next-env.d.ts
vendored
Normal file
5
apps/invoice-hub/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.
|
47
apps/invoice-hub/next.config.js
Normal file
47
apps/invoice-hub/next.config.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// This file sets a custom webpack configuration to use your Next.js app
|
||||||
|
// with Sentry.
|
||||||
|
// https://nextjs.org/docs/api-reference/next.config.js/introduction
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
const { withSentryConfig } = require("@sentry/nextjs");
|
||||||
|
|
||||||
|
const isSentryPropertiesInEnvironment =
|
||||||
|
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type import("next").NextConfig
|
||||||
|
*/
|
||||||
|
const moduleExports = {
|
||||||
|
transpilePackages: ["@saleor/shared"],
|
||||||
|
sentry: {
|
||||||
|
// Use `hidden-source-map` rather than `source-map` as the Webpack `devtool`
|
||||||
|
// for client-side builds. (This will be the default starting in
|
||||||
|
// `@sentry/nextjs` version 8.0.0.) See
|
||||||
|
// https://webpack.js.org/configuration/devtool/ and
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#use-hidden-source-map
|
||||||
|
// for more information.
|
||||||
|
hideSourceMaps: true,
|
||||||
|
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||||
|
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentryWebpackPluginOptions = {
|
||||||
|
// Additional config options for the Sentry Webpack plugin. Keep in mind that
|
||||||
|
// the following options are set automatically, and overriding them is not
|
||||||
|
// recommended:
|
||||||
|
// release, url, org, project, authToken, configFile, stripPrefix,
|
||||||
|
// urlPrefix, include, ignore
|
||||||
|
|
||||||
|
silent: true, // Suppresses all logs
|
||||||
|
// For all available options, see:
|
||||||
|
// https://github.com/getsentry/sentry-webpack-plugin#options.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make sure adding Sentry options is the last code to run before exporting, to
|
||||||
|
// ensure that your source maps include changes from all other Webpack plugins
|
||||||
|
module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions);
|
79
apps/invoice-hub/package.json
Normal file
79
apps/invoice-hub/package.json
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
{
|
||||||
|
"name": "saleor-app-invoice",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||||
|
"build": "pnpm generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||||
|
"generate": "graphql-codegen",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ci": "CI=true vitest --coverage"
|
||||||
|
},
|
||||||
|
"saleor": {
|
||||||
|
"schemaVersion": "3.10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@saleor/shared": "workspace:*",
|
||||||
|
"@material-ui/core": "^4.12.4",
|
||||||
|
"@material-ui/icons": "^4.11.3",
|
||||||
|
"@material-ui/lab": "4.0.0-alpha.61",
|
||||||
|
"@saleor/app-sdk": "0.26.0",
|
||||||
|
"@saleor/macaw-ui": "^0.7.2",
|
||||||
|
"@sentry/nextjs": "^7.31.1",
|
||||||
|
"@tanstack/react-query": "^4.19.1",
|
||||||
|
"@trpc/client": "^10.4.3",
|
||||||
|
"@trpc/next": "^10.4.3",
|
||||||
|
"@trpc/react-query": "^10.4.3",
|
||||||
|
"@trpc/server": "^10.4.3",
|
||||||
|
"@urql/exchange-auth": "^1.0.0",
|
||||||
|
"@urql/exchange-multipart-fetch": "^1.0.1",
|
||||||
|
"@web-std/file": "^3.0.2",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"graphql": "^16.6.0",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
|
"microinvoice": "^1.0.6",
|
||||||
|
"next": "13.1.3",
|
||||||
|
"pino": "^8.8.0",
|
||||||
|
"pino-pretty": "^9.1.1",
|
||||||
|
"puppeteer": "^19.4.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-hook-form": "^7.41.0",
|
||||||
|
"tiny-invariant": "^1.3.1",
|
||||||
|
"urql": "^3.0.3",
|
||||||
|
"usehooks-ts": "^2.9.1",
|
||||||
|
"zod": "^3.19.1"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"@types/node": "^18.8.1",
|
||||||
|
"@types/react": "^18.0.21",
|
||||||
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/rimraf": "^3.0.2",
|
||||||
|
"@vitejs/plugin-react": "^3.0.0",
|
||||||
|
"@vitest/coverage-c8": "^0.25.7",
|
||||||
|
"eslint": "8.25.0",
|
||||||
|
"eslint-config-next": "13.1.3",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"jsdom": "^20.0.3",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"typescript": "4.9.4",
|
||||||
|
"vite": "^4.0.0",
|
||||||
|
"vitest": "^0.25.7"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||||
|
"*.{js,ts,tsx,css,md,json}": "prettier --write"
|
||||||
|
}
|
||||||
|
}
|
7623
apps/invoice-hub/pnpm-lock.yaml
Normal file
7623
apps/invoice-hub/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
39
apps/invoice-hub/saleor-app.ts
Normal file
39
apps/invoice-hub/saleor-app.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL, VercelAPL } from "@saleor/app-sdk/APL";
|
||||||
|
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||||
|
|
||||||
|
const aplType = process.env.APL ?? "file";
|
||||||
|
|
||||||
|
let apl: APL;
|
||||||
|
|
||||||
|
switch (aplType) {
|
||||||
|
case "vercel":
|
||||||
|
apl = new VercelAPL();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "upstash":
|
||||||
|
apl = new UpstashAPL();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "file":
|
||||||
|
apl = new FileAPL();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "rest": {
|
||||||
|
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
|
||||||
|
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
|
||||||
|
}
|
||||||
|
|
||||||
|
apl = new SaleorCloudAPL({
|
||||||
|
resourceUrl: process.env.REST_APL_ENDPOINT,
|
||||||
|
token: process.env.REST_APL_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error("Invalid APL config, ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const saleorApp = new SaleorApp({
|
||||||
|
apl,
|
||||||
|
});
|
17
apps/invoice-hub/sentry.client.config.js
Normal file
17
apps/invoice-hub/sentry.client.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file configures the initialization of Sentry on the browser.
|
||||||
|
// The config you add here will be used whenever a page is visited.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
17
apps/invoice-hub/sentry.edge.config.js
Normal file
17
apps/invoice-hub/sentry.edge.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file configures the initialization of Sentry on the server.
|
||||||
|
// The config you add here will be used whenever middleware or an Edge route handles a request.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
17
apps/invoice-hub/sentry.server.config.js
Normal file
17
apps/invoice-hub/sentry.server.config.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file configures the initialization of Sentry on the server.
|
||||||
|
// The config you add here will be used whenever the server handles a request.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: SENTRY_DSN,
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
// ...
|
||||||
|
// Note: if you want to override the automatic release value, do not set a
|
||||||
|
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||||
|
// that it will also get attached to your source maps
|
||||||
|
});
|
16
apps/invoice-hub/src/fixtures/mock-address.ts
Normal file
16
apps/invoice-hub/src/fixtures/mock-address.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { SellerShopConfig } from "../modules/app-configuration/app-config";
|
||||||
|
|
||||||
|
export const getMockAddress = (): SellerShopConfig["address"] => {
|
||||||
|
return {
|
||||||
|
city: "Wrocław",
|
||||||
|
cityArea: "",
|
||||||
|
companyName: "Saleor",
|
||||||
|
country: "Poland",
|
||||||
|
countryArea: "Dolnoslaskie",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
postalCode: "12-123",
|
||||||
|
streetAddress1: "Techowa 7",
|
||||||
|
streetAddress2: "",
|
||||||
|
};
|
||||||
|
};
|
102
apps/invoice-hub/src/fixtures/mock-order.ts
Normal file
102
apps/invoice-hub/src/fixtures/mock-order.ts
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { OrderPayloadFragment, OrderStatus } from "../../generated/graphql";
|
||||||
|
|
||||||
|
export const mockOrder: OrderPayloadFragment = {
|
||||||
|
channel: {
|
||||||
|
slug: "default-channel",
|
||||||
|
},
|
||||||
|
shippingPrice: {
|
||||||
|
currency: "USD",
|
||||||
|
gross: {
|
||||||
|
amount: 1,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
amount: 1,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
tax: {
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shippingMethodName: "CyCreateVariants-1462",
|
||||||
|
number: "3991",
|
||||||
|
id: "T3JkZXI6OTFiZjM5ZDQtZjRiMC00M2QyLTgwMjEtZjVkMTMwNDVlMjkx",
|
||||||
|
billingAddress: {
|
||||||
|
id: "QWRkcmVzczoxNzE4Ng==",
|
||||||
|
country: {
|
||||||
|
country: "Poland",
|
||||||
|
code: "PL",
|
||||||
|
},
|
||||||
|
companyName: "Fajna firma lol",
|
||||||
|
cityArea: "",
|
||||||
|
countryArea: "",
|
||||||
|
streetAddress1: "street 1",
|
||||||
|
streetAddress2: "Street 2",
|
||||||
|
postalCode: "55-123",
|
||||||
|
phone: "+48690563008",
|
||||||
|
firstName: "MAdzia",
|
||||||
|
lastName: "Markusik",
|
||||||
|
city: "WRO",
|
||||||
|
},
|
||||||
|
created: "2022-12-02T15:05:56.637068+00:00",
|
||||||
|
fulfillments: [],
|
||||||
|
status: OrderStatus.Unfulfilled,
|
||||||
|
total: {
|
||||||
|
currency: "USD",
|
||||||
|
gross: {
|
||||||
|
amount: 207.15,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
amount: 206,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
tax: {
|
||||||
|
amount: 1.15,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
productName: "Tales of pirate kittycat",
|
||||||
|
variantName: "Signed: Yes / vinyl",
|
||||||
|
quantity: 2,
|
||||||
|
totalPrice: {
|
||||||
|
currency: "USD",
|
||||||
|
gross: {
|
||||||
|
amount: 200,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
amount: 200,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
tax: {
|
||||||
|
amount: 0,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
productName: "White Hoodie",
|
||||||
|
variantName: "10404946",
|
||||||
|
quantity: 1,
|
||||||
|
totalPrice: {
|
||||||
|
currency: "USD",
|
||||||
|
gross: {
|
||||||
|
amount: 6.15,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
amount: 5,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
tax: {
|
||||||
|
amount: 1.15,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
45
apps/invoice-hub/src/lib/graphql.ts
Normal file
45
apps/invoice-hub/src/lib/graphql.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { AuthConfig, authExchange } from "@urql/exchange-auth";
|
||||||
|
import { cacheExchange, createClient as urqlCreateClient, dedupExchange } from "urql";
|
||||||
|
|
||||||
|
import { multipartFetchExchange } from "@urql/exchange-multipart-fetch";
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
multipartFetchExchange,
|
||||||
|
],
|
||||||
|
});
|
19
apps/invoice-hub/src/lib/logger.ts
Normal file
19
apps/invoice-hub/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: process.env.APP_DEBUG ?? "silent",
|
||||||
|
transport:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createLogger = logger.child.bind(logger);
|
8
apps/invoice-hub/src/lib/no-ssr-wrapper.tsx
Normal file
8
apps/invoice-hub/src/lib/no-ssr-wrapper.tsx
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
|
||||||
|
|
||||||
|
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
18
apps/invoice-hub/src/modules/app-configuration/address.ts
Normal file
18
apps/invoice-hub/src/modules/app-configuration/address.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { SellerShopConfig } from "./app-config";
|
||||||
|
|
||||||
|
export const Address = {
|
||||||
|
createEmpty(): SellerShopConfig["address"] {
|
||||||
|
return {
|
||||||
|
city: "",
|
||||||
|
cityArea: "",
|
||||||
|
companyName: "",
|
||||||
|
country: "",
|
||||||
|
countryArea: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
postalCode: "",
|
||||||
|
streetAddress1: "",
|
||||||
|
streetAddress2: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { AppConfigContainer } from "./app-config-container";
|
||||||
|
import { AppConfig, SellerShopConfig } from "./app-config";
|
||||||
|
|
||||||
|
const getDefaultAddressData = (): SellerShopConfig["address"] => ({
|
||||||
|
city: "",
|
||||||
|
cityArea: "",
|
||||||
|
companyName: "Saleor",
|
||||||
|
country: "",
|
||||||
|
countryArea: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
postalCode: "",
|
||||||
|
streetAddress1: "",
|
||||||
|
streetAddress2: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AppConfigContainer", () => {
|
||||||
|
describe("Get address from config", () => {
|
||||||
|
it("Gets address if exists", () => {
|
||||||
|
expect(
|
||||||
|
AppConfigContainer.getChannelAddress({
|
||||||
|
shopConfigPerChannel: {
|
||||||
|
channel: {
|
||||||
|
address: getDefaultAddressData(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})("channel")
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyName: "Saleor",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Returns null if entire config is null", () => {
|
||||||
|
expect(AppConfigContainer.getChannelAddress(null)("channel")).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Set address to config per slug of the channel", () => {
|
||||||
|
it("Will create entire config object if initially was null", () => {
|
||||||
|
const newConfig = AppConfigContainer.setChannelAddress(null)("channel")(
|
||||||
|
getDefaultAddressData()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(newConfig).toEqual({
|
||||||
|
shopConfigPerChannel: expect.objectContaining({
|
||||||
|
channel: expect.objectContaining({
|
||||||
|
address: expect.objectContaining({ companyName: "Saleor" }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Will preserve another existing config for another channel after setting a new one", () => {
|
||||||
|
const config: AppConfig = {
|
||||||
|
shopConfigPerChannel: {
|
||||||
|
c1: {
|
||||||
|
address: {
|
||||||
|
...getDefaultAddressData(),
|
||||||
|
companyName: "Mirumee",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const newConfig = AppConfigContainer.setChannelAddress(config)("c2")(getDefaultAddressData());
|
||||||
|
|
||||||
|
expect(newConfig.shopConfigPerChannel.c1.address.companyName).toEqual("Mirumee");
|
||||||
|
expect(newConfig.shopConfigPerChannel.c2.address.companyName).toEqual("Saleor");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { AppConfig, SellerShopConfig } from "./app-config";
|
||||||
|
|
||||||
|
const getDefaultEmptyAddress = (): SellerShopConfig["address"] => ({
|
||||||
|
city: "",
|
||||||
|
cityArea: "",
|
||||||
|
companyName: "",
|
||||||
|
country: "",
|
||||||
|
countryArea: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
postalCode: "",
|
||||||
|
streetAddress1: "",
|
||||||
|
streetAddress2: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const getChannelAddress = (appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
|
||||||
|
try {
|
||||||
|
return appConfig?.shopConfigPerChannel[channelSlug].address ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChannelAddress =
|
||||||
|
(appConfig: AppConfig | null | undefined) =>
|
||||||
|
(channelSlug: string) =>
|
||||||
|
(address: SellerShopConfig["address"]) => {
|
||||||
|
const appConfigNormalized = structuredClone(appConfig) ?? { shopConfigPerChannel: {} };
|
||||||
|
|
||||||
|
appConfigNormalized.shopConfigPerChannel[channelSlug] ??= { address: getDefaultEmptyAddress() };
|
||||||
|
appConfigNormalized.shopConfigPerChannel[channelSlug].address = address;
|
||||||
|
|
||||||
|
return appConfigNormalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppConfigContainer = {
|
||||||
|
getChannelAddress,
|
||||||
|
setChannelAddress,
|
||||||
|
};
|
|
@ -0,0 +1,49 @@
|
||||||
|
import {describe, it, expect} from "vitest";
|
||||||
|
import {appConfigInputSchema} from "./app-config-input-schema";
|
||||||
|
import {AppConfig, SellerShopConfig} from "./app-config";
|
||||||
|
import {getMockAddress} from "../../fixtures/mock-address";
|
||||||
|
|
||||||
|
describe("appConfigInputSchema", () => {
|
||||||
|
it('Passes with no channels at all', () => {
|
||||||
|
expect(() =>
|
||||||
|
appConfigInputSchema.parse({
|
||||||
|
shopConfigPerChannel: {}
|
||||||
|
} satisfies AppConfig)
|
||||||
|
).not.to.throw()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Passes with all address fields empty', () => {
|
||||||
|
expect(() =>
|
||||||
|
appConfigInputSchema.parse({
|
||||||
|
shopConfigPerChannel: {
|
||||||
|
channel: {
|
||||||
|
address: {
|
||||||
|
city: "",
|
||||||
|
cityArea: "",
|
||||||
|
companyName: "",
|
||||||
|
country: "",
|
||||||
|
countryArea: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
postalCode: "",
|
||||||
|
streetAddress1: "",
|
||||||
|
streetAddress2: ""
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies AppConfig)
|
||||||
|
).not.to.throw()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Passes with partial address', () => {
|
||||||
|
expect(() =>
|
||||||
|
appConfigInputSchema.parse({
|
||||||
|
shopConfigPerChannel: {
|
||||||
|
channel: {
|
||||||
|
address: getMockAddress() }
|
||||||
|
}
|
||||||
|
} satisfies AppConfig)
|
||||||
|
).not.to.throw()
|
||||||
|
})
|
||||||
|
})
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const appConfigInputSchema = z.object({
|
||||||
|
shopConfigPerChannel: z.record(
|
||||||
|
z.object({
|
||||||
|
address: z.object({
|
||||||
|
/**
|
||||||
|
* min() to allow empty strings
|
||||||
|
*/
|
||||||
|
companyName: z.string().min(0),
|
||||||
|
cityArea: z.string().min(0),
|
||||||
|
countryArea: z.string().min(0),
|
||||||
|
streetAddress1: z.string().min(0),
|
||||||
|
streetAddress2: z.string().min(0),
|
||||||
|
postalCode: z.string().min(0),
|
||||||
|
firstName: z.string().min(0),
|
||||||
|
lastName: z.string().min(0),
|
||||||
|
city: z.string().min(0),
|
||||||
|
country: z.string().min(0),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
22
apps/invoice-hub/src/modules/app-configuration/app-config.ts
Normal file
22
apps/invoice-hub/src/modules/app-configuration/app-config.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { AddressFragment } from "../../../generated/graphql";
|
||||||
|
|
||||||
|
export interface SellerShopConfig {
|
||||||
|
address: {
|
||||||
|
companyName: string;
|
||||||
|
cityArea: string;
|
||||||
|
countryArea: string;
|
||||||
|
streetAddress1: string;
|
||||||
|
streetAddress2: string;
|
||||||
|
postalCode: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShopConfigPerChannelSlug = Record<string, SellerShopConfig>;
|
||||||
|
|
||||||
|
export type AppConfig = {
|
||||||
|
shopConfigPerChannel: ShopConfigPerChannelSlug;
|
||||||
|
};
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { router } from "../trpc/trpc-server";
|
||||||
|
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
|
import { PrivateMetadataAppConfigurator } from "./app-configurator";
|
||||||
|
import { createSettingsManager } from "./metadata-manager";
|
||||||
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
|
import { appConfigInputSchema } from "./app-config-input-schema";
|
||||||
|
import { GetAppConfigurationService } from "./get-app-configuration.service";
|
||||||
|
|
||||||
|
export const appConfigurationRouter = router({
|
||||||
|
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
|
||||||
|
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
|
logger.debug("appConfigurationRouter.fetch called");
|
||||||
|
|
||||||
|
return new GetAppConfigurationService({
|
||||||
|
apiClient: ctx.apiClient,
|
||||||
|
saleorApiUrl: ctx.saleorApiUrl,
|
||||||
|
}).getConfiguration();
|
||||||
|
}),
|
||||||
|
setAndReplace: protectedClientProcedure
|
||||||
|
.input(appConfigInputSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
|
||||||
|
|
||||||
|
logger.info(input, "appConfigurationRouter.setAndReplace called with input");
|
||||||
|
|
||||||
|
const appConfigurator = new PrivateMetadataAppConfigurator(
|
||||||
|
createSettingsManager(ctx.apiClient),
|
||||||
|
ctx.saleorApiUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
await appConfigurator.setConfig(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,31 @@
|
||||||
|
import { AppConfig } from "./app-config";
|
||||||
|
import { AppConfigContainer } from "./app-config-container";
|
||||||
|
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO Test
|
||||||
|
*/
|
||||||
|
export const FallbackAppConfig = {
|
||||||
|
createFallbackConfigFromExistingShopAndChannels(
|
||||||
|
channels: ChannelFragment[],
|
||||||
|
shopAddress: ShopInfoFragment | null
|
||||||
|
) {
|
||||||
|
return (channels ?? []).reduce<AppConfig>(
|
||||||
|
(state, channel) => {
|
||||||
|
return AppConfigContainer.setChannelAddress(state)(channel.slug)({
|
||||||
|
city: shopAddress?.companyAddress?.city ?? "",
|
||||||
|
cityArea: shopAddress?.companyAddress?.cityArea ?? "",
|
||||||
|
companyName: shopAddress?.companyAddress?.companyName ?? "",
|
||||||
|
country: shopAddress?.companyAddress?.country.country ?? "",
|
||||||
|
countryArea: shopAddress?.companyAddress?.countryArea ?? "",
|
||||||
|
firstName: shopAddress?.companyAddress?.firstName ?? "",
|
||||||
|
lastName: shopAddress?.companyAddress?.lastName ?? "",
|
||||||
|
postalCode: shopAddress?.companyAddress?.postalCode ?? "",
|
||||||
|
streetAddress1: shopAddress?.companyAddress?.streetAddress1 ?? "",
|
||||||
|
streetAddress2: shopAddress?.companyAddress?.streetAddress2 ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ shopConfigPerChannel: {} }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { PrivateMetadataAppConfigurator } from "./app-configurator";
|
||||||
|
import { createSettingsManager } from "./metadata-manager";
|
||||||
|
import { ChannelsFetcher } from "../channels/channels-fetcher";
|
||||||
|
import { ShopInfoFetcher } from "../shop-info/shop-info-fetcher";
|
||||||
|
import { FallbackAppConfig } from "./fallback-app-config";
|
||||||
|
import { Client } from "urql";
|
||||||
|
import { logger as pinoLogger } from "../../lib/logger";
|
||||||
|
|
||||||
|
// todo test
|
||||||
|
export class GetAppConfigurationService {
|
||||||
|
constructor(
|
||||||
|
private settings: {
|
||||||
|
apiClient: Client;
|
||||||
|
saleorApiUrl: string;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getConfiguration() {
|
||||||
|
const logger = pinoLogger.child({
|
||||||
|
service: "GetAppConfigurationService",
|
||||||
|
saleorApiUrl: this.settings.saleorApiUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { saleorApiUrl, apiClient } = this.settings;
|
||||||
|
|
||||||
|
const appConfigurator = new PrivateMetadataAppConfigurator(
|
||||||
|
createSettingsManager(apiClient),
|
||||||
|
saleorApiUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const savedAppConfig = (await appConfigurator.getConfig()) ?? null;
|
||||||
|
|
||||||
|
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
|
||||||
|
|
||||||
|
if (savedAppConfig) {
|
||||||
|
return savedAppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("App config not found in metadata. Will create default config now.");
|
||||||
|
|
||||||
|
const channelsFetcher = new ChannelsFetcher(apiClient);
|
||||||
|
const shopInfoFetcher = new ShopInfoFetcher(apiClient);
|
||||||
|
|
||||||
|
const [channels, shopAddress] = await Promise.all([
|
||||||
|
channelsFetcher.fetchChannels(),
|
||||||
|
shopInfoFetcher.fetchShopInfo(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.debug(channels, "Fetched channels");
|
||||||
|
logger.debug(shopAddress, "Fetched shop address");
|
||||||
|
|
||||||
|
const appConfig = FallbackAppConfig.createFallbackConfigFromExistingShopAndChannels(
|
||||||
|
channels ?? [],
|
||||||
|
shopAddress
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(appConfig, "Created a fallback AppConfig. Will save it.");
|
||||||
|
|
||||||
|
await appConfigurator.setConfig(appConfig);
|
||||||
|
|
||||||
|
logger.info("Saved initial AppConfig");
|
||||||
|
|
||||||
|
return appConfig;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,80 @@
|
||||||
|
import { SellerShopConfig } from "../app-config";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { TextField, TextFieldProps, Typography } from "@material-ui/core";
|
||||||
|
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import React from "react";
|
||||||
|
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
field: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
channelName: {
|
||||||
|
fontFamily: "monospace",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
channelSlug: string;
|
||||||
|
channelName: string;
|
||||||
|
channelID: string;
|
||||||
|
onSubmit(data: SellerShopConfig["address"]): Promise<void>;
|
||||||
|
initialData?: SellerShopConfig["address"] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddressForm = (props: Props) => {
|
||||||
|
const { register, handleSubmit } = useForm<SellerShopConfig["address"]>({
|
||||||
|
defaultValues: props.initialData ?? undefined,
|
||||||
|
});
|
||||||
|
const styles = useStyles();
|
||||||
|
const { appBridge } = useAppBridge();
|
||||||
|
|
||||||
|
const CommonFieldProps: TextFieldProps = {
|
||||||
|
className: styles.field,
|
||||||
|
fullWidth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChannelNameClick = () => {
|
||||||
|
appBridge?.dispatch(
|
||||||
|
actions.Redirect({
|
||||||
|
to: `/channels/${props.channelID}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data, event) => {
|
||||||
|
props.onSubmit(data);
|
||||||
|
})}
|
||||||
|
className={styles.form}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" paragraph>
|
||||||
|
Configure
|
||||||
|
<strong onClick={handleChannelNameClick} className={styles.channelName}>
|
||||||
|
{` ${props.channelName} `}
|
||||||
|
</strong>
|
||||||
|
channel:
|
||||||
|
</Typography>
|
||||||
|
<TextField label="Company Name" {...CommonFieldProps} {...register("companyName")} />
|
||||||
|
<TextField {...CommonFieldProps} label="First Name" {...register("firstName")} />
|
||||||
|
<TextField {...CommonFieldProps} label="Last Name" {...register("lastName")} />
|
||||||
|
<TextField label="Street Address 1" {...CommonFieldProps} {...register("streetAddress1")} />
|
||||||
|
<TextField {...CommonFieldProps} label="Street Address 2" {...register("streetAddress2")} />
|
||||||
|
<div style={{ display: "grid", gap: 20, gridTemplateColumns: "1fr 2fr" }}>
|
||||||
|
<TextField {...CommonFieldProps} label="Postal Code" {...register("postalCode")} />
|
||||||
|
<TextField {...CommonFieldProps} label="City" {...register("city")} />
|
||||||
|
</div>
|
||||||
|
<TextField {...CommonFieldProps} label="City Area" {...register("cityArea")} />
|
||||||
|
<TextField {...CommonFieldProps} label="Country" {...register("country")} />
|
||||||
|
<TextField label="Country Area" {...CommonFieldProps} {...register("countryArea")} />
|
||||||
|
<Button type="submit" fullWidth variant="primary">
|
||||||
|
Save channel configuration
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { LinearProgress, Paper, Typography } from "@material-ui/core";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { makeStyles } from "@saleor/macaw-ui";
|
||||||
|
import { AppConfigContainer } from "../app-config-container";
|
||||||
|
import { AddressForm } from "./address-form";
|
||||||
|
import { ChannelsList } from "./channels-list";
|
||||||
|
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => {
|
||||||
|
return {
|
||||||
|
header: { marginBottom: 20 },
|
||||||
|
grid: { display: "grid", gridTemplateColumns: "1fr 1fr", alignItems: "start", gap: 40 },
|
||||||
|
formContainer: {
|
||||||
|
top: 0,
|
||||||
|
position: "sticky",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChannelsConfiguration = () => {
|
||||||
|
const styles = useStyles();
|
||||||
|
const { appBridge } = useAppBridge();
|
||||||
|
|
||||||
|
const { data: configurationData, refetch: refetchConfig } =
|
||||||
|
trpcClient.appConfiguration.fetch.useQuery();
|
||||||
|
|
||||||
|
const channels = trpcClient.channels.fetch.useQuery();
|
||||||
|
|
||||||
|
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutate, error: saveError } = trpcClient.appConfiguration.setAndReplace.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
refetchConfig();
|
||||||
|
appBridge?.dispatch(
|
||||||
|
actions.Notification({
|
||||||
|
title: "Success",
|
||||||
|
text: "Saved app configuration",
|
||||||
|
status: "success",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channels.isSuccess) {
|
||||||
|
setActiveChannelSlug(channels.data![0].slug ?? null);
|
||||||
|
}
|
||||||
|
}, [channels.isSuccess, channels.data]);
|
||||||
|
|
||||||
|
const activeChannel = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return channels.data!.find((c) => c.slug === activeChannelSlug)!;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [channels.data, activeChannelSlug]);
|
||||||
|
|
||||||
|
if (channels.isLoading || !channels.data) {
|
||||||
|
return <LinearProgress />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeChannel) {
|
||||||
|
return <div>Error. No channel available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography className={styles.header} variant="subtitle1">
|
||||||
|
Configure seller details visible on the invoice
|
||||||
|
</Typography>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<ChannelsList
|
||||||
|
channels={channels.data}
|
||||||
|
activeChannelSlug={activeChannel.slug}
|
||||||
|
onChannelClick={setActiveChannelSlug}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeChannel && (
|
||||||
|
<Paper elevation={0} className={styles.formContainer}>
|
||||||
|
<AddressForm
|
||||||
|
channelID={activeChannel.id}
|
||||||
|
key={activeChannelSlug}
|
||||||
|
channelSlug={activeChannel.slug}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
const newConfig = AppConfigContainer.setChannelAddress(configurationData)(
|
||||||
|
activeChannel.slug
|
||||||
|
)(data);
|
||||||
|
|
||||||
|
mutate(newConfig);
|
||||||
|
}}
|
||||||
|
initialData={AppConfigContainer.getChannelAddress(configurationData)(
|
||||||
|
activeChannel.slug
|
||||||
|
)}
|
||||||
|
channelName={activeChannel?.name ?? activeChannelSlug}
|
||||||
|
/>
|
||||||
|
{saveError && <span>{saveError.message}</span>}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,71 @@
|
||||||
|
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",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
},
|
||||||
|
listItemActive: {
|
||||||
|
border: `2px solid ${theme.palette.primary.main}`,
|
||||||
|
},
|
||||||
|
cellSlug: {
|
||||||
|
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", "1fr"]}>
|
||||||
|
<OffsettedListHeader>
|
||||||
|
<OffsettedListItem className={styles.listItem}>
|
||||||
|
<OffsettedListItemCell>Channel name</OffsettedListItemCell>
|
||||||
|
<OffsettedListItemCell>Channel slug</OffsettedListItemCell>
|
||||||
|
</OffsettedListItem>
|
||||||
|
</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}</OffsettedListItemCell>
|
||||||
|
<OffsettedListItemCell className={styles.cellSlug}>
|
||||||
|
<Typography variant="caption">{c.slug}</Typography>
|
||||||
|
</OffsettedListItemCell>
|
||||||
|
</OffsettedListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</OffsettedListBody>
|
||||||
|
</OffsettedList>
|
||||||
|
);
|
||||||
|
};
|
27
apps/invoice-hub/src/modules/channels/channels-fetcher.ts
Normal file
27
apps/invoice-hub/src/modules/channels/channels-fetcher.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
17
apps/invoice-hub/src/modules/channels/channels.router.ts
Normal file
17
apps/invoice-hub/src/modules/channels/channels.router.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { createClient } from "../../lib/graphql";
|
||||||
|
import { router } from "../trpc/trpc-server";
|
||||||
|
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
|
import { ChannelsFetcher } from "./channels-fetcher";
|
||||||
|
import { ChannelFragment } from "../../../generated/graphql";
|
||||||
|
|
||||||
|
export const channelsRouter = router({
|
||||||
|
fetch: protectedClientProcedure.query(async ({ ctx, input }): Promise<ChannelFragment[]> => {
|
||||||
|
const client = createClient(ctx.saleorApiUrl, async () =>
|
||||||
|
Promise.resolve({ token: ctx.appToken })
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetcher = new ChannelsFetcher(client);
|
||||||
|
|
||||||
|
return fetcher.fetchChannels().then((channels) => channels ?? []);
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Client, gql } from "urql";
|
||||||
|
import { InvoiceCreateDocument } from "../../../generated/graphql";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
|
||||||
|
gql`
|
||||||
|
mutation InvoiceCreate($orderId: ID!, $invoiceInput: InvoiceCreateInput!) {
|
||||||
|
invoiceCreate(input: $invoiceInput, orderId: $orderId) {
|
||||||
|
errors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class InvoiceCreateNotifier {
|
||||||
|
constructor(private client: Client) {}
|
||||||
|
|
||||||
|
notifyInvoiceCreated(orderId: string, invoiceNumber: string, invoiceUrl: string) {
|
||||||
|
logger.info(
|
||||||
|
{ orderId, invoiceNumber, invoiceUrl },
|
||||||
|
"Will notify Saleor with invoiceCreate mutation"
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.client
|
||||||
|
.mutation(InvoiceCreateDocument, {
|
||||||
|
orderId,
|
||||||
|
invoiceInput: {
|
||||||
|
url: invoiceUrl,
|
||||||
|
number: invoiceNumber,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.toPromise()
|
||||||
|
.then((result) => {
|
||||||
|
logger.info(result.data, "invoiceCreate finished");
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { hashInvoiceFilename } from "./hash-invoice-filename";
|
||||||
|
|
||||||
|
vi.mock("crypto", () => ({
|
||||||
|
randomUUID() {
|
||||||
|
return "RANDOM_UUID_MOCK";
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("hashInvoiceFilename", () => {
|
||||||
|
it("Creates hashed invoice name", () => {
|
||||||
|
expect(hashInvoiceFilename("1/12/2022", "1234-xxxx-zzzz-1234")).toBe(
|
||||||
|
"1/12/2022_1234-xxxx-zzzz-1234_RANDOM_UUID_MOCK"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export const hashInvoiceFilename = (invoiceName: string, orderId: string) => {
|
||||||
|
return `${invoiceName}_${orderId}_${randomUUID()}`;
|
||||||
|
};
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveTempPdfFileLocation } from "./resolve-temp-pdf-file-location";
|
||||||
|
|
||||||
|
describe("resolveTempPdfFileLocation", () => {
|
||||||
|
it("generates path with encoded file name, in case of invoice name contains path segments", () => {
|
||||||
|
expect(resolveTempPdfFileLocation("12/12/2022-foobar.pdf")).toBe("12%2F12%2F2022-foobar.pdf");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { join } from "path";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
|
||||||
|
export const resolveTempPdfFileLocation = (fileName: string) => {
|
||||||
|
invariant(fileName.includes(".pdf"), `fileName should include pdf extension`);
|
||||||
|
|
||||||
|
return join(process.env.TEMP_PDF_STORAGE_DIR ?? "", encodeURIComponent(fileName));
|
||||||
|
};
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { OrderPayloadFragment } from "../../../generated/graphql";
|
||||||
|
import { SellerShopConfig } from "../app-configuration/app-config";
|
||||||
|
|
||||||
|
export interface InvoiceGenerator {
|
||||||
|
generate(input: {
|
||||||
|
order: OrderPayloadFragment;
|
||||||
|
invoiceNumber: string;
|
||||||
|
filename: string;
|
||||||
|
companyAddressData: SellerShopConfig["address"];
|
||||||
|
}): Promise<void>;
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { beforeEach, describe, it } from "vitest";
|
||||||
|
import { MicroinvoiceInvoiceGenerator } from "./microinvoice-invoice-generator";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import rimraf from "rimraf";
|
||||||
|
import { mockOrder } from "../../../fixtures/mock-order";
|
||||||
|
import { getMockAddress } from "../../../fixtures/mock-address";
|
||||||
|
|
||||||
|
const cleanup = () => rimraf.sync("test-invoice.pdf");
|
||||||
|
|
||||||
|
describe("MicroinvoiceInvoiceGenerator", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// afterEach(() => {
|
||||||
|
// cleanup();
|
||||||
|
// });
|
||||||
|
|
||||||
|
it("Generates invoice file from Order", async () => {
|
||||||
|
const instance = new MicroinvoiceInvoiceGenerator();
|
||||||
|
|
||||||
|
await instance.generate({
|
||||||
|
order: mockOrder,
|
||||||
|
filename: "test-invoice.pdf",
|
||||||
|
invoiceNumber: "test-123/123",
|
||||||
|
companyAddressData: getMockAddress(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return readFile("test-invoice.pdf");
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { InvoiceGenerator } from "../invoice-generator";
|
||||||
|
import { Order, OrderPayloadFragment } from "../../../../generated/graphql";
|
||||||
|
import { SellerShopConfig } from "../../app-configuration/app-config";
|
||||||
|
const Microinvoice = require("microinvoice");
|
||||||
|
|
||||||
|
export class MicroinvoiceInvoiceGenerator implements InvoiceGenerator {
|
||||||
|
constructor(
|
||||||
|
private settings = {
|
||||||
|
locale: "en-US",
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
async generate(input: {
|
||||||
|
order: OrderPayloadFragment;
|
||||||
|
invoiceNumber: string;
|
||||||
|
filename: string;
|
||||||
|
companyAddressData: SellerShopConfig["address"];
|
||||||
|
}): Promise<void> {
|
||||||
|
const { invoiceNumber, order, companyAddressData, filename } = input;
|
||||||
|
|
||||||
|
const microinvoiceInstance = new Microinvoice({
|
||||||
|
style: {
|
||||||
|
// header: {
|
||||||
|
// image: {
|
||||||
|
// path: "./examples/logo.png",
|
||||||
|
// width: 50,
|
||||||
|
// height: 19,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
invoice: {
|
||||||
|
name: `Invoice ${invoiceNumber}`,
|
||||||
|
|
||||||
|
header: [
|
||||||
|
{
|
||||||
|
label: "Order number",
|
||||||
|
value: order.number,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Date",
|
||||||
|
value: Intl.DateTimeFormat(this.settings.locale, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "medium",
|
||||||
|
}).format(new Date(order.created)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
currency: order.total.currency,
|
||||||
|
|
||||||
|
customer: [
|
||||||
|
{
|
||||||
|
label: "Customer",
|
||||||
|
value: [
|
||||||
|
`${order.billingAddress?.firstName} ${order.billingAddress?.lastName}`,
|
||||||
|
order.billingAddress?.companyName,
|
||||||
|
order.billingAddress?.phone,
|
||||||
|
`${order.billingAddress?.streetAddress1}`,
|
||||||
|
`${order.billingAddress?.streetAddress2}`,
|
||||||
|
`${order.billingAddress?.postalCode} ${order.billingAddress?.city}`,
|
||||||
|
order.billingAddress?.country.country,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: "Tax Identifier",
|
||||||
|
// value: "todo",
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
seller: [
|
||||||
|
{
|
||||||
|
label: "Seller",
|
||||||
|
value: [
|
||||||
|
`${companyAddressData.firstName} ${companyAddressData.lastName}`,
|
||||||
|
companyAddressData.companyName,
|
||||||
|
companyAddressData.streetAddress1,
|
||||||
|
companyAddressData.streetAddress2,
|
||||||
|
`${companyAddressData.postalCode} ${companyAddressData.city}`,
|
||||||
|
companyAddressData.cityArea,
|
||||||
|
companyAddressData.country,
|
||||||
|
companyAddressData.countryArea,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: "Tax Identifier",
|
||||||
|
// value: "todo",
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
legal: [
|
||||||
|
// {
|
||||||
|
// value: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
|
||||||
|
// weight: "bold",
|
||||||
|
// color: "primary",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// value: "sed do eiusmod tempor incididunt ut labore et dolore magna.",
|
||||||
|
// weight: "bold",
|
||||||
|
// color: "secondary",
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
details: {
|
||||||
|
header: [
|
||||||
|
{
|
||||||
|
value: "Description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Quantity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "Subtotal",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
parts: [
|
||||||
|
...order.lines.map((line) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: line.productName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: line.quantity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: line.totalPrice.gross.amount,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: order.shippingMethodName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: order.shippingPrice.gross.amount,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
total: [
|
||||||
|
{
|
||||||
|
label: "Total net",
|
||||||
|
value: order.total.net.amount,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tax value",
|
||||||
|
value: order.total.tax.amount,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total with tax",
|
||||||
|
value: order.total.gross.amount,
|
||||||
|
price: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return microinvoiceInstance.generate(filename);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { InvoiceNumberGenerationStrategy } from "./invoice-number-generator";
|
||||||
|
|
||||||
|
describe("InvoiceNumberGenerationStrategies", () => {
|
||||||
|
describe("localizedDate strategy", () => {
|
||||||
|
it("Generates proper name for US locale", () => {
|
||||||
|
const strategy = InvoiceNumberGenerationStrategy.localizedDate("en-US");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Javascript starts counting months from 0
|
||||||
|
*/
|
||||||
|
expect(strategy({ created: new Date(2020, 5, 1).toISOString() })).toBe("6/1/2020");
|
||||||
|
});
|
||||||
|
it("Generates proper name for PL locale", () => {
|
||||||
|
const strategy = InvoiceNumberGenerationStrategy.localizedDate("pl-PL");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Javascript starts counting months from 0
|
||||||
|
*/
|
||||||
|
expect(strategy({ created: new Date(2020, 5, 1).toISOString() })).toBe("1.06.2020");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,19 @@
|
||||||
|
import {OrderPayloadFragment} from "../../../generated/graphql";
|
||||||
|
|
||||||
|
interface IInvoiceNumberGenerationStrategy {
|
||||||
|
(order: OrderPayloadFragment): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InvoiceNumberGenerationStrategy = {
|
||||||
|
localizedDate: (locale: string) => (order: Pick<OrderPayloadFragment, 'created'>) => {
|
||||||
|
const orderCreatedDate = new Date(order.created);
|
||||||
|
|
||||||
|
return Intl.DateTimeFormat(locale,).format(orderCreatedDate)
|
||||||
|
}
|
||||||
|
} satisfies Record<string, (...args: any[]) => IInvoiceNumberGenerationStrategy>
|
||||||
|
|
||||||
|
export class InvoiceNumberGenerator {
|
||||||
|
generateFromOrder(order: OrderPayloadFragment, strategy: IInvoiceNumberGenerationStrategy): string {
|
||||||
|
return strategy(order)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface InvoiceUploader {
|
||||||
|
upload(filePath: string, asName: string): Promise<string>;
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { InvoiceUploader } from "./invoice-uploader";
|
||||||
|
import { Client, gql } from "urql";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { FileUploadMutation } from "../../../generated/graphql";
|
||||||
|
/**
|
||||||
|
* Polyfill file because Node doesnt have it yet
|
||||||
|
* https://github.com/nodejs/node/commit/916af4ef2d63fe936a369bcf87ee4f69ec7c67ce
|
||||||
|
*
|
||||||
|
* Use File instead of Blob so Saleor can understand name
|
||||||
|
*/
|
||||||
|
import { File } from "@web-std/file";
|
||||||
|
|
||||||
|
const fileUpload = gql`
|
||||||
|
mutation FileUpload($file: Upload!) {
|
||||||
|
fileUpload(file: $file) {
|
||||||
|
errors {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
uploadedFile {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class SaleorInvoiceUploader implements InvoiceUploader {
|
||||||
|
constructor(private client: Client) {}
|
||||||
|
|
||||||
|
upload(filePath: string, asName: string): Promise<string> {
|
||||||
|
return readFile(filePath).then((file) => {
|
||||||
|
const blob = new File([file], asName, { type: "application/pdf" });
|
||||||
|
|
||||||
|
return this.client
|
||||||
|
.mutation<FileUploadMutation>(fileUpload, {
|
||||||
|
file: blob,
|
||||||
|
})
|
||||||
|
.toPromise()
|
||||||
|
.then((r) => {
|
||||||
|
if (r.data?.fileUpload?.uploadedFile?.url) {
|
||||||
|
return r.data.fileUpload.uploadedFile.url;
|
||||||
|
} else {
|
||||||
|
throw new Error(r.error?.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
43
apps/invoice-hub/src/modules/shop-info/shop-info-fetcher.ts
Normal file
43
apps/invoice-hub/src/modules/shop-info/shop-info-fetcher.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
111
apps/invoice-hub/src/modules/trpc/protected-client-procedure.ts
Normal file
111
apps/invoice-hub/src/modules/trpc/protected-client-procedure.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { createClient } from "../../lib/graphql";
|
||||||
|
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
|
||||||
|
import { middleware, procedure } from "./trpc-server";
|
||||||
|
import { saleorApp } from "../../../saleor-app";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
|
logger.debug("validateClientToken middleware");
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
} 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
10
apps/invoice-hub/src/modules/trpc/trpc-app-router.ts
Normal file
10
apps/invoice-hub/src/modules/trpc/trpc-app-router.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { channelsRouter } from "../channels/channels.router";
|
||||||
|
import { router } from "./trpc-server";
|
||||||
|
import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
|
||||||
|
|
||||||
|
export const appRouter = router({
|
||||||
|
channels: channelsRouter,
|
||||||
|
appConfiguration: appConfigurationRouter,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
36
apps/invoice-hub/src/modules/trpc/trpc-client.ts
Normal file
36
apps/invoice-hub/src/modules/trpc/trpc-client.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { httpBatchLink } from "@trpc/client";
|
||||||
|
import { createTRPCNext } from "@trpc/next";
|
||||||
|
|
||||||
|
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||||
|
import { appBridgeInstance } from "../../pages/_app";
|
||||||
|
import { AppRouter } from "./trpc-app-router";
|
||||||
|
|
||||||
|
function getBaseUrl() {
|
||||||
|
if (typeof window !== "undefined") return "";
|
||||||
|
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||||
|
|
||||||
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trpcClient = createTRPCNext<AppRouter>({
|
||||||
|
config({ ctx }) {
|
||||||
|
return {
|
||||||
|
links: [
|
||||||
|
httpBatchLink({
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
headers() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Attach headers from app to client requests, so tRPC can add them to context
|
||||||
|
*/
|
||||||
|
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
|
||||||
|
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
// queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ssr: true,
|
||||||
|
});
|
13
apps/invoice-hub/src/modules/trpc/trpc-context.ts
Normal file
13
apps/invoice-hub/src/modules/trpc/trpc-context.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;
|
8
apps/invoice-hub/src/modules/trpc/trpc-server.ts
Normal file
8
apps/invoice-hub/src/modules/trpc/trpc-server.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { initTRPC } from "@trpc/server";
|
||||||
|
import { TrpcContext } from "./trpc-context";
|
||||||
|
|
||||||
|
const t = initTRPC.context<TrpcContext>().create();
|
||||||
|
|
||||||
|
export const router = t.router;
|
||||||
|
export const procedure = t.procedure;
|
||||||
|
export const middleware = t.middleware;
|
49
apps/invoice-hub/src/modules/ui/main-info.tsx
Normal file
49
apps/invoice-hub/src/modules/ui/main-info.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { Typography } from "@material-ui/core";
|
||||||
|
import { AlertBase, Button } from "@saleor/macaw-ui";
|
||||||
|
import React from "react";
|
||||||
|
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
|
||||||
|
const alertStyle = {
|
||||||
|
marginBottom: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainInfo = () => {
|
||||||
|
const { appBridge } = useAppBridge();
|
||||||
|
|
||||||
|
const openInNewTab = (url: string) => {
|
||||||
|
appBridge?.dispatch(
|
||||||
|
actions.Redirect({
|
||||||
|
to: url,
|
||||||
|
newContext: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertBase style={alertStyle} variant="info">
|
||||||
|
<Typography paragraph>Generate invoices for Orders in your shop</Typography>
|
||||||
|
<Typography paragraph>
|
||||||
|
Open any order and generate invoice. It will be uploaded and attached to the order. App will
|
||||||
|
use Seller data from configuration below
|
||||||
|
</Typography>
|
||||||
|
<div style={{ display: "flex", gap: 20 }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
openInNewTab("https://github.com/saleor/saleor-app-invoices");
|
||||||
|
}}
|
||||||
|
variant="tertiary"
|
||||||
|
>
|
||||||
|
Repository
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
openInNewTab("https://github.com/saleor/apps/discussions");
|
||||||
|
}}
|
||||||
|
variant="tertiary"
|
||||||
|
>
|
||||||
|
Request a feature
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertBase>
|
||||||
|
);
|
||||||
|
};
|
60
apps/invoice-hub/src/pages/_app.tsx
Normal file
60
apps/invoice-hub/src/pages/_app.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
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 { ThemeProvider as MacawUIThemeProvider } from "@saleor/macaw-ui";
|
||||||
|
import React, { PropsWithChildren, useEffect } from "react";
|
||||||
|
import { AppProps } from "next/app";
|
||||||
|
|
||||||
|
import GraphQLProvider from "../providers/GraphQLProvider";
|
||||||
|
import { ThemeSynchronizer } from "@saleor/shared";
|
||||||
|
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
||||||
|
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||||
|
|
||||||
|
const themeOverrides: Partial<Theme> = {
|
||||||
|
/**
|
||||||
|
* You can override MacawUI theme here
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }>
|
||||||
|
>;
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<GraphQLProvider>
|
||||||
|
<ThemeProvider overrides={themeOverrides} ssr={false}>
|
||||||
|
<ThemeSynchronizer />
|
||||||
|
<RoutePropagator />
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</GraphQLProvider>
|
||||||
|
</AppBridgeProvider>
|
||||||
|
</NoSSRWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default trpcClient.withTRPC(NextApp);
|
39
apps/invoice-hub/src/pages/_error.js
Normal file
39
apps/invoice-hub/src/pages/_error.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
|
||||||
|
*
|
||||||
|
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
|
||||||
|
* penultimate line in `CustomErrorComponent`.
|
||||||
|
*
|
||||||
|
* This page is loaded by Nextjs:
|
||||||
|
* - on the server, when data-fetching methods throw or reject
|
||||||
|
* - on the client, when `getInitialProps` throws or rejects
|
||||||
|
* - on the client, when a React lifecycle method throws or rejects, and it's
|
||||||
|
* caught by the built-in Nextjs error boundary
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
* - https://nextjs.org/docs/basic-features/data-fetching/overview
|
||||||
|
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
|
||||||
|
* - https://reactjs.org/docs/error-boundaries.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import NextErrorComponent from 'next/error';
|
||||||
|
|
||||||
|
const CustomErrorComponent = props => {
|
||||||
|
// If you're using a Nextjs version prior to 12.2.1, uncomment this to
|
||||||
|
// compensate for https://github.com/vercel/next.js/issues/8592
|
||||||
|
// Sentry.captureUnderscoreErrorException(props);
|
||||||
|
|
||||||
|
return <NextErrorComponent statusCode={props.statusCode} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
CustomErrorComponent.getInitialProps = async contextData => {
|
||||||
|
// In case this is running in a serverless function, await this in order to give Sentry
|
||||||
|
// time to send the error before the lambda exits
|
||||||
|
await Sentry.captureUnderscoreErrorException(contextData);
|
||||||
|
|
||||||
|
// This will contain the status code of the response
|
||||||
|
return NextErrorComponent.getInitialProps(contextData);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomErrorComponent;
|
22
apps/invoice-hub/src/pages/api/manifest.ts
Normal file
22
apps/invoice-hub/src/pages/api/manifest.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { AppManifest } from "@saleor/app-sdk/types";
|
||||||
|
|
||||||
|
import packageJson from "../../../package.json";
|
||||||
|
import { invoiceRequestedWebhook } from "./webhooks/invoice-requested";
|
||||||
|
|
||||||
|
export default createManifestHandler({
|
||||||
|
async manifestFactory(context) {
|
||||||
|
const manifest: AppManifest = {
|
||||||
|
name: packageJson.name,
|
||||||
|
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
|
||||||
|
appUrl: context.appBaseUrl,
|
||||||
|
permissions: ["MANAGE_ORDERS"],
|
||||||
|
id: "app.saleor.invoices",
|
||||||
|
version: packageJson.version,
|
||||||
|
webhooks: [invoiceRequestedWebhook.getWebhookManifest(context.appBaseUrl)],
|
||||||
|
extensions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
},
|
||||||
|
});
|
28
apps/invoice-hub/src/pages/api/register.ts
Normal file
28
apps/invoice-hub/src/pages/api/register.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
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,
|
||||||
|
/**
|
||||||
|
* Prohibit installation from Saleors other than specified by the regex.
|
||||||
|
* Regex source is ENV so if ENV is not set, all installations will be allowed.
|
||||||
|
*/
|
||||||
|
allowedSaleorUrls: [
|
||||||
|
(url) => {
|
||||||
|
if (allowedUrlsPattern) {
|
||||||
|
const regex = new RegExp(allowedUrlsPattern);
|
||||||
|
|
||||||
|
return regex.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
8
apps/invoice-hub/src/pages/api/trpc/[trpc].ts
Normal file
8
apps/invoice-hub/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,
|
||||||
|
});
|
225
apps/invoice-hub/src/pages/api/webhooks/invoice-requested.ts
Normal file
225
apps/invoice-hub/src/pages/api/webhooks/invoice-requested.ts
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||||
|
import { gql } from "urql";
|
||||||
|
import { saleorApp } from "../../../../saleor-app";
|
||||||
|
import {
|
||||||
|
InvoiceRequestedPayloadFragment,
|
||||||
|
OrderPayloadFragment,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
import { createClient } from "../../../lib/graphql";
|
||||||
|
import { SaleorInvoiceUploader } from "../../../modules/invoice-uploader/saleor-invoice-uploader";
|
||||||
|
import { InvoiceCreateNotifier } from "../../../modules/invoice-create-notifier/invoice-create-notifier";
|
||||||
|
import {
|
||||||
|
InvoiceNumberGenerationStrategy,
|
||||||
|
InvoiceNumberGenerator,
|
||||||
|
} from "../../../modules/invoice-number-generator/invoice-number-generator";
|
||||||
|
import { MicroinvoiceInvoiceGenerator } from "../../../modules/invoice-generator/microinvoice/microinvoice-invoice-generator";
|
||||||
|
import { hashInvoiceFilename } from "../../../modules/invoice-file-name/hash-invoice-filename";
|
||||||
|
import { resolveTempPdfFileLocation } from "../../../modules/invoice-file-name/resolve-temp-pdf-file-location";
|
||||||
|
import { appConfigurationRouter } from "../../../modules/app-configuration/app-configuration.router";
|
||||||
|
import { createLogger } from "../../../lib/logger";
|
||||||
|
import { GetAppConfigurationService } from "../../../modules/app-configuration/get-app-configuration.service";
|
||||||
|
|
||||||
|
const OrderPayload = gql`
|
||||||
|
fragment Address on Address {
|
||||||
|
id
|
||||||
|
country {
|
||||||
|
country
|
||||||
|
code
|
||||||
|
}
|
||||||
|
companyName
|
||||||
|
cityArea
|
||||||
|
countryArea
|
||||||
|
streetAddress1
|
||||||
|
streetAddress2
|
||||||
|
postalCode
|
||||||
|
phone
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
city
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment Money on Money {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment TaxedMoney on TaxedMoney {
|
||||||
|
currency
|
||||||
|
gross {
|
||||||
|
...Money
|
||||||
|
}
|
||||||
|
net {
|
||||||
|
...Money
|
||||||
|
}
|
||||||
|
tax {
|
||||||
|
...Money
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment OrderPayload on Order {
|
||||||
|
shippingPrice {
|
||||||
|
...TaxedMoney
|
||||||
|
}
|
||||||
|
shippingMethodName
|
||||||
|
number
|
||||||
|
|
||||||
|
id
|
||||||
|
billingAddress {
|
||||||
|
...Address
|
||||||
|
}
|
||||||
|
created
|
||||||
|
fulfillments {
|
||||||
|
created
|
||||||
|
}
|
||||||
|
status
|
||||||
|
number
|
||||||
|
total {
|
||||||
|
...TaxedMoney
|
||||||
|
}
|
||||||
|
channel {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
lines {
|
||||||
|
productName
|
||||||
|
variantName
|
||||||
|
quantity
|
||||||
|
totalPrice {
|
||||||
|
...TaxedMoney
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shippingPrice {
|
||||||
|
...TaxedMoney
|
||||||
|
}
|
||||||
|
shippingMethodName
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const InvoiceCreatedPayloadFragment = gql`
|
||||||
|
${OrderPayload}
|
||||||
|
|
||||||
|
fragment InvoiceRequestedPayload on InvoiceRequested {
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
order {
|
||||||
|
... on Order {
|
||||||
|
...OrderPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InvoiceRequestedSubscription = gql`
|
||||||
|
${InvoiceCreatedPayloadFragment}
|
||||||
|
|
||||||
|
subscription InvoiceRequested {
|
||||||
|
event {
|
||||||
|
...InvoiceRequestedPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const invoiceRequestedWebhook = new SaleorAsyncWebhook<InvoiceRequestedPayloadFragment>({
|
||||||
|
name: "Invoice requested",
|
||||||
|
webhookPath: "api/webhooks/invoice-requested",
|
||||||
|
asyncEvent: "INVOICE_REQUESTED",
|
||||||
|
apl: saleorApp.apl,
|
||||||
|
subscriptionQueryAst: InvoiceRequestedSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceNumberGenerator = new InvoiceNumberGenerator();
|
||||||
|
|
||||||
|
export const handler: NextWebhookApiHandler<InvoiceRequestedPayloadFragment> = async (
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
const { authData, payload, baseUrl } = context;
|
||||||
|
const logger = createLogger({ domain: authData.saleorApiUrl, url: baseUrl });
|
||||||
|
|
||||||
|
const order = payload.order;
|
||||||
|
|
||||||
|
logger.info({ orderId: order.id }, "Received event INVOICE_REQUESTED");
|
||||||
|
logger.debug(order, "Order from payload:");
|
||||||
|
|
||||||
|
const orderId = order.id;
|
||||||
|
/**
|
||||||
|
* TODO -> should generate from generation date or order date?
|
||||||
|
*/
|
||||||
|
const invoiceName = invoiceNumberGenerator.generateFromOrder(
|
||||||
|
order as OrderPayloadFragment,
|
||||||
|
InvoiceNumberGenerationStrategy.localizedDate("en-US") // todo connect locale -> where from?
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug({ invoiceName }, "Generated invoice name");
|
||||||
|
|
||||||
|
if (!authData) {
|
||||||
|
logger.error("Auth data not found");
|
||||||
|
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Could not find auth data. Check if app is installed.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = createClient(authData.saleorApiUrl, async () =>
|
||||||
|
Promise.resolve({ token: authData.token })
|
||||||
|
);
|
||||||
|
|
||||||
|
const hashedInvoiceName = hashInvoiceFilename(invoiceName, orderId);
|
||||||
|
logger.debug({ hashedInvoiceName });
|
||||||
|
|
||||||
|
const hashedInvoiceFileName = `${hashedInvoiceName}.pdf`;
|
||||||
|
const tempPdfLocation = resolveTempPdfFileLocation(hashedInvoiceFileName);
|
||||||
|
logger.debug({ tempPdfLocation });
|
||||||
|
|
||||||
|
const appConfig = await new GetAppConfigurationService({
|
||||||
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
|
apiClient: client,
|
||||||
|
}).getConfiguration();
|
||||||
|
|
||||||
|
await new MicroinvoiceInvoiceGenerator()
|
||||||
|
.generate({
|
||||||
|
order,
|
||||||
|
invoiceNumber: invoiceName,
|
||||||
|
filename: tempPdfLocation,
|
||||||
|
companyAddressData: appConfig.shopConfigPerChannel[order.channel.slug]?.address,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error(err, "Error generating invoice");
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Error generating invoice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploader = new SaleorInvoiceUploader(client);
|
||||||
|
|
||||||
|
const uploadedFileUrl = await uploader.upload(tempPdfLocation, `${invoiceName}.pdf`);
|
||||||
|
logger.info({ uploadedFileUrl }, "Uploaded file to storage, will notify Saleor now");
|
||||||
|
|
||||||
|
await new InvoiceCreateNotifier(client).notifyInvoiceCreated(
|
||||||
|
orderId,
|
||||||
|
invoiceName,
|
||||||
|
uploadedFileUrl
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: (e as any)?.message ?? "Error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Success");
|
||||||
|
|
||||||
|
return res.status(200).end();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default invoiceRequestedWebhook.createHandler(handler);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
41
apps/invoice-hub/src/pages/configuration.tsx
Normal file
41
apps/invoice-hub/src/pages/configuration.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { ChannelsConfiguration } from "../modules/app-configuration/ui/channels-configuration";
|
||||||
|
import { trpcClient } from "../modules/trpc/trpc-client";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { MainInfo } from "../modules/ui/main-info";
|
||||||
|
|
||||||
|
type Tab = "channels";
|
||||||
|
|
||||||
|
const ConfigurationPage: NextPage = () => {
|
||||||
|
const [activeTab, setActiveTab] = React.useState<Tab>("channels");
|
||||||
|
const channels = trpcClient.channels.fetch.useQuery();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (channels.isSuccess && channels.data.length === 0) {
|
||||||
|
router.push("/not-ready");
|
||||||
|
}
|
||||||
|
}, [channels.data, channels.isSuccess]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Saleor Invoices</h1>
|
||||||
|
<MainInfo />
|
||||||
|
|
||||||
|
{/* Enable if more config available */}
|
||||||
|
{/*<PageTabs*/}
|
||||||
|
{/* style={{ marginBottom: 20 }}*/}
|
||||||
|
{/* value={activeTab}*/}
|
||||||
|
{/* onChange={(e) => setActiveTab(e as Tab)}*/}
|
||||||
|
{/*>*/}
|
||||||
|
{/* <PageTab value="channels" label="Channels configuration" />*/}
|
||||||
|
{/*</PageTabs>*/}
|
||||||
|
{/*<Divider style={{ marginBottom: 20 }} />*/}
|
||||||
|
|
||||||
|
{activeTab === "channels" && <ChannelsConfiguration />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationPage;
|
27
apps/invoice-hub/src/pages/index.tsx
Normal file
27
apps/invoice-hub/src/pages/index.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { NextPage } from "next";
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useIsMounted } from "usehooks-ts";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const IndexPage: NextPage = () => {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
const { replace } = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMounted() && appBridgeState?.ready) {
|
||||||
|
replace("/configuration");
|
||||||
|
}
|
||||||
|
}, [isMounted, appBridgeState?.ready]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Saleor Invoices</h1>
|
||||||
|
<p>This is Saleor App that allows invoices generation</p>
|
||||||
|
<p>Install app in your Saleor instance and open in with Dashboard</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndexPage;
|
36
apps/invoice-hub/src/pages/not-ready.tsx
Normal file
36
apps/invoice-hub/src/pages/not-ready.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
const NotReadyPage = () => {
|
||||||
|
const { appBridge } = useAppBridge();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Saleor Invoices App</h1>
|
||||||
|
<AlertBase variant="error">
|
||||||
|
<Typography variant="h3" paragraph>
|
||||||
|
App can not be used
|
||||||
|
</Typography>
|
||||||
|
<Typography paragraph>
|
||||||
|
To configure Invoices 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;
|
21
apps/invoice-hub/src/providers/GraphQLProvider.tsx
Normal file
21
apps/invoice-hub/src/providers/GraphQLProvider.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import { Provider } from "urql";
|
||||||
|
|
||||||
|
import { createClient } from "../lib/graphql";
|
||||||
|
|
||||||
|
function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
|
||||||
|
if (!appBridgeState?.saleorApiUrl) {
|
||||||
|
return <div {...props}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(appBridgeState?.saleorApiUrl, async () =>
|
||||||
|
Promise.resolve({ token: appBridgeState?.token! })
|
||||||
|
);
|
||||||
|
|
||||||
|
return <Provider value={client} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GraphQLProvider;
|
BIN
apps/invoice-hub/src/public/favicon.ico
Normal file
BIN
apps/invoice-hub/src/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
4
apps/invoice-hub/src/public/vercel.svg
Normal file
4
apps/invoice-hub/src/public/vercel.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/invoice-hub/src/setup-tests.ts
Normal file
1
apps/invoice-hub/src/setup-tests.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export {};
|
22
apps/invoice-hub/src/styles/globals.css
Normal file
22
apps/invoice-hub/src/styles/globals.css
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
body {
|
||||||
|
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||||
|
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
|
color: #111;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f6f8fa;
|
||||||
|
border: 1px solid #eaeaea;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||||
|
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
code::before {
|
||||||
|
content: "$ ";
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
20
apps/invoice-hub/tsconfig.json
Normal file
20
apps/invoice-hub/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
16
apps/invoice-hub/vitest.config.ts
Normal file
16
apps/invoice-hub/vitest.config.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./src/setup-tests.ts",
|
||||||
|
css: false,
|
||||||
|
coverage: {
|
||||||
|
provider: "c8",
|
||||||
|
reporter: ["text-summary", "cobertura"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
3
apps/slack/.eslintrc
Normal file
3
apps/slack/.eslintrc
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": ["custom"]
|
||||||
|
}
|
50
apps/slack/.gitignore
vendored
Normal file
50
apps/slack/.gitignore
vendored
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.envfile
|
||||||
|
.saleor-app-auth.json
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.auth_token
|
||||||
|
|
||||||
|
#editor
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# GraphQL auto-generated
|
||||||
|
generated/
|
||||||
|
|
||||||
|
# Sentry
|
||||||
|
.sentryclirc
|
||||||
|
sentry.properties
|
20
apps/slack/.graphqlrc.yml
Normal file
20
apps/slack/.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/slack/.npmrc
Normal file
1
apps/slack/.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
strict-peer-dependencies=false
|
5
apps/slack/.prettierignore
Normal file
5
apps/slack/.prettierignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.next
|
||||||
|
saleor/api.tsx
|
||||||
|
pnpm-lock.yaml
|
||||||
|
graphql/schema.graphql
|
||||||
|
generated
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue