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:
Kasper Fabricius Kristensen
2023-08-17 14:14:45 +02:00
committed by GitHub
parent 26c78bbc03
commit f1a05f4725
189 changed files with 14570 additions and 12773 deletions

View 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

View File

@@ -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: "^_",
},
],
},
},
], ],
} }

View File

@@ -1,4 +1,5 @@
/dist /dist
/build /build
.vercel .vercel
/ui/preview /ui/preview
/ui/src/extensions

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

View File

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

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

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

View File

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

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

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

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

View File

@@ -0,0 +1,5 @@
import { build } from "./build"
import { clean } from "./clean"
import { develop } from "./develop"
export { clean, build, develop }

View 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

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

View 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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
import { AdminBuildConfig } from "./build"
export type AdminUIConfig = {
build?: AdminBuildConfig
}

View File

@@ -1,4 +0,0 @@
export type AdminDevConfig = {
backend?: string
port?: number
}

View File

@@ -1,3 +0,0 @@
export * from "./build"
export * from "./dev"
export * from "./misc"

View File

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

View File

@@ -1,9 +0,0 @@
import { Base } from "../types"
export const formatBase = <T extends string>(base?: T): Base<T> => {
if (!base) {
return undefined
}
return `/${base}/`
}

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
export * from "./format-base"
export * from "./get-custom-vite-config"
export * from "./get-custom-vite-dev-config"

View File

@@ -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__"]
} }

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

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

View File

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

View File

@@ -26,7 +26,7 @@ const router = createBrowserRouter(
</> </>
), ),
{ {
basename: __BASE__, basename: process.env.ADMIN_PATH,
} }
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
export const useSettingContainerProps = () => {
const baseProps = useExtensionBaseProps()
return baseProps
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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") &&

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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