feat(admin, admin-ui, medusa-js, medusa-react, medusa): Support Admin Extensions (#4761)
Co-authored-by: Rares Stefan <948623+StephixOne@users.noreply.github.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
26c78bbc03
commit
f1a05f4725
10
.changeset/odd-dolls-sit.md
Normal file
10
.changeset/odd-dolls-sit.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/admin-ui": major
|
||||||
|
"@medusajs/admin": major
|
||||||
|
"medusa-payment-stripe": patch
|
||||||
|
"medusa-react": patch
|
||||||
|
"@medusajs/medusa-js": patch
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(admin, admin-ui, medusa, medusa-js, medusa-react, stripe-plugin): Support admin extensions
|
||||||
47
.eslintrc.js
47
.eslintrc.js
@@ -85,6 +85,7 @@ module.exports = {
|
|||||||
"./packages/medusa-payment-stripe/tsconfig.spec.json",
|
"./packages/medusa-payment-stripe/tsconfig.spec.json",
|
||||||
"./packages/medusa-payment-paypal/tsconfig.spec.json",
|
"./packages/medusa-payment-paypal/tsconfig.spec.json",
|
||||||
"./packages/admin-ui/tsconfig.json",
|
"./packages/admin-ui/tsconfig.json",
|
||||||
|
"./packages/admin-ui/tsconfig.spec.json",
|
||||||
"./packages/event-bus-local/tsconfig.spec.json",
|
"./packages/event-bus-local/tsconfig.spec.json",
|
||||||
"./packages/event-bus-redis/tsconfig.spec.json",
|
"./packages/event-bus-redis/tsconfig.spec.json",
|
||||||
"./packages/medusa-plugin-meilisearch/tsconfig.spec.json",
|
"./packages/medusa-plugin-meilisearch/tsconfig.spec.json",
|
||||||
@@ -94,6 +95,7 @@ module.exports = {
|
|||||||
"./packages/stock-location/tsconfig.spec.json",
|
"./packages/stock-location/tsconfig.spec.json",
|
||||||
"./packages/cache-redis/tsconfig.spec.json",
|
"./packages/cache-redis/tsconfig.spec.json",
|
||||||
"./packages/cache-inmemory/tsconfig.spec.json",
|
"./packages/cache-inmemory/tsconfig.spec.json",
|
||||||
|
"./packages/admin-ui/tsconfig.json",
|
||||||
"./packages/create-medusa-app/tsconfig.json",
|
"./packages/create-medusa-app/tsconfig.json",
|
||||||
"./packages/product/tsconfig.json",
|
"./packages/product/tsconfig.json",
|
||||||
],
|
],
|
||||||
@@ -141,6 +143,9 @@ module.exports = {
|
|||||||
sourceType: "module", // Allows for the use of imports
|
sourceType: "module", // Allows for the use of imports
|
||||||
project: "./packages/admin-ui/ui/tsconfig.json",
|
project: "./packages/admin-ui/ui/tsconfig.json",
|
||||||
},
|
},
|
||||||
|
globals: {
|
||||||
|
__BASE__: "readonly",
|
||||||
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
},
|
},
|
||||||
@@ -177,5 +182,47 @@ module.exports = {
|
|||||||
project: "./packages/admin/tsconfig.json",
|
project: "./packages/admin/tsconfig.json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"packages/medusa-payment-stripe/src/admin/**/*.ts",
|
||||||
|
"packages/medusa-payment-stripe/src/admin/**/*.tsx",
|
||||||
|
],
|
||||||
|
plugins: ["unused-imports"],
|
||||||
|
extends: [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
|
||||||
|
sourceType: "module", // Allows for the use of imports
|
||||||
|
project: "./packages/medusa-payment-stripe/tsconfig.admin.json",
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"new-cap": "off",
|
||||||
|
"require-jsdoc": "off",
|
||||||
|
"valid-jsdoc": "off",
|
||||||
|
"no-unused-expressions": "off",
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
vars: "all",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
args: "after-used",
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
3
packages/admin-ui/.gitignore
vendored
3
packages/admin-ui/.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
.vercel
|
.vercel
|
||||||
/ui/preview
|
/ui/preview
|
||||||
|
/ui/src/extensions
|
||||||
13
packages/admin-ui/jest.config.js
Normal file
13
packages/admin-ui/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
globals: {
|
||||||
|
"ts-jest": {
|
||||||
|
tsConfig: "tsconfig.spec.json",
|
||||||
|
isolatedModules: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]s?$": "ts-jest",
|
||||||
|
},
|
||||||
|
testEnvironment: `node`,
|
||||||
|
moduleFileExtensions: [`js`, `ts`],
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@
|
|||||||
"directory": "packages/admin-ui"
|
"directory": "packages/admin-ui"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": {
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
},
|
||||||
"./ui": "./ui",
|
"./ui": "./ui",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
@@ -20,14 +23,20 @@
|
|||||||
"ui"
|
"ui"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite -c vite.config.dev.ts --port 7001",
|
"test": "jest --runInBand --forceExit -- ./src/**/__tests__/**/*.ts",
|
||||||
"build": "tsc --build",
|
"create:dev:entry": "node ./scripts/create-dev-entry.js",
|
||||||
"test:ui": "vitest --config vite.config.dev.ts",
|
"dev": "yarn create:dev:entry && webpack serve --mode=development --config ./webpack.config.dev.ts --progress profile",
|
||||||
"test:ui:once": "vitest --config vite.config.dev.ts --run",
|
"analyze:bundle": "ANALYZE_BUNDLE=true webpack --config ./webpack.config.dev.ts",
|
||||||
"test": "echo \"Tests disabled temporarily\""
|
"analyze:deps": "ANALYZE_DEPS=true webpack serve --config ./webpack.config.dev.ts --progress profile",
|
||||||
|
"build": "tsup"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/parser": "7.22.5",
|
||||||
|
"@babel/traverse": "7.22.5",
|
||||||
"@hookform/error-message": "^2.0.1",
|
"@hookform/error-message": "^2.0.1",
|
||||||
|
"@medusajs/ui": "0.0.0-snapshot-20230816112538",
|
||||||
|
"@medusajs/ui-preset": "0.0.0-snapshot-20230816112538",
|
||||||
|
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||||
"@radix-ui/react-accordion": "^1.0.1",
|
"@radix-ui/react-accordion": "^1.0.1",
|
||||||
"@radix-ui/react-avatar": "^1.0.1",
|
"@radix-ui/react-avatar": "^1.0.1",
|
||||||
"@radix-ui/react-collapsible": "^1.0.1",
|
"@radix-ui/react-collapsible": "^1.0.1",
|
||||||
@@ -40,26 +49,37 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.1",
|
"@radix-ui/react-switch": "^1.0.1",
|
||||||
"@radix-ui/react-tooltip": "^1.0.3",
|
"@radix-ui/react-tooltip": "^1.0.3",
|
||||||
"@segment/analytics-next": "^1.51.1",
|
"@segment/analytics-next": "^1.51.1",
|
||||||
|
"@svgr/webpack": "^8.0.1",
|
||||||
|
"@swc/core": "^1.3.61",
|
||||||
"@tailwindcss/forms": "^0.5.3",
|
"@tailwindcss/forms": "^0.5.3",
|
||||||
"@tailwindcss/line-clamp": "^0.4.2",
|
|
||||||
"@tanstack/react-query": "4.22.0",
|
"@tanstack/react-query": "4.22.0",
|
||||||
"@tanstack/react-table": "^8.7.9",
|
"@tanstack/react-table": "^8.7.9",
|
||||||
"@vitejs/plugin-react": "^3.1.0",
|
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
|
"chokidar": "^3.5.3",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"copy-to-clipboard": "^3.3.1",
|
"copy-to-clipboard": "^3.3.1",
|
||||||
|
"css-loader": "^6.8.1",
|
||||||
"emoji-picker-react": "^4.4.3",
|
"emoji-picker-react": "^4.4.3",
|
||||||
"framer-motion": "^9.1.6",
|
"framer-motion": "^9.1.6",
|
||||||
"medusa-react": "^9.0.3",
|
"html-webpack-plugin": "^5.5.1",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"medusa-react": "*",
|
||||||
|
"mini-css-extract-plugin": "^2.7.6",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
|
"postcss-loader": "^7.3.2",
|
||||||
|
"postcss-preset-env": "^8.4.1",
|
||||||
|
"prism-react-renderer": "^2.0.4",
|
||||||
|
"process": "^0.11.10",
|
||||||
"query-string": "^8.1.0",
|
"query-string": "^8.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-collapsible": "^2.8.3",
|
"react-collapsible": "^2.8.3",
|
||||||
"react-country-flag": "^3.0.2",
|
"react-country-flag": "^3.0.2",
|
||||||
"react-currency-input-field": "^3.6.8",
|
"react-currency-input-field": "^3.6.8",
|
||||||
"react-datepicker": "^4.8.0",
|
"react-datepicker": "^4.8.0",
|
||||||
|
"react-dev-utils": "^12.0.1",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -71,30 +91,39 @@
|
|||||||
"react-json-tree": "^0.17.0",
|
"react-json-tree": "^0.17.0",
|
||||||
"react-jwt": "^1.1.4",
|
"react-jwt": "^1.1.4",
|
||||||
"react-nestable": "^2.0.0",
|
"react-nestable": "^2.0.0",
|
||||||
|
"react-refresh": "^0.14.0",
|
||||||
"react-router-dom": "6.8.0",
|
"react-router-dom": "6.8.0",
|
||||||
"react-select": "^5.5.4",
|
"react-select": "^5.5.4",
|
||||||
"react-table": "^7.7.0",
|
"react-table": "^7.7.0",
|
||||||
|
"source-map-loader": "^4.0.1",
|
||||||
|
"style-loader": "^3.3.3",
|
||||||
|
"swc-loader": "^0.2.3",
|
||||||
|
"swc-minify-webpack-plugin": "^2.1.1",
|
||||||
"tailwindcss": "3.2.2",
|
"tailwindcss": "3.2.2",
|
||||||
"tailwindcss-radix": "^2.7.0",
|
"tailwindcss-radix": "^2.7.0",
|
||||||
|
"ts-dedent": "^2.2.0",
|
||||||
"type-fest": "^3.6.0",
|
"type-fest": "^3.6.0",
|
||||||
"vite": "^4.1.4"
|
"webpack": "^5.84.1",
|
||||||
},
|
"webpack-dev-server": "4.15.0",
|
||||||
"peerDependencies": {
|
"webpackbar": "^5.0.2"
|
||||||
"@medusajs/medusa": "^1.12.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@medusajs/medusa": "^1.13.1",
|
"@babel/types": "7.22.5",
|
||||||
"@medusajs/types": "^1.10.1",
|
"@medusajs/medusa": "*",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@medusajs/types": "*",
|
||||||
"@testing-library/react": "^14.0.0",
|
|
||||||
"@testing-library/user-event": "^14.4.3",
|
|
||||||
"@types/pluralize": "^0.0.29",
|
"@types/pluralize": "^0.0.29",
|
||||||
"@types/react": "^18.0.27",
|
"@types/react": "^18.0.27",
|
||||||
"@types/react-datepicker": "^4.10.0",
|
"@types/react-datepicker": "^4.10.0",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/react-table": "^7.7.9",
|
"@types/react-table": "^7.7.9",
|
||||||
|
"duplicate-dependencies-webpack-plugin": "^1.0.2",
|
||||||
|
"jest": "25.5.4",
|
||||||
|
"ts-jest": "25.5.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsup": "6.7.0",
|
||||||
"typescript": "^4.9.3",
|
"typescript": "^4.9.3",
|
||||||
"vitest": "^0.28.5"
|
"webpack-bundle-analyzer": "^4.9.0",
|
||||||
|
"webpack-cli": "^5.1.1"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@3.2.1"
|
"packageManager": "yarn@3.2.1"
|
||||||
}
|
}
|
||||||
|
|||||||
23
packages/admin-ui/scripts/create-dev-entry.js
Normal file
23
packages/admin-ui/scripts/create-dev-entry.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const fse = require("fs-extra")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
function createDevEntryFile() {
|
||||||
|
const devEntryContent = `
|
||||||
|
const extensions = []
|
||||||
|
|
||||||
|
export default extensions
|
||||||
|
`
|
||||||
|
|
||||||
|
const devEntryPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"ui",
|
||||||
|
"src",
|
||||||
|
"extensions",
|
||||||
|
"_main-entry.ts"
|
||||||
|
)
|
||||||
|
|
||||||
|
fse.outputFileSync(devEntryPath, devEntryContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
createDevEntryFile()
|
||||||
16
packages/admin-ui/src/client/index.ts
Normal file
16
packages/admin-ui/src/client/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export { forbiddenRoutes } from "../../ui/src/constants/forbidden-routes"
|
||||||
|
export { injectionZones } from "../../ui/src/constants/injection-zones"
|
||||||
|
export type {
|
||||||
|
Extension,
|
||||||
|
ForbiddenRoute,
|
||||||
|
InjectionZone,
|
||||||
|
RouteConfig,
|
||||||
|
RouteExtension,
|
||||||
|
RouteProps,
|
||||||
|
SettingConfig,
|
||||||
|
SettingExtension,
|
||||||
|
SettingProps,
|
||||||
|
WidgetConfig,
|
||||||
|
WidgetExtension,
|
||||||
|
WidgetProps,
|
||||||
|
} from "../../ui/src/types/extensions"
|
||||||
@@ -1,42 +1,2 @@
|
|||||||
import dns from "dns"
|
export * from "./client"
|
||||||
import fse from "fs-extra"
|
export * from "./node"
|
||||||
import { resolve } from "path"
|
|
||||||
import vite from "vite"
|
|
||||||
import { AdminBuildConfig } from "./types"
|
|
||||||
import { AdminDevConfig } from "./types/dev"
|
|
||||||
import { getCustomViteConfig, getCustomViteDevConfig } from "./utils"
|
|
||||||
|
|
||||||
async function build(options?: AdminBuildConfig) {
|
|
||||||
const config = getCustomViteConfig(options)
|
|
||||||
|
|
||||||
await vite.build(config).catch((_err) => {
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
await fse.writeJSON(
|
|
||||||
resolve(config.build.outDir, "build-manifest.json"),
|
|
||||||
options
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function watch() {
|
|
||||||
throw new Error("Not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clean() {
|
|
||||||
throw new Error("Not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dev(options: AdminDevConfig) {
|
|
||||||
// Resolve localhost for Node v16 and older.
|
|
||||||
// @see https://vitejs.dev/config/server-options.html#server-host.
|
|
||||||
dns.setDefaultResultOrder("verbatim")
|
|
||||||
|
|
||||||
const server = await vite.createServer(getCustomViteDevConfig(options))
|
|
||||||
await server.listen()
|
|
||||||
|
|
||||||
server.printUrls()
|
|
||||||
}
|
|
||||||
|
|
||||||
export { build, dev, watch, clean }
|
|
||||||
export type { AdminBuildConfig, AdminDevConfig }
|
|
||||||
|
|||||||
58
packages/admin-ui/src/node/actions/build.ts
Normal file
58
packages/admin-ui/src/node/actions/build.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import webpack, { WebpackError } from "webpack"
|
||||||
|
import { BuildArgs } from "../types"
|
||||||
|
import { logger } from "../utils"
|
||||||
|
import { createCacheDir } from "../utils/create-cache-dir"
|
||||||
|
import { getCustomWebpackConfig } from "../webpack"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the admin UI.
|
||||||
|
*/
|
||||||
|
export async function build({
|
||||||
|
appDir,
|
||||||
|
buildDir,
|
||||||
|
plugins,
|
||||||
|
options,
|
||||||
|
reporting = "fancy",
|
||||||
|
}: BuildArgs) {
|
||||||
|
await createCacheDir({ appDir, plugins })
|
||||||
|
|
||||||
|
const cacheDir = path.resolve(appDir, ".cache")
|
||||||
|
const entry = path.resolve(cacheDir, "admin", "src", "main.tsx")
|
||||||
|
const dest = path.resolve(appDir, buildDir)
|
||||||
|
const env = "production"
|
||||||
|
|
||||||
|
const config = await getCustomWebpackConfig(appDir, {
|
||||||
|
entry,
|
||||||
|
dest,
|
||||||
|
cacheDir,
|
||||||
|
env,
|
||||||
|
options,
|
||||||
|
reporting,
|
||||||
|
})
|
||||||
|
|
||||||
|
const compiler = webpack(config)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
compiler.run((err: WebpackError, stats) => {
|
||||||
|
if (err) {
|
||||||
|
if (err.details) {
|
||||||
|
logger.error(err.details)
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = stats.toJson()
|
||||||
|
|
||||||
|
if (stats.hasErrors()) {
|
||||||
|
logger.error(JSON.stringify(info.errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve({
|
||||||
|
stats,
|
||||||
|
warnings: info.warnings,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
18
packages/admin-ui/src/node/actions/clean.ts
Normal file
18
packages/admin-ui/src/node/actions/clean.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
type CleanArgs = {
|
||||||
|
appDir: string
|
||||||
|
outDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans the build directory and cache directory.
|
||||||
|
*/
|
||||||
|
export async function clean({ appDir, outDir }: CleanArgs) {
|
||||||
|
const cacheDir = path.resolve(appDir, ".cache", "admin")
|
||||||
|
const buildDir = path.resolve(appDir, outDir)
|
||||||
|
|
||||||
|
await fse.remove(buildDir)
|
||||||
|
await fse.remove(cacheDir)
|
||||||
|
}
|
||||||
103
packages/admin-ui/src/node/actions/develop.ts
Normal file
103
packages/admin-ui/src/node/actions/develop.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import path from "node:path"
|
||||||
|
import openBrowser from "react-dev-utils/openBrowser"
|
||||||
|
import webpack from "webpack"
|
||||||
|
import WebpackDevDerver, {
|
||||||
|
Configuration as DevServerConfiguration,
|
||||||
|
} from "webpack-dev-server"
|
||||||
|
|
||||||
|
import { DevelopArgs } from "../types"
|
||||||
|
import { logger, watchLocalAdminFolder } from "../utils"
|
||||||
|
import { createCacheDir } from "../utils/create-cache-dir"
|
||||||
|
import { getCustomWebpackConfig } from "../webpack"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a development server for the admin UI.
|
||||||
|
*/
|
||||||
|
export async function develop({
|
||||||
|
appDir,
|
||||||
|
buildDir,
|
||||||
|
plugins,
|
||||||
|
options = {
|
||||||
|
path: "/",
|
||||||
|
backend: "http://localhost:9000",
|
||||||
|
develop: {
|
||||||
|
open: true,
|
||||||
|
port: 7001,
|
||||||
|
logLevel: "error",
|
||||||
|
stats: "normal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}: DevelopArgs) {
|
||||||
|
const { cacheDir } = await createCacheDir({
|
||||||
|
appDir,
|
||||||
|
plugins,
|
||||||
|
})
|
||||||
|
|
||||||
|
const entry = path.resolve(cacheDir, "admin", "src", "main.tsx")
|
||||||
|
const dest = path.resolve(appDir, buildDir)
|
||||||
|
const env = "development"
|
||||||
|
|
||||||
|
const config = await getCustomWebpackConfig(appDir, {
|
||||||
|
entry,
|
||||||
|
dest,
|
||||||
|
cacheDir,
|
||||||
|
env,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
|
||||||
|
const compiler = webpack({
|
||||||
|
...config,
|
||||||
|
infrastructureLogging: { level: options.develop.logLevel },
|
||||||
|
stats: options.develop.stats === "normal" ? "errors-only" : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const devServerArgs: DevServerConfiguration = {
|
||||||
|
port: options.develop.port,
|
||||||
|
client: {
|
||||||
|
logging: "none",
|
||||||
|
overlay: {
|
||||||
|
errors: true,
|
||||||
|
warnings: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
open: false,
|
||||||
|
onListening: options.develop.open
|
||||||
|
? function (devServer) {
|
||||||
|
if (!devServer) {
|
||||||
|
logger.warn("Failed to open browser.")
|
||||||
|
}
|
||||||
|
|
||||||
|
openBrowser(
|
||||||
|
`http://localhost:${options.develop.port}${
|
||||||
|
options.path ? options.path : ""
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
devMiddleware: {
|
||||||
|
publicPath: options.path,
|
||||||
|
stats: options.develop.stats === "normal" ? false : undefined,
|
||||||
|
},
|
||||||
|
historyApiFallback: {
|
||||||
|
index: options.path,
|
||||||
|
disableDotRule: true,
|
||||||
|
},
|
||||||
|
hot: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new WebpackDevDerver(devServerArgs, compiler)
|
||||||
|
|
||||||
|
const runServer = async () => {
|
||||||
|
logger.info(
|
||||||
|
`Started development server on http://localhost:${options.develop.port}${
|
||||||
|
options.path ? options.path : ""
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await server.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
await runServer()
|
||||||
|
|
||||||
|
await watchLocalAdminFolder(appDir, cacheDir, plugins)
|
||||||
|
}
|
||||||
5
packages/admin-ui/src/node/actions/index.ts
Normal file
5
packages/admin-ui/src/node/actions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { build } from "./build"
|
||||||
|
import { clean } from "./clean"
|
||||||
|
import { develop } from "./develop"
|
||||||
|
|
||||||
|
export { clean, build, develop }
|
||||||
13
packages/admin-ui/src/node/constants.ts
Normal file
13
packages/admin-ui/src/node/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const ALIASED_PACKAGES = [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react-router-dom",
|
||||||
|
"react-dnd",
|
||||||
|
"react-dnd-html5-backend",
|
||||||
|
"react-select",
|
||||||
|
"react-helmet-async",
|
||||||
|
"@tanstack/react-query",
|
||||||
|
"@tanstack/react-table",
|
||||||
|
"@emotion/react",
|
||||||
|
"medusa-react",
|
||||||
|
] as const
|
||||||
11
packages/admin-ui/src/node/index.ts
Normal file
11
packages/admin-ui/src/node/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { build, clean, develop } from "./actions"
|
||||||
|
export { ALIASED_PACKAGES } from "./constants"
|
||||||
|
export type { AdminOptions, DevelopArgs } from "./types"
|
||||||
|
export {
|
||||||
|
findAllValidRoutes,
|
||||||
|
findAllValidSettings,
|
||||||
|
findAllValidWidgets,
|
||||||
|
logger,
|
||||||
|
normalizePath,
|
||||||
|
} from "./utils"
|
||||||
|
export { withCustomWebpackConfig } from "./webpack"
|
||||||
80
packages/admin-ui/src/node/types.ts
Normal file
80
packages/admin-ui/src/node/types.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Configuration } from "webpack"
|
||||||
|
|
||||||
|
export type DevelopOptions = {
|
||||||
|
/**
|
||||||
|
* Determines whether the development server should open the admin dashboard
|
||||||
|
* in the browser.
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
open?: boolean
|
||||||
|
/**
|
||||||
|
* The port the development server should run on.
|
||||||
|
* @default 7001
|
||||||
|
* */
|
||||||
|
port?: number
|
||||||
|
/**
|
||||||
|
* Determines the log level of the development server.
|
||||||
|
* @default "error"
|
||||||
|
*/
|
||||||
|
logLevel?: "error" | "none" | "warn" | "info" | "log" | "verbose"
|
||||||
|
/**
|
||||||
|
* Determines the verbosity of the development server.
|
||||||
|
* @default "normal"
|
||||||
|
*/
|
||||||
|
stats?: "normal" | "debug"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminOptions = {
|
||||||
|
/**
|
||||||
|
* The URL of your Medusa instance.
|
||||||
|
*
|
||||||
|
* This option will only be used if `serve` is `false`.
|
||||||
|
*/
|
||||||
|
backend?: string
|
||||||
|
/**
|
||||||
|
* The path to the admin dashboard. The path must be in the format of `/<path>`.
|
||||||
|
* The chosen path cannot be one of the reserved paths: "admin", "store".
|
||||||
|
* @default "/app"
|
||||||
|
*/
|
||||||
|
path?: string
|
||||||
|
/**
|
||||||
|
* The directory to output the build to. By default the plugin will build
|
||||||
|
* the dashboard to the `build` directory in the root folder.
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
|
outDir?: string
|
||||||
|
/**
|
||||||
|
* Options for the development server.
|
||||||
|
*/
|
||||||
|
develop?: DevelopOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildReporting = "minimal" | "fancy"
|
||||||
|
|
||||||
|
export type WebpackConfigArgs = {
|
||||||
|
entry: string
|
||||||
|
dest: string
|
||||||
|
cacheDir: string
|
||||||
|
env: "development" | "production"
|
||||||
|
options?: AdminOptions
|
||||||
|
template?: string
|
||||||
|
reporting?: BuildReporting
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomWebpackConfigArgs = WebpackConfigArgs & {
|
||||||
|
devServer?: Configuration["devServer"]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseArgs = {
|
||||||
|
appDir: string
|
||||||
|
buildDir: string
|
||||||
|
plugins?: string[]
|
||||||
|
options?: AdminOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildArgs = BaseArgs & {
|
||||||
|
reporting?: BuildReporting
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DevelopArgs = BaseArgs
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { normalizePath } from "../normalize-path"
|
||||||
|
|
||||||
|
describe("normalize path", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("normalizePath", () => {
|
||||||
|
it("should normalize a file path", async () => {
|
||||||
|
const testPath = path.join("/", "custom", "page.tsx")
|
||||||
|
|
||||||
|
const result = normalizePath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom/page.tsx")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should normalize a file path with brackets", async () => {
|
||||||
|
const testPath = path.join("/", "custom", "[id]", "page.tsx")
|
||||||
|
|
||||||
|
const result = normalizePath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom/[id]/page.tsx")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("test windows platform", () => {
|
||||||
|
const originalPlatform = process.platform
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
value: "win32",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
value: originalPlatform,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should normalize a file path on Windows", async () => {
|
||||||
|
const testPath = path.win32.join("/", "custom", "page.tsx")
|
||||||
|
|
||||||
|
const result = normalizePath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom/page.tsx")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should normalize a file path with brackets on Windows", async () => {
|
||||||
|
const testPath = path.win32.join("/", "custom", "[id]", "page.tsx")
|
||||||
|
|
||||||
|
const result = normalizePath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom/[id]/page.tsx")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { createPath } from "../validate-extensions"
|
||||||
|
|
||||||
|
describe("validate extensions", () => {
|
||||||
|
beforeEach(function () {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createPath", () => {
|
||||||
|
it("should return a URL path", async () => {
|
||||||
|
const testPath = path.join("/", "custom", "page.tsx")
|
||||||
|
|
||||||
|
const result = createPath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return a URL path with a parameter", async () => {
|
||||||
|
const testPath = path.join("/", "custom", "[id]", "page.tsx")
|
||||||
|
|
||||||
|
const result = createPath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom/:id")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("test windows platform", () => {
|
||||||
|
const originalPlatform = process.platform
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
value: "win32",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
Object.defineProperty(process, "platform", {
|
||||||
|
value: originalPlatform,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createPath", () => {
|
||||||
|
it("should return a URL path on Windows", async () => {
|
||||||
|
const testPath = path.win32.join("/", "custom", "page.tsx")
|
||||||
|
|
||||||
|
const result = createPath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return a URL path with a parameter on Windows", async () => {
|
||||||
|
const testPath = path.win32.join("/", "custom", "[id]", "page.tsx")
|
||||||
|
|
||||||
|
const result = createPath(testPath)
|
||||||
|
|
||||||
|
expect(result).toEqual("/custom/:id")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
22
packages/admin-ui/src/node/utils/copy-filter.ts
Normal file
22
packages/admin-ui/src/node/utils/copy-filter.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import fse from "fs-extra"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter function to exclude test files and folders, as well as webpack configurations from being copied to the cache folder.
|
||||||
|
*/
|
||||||
|
export function copyFilter(src: string) {
|
||||||
|
if (fse.lstatSync(src).isDirectory() && src.includes("__test__")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fse.lstatSync(src).isFile()) {
|
||||||
|
if (
|
||||||
|
src.includes(".test") ||
|
||||||
|
src.includes(".spec") ||
|
||||||
|
src.includes("webpack.config")
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
46
packages/admin-ui/src/node/utils/create-cache-dir.ts
Normal file
46
packages/admin-ui/src/node/utils/create-cache-dir.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "node:path"
|
||||||
|
import { copyFilter } from "./copy-filter"
|
||||||
|
import { createEntry } from "./create-entry"
|
||||||
|
import { logger } from "./logger"
|
||||||
|
|
||||||
|
async function copyAdmin(dest: string) {
|
||||||
|
const adminDir = path.resolve(__dirname, "..", "ui")
|
||||||
|
const destDir = path.resolve(dest, "admin")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fse.copy(adminDir, destDir, {
|
||||||
|
filter: copyFilter,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.panic(
|
||||||
|
`Could not copy the admin UI to ${destDir}. See the error below for details:`,
|
||||||
|
{
|
||||||
|
error: err,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCacheDirArgs = {
|
||||||
|
appDir: string
|
||||||
|
plugins?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCacheDir({ appDir, plugins }: CreateCacheDirArgs) {
|
||||||
|
const cacheDir = path.resolve(appDir, ".cache")
|
||||||
|
|
||||||
|
await copyAdmin(cacheDir)
|
||||||
|
|
||||||
|
await createEntry({
|
||||||
|
appDir,
|
||||||
|
dest: cacheDir,
|
||||||
|
plugins,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createCacheDir }
|
||||||
321
packages/admin-ui/src/node/utils/create-entry.ts
Normal file
321
packages/admin-ui/src/node/utils/create-entry.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "node:path"
|
||||||
|
import dedent from "ts-dedent"
|
||||||
|
import { copyFilter } from "./copy-filter"
|
||||||
|
import { logger } from "./logger"
|
||||||
|
import { normalizePath } from "./normalize-path"
|
||||||
|
import {
|
||||||
|
findAllValidRoutes,
|
||||||
|
findAllValidSettings,
|
||||||
|
findAllValidWidgets,
|
||||||
|
} from "./validate-extensions"
|
||||||
|
|
||||||
|
const FILE_EXT_REGEX = /\.[^/.]+$/
|
||||||
|
|
||||||
|
async function copyLocalExtensions(src: string, dest: string) {
|
||||||
|
try {
|
||||||
|
await fse.copy(src, dest, {
|
||||||
|
filter: copyFilter,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Could not copy local extensions to cache folder. See the error below for details:`,
|
||||||
|
{
|
||||||
|
error: err,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an entry file for any local extensions, if they exist.
|
||||||
|
*/
|
||||||
|
async function createLocalExtensionsEntry(appDir: string, dest: string) {
|
||||||
|
const localAdminDir = path.resolve(appDir, "src", "admin")
|
||||||
|
|
||||||
|
const localAdminDirExists = await fse.pathExists(localAdminDir)
|
||||||
|
|
||||||
|
if (!localAdminDirExists) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = await copyLocalExtensions(
|
||||||
|
localAdminDir,
|
||||||
|
path.resolve(dest, "admin", "src", "extensions")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
logger.error(
|
||||||
|
"Could not copy local extensions to cache folder. See above error for details. The error must be fixed before any local extensions can be injected."
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [localWidgets, localRoutes, localSettings] = await Promise.all([
|
||||||
|
findAllValidWidgets(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions", "widgets")
|
||||||
|
),
|
||||||
|
findAllValidRoutes(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions", "routes")
|
||||||
|
),
|
||||||
|
findAllValidSettings(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions", "settings")
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const widgetsArray = localWidgets.map((file, index) => {
|
||||||
|
const relativePath = normalizePath(
|
||||||
|
path
|
||||||
|
.relative(path.resolve(dest, "admin", "src", "extensions"), file)
|
||||||
|
.replace(FILE_EXT_REGEX, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
importStatement: `import Widget${index}, { config as widgetConfig${index} } from "./${relativePath}"`,
|
||||||
|
extension: `{ Component: Widget${index}, config: { ...widgetConfig${index}, type: "widget" } }`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const routesArray = localRoutes.map((route, index) => {
|
||||||
|
const relativePath = normalizePath(
|
||||||
|
path
|
||||||
|
.relative(path.resolve(dest, "admin", "src", "extensions"), route.file)
|
||||||
|
.replace(FILE_EXT_REGEX, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
const importStatement = route.hasConfig
|
||||||
|
? `import Page${index}, { config as routeConfig${index} } from "./${relativePath}"`
|
||||||
|
: `import Page${index} from "./${relativePath}"`
|
||||||
|
|
||||||
|
const extension = route.hasConfig
|
||||||
|
? `{ Component: Page${index}, config: { ...routeConfig${index}, type: "route", path: "${route.path}" } }`
|
||||||
|
: `{ Component: Page${index}, config: { path: "${route.path}", type: "route" } }`
|
||||||
|
|
||||||
|
return {
|
||||||
|
importStatement,
|
||||||
|
extension,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsArray = localSettings.map((setting, index) => {
|
||||||
|
const relativePath = normalizePath(
|
||||||
|
path
|
||||||
|
.relative(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions"),
|
||||||
|
setting.file
|
||||||
|
)
|
||||||
|
.replace(FILE_EXT_REGEX, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
importStatement: `import Setting${index}, { config as settingConfig${index} } from "./${relativePath}"`,
|
||||||
|
extension: `{ Component: Setting${index}, config: { ...settingConfig${index}, path: "${setting.path}", type: "setting" } }`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensionsArray = [...widgetsArray, ...routesArray, ...settingsArray]
|
||||||
|
|
||||||
|
const extensionsEntry = dedent`
|
||||||
|
${extensionsArray.map((extension) => extension.importStatement).join("\n")}
|
||||||
|
|
||||||
|
const LocalEntry = {
|
||||||
|
identifier: "local",
|
||||||
|
extensions: [
|
||||||
|
${extensionsArray.map((extension) => extension.extension).join(",\n")}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LocalEntry
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fse.outputFile(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions", "_local-entry.ts"),
|
||||||
|
extensionsEntry
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.panic(
|
||||||
|
`Failed to write the entry file for the local extensions. See the error below for details:`,
|
||||||
|
{
|
||||||
|
error: err,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPluginsWithExtensions(plugins: string[]) {
|
||||||
|
const pluginsWithExtensions: string[] = []
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
try {
|
||||||
|
const pluginDir = path.dirname(
|
||||||
|
require.resolve(`${plugin}/package.json`, {
|
||||||
|
paths: [process.cwd()],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const entrypoint = path.resolve(
|
||||||
|
pluginDir,
|
||||||
|
"dist",
|
||||||
|
"admin",
|
||||||
|
"_virtual_entry.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fse.existsSync(entrypoint)) {
|
||||||
|
pluginsWithExtensions.push(entrypoint)
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
logger.warn(
|
||||||
|
`There was an error while attempting to load extensions from the plugin: ${plugin}. Are you sure it is installed?`
|
||||||
|
)
|
||||||
|
// no plugin found - noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pluginsWithExtensions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTailwindContentFile(dest: string, plugins: string[]) {
|
||||||
|
const tailwindContent = dedent`
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const devPath = path.join(__dirname, "..", "..", "src/admin/**/*.{js,jsx,ts,tsx}")
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
devPath,
|
||||||
|
${plugins
|
||||||
|
.map((plugin) => {
|
||||||
|
const tailwindContentPath = normalizePath(
|
||||||
|
path.relative(
|
||||||
|
path.resolve(dest, "admin"),
|
||||||
|
path.dirname(path.join(plugin, "..", ".."))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return `"${tailwindContentPath}/dist/admin/**/*.{js,jsx,ts,tsx}"`
|
||||||
|
})
|
||||||
|
.join(",\n")}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fse.outputFile(
|
||||||
|
path.resolve(dest, "admin", "tailwind.content.js"),
|
||||||
|
tailwindContent
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to write the Tailwind content file to ${dest}. The admin UI will remain functional, but CSS classes applied to extensions from plugins might not have the correct styles`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMainExtensionsEntry(
|
||||||
|
dest: string,
|
||||||
|
plugins: string[],
|
||||||
|
hasLocalExtensions: boolean
|
||||||
|
) {
|
||||||
|
if (!plugins.length && !hasLocalExtensions) {
|
||||||
|
// We still want to generate the entry file, even if there are no extensions
|
||||||
|
// to load, so that the admin UI can be built without errors
|
||||||
|
const emptyEntry = dedent`
|
||||||
|
const extensions = []
|
||||||
|
|
||||||
|
export default extensions
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fse.outputFile(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions", "_main-entry.ts"),
|
||||||
|
emptyEntry
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.panic(
|
||||||
|
`Failed to write the entry file for the main extensions. See the error below for details:`,
|
||||||
|
{
|
||||||
|
error: err,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsArray = plugins.map((plugin) => {
|
||||||
|
const relativePath = normalizePath(
|
||||||
|
path
|
||||||
|
.relative(path.resolve(dest, "admin", "src", "extensions"), plugin)
|
||||||
|
.replace(FILE_EXT_REGEX, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return relativePath
|
||||||
|
})
|
||||||
|
|
||||||
|
const extensionsArray = [
|
||||||
|
...pluginsArray.map((plugin, index) => {
|
||||||
|
const importStatement = `import Plugin${index} from "${plugin}"`
|
||||||
|
|
||||||
|
return {
|
||||||
|
importStatement,
|
||||||
|
extension: `Plugin${index}`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(hasLocalExtensions
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
importStatement: `import LocalEntry from "./_local-entry"`,
|
||||||
|
extension: `LocalEntry`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
|
||||||
|
const extensionsEntry = dedent`
|
||||||
|
${extensionsArray
|
||||||
|
.map((extension) => extension.importStatement)
|
||||||
|
.join("\n")}
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
${extensionsArray.map((extension) => extension.extension).join(",\n")}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default extensions
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fse.outputFile(
|
||||||
|
path.resolve(dest, "admin", "src", "extensions", "_main-entry.ts"),
|
||||||
|
extensionsEntry
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.panic(
|
||||||
|
`Failed to write the extensions entry file. See the error below for details:`,
|
||||||
|
{
|
||||||
|
error: err,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateEntryArgs = {
|
||||||
|
appDir: string
|
||||||
|
dest: string
|
||||||
|
plugins?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEntry({ appDir, dest, plugins }: CreateEntryArgs) {
|
||||||
|
const hasLocalExtensions = await createLocalExtensionsEntry(appDir, dest)
|
||||||
|
|
||||||
|
const adminPlugins = findPluginsWithExtensions(plugins)
|
||||||
|
|
||||||
|
await createMainExtensionsEntry(dest, adminPlugins, hasLocalExtensions)
|
||||||
|
await writeTailwindContentFile(dest, adminPlugins)
|
||||||
|
}
|
||||||
61
packages/admin-ui/src/node/utils/get-client-env.ts
Normal file
61
packages/admin-ui/src/node/utils/get-client-env.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import dotenv from "dotenv"
|
||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const MEDUSA_ADMIN = /^MEDUSA_ADMIN_/i
|
||||||
|
|
||||||
|
let ENV_FILE_NAME = ""
|
||||||
|
switch (process.env.NODE_ENV) {
|
||||||
|
case "production":
|
||||||
|
ENV_FILE_NAME = ".env.production"
|
||||||
|
break
|
||||||
|
case "staging":
|
||||||
|
ENV_FILE_NAME = ".env.staging"
|
||||||
|
break
|
||||||
|
case "test":
|
||||||
|
ENV_FILE_NAME = ".env.test"
|
||||||
|
break
|
||||||
|
case "development":
|
||||||
|
default:
|
||||||
|
ENV_FILE_NAME = ".env"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fse.existsSync(ENV_FILE_NAME)) {
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), ENV_FILE_NAME) })
|
||||||
|
} else if (ENV_FILE_NAME !== ".env") {
|
||||||
|
// Fall back to .env if the specified file does not exist
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), ".env") })
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetClientEnvArgs = {
|
||||||
|
path?: string
|
||||||
|
env?: string
|
||||||
|
backend?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getClientEnv = (args: GetClientEnvArgs) => {
|
||||||
|
const raw = Object.keys(process.env)
|
||||||
|
.filter((key) => MEDUSA_ADMIN.test(key))
|
||||||
|
.reduce(
|
||||||
|
(acc, current) => {
|
||||||
|
acc[current] = process.env[current]
|
||||||
|
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ADMIN_PATH: args.path || "/",
|
||||||
|
NODE_ENV: args.env || "development",
|
||||||
|
MEDUSA_BACKEND_URL: args.backend || process.env.MEDUSA_BACKEND_URL,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const stringified = {
|
||||||
|
"process.env": Object.keys(raw).reduce((env, key) => {
|
||||||
|
env[key] = JSON.stringify(raw[key])
|
||||||
|
return env
|
||||||
|
}, {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringified
|
||||||
|
}
|
||||||
27
packages/admin-ui/src/node/utils/index.ts
Normal file
27
packages/admin-ui/src/node/utils/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createCacheDir } from "./create-cache-dir"
|
||||||
|
import { getClientEnv } from "./get-client-env"
|
||||||
|
import { logger } from "./logger"
|
||||||
|
import { normalizePath } from "./normalize-path"
|
||||||
|
import {
|
||||||
|
findAllValidRoutes,
|
||||||
|
findAllValidSettings,
|
||||||
|
findAllValidWidgets,
|
||||||
|
validateRoute,
|
||||||
|
validateSetting,
|
||||||
|
validateWidget,
|
||||||
|
} from "./validate-extensions"
|
||||||
|
import { watchLocalAdminFolder } from "./watch-local-admin-folder"
|
||||||
|
|
||||||
|
export {
|
||||||
|
logger,
|
||||||
|
normalizePath,
|
||||||
|
getClientEnv,
|
||||||
|
createCacheDir,
|
||||||
|
validateWidget,
|
||||||
|
validateRoute,
|
||||||
|
validateSetting,
|
||||||
|
findAllValidWidgets,
|
||||||
|
findAllValidRoutes,
|
||||||
|
findAllValidSettings,
|
||||||
|
watchLocalAdminFolder,
|
||||||
|
}
|
||||||
74
packages/admin-ui/src/node/utils/logger.ts
Normal file
74
packages/admin-ui/src/node/utils/logger.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import colors from "picocolors"
|
||||||
|
import readline from "readline"
|
||||||
|
|
||||||
|
const prefix = "[@medusajs/admin]"
|
||||||
|
|
||||||
|
type LogType = "error" | "warn" | "info"
|
||||||
|
|
||||||
|
interface LogOptions {
|
||||||
|
clearScreen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogErrorOptions extends LogOptions {
|
||||||
|
error?: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Logger {
|
||||||
|
info(msg: string, options?: LogOptions): void
|
||||||
|
warn(msg: string, options?: LogOptions): void
|
||||||
|
error(msg: string, options?: LogErrorOptions): void
|
||||||
|
panic(msg: string, options?: LogErrorOptions): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreen() {
|
||||||
|
const repeatCount = process.stdout.rows - 2
|
||||||
|
const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : ""
|
||||||
|
console.log(blank)
|
||||||
|
readline.cursorTo(process.stdout, 0, 0)
|
||||||
|
readline.clearScreenDown(process.stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canClearScreen = process.stdout.isTTY && !process.env.CI
|
||||||
|
const clear = canClearScreen
|
||||||
|
? clearScreen
|
||||||
|
: () => {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogger(): Logger {
|
||||||
|
const output = (type: LogType, msg: string, options?: LogErrorOptions) => {
|
||||||
|
const method = type === "info" ? "log" : type
|
||||||
|
const format = () => {
|
||||||
|
const tag =
|
||||||
|
type === "info"
|
||||||
|
? colors.cyan(colors.bold(prefix))
|
||||||
|
: type === "warn"
|
||||||
|
? colors.yellow(colors.bold(prefix))
|
||||||
|
: colors.red(colors.bold(prefix))
|
||||||
|
return `${colors.dim(new Date().toLocaleTimeString())} ${tag} ${msg}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.clearScreen) {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
console[method](format())
|
||||||
|
|
||||||
|
if (options?.error) {
|
||||||
|
console.error(options.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: (msg, options) => output("info", msg, options),
|
||||||
|
warn: (msg, options) => output("warn", msg, options),
|
||||||
|
error: (msg, options) => output("error", msg, options),
|
||||||
|
panic: (msg, options) => {
|
||||||
|
output("error", msg, options)
|
||||||
|
output("error", "Exiting process", {})
|
||||||
|
process.exit(1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = createLogger()
|
||||||
6
packages/admin-ui/src/node/utils/normalize-path.ts
Normal file
6
packages/admin-ui/src/node/utils/normalize-path.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function normalizePath(path: string): string {
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const separator = isWindows ? "\\" : "/"
|
||||||
|
const regex = new RegExp(`\\${separator}`, "g")
|
||||||
|
return path.replace(regex, "/")
|
||||||
|
}
|
||||||
28
packages/admin-ui/src/node/utils/validate-args.ts
Normal file
28
packages/admin-ui/src/node/utils/validate-args.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { CustomWebpackConfigArgs } from "../types"
|
||||||
|
import { logger } from "./logger"
|
||||||
|
|
||||||
|
function validateArgs(args: CustomWebpackConfigArgs) {
|
||||||
|
const { options } = args
|
||||||
|
|
||||||
|
if (options.path) {
|
||||||
|
if (!options.path.startsWith("/")) {
|
||||||
|
logger.panic(
|
||||||
|
"'path' in the options of `@medusajs/admin` must start with a '/'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.path !== "/" && options.path.endsWith("/")) {
|
||||||
|
logger.panic(
|
||||||
|
"'path' in the options of `@medusajs/admin` cannot end with a '/'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof options.path !== "string") {
|
||||||
|
logger.panic(
|
||||||
|
"'path' in the options of `@medusajs/admin` must be a string"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { validateArgs }
|
||||||
685
packages/admin-ui/src/node/utils/validate-extensions.ts
Normal file
685
packages/admin-ui/src/node/utils/validate-extensions.ts
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
import { parse, ParseResult, ParserOptions } from "@babel/parser"
|
||||||
|
import traverse, { NodePath } from "@babel/traverse"
|
||||||
|
import type {
|
||||||
|
ExportDefaultDeclaration,
|
||||||
|
ExportNamedDeclaration,
|
||||||
|
ObjectExpression,
|
||||||
|
ObjectMethod,
|
||||||
|
ObjectProperty,
|
||||||
|
SpreadElement,
|
||||||
|
} from "@babel/types"
|
||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "path"
|
||||||
|
import { forbiddenRoutes, InjectionZone, injectionZones } from "../../client"
|
||||||
|
import { logger } from "./logger"
|
||||||
|
import { normalizePath } from "./normalize-path"
|
||||||
|
|
||||||
|
function isValidInjectionZone(zone: any): zone is InjectionZone {
|
||||||
|
return injectionZones.includes(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the widget config export is valid.
|
||||||
|
* In order to be valid it must have a `zone` property that is either a `InjectionZone` or a `InjectionZone` array.
|
||||||
|
*/
|
||||||
|
function validateWidgetConfigExport(
|
||||||
|
properties: (ObjectMethod | ObjectProperty | SpreadElement)[]
|
||||||
|
): boolean {
|
||||||
|
const zoneProperty = properties.find(
|
||||||
|
(p) =>
|
||||||
|
p.type === "ObjectProperty" &&
|
||||||
|
p.key.type === "Identifier" &&
|
||||||
|
p.key.name === "zone"
|
||||||
|
) as ObjectProperty | undefined
|
||||||
|
|
||||||
|
if (!zoneProperty) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let zoneIsValid = false
|
||||||
|
|
||||||
|
if (zoneProperty.value.type === "StringLiteral") {
|
||||||
|
zoneIsValid = isValidInjectionZone(zoneProperty.value.value)
|
||||||
|
} else if (zoneProperty.value.type === "ArrayExpression") {
|
||||||
|
zoneIsValid = zoneProperty.value.elements.every(
|
||||||
|
(zone) =>
|
||||||
|
zone.type === "StringLiteral" && isValidInjectionZone(zone.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zoneIsValid
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRouteConfigExport(
|
||||||
|
properties: (ObjectMethod | ObjectProperty | SpreadElement)[]
|
||||||
|
): boolean {
|
||||||
|
const linkProperty = properties.find(
|
||||||
|
(p) =>
|
||||||
|
p.type === "ObjectProperty" &&
|
||||||
|
p.key.type === "Identifier" &&
|
||||||
|
p.key.name === "link"
|
||||||
|
) as ObjectProperty | undefined
|
||||||
|
|
||||||
|
// Link property is optional for routes
|
||||||
|
if (!linkProperty) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkValue = linkProperty.value as ObjectExpression
|
||||||
|
|
||||||
|
let labelIsValid = false
|
||||||
|
|
||||||
|
// Check that the linkProperty is an object and has a `label` property that is a string
|
||||||
|
if (
|
||||||
|
linkValue.properties.some(
|
||||||
|
(p) =>
|
||||||
|
p.type === "ObjectProperty" &&
|
||||||
|
p.key.type === "Identifier" &&
|
||||||
|
p.key.name === "label" &&
|
||||||
|
p.value.type === "StringLiteral"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
labelIsValid = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelIsValid
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSettingConfigExport(
|
||||||
|
properties: (ObjectMethod | ObjectProperty | SpreadElement)[]
|
||||||
|
): boolean {
|
||||||
|
const cardProperty = properties.find(
|
||||||
|
(p) =>
|
||||||
|
p.type === "ObjectProperty" &&
|
||||||
|
p.key.type === "Identifier" &&
|
||||||
|
p.key.name === "card"
|
||||||
|
) as ObjectProperty | undefined
|
||||||
|
|
||||||
|
// Link property is required for settings
|
||||||
|
if (!cardProperty) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardValue = cardProperty.value as ObjectExpression
|
||||||
|
|
||||||
|
let hasLabel = false
|
||||||
|
let hasDescription = false
|
||||||
|
|
||||||
|
if (
|
||||||
|
cardValue.properties.some(
|
||||||
|
(p) =>
|
||||||
|
p.type === "ObjectProperty" &&
|
||||||
|
p.key.type === "Identifier" &&
|
||||||
|
p.key.name === "label" &&
|
||||||
|
p.value.type === "StringLiteral"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
hasLabel = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cardValue.properties.some(
|
||||||
|
(p) =>
|
||||||
|
p.type === "ObjectProperty" &&
|
||||||
|
p.key.type === "Identifier" &&
|
||||||
|
p.key.name === "description" &&
|
||||||
|
p.value.type === "StringLiteral"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
hasDescription = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasLabel && hasDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfigExport(
|
||||||
|
path: NodePath<ExportNamedDeclaration>,
|
||||||
|
type: "widget" | "route" | "setting"
|
||||||
|
) {
|
||||||
|
let hasValidConfigExport = false
|
||||||
|
|
||||||
|
const declaration = path.node.declaration
|
||||||
|
if (declaration && declaration.type === "VariableDeclaration") {
|
||||||
|
const configDeclaration = declaration.declarations.find(
|
||||||
|
(d) =>
|
||||||
|
d.type === "VariableDeclarator" &&
|
||||||
|
d.id.type === "Identifier" &&
|
||||||
|
d.id.name === "config"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
configDeclaration &&
|
||||||
|
configDeclaration.init.type === "ObjectExpression"
|
||||||
|
) {
|
||||||
|
const properties = configDeclaration.init.properties
|
||||||
|
|
||||||
|
if (type === "widget") {
|
||||||
|
hasValidConfigExport = validateWidgetConfigExport(properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "route") {
|
||||||
|
hasValidConfigExport = validateRouteConfigExport(properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "setting") {
|
||||||
|
hasValidConfigExport = validateSettingConfigExport(properties)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasValidConfigExport = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasValidConfigExport
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the default export of a file is a valid React component.
|
||||||
|
* This is determined by checking if the default export is a function declaration
|
||||||
|
* with a return statement that returns a JSX element or fragment.
|
||||||
|
*/
|
||||||
|
function validateDefaultExport(
|
||||||
|
path: NodePath<ExportDefaultDeclaration>,
|
||||||
|
ast: ParseResult<any>
|
||||||
|
) {
|
||||||
|
let hasComponentExport = false
|
||||||
|
|
||||||
|
const declaration = path.node.declaration
|
||||||
|
if (
|
||||||
|
declaration &&
|
||||||
|
(declaration.type === "Identifier" ||
|
||||||
|
declaration.type === "FunctionDeclaration")
|
||||||
|
) {
|
||||||
|
const exportName =
|
||||||
|
declaration.type === "Identifier"
|
||||||
|
? declaration.name
|
||||||
|
: declaration.id && declaration.id.name
|
||||||
|
|
||||||
|
if (exportName) {
|
||||||
|
try {
|
||||||
|
traverse(ast, {
|
||||||
|
VariableDeclarator({ node, scope }) {
|
||||||
|
let isDefaultExport = false
|
||||||
|
|
||||||
|
if (node.id.type === "Identifier" && node.id.name === exportName) {
|
||||||
|
isDefaultExport = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDefaultExport) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(
|
||||||
|
node,
|
||||||
|
{
|
||||||
|
ReturnStatement(path) {
|
||||||
|
if (
|
||||||
|
path.node.argument?.type === "JSXElement" ||
|
||||||
|
path.node.argument?.type === "JSXFragment"
|
||||||
|
) {
|
||||||
|
hasComponentExport = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scope
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`There was an error while validating the default export of ${path}. The following error must be resolved before continuing:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasComponentExport
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a widget file has a valid default export and a valid config export.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async function validateWidget(file: string) {
|
||||||
|
const content = await fse.readFile(file, "utf-8")
|
||||||
|
|
||||||
|
const parserOptions: ParserOptions = {
|
||||||
|
sourceType: "module",
|
||||||
|
plugins: ["jsx"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.endsWith(".ts") || file.endsWith(".tsx")) {
|
||||||
|
parserOptions.plugins.push("typescript")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ast: ParseResult<any>
|
||||||
|
|
||||||
|
try {
|
||||||
|
ast = parse(content, parserOptions)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`An error occurred while parsing the Widget "${file}", and the Widget cannot be injected. The following error must be resolved before continuing:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasConfigExport = false
|
||||||
|
let hasComponentExport = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
traverse(ast, {
|
||||||
|
ExportDefaultDeclaration: (path) => {
|
||||||
|
hasComponentExport = validateDefaultExport(path, ast)
|
||||||
|
},
|
||||||
|
ExportNamedDeclaration: (path) => {
|
||||||
|
hasConfigExport = validateConfigExport(path, "widget")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`An error occurred while validating the Widget "${file}". The following error must be resolved before continuing:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasConfigExport && !hasComponentExport) {
|
||||||
|
if (!hasComponentExport) {
|
||||||
|
logger.error(
|
||||||
|
`The default export in the Widget "${file}" is invalid and the widget will not be injected. Please make sure that the default export is a valid React component.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasConfigExport && hasComponentExport) {
|
||||||
|
logger.error(
|
||||||
|
`The Widget config export in "${file}" is invalid and the Widget cannot be injected. Please ensure that the config is valid.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasConfigExport && hasComponentExport
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function takes a file path and converts it to a URL path.
|
||||||
|
* It converts the file path to a URL path by replacing any
|
||||||
|
* square brackets with colons, and then removing the "page.[jt]s" suffix.
|
||||||
|
*/
|
||||||
|
function createPath(filePath: string): string {
|
||||||
|
const normalizedPath = normalizePath(filePath)
|
||||||
|
|
||||||
|
const regex = /\[(.*?)\]/g
|
||||||
|
const strippedPath = normalizedPath.replace(regex, ":$1")
|
||||||
|
|
||||||
|
const url = strippedPath.replace(/\/page\.[jt]sx?$/i, "")
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenRoute(path: any): boolean {
|
||||||
|
return forbiddenRoutes.includes(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePath(
|
||||||
|
path: string,
|
||||||
|
origin: string
|
||||||
|
): {
|
||||||
|
valid: boolean
|
||||||
|
error: string
|
||||||
|
} {
|
||||||
|
if (isForbiddenRoute(path)) {
|
||||||
|
return {
|
||||||
|
error: `A route from ${origin} is using a forbidden path: ${path}.`,
|
||||||
|
valid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialChars = ["/", ":", "-"]
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const currentChar = path[i]
|
||||||
|
|
||||||
|
if (
|
||||||
|
!specialChars.includes(currentChar) &&
|
||||||
|
!/^[a-z0-9]$/i.test(currentChar)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
error: `A route from ${origin} is using an invalid path: ${path}. Only alphanumeric characters, "/", ":", and "-" are allowed.`,
|
||||||
|
valid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChar === ":" && (i === 0 || path[i - 1] !== "/")) {
|
||||||
|
return {
|
||||||
|
error: `A route from ${origin} is using an invalid path: ${path}. All dynamic segments must be preceded by a "/".`,
|
||||||
|
valid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a file is a valid route.
|
||||||
|
* This is determined by checking if the file exports a valid React component
|
||||||
|
* as the default export, and a optional route config as a named export.
|
||||||
|
* If the file is not a valid route, `null` is returned.
|
||||||
|
* If the file is a valid route, a `ValidRouteResult` is returned.
|
||||||
|
*/
|
||||||
|
async function validateRoute(
|
||||||
|
file: string,
|
||||||
|
basePath: string
|
||||||
|
): Promise<{
|
||||||
|
path: string
|
||||||
|
hasConfig: boolean
|
||||||
|
file: string
|
||||||
|
} | null> {
|
||||||
|
const cleanPath = createPath(file.replace(basePath, ""))
|
||||||
|
|
||||||
|
const { valid, error } = validatePath(cleanPath, file)
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
logger.error(
|
||||||
|
`The path ${cleanPath} for the UI Route "${file}" is invalid and the route cannot be injected. The following error must be fixed before the route can be injected: ${error}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fse.readFile(file, "utf-8")
|
||||||
|
|
||||||
|
let hasComponentExport = false
|
||||||
|
let hasConfigExport = false
|
||||||
|
|
||||||
|
const parserOptions: ParserOptions = {
|
||||||
|
sourceType: "module",
|
||||||
|
plugins: ["jsx"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.endsWith(".ts") || file.endsWith(".tsx")) {
|
||||||
|
parserOptions.plugins.push("typescript")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ast: ParseResult<any>
|
||||||
|
|
||||||
|
try {
|
||||||
|
ast = parse(content, parserOptions)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`An error occurred while parsing the UI Route "${file}", and the UI Route cannot be injected. The following error must be resolved before continuing:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
traverse(ast, {
|
||||||
|
ExportDefaultDeclaration: (path) => {
|
||||||
|
hasComponentExport = validateDefaultExport(path, ast)
|
||||||
|
},
|
||||||
|
ExportNamedDeclaration: (path) => {
|
||||||
|
hasConfigExport = validateConfigExport(path, "route")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`An error occurred while validating the UI Route "${file}", and the UI Route cannot be injected. The following error must be resolved before continuing:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasComponentExport) {
|
||||||
|
logger.error(
|
||||||
|
`The default export in the UI Route "${file}" is invalid and the route cannot be injected. Please make sure that the default export is a valid React component.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: cleanPath,
|
||||||
|
hasConfig: hasConfigExport,
|
||||||
|
file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSetting(file: string, basePath: string) {
|
||||||
|
const cleanPath = createPath(file.replace(basePath, ""))
|
||||||
|
|
||||||
|
const { valid, error } = validatePath(cleanPath, file)
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
logger.error(
|
||||||
|
`The path ${cleanPath} for the Setting "${file}" is invalid and the setting cannot be injected. The following error must be fixed before the Setting can be injected: ${error}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fse.readFile(file, "utf-8")
|
||||||
|
|
||||||
|
let hasComponentExport = false
|
||||||
|
let hasConfigExport = false
|
||||||
|
|
||||||
|
const parserOptions: ParserOptions = {
|
||||||
|
sourceType: "module",
|
||||||
|
plugins: ["jsx"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.endsWith(".ts") || file.endsWith(".tsx")) {
|
||||||
|
parserOptions.plugins.push("typescript")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ast: ParseResult<any>
|
||||||
|
|
||||||
|
try {
|
||||||
|
ast = parse(content, parserOptions)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`
|
||||||
|
An error occured while parsing the Setting "${file}". The following error must be resolved before continuing:
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
traverse(ast, {
|
||||||
|
ExportDefaultDeclaration: (path) => {
|
||||||
|
hasComponentExport = validateDefaultExport(path, ast)
|
||||||
|
},
|
||||||
|
ExportNamedDeclaration: (path) => {
|
||||||
|
hasConfigExport = validateConfigExport(path, "setting")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`
|
||||||
|
An error occured while validating the Setting "${file}". The following error must be resolved before continuing:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasComponentExport) {
|
||||||
|
logger.error(
|
||||||
|
`The default export in the Setting "${file}" is invalid and the page will not be injected. Please make sure that the default export is a valid React component.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasConfigExport) {
|
||||||
|
logger.error(
|
||||||
|
`The named export "config" in the Setting "${file}" is invalid or missing and the settings page will not be injected. Please make sure that the file exports a valid config.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: cleanPath,
|
||||||
|
file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAllValidSettings(dir: string) {
|
||||||
|
const settingsFiles: string[] = []
|
||||||
|
|
||||||
|
const dirExists = await fse.pathExists(dir)
|
||||||
|
|
||||||
|
if (!dirExists) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = await fse.readdir(dir)
|
||||||
|
|
||||||
|
let hasSubDirs = false
|
||||||
|
|
||||||
|
// We only check the first level of directories for settings files
|
||||||
|
for (const pa of paths) {
|
||||||
|
const filePath = path.join(dir, pa)
|
||||||
|
const fileStat = await fse.stat(filePath)
|
||||||
|
|
||||||
|
if (fileStat.isDirectory()) {
|
||||||
|
const files = await fse.readdir(filePath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(dir, pa, file)
|
||||||
|
const fileStat = await fse.stat(filePath)
|
||||||
|
|
||||||
|
if (fileStat.isFile() && /^(.*\/)?page\.[jt]sx?$/i.test(file)) {
|
||||||
|
settingsFiles.push(filePath)
|
||||||
|
break
|
||||||
|
} else if (fileStat.isDirectory()) {
|
||||||
|
hasSubDirs = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSubDirs) {
|
||||||
|
logger.warn(
|
||||||
|
`The directory ${dir} contains subdirectories. Settings do not support nested routes, only UI Routes support nested paths.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSettingsFiles = await Promise.all(
|
||||||
|
settingsFiles.map(async (file) => validateSetting(file, dir))
|
||||||
|
)
|
||||||
|
|
||||||
|
return validSettingsFiles.filter((file) => file !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans a directory for valid widgets.
|
||||||
|
* A valid widget is a file that exports a valid widget config and a valid React component.
|
||||||
|
*/
|
||||||
|
async function findAllValidWidgets(dir: string) {
|
||||||
|
const jsxAndTsxFiles: string[] = []
|
||||||
|
|
||||||
|
const dirExists = await fse.pathExists(dir)
|
||||||
|
|
||||||
|
if (!dirExists) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function traverseDirectory(currentPath: string) {
|
||||||
|
const files = await fse.readdir(currentPath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(currentPath, file)
|
||||||
|
const fileStat = await fse.stat(filePath)
|
||||||
|
|
||||||
|
if (fileStat.isDirectory()) {
|
||||||
|
await traverseDirectory(filePath)
|
||||||
|
} else if (fileStat.isFile() && /\.(js|jsx|ts|tsx)$/i.test(file)) {
|
||||||
|
jsxAndTsxFiles.push(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await traverseDirectory(dir)
|
||||||
|
|
||||||
|
const promises = jsxAndTsxFiles.map((file) => {
|
||||||
|
const isValid = validateWidget(file)
|
||||||
|
|
||||||
|
return isValid ? file : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const validFiles = await Promise.all(promises)
|
||||||
|
|
||||||
|
return validFiles.filter((file) => file !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans a directory for valid routes.
|
||||||
|
* A valid route is a file that exports a optional route config and a valid React component.
|
||||||
|
*/
|
||||||
|
async function findAllValidRoutes(dir: string) {
|
||||||
|
const pageFiles: string[] = []
|
||||||
|
|
||||||
|
const dirExists = await fse.pathExists(dir)
|
||||||
|
|
||||||
|
if (!dirExists) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function traverseDirectory(currentPath: string) {
|
||||||
|
const files = await fse.readdir(currentPath)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(currentPath, file)
|
||||||
|
const fileStat = await fse.stat(filePath)
|
||||||
|
|
||||||
|
if (fileStat.isDirectory()) {
|
||||||
|
await traverseDirectory(filePath)
|
||||||
|
} else if (fileStat.isFile() && /^(.*\/)?page\.[jt]sx?$/i.test(file)) {
|
||||||
|
pageFiles.push(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await traverseDirectory(dir)
|
||||||
|
|
||||||
|
const promises = pageFiles.map(async (file) => {
|
||||||
|
return validateRoute(file, dir)
|
||||||
|
})
|
||||||
|
|
||||||
|
const validFiles = await Promise.all(promises)
|
||||||
|
|
||||||
|
return validFiles.filter((file) => file !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPath,
|
||||||
|
validateWidget,
|
||||||
|
validateRoute,
|
||||||
|
validateSetting,
|
||||||
|
findAllValidSettings,
|
||||||
|
findAllValidWidgets,
|
||||||
|
findAllValidRoutes,
|
||||||
|
}
|
||||||
57
packages/admin-ui/src/node/utils/watch-local-admin-folder.ts
Normal file
57
packages/admin-ui/src/node/utils/watch-local-admin-folder.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import chokidar from "chokidar"
|
||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createEntry } from "./create-entry"
|
||||||
|
import { logger } from "./logger"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches the local admin directory for changes and updates the extensions cache directory accordingly.
|
||||||
|
*/
|
||||||
|
export async function watchLocalAdminFolder(
|
||||||
|
appDir: string,
|
||||||
|
cacheDir: string,
|
||||||
|
plugins: string[]
|
||||||
|
) {
|
||||||
|
const adminDir = path.resolve(appDir, "src", "admin")
|
||||||
|
|
||||||
|
const watcher = chokidar.watch(adminDir, {
|
||||||
|
ignored: /(^|[/\\])\../,
|
||||||
|
ignoreInitial: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watcher.on("all", async (event, file) => {
|
||||||
|
if (event === "unlinkDir" || event === "unlink") {
|
||||||
|
removeUnlinkedFile(file, appDir, cacheDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
await createEntry({
|
||||||
|
appDir,
|
||||||
|
dest: cacheDir,
|
||||||
|
plugins,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("Extensions cache directory was re-initialized")
|
||||||
|
})
|
||||||
|
|
||||||
|
process
|
||||||
|
.on("SIGINT", async () => {
|
||||||
|
await watcher.close()
|
||||||
|
})
|
||||||
|
.on("SIGTERM", async () => {
|
||||||
|
await watcher.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUnlinkedFile(file: string, appDir: string, cacheDir: string) {
|
||||||
|
const srcDir = path.resolve(appDir, "src", "admin")
|
||||||
|
const relativePath = path.relative(srcDir, file)
|
||||||
|
|
||||||
|
const destDir = path.resolve(cacheDir, "admin", "src", "extensions")
|
||||||
|
const fileToDelete = path.resolve(destDir, relativePath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
fse.removeSync(fileToDelete)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`An error occurred while removing ${fileToDelete}: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import fse from "fs-extra"
|
||||||
|
import path from "node:path"
|
||||||
|
import webpack from "webpack"
|
||||||
|
import { CustomWebpackConfigArgs } from "../types"
|
||||||
|
import { logger } from "../utils"
|
||||||
|
import { validateArgs } from "../utils/validate-args"
|
||||||
|
import { getWebpackConfig } from "./get-webpack-config"
|
||||||
|
import { withCustomWebpackConfig } from "./with-custom-webpack-config"
|
||||||
|
|
||||||
|
export async function getCustomWebpackConfig(
|
||||||
|
appDir: string,
|
||||||
|
args: CustomWebpackConfigArgs
|
||||||
|
) {
|
||||||
|
validateArgs(args)
|
||||||
|
|
||||||
|
let config = getWebpackConfig(args)
|
||||||
|
|
||||||
|
const adminConfigPath = path.join(appDir, "src", "admin", "webpack.config.js")
|
||||||
|
|
||||||
|
const pathExists = await fse.pathExists(adminConfigPath)
|
||||||
|
|
||||||
|
if (pathExists) {
|
||||||
|
let webpackAdminConfig: ReturnType<typeof withCustomWebpackConfig>
|
||||||
|
|
||||||
|
try {
|
||||||
|
webpackAdminConfig = require(adminConfigPath)
|
||||||
|
} catch (e) {
|
||||||
|
logger.panic(
|
||||||
|
`An error occured while trying to load your custom Webpack config. See the error below for details:`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof webpackAdminConfig === "function") {
|
||||||
|
if (args.devServer) {
|
||||||
|
config.devServer = args.devServer
|
||||||
|
}
|
||||||
|
|
||||||
|
config = webpackAdminConfig(config, webpack)
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
logger.panic(
|
||||||
|
"Nothing was returned from your custom webpack configuration"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
185
packages/admin-ui/src/node/webpack/get-webpack-config.ts
Normal file
185
packages/admin-ui/src/node/webpack/get-webpack-config.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import ReactRefreshPlugin from "@pmmmwh/react-refresh-webpack-plugin"
|
||||||
|
import HtmlWebpackPlugin from "html-webpack-plugin"
|
||||||
|
import MiniCssExtractPlugin from "mini-css-extract-plugin"
|
||||||
|
import path from "node:path"
|
||||||
|
import { SwcMinifyWebpackPlugin } from "swc-minify-webpack-plugin"
|
||||||
|
import type { Configuration } from "webpack"
|
||||||
|
import webpack from "webpack"
|
||||||
|
import WebpackBar from "webpackbar"
|
||||||
|
import { WebpackConfigArgs } from "../types"
|
||||||
|
import { getClientEnv } from "../utils"
|
||||||
|
import { webpackAliases } from "./webpack-aliases"
|
||||||
|
|
||||||
|
function formatPublicPath(path?: string) {
|
||||||
|
if (!path) {
|
||||||
|
return "/app/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.endsWith("/") ? path : `${path}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebpackConfig({
|
||||||
|
entry,
|
||||||
|
dest,
|
||||||
|
cacheDir,
|
||||||
|
env,
|
||||||
|
options,
|
||||||
|
template,
|
||||||
|
reporting = "fancy",
|
||||||
|
}: WebpackConfigArgs): Configuration {
|
||||||
|
const isProd = env === "production"
|
||||||
|
|
||||||
|
const envVars = getClientEnv({
|
||||||
|
env,
|
||||||
|
backend: options?.backend,
|
||||||
|
path: options?.path,
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicPath = formatPublicPath(options?.path)
|
||||||
|
|
||||||
|
const webpackPlugins = isProd
|
||||||
|
? [
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
filename: "[name].[chunkhash].css",
|
||||||
|
chunkFilename: "[name].[chunkhash].css",
|
||||||
|
}),
|
||||||
|
new WebpackBar({
|
||||||
|
basic: reporting === "minimal",
|
||||||
|
fancy: reporting === "fancy",
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [new MiniCssExtractPlugin()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: env,
|
||||||
|
bail: !!isProd,
|
||||||
|
devtool: isProd ? false : "eval-source-map",
|
||||||
|
entry: [entry],
|
||||||
|
output: {
|
||||||
|
path: dest,
|
||||||
|
filename: isProd ? "[name].[contenthash:8].js" : "[name].bundle.js",
|
||||||
|
chunkFilename: isProd
|
||||||
|
? "[name].[contenthash:8].chunk.js"
|
||||||
|
: "[name].chunk.js",
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
minimize: true,
|
||||||
|
minimizer: [new SwcMinifyWebpackPlugin()],
|
||||||
|
moduleIds: "deterministic",
|
||||||
|
runtimeChunk: true,
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.tsx?$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
include: [cacheDir],
|
||||||
|
use: {
|
||||||
|
loader: "swc-loader",
|
||||||
|
options: {
|
||||||
|
jsc: {
|
||||||
|
parser: {
|
||||||
|
syntax: "typescript", // Use TypeScript syntax for parsing
|
||||||
|
jsx: true, // Enable JSX parsing
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
react: {
|
||||||
|
runtime: "automatic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.jsx?$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
include: [cacheDir],
|
||||||
|
use: {
|
||||||
|
loader: "swc-loader",
|
||||||
|
options: {
|
||||||
|
jsc: {
|
||||||
|
parser: {
|
||||||
|
syntax: "ecmascript", // Use Ecmascript syntax for parsing
|
||||||
|
jsx: true, // Enable JSX parsing
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
react: {
|
||||||
|
runtime: "automatic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg$/,
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "asset/resource",
|
||||||
|
resourceQuery: /url/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "asset/inline",
|
||||||
|
resourceQuery: /base64/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issuer: /\.[jt]sx?$/,
|
||||||
|
use: ["@svgr/webpack"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generator: {
|
||||||
|
filename: `images/${isProd ? "[name]-[hash][ext]" : "[name][ext]"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(eot|otf|ttf|woff|woff2)$/,
|
||||||
|
type: "asset/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(js|mjs)(\.map)?$/,
|
||||||
|
enforce: "pre",
|
||||||
|
use: ["source-map-loader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.m?jsx?$/,
|
||||||
|
resolve: {
|
||||||
|
fullySpecified: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: webpackAliases,
|
||||||
|
symlinks: false,
|
||||||
|
extensions: [".js", ".jsx", ".ts", ".tsx"],
|
||||||
|
mainFields: ["browser", "module", "main"],
|
||||||
|
modules: ["node_modules", path.resolve(__dirname, "..", "node_modules")],
|
||||||
|
fallback: {
|
||||||
|
readline: false,
|
||||||
|
path: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
inject: true,
|
||||||
|
template: template || path.resolve(__dirname, "..", "ui", "index.html"),
|
||||||
|
publicPath: publicPath,
|
||||||
|
}),
|
||||||
|
|
||||||
|
new webpack.DefinePlugin(envVars),
|
||||||
|
|
||||||
|
!isProd && new ReactRefreshPlugin(),
|
||||||
|
|
||||||
|
...webpackPlugins,
|
||||||
|
].filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/admin-ui/src/node/webpack/index.ts
Normal file
5
packages/admin-ui/src/node/webpack/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { getCustomWebpackConfig } from "./get-custom-webpack-config"
|
||||||
|
import { getWebpackConfig } from "./get-webpack-config"
|
||||||
|
import { withCustomWebpackConfig } from "./with-custom-webpack-config"
|
||||||
|
|
||||||
|
export { getCustomWebpackConfig, getWebpackConfig, withCustomWebpackConfig }
|
||||||
9
packages/admin-ui/src/node/webpack/webpack-aliases.ts
Normal file
9
packages/admin-ui/src/node/webpack/webpack-aliases.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ALIASED_PACKAGES } from "../constants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that the admin-ui uses the same version of these packages as the project.
|
||||||
|
*/
|
||||||
|
export const webpackAliases = ALIASED_PACKAGES.reduce((acc, pkg) => {
|
||||||
|
acc[`${pkg}$`] = require.resolve(pkg)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import webpack, { type Configuration } from "webpack"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a custom webpack config that can be used to
|
||||||
|
* extend the default webpack config used to build the admin UI.
|
||||||
|
*/
|
||||||
|
export function withCustomWebpackConfig(
|
||||||
|
callback: (
|
||||||
|
config: Configuration,
|
||||||
|
webpackInstance: typeof webpack
|
||||||
|
) => Configuration
|
||||||
|
) {
|
||||||
|
return (config: Configuration, webpackInstance: typeof webpack) => {
|
||||||
|
return callback(config, webpackInstance)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { DeepPartial } from "./misc"
|
|
||||||
|
|
||||||
type GlobalsConfig = {
|
|
||||||
base?: string
|
|
||||||
backend?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuildConfig = {
|
|
||||||
outDir?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminBuildConfig = {
|
|
||||||
globals?: DeepPartial<GlobalsConfig>
|
|
||||||
build?: DeepPartial<BuildConfig>
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { AdminBuildConfig } from "./build"
|
|
||||||
|
|
||||||
export type AdminUIConfig = {
|
|
||||||
build?: AdminBuildConfig
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export type AdminDevConfig = {
|
|
||||||
backend?: string
|
|
||||||
port?: number
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./build"
|
|
||||||
export * from "./dev"
|
|
||||||
export * from "./misc"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type DeepPartial<T> = {
|
|
||||||
[P in keyof T]?: T[P] extends (infer U)[]
|
|
||||||
? DeepPartial<U>[]
|
|
||||||
: T[P] extends ReadonlyArray<infer V>
|
|
||||||
? ReadonlyArray<DeepPartial<V>>
|
|
||||||
: DeepPartial<T[P]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Base<T extends string> = `/${T}/`
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Base } from "../types"
|
|
||||||
|
|
||||||
export const formatBase = <T extends string>(base?: T): Base<T> => {
|
|
||||||
if (!base) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/${base}/`
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import react from "@vitejs/plugin-react"
|
|
||||||
import { resolve } from "path"
|
|
||||||
import { BuildOptions, InlineConfig } from "vite"
|
|
||||||
import { AdminBuildConfig } from "../types"
|
|
||||||
import { formatBase } from "./format-base"
|
|
||||||
|
|
||||||
export const getCustomViteConfig = (config: AdminBuildConfig): InlineConfig => {
|
|
||||||
const { globals = {}, build = {} } = config
|
|
||||||
|
|
||||||
const uiPath = resolve(__dirname, "..", "..", "ui")
|
|
||||||
|
|
||||||
const globalReplacements = () => {
|
|
||||||
let backend = undefined
|
|
||||||
|
|
||||||
if (globals.backend) {
|
|
||||||
try {
|
|
||||||
// Test if the backend is a valid URL
|
|
||||||
new URL(globals.backend)
|
|
||||||
backend = globals.backend
|
|
||||||
} catch (_e) {
|
|
||||||
throw new Error(
|
|
||||||
`The provided backend URL is not valid: ${globals.backend}. Please provide a valid URL (e.g. https://my-medusa-server.com).`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const global = {}
|
|
||||||
|
|
||||||
global["__BASE__"] = JSON.stringify(globals.base ? `/${globals.base}` : "/")
|
|
||||||
global["__MEDUSA_BACKEND_URL__"] = JSON.stringify(backend ? backend : "/")
|
|
||||||
|
|
||||||
return global
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildConfig = (): BuildOptions => {
|
|
||||||
const { outDir } = build
|
|
||||||
|
|
||||||
let destDir: string
|
|
||||||
|
|
||||||
if (!outDir) {
|
|
||||||
/**
|
|
||||||
* Default build directory is at the root of the `@medusajs/admin-ui` package.
|
|
||||||
*/
|
|
||||||
destDir = resolve(process.cwd(), "build")
|
|
||||||
} else {
|
|
||||||
/**
|
|
||||||
* If a custom build directory is specified, it is resolved relative to the
|
|
||||||
* current working directory.
|
|
||||||
*/
|
|
||||||
destDir = resolve(process.cwd(), outDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outDir: destDir,
|
|
||||||
emptyOutDir: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
plugins: [react()],
|
|
||||||
root: uiPath,
|
|
||||||
mode: "production",
|
|
||||||
base: formatBase(globals.base),
|
|
||||||
define: globalReplacements(),
|
|
||||||
build: buildConfig(),
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@tanstack/react-query": resolve(
|
|
||||||
require.resolve("@tanstack/react-query")
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
clearScreen: false,
|
|
||||||
logLevel: "error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import react from "@vitejs/plugin-react"
|
|
||||||
import { resolve } from "path"
|
|
||||||
import { InlineConfig } from "vite"
|
|
||||||
import { AdminDevConfig } from "../types/dev"
|
|
||||||
|
|
||||||
export const getCustomViteDevConfig = ({
|
|
||||||
backend = "http://localhost:9000",
|
|
||||||
port = 7001,
|
|
||||||
}: AdminDevConfig): InlineConfig => {
|
|
||||||
const uiPath = resolve(__dirname, "..", "..", "ui")
|
|
||||||
|
|
||||||
return {
|
|
||||||
define: {
|
|
||||||
__BASE__: JSON.stringify("/"),
|
|
||||||
__MEDUSA_BACKEND_URL__: JSON.stringify(backend),
|
|
||||||
},
|
|
||||||
plugins: [react()],
|
|
||||||
root: uiPath,
|
|
||||||
mode: "development",
|
|
||||||
server: {
|
|
||||||
port,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export * from "./format-base"
|
|
||||||
export * from "./get-custom-vite-config"
|
|
||||||
export * from "./get-custom-vite-dev-config"
|
|
||||||
@@ -4,15 +4,21 @@
|
|||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
"lib": ["es2019"],
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": ".",
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": [
|
||||||
"exclude": ["**/node_modules", "ui"]
|
"src",
|
||||||
|
"tsup.config.ts",
|
||||||
|
"webpack.config.dev.ts",
|
||||||
|
"src/node/webpack/get-webpack-config.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "ui", "./src/**/__tests__"]
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/admin-ui/tsconfig.spec.json
Normal file
5
packages/admin-ui/tsconfig.spec.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
11
packages/admin-ui/tsup.config.ts
Normal file
11
packages/admin-ui/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "tsup"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
dts: true,
|
||||||
|
skipNodeModulesBundle: true,
|
||||||
|
sourcemap: true,
|
||||||
|
minify: true,
|
||||||
|
clean: true,
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
})
|
||||||
@@ -8,6 +8,5 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const router = createBrowserRouter(
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
basename: __BASE__,
|
basename: process.env.ADMIN_PATH,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("../../fonts/Inter-Medium.ttf") format("truetype");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Inter";
|
font-family: "Inter";
|
||||||
src: url("../../fonts/Inter-SemiBold.ttf") format("truetype");
|
src: url("../../fonts/Inter-SemiBold.ttf") format("truetype");
|
||||||
@@ -42,129 +50,129 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.inter-5xlarge-regular {
|
.inter-5xlarge-regular {
|
||||||
@apply font-sans text-5xlarge leading-4xlarge font-normal;
|
@apply text-5xlarge leading-4xlarge font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-5xlarge-semibold {
|
.inter-5xlarge-semibold {
|
||||||
@apply font-sans text-5xlarge leading-4xlarge font-semibold;
|
@apply text-5xlarge leading-4xlarge font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-4xlarge-regular {
|
.inter-4xlarge-regular {
|
||||||
@apply font-sans text-4xlarge leading-3xlarge font-normal;
|
@apply text-4xlarge leading-3xlarge font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-4xlarge-semibold {
|
.inter-4xlarge-semibold {
|
||||||
@apply font-sans text-4xlarge leading-3xlarge font-semibold;
|
@apply text-4xlarge leading-3xlarge font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-3xlarge-regular {
|
.inter-3xlarge-regular {
|
||||||
@apply font-sans text-3xlarge leading-2xlarge font-normal;
|
@apply text-3xlarge leading-2xlarge font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-3xlarge-semibold {
|
.inter-3xlarge-semibold {
|
||||||
@apply font-sans text-3xlarge leading-2xlarge font-semibold;
|
@apply text-3xlarge leading-2xlarge font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-2xlarge-regular {
|
.inter-2xlarge-regular {
|
||||||
@apply font-sans text-2xlarge leading-xlarge font-normal;
|
@apply text-2xlarge leading-xlarge font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-2xlarge-semibold {
|
.inter-2xlarge-semibold {
|
||||||
@apply font-sans text-2xlarge leading-xlarge font-semibold;
|
@apply text-2xlarge leading-xlarge font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-xlarge-regular {
|
.inter-xlarge-regular {
|
||||||
@apply font-sans text-xlarge leading-large font-normal;
|
@apply text-xlarge leading-large font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-xlarge-semibold {
|
.inter-xlarge-semibold {
|
||||||
@apply font-sans text-xlarge leading-large font-semibold;
|
@apply text-xlarge leading-large font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-large-regular {
|
.inter-large-regular {
|
||||||
@apply font-sans text-large leading-base font-normal;
|
@apply text-large leading-base font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-large-semibold {
|
.inter-large-semibold {
|
||||||
@apply font-sans text-large leading-base font-semibold;
|
@apply text-large leading-base font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-base-regular {
|
.inter-base-regular {
|
||||||
@apply font-sans text-base leading-base font-normal;
|
@apply leading-base font-sans text-base font-normal;
|
||||||
}
|
}
|
||||||
.inter-base-semibold {
|
.inter-base-semibold {
|
||||||
@apply font-sans text-base leading-base font-semibold;
|
@apply leading-base font-sans text-base font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-small-regular {
|
.inter-small-regular {
|
||||||
@apply font-sans text-small leading-small font-normal;
|
@apply text-small leading-small font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-small-semibold {
|
.inter-small-semibold {
|
||||||
@apply font-sans text-small leading-small font-semibold;
|
@apply text-small leading-small font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inter-xsmall-regular {
|
.inter-xsmall-regular {
|
||||||
@apply font-sans text-xsmall leading-xsmall font-normal;
|
@apply text-xsmall leading-xsmall font-sans font-normal;
|
||||||
}
|
}
|
||||||
.inter-xsmall-semibold {
|
.inter-xsmall-semibold {
|
||||||
@apply font-sans text-xsmall leading-xsmall font-semibold;
|
@apply text-xsmall leading-xsmall font-sans font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-5xlarge-regular {
|
.mono-5xlarge-regular {
|
||||||
@apply font-mono text-5xlarge leading-4xlarge font-normal;
|
@apply text-5xlarge leading-4xlarge font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-5xlarge-semibold {
|
.mono-5xlarge-semibold {
|
||||||
@apply font-mono text-5xlarge leading-4xlarge font-bold;
|
@apply text-5xlarge leading-4xlarge font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-4xlarge-regular {
|
.mono-4xlarge-regular {
|
||||||
@apply font-mono text-4xlarge leading-3xlarge font-normal;
|
@apply text-4xlarge leading-3xlarge font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-4xlarge-semibold {
|
.mono-4xlarge-semibold {
|
||||||
@apply font-mono text-4xlarge leading-3xlarge font-bold;
|
@apply text-4xlarge leading-3xlarge font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-3xlarge-regular {
|
.mono-3xlarge-regular {
|
||||||
@apply font-mono text-3xlarge leading-2xlarge font-normal;
|
@apply text-3xlarge leading-2xlarge font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-3xlarge-semibold {
|
.mono-3xlarge-semibold {
|
||||||
@apply font-mono text-3xlarge leading-2xlarge font-bold;
|
@apply text-3xlarge leading-2xlarge font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-2xlarge-regular {
|
.mono-2xlarge-regular {
|
||||||
@apply font-mono text-2xlarge leading-xlarge font-normal;
|
@apply text-2xlarge leading-xlarge font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-2xlarge-semibold {
|
.mono-2xlarge-semibold {
|
||||||
@apply font-mono text-2xlarge leading-xlarge font-bold;
|
@apply text-2xlarge leading-xlarge font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-xlarge-regular {
|
.mono-xlarge-regular {
|
||||||
@apply font-mono text-xlarge leading-large font-normal;
|
@apply text-xlarge leading-large font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-xlarge-semibold {
|
.mono-xlarge-semibold {
|
||||||
@apply font-mono text-xlarge leading-large font-bold;
|
@apply text-xlarge leading-large font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-large-regular {
|
.mono-large-regular {
|
||||||
@apply font-mono text-large leading-base font-normal;
|
@apply text-large leading-base font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-large-semibold {
|
.mono-large-semibold {
|
||||||
@apply font-mono text-large leading-base font-bold;
|
@apply text-large leading-base font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-base-regular {
|
.mono-base-regular {
|
||||||
@apply font-mono text-base leading-base font-normal;
|
@apply leading-base font-mono text-base font-normal;
|
||||||
}
|
}
|
||||||
.mono-base-semibold {
|
.mono-base-semibold {
|
||||||
@apply font-mono text-base leading-base font-bold;
|
@apply leading-base font-mono text-base font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-small-regular {
|
.mono-small-regular {
|
||||||
@apply font-mono text-small leading-small font-normal;
|
@apply text-small leading-small font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-small-semibold {
|
.mono-small-semibold {
|
||||||
@apply font-mono text-small leading-small font-bold;
|
@apply text-small leading-small font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono-xsmall-regular {
|
.mono-xsmall-regular {
|
||||||
@apply font-mono text-xsmall leading-xsmall font-normal;
|
@apply text-xsmall leading-xsmall font-mono font-normal;
|
||||||
}
|
}
|
||||||
.mono-xsmall-semibold {
|
.mono-xsmall-semibold {
|
||||||
@apply font-mono text-xsmall leading-xsmall font-bold;
|
@apply text-xsmall leading-xsmall font-mono font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radio-outer-ring > span.indicator[data-state="checked"] {
|
.radio-outer-ring > span.indicator[data-state="checked"] {
|
||||||
@@ -178,7 +186,7 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.react-select-container {
|
.react-select-container {
|
||||||
@apply p-0 -mx-3 border-0 mb-1 cursor-text h-6;
|
@apply -mx-3 mb-1 h-6 cursor-text border-0 p-0;
|
||||||
|
|
||||||
.react-select__control {
|
.react-select__control {
|
||||||
@apply border-0 bg-inherit shadow-none;
|
@apply border-0 bg-inherit shadow-none;
|
||||||
@@ -187,17 +195,17 @@
|
|||||||
.react-select__control,
|
.react-select__control,
|
||||||
.react-select__control--is-focused,
|
.react-select__control--is-focused,
|
||||||
.react-select__control--menu-is-open {
|
.react-select__control--menu-is-open {
|
||||||
@apply h-6 p-0 m-0 !important;
|
@apply m-0 h-6 p-0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-select__value-container--is-multi,
|
.react-select__value-container--is-multi,
|
||||||
.react-select__value-container--has-value {
|
.react-select__value-container--has-value {
|
||||||
@apply h-6 pl-3 p-0 m-0 !important;
|
@apply m-0 h-6 p-0 pl-3 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-select__menu,
|
.react-select__menu,
|
||||||
.react-select__menu-list {
|
.react-select__menu-list {
|
||||||
@apply rounded-t-none mt-0 z-[110] !important;
|
@apply z-[110] mt-0 rounded-t-none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-select__value-container {
|
.react-select__value-container {
|
||||||
@@ -205,7 +213,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.react-select__indicators {
|
.react-select__indicators {
|
||||||
@apply p-0 h-full items-center flex pr-3;
|
@apply flex h-full items-center p-0 pr-3;
|
||||||
|
|
||||||
.react-select__indicator {
|
.react-select__indicator {
|
||||||
@apply p-0;
|
@apply p-0;
|
||||||
@@ -213,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.react-select__input {
|
.react-select__input {
|
||||||
@apply w-full mt-0 min-w-[120px] pt-0 !important;
|
@apply mt-0 w-full min-w-[120px] pt-0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-select__option,
|
.react-select__option,
|
||||||
@@ -231,15 +239,15 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.badge {
|
.badge {
|
||||||
@apply w-min py-0.5 px-2 rounded-rounded inter-small-semibold;
|
@apply rounded-rounded inter-small-semibold w-min py-0.5 px-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-disabled {
|
.badge-disabled {
|
||||||
@apply bg-grey-50 bg-opacity-10 text-grey-50;
|
@apply bg-grey-50 text-grey-50 bg-opacity-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
@apply bg-violet-60 bg-opacity-10 text-violet-60;
|
@apply bg-violet-60 text-violet-60 bg-opacity-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-danger {
|
.badge-danger {
|
||||||
@@ -251,11 +259,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-warning {
|
.badge-warning {
|
||||||
@apply bg-yellow-40 bg-opacity-20 text-yellow-60;
|
@apply bg-yellow-40 text-yellow-60 bg-opacity-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-ghost {
|
.badge-ghost {
|
||||||
@apply text-grey-90 border border-grey-20 whitespace-nowrap;
|
@apply text-grey-90 border-grey-20 whitespace-nowrap border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-default {
|
.badge-default {
|
||||||
@@ -263,7 +271,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@apply flex items-center justify-center rounded-rounded focus:outline-none focus:shadow-cta;
|
@apply rounded-rounded focus:shadow-cta flex items-center justify-center focus:outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-large {
|
.btn-large {
|
||||||
@@ -279,23 +287,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-violet-60 text-grey-0 hover:bg-violet-50 active:bg-violet-70 disabled:bg-grey-20 disabled:text-grey-40;
|
@apply bg-violet-60 text-grey-0 active:bg-violet-70 disabled:bg-grey-20 disabled:text-grey-40 hover:bg-violet-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-grey-0 text-grey-90 border border-grey-20 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-grey-0 disabled:text-grey-30;
|
@apply bg-grey-0 text-grey-90 border-grey-20 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-grey-0 disabled:text-grey-30 border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply bg-grey-0 text-rose-50 border border-grey-20 hover:bg-grey-10 active:bg-grey-20 disabled:bg-grey-0 disabled:text-grey-30;
|
@apply bg-grey-0 border-grey-20 hover:bg-grey-10 active:bg-grey-20 disabled:bg-grey-0 disabled:text-grey-30 border text-rose-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-nuclear {
|
.btn-nuclear {
|
||||||
@apply bg-rose-50 text-grey-0 hover:bg-rose-40 active:bg-rose-60 disabled:bg-grey-20 disabled:text-grey-40;
|
@apply text-grey-0 hover:bg-rose-40 active:bg-rose-60 disabled:bg-grey-20 disabled:text-grey-40 bg-rose-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply bg-transparent text-grey-90 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-transparent disabled:text-grey-30;
|
@apply text-grey-90 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:text-grey-30 bg-transparent disabled:bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary-large {
|
.btn-primary-large {
|
||||||
@@ -329,11 +337,11 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.date-picker {
|
.date-picker {
|
||||||
@apply border-0 outline-none pt-6 !important;
|
@apply border-0 pt-6 outline-none !important;
|
||||||
|
|
||||||
.react-datepicker__month-container {
|
.react-datepicker__month-container {
|
||||||
.react-datepicker__header {
|
.react-datepicker__header {
|
||||||
@apply bg-inherit border-0;
|
@apply border-0 bg-inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +349,7 @@
|
|||||||
@apply inter-base-semibold pt-4;
|
@apply inter-base-semibold pt-4;
|
||||||
|
|
||||||
.react-datepicker__day-name {
|
.react-datepicker__day-name {
|
||||||
@apply w-[40px] m-0;
|
@apply m-0 w-[40px];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +369,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
@apply text-grey-90 m-[0px] w-[38px] h-[38px] align-middle relative leading-none pt-3;
|
@apply text-grey-90 relative m-[0px] h-[38px] w-[38px] pt-3 align-middle leading-none;
|
||||||
:hover {
|
:hover {
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
}
|
}
|
||||||
@@ -396,7 +404,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vice-city {
|
.vice-city {
|
||||||
@apply bg-gradient-to-tr from-vice-start to-vice-stop;
|
@apply from-vice-start to-vice-stop bg-gradient-to-tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden-actions[data-state="open"] {
|
.hidden-actions[data-state="open"] {
|
||||||
@@ -426,9 +434,10 @@
|
|||||||
@apply bg-grey-40;
|
@apply bg-grey-40;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-margin-transition {
|
/* TODO: Fix this as it breaks builds when using preset */
|
||||||
|
/* .accordion-margin-transition {
|
||||||
@apply transition-[margin] duration-300 ease-[cubic-bezier(0.87,0,0.13,1)];
|
@apply transition-[margin] duration-300 ease-[cubic-bezier(0.87,0,0.13,1)];
|
||||||
}
|
} */
|
||||||
|
|
||||||
.col-tree:last-child .bottom-half-dash {
|
.col-tree:last-child .bottom-half-dash {
|
||||||
@apply border-none;
|
@apply border-none;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const SettingsCard: React.FC<SettingsCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Link to={to ?? ""} className="flex flex-1 items-center">
|
<Link to={to ?? ""} className="flex flex-1 items-center">
|
||||||
<button
|
<button
|
||||||
className="bg-grey-0 rounded-rounded p-large border-grey-20 group flex h-full flex-1 items-center border"
|
className="bg-grey-0 rounded-rounded p-base border-grey-20 group flex h-full flex-1 items-center border"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (externalLink) {
|
if (externalLink) {
|
||||||
@@ -34,8 +34,10 @@ const SettingsCard: React.FC<SettingsCardProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-2xlarge w-2xlarge bg-grey-10 rounded-circle text-grey-60 group-disabled:bg-grey-10 group-disabled:text-grey-40 flex items-center justify-center">
|
<div className="h-2xlarge w-2xlarge bg-grey-0 rounded-rounded border-grey-20 text-grey-60 group-disabled:bg-grey-10 group-disabled:text-grey-40 flex items-center justify-center border">
|
||||||
{icon}
|
<div className="bg-grey-10 h-xlarge w-xlarge flex items-center justify-center overflow-hidden rounded-md">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-large flex-1 text-left">
|
<div className="mx-large flex-1 text-left">
|
||||||
<h3 className="inter-large-semibold text-grey-90 group-disabled:text-grey-40 m-0">
|
<h3 className="inter-large-semibold text-grey-90 group-disabled:text-grey-40 m-0">
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
import { useRoutes } from "../../../providers/route-provider"
|
||||||
|
import { Route as AdminRoute, RouteSegment } from "../../../types/extensions"
|
||||||
|
import { isRoute } from "../../../utils/extensions"
|
||||||
|
import RouteErrorElement from "./route-error-element"
|
||||||
|
import { useRouteContainerProps } from "./use-route-container-props"
|
||||||
|
|
||||||
|
type RouteContainerProps = {
|
||||||
|
route: AdminRoute | RouteSegment
|
||||||
|
previousPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RouteContainer = ({ route, previousPath = "" }: RouteContainerProps) => {
|
||||||
|
const routeContainerProps = useRouteContainerProps()
|
||||||
|
|
||||||
|
const isFullRoute = isRoute(route)
|
||||||
|
|
||||||
|
const { path } = route
|
||||||
|
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const fullPath = `${previousPath}${path}`
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes(fullPath)
|
||||||
|
|
||||||
|
const hasNestedRoutes = nestedRoutes.length > 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the route is only a segment, we need to render the nested routes that
|
||||||
|
* are children of the segment. If the segment has no nested routes, we
|
||||||
|
* return null.
|
||||||
|
*/
|
||||||
|
if (!isFullRoute) {
|
||||||
|
if (hasNestedRoutes) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{nestedRoutes.map((r, i) => (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={fullPath} />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Page, origin } = route
|
||||||
|
|
||||||
|
const PageWithProps = React.createElement(Page, routeContainerProps)
|
||||||
|
|
||||||
|
if (!hasNestedRoutes) {
|
||||||
|
return PageWithProps
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path={"/"}
|
||||||
|
element={PageWithProps}
|
||||||
|
errorElement={<RouteErrorElement origin={origin} />}
|
||||||
|
/>
|
||||||
|
{nestedRoutes.map((r, i) => (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={fullPath} />}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Routes>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RouteContainer
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouteError } from "react-router-dom"
|
||||||
|
import Button from "../../fundamentals/button"
|
||||||
|
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
|
||||||
|
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
|
||||||
|
|
||||||
|
type PageErrorElementProps = {
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production"
|
||||||
|
|
||||||
|
const RouteErrorElement = ({ origin }: PageErrorElementProps) => {
|
||||||
|
const error = useRouteError()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isProd && error) {
|
||||||
|
console.group(
|
||||||
|
`%cAn error occurred in a page from ${origin}:`,
|
||||||
|
"color: red; font-weight: bold;"
|
||||||
|
)
|
||||||
|
console.error(error)
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
|
}, [error, origin])
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
|
||||||
|
<div>
|
||||||
|
<WarningCircleIcon
|
||||||
|
size={20}
|
||||||
|
fillType="solid"
|
||||||
|
className="text-rose-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
|
||||||
|
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
|
||||||
|
<p className="mb-small">
|
||||||
|
{isProd
|
||||||
|
? "An error unknown error occurred, and the page could not be loaded."
|
||||||
|
: `A Page from <strong>${origin}</strong> crashed. See the console for more info.`}
|
||||||
|
</p>
|
||||||
|
<p className="mb-large">
|
||||||
|
<strong>What should I do?</strong>
|
||||||
|
<br />
|
||||||
|
If you are the developer of this page, you should fix the error and
|
||||||
|
reload the page. If you are not the developer, you should contact
|
||||||
|
the maintainer and report the error.
|
||||||
|
</p>
|
||||||
|
<div className="gap-x-base flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="nuclear"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={reload}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RefreshIcon size="20" />
|
||||||
|
<span className="ml-xsmall">Reload</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RouteErrorElement
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
|
||||||
|
import { RouteProps } from "../../../types/extensions"
|
||||||
|
|
||||||
|
export const useRouteContainerProps = (): RouteProps => {
|
||||||
|
const baseProps = useExtensionBaseProps()
|
||||||
|
|
||||||
|
return baseProps
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React, { ComponentType } from "react"
|
||||||
|
import { useSettingContainerProps } from "./use-setting-container-props"
|
||||||
|
|
||||||
|
type SettingContainerProps = {
|
||||||
|
Page: ComponentType<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingContainer = ({ Page }: SettingContainerProps) => {
|
||||||
|
const props = useSettingContainerProps()
|
||||||
|
|
||||||
|
return React.createElement(Page, props)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingContainer
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouteError } from "react-router-dom"
|
||||||
|
import Button from "../../fundamentals/button"
|
||||||
|
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
|
||||||
|
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
|
||||||
|
|
||||||
|
type SettingsPageErrorElementProps = {
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production"
|
||||||
|
|
||||||
|
const SettingsPageErrorElement = ({
|
||||||
|
origin,
|
||||||
|
}: SettingsPageErrorElementProps) => {
|
||||||
|
const error = useRouteError()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isProd && error) {
|
||||||
|
console.group(
|
||||||
|
`%cAn error occurred in a settings page from ${origin}:`,
|
||||||
|
"color: red; font-weight: bold;"
|
||||||
|
)
|
||||||
|
console.error(error)
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
|
}, [error, origin])
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
|
||||||
|
<div>
|
||||||
|
<WarningCircleIcon
|
||||||
|
size={20}
|
||||||
|
fillType="solid"
|
||||||
|
className="text-rose-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
|
||||||
|
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
|
||||||
|
<p className="mb-small">
|
||||||
|
{isProd
|
||||||
|
? "An error unknown error occurred, and the page could not be loaded."
|
||||||
|
: `A Page from <strong>${origin}</strong> crashed. See the console for more info.`}
|
||||||
|
</p>
|
||||||
|
<p className="mb-large">
|
||||||
|
<strong>What should I do?</strong>
|
||||||
|
<br />
|
||||||
|
If you are the developer of this setting page, you should fix the
|
||||||
|
error and reload the page. If you are not the developer, you should
|
||||||
|
contact the maintainer and report the error.
|
||||||
|
</p>
|
||||||
|
<div className="gap-x-base flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="nuclear"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={reload}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RefreshIcon size="20" />
|
||||||
|
<span className="ml-xsmall">Reload</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPageErrorElement
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
|
||||||
|
|
||||||
|
export const useSettingContainerProps = () => {
|
||||||
|
const baseProps = useExtensionBaseProps()
|
||||||
|
|
||||||
|
return baseProps
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { InjectionZone, Widget } from "../../../types/extensions"
|
||||||
|
import { EntityMap } from "./types"
|
||||||
|
import { useWidgetContainerProps } from "./use-widget-container-props"
|
||||||
|
import WidgetErrorBoundary from "./widget-error-boundary"
|
||||||
|
|
||||||
|
type WidgetContainerProps<T extends keyof EntityMap> = {
|
||||||
|
injectionZone: T
|
||||||
|
widget: Widget
|
||||||
|
entity: EntityMap[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
const WidgetContainer = <T extends InjectionZone>({
|
||||||
|
injectionZone,
|
||||||
|
widget,
|
||||||
|
entity,
|
||||||
|
}: WidgetContainerProps<T>) => {
|
||||||
|
const { Widget, origin } = widget
|
||||||
|
|
||||||
|
const props = useWidgetContainerProps({
|
||||||
|
injectionZone,
|
||||||
|
entity,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetErrorBoundary origin={origin}>
|
||||||
|
{React.createElement(Widget, props)}
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetContainer
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Customer,
|
||||||
|
CustomerGroup,
|
||||||
|
Discount,
|
||||||
|
DraftOrder,
|
||||||
|
GiftCard,
|
||||||
|
Order,
|
||||||
|
PriceList,
|
||||||
|
Product,
|
||||||
|
ProductCollection,
|
||||||
|
} from "@medusajs/medusa"
|
||||||
|
|
||||||
|
export type EntityMap = {
|
||||||
|
// Details
|
||||||
|
"product.details.after": Product
|
||||||
|
"product.details.before": Product
|
||||||
|
"product_collection.details.after": ProductCollection
|
||||||
|
"product_collection.details.before": ProductCollection
|
||||||
|
"order.details.after": Order
|
||||||
|
"order.details.before": Order
|
||||||
|
"draft_order.details.after": DraftOrder
|
||||||
|
"draft_order.details.before": DraftOrder
|
||||||
|
"customer.details.after": Customer
|
||||||
|
"customer.details.before": Customer
|
||||||
|
"customer_group.details.after": CustomerGroup
|
||||||
|
"customer_group.details.before": CustomerGroup
|
||||||
|
"discount.details.after": Discount
|
||||||
|
"discount.details.before": Discount
|
||||||
|
"price_list.details.after": PriceList
|
||||||
|
"price_list.details.before": PriceList
|
||||||
|
"gift_card.details.after": Product
|
||||||
|
"gift_card.details.before": Product
|
||||||
|
"custom_gift_card.after": GiftCard
|
||||||
|
"custom_gift_card.before": GiftCard
|
||||||
|
// List
|
||||||
|
"product.list.after"?: never | null | undefined
|
||||||
|
"product.list.before"?: never | null | undefined
|
||||||
|
"product_collection.list.after"?: never | null | undefined
|
||||||
|
"product_collection.list.before"?: never | null | undefined
|
||||||
|
"order.list.after"?: never | null | undefined
|
||||||
|
"order.list.before"?: never | null | undefined
|
||||||
|
"draft_order.list.after"?: never | null | undefined
|
||||||
|
"draft_order.list.before"?: never | null | undefined
|
||||||
|
"customer.list.after"?: never | null | undefined
|
||||||
|
"customer.list.before"?: never | null | undefined
|
||||||
|
"customer_group.list.after"?: never | null | undefined
|
||||||
|
"customer_group.list.before"?: never | null | undefined
|
||||||
|
"discount.list.after"?: never | null | undefined
|
||||||
|
"discount.list.before"?: never | null | undefined
|
||||||
|
"price_list.list.after"?: never | null | undefined
|
||||||
|
"price_list.list.before"?: never | null | undefined
|
||||||
|
"gift_card.list.after"?: never | null | undefined
|
||||||
|
"gift_card.list.before"?: never | null | undefined
|
||||||
|
// Login
|
||||||
|
"login.before"?: never | null | undefined
|
||||||
|
"login.after"?: never | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PropKeyMap = {
|
||||||
|
"product.details.after": "product",
|
||||||
|
"product.details.before": "product",
|
||||||
|
"product_collection.details.after": "productCollection",
|
||||||
|
"product_collection.details.before": "productCollection",
|
||||||
|
"order.details.after": "order",
|
||||||
|
"order.details.before": "order",
|
||||||
|
"draft_order.details.after": "draftOrder",
|
||||||
|
"draft_order.details.before": "draftOrder",
|
||||||
|
"customer.details.after": "customer",
|
||||||
|
"customer.details.before": "customer",
|
||||||
|
"customer_group.details.after": "customerGroup",
|
||||||
|
"customer_group.details.before": "customerGroup",
|
||||||
|
"discount.details.after": "discount",
|
||||||
|
"discount.details.before": "discount",
|
||||||
|
"price_list.details.after": "priceList",
|
||||||
|
"price_list.details.before": "priceList",
|
||||||
|
custom_gift_card: "giftCard",
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
|
||||||
|
import { WidgetProps } from "../../../types/extensions"
|
||||||
|
import { EntityMap, PropKeyMap } from "./types"
|
||||||
|
|
||||||
|
type UseWidgetContainerProps<T extends keyof EntityMap> = {
|
||||||
|
injectionZone: T
|
||||||
|
entity?: EntityMap[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWidgetContainerProps = <T extends keyof EntityMap>({
|
||||||
|
injectionZone,
|
||||||
|
entity,
|
||||||
|
}: UseWidgetContainerProps<T>) => {
|
||||||
|
const baseProps = useExtensionBaseProps() satisfies WidgetProps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not all InjectionZones have an entity, so we need to check for it first, and then
|
||||||
|
* add it to the props if it exists.
|
||||||
|
*/
|
||||||
|
if (entity) {
|
||||||
|
const propKey = injectionZone as keyof typeof PropKeyMap
|
||||||
|
const entityKey = PropKeyMap[propKey]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseProps,
|
||||||
|
[entityKey]: entity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseProps
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { ErrorInfo } from "react"
|
||||||
|
import Button from "../../fundamentals/button"
|
||||||
|
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
|
||||||
|
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
|
||||||
|
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
hasError: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class WidgetErrorBoundary extends React.Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(_: Error): State {
|
||||||
|
return { hasError: true, hidden: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.group(
|
||||||
|
`%cAn error occurred in a widget from ${this.props.origin}:`,
|
||||||
|
"color: red; font-weight: bold, background-color: #fff;"
|
||||||
|
)
|
||||||
|
console.error(error)
|
||||||
|
console.error(
|
||||||
|
"%cComponent Stack:",
|
||||||
|
"color: red",
|
||||||
|
errorInfo.componentStack
|
||||||
|
)
|
||||||
|
console.groupEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleResetError() {
|
||||||
|
this.setState({ hasError: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
public hideError() {
|
||||||
|
this.setState({ hidden: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderFallback() {
|
||||||
|
if (process.env.NODE_ENV !== "production" && !this.state.hidden) {
|
||||||
|
return (
|
||||||
|
<FallbackWidget
|
||||||
|
origin={this.props.origin}
|
||||||
|
reset={this.handleResetError.bind(this)}
|
||||||
|
hide={this.hideError.bind(this)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render anything in production
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.renderFallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FallbackWidget = ({
|
||||||
|
origin,
|
||||||
|
reset,
|
||||||
|
hide,
|
||||||
|
}: {
|
||||||
|
origin: string
|
||||||
|
reset: () => void
|
||||||
|
hide: () => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
|
||||||
|
<div>
|
||||||
|
<WarningCircleIcon
|
||||||
|
size={20}
|
||||||
|
fillType="solid"
|
||||||
|
className="text-rose-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
|
||||||
|
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
|
||||||
|
<p className="mb-small">
|
||||||
|
A widget from <strong>{origin}</strong> crashed. See the console for
|
||||||
|
more info.
|
||||||
|
</p>
|
||||||
|
<p className="mb-large">
|
||||||
|
<strong>What should I do?</strong>
|
||||||
|
<br />
|
||||||
|
If you are the developer of this widget, you should fix the error and
|
||||||
|
reload the page. If you are not the developer, you should contact the
|
||||||
|
maintainer and report the error.
|
||||||
|
</p>
|
||||||
|
<div className="gap-x-base flex items-center">
|
||||||
|
<Button
|
||||||
|
variant="nuclear"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={hide}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<XCircleIcon size="20" />
|
||||||
|
<span className="ml-xsmall">Hide</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="nuclear"
|
||||||
|
size="small"
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RefreshIcon size="20" />
|
||||||
|
<span className="ml-xsmall">Reload</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetErrorBoundary
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react"
|
||||||
|
import IconProps from "../types/icon-type"
|
||||||
|
|
||||||
|
const ArrowUTurnLeft: React.FC<IconProps> = ({
|
||||||
|
size = "20",
|
||||||
|
color = "currentColor",
|
||||||
|
...attributes
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke={color}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M7.667 12.333 3.001 7.667m0 0 4.666-4.666M3.001 7.667h9.332a4.666 4.666 0 1 1 0 9.332H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArrowUTurnLeft
|
||||||
@@ -10,27 +10,13 @@ const InfoIcon: React.FC<IconProps> = ({
|
|||||||
<svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 18 18"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z"
|
d="M8.375 8.375L8.40917 8.35833C8.51602 8.30495 8.63594 8.2833 8.75472 8.29596C8.8735 8.30862 8.98616 8.35505 9.07937 8.42976C9.17258 8.50446 9.24242 8.60432 9.28064 8.71749C9.31885 8.83066 9.32384 8.95242 9.295 9.06833L8.705 11.4317C8.67595 11.5476 8.68078 11.6695 8.71891 11.7828C8.75704 11.8961 8.82687 11.9961 8.92011 12.071C9.01336 12.1458 9.12611 12.1923 9.245 12.205C9.36388 12.2177 9.4839 12.196 9.59083 12.1425L9.625 12.125M16.5 9C16.5 9.98491 16.306 10.9602 15.9291 11.8701C15.5522 12.7801 14.9997 13.6069 14.3033 14.3033C13.6069 14.9997 12.7801 15.5522 11.8701 15.9291C10.9602 16.306 9.98491 16.5 9 16.5C8.01509 16.5 7.03982 16.306 6.12987 15.9291C5.21993 15.5522 4.39314 14.9997 3.6967 14.3033C3.00026 13.6069 2.44781 12.7801 2.0709 11.8701C1.69399 10.9602 1.5 9.98491 1.5 9C1.5 7.01088 2.29018 5.10322 3.6967 3.6967C5.10322 2.29018 7.01088 1.5 9 1.5C10.9891 1.5 12.8968 2.29018 14.3033 3.6967C15.7098 5.10322 16.5 7.01088 16.5 9ZM9 5.875H9.00667V5.88167H9V5.875Z"
|
||||||
stroke={color}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M8 10.6667V8"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M8 5.33331H8.0075"
|
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from "react"
|
||||||
|
import IconProps from "../types/icon-type"
|
||||||
|
|
||||||
|
const SquaresPlus: React.FC<IconProps> = ({
|
||||||
|
size = "20",
|
||||||
|
color = "currentColor",
|
||||||
|
...attributes
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11.25 14.0625H14.0625M14.0625 14.0625H16.875M14.0625 14.0625V11.25M14.0625 14.0625V16.875M5 8.75H6.875C7.37228 8.75 7.84919 8.55246 8.20082 8.20082C8.55246 7.84919 8.75 7.37228 8.75 6.875V5C8.75 4.50272 8.55246 4.02581 8.20082 3.67417C7.84919 3.32254 7.37228 3.125 6.875 3.125H5C4.50272 3.125 4.02581 3.32254 3.67417 3.67417C3.32254 4.02581 3.125 4.50272 3.125 5V6.875C3.125 7.37228 3.32254 7.84919 3.67417 8.20082C4.02581 8.55246 4.50272 8.75 5 8.75V8.75ZM5 16.875H6.875C7.37228 16.875 7.84919 16.6775 8.20082 16.3258C8.55246 15.9742 8.75 15.4973 8.75 15V13.125C8.75 12.6277 8.55246 12.1508 8.20082 11.7992C7.84919 11.4475 7.37228 11.25 6.875 11.25H5C4.50272 11.25 4.02581 11.4475 3.67417 11.7992C3.32254 12.1508 3.125 12.6277 3.125 13.125V15C3.125 15.4973 3.32254 15.9742 3.67417 16.3258C4.02581 16.6775 4.50272 16.875 5 16.875ZM13.125 8.75H15C15.4973 8.75 15.9742 8.55246 16.3258 8.20082C16.6775 7.84919 16.875 7.37228 16.875 6.875V5C16.875 4.50272 16.6775 4.02581 16.3258 3.67417C15.9742 3.32254 15.4973 3.125 15 3.125H13.125C12.6277 3.125 12.1508 3.32254 11.7992 3.67417C11.4475 4.02581 11.25 4.50272 11.25 5V6.875C11.25 7.37228 11.4475 7.84919 11.7992 8.20082C12.1508 8.55246 12.6277 8.75 13.125 8.75V8.75Z"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SquaresPlus
|
||||||
@@ -51,7 +51,7 @@ const SigninInput = React.forwardRef(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"rounded-rounded h-[40px] w-[280px] overflow-hidden border",
|
"rounded-rounded h-[40px] w-[300px] overflow-hidden border",
|
||||||
"bg-grey-5 inter-base-regular placeholder:text-grey-40",
|
"bg-grey-5 inter-base-regular placeholder:text-grey-40",
|
||||||
"focus-within:shadow-input focus-within:border-violet-60",
|
"focus-within:shadow-input focus-within:border-violet-60",
|
||||||
"flex items-center",
|
"flex items-center",
|
||||||
@@ -83,6 +83,7 @@ const SigninInput = React.forwardRef(
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="text-grey-40 focus:text-violet-60 px-4 focus:outline-none"
|
className="text-grey-40 focus:text-violet-60 px-4 focus:outline-none"
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />}
|
{showPassword ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useEffect } from "react"
|
|||||||
import { Controller, useWatch } from "react-hook-form"
|
import { Controller, useWatch } from "react-hook-form"
|
||||||
import { NestedForm } from "../../../utils/nested-form"
|
import { NestedForm } from "../../../utils/nested-form"
|
||||||
import Switch from "../../atoms/switch"
|
import Switch from "../../atoms/switch"
|
||||||
|
import InfoIcon from "../../fundamentals/icons/info-icon"
|
||||||
|
import Tooltip from "../../atoms/tooltip"
|
||||||
|
|
||||||
export type AnalyticsConfigFormType = {
|
export type AnalyticsConfigFormType = {
|
||||||
anonymize: boolean
|
anonymize: boolean
|
||||||
@@ -11,9 +13,10 @@ export type AnalyticsConfigFormType = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
form: NestedForm<AnalyticsConfigFormType>
|
form: NestedForm<AnalyticsConfigFormType>
|
||||||
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AnalyticsConfigForm = ({ form }: Props) => {
|
const AnalyticsConfigForm = ({ form, compact }: Props) => {
|
||||||
const { control, setValue, path } = form
|
const { control, setValue, path } = form
|
||||||
|
|
||||||
const watchOptOut = useWatch({
|
const watchOptOut = useWatch({
|
||||||
@@ -31,17 +34,33 @@ const AnalyticsConfigForm = ({ form }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="gap-y-xlarge flex flex-col">
|
<div className="gap-y-xlarge flex flex-col">
|
||||||
<div
|
<div
|
||||||
className={clsx("flex items-start transition-opacity", {
|
className={clsx("flex items-center gap-3 transition-opacity", {
|
||||||
"opacity-50": watchOptOut,
|
"opacity-50": watchOptOut,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="gap-y-2xsmall flex flex-1 flex-col">
|
<div className="gap-y-2xsmall flex flex-1 flex-col">
|
||||||
<h2 className="inter-base-semibold">Anonymize my usage data</h2>
|
<div className="flex items-center">
|
||||||
<p className="inter-base-regular text-grey-50">
|
<h2 className="inter-base-semibold mr-2">
|
||||||
You can choose to anonymize your usage data. If this option is
|
Anonymize my usage data{" "}
|
||||||
selected, we will not collect your personal information, such as
|
</h2>
|
||||||
your name and email address.
|
{compact && (
|
||||||
</p>
|
<Tooltip
|
||||||
|
content="You can choose to anonymize your usage data. If this option is
|
||||||
|
selected, we will not collect your personal information, such as
|
||||||
|
your name and email address."
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
<InfoIcon size="18px" color={"#889096"} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!compact && (
|
||||||
|
<p className="inter-base-regular text-grey-50">
|
||||||
|
You can choose to anonymize your usage data. If this option is
|
||||||
|
selected, we will not collect your personal information, such as
|
||||||
|
your name and email address.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Controller
|
<Controller
|
||||||
name={path("anonymize")}
|
name={path("anonymize")}
|
||||||
@@ -57,14 +76,26 @@ const AnalyticsConfigForm = ({ form }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-center gap-3">
|
||||||
<div className="gap-y-2xsmall flex flex-1 flex-col">
|
<div className="gap-y-2xsmall flex flex-1 flex-col">
|
||||||
<h2 className="inter-base-semibold">
|
<div className="flex items-center">
|
||||||
Opt out of sharing my usage data
|
<h2 className="inter-base-semibold mr-2">
|
||||||
</h2>
|
Opt out of sharing my usage data
|
||||||
<p className="inter-base-regular text-grey-50">
|
</h2>
|
||||||
You can always opt out of sharing your usage data at any time.
|
{compact && (
|
||||||
</p>
|
<Tooltip
|
||||||
|
content="You can always opt out of sharing your usage data at any time."
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
<InfoIcon size="18px" color={"#889096"} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!compact && (
|
||||||
|
<p className="inter-base-regular text-grey-50">
|
||||||
|
You can always opt out of sharing your usage data at any time.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Controller
|
<Controller
|
||||||
name={path("opt_out")}
|
name={path("opt_out")}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useAdminLogin } from "medusa-react"
|
import { useAdminLogin } from "medusa-react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import InputError from "../../atoms/input-error"
|
import InputError from "../../atoms/input-error"
|
||||||
|
import WidgetContainer from "../../extensions/widget-container"
|
||||||
import Button from "../../fundamentals/button"
|
import Button from "../../fundamentals/button"
|
||||||
import SigninInput from "../../molecules/input-signin"
|
import SigninInput from "../../molecules/input-signin"
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ const LoginCard = ({ toResetPassword }: LoginCardProps) => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { mutate, isLoading } = useAdminLogin()
|
const { mutate, isLoading } = useAdminLogin()
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
mutate(values, {
|
mutate(values, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -44,44 +48,66 @@ const LoginCard = ({ toResetPassword }: LoginCardProps) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<div className="gap-y-large flex flex-col">
|
||||||
<div className="flex flex-col items-center">
|
{getWidgets("login.before").map((w, i) => {
|
||||||
<h1 className="inter-xlarge-semibold text-grey-90 mb-large text-[20px]">
|
return (
|
||||||
Log in to Medusa
|
<WidgetContainer
|
||||||
</h1>
|
key={i}
|
||||||
<div>
|
widget={w}
|
||||||
<SigninInput
|
injectionZone="login.before"
|
||||||
placeholder="Email"
|
entity={undefined}
|
||||||
{...register("email", { required: true })}
|
|
||||||
autoComplete="email"
|
|
||||||
className="mb-small"
|
|
||||||
/>
|
/>
|
||||||
<SigninInput
|
)
|
||||||
placeholder="Password"
|
})}
|
||||||
type={"password"}
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
{...register("password", { required: true })}
|
<div className="flex flex-col items-center">
|
||||||
autoComplete="current-password"
|
<h1 className="inter-xlarge-semibold text-grey-90 mb-large text-[20px]">
|
||||||
className="mb-xsmall"
|
Log in to Medusa
|
||||||
/>
|
</h1>
|
||||||
<InputError errors={errors} name="password" />
|
<div>
|
||||||
|
<SigninInput
|
||||||
|
placeholder="Email"
|
||||||
|
{...register("email", { required: true })}
|
||||||
|
autoComplete="email"
|
||||||
|
className="mb-small"
|
||||||
|
/>
|
||||||
|
<SigninInput
|
||||||
|
placeholder="Password"
|
||||||
|
type={"password"}
|
||||||
|
{...register("password", { required: true })}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="mb-xsmall"
|
||||||
|
/>
|
||||||
|
<InputError errors={errors} name="password" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="rounded-rounded inter-base-regular mt-4 w-[280px]"
|
||||||
|
variant="secondary"
|
||||||
|
size="medium"
|
||||||
|
type="submit"
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
<span
|
||||||
|
className="inter-small-regular text-grey-50 mt-8 cursor-pointer"
|
||||||
|
onClick={toResetPassword}
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</form>
|
||||||
className="rounded-rounded inter-base-regular mt-4 w-[280px]"
|
{getWidgets("login.after").map((w, i) => {
|
||||||
variant="secondary"
|
return (
|
||||||
size="medium"
|
<WidgetContainer
|
||||||
type="submit"
|
key={i}
|
||||||
loading={isLoading}
|
widget={w}
|
||||||
>
|
injectionZone="login.after"
|
||||||
Continue
|
entity={undefined}
|
||||||
</Button>
|
/>
|
||||||
<span
|
)
|
||||||
className="inter-small-regular text-grey-50 mt-8 cursor-pointer"
|
})}
|
||||||
onClick={toResetPassword}
|
</div>
|
||||||
>
|
|
||||||
Forgot your password?
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { useAdminStore } from "medusa-react"
|
|||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
|
|
||||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||||
|
import { useRoutes } from "../../../providers/route-provider"
|
||||||
import BuildingsIcon from "../../fundamentals/icons/buildings-icon"
|
import BuildingsIcon from "../../fundamentals/icons/buildings-icon"
|
||||||
import CartIcon from "../../fundamentals/icons/cart-icon"
|
import CartIcon from "../../fundamentals/icons/cart-icon"
|
||||||
import CashIcon from "../../fundamentals/icons/cash-icon"
|
import CashIcon from "../../fundamentals/icons/cash-icon"
|
||||||
import GearIcon from "../../fundamentals/icons/gear-icon"
|
import GearIcon from "../../fundamentals/icons/gear-icon"
|
||||||
import GiftIcon from "../../fundamentals/icons/gift-icon"
|
import GiftIcon from "../../fundamentals/icons/gift-icon"
|
||||||
import SaleIcon from "../../fundamentals/icons/sale-icon"
|
import SaleIcon from "../../fundamentals/icons/sale-icon"
|
||||||
import TagIcon from "../../fundamentals/icons/tag-icon"
|
import SquaresPlus from "../../fundamentals/icons/squares-plus"
|
||||||
import SwatchIcon from "../../fundamentals/icons/swatch-icon"
|
import SwatchIcon from "../../fundamentals/icons/swatch-icon"
|
||||||
|
import TagIcon from "../../fundamentals/icons/tag-icon"
|
||||||
import UsersIcon from "../../fundamentals/icons/users-icon"
|
import UsersIcon from "../../fundamentals/icons/users-icon"
|
||||||
import SidebarMenuItem from "../../molecules/sidebar-menu-item"
|
import SidebarMenuItem from "../../molecules/sidebar-menu-item"
|
||||||
import UserMenu from "../../molecules/user-menu"
|
import UserMenu from "../../molecules/user-menu"
|
||||||
@@ -22,6 +24,8 @@ const Sidebar: React.FC = () => {
|
|||||||
const { isFeatureEnabled } = useFeatureFlag()
|
const { isFeatureEnabled } = useFeatureFlag()
|
||||||
const { store } = useAdminStore()
|
const { store } = useAdminStore()
|
||||||
|
|
||||||
|
const { getLinks } = useRoutes()
|
||||||
|
|
||||||
const triggerHandler = () => {
|
const triggerHandler = () => {
|
||||||
const id = triggerHandler.id++
|
const id = triggerHandler.id++
|
||||||
return {
|
return {
|
||||||
@@ -104,6 +108,21 @@ const Sidebar: React.FC = () => {
|
|||||||
triggerHandler={triggerHandler}
|
triggerHandler={triggerHandler}
|
||||||
text={"Pricing"}
|
text={"Pricing"}
|
||||||
/>
|
/>
|
||||||
|
{getLinks().map(({ path, label, icon }, index) => {
|
||||||
|
const cleanLink = path.replace("/a/", "")
|
||||||
|
|
||||||
|
const Icon = icon ? icon : SquaresPlus
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem
|
||||||
|
key={index}
|
||||||
|
pageLink={`/a${cleanLink}`}
|
||||||
|
icon={icon ? <Icon /> : <SquaresPlus size={ICON_SIZE} />}
|
||||||
|
triggerHandler={triggerHandler}
|
||||||
|
text={label}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
pageLink={"/a/settings"}
|
pageLink={"/a/settings"}
|
||||||
icon={<GearIcon size={ICON_SIZE} />}
|
icon={<GearIcon size={ICON_SIZE} />}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const UserTable: React.FC<UserTableProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `${window.location.origin}${
|
return `${window.location.origin}${
|
||||||
__BASE__ ? `${__BASE__}/` : "/"
|
process.env.ADMIN_PATH ? `${process.env.ADMIN_PATH}/` : "/"
|
||||||
}invite?token={invite_token}`
|
}invite?token={invite_token}`
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
|
|||||||
59
packages/admin-ui/ui/src/constants/forbidden-routes.ts
Normal file
59
packages/admin-ui/ui/src/constants/forbidden-routes.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export const forbiddenRoutes = [
|
||||||
|
"/products",
|
||||||
|
"/products/:id",
|
||||||
|
"/product-categories",
|
||||||
|
"/product-categories",
|
||||||
|
"/orders",
|
||||||
|
"/orders/:id",
|
||||||
|
"/customers",
|
||||||
|
"/customers/:id",
|
||||||
|
"/customers/groups",
|
||||||
|
"/customers/groups/:id",
|
||||||
|
"/discounts",
|
||||||
|
"/discounts/new",
|
||||||
|
"/discounts/:id",
|
||||||
|
"/gift-cards",
|
||||||
|
"/gift-cards/:id",
|
||||||
|
"/gift-cards/manage",
|
||||||
|
"/pricing",
|
||||||
|
"/pricing/new",
|
||||||
|
"/pricing/:id",
|
||||||
|
"/inventory",
|
||||||
|
"/collections",
|
||||||
|
"/collections/:id",
|
||||||
|
"/draft-orders",
|
||||||
|
"/draft-orders/:id",
|
||||||
|
"/login",
|
||||||
|
"/sales-channels",
|
||||||
|
"/publishable-api-keys",
|
||||||
|
"/oauth",
|
||||||
|
"/oauth/:app_name",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const isSettingsRoute = (route: string) => {
|
||||||
|
return route.startsWith("/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isForbiddenRoute = (route: any): boolean => {
|
||||||
|
if (isSettingsRoute(route)) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.warn(
|
||||||
|
`The route "${route}" is a settings route. Please register the extension in the "settings" directory instead.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forbiddenRoutes.includes(route)) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.warn(
|
||||||
|
`The route "${route}" is a forbidden route. We do not currently support overriding default routes.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
52
packages/admin-ui/ui/src/constants/injection-zones.ts
Normal file
52
packages/admin-ui/ui/src/constants/injection-zones.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export const injectionZones = [
|
||||||
|
// Order injection zones
|
||||||
|
"order.details.before",
|
||||||
|
"order.details.after",
|
||||||
|
"order.list.before",
|
||||||
|
"order.list.after",
|
||||||
|
// Draft order injection zones
|
||||||
|
"draft_order.list.before",
|
||||||
|
"draft_order.list.after",
|
||||||
|
"draft_order.details.before",
|
||||||
|
"draft_order.details.after",
|
||||||
|
// Customer injection zones
|
||||||
|
"customer.details.before",
|
||||||
|
"customer.details.after",
|
||||||
|
"customer.list.before",
|
||||||
|
"customer.list.after",
|
||||||
|
// Customer group injection zones
|
||||||
|
"customer_group.details.before",
|
||||||
|
"customer_group.details.after",
|
||||||
|
"customer_group.list.before",
|
||||||
|
"customer_group.list.after",
|
||||||
|
// Product injection zones
|
||||||
|
"product.details.before",
|
||||||
|
"product.details.after",
|
||||||
|
"product.list.before",
|
||||||
|
"product.list.after",
|
||||||
|
// Product collection injection zones
|
||||||
|
"product_collection.details.before",
|
||||||
|
"product_collection.details.after",
|
||||||
|
"product_collection.list.before",
|
||||||
|
"product_collection.list.after",
|
||||||
|
// Price list injection zones
|
||||||
|
"price_list.details.before",
|
||||||
|
"price_list.details.after",
|
||||||
|
"price_list.list.before",
|
||||||
|
"price_list.list.after",
|
||||||
|
// Discount injection zones
|
||||||
|
"discount.details.before",
|
||||||
|
"discount.details.after",
|
||||||
|
"discount.list.before",
|
||||||
|
"discount.list.after",
|
||||||
|
// Gift card injection zones
|
||||||
|
"gift_card.details.before",
|
||||||
|
"gift_card.details.after",
|
||||||
|
"gift_card.list.before",
|
||||||
|
"gift_card.list.after",
|
||||||
|
"custom_gift_card.before",
|
||||||
|
"custom_gift_card.after",
|
||||||
|
// Login
|
||||||
|
"login.before",
|
||||||
|
"login.after",
|
||||||
|
] as const
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export const MEDUSA_BACKEND_URL =
|
export const MEDUSA_BACKEND_URL =
|
||||||
__MEDUSA_BACKEND_URL__ || "http://localhost:9000"
|
process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
|
||||||
|
|||||||
@@ -6,25 +6,30 @@ import {
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
|
import Spacer from "../../../components/atoms/spacer"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
||||||
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
|
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
|
||||||
import Actionables from "../../../components/molecules/actionables"
|
import Actionables from "../../../components/molecules/actionables"
|
||||||
import JSONView from "../../../components/molecules/json-view"
|
import JSONView from "../../../components/molecules/json-view"
|
||||||
import DeletePrompt from "../../../components/organisms/delete-prompt"
|
import DeletePrompt from "../../../components/organisms/delete-prompt"
|
||||||
import { MetadataField } from "../../../components/organisms/metadata"
|
import { MetadataField } from "../../../components/organisms/metadata"
|
||||||
|
import RawJSON from "../../../components/organisms/raw-json"
|
||||||
import Section from "../../../components/organisms/section"
|
import Section from "../../../components/organisms/section"
|
||||||
import CollectionModal from "../../../components/templates/collection-modal"
|
import CollectionModal from "../../../components/templates/collection-modal"
|
||||||
import AddProductsTable from "../../../components/templates/collection-product-table/add-product-table"
|
import AddProductsTable from "../../../components/templates/collection-product-table/add-product-table"
|
||||||
import ViewProductsTable from "../../../components/templates/collection-product-table/view-products-table"
|
import ViewProductsTable from "../../../components/templates/collection-product-table/view-products-table"
|
||||||
import useNotification from "../../../hooks/use-notification"
|
import useNotification from "../../../hooks/use-notification"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import Medusa from "../../../services/api"
|
import Medusa from "../../../services/api"
|
||||||
import { getErrorMessage } from "../../../utils/error-messages"
|
import { getErrorMessage } from "../../../utils/error-messages"
|
||||||
|
import { getErrorStatus } from "../../../utils/get-error-status"
|
||||||
|
|
||||||
const CollectionDetails = () => {
|
const CollectionDetails = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
|
||||||
const { collection, isLoading, refetch } = useAdminCollection(id!)
|
const { collection, isLoading, error, refetch } = useAdminCollection(id!)
|
||||||
const deleteCollection = useAdminDeleteCollection(id!)
|
const deleteCollection = useAdminDeleteCollection(id!)
|
||||||
const updateCollection = useAdminUpdateCollection(id!)
|
const updateCollection = useAdminUpdateCollection(id!)
|
||||||
const [showEdit, setShowEdit] = useState(false)
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
@@ -105,6 +110,32 @@ const CollectionDetails = () => {
|
|||||||
}
|
}
|
||||||
}, [collection?.products])
|
}, [collection?.products])
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorStatus = getErrorStatus(error)
|
||||||
|
|
||||||
|
if (errorStatus) {
|
||||||
|
// If the product is not found, redirect to the 404 page
|
||||||
|
if (errorStatus.status === 404) {
|
||||||
|
navigate("/404")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the error boundary handle the error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !collection) {
|
||||||
|
// temp, perhaps use skeletons?
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
|
||||||
|
<Spinner variant="secondary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -113,12 +144,19 @@ const CollectionDetails = () => {
|
|||||||
path="/a/products?view=collections"
|
path="/a/products?view=collections"
|
||||||
label="Back to Collections"
|
label="Back to Collections"
|
||||||
/>
|
/>
|
||||||
<div className="rounded-rounded py-large px-xlarge border-grey-20 bg-grey-0 mb-large border">
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
{isLoading || !collection ? (
|
{getWidgets("product_collection.details.before").map((w, i) => {
|
||||||
<div className="flex h-12 w-full items-center">
|
return (
|
||||||
<Spinner variant="secondary" size="large" />
|
<WidgetContainer
|
||||||
</div>
|
key={i}
|
||||||
) : (
|
entity={collection}
|
||||||
|
injectionZone="product_collection.details.before"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="rounded-rounded py-large px-xlarge border-grey-20 bg-grey-0 border">
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -155,29 +193,44 @@ const CollectionDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Products"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit Products",
|
||||||
|
icon: <EditIcon size="20" />,
|
||||||
|
onClick: () => setShowAddProducts(!showAddProducts),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p className="text-grey-50 inter-base-regular mt-xsmall mb-base">
|
||||||
|
Products in this collection
|
||||||
|
</p>
|
||||||
|
{collection && (
|
||||||
|
<ViewProductsTable
|
||||||
|
key={updates} // force re-render when collection is updated
|
||||||
|
collectionId={collection.id}
|
||||||
|
refetchCollection={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{getWidgets("product_collection.details.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={collection}
|
||||||
|
injectionZone="product_collection.details.after"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RawJSON data={collection} title="Raw collection" />
|
||||||
</div>
|
</div>
|
||||||
<Section
|
<Spacer />
|
||||||
title="Products"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Edit Products",
|
|
||||||
icon: <EditIcon size="20" />,
|
|
||||||
onClick: () => setShowAddProducts(!showAddProducts),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p className="text-grey-50 inter-base-regular mt-xsmall mb-base">
|
|
||||||
To start selling, all you need is a name, price, and image.
|
|
||||||
</p>
|
|
||||||
{collection && (
|
|
||||||
<ViewProductsTable
|
|
||||||
key={updates} // force re-render when collection is updated
|
|
||||||
collectionId={collection.id}
|
|
||||||
refetchCollection={refetch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
{showEdit && (
|
{showEdit && (
|
||||||
<CollectionModal
|
<CollectionModal
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
import CollectionDetails from "./details"
|
import CollectionDetails from "./details"
|
||||||
|
|
||||||
const Collections = () => {
|
const Collections = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/collections")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/:id" element={<CollectionDetails />} />
|
<Route path="/:id" element={<CollectionDetails />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={"/collections"} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useAdminCustomer } from "medusa-react"
|
import { useAdminCustomer } from "medusa-react"
|
||||||
import moment from "moment"
|
import moment from "moment"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import Avatar from "../../../components/atoms/avatar"
|
import Avatar from "../../../components/atoms/avatar"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
||||||
import StatusDot from "../../../components/fundamentals/status-indicator"
|
import StatusDot from "../../../components/fundamentals/status-indicator"
|
||||||
import Actionables, {
|
import Actionables, {
|
||||||
@@ -14,12 +15,15 @@ import BodyCard from "../../../components/organisms/body-card"
|
|||||||
import RawJSON from "../../../components/organisms/raw-json"
|
import RawJSON from "../../../components/organisms/raw-json"
|
||||||
import Section from "../../../components/organisms/section"
|
import Section from "../../../components/organisms/section"
|
||||||
import CustomerOrdersTable from "../../../components/templates/customer-orders-table"
|
import CustomerOrdersTable from "../../../components/templates/customer-orders-table"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
|
import { getErrorStatus } from "../../../utils/get-error-status"
|
||||||
import EditCustomerModal from "./edit"
|
import EditCustomerModal from "./edit"
|
||||||
|
|
||||||
const CustomerDetail = () => {
|
const CustomerDetail = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { customer, isLoading } = useAdminCustomer(id!)
|
const { customer, isLoading, error } = useAdminCustomer(id!)
|
||||||
const [showEdit, setShowEdit] = useState(false)
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
|
||||||
const customerName = () => {
|
const customerName = () => {
|
||||||
@@ -38,6 +42,31 @@ const CustomerDetail = () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorStatus = getErrorStatus(error)
|
||||||
|
|
||||||
|
if (errorStatus) {
|
||||||
|
// If the product is not found, redirect to the 404 page
|
||||||
|
if (errorStatus.status === 404) {
|
||||||
|
navigate("/404")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the error boundary handle the error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !customer) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
|
||||||
|
<Spinner variant="secondary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackButton
|
<BackButton
|
||||||
@@ -46,6 +75,17 @@ const CustomerDetail = () => {
|
|||||||
className="mb-xsmall"
|
className="mb-xsmall"
|
||||||
/>
|
/>
|
||||||
<div className="gap-y-xsmall flex flex-col">
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
|
{getWidgets("customer.details.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={customer}
|
||||||
|
injectionZone="customer.details.before"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<div className="flex w-full items-start justify-between">
|
<div className="flex w-full items-start justify-between">
|
||||||
<div className="gap-x-base flex w-full items-center">
|
<div className="gap-x-base flex w-full items-center">
|
||||||
@@ -61,7 +101,7 @@ const CustomerDetail = () => {
|
|||||||
{customerName()}
|
{customerName()}
|
||||||
</h1>
|
</h1>
|
||||||
<h3 className="inter-small-regular text-grey-50">
|
<h3 className="inter-small-regular text-grey-50">
|
||||||
{customer?.email}
|
{customer.email}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,21 +112,21 @@ const CustomerDetail = () => {
|
|||||||
<div className="inter-smaller-regular text-grey-50 mb-1">
|
<div className="inter-smaller-regular text-grey-50 mb-1">
|
||||||
First seen
|
First seen
|
||||||
</div>
|
</div>
|
||||||
<div>{moment(customer?.created_at).format("DD MMM YYYY")}</div>
|
<div>{moment(customer.created_at).format("DD MMM YYYY")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col pl-6">
|
<div className="flex flex-col pl-6">
|
||||||
<div className="inter-smaller-regular text-grey-50 mb-1">
|
<div className="inter-smaller-regular text-grey-50 mb-1">
|
||||||
Phone
|
Phone
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[200px] truncate">
|
<div className="max-w-[200px] truncate">
|
||||||
{customer?.phone || "N/A"}
|
{customer.phone || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col pl-6">
|
<div className="flex flex-col pl-6">
|
||||||
<div className="inter-smaller-regular text-grey-50 mb-1">
|
<div className="inter-smaller-regular text-grey-50 mb-1">
|
||||||
Orders
|
Orders
|
||||||
</div>
|
</div>
|
||||||
<div>{customer?.orders.length}</div>
|
<div>{customer.orders.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-100 flex flex-col pl-6">
|
<div className="h-100 flex flex-col pl-6">
|
||||||
<div className="inter-smaller-regular text-grey-50 mb-1">
|
<div className="inter-smaller-regular text-grey-50 mb-1">
|
||||||
@@ -94,28 +134,33 @@ const CustomerDetail = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-50 flex items-center justify-center">
|
<div className="h-50 flex items-center justify-center">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={customer?.has_account ? "success" : "danger"}
|
variant={customer.has_account ? "success" : "danger"}
|
||||||
title={customer?.has_account ? "Registered" : "Guest"}
|
title={customer.has_account ? "Registered" : "Guest"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
<BodyCard
|
<BodyCard
|
||||||
title={`Orders (${customer?.orders.length})`}
|
title={`Orders (${customer.orders.length})`}
|
||||||
subtitle="An overview of Customer Orders"
|
subtitle="An overview of Customer Orders"
|
||||||
>
|
>
|
||||||
{isLoading || !customer ? (
|
<div className="flex grow flex-col">
|
||||||
<div className="pt-2xlarge flex w-full items-center justify-center">
|
<CustomerOrdersTable id={customer.id} />
|
||||||
<Spinner size={"large"} variant={"secondary"} />
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex grow flex-col">
|
|
||||||
<CustomerOrdersTable id={customer.id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
|
||||||
|
{getWidgets("customer.details.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={customer}
|
||||||
|
injectionZone="customer.details.after"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<RawJSON data={customer} title="Raw customer" />
|
<RawJSON data={customer} title="Raw customer" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useEffect, useState } from "react"
|
|||||||
|
|
||||||
import { useNavigate, useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
||||||
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
|
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
|
||||||
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
|
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
|
||||||
@@ -21,6 +23,8 @@ import CustomersListTable from "../../../components/templates/customer-group-tab
|
|||||||
import EditCustomersTable from "../../../components/templates/customer-group-table/edit-customers-table"
|
import EditCustomersTable from "../../../components/templates/customer-group-table/edit-customers-table"
|
||||||
import useQueryFilters from "../../../hooks/use-query-filters"
|
import useQueryFilters from "../../../hooks/use-query-filters"
|
||||||
import useToggleState from "../../../hooks/use-toggle-state"
|
import useToggleState from "../../../hooks/use-toggle-state"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
|
import { getErrorStatus } from "../../../utils/get-error-status"
|
||||||
import CustomerGroupModal from "./customer-group-modal"
|
import CustomerGroupModal from "./customer-group-modal"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,7 +131,7 @@ function CustomerGroupCustomersList(props: CustomerGroupCustomersListProps) {
|
|||||||
<BodyCard
|
<BodyCard
|
||||||
title="Customers"
|
title="Customers"
|
||||||
actionables={actions}
|
actionables={actions}
|
||||||
className="my-4 min-h-[756px] w-full"
|
className="min-h-[756px] w-full"
|
||||||
>
|
>
|
||||||
{showCustomersModal && (
|
{showCustomersModal && (
|
||||||
<EditCustomersTable
|
<EditCustomersTable
|
||||||
@@ -229,11 +233,32 @@ function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
|
|||||||
*/
|
*/
|
||||||
function CustomerGroupDetails() {
|
function CustomerGroupDetails() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { customer_group } = useAdminCustomerGroup(id!)
|
const { customer_group, isLoading, error } = useAdminCustomerGroup(id!)
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
if (!customer_group) {
|
if (error) {
|
||||||
return null
|
const errorStatus = getErrorStatus(error)
|
||||||
|
|
||||||
|
if (errorStatus) {
|
||||||
|
// If the product is not found, redirect to the 404 page
|
||||||
|
if (errorStatus.status === 404) {
|
||||||
|
navigate("/404")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the error boundary handle the error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !customer_group) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
|
||||||
|
<Spinner variant="secondary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,8 +268,32 @@ function CustomerGroupDetails() {
|
|||||||
label="Back to customer groups"
|
label="Back to customer groups"
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
<CustomerGroupDetailsHeader customerGroup={customer_group} />
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
<CustomerGroupCustomersList group={customer_group} />
|
{getWidgets("customer_group.details.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={customer_group}
|
||||||
|
injectionZone="customer_group.details.before"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<CustomerGroupDetailsHeader customerGroup={customer_group} />
|
||||||
|
|
||||||
|
{getWidgets("customer_group.details.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={customer_group}
|
||||||
|
injectionZone="customer_group.details.after"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<CustomerGroupCustomersList group={customer_group} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
import RouteContainer from "../../../components/extensions/route-container"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
|
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
|
||||||
import BodyCard from "../../../components/organisms/body-card"
|
import BodyCard from "../../../components/organisms/body-card"
|
||||||
import CustomerGroupsTable from "../../../components/templates/customer-group-table/customer-groups-table"
|
import CustomerGroupsTable from "../../../components/templates/customer-group-table/customer-groups-table"
|
||||||
import useToggleState from "../../../hooks/use-toggle-state"
|
import useToggleState from "../../../hooks/use-toggle-state"
|
||||||
|
import { useRoutes } from "../../../providers/route-provider"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import CustomersPageTableHeader from "../header"
|
import CustomersPageTableHeader from "../header"
|
||||||
import CustomerGroupModal from "./customer-group-modal"
|
import CustomerGroupModal from "./customer-group-modal"
|
||||||
import Details from "./details"
|
import Details from "./details"
|
||||||
@@ -12,6 +16,7 @@ import Details from "./details"
|
|||||||
*/
|
*/
|
||||||
function Index() {
|
function Index() {
|
||||||
const { state, open, close } = useToggleState()
|
const { state, open, close } = useToggleState()
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
@@ -27,7 +32,18 @@ function Index() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full grow flex-col">
|
<div className="gap-y-xsmall flex h-full grow flex-col">
|
||||||
|
{getWidgets("customer_group.list.before").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
entity={null}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="customer_group.list.before"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<BodyCard
|
<BodyCard
|
||||||
actionables={actions}
|
actionables={actions}
|
||||||
className="h-auto"
|
className="h-auto"
|
||||||
@@ -35,6 +51,17 @@ function Index() {
|
|||||||
>
|
>
|
||||||
<CustomerGroupsTable />
|
<CustomerGroupsTable />
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
|
||||||
|
{getWidgets("customer_group.list.after").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
entity={null}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="customer_group.list.after"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<CustomerGroupModal open={state} onClose={close} />
|
<CustomerGroupModal open={state} onClose={close} />
|
||||||
</>
|
</>
|
||||||
@@ -45,10 +72,25 @@ function Index() {
|
|||||||
* Customer groups routes
|
* Customer groups routes
|
||||||
*/
|
*/
|
||||||
function CustomerGroups() {
|
function CustomerGroups() {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/customers/groups")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Index />} />
|
<Route index element={<Index />} />
|
||||||
<Route path="/:id" element={<Details />} />
|
<Route path="/:id" element={<Details />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={
|
||||||
|
<RouteContainer route={r} previousPath={"/customers/groups"} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,72 @@
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
import Spacer from "../../components/atoms/spacer"
|
import Spacer from "../../components/atoms/spacer"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import WidgetContainer from "../../components/extensions/widget-container"
|
||||||
import BodyCard from "../../components/organisms/body-card"
|
import BodyCard from "../../components/organisms/body-card"
|
||||||
import CustomerTable from "../../components/templates/customer-table"
|
import CustomerTable from "../../components/templates/customer-table"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
|
import { useWidgets } from "../../providers/widget-provider"
|
||||||
import Details from "./details"
|
import Details from "./details"
|
||||||
import CustomerGroups from "./groups"
|
import CustomerGroups from "./groups"
|
||||||
import CustomersPageTableHeader from "./header"
|
import CustomersPageTableHeader from "./header"
|
||||||
|
|
||||||
const CustomerIndex = () => {
|
const CustomerIndex = () => {
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
|
{getWidgets("customer.list.before").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
entity={null}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="customer.list.before"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<BodyCard
|
<BodyCard
|
||||||
customHeader={<CustomersPageTableHeader activeView="customers" />}
|
customHeader={<CustomersPageTableHeader activeView="customers" />}
|
||||||
className="h-fit"
|
className="h-fit"
|
||||||
>
|
>
|
||||||
<CustomerTable />
|
<CustomerTable />
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
|
||||||
|
{getWidgets("customer.list.after").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
entity={null}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="customer.list.after"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Customers = () => {
|
const Customers = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/customers")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<CustomerIndex />} />
|
<Route index element={<CustomerIndex />} />
|
||||||
<Route path="/groups/*" element={<CustomerGroups />} />
|
<Route path="/groups/*" element={<CustomerGroups />} />
|
||||||
<Route path="/:id" element={<Details />} />
|
<Route path="/:id" element={<Details />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={"/customers"} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useAdminDeleteDiscount, useAdminDiscount } from "medusa-react"
|
import { useAdminDeleteDiscount, useAdminDiscount } from "medusa-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import DeletePrompt from "../../../components/organisms/delete-prompt"
|
import DeletePrompt from "../../../components/organisms/delete-prompt"
|
||||||
import RawJSON from "../../../components/organisms/raw-json"
|
import RawJSON from "../../../components/organisms/raw-json"
|
||||||
import useNotification from "../../../hooks/use-notification"
|
import useNotification from "../../../hooks/use-notification"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import { getErrorMessage } from "../../../utils/error-messages"
|
import { getErrorMessage } from "../../../utils/error-messages"
|
||||||
|
import { getErrorStatus } from "../../../utils/get-error-status"
|
||||||
import { DiscountFormProvider } from "../new/discount-form/form/discount-form-context"
|
import { DiscountFormProvider } from "../new/discount-form/form/discount-form-context"
|
||||||
import DiscountDetailsConditions from "./conditions"
|
import DiscountDetailsConditions from "./conditions"
|
||||||
import Configurations from "./configurations"
|
import Configurations from "./configurations"
|
||||||
@@ -14,8 +17,9 @@ import General from "./general"
|
|||||||
|
|
||||||
const Edit = () => {
|
const Edit = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { discount, isLoading } = useAdminDiscount(
|
const { discount, isLoading, error } = useAdminDiscount(
|
||||||
id!,
|
id!,
|
||||||
{ expand: "rule,rule.conditions" },
|
{ expand: "rule,rule.conditions" },
|
||||||
{
|
{
|
||||||
@@ -26,6 +30,8 @@ const Edit = () => {
|
|||||||
const deleteDiscount = useAdminDeleteDiscount(id!)
|
const deleteDiscount = useAdminDeleteDiscount(id!)
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
deleteDiscount.mutate(undefined, {
|
deleteDiscount.mutate(undefined, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -37,6 +43,29 @@ const Edit = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorStatus = getErrorStatus(error)
|
||||||
|
|
||||||
|
if (errorStatus) {
|
||||||
|
// If the discount is not found, redirect to the 404 page
|
||||||
|
if (errorStatus.status === 404) {
|
||||||
|
navigate("/404")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the error boundary handle the error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !discount) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
|
||||||
|
<Spinner variant="secondary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-xlarge">
|
<div className="pb-xlarge">
|
||||||
{showDelete && (
|
{showDelete && (
|
||||||
@@ -55,20 +84,34 @@ const Edit = () => {
|
|||||||
path="/a/discounts"
|
path="/a/discounts"
|
||||||
className="mb-xsmall"
|
className="mb-xsmall"
|
||||||
/>
|
/>
|
||||||
{isLoading || !discount ? (
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
<div className="flex h-full items-center justify-center">
|
<DiscountFormProvider>
|
||||||
<Spinner variant="secondary" />
|
{getWidgets("discount.details.before").map((w, index) => {
|
||||||
</div>
|
return (
|
||||||
) : (
|
<WidgetContainer
|
||||||
<div className="gap-y-xsmall flex flex-col">
|
key={index}
|
||||||
<DiscountFormProvider>
|
entity={discount}
|
||||||
<General discount={discount} />
|
widget={w}
|
||||||
<Configurations discount={discount} />
|
injectionZone="discount.details.before"
|
||||||
<DiscountDetailsConditions discount={discount} />
|
/>
|
||||||
<RawJSON data={discount} title="Raw discount" />
|
)
|
||||||
</DiscountFormProvider>
|
})}
|
||||||
</div>
|
<General discount={discount} />
|
||||||
)}
|
<Configurations discount={discount} />
|
||||||
|
<DiscountDetailsConditions discount={discount} />
|
||||||
|
{getWidgets("discount.details.after").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
entity={discount}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="discount.details.after"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<RawJSON data={discount} title="Raw discount" />
|
||||||
|
</DiscountFormProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useState } from "react"
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
import Fade from "../../components/atoms/fade-wrapper"
|
import Fade from "../../components/atoms/fade-wrapper"
|
||||||
import Spacer from "../../components/atoms/spacer"
|
import Spacer from "../../components/atoms/spacer"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import WidgetContainer from "../../components/extensions/widget-container"
|
||||||
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
|
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
|
||||||
import BodyCard from "../../components/organisms/body-card"
|
import BodyCard from "../../components/organisms/body-card"
|
||||||
import TableViewHeader from "../../components/organisms/custom-table-header"
|
import TableViewHeader from "../../components/organisms/custom-table-header"
|
||||||
import DiscountTable from "../../components/templates/discount-table"
|
import DiscountTable from "../../components/templates/discount-table"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
|
import { useWidgets } from "../../providers/widget-provider"
|
||||||
import Details from "./details"
|
import Details from "./details"
|
||||||
import New from "./new"
|
import New from "./new"
|
||||||
import DiscountForm from "./new/discount-form"
|
import DiscountForm from "./new/discount-form"
|
||||||
@@ -22,9 +26,21 @@ const DiscountIndex = () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="gap-y-xsmall flex w-full grow flex-col">
|
||||||
|
{getWidgets("discount.list.before").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="discount.list.before"
|
||||||
|
entity={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<BodyCard
|
<BodyCard
|
||||||
actionables={actionables}
|
actionables={actionables}
|
||||||
customHeader={<TableViewHeader views={["discounts"]} />}
|
customHeader={<TableViewHeader views={["discounts"]} />}
|
||||||
@@ -32,6 +48,16 @@ const DiscountIndex = () => {
|
|||||||
>
|
>
|
||||||
<DiscountTable />
|
<DiscountTable />
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
{getWidgets("discount.list.after").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="discount.list.after"
|
||||||
|
entity={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<Spacer />
|
<Spacer />
|
||||||
</div>
|
</div>
|
||||||
<DiscountFormProvider>
|
<DiscountFormProvider>
|
||||||
@@ -44,11 +70,24 @@ const DiscountIndex = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Discounts = () => {
|
const Discounts = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/discounts")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<DiscountIndex />} />
|
<Route index element={<DiscountIndex />} />
|
||||||
<Route path="/new" element={<New />} />
|
<Route path="/new" element={<New />} />
|
||||||
<Route path="/:id" element={<Details />} />
|
<Route path="/:id" element={<Details />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={"/discounts"} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import moment from "moment"
|
|||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
|
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
|
||||||
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
|
||||||
import StatusSelector from "../../../components/molecules/status-selector"
|
import StatusSelector from "../../../components/molecules/status-selector"
|
||||||
@@ -10,6 +11,7 @@ import BodyCard from "../../../components/organisms/body-card"
|
|||||||
import RawJSON from "../../../components/organisms/raw-json"
|
import RawJSON from "../../../components/organisms/raw-json"
|
||||||
import useNotification from "../../../hooks/use-notification"
|
import useNotification from "../../../hooks/use-notification"
|
||||||
import useToggleState from "../../../hooks/use-toggle-state"
|
import useToggleState from "../../../hooks/use-toggle-state"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import { getErrorMessage } from "../../../utils/error-messages"
|
import { getErrorMessage } from "../../../utils/error-messages"
|
||||||
import { formatAmountWithSymbol } from "../../../utils/prices"
|
import { formatAmountWithSymbol } from "../../../utils/prices"
|
||||||
import EditGiftCardModal from "./edit-gift-card-modal"
|
import EditGiftCardModal from "./edit-gift-card-modal"
|
||||||
@@ -24,6 +26,8 @@ const GiftCardDetails = () => {
|
|||||||
|
|
||||||
const updateGiftCard = useAdminUpdateGiftCard(giftCard?.id!)
|
const updateGiftCard = useAdminUpdateGiftCard(giftCard?.id!)
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -81,6 +85,17 @@ const GiftCardDetails = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="gap-y-xsmall flex flex-col">
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
|
{getWidgets("custom_gift_card.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
entity={giftCard}
|
||||||
|
injectionZone="custom_gift_card.before"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"h-auto min-h-0 w-full"}
|
className={"h-auto min-h-0 w-full"}
|
||||||
title={`${giftCard?.code}`}
|
title={`${giftCard?.code}`}
|
||||||
@@ -147,6 +162,18 @@ const GiftCardDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
|
||||||
|
{getWidgets("custom_gift_card.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
entity={giftCard}
|
||||||
|
injectionZone="custom_gift_card.after"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<RawJSON data={giftCard} title="Raw gift card" />
|
<RawJSON data={giftCard} title="Raw gift card" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
import GiftCardDetails from "./details"
|
import GiftCardDetails from "./details"
|
||||||
import ManageGiftCard from "./manage"
|
import ManageGiftCard from "./manage"
|
||||||
import Overview from "./overview"
|
import Overview from "./overview"
|
||||||
|
|
||||||
const GiftCard = () => {
|
const GiftCard = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/gift-cards")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Overview />} />
|
<Route path="/" element={<Overview />} />
|
||||||
<Route path="/:id" element={<GiftCardDetails />} />
|
<Route path="/:id" element={<GiftCardDetails />} />
|
||||||
<Route path="/manage" element={<ManageGiftCard />} />
|
<Route path="/manage" element={<ManageGiftCard />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={"/gift-cards"} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { useAdminProducts } from "medusa-react"
|
|||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import GiftCardDenominationsSection from "../../../components/organisms/gift-card-denominations-section"
|
import GiftCardDenominationsSection from "../../../components/organisms/gift-card-denominations-section"
|
||||||
import ProductAttributesSection from "../../../components/organisms/product-attributes-section"
|
import ProductAttributesSection from "../../../components/organisms/product-attributes-section"
|
||||||
import ProductGeneralSection from "../../../components/organisms/product-general-section"
|
import ProductGeneralSection from "../../../components/organisms/product-general-section"
|
||||||
import ProductMediaSection from "../../../components/organisms/product-media-section"
|
import ProductMediaSection from "../../../components/organisms/product-media-section"
|
||||||
import ProductRawSection from "../../../components/organisms/product-raw-section"
|
import ProductRawSection from "../../../components/organisms/product-raw-section"
|
||||||
import ProductThumbnailSection from "../../../components/organisms/product-thumbnail-section"
|
import ProductThumbnailSection from "../../../components/organisms/product-thumbnail-section"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import { getErrorStatus } from "../../../utils/get-error-status"
|
import { getErrorStatus } from "../../../utils/get-error-status"
|
||||||
|
|
||||||
const Manage = () => {
|
const Manage = () => {
|
||||||
@@ -25,6 +27,8 @@ const Manage = () => {
|
|||||||
|
|
||||||
const giftCard = products?.[0] as Product | undefined
|
const giftCard = products?.[0] as Product | undefined
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
if (!giftCard) {
|
if (!giftCard) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full items-center justify-center">
|
<div className="flex h-screen w-full items-center justify-center">
|
||||||
@@ -57,9 +61,34 @@ const Manage = () => {
|
|||||||
/>
|
/>
|
||||||
<div className="gap-x-base grid grid-cols-12">
|
<div className="gap-x-base grid grid-cols-12">
|
||||||
<div className="gap-y-xsmall col-span-8 flex flex-col">
|
<div className="gap-y-xsmall col-span-8 flex flex-col">
|
||||||
|
{getWidgets("gift_card.details.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
injectionZone={"gift_card.details.before"}
|
||||||
|
entity={giftCard}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<ProductGeneralSection product={giftCard} />
|
<ProductGeneralSection product={giftCard} />
|
||||||
|
|
||||||
<GiftCardDenominationsSection giftCard={giftCard} />
|
<GiftCardDenominationsSection giftCard={giftCard} />
|
||||||
|
|
||||||
<ProductAttributesSection product={giftCard} />
|
<ProductAttributesSection product={giftCard} />
|
||||||
|
|
||||||
|
{getWidgets("gift_card.details.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
injectionZone={"gift_card.details.after"}
|
||||||
|
entity={giftCard}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<ProductRawSection product={giftCard} />
|
<ProductRawSection product={giftCard} />
|
||||||
</div>
|
</div>
|
||||||
<div className="gap-y-xsmall col-span-4 flex flex-col">
|
<div className="gap-y-xsmall col-span-4 flex flex-col">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom"
|
|||||||
import PageDescription from "../../components/atoms/page-description"
|
import PageDescription from "../../components/atoms/page-description"
|
||||||
import Spacer from "../../components/atoms/spacer"
|
import Spacer from "../../components/atoms/spacer"
|
||||||
import Spinner from "../../components/atoms/spinner"
|
import Spinner from "../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../components/extensions/widget-container"
|
||||||
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
|
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
|
||||||
import BannerCard from "../../components/molecules/banner-card"
|
import BannerCard from "../../components/molecules/banner-card"
|
||||||
import BodyCard from "../../components/organisms/body-card"
|
import BodyCard from "../../components/organisms/body-card"
|
||||||
@@ -18,6 +19,7 @@ import GiftCardBanner from "../../components/organisms/gift-card-banner"
|
|||||||
import GiftCardTable from "../../components/templates/gift-card-table"
|
import GiftCardTable from "../../components/templates/gift-card-table"
|
||||||
import useNotification from "../../hooks/use-notification"
|
import useNotification from "../../hooks/use-notification"
|
||||||
import useToggleState from "../../hooks/use-toggle-state"
|
import useToggleState from "../../hooks/use-toggle-state"
|
||||||
|
import { useWidgets } from "../../providers/widget-provider"
|
||||||
import { ProductStatus } from "../../types/shared"
|
import { ProductStatus } from "../../types/shared"
|
||||||
import { getErrorMessage } from "../../utils/error-messages"
|
import { getErrorMessage } from "../../utils/error-messages"
|
||||||
import CustomGiftcard from "./custom-giftcard"
|
import CustomGiftcard from "./custom-giftcard"
|
||||||
@@ -94,6 +96,8 @@ const Overview = () => {
|
|||||||
}
|
}
|
||||||
}, [giftCard, store])
|
}, [giftCard, store])
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -103,6 +107,16 @@ const Overview = () => {
|
|||||||
/>
|
/>
|
||||||
{!isLoading ? (
|
{!isLoading ? (
|
||||||
<div className="gap-y-xsmall flex flex-col">
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
|
{getWidgets("gift_card.list.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="gift_card.list.before"
|
||||||
|
entity={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{giftCardWithCurrency ? (
|
{giftCardWithCurrency ? (
|
||||||
<GiftCardBanner
|
<GiftCardBanner
|
||||||
{...giftCardWithCurrency}
|
{...giftCardWithCurrency}
|
||||||
@@ -130,6 +144,17 @@ const Overview = () => {
|
|||||||
>
|
>
|
||||||
<GiftCardTable />
|
<GiftCardTable />
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
|
||||||
|
{getWidgets("gift_card.list.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="gift_card.list.after"
|
||||||
|
entity={null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-rounded border-grey-20 flex h-44 w-full items-center justify-center border">
|
<div className="rounded-rounded border-grey-20 flex h-44 w-full items-center justify-center border">
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Order } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import ClaimTypeForm from ".."
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
|
|
||||||
|
|
||||||
describe("ClaimTypeForm", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: getDefaultClaimValues(order),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(<ClaimTypeForm form={nestedForm(form, "claim_type")} />)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly with the initial value of refund", async () => {
|
|
||||||
const {
|
|
||||||
claim_type: { type },
|
|
||||||
} = form.getValues()
|
|
||||||
|
|
||||||
expect(screen.getByText("Refund")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Replace")).toBeInTheDocument()
|
|
||||||
|
|
||||||
expect(type).toEqual("refund")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should update the value of the form when a new type is selected", async () => {
|
|
||||||
const {
|
|
||||||
claim_type: { type: initialType },
|
|
||||||
} = form.getValues()
|
|
||||||
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
expect(initialType).toEqual("refund")
|
|
||||||
|
|
||||||
const replace = screen.getByLabelText("Replace")
|
|
||||||
|
|
||||||
await user.click(replace)
|
|
||||||
|
|
||||||
const {
|
|
||||||
claim_type: { type },
|
|
||||||
} = form.getValues()
|
|
||||||
|
|
||||||
expect(type).toEqual("replace")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Order, Return } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { ReceiveReturnFormType } from "../../../details/receive-return"
|
|
||||||
import { getDefaultReceiveReturnValues } from "../../../details/utils/get-default-values"
|
|
||||||
import { ItemsToReceiveForm } from "../items-to-receive-form"
|
|
||||||
|
|
||||||
describe("ItemsToReceiveForm with ReceiveReturnMenu", () => {
|
|
||||||
let form: UseFormReturn<ReceiveReturnFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
const return_ = fixtures.get("return") as unknown as Return
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<ReceiveReturnFormType>({
|
|
||||||
defaultValues: getDefaultReceiveReturnValues(order, return_),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<ItemsToReceiveForm
|
|
||||||
form={nestedForm(form, "receive_items")}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly", async () => {
|
|
||||||
expect(screen.getByText("Items to receive")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Medusa Shorts")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("S")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("1")).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should mark an item as to be received when checkbox is checked", async () => {
|
|
||||||
const checkboxes = screen.getAllByRole("checkbox")
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
// We expect two checkboxes, one for the header and one for the item
|
|
||||||
expect(checkboxes).toHaveLength(2)
|
|
||||||
|
|
||||||
// Item checkbox
|
|
||||||
const checkbox = checkboxes[1]
|
|
||||||
expect(checkbox).not.toBeChecked()
|
|
||||||
|
|
||||||
await user.click(checkbox)
|
|
||||||
|
|
||||||
const { receive_items } = form.getValues()
|
|
||||||
expect(checkbox).toBeChecked()
|
|
||||||
expect(receive_items.items[0].receive).toEqual(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Order } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import ItemsToReturnForm from ".."
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
|
|
||||||
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
describe("ItemsToSendForm with RegisterClaimMenu", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: getDefaultClaimValues(order),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<ItemsToReturnForm
|
|
||||||
form={nestedForm(form, "return_items")}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly", async () => {
|
|
||||||
const titles = order.returnable_items?.map((item) => item.title)
|
|
||||||
|
|
||||||
// expect all titles in titles array to appear at least once in the document
|
|
||||||
titles?.forEach((title) => {
|
|
||||||
expect(screen.getAllByText(title).length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should initially not display any items as marked for return", async () => {
|
|
||||||
const checkboxes = screen.getAllByRole("checkbox")
|
|
||||||
|
|
||||||
checkboxes.forEach((checkbox) => {
|
|
||||||
expect(checkbox).not.toBeChecked()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should mark all item as to be returned when checkbox is checked", async () => {
|
|
||||||
const checkboxes = screen.getAllByRole("checkbox")
|
|
||||||
|
|
||||||
// Checkbox to select all items
|
|
||||||
const checkbox = checkboxes[0]
|
|
||||||
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
await user.click(checkbox)
|
|
||||||
|
|
||||||
expect(checkbox).toBeChecked()
|
|
||||||
|
|
||||||
const { return_items } = form.getValues()
|
|
||||||
|
|
||||||
// expect all items to be marked for return
|
|
||||||
for (const item of return_items.items) {
|
|
||||||
expect(item.return).toBeTruthy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should only mark the first item as to be returned", async () => {
|
|
||||||
const checkboxes = screen.getAllByRole("checkbox")
|
|
||||||
|
|
||||||
// Checkbox to select first item
|
|
||||||
const checkbox = checkboxes[1]
|
|
||||||
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
await user.click(checkbox)
|
|
||||||
|
|
||||||
expect(checkbox).toBeChecked()
|
|
||||||
|
|
||||||
const { return_items } = form.getValues()
|
|
||||||
|
|
||||||
// expect first item to be marked for return
|
|
||||||
expect(return_items.items[0].return).toBeTruthy()
|
|
||||||
|
|
||||||
// expect all other items to not be marked for return
|
|
||||||
for (const item of return_items.items.slice(1)) {
|
|
||||||
expect(item.return).toBeFalsy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should update quantity correctly", async () => {
|
|
||||||
const checkboxes = screen.getAllByRole("checkbox")
|
|
||||||
const checkbox = checkboxes[1]
|
|
||||||
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
await user.click(checkbox)
|
|
||||||
|
|
||||||
expect(checkbox).toBeChecked()
|
|
||||||
|
|
||||||
const decrement = screen.getByLabelText("Decrease quantity")
|
|
||||||
|
|
||||||
await user.click(decrement)
|
|
||||||
|
|
||||||
const { return_items } = form.getValues()
|
|
||||||
|
|
||||||
expect(return_items.items[0].quantity).toEqual(1)
|
|
||||||
|
|
||||||
const increment = screen.getByLabelText("Increase quantity")
|
|
||||||
|
|
||||||
await user.click(increment)
|
|
||||||
|
|
||||||
// should return to initial quantity
|
|
||||||
expect(return_items.items[0].quantity).toEqual(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Order } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import ItemsToSendForm from ".."
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
|
|
||||||
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
describe("ItemsToSendForm with RegisterClaimMenu", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: getDefaultClaimValues(order),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
form.setValue("additional_items.items", [
|
|
||||||
{
|
|
||||||
in_stock: 100,
|
|
||||||
original_price: 10000,
|
|
||||||
price: 10000,
|
|
||||||
product_title: "Test",
|
|
||||||
quantity: 1,
|
|
||||||
variant_id: "test",
|
|
||||||
variant_title: "Test",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<ItemsToSendForm
|
|
||||||
form={nestedForm(form, "additional_items")}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly", async () => {
|
|
||||||
expect(screen.getByText("Items to send")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Add products")).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should display products to send correctly", async () => {
|
|
||||||
expect(screen.getByText("Test")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("€100.00")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("1")).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should update quantity correctly", async () => {
|
|
||||||
const { additional_items } = form.getValues()
|
|
||||||
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const increment = screen.getByLabelText("Increase quantity")
|
|
||||||
|
|
||||||
await user.click(increment)
|
|
||||||
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument()
|
|
||||||
expect(additional_items.items[0].quantity).toEqual(2)
|
|
||||||
|
|
||||||
await user.click(increment)
|
|
||||||
|
|
||||||
expect(screen.getByText("3")).toBeInTheDocument()
|
|
||||||
expect(additional_items.items[0].quantity).toEqual(3)
|
|
||||||
|
|
||||||
const decrement = screen.getByLabelText("Decrease quantity")
|
|
||||||
|
|
||||||
await user.click(decrement)
|
|
||||||
|
|
||||||
expect(screen.getByText("2")).toBeInTheDocument()
|
|
||||||
expect(additional_items.items[0].quantity).toEqual(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Order } from "@medusajs/medusa"
|
|
||||||
import { fireEvent, renderHook, screen, waitFor } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import RefundAmountForm from ".."
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
|
|
||||||
|
|
||||||
describe("RefundAmountForm refund claim", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: getDefaultClaimValues(order),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<RefundAmountForm
|
|
||||||
form={nestedForm(form, "refund_amount")}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly", async () => {
|
|
||||||
// Initial value should be 0
|
|
||||||
expect(screen.getByText("€0.00")).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should update value when input is changed", async () => {
|
|
||||||
const button = screen.getByLabelText("Edit refund amount")
|
|
||||||
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
await user.click(button)
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText("-")
|
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: "100" } })
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const {
|
|
||||||
refund_amount: { amount },
|
|
||||||
} = form.getValues()
|
|
||||||
|
|
||||||
// We enter 100, but the value should be 10000 since we are transforming from dollars to cents
|
|
||||||
expect(amount).toEqual(10000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { Order, ShippingOption } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen } from "@testing-library/react"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { ClaimSummary } from "../claim-summary"
|
|
||||||
|
|
||||||
describe("ClaimSummary", () => {
|
|
||||||
let order: Order
|
|
||||||
let so: ShippingOption
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
order = fixtures.get("order") as unknown as Order
|
|
||||||
so = fixtures.get("shipping_option") as unknown as ShippingOption
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: {
|
|
||||||
return_items: {
|
|
||||||
items: fixtures.get("order").items.map((item) => ({
|
|
||||||
item_id: item.id,
|
|
||||||
quantity: item.quantity,
|
|
||||||
return: true,
|
|
||||||
refundable: 90000,
|
|
||||||
total: 90000,
|
|
||||||
original_quantity: item.quantity,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
additional_items: {
|
|
||||||
items: fixtures.list("line_item", 5).map((item) => ({
|
|
||||||
item_id: item.id,
|
|
||||||
quantity: item.quantity,
|
|
||||||
price: 10000,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
replacement_shipping: {
|
|
||||||
option: {
|
|
||||||
label: so.name,
|
|
||||||
value: {
|
|
||||||
id: so.id,
|
|
||||||
taxRate: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
return_shipping: {
|
|
||||||
option: {
|
|
||||||
label: so.name,
|
|
||||||
value: {
|
|
||||||
id: so.id,
|
|
||||||
taxRate: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
claim_type: {
|
|
||||||
type: "replace",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
renderWithProviders(<ClaimSummary order={order} form={result.current} />)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render both a return and replacement shipping option", async () => {
|
|
||||||
expect(screen.getAllByText(so.name)).toHaveLength(2)
|
|
||||||
|
|
||||||
expect(screen.getByText("Return shipping")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Replacement shipping")).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText("Free")).toHaveLength(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { renderHook, screen } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import SendNotificationForm from ".."
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
|
|
||||||
describe("SendNotificationForm", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: {
|
|
||||||
notification: {
|
|
||||||
send_notification: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<SendNotificationForm
|
|
||||||
type="claim"
|
|
||||||
form={nestedForm(form, "notification")}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render initial value correctly", async () => {
|
|
||||||
const checkbox = screen.getByRole("checkbox")
|
|
||||||
|
|
||||||
expect(checkbox).toBeChecked()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should update the value when the checkbox is clicked", async () => {
|
|
||||||
const checkbox = screen.getByRole("checkbox")
|
|
||||||
const user = userEvent.setup()
|
|
||||||
|
|
||||||
await user.click(checkbox)
|
|
||||||
|
|
||||||
const {
|
|
||||||
notification: { send_notification },
|
|
||||||
} = form.getValues()
|
|
||||||
|
|
||||||
expect(send_notification).toEqual(false)
|
|
||||||
expect(checkbox).not.toBeChecked()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Order } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen, waitFor } from "@testing-library/react"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import ShippingAddressForm from ".."
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
|
|
||||||
|
|
||||||
describe("ShippingAddressForm with RegisterClaimMenu", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: getDefaultClaimValues(order),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<ShippingAddressForm
|
|
||||||
form={nestedForm(form, "shipping_address")}
|
|
||||||
order={order}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render the initial address correctly", async () => {
|
|
||||||
expect(screen.getByText("Shipping address")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Faker Street 1, 3 Floor")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Medusa JS, 2100 Copenhagen")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("Denmark")).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render the address correctly when the address is changed", async () => {
|
|
||||||
await waitFor(() => {
|
|
||||||
form.setValue("shipping_address.address_1", "123 Second St")
|
|
||||||
form.setValue("shipping_address.address_2", "Apt 2")
|
|
||||||
})
|
|
||||||
|
|
||||||
const {
|
|
||||||
shipping_address: { address_1, address_2 },
|
|
||||||
} = form.getValues()
|
|
||||||
|
|
||||||
expect(address_1).toEqual("123 Second St")
|
|
||||||
expect(address_2).toEqual("Apt 2")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Order, ShippingOption } from "@medusajs/medusa"
|
|
||||||
import { renderHook, screen, waitFor } from "@testing-library/react"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"
|
|
||||||
import { useForm, UseFormReturn } from "react-hook-form"
|
|
||||||
import ShippingForm from ".."
|
|
||||||
import { fixtures } from "../../../../../../test/fixtures"
|
|
||||||
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
|
|
||||||
import { nestedForm } from "../../../../../utils/nested-form"
|
|
||||||
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
|
|
||||||
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
|
|
||||||
|
|
||||||
const selectFirstOption = async (user: UserEvent) => {
|
|
||||||
const combobox = screen.getByRole("combobox")
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
combobox.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
await user.keyboard("{arrowdown}")
|
|
||||||
|
|
||||||
// Go to first option and select
|
|
||||||
await user.keyboard("{arrowdown}")
|
|
||||||
await user.keyboard("{Enter}")
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ShippingForm return shipping", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: getDefaultClaimValues(order),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<div>
|
|
||||||
<ShippingForm
|
|
||||||
order={order}
|
|
||||||
isClaim
|
|
||||||
isReturn
|
|
||||||
form={nestedForm(form, "return_shipping")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly when type is refund", async () => {
|
|
||||||
expect(screen.getByText("Shipping for return items"))
|
|
||||||
expect(screen.queryByText("Shipping for replacement items")).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render options when dropdown is opened", async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const combobox = screen.getByRole("combobox")
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
combobox.focus()
|
|
||||||
})
|
|
||||||
|
|
||||||
await user.keyboard("{arrowdown}")
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByText("Free Shipping")).toHaveLength(5)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should select an option when clicked", async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
await selectFirstOption(user)
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByText("Free Shipping")).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
const { return_shipping } = form.getValues()
|
|
||||||
|
|
||||||
expect(return_shipping.option?.label).toEqual("Free Shipping")
|
|
||||||
expect(return_shipping.option?.value).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: expect.any(String),
|
|
||||||
taxRate: 0,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly when option is selected", async () => {
|
|
||||||
const shippingOption = fixtures.get(
|
|
||||||
"shipping_option"
|
|
||||||
) as unknown as ShippingOption
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
form.setValue("return_shipping.option", {
|
|
||||||
label: shippingOption.name,
|
|
||||||
value: {
|
|
||||||
id: shippingOption.id,
|
|
||||||
taxRate: 0.12,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(shippingOption.name)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("ShippingForm return shipping", () => {
|
|
||||||
let form: UseFormReturn<CreateClaimFormType, any>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const order = fixtures.get("order") as unknown as Order
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useForm<CreateClaimFormType>({
|
|
||||||
defaultValues: {
|
|
||||||
...getDefaultClaimValues(order),
|
|
||||||
claim_type: {
|
|
||||||
type: "replace",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
form = result.current
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<div>
|
|
||||||
<ShippingForm
|
|
||||||
order={order}
|
|
||||||
isClaim
|
|
||||||
form={nestedForm(form, "replacement_shipping")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should render correctly when type is replace", async () => {
|
|
||||||
expect(screen.getByText("Shipping for replacement items"))
|
|
||||||
expect(screen.queryByText("Shipping for return items")).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
Order,
|
Order,
|
||||||
VariantInventory,
|
VariantInventory,
|
||||||
} from "@medusajs/medusa"
|
} from "@medusajs/medusa"
|
||||||
import { DisplayTotal, PaymentDetails } from "../templates"
|
|
||||||
import React, { useContext, useMemo } from "react"
|
import React, { useContext, useMemo } from "react"
|
||||||
|
import { DisplayTotal, PaymentDetails } from "../templates"
|
||||||
|
|
||||||
import { ActionType } from "../../../../components/molecules/actionables"
|
import { ActionType } from "../../../../components/molecules/actionables"
|
||||||
import Badge from "../../../../components/fundamentals/badge"
|
import Badge from "../../../../components/fundamentals/badge"
|
||||||
@@ -15,11 +15,11 @@ import OrderLine from "../order-line"
|
|||||||
import { ReservationItemDTO } from "@medusajs/types"
|
import { ReservationItemDTO } from "@medusajs/types"
|
||||||
import ReserveItemsModal from "../reservation/reserve-items-modal"
|
import ReserveItemsModal from "../reservation/reserve-items-modal"
|
||||||
import { Response } from "@medusajs/medusa-js"
|
import { Response } from "@medusajs/medusa-js"
|
||||||
import StatusIndicator from "../../../../components/fundamentals/status-indicator"
|
|
||||||
import { sum } from "lodash"
|
import { sum } from "lodash"
|
||||||
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
|
|
||||||
import { useMedusa } from "medusa-react"
|
import { useMedusa } from "medusa-react"
|
||||||
|
import StatusIndicator from "../../../../components/fundamentals/status-indicator"
|
||||||
import useToggleState from "../../../../hooks/use-toggle-state"
|
import useToggleState from "../../../../hooks/use-toggle-state"
|
||||||
|
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
|
||||||
|
|
||||||
type SummaryCardProps = {
|
type SummaryCardProps = {
|
||||||
order: Order
|
order: Order
|
||||||
@@ -168,7 +168,7 @@ const SummaryCard: React.FC<SummaryCardProps> = ({ order, reservations }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 h-auto min-h-0 w-full"}
|
className={"h-auto min-h-0 w-full"}
|
||||||
title="Summary"
|
title="Summary"
|
||||||
status={
|
status={
|
||||||
isFeatureEnabled("inventoryService") &&
|
isFeatureEnabled("inventoryService") &&
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ import { useEffect, useMemo, useState } from "react"
|
|||||||
import { useHotkeys } from "react-hotkeys-hook"
|
import { useHotkeys } from "react-hotkeys-hook"
|
||||||
import Avatar from "../../../components/atoms/avatar"
|
import Avatar from "../../../components/atoms/avatar"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
|
import Spacer from "../../../components/atoms/spacer"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
import Tooltip from "../../../components/atoms/tooltip"
|
import Tooltip from "../../../components/atoms/tooltip"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import Button from "../../../components/fundamentals/button"
|
import Button from "../../../components/fundamentals/button"
|
||||||
import DetailsIcon from "../../../components/fundamentals/details-icon"
|
import DetailsIcon from "../../../components/fundamentals/details-icon"
|
||||||
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
|
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
|
||||||
@@ -54,6 +56,7 @@ import useImperativeDialog from "../../../hooks/use-imperative-dialog"
|
|||||||
import useNotification from "../../../hooks/use-notification"
|
import useNotification from "../../../hooks/use-notification"
|
||||||
import useToggleState from "../../../hooks/use-toggle-state"
|
import useToggleState from "../../../hooks/use-toggle-state"
|
||||||
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import { isoAlpha2Countries } from "../../../utils/countries"
|
import { isoAlpha2Countries } from "../../../utils/countries"
|
||||||
import { getErrorMessage } from "../../../utils/error-messages"
|
import { getErrorMessage } from "../../../utils/error-messages"
|
||||||
import extractCustomerName from "../../../utils/extract-customer-name"
|
import extractCustomerName from "../../../utils/extract-customer-name"
|
||||||
@@ -196,6 +199,8 @@ const OrderDetails = () => {
|
|||||||
useHotkeys("esc", () => navigate("/a/orders"))
|
useHotkeys("esc", () => navigate("/a/orders"))
|
||||||
useHotkeys("command+i", handleCopy)
|
useHotkeys("command+i", handleCopy)
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const handleDeleteOrder = async () => {
|
const handleDeleteOrder = async () => {
|
||||||
const shouldDelete = await dialog({
|
const shouldDelete = await dialog({
|
||||||
heading: "Cancel order",
|
heading: "Cancel order",
|
||||||
@@ -296,10 +301,22 @@ const OrderDetails = () => {
|
|||||||
</BodyCard>
|
</BodyCard>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div>
|
||||||
|
{getWidgets("order.details.before").map((widget, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
injectionZone={"order.details.before"}
|
||||||
|
widget={widget}
|
||||||
|
entity={order}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<div className="flex h-full w-7/12 flex-col">
|
<div className="gap-y-base flex h-full w-7/12 flex-col">
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 min-h-[200px] w-full"}
|
className={"min-h-[200px] w-full"}
|
||||||
customHeader={
|
customHeader={
|
||||||
<Tooltip side="top" content={"Copy ID"}>
|
<Tooltip side="top" content={"Copy ID"}>
|
||||||
<button
|
<button
|
||||||
@@ -359,7 +376,7 @@ const OrderDetails = () => {
|
|||||||
<SummaryCard order={order} reservations={reservations || []} />
|
<SummaryCard order={order} reservations={reservations || []} />
|
||||||
|
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 h-auto min-h-0 w-full"}
|
className={"h-auto min-h-0 w-full"}
|
||||||
title="Payment"
|
title="Payment"
|
||||||
status={
|
status={
|
||||||
<PaymentStatusComponent status={order.payment_status} />
|
<PaymentStatusComponent status={order.payment_status} />
|
||||||
@@ -428,7 +445,7 @@ const OrderDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 h-auto min-h-0 w-full"}
|
className={"h-auto min-h-0 w-full"}
|
||||||
title="Fulfillment"
|
title="Fulfillment"
|
||||||
status={
|
status={
|
||||||
<FulfillmentStatusComponent
|
<FulfillmentStatusComponent
|
||||||
@@ -475,7 +492,7 @@ const OrderDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 h-auto min-h-0 w-full"}
|
className={"h-auto min-h-0 w-full"}
|
||||||
title="Customer"
|
title="Customer"
|
||||||
actionables={customerActionables}
|
actionables={customerActionables}
|
||||||
>
|
>
|
||||||
@@ -525,9 +542,20 @@ const OrderDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
<div className="mt-large">
|
<div>
|
||||||
<RawJSON data={order} title="Raw order" />
|
{getWidgets("order.details.after").map((widget, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
injectionZone={"order.details.after"}
|
||||||
|
widget={widget}
|
||||||
|
entity={order}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<RawJSON data={order} title="Raw order" />
|
||||||
|
<Spacer />
|
||||||
</div>
|
</div>
|
||||||
<Timeline orderId={order.id} />
|
<Timeline orderId={order.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Avatar from "../../../components/atoms/avatar"
|
|||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
import CopyToClipboard from "../../../components/atoms/copy-to-clipboard"
|
import CopyToClipboard from "../../../components/atoms/copy-to-clipboard"
|
||||||
import Spinner from "../../../components/atoms/spinner"
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import Button from "../../../components/fundamentals/button"
|
import Button from "../../../components/fundamentals/button"
|
||||||
import DetailsIcon from "../../../components/fundamentals/details-icon"
|
import DetailsIcon from "../../../components/fundamentals/details-icon"
|
||||||
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
|
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
|
||||||
@@ -24,6 +25,7 @@ import ConfirmationPrompt from "../../../components/organisms/confirmation-promp
|
|||||||
import DeletePrompt from "../../../components/organisms/delete-prompt"
|
import DeletePrompt from "../../../components/organisms/delete-prompt"
|
||||||
import { AddressType } from "../../../components/templates/address-form"
|
import { AddressType } from "../../../components/templates/address-form"
|
||||||
import useNotification from "../../../hooks/use-notification"
|
import useNotification from "../../../hooks/use-notification"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import { isoAlpha2Countries } from "../../../utils/countries"
|
import { isoAlpha2Countries } from "../../../utils/countries"
|
||||||
import { getErrorMessage } from "../../../utils/error-messages"
|
import { getErrorMessage } from "../../../utils/error-messages"
|
||||||
import extractCustomerName from "../../../utils/extract-customer-name"
|
import extractCustomerName from "../../../utils/extract-customer-name"
|
||||||
@@ -119,6 +121,11 @@ const DraftOrderDetails = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
|
const afterWidgets = getWidgets("draft_order.details.after")
|
||||||
|
const beforeWidgets = getWidgets("draft_order.details.before")
|
||||||
|
|
||||||
const { cart } = draft_order || {}
|
const { cart } = draft_order || {}
|
||||||
const { region } = cart || {}
|
const { region } = cart || {}
|
||||||
|
|
||||||
@@ -136,6 +143,21 @@ const DraftOrderDetails = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
|
{beforeWidgets?.length > 0 && (
|
||||||
|
<div className="mb-4 flex w-full flex-col gap-y-4">
|
||||||
|
{beforeWidgets.map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="draft_order.details.before"
|
||||||
|
entity={draft_order}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 min-h-[200px] w-full"}
|
className={"mb-4 min-h-[200px] w-full"}
|
||||||
title={`Order #${draft_order.display_id}`}
|
title={`Order #${draft_order.display_id}`}
|
||||||
@@ -359,6 +381,20 @@ const DraftOrderDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
|
{afterWidgets?.length > 0 && (
|
||||||
|
<div className="mb-4 flex w-full flex-col gap-y-4">
|
||||||
|
{afterWidgets.map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
widget={w}
|
||||||
|
injectionZone="draft_order.details.after"
|
||||||
|
entity={draft_order}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<BodyCard
|
<BodyCard
|
||||||
className={"mb-4 h-auto min-h-0 w-full pt-[15px]"}
|
className={"mb-4 h-auto min-h-0 w-full pt-[15px]"}
|
||||||
title="Raw Draft Order"
|
title="Raw Draft Order"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Route, Routes, useNavigate } from "react-router-dom"
|
import { Route, Routes, useNavigate } from "react-router-dom"
|
||||||
import Spacer from "../../../components/atoms/spacer"
|
import Spacer from "../../../components/atoms/spacer"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
|
|
||||||
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
|
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
|
||||||
import BodyCard from "../../../components/organisms/body-card"
|
import BodyCard from "../../../components/organisms/body-card"
|
||||||
import TableViewHeader from "../../../components/organisms/custom-table-header"
|
import TableViewHeader from "../../../components/organisms/custom-table-header"
|
||||||
import DraftOrderTable from "../../../components/templates/draft-order-table"
|
import DraftOrderTable from "../../../components/templates/draft-order-table"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
import NewOrderFormProvider from "../new/form"
|
import NewOrderFormProvider from "../new/form"
|
||||||
import NewOrder from "../new/new-order"
|
import NewOrder from "../new/new-order"
|
||||||
import DraftOrderDetails from "./details"
|
import DraftOrderDetails from "./details"
|
||||||
@@ -18,6 +20,8 @@ const DraftOrderIndex = () => {
|
|||||||
const view = "drafts"
|
const view = "drafts"
|
||||||
const [showNewOrder, setShowNewOrder] = useState(false)
|
const [showNewOrder, setShowNewOrder] = useState(false)
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const actions = useMemo(() => {
|
const actions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -29,7 +33,17 @@ const DraftOrderIndex = () => {
|
|||||||
}, [view])
|
}, [view])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full grow flex-col">
|
<div className="gap-y-xsmall flex h-full grow flex-col">
|
||||||
|
{getWidgets("draft_order.list.before").map((Widget, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={null}
|
||||||
|
injectionZone="draft_order.list.before"
|
||||||
|
widget={Widget}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<BodyCard
|
<BodyCard
|
||||||
customHeader={
|
customHeader={
|
||||||
@@ -48,8 +62,18 @@ const DraftOrderIndex = () => {
|
|||||||
>
|
>
|
||||||
<DraftOrderTable />
|
<DraftOrderTable />
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
<Spacer />
|
|
||||||
</div>
|
</div>
|
||||||
|
{getWidgets("draft_order.list.after").map((Widget, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={null}
|
||||||
|
injectionZone="draft_order.list.after"
|
||||||
|
widget={Widget}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Spacer />
|
||||||
{showNewOrder && (
|
{showNewOrder && (
|
||||||
<NewOrderFormProvider>
|
<NewOrderFormProvider>
|
||||||
<NewOrder onDismiss={() => setShowNewOrder(false)} />
|
<NewOrder onDismiss={() => setShowNewOrder(false)} />
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Route, Routes, useNavigate } from "react-router-dom"
|
|||||||
|
|
||||||
import { useAdminCreateBatchJob } from "medusa-react"
|
import { useAdminCreateBatchJob } from "medusa-react"
|
||||||
import Spacer from "../../components/atoms/spacer"
|
import Spacer from "../../components/atoms/spacer"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import WidgetContainer from "../../components/extensions/widget-container"
|
||||||
import Button from "../../components/fundamentals/button"
|
import Button from "../../components/fundamentals/button"
|
||||||
import ExportIcon from "../../components/fundamentals/icons/export-icon"
|
import ExportIcon from "../../components/fundamentals/icons/export-icon"
|
||||||
import BodyCard from "../../components/organisms/body-card"
|
import BodyCard from "../../components/organisms/body-card"
|
||||||
@@ -12,6 +14,8 @@ import OrderTable from "../../components/templates/order-table"
|
|||||||
import useNotification from "../../hooks/use-notification"
|
import useNotification from "../../hooks/use-notification"
|
||||||
import useToggleState from "../../hooks/use-toggle-state"
|
import useToggleState from "../../hooks/use-toggle-state"
|
||||||
import { usePolling } from "../../providers/polling-provider"
|
import { usePolling } from "../../providers/polling-provider"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
|
import { useWidgets } from "../../providers/widget-provider"
|
||||||
import { getErrorMessage } from "../../utils/error-messages"
|
import { getErrorMessage } from "../../utils/error-messages"
|
||||||
import Details from "./details"
|
import Details from "./details"
|
||||||
import { transformFiltersAsExportContext } from "./utils"
|
import { transformFiltersAsExportContext } from "./utils"
|
||||||
@@ -35,6 +39,8 @@ const OrderIndex = () => {
|
|||||||
state: exportModalOpen,
|
state: exportModalOpen,
|
||||||
} = useToggleState(false)
|
} = useToggleState(false)
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
const actions = useMemo(() => {
|
const actions = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
<Button
|
<Button
|
||||||
@@ -73,7 +79,17 @@ const OrderIndex = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full grow flex-col">
|
<div className="gap-y-xsmall flex h-full grow flex-col">
|
||||||
|
{getWidgets("order.list.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
injectionZone={"order.list.before"}
|
||||||
|
widget={w}
|
||||||
|
entity={undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<BodyCard
|
<BodyCard
|
||||||
customHeader={
|
customHeader={
|
||||||
@@ -92,8 +108,18 @@ const OrderIndex = () => {
|
|||||||
>
|
>
|
||||||
<OrderTable setContextFilters={setContextFilters} />
|
<OrderTable setContextFilters={setContextFilters} />
|
||||||
</BodyCard>
|
</BodyCard>
|
||||||
<Spacer />
|
|
||||||
</div>
|
</div>
|
||||||
|
{getWidgets("order.list.after").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
injectionZone={"order.list.after"}
|
||||||
|
widget={w}
|
||||||
|
entity={undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Spacer />
|
||||||
</div>
|
</div>
|
||||||
{exportModalOpen && (
|
{exportModalOpen && (
|
||||||
<ExportModal
|
<ExportModal
|
||||||
@@ -108,10 +134,23 @@ const OrderIndex = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Orders = () => {
|
const Orders = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/products")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<OrderIndex />} />
|
<Route index element={<OrderIndex />} />
|
||||||
<Route path="/:id" element={<Details />} />
|
<Route path="/:id" element={<Details />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={"/orders"} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ function ImportPrices(props: ImportPricesProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateLink = process.env.ADMIN_PATH
|
||||||
|
? `${process.env.ADMIN_PATH}/temp/price-list-import-template.csv`
|
||||||
|
: `/temp/price-list-import-template.csv`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UploadModal
|
<UploadModal
|
||||||
type="prices"
|
type="prices"
|
||||||
@@ -186,7 +190,7 @@ function ImportPrices(props: ImportPricesProps) {
|
|||||||
summary={getSummary()}
|
summary={getSummary()}
|
||||||
onFileRemove={onFileRemove}
|
onFileRemove={onFileRemove}
|
||||||
processUpload={processUpload}
|
processUpload={processUpload}
|
||||||
templateLink="/temp/price-list-import-template.csv"
|
templateLink={templateLink}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useAdminPriceList } from "medusa-react"
|
import { useAdminPriceList } from "medusa-react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useNavigate, useParams } from "react-router-dom"
|
||||||
import BackButton from "../../../components/atoms/back-button"
|
import BackButton from "../../../components/atoms/back-button"
|
||||||
|
import Spinner from "../../../components/atoms/spinner"
|
||||||
|
import WidgetContainer from "../../../components/extensions/widget-container"
|
||||||
import RawJSON from "../../../components/organisms/raw-json"
|
import RawJSON from "../../../components/organisms/raw-json"
|
||||||
|
import { useWidgets } from "../../../providers/widget-provider"
|
||||||
|
import { getErrorStatus } from "../../../utils/get-error-status"
|
||||||
import { mapPriceListToFormValues } from "../pricing-form/form/mappers"
|
import { mapPriceListToFormValues } from "../pricing-form/form/mappers"
|
||||||
import { PriceListFormProvider } from "../pricing-form/form/pricing-form-context"
|
import { PriceListFormProvider } from "../pricing-form/form/pricing-form-context"
|
||||||
import Header from "./sections/header"
|
import Header from "./sections/header"
|
||||||
@@ -9,8 +13,33 @@ import PricesDetails from "./sections/prices-details"
|
|||||||
|
|
||||||
const PricingDetails = () => {
|
const PricingDetails = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const { price_list, isLoading } = useAdminPriceList(id!)
|
const { price_list, isLoading, error } = useAdminPriceList(id!)
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorStatus = getErrorStatus(error)
|
||||||
|
|
||||||
|
if (errorStatus) {
|
||||||
|
// If the product is not found, redirect to the 404 page
|
||||||
|
if (errorStatus.status === 404) {
|
||||||
|
navigate("/404")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the error boundary handle the error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !price_list) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
|
||||||
|
<Spinner variant="secondary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-large">
|
<div className="pb-large">
|
||||||
@@ -19,18 +48,37 @@ const PricingDetails = () => {
|
|||||||
path="/a/pricing"
|
path="/a/pricing"
|
||||||
className="mb-xsmall"
|
className="mb-xsmall"
|
||||||
/>
|
/>
|
||||||
|
<PriceListFormProvider priceList={mapPriceListToFormValues(price_list)}>
|
||||||
|
<div className="gap-y-xsmall flex flex-col">
|
||||||
|
{getWidgets("price_list.details.before").map((w, i) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={i}
|
||||||
|
entity={price_list}
|
||||||
|
injectionZone="price_list.details.before"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{!isLoading && price_list ? (
|
<Header priceList={price_list} />
|
||||||
<PriceListFormProvider priceList={mapPriceListToFormValues(price_list)}>
|
|
||||||
<div className="gap-y-xsmall flex flex-col">
|
|
||||||
<Header priceList={price_list} />
|
|
||||||
|
|
||||||
<PricesDetails id={price_list?.id} />
|
<PricesDetails id={price_list?.id} />
|
||||||
|
|
||||||
<RawJSON data={price_list} title="Raw price list" />
|
{getWidgets("price_list.details.after").map((w, i) => {
|
||||||
</div>
|
return (
|
||||||
</PriceListFormProvider>
|
<WidgetContainer
|
||||||
) : null}
|
key={i}
|
||||||
|
entity={price_list}
|
||||||
|
injectionZone="price_list.details.after"
|
||||||
|
widget={w}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<RawJSON data={price_list} title="Raw price list" />
|
||||||
|
</div>
|
||||||
|
</PriceListFormProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Route, Routes, useNavigate } from "react-router-dom"
|
import { Route, Routes, useNavigate } from "react-router-dom"
|
||||||
import Spacer from "../../components/atoms/spacer"
|
import Spacer from "../../components/atoms/spacer"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import WidgetContainer from "../../components/extensions/widget-container"
|
||||||
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
|
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
|
||||||
import BodyCard from "../../components/organisms/body-card"
|
import BodyCard from "../../components/organisms/body-card"
|
||||||
import TableViewHeader from "../../components/organisms/custom-table-header"
|
import TableViewHeader from "../../components/organisms/custom-table-header"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
|
import { useWidgets } from "../../providers/widget-provider"
|
||||||
import PricingDetails from "./details"
|
import PricingDetails from "./details"
|
||||||
import New from "./new"
|
import New from "./new"
|
||||||
import PricingTable from "./pricing-table"
|
import PricingTable from "./pricing-table"
|
||||||
@@ -18,8 +22,20 @@ const PricingIndex = () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const { getWidgets } = useWidgets()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="gap-y-xsmall flex h-full flex-col">
|
||||||
|
{getWidgets("price_list.list.before").map((w, index) => {
|
||||||
|
return (
|
||||||
|
<WidgetContainer
|
||||||
|
key={index}
|
||||||
|
widget={w}
|
||||||
|
entity={null}
|
||||||
|
injectionZone="price_list.list.before"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<div className="flex w-full grow flex-col">
|
<div className="flex w-full grow flex-col">
|
||||||
<BodyCard
|
<BodyCard
|
||||||
actionables={actionables}
|
actionables={actionables}
|
||||||
@@ -35,11 +51,24 @@ const PricingIndex = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Pricing = () => {
|
const Pricing = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/pricing")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<PricingIndex />} />
|
<Route index element={<PricingIndex />} />
|
||||||
<Route path="/new" element={<New />} />
|
<Route path="/new" element={<New />} />
|
||||||
<Route path="/:id" element={<PricingDetails />} />
|
<Route path="/:id" element={<PricingDetails />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={<RouteContainer route={r} previousPath={"/pricing"} />}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
import RouteContainer from "../../components/extensions/route-container"
|
||||||
|
import { useRoutes } from "../../providers/route-provider"
|
||||||
|
|
||||||
import ProductCategoryIndex from "./pages"
|
import ProductCategoryIndex from "./pages"
|
||||||
|
|
||||||
const ProductCategories = () => {
|
const ProductCategories = () => {
|
||||||
|
const { getNestedRoutes } = useRoutes()
|
||||||
|
|
||||||
|
const nestedRoutes = getNestedRoutes("/product-categories")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<ProductCategoryIndex />} />
|
<Route index element={<ProductCategoryIndex />} />
|
||||||
|
{nestedRoutes.map((r, i) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
path={r.path}
|
||||||
|
key={i}
|
||||||
|
element={
|
||||||
|
<RouteContainer route={r} previousPath={"/product-categories"} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ function ImportProducts(props: ImportProductsProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateLink = process.env.ADMIN_PATH
|
||||||
|
? `${process.env.ADMIN_PATH}/temp/product-import-template.csv`
|
||||||
|
: "/temp/product-import-template.csv"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UploadModal
|
<UploadModal
|
||||||
type="products"
|
type="products"
|
||||||
@@ -190,7 +194,7 @@ function ImportProducts(props: ImportProductsProps) {
|
|||||||
onFileRemove={onFileRemove}
|
onFileRemove={onFileRemove}
|
||||||
processUpload={processUpload}
|
processUpload={processUpload}
|
||||||
fileTitle={"products list"}
|
fileTitle={"products list"}
|
||||||
templateLink="/temp/product-import-template.csv"
|
templateLink={templateLink}
|
||||||
errorMessage={batchJob?.result?.errors?.join(" \n")}
|
errorMessage={batchJob?.result?.errors?.join(" \n")}
|
||||||
description2Title="Unsure about how to arrange your list?"
|
description2Title="Unsure about how to arrange your list?"
|
||||||
description2Text="Download the template below to ensure you are following the correct format."
|
description2Text="Download the template below to ensure you are following the correct format."
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user