chore: move next admin packages to core repo (#5983)
**What** - Move packages for `next` version of admin to core repo **Other** - Since this PR introduces packages that depend on Vite 5, it also introduces @types/node@^20. We have never had a direct dependency on the types package for Node, and as far as I can see that has resulted in us using the types from Node.js@8, as those are a dependency of one of our dependencies. With the introduction of @types/node@^20, two of our packages had TS errors because they were using the NodeJS.Timer type, which was deprecated in Node.js@14. We should add specific @types/node packages to all our packages, but I haven't done so in this PR to keep it as clean as possible. - Q: @olivermrbl I've added the new packages to the ignore list for changeset, is this enough to prevent them from being published?
This commit is contained in:
committed by
GitHub
parent
479a8b82a9
commit
f868775861
@@ -7,12 +7,19 @@
|
||||
"access": "public",
|
||||
"baseBranch": "develop",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["integration-tests-api", "integration-tests-plugins", "integration-tests-repositories"],
|
||||
"ignore": [
|
||||
"integration-tests-api",
|
||||
"integration-tests-plugins",
|
||||
"integration-tests-repositories",
|
||||
"@medusajs/dashboard",
|
||||
"@medusajs/admin-shared",
|
||||
"@medusajs/admin-bundler",
|
||||
"@medusajs/vite-plugin-extension"
|
||||
],
|
||||
"snapshot": {
|
||||
"useCalculatedVersion": true
|
||||
},
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
7
.changeset/hip-squids-return.md
Normal file
7
.changeset/hip-squids-return.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"create-medusa-app": patch
|
||||
"medusa-core-utils": patch
|
||||
---
|
||||
|
||||
fix(create-medusa-app,medusa-core-utils): Use NodeJS.Timeout instead of NodeJS.Timer as the latter was deprecated in v14.
|
||||
chore(icons): Update icons to latest version.
|
||||
21
.eslintrc.js
21
.eslintrc.js
@@ -224,6 +224,27 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/admin-next/dashboard/**/*"],
|
||||
env: { browser: true, es2020: true, node: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./packages/admin-next/dashboard/tsconfig.json",
|
||||
},
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["packages/admin-ui/lib/**/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"packages/medusa-js",
|
||||
"packages/medusa-react",
|
||||
"packages/*",
|
||||
"packages/admin-next/*",
|
||||
"packages/design-system/*",
|
||||
"packages/generated/*",
|
||||
"packages/oas/*",
|
||||
@@ -38,6 +39,7 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.31.11",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-storybook": "^0.6.12",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"express": "^4.17.1",
|
||||
|
||||
1
packages/admin-next/admin-bundler/README.md
Normal file
1
packages/admin-next/admin-bundler/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# cli
|
||||
6
packages/admin-next/admin-bundler/bin/medusa-admin.js
Executable file
6
packages/admin-next/admin-bundler/bin/medusa-admin.js
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
function start() {
|
||||
return import("../dist/cli/index.mjs");
|
||||
}
|
||||
|
||||
start();
|
||||
34
packages/admin-next/admin-bundler/package.json
Normal file
34
packages/admin-next/admin-bundler/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@medusajs/admin-bundler",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsup"
|
||||
},
|
||||
"bin": {
|
||||
"medusa-admin": "./bin/medusa-admin.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"module": "dist/index.mjs",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"rimraf": "5.0.1",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/ui-preset": "^1.0.2",
|
||||
"@medusajs/vite-plugin-extension": "*",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"commander": "^11.1.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"glob": "^7.1.6",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "5.0.10"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
22
packages/admin-next/admin-bundler/src/api/build.ts
Normal file
22
packages/admin-next/admin-bundler/src/api/build.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { resolve } from "path"
|
||||
import { build as command } from "vite"
|
||||
|
||||
import { createViteConfig } from "./create-vite-config"
|
||||
|
||||
type BuildArgs = {
|
||||
root?: string
|
||||
}
|
||||
|
||||
export async function build({ root }: BuildArgs) {
|
||||
const config = await createViteConfig({
|
||||
build: {
|
||||
outDir: resolve(process.cwd(), "build"),
|
||||
},
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
await command(config)
|
||||
}
|
||||
46
packages/admin-next/admin-bundler/src/api/bundle.ts
Normal file
46
packages/admin-next/admin-bundler/src/api/bundle.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "fs"
|
||||
import glob from "glob"
|
||||
import { relative, resolve } from "path"
|
||||
import { build as command } from "vite"
|
||||
|
||||
type BundleArgs = {
|
||||
root?: string | undefined
|
||||
watch?: boolean | undefined
|
||||
}
|
||||
|
||||
export async function bundle({ watch, root }: BundleArgs) {
|
||||
const resolvedRoot = root
|
||||
? resolve(process.cwd(), root)
|
||||
: resolve(process.cwd(), "src", "admin")
|
||||
|
||||
const files = glob.sync(`${resolvedRoot}/**/*.{ts,tsx,js,jsx}`)
|
||||
|
||||
const input: Record<string, string> = {}
|
||||
for (const file of files) {
|
||||
const relativePath = relative(resolvedRoot, file)
|
||||
input[relativePath] = file
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), "package.json"), "utf-8")
|
||||
)
|
||||
const external = [
|
||||
...Object.keys(packageJson.dependencies),
|
||||
"@medusajs/ui",
|
||||
"@medusajs/ui-preset",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-router-dom",
|
||||
"react-hook-form",
|
||||
]
|
||||
|
||||
await command({
|
||||
build: {
|
||||
watch: watch ? {} : undefined,
|
||||
rollupOptions: {
|
||||
input: input,
|
||||
external: external,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
253
packages/admin-next/admin-bundler/src/api/create-vite-config.ts
Normal file
253
packages/admin-next/admin-bundler/src/api/create-vite-config.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import inject from "@medusajs/vite-plugin-extension"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import deepmerge from "deepmerge"
|
||||
import { createRequire } from "module"
|
||||
import path from "path"
|
||||
import { type Config } from "tailwindcss"
|
||||
import { ContentConfig } from "tailwindcss/types/config"
|
||||
import { InlineConfig, Logger, createLogger, mergeConfig } from "vite"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export async function createViteConfig(
|
||||
inline: InlineConfig
|
||||
): Promise<InlineConfig | null> {
|
||||
const root = process.cwd()
|
||||
const logger = createCustomLogger()
|
||||
|
||||
let dashboardRoot: string | null = null
|
||||
|
||||
try {
|
||||
dashboardRoot = path.dirname(require.resolve("@medusajs/dashboard"))
|
||||
} catch (err) {
|
||||
dashboardRoot = null
|
||||
}
|
||||
|
||||
if (!dashboardRoot) {
|
||||
logger.error(
|
||||
"Unable to find @medusajs/dashboard. Please install it in your project, or specify the root directory."
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const { plugins, userConfig } = (await loadConfig(root, logger)) ?? {}
|
||||
|
||||
let viteConfig: InlineConfig = mergeConfig(inline, {
|
||||
plugins: [
|
||||
react(),
|
||||
inject({
|
||||
sources: plugins,
|
||||
}),
|
||||
],
|
||||
configFile: false,
|
||||
root: dashboardRoot,
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
require("tailwindcss")({
|
||||
config: createTwConfig(process.cwd(), dashboardRoot),
|
||||
}),
|
||||
require("autoprefixer"),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies InlineConfig)
|
||||
|
||||
if (userConfig) {
|
||||
viteConfig = await userConfig(viteConfig)
|
||||
}
|
||||
|
||||
return viteConfig
|
||||
}
|
||||
|
||||
function mergeTailwindConfigs(config1: Config, config2: Config): Config {
|
||||
const content1 = config1.content
|
||||
const content2 = config2.content
|
||||
|
||||
let mergedContent: ContentConfig
|
||||
|
||||
if (Array.isArray(content1) && Array.isArray(content2)) {
|
||||
mergedContent = [...content1, ...content2]
|
||||
} else if (!Array.isArray(content1) && !Array.isArray(content2)) {
|
||||
mergedContent = {
|
||||
files: [...content1.files, ...content2.files],
|
||||
relative: content1.relative || content2.relative,
|
||||
extract: { ...content1.extract, ...content2.extract },
|
||||
transform: { ...content1.transform, ...content2.transform },
|
||||
}
|
||||
} else {
|
||||
throw new Error("Cannot merge content fields of different types")
|
||||
}
|
||||
|
||||
const mergedConfig = deepmerge(config1, config2)
|
||||
mergedConfig.content = mergedContent
|
||||
|
||||
console.log(config1.presets, config2.presets)
|
||||
|
||||
// Ensure presets only contain unique values
|
||||
mergedConfig.presets = config1.presets || []
|
||||
|
||||
return mergedConfig
|
||||
}
|
||||
|
||||
function createTwConfig(root: string, dashboardRoot: string) {
|
||||
const uiRoot = path.join(
|
||||
path.dirname(require.resolve("@medusajs/ui")),
|
||||
"**/*.{js,jsx,ts,tsx}"
|
||||
)
|
||||
|
||||
const baseConfig: Config = {
|
||||
presets: [require("@medusajs/ui-preset")],
|
||||
content: [
|
||||
`${root}/src/admin/**/*.{js,jsx,ts,tsx}`,
|
||||
`${dashboardRoot}/src/**/*.{js,jsx,ts,tsx}`,
|
||||
uiRoot,
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
let userConfig: Config | null = null
|
||||
const extensions = ["js", "cjs", "mjs", "ts", "cts", "mts"]
|
||||
|
||||
for (const ext of extensions) {
|
||||
try {
|
||||
userConfig = require(path.join(root, `tailwind.config.${ext}`))
|
||||
break
|
||||
} catch (err) {
|
||||
console.log("Failed to load tailwind config with extension", ext, err)
|
||||
userConfig = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!userConfig) {
|
||||
return baseConfig
|
||||
}
|
||||
|
||||
return mergeTailwindConfigs(baseConfig, userConfig)
|
||||
}
|
||||
|
||||
function createCustomLogger() {
|
||||
const logger = createLogger("info", {
|
||||
prefix: "medusa-admin",
|
||||
})
|
||||
const loggerInfo = logger.info
|
||||
|
||||
logger.info = (msg, opts) => {
|
||||
if (
|
||||
msg.includes("hmr invalidate") &&
|
||||
msg.includes(
|
||||
"Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loggerInfo(msg, opts)
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
interface PluginOptions extends Record<string, unknown> {
|
||||
enableUI?: boolean
|
||||
}
|
||||
|
||||
type Plugin =
|
||||
| string
|
||||
| {
|
||||
resolve: string
|
||||
options?: PluginOptions
|
||||
}
|
||||
|
||||
interface MedusaConfig extends Record<string, unknown> {
|
||||
plugins?: Plugin[]
|
||||
}
|
||||
|
||||
async function loadConfig(root: string, logger: Logger) {
|
||||
const configPath = path.resolve(root, "medusa-config.js")
|
||||
|
||||
const config: MedusaConfig = await import(configPath)
|
||||
.then((c) => c)
|
||||
.catch((e) => {
|
||||
if (e.code === "ERR_MODULE_NOT_FOUND") {
|
||||
logger.warn(
|
||||
"Root 'medusa-config.js' file not found; extensions won't load. If running Admin UI as a standalone app, use the 'standalone' option.",
|
||||
{
|
||||
timestamp: true,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
logger.error(
|
||||
`An error occured while attempting to load '${configPath}':\n${e}`,
|
||||
{
|
||||
timestamp: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!config.plugins?.length) {
|
||||
logger.info(
|
||||
"No plugins in 'medusa-config.js', no extensions will load. To enable Admin UI extensions, add them to the 'plugins' array in 'medusa-config.js'.",
|
||||
{
|
||||
timestamp: true,
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const uiPlugins = config.plugins
|
||||
.filter((p) => typeof p !== "string" && p.options?.enableUI)
|
||||
.map((p: Plugin) => {
|
||||
return typeof p === "string" ? p : p.resolve
|
||||
})
|
||||
|
||||
const extensionSources = uiPlugins.map((p) => {
|
||||
return path.resolve(require.resolve(p), "dist", "admin")
|
||||
})
|
||||
|
||||
const rootSource = path.resolve(process.cwd(), "src", "admin")
|
||||
|
||||
extensionSources.push(rootSource)
|
||||
|
||||
const adminPlugin = config.plugins.find((p) =>
|
||||
typeof p === "string"
|
||||
? p === "@medusajs/admin"
|
||||
: p.resolve === "@medusajs/admin"
|
||||
)
|
||||
|
||||
if (!adminPlugin) {
|
||||
logger.info(
|
||||
"No @medusajs/admin in 'medusa-config.js', no extensions will load. To enable Admin UI extensions, add it to the 'plugins' array in 'medusa-config.js'.",
|
||||
{
|
||||
timestamp: true,
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const adminPluginOptions =
|
||||
typeof adminPlugin !== "string" && !!adminPlugin.options
|
||||
? adminPlugin.options
|
||||
: {}
|
||||
|
||||
const viteConfig = adminPluginOptions.withFinal as
|
||||
| ((config: InlineConfig) => InlineConfig)
|
||||
| ((config: InlineConfig) => Promise<InlineConfig>)
|
||||
| undefined
|
||||
|
||||
return {
|
||||
plugins: extensionSources,
|
||||
userConfig: viteConfig,
|
||||
}
|
||||
}
|
||||
28
packages/admin-next/admin-bundler/src/api/dev.ts
Normal file
28
packages/admin-next/admin-bundler/src/api/dev.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createServer } from "vite"
|
||||
// @ts-ignore
|
||||
import { createViteConfig } from "./create-vite-config"
|
||||
|
||||
type DevArgs = {
|
||||
port?: number | undefined
|
||||
host?: string | boolean | undefined
|
||||
}
|
||||
|
||||
export async function dev({ port = 5173, host }: DevArgs) {
|
||||
const config = await createViteConfig({
|
||||
server: {
|
||||
port,
|
||||
host,
|
||||
},
|
||||
})
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const server = await createServer(config)
|
||||
|
||||
await server.listen()
|
||||
|
||||
server.printUrls()
|
||||
server.bindCLIShortcuts({ print: true })
|
||||
}
|
||||
28
packages/admin-next/admin-bundler/src/cli/create-cli.ts
Normal file
28
packages/admin-next/admin-bundler/src/cli/create-cli.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Command } from "commander"
|
||||
|
||||
import { build } from "../api/build"
|
||||
import { bundle } from "../api/bundle"
|
||||
import { dev } from "../api/dev"
|
||||
|
||||
export async function createCli() {
|
||||
const program = new Command()
|
||||
|
||||
program.name("medusa-admin")
|
||||
|
||||
program
|
||||
.command("dev")
|
||||
.description("Starts the development server")
|
||||
.action(dev)
|
||||
|
||||
program
|
||||
.command("build")
|
||||
.description("Builds the admin dashboard")
|
||||
.action(build)
|
||||
|
||||
program
|
||||
.command("bundle")
|
||||
.description("Bundles the admin dashboard")
|
||||
.action(bundle)
|
||||
|
||||
return program
|
||||
}
|
||||
8
packages/admin-next/admin-bundler/src/cli/index.ts
Normal file
8
packages/admin-next/admin-bundler/src/cli/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createCli } from "./create-cli"
|
||||
|
||||
createCli()
|
||||
.then(async (cli) => cli.parseAsync(process.argv))
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
3
packages/admin-next/admin-bundler/src/index.ts
Normal file
3
packages/admin-next/admin-bundler/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { build } from "./api/build.js"
|
||||
export { bundle } from "./api/bundle.js"
|
||||
export { dev } from "./api/dev.js"
|
||||
18
packages/admin-next/admin-bundler/tsconfig.json
Normal file
18
packages/admin-next/admin-bundler/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/admin-next/admin-bundler/tsup.config.ts
Normal file
7
packages/admin-next/admin-bundler/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["./src/index.ts", "./src/cli/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
})
|
||||
1
packages/admin-next/admin-shared/README.md
Normal file
1
packages/admin-next/admin-shared/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# shared
|
||||
13
packages/admin-next/admin-shared/package.json
Normal file
13
packages/admin-next/admin-shared/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@medusajs/admin-shared",
|
||||
"version": "0.0.0",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
59
packages/admin-next/admin-shared/src/constants.ts
Normal file
59
packages/admin-next/admin-shared/src/constants.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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.details.side.before",
|
||||
"product.details.side.after",
|
||||
// Product collection injection zones
|
||||
"product_collection.details.before",
|
||||
"product_collection.details.after",
|
||||
"product_collection.list.before",
|
||||
"product_collection.list.after",
|
||||
// Product category injection zones
|
||||
"product_category.details.before",
|
||||
"product_category.details.after",
|
||||
"product_category.list.before",
|
||||
"product_category.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
|
||||
2
packages/admin-next/admin-shared/src/index.ts
Normal file
2
packages/admin-next/admin-shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
3
packages/admin-next/admin-shared/src/types.ts
Normal file
3
packages/admin-next/admin-shared/src/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { injectionZones } from "./constants"
|
||||
|
||||
export type InjectionZone = (typeof injectionZones)[number]
|
||||
18
packages/admin-next/admin-shared/tsconfig.json
Normal file
18
packages/admin-next/admin-shared/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
25
packages/admin-next/dashboard/.gitignore
vendored
Normal file
25
packages/admin-next/dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vercel
|
||||
30
packages/admin-next/dashboard/README.md
Normal file
30
packages/admin-next/dashboard/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||
13
packages/admin-next/dashboard/index.html
Normal file
13
packages/admin-next/dashboard/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Medusa Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
53
packages/admin-next/dashboard/package.json
Normal file
53
packages/admin-next/dashboard/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@medusajs/dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"main": "index.html",
|
||||
"files": [
|
||||
"index.html",
|
||||
"public",
|
||||
"src",
|
||||
"package.json"
|
||||
],
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "3.3.2",
|
||||
"@medusajs/icons": "workspace:^",
|
||||
"@medusajs/ui": "workspace:^",
|
||||
"@radix-ui/react-collapsible": "1.0.3",
|
||||
"@tanstack/react-query": "4.22.0",
|
||||
"@tanstack/react-table": "8.10.7",
|
||||
"@uiw/react-json-view": "2.0.0-alpha.10",
|
||||
"cmdk": "^0.2.0",
|
||||
"i18next": "23.7.11",
|
||||
"i18next-browser-languagedetector": "7.2.0",
|
||||
"i18next-http-backend": "2.4.2",
|
||||
"medusa-react": "workspace:^",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.49.1",
|
||||
"react-i18next": "13.5.0",
|
||||
"react-router-dom": "6.20.1",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/medusa": "workspace:^",
|
||||
"@medusajs/ui-preset": "workspace:^",
|
||||
"@medusajs/vite-plugin-extension": "workspace:^",
|
||||
"@types/react": "18.2.43",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "6.14.0",
|
||||
"@typescript-eslint/parser": "6.14.0",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.32",
|
||||
"tailwindcss": "3.3.6",
|
||||
"typescript": "5.2.2",
|
||||
"vite": "5.0.10"
|
||||
},
|
||||
"packageManager": "yarn@3.2.1"
|
||||
}
|
||||
6
packages/admin-next/dashboard/postcss.config.cjs
Normal file
6
packages/admin-next/dashboard/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
259
packages/admin-next/dashboard/public/locales/$schema.json
Normal file
259
packages/admin-next/dashboard/public/locales/$schema.json
Normal file
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"general": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cancel": {
|
||||
"type": "string"
|
||||
},
|
||||
"save": {
|
||||
"type": "string"
|
||||
},
|
||||
"create": {
|
||||
"type": "string"
|
||||
},
|
||||
"delete": {
|
||||
"type": "string"
|
||||
},
|
||||
"edit": {
|
||||
"type": "string"
|
||||
},
|
||||
"extensions": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cancel",
|
||||
"save",
|
||||
"create",
|
||||
"createItem",
|
||||
"delete",
|
||||
"deleteItem",
|
||||
"edit",
|
||||
"editItem",
|
||||
"extensions",
|
||||
"details"
|
||||
]
|
||||
},
|
||||
"products": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"variants": {
|
||||
"type": "string"
|
||||
},
|
||||
"availableInSalesChannels": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain", "variants", "availableInSalesChannels"]
|
||||
},
|
||||
"categories": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"collections": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"inventory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"customers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"customerGroups": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"orders": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"draftOrders": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"discounts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"giftCards": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"pricing": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["domain"]
|
||||
},
|
||||
"users": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"admin": {
|
||||
"type": "string"
|
||||
},
|
||||
"member": {
|
||||
"type": "string"
|
||||
},
|
||||
"developer": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["admin", "member", "developer"]
|
||||
}
|
||||
},
|
||||
"required": ["domain", "role", "roles"]
|
||||
},
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"categories": {
|
||||
"type": "string"
|
||||
},
|
||||
"collection": {
|
||||
"type": "string"
|
||||
},
|
||||
"discountable": {
|
||||
"type": "string"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string"
|
||||
},
|
||||
"sales_channels": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"description",
|
||||
"name",
|
||||
"email",
|
||||
"password",
|
||||
"subtitle",
|
||||
"handle",
|
||||
"type",
|
||||
"category",
|
||||
"categories",
|
||||
"collection",
|
||||
"discountable",
|
||||
"tags",
|
||||
"sales_channels"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"general",
|
||||
"products",
|
||||
"categories",
|
||||
"collections",
|
||||
"inventory",
|
||||
"customers",
|
||||
"customerGroups",
|
||||
"orders",
|
||||
"draftOrders",
|
||||
"discounts",
|
||||
"giftCards",
|
||||
"pricing",
|
||||
"users",
|
||||
"fields"
|
||||
]
|
||||
}
|
||||
140
packages/admin-next/dashboard/public/locales/en/translation.json
Normal file
140
packages/admin-next/dashboard/public/locales/en/translation.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"$schema": "../$schema.json",
|
||||
"general": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"search": "Search",
|
||||
"extensions": "Extensions",
|
||||
"settings": "Settings",
|
||||
"general": "General",
|
||||
"details": "Details",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"remove": "Remove",
|
||||
"countSelected": "{{count}} selected",
|
||||
"plusCountMore": "+ {{count}} more",
|
||||
"areYouSure": "Are you sure?",
|
||||
"noRecordsFound": "No records found"
|
||||
},
|
||||
"products": {
|
||||
"domain": "Products",
|
||||
"variants": "Variants",
|
||||
"availableInSalesChannels": "Available in <0>{{x}}</0> of <1>{{y}}</1> sales channels",
|
||||
"inStockVariants_one": "{{inventory}} in stock for {{count}} variant",
|
||||
"inStockVariants_other": "{{inventory}} in stock for {{count}} variants",
|
||||
"productStatus": {
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"proposed": "Proposed",
|
||||
"rejected": "Rejected"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
"domain": "Collections"
|
||||
},
|
||||
"categories": {
|
||||
"domain": "Categories"
|
||||
},
|
||||
"inventory": {
|
||||
"domain": "Inventory"
|
||||
},
|
||||
"giftCards": {
|
||||
"domain": "Gift Cards"
|
||||
},
|
||||
"customers": {
|
||||
"domain": "Customers"
|
||||
},
|
||||
"customerGroups": {
|
||||
"domain": "Customer Groups"
|
||||
},
|
||||
"orders": {
|
||||
"domain": "Orders"
|
||||
},
|
||||
"draftOrders": {
|
||||
"domain": "Draft Orders"
|
||||
},
|
||||
"discounts": {
|
||||
"domain": "Discounts"
|
||||
},
|
||||
"pricing": {
|
||||
"domain": "Pricing"
|
||||
},
|
||||
"profile": {
|
||||
"domain": "Profile",
|
||||
"manageYourProfileDetails": "Manage your profile details",
|
||||
"editProfileDetails": "Edit Profile Details",
|
||||
"languageHint": "The language you want to use in the admin dashboard. This will not change the language of your store.",
|
||||
"userInsightsHint": "Share usage insights and help us improve Medusa. You can read more about what we collect and how we use it in our <0>documentation</0>."
|
||||
},
|
||||
"users": {
|
||||
"domain": "Users",
|
||||
"role": "Role",
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"developer": "Developer",
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"store": {
|
||||
"domain": "Store",
|
||||
"manageYourStoresDetails": "Manage your store's details",
|
||||
"editStoreDetails": "Edit Store Details",
|
||||
"storeName": "Store name",
|
||||
"swapLinkTemplate": "Swap link template",
|
||||
"paymentLinkTemplate": "Payment link template",
|
||||
"inviteLinkTemplate": "Invite link template"
|
||||
},
|
||||
"regions": {
|
||||
"domain": "Regions"
|
||||
},
|
||||
"salesChannels": {
|
||||
"domain": "Sales Channels",
|
||||
"removeProductsWarning_one": "You are about to remove {{count}} product from {{sales_channel}}.",
|
||||
"removeProductsWarning_other": "You are about to remove {{count}} products from {{sales_channel}}.",
|
||||
"addProducts": "Add Products",
|
||||
"editSalesChannel": "Edit Sales Channel",
|
||||
"isEnabledHint": "Specify if the sales channel is enabled or disabled.",
|
||||
"productAlreadyAdded": "The product has already been added to the sales channel."
|
||||
},
|
||||
"currencies": {
|
||||
"domain": "Currencies",
|
||||
"manageTheCurrencies": "Manage the currencies you want to use in your store",
|
||||
"editCurrencyDetails": "Edit Currency Details",
|
||||
"defaultCurrency": "Default Currency",
|
||||
"defaultCurrencyHint": "The default currency of your store.",
|
||||
"removeCurrenciesWarning_one": "You are about to remove {{count}} currency from your store. Ensure that you have removed all prices using the currency before proceeding.",
|
||||
"removeCurrenciesWarning_other": "You are about to remove {{count}} currencies from your store. Ensure that you have removed all prices using the currencies before proceeding.",
|
||||
"currencyAlreadyAdded": "The currency has already been added to your store."
|
||||
},
|
||||
"apiKeyManagement": {
|
||||
"domain": "API Key Management",
|
||||
"createAPublishableApiKey": "Create a publishable API key",
|
||||
"createKey": "Create Key"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"lastName": "Last Name",
|
||||
"firstName": "First Name",
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"collection": "Collection",
|
||||
"discountable": "Discountable",
|
||||
"handle": "Handle",
|
||||
"subtitle": "Subtitle",
|
||||
"tags": "Tags",
|
||||
"type": "Type",
|
||||
"sales_channels": "Sales Channels",
|
||||
"status": "Status",
|
||||
"code": "Code",
|
||||
"availability": "Availability",
|
||||
"inventory": "Inventory",
|
||||
"optional": "Optional"
|
||||
}
|
||||
}
|
||||
1
packages/admin-next/dashboard/public/vite.svg
Normal file
1
packages/admin-next/dashboard/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
31
packages/admin-next/dashboard/src/app.tsx
Normal file
31
packages/admin-next/dashboard/src/app.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Toaster } from "@medusajs/ui"
|
||||
import { MedusaProvider } from "medusa-react"
|
||||
|
||||
import { AuthProvider } from "./providers/auth-provider"
|
||||
import { RouterProvider } from "./providers/router-provider"
|
||||
import { ThemeProvider } from "./providers/theme-provider"
|
||||
|
||||
import { queryClient } from "./lib/medusa"
|
||||
|
||||
const BASE_URL =
|
||||
import.meta.env.VITE_MEDUSA_ADMIN_BACKEND_URL || "http://localhost:9000"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MedusaProvider
|
||||
baseUrl={BASE_URL}
|
||||
queryClientProviderProps={{
|
||||
client: queryClient,
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<RouterProvider />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</MedusaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
BIN
packages/admin-next/dashboard/src/assets/fonts/Inter-Medium.ttf
Normal file
BIN
packages/admin-next/dashboard/src/assets/fonts/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
packages/admin-next/dashboard/src/assets/fonts/Inter-Regular.ttf
Normal file
BIN
packages/admin-next/dashboard/src/assets/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
export * from "./require-auth";
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Spinner } from "@medusajs/icons";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../../../providers/auth-provider";
|
||||
|
||||
export const RequireAuth = ({ children }: PropsWithChildren) => {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (auth.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Spinner className="animate-spin text-ui-fg-interactive" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.user) {
|
||||
console.log("redirecting");
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Input } from "@medusajs/ui"
|
||||
import { ComponentProps, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type DebouncedSearchProps = Omit<
|
||||
ComponentProps<typeof Input>,
|
||||
"value" | "defaultValue" | "onChange" | "type"
|
||||
> & {
|
||||
debounce?: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const DebouncedSearch = ({
|
||||
value: initialValue,
|
||||
onChange,
|
||||
debounce = 500,
|
||||
placeholder,
|
||||
...props
|
||||
}: DebouncedSearchProps) => {
|
||||
const [value, setValue] = useState<string>(initialValue)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue)
|
||||
}, [initialValue])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
onChange?.(value)
|
||||
}, debounce)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
placeholder={placeholder || t("general.search")}
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./debounced-search"
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
Hint as HintComponent,
|
||||
Label as LabelComponent,
|
||||
Text,
|
||||
clx,
|
||||
} from "@medusajs/ui"
|
||||
import * as LabelPrimitives from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { createContext, forwardRef, useContext, useId } from "react"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const Provider = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const Field = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = useContext(FormFieldContext)
|
||||
const itemContext = useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within a FormField")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formErrorMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
const Item = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={clx("flex flex-col space-y-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Item.displayName = "Form.Item"
|
||||
|
||||
const Label = forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitives.Root> & {
|
||||
optional?: boolean
|
||||
}
|
||||
>(({ className, optional = false, ...props }, ref) => {
|
||||
const { formItemId } = useFormField()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LabelComponent
|
||||
ref={ref}
|
||||
className={clx(className)}
|
||||
htmlFor={formItemId}
|
||||
size="small"
|
||||
weight="plus"
|
||||
{...props}
|
||||
/>
|
||||
{optional && (
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
({t("fields.optional")})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Label.displayName = "Form.Label"
|
||||
|
||||
const Control = forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formErrorMessageId } =
|
||||
useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formErrorMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Control.displayName = "Form.Control"
|
||||
|
||||
const Hint = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<HintComponent
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Hint.displayName = "Form.Hint"
|
||||
|
||||
const ErrorMessage = forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formErrorMessageId } = useFormField()
|
||||
const msg = error ? String(error?.message) : children
|
||||
|
||||
if (!msg) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HintComponent
|
||||
ref={ref}
|
||||
id={formErrorMessageId}
|
||||
className={className}
|
||||
variant={error ? "error" : "info"}
|
||||
{...props}
|
||||
>
|
||||
{msg}
|
||||
</HintComponent>
|
||||
)
|
||||
})
|
||||
ErrorMessage.displayName = "Form.ErrorMessage"
|
||||
|
||||
const Form = Object.assign(Provider, {
|
||||
Item,
|
||||
Label,
|
||||
Control,
|
||||
Hint,
|
||||
ErrorMessage,
|
||||
Field,
|
||||
})
|
||||
|
||||
export { Form }
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./form";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./json-view";
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
ArrowsPointingOut,
|
||||
CheckCircleMiniSolid,
|
||||
SquareTwoStackMini,
|
||||
XMarkMini,
|
||||
} from "@medusajs/icons"
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Drawer,
|
||||
Heading,
|
||||
IconButton,
|
||||
Kbd,
|
||||
} from "@medusajs/ui"
|
||||
import Primitive from "@uiw/react-json-view"
|
||||
import { CSSProperties, Suspense } from "react"
|
||||
|
||||
type JsonViewProps = {
|
||||
data: object
|
||||
root?: string
|
||||
}
|
||||
|
||||
// TODO: Fix the positioning of the copy btn
|
||||
export const JsonView = ({ data, root }: JsonViewProps) => {
|
||||
const numberOfKeys = Object.keys(data).length
|
||||
|
||||
return (
|
||||
<Container className="flex items-center justify-between py-6">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Heading level="h2">JSON</Heading>
|
||||
<Badge>{numberOfKeys} keys</Badge>
|
||||
</div>
|
||||
<Drawer>
|
||||
<Drawer.Trigger asChild>
|
||||
<IconButton variant="transparent" className="text-ui-fg-subtle">
|
||||
<ArrowsPointingOut />
|
||||
</IconButton>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content className="border-ui-code-border bg-ui-code-bg-base text-ui-code-text-base dark overflow-hidden border shadow-none max-md:inset-x-2 max-md:max-w-[calc(100%-16px)]">
|
||||
<div className="bg-ui-code-bg-header border-ui-code-border flex items-center justify-between border-b px-8 py-6">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Heading>JSON</Heading>
|
||||
<Badge>{numberOfKeys} keys</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Kbd>esc</Kbd>
|
||||
<Drawer.Close asChild>
|
||||
<IconButton variant="transparent" className="text-ui-fg-subtle">
|
||||
<XMarkMini />
|
||||
</IconButton>
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
</div>
|
||||
<Drawer.Body className="overflow-auto p-4">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Primitive
|
||||
value={data}
|
||||
displayDataTypes={false}
|
||||
keyName={root}
|
||||
style={
|
||||
{
|
||||
"--w-rjv-font-family": "Roboto Mono, monospace",
|
||||
"--w-rjv-line-color": "#2E3035",
|
||||
"--w-rjv-curlybraces-color": "#ADB1B8",
|
||||
"--w-rjv-key-string": "#A78BFA",
|
||||
"--w-rjv-info-color": "#FBBF24",
|
||||
"--w-rjv-type-string-color": "#34D399",
|
||||
"--w-rjv-quotes-string-color": "#34D399",
|
||||
"--w-rjv-type-boolean-color": "#FBBF24",
|
||||
"--w-rjv-type-int-color": "#60A5FA",
|
||||
"--w-rjv-type-float-color": "#60A5FA",
|
||||
"--w-rjv-type-bigint-color": "#60A5FA",
|
||||
"--w-rjv-key-number": "#60A5FA",
|
||||
} as CSSProperties
|
||||
}
|
||||
collapsed={1}
|
||||
>
|
||||
<Primitive.Copied
|
||||
// @ts-expect-error - types are missing the 'data-copied' prop
|
||||
render={({ "data-copied": copied, onClick }) => {
|
||||
if (copied) {
|
||||
return (
|
||||
<CheckCircleMiniSolid className="text-ui-fg-subtle cursor-pointer align-middle" />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SquareTwoStackMini
|
||||
className="text-ui-fg-subtle cursor-pointer align-middle"
|
||||
onClick={onClick}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Primitive.Quote render={() => " "} />
|
||||
<Primitive.Null
|
||||
render={() => (
|
||||
<span className="text-ui-tag-red-text">null</span>
|
||||
)}
|
||||
/>
|
||||
<Primitive.CountInfo
|
||||
render={(_props, { value }) => {
|
||||
return (
|
||||
<span className="text-ui-tag-neutral-text ml-2">
|
||||
{Object.keys(value as object).length} items
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Primitive>
|
||||
</Suspense>
|
||||
</Drawer.Body>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./product-table-cells"
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Product,
|
||||
ProductCollection,
|
||||
ProductVariant,
|
||||
SalesChannel,
|
||||
} from "@medusajs/medusa"
|
||||
import { StatusBadge, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Thumbnail } from "../thumbnail"
|
||||
|
||||
export const ProductInventoryCell = ({
|
||||
variants,
|
||||
}: {
|
||||
variants: ProductVariant[] | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!variants || !variants.length) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
const inventory = variants.reduce((acc, v) => acc + v.inventory_quantity, 0)
|
||||
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{t("products.inStockVariants", {
|
||||
count: variants.length,
|
||||
inventory: inventory,
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductStatusCell = ({
|
||||
status,
|
||||
}: {
|
||||
status: Product["status"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const color = {
|
||||
draft: "grey",
|
||||
published: "green",
|
||||
rejected: "red",
|
||||
proposed: "blue",
|
||||
}[status] as "grey" | "green" | "red" | "blue"
|
||||
|
||||
return (
|
||||
<StatusBadge color={color}>
|
||||
{t(`products.productStatus.${status}`)}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductAvailabilityCell = ({
|
||||
salesChannels,
|
||||
}: {
|
||||
salesChannels: SalesChannel[] | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!salesChannels || salesChannels.length === 0) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (salesChannels.length < 3) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{salesChannels.map((sc) => sc.name).join(", ")}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
<span>
|
||||
{salesChannels
|
||||
.slice(0, 2)
|
||||
.map((sc) => sc.name)
|
||||
.join(", ")}
|
||||
</span>{" "}
|
||||
<span>
|
||||
{t("general.plusCountMore", {
|
||||
count: salesChannels.length - 2,
|
||||
})}
|
||||
</span>
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductTitleCell = ({ product }: { product: Product }) => {
|
||||
const thumbnail = product.thumbnail
|
||||
const title = product.title
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Thumbnail src={thumbnail} alt={`Thumbnail image of ${title}`} />
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCollectionCell = ({
|
||||
collection,
|
||||
}: {
|
||||
collection: ProductCollection | null
|
||||
}) => {
|
||||
if (!collection) {
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
-
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="small" className="text-ui-fg-base">
|
||||
{collection.title}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./thumbnail";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Photo } from "@medusajs/icons";
|
||||
|
||||
type ThumbnailProps = {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Thumbnail = ({ src, alt }: ThumbnailProps) => {
|
||||
return (
|
||||
<div className="bg-ui-bg-component flex h-8 w-6 items-center justify-center overflow-hidden rounded-[4px]">
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover object-center"
|
||||
/>
|
||||
) : (
|
||||
<Photo />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { AxiosError } from "axios"
|
||||
import { Navigate, useLocation, useRouteError } from "react-router-dom"
|
||||
|
||||
export const ErrorBoundary = () => {
|
||||
const error = useRouteError()
|
||||
const location = useLocation()
|
||||
|
||||
if (isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
return <Navigate to="/404" />
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
// TODO: Catch other server errors
|
||||
}
|
||||
|
||||
// TODO: Actual catch-all error page
|
||||
return <div>Dang!</div>
|
||||
}
|
||||
|
||||
const isAxiosError = (error: any): error is AxiosError => {
|
||||
return error.isAxiosError
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorBoundary } from "./error-boundary"
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Outlet, useLocation } from "react-router-dom"
|
||||
import { Gutter } from "./gutter"
|
||||
import { MainNav } from "./main-nav"
|
||||
import { SettingsNav } from "./settings-nav"
|
||||
import { Topbar } from "./topbar"
|
||||
|
||||
export const AppLayout = () => {
|
||||
const location = useLocation()
|
||||
|
||||
const isSettings = location.pathname.startsWith("/settings")
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-start overflow-hidden md:flex-row">
|
||||
<MainNav />
|
||||
<div className="flex h-[calc(100vh-57px)] w-full md:h-screen">
|
||||
{isSettings && <SettingsNav />}
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto p-4">
|
||||
<Gutter>
|
||||
<Topbar />
|
||||
<Outlet />
|
||||
</Gutter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TriangleRightMini } from "@medusajs/icons";
|
||||
import { clx } from "@medusajs/ui";
|
||||
import { Link, UIMatch, useMatches } from "react-router-dom";
|
||||
|
||||
type BreadcrumbProps = React.ComponentPropsWithoutRef<"ol">;
|
||||
|
||||
export const Breadcrumbs = ({ className, ...props }: BreadcrumbProps) => {
|
||||
const matches = useMatches() as unknown as UIMatch<
|
||||
unknown,
|
||||
{ crumb?: (data?: unknown) => string }
|
||||
>[];
|
||||
|
||||
const crumbs = matches
|
||||
.filter((match) => Boolean(match.handle?.crumb))
|
||||
.map((match) => {
|
||||
const handle = match.handle;
|
||||
|
||||
return {
|
||||
label: handle.crumb!(match.data),
|
||||
path: match.pathname,
|
||||
};
|
||||
});
|
||||
|
||||
if (crumbs.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol
|
||||
className={clx("flex items-center gap-x-1 text-ui-fg-muted", className)}
|
||||
{...props}
|
||||
>
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className="txt-compact-small-plus flex items-center gap-x-1"
|
||||
>
|
||||
{!isLast ? (
|
||||
<Link to={crumb.path}>{crumb.label}</Link>
|
||||
) : (
|
||||
<span key={index}>{crumb.label}</span>
|
||||
)}
|
||||
{!isLast && <TriangleRightMini />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export const Gutter = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="w-full max-w-[1200px] flex flex-col gap-y-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./app-layout";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { adminProductKeys } from "medusa-react";
|
||||
import { LoaderFunctionArgs } from "react-router-dom";
|
||||
import { medusa, queryClient } from "../../../lib/medusa";
|
||||
|
||||
const appLoaderQuery = (id: string) => ({
|
||||
queryKey: adminProductKeys.detail(id),
|
||||
queryFn: async () => medusa.admin.products.retrieve(id),
|
||||
});
|
||||
|
||||
export const productLoader = (client: QueryClient) => {
|
||||
return async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params?.id;
|
||||
|
||||
if (!id) {
|
||||
throw new Error("No id provided");
|
||||
}
|
||||
|
||||
const query = appLoaderQuery(id);
|
||||
|
||||
return (
|
||||
queryClient.getQueryData(query.queryKey) ??
|
||||
(await client.fetchQuery(query))
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import {
|
||||
ArrowRightOnRectangle,
|
||||
BookOpen,
|
||||
BuildingStorefront,
|
||||
Calendar,
|
||||
ChevronDownMini,
|
||||
CircleHalfSolid,
|
||||
CogSixTooth,
|
||||
CurrencyDollar,
|
||||
EllipsisHorizontal,
|
||||
MinusMini,
|
||||
ReceiptPercent,
|
||||
ShoppingCart,
|
||||
Sidebar,
|
||||
SquaresPlus,
|
||||
Tag,
|
||||
Users,
|
||||
} from "@medusajs/icons"
|
||||
import { Avatar, DropdownMenu, IconButton, Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { useAdminDeleteSession, useAdminStore } from "medusa-react"
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||
|
||||
import { useAuth } from "../../../providers/auth-provider"
|
||||
import { useTheme } from "../../../providers/theme-provider"
|
||||
|
||||
import { Fragment, useEffect, useState } from "react"
|
||||
import { Breadcrumbs } from "./breadcrumbs"
|
||||
import { NavItem, NavItemProps } from "./nav-item"
|
||||
import { Notifications } from "./notifications"
|
||||
import { SearchToggle } from "./search-toggle"
|
||||
import { Spacer } from "./spacer"
|
||||
|
||||
import extensions from "medusa-admin:routes/links"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export const MainNav = () => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DesktopNav />
|
||||
<MobileNav />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
const MobileNav = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
// If the user navigates to a new route, we want to close the menu
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-base border-ui-border-base flex h-[57px] w-full items-center justify-between border-b px-4 md:hidden">
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Dialog.Trigger asChild>
|
||||
<IconButton variant="transparent">
|
||||
<Sidebar />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0 lg:hidden" />
|
||||
<Dialog.Content className="bg-ui-bg-subtle fixed inset-y-0 left-0 flex w-full flex-col overflow-y-auto sm:max-w-[240px] lg:hidden">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="sticky top-0">
|
||||
<Header />
|
||||
<Spacer />
|
||||
</div>
|
||||
<CoreRouteSection />
|
||||
<ExtensionRouteSection />
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex w-full flex-col">
|
||||
<SettingsSection />
|
||||
<Spacer />
|
||||
<UserSection />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<SearchToggle />
|
||||
<Notifications />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DesktopNav = () => {
|
||||
return (
|
||||
<aside className="flex h-full max-h-screen w-full max-w-[240px] flex-col justify-between overflow-y-auto max-md:hidden">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="bg-ui-bg-subtle sticky top-0">
|
||||
<Header />
|
||||
<Spacer />
|
||||
</div>
|
||||
<CoreRouteSection />
|
||||
<ExtensionRouteSection />
|
||||
</div>
|
||||
<div className="bg-ui-bg-subtle sticky bottom-0 flex flex-col">
|
||||
<SettingsSection />
|
||||
<Spacer />
|
||||
<UserSection />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const { store } = useAdminStore()
|
||||
const { setTheme, theme } = useTheme()
|
||||
const { mutateAsync: logoutMutation } = useAdminDeleteSession()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const logout = async () => {
|
||||
await logoutMutation(undefined, {
|
||||
onSuccess: () => {
|
||||
navigate("/login")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger className="hover:bg-ui-bg-subtle-hover active:bg-ui-bg-subtle-pressed focus:bg-ui-bg-subtle-pressed transition-fg w-full rounded-md outline-none">
|
||||
<div className="flex items-center justify-between p-1 md:pr-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className="bg-ui-bg-base shadow-borders-base flex h-8 w-8 items-center justify-center overflow-hidden rounded-md">
|
||||
<div className="bg-ui-bg-component flex h-[28px] w-[28px] items-center justify-center overflow-hidden rounded-[4px]">
|
||||
{store.name[0].toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{store.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle">
|
||||
<EllipsisHorizontal />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item>
|
||||
<BuildingStorefront className="text-ui-fg-subtle mr-2" />
|
||||
Store Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<Link to="https://docs.medusajs.com/user-guide" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<BookOpen className="text-ui-fg-subtle mr-2" />
|
||||
Documentation
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<Link to="https://medusajs.com/changelog/" target="_blank">
|
||||
<DropdownMenu.Item>
|
||||
<Calendar className="text-ui-fg-subtle mr-2" />
|
||||
Changelog
|
||||
</DropdownMenu.Item>
|
||||
</Link>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.SubMenu>
|
||||
<DropdownMenu.SubMenuTrigger className="rounded-md">
|
||||
<CircleHalfSolid className="text-ui-fg-subtle mr-2" />
|
||||
Theme
|
||||
</DropdownMenu.SubMenuTrigger>
|
||||
<DropdownMenu.SubMenuContent>
|
||||
<DropdownMenu.RadioGroup value={theme}>
|
||||
<DropdownMenu.RadioItem
|
||||
value="light"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setTheme("light")
|
||||
}}
|
||||
>
|
||||
Light
|
||||
</DropdownMenu.RadioItem>
|
||||
<DropdownMenu.RadioItem
|
||||
value="dark"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setTheme("dark")
|
||||
}}
|
||||
>
|
||||
Dark
|
||||
</DropdownMenu.RadioItem>
|
||||
</DropdownMenu.RadioGroup>
|
||||
</DropdownMenu.SubMenuContent>
|
||||
</DropdownMenu.SubMenu>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onClick={logout}>
|
||||
<ArrowRightOnRectangle className="text-ui-fg-subtle mr-2" />
|
||||
Logout
|
||||
<DropdownMenu.Shortcut>⌥⇧Q</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <ShoppingCart />,
|
||||
label: t("orders.domain"),
|
||||
to: "/orders",
|
||||
items: [
|
||||
{
|
||||
label: t("draftOrders.domain"),
|
||||
to: "/draft-orders",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Tag />,
|
||||
label: t("products.domain"),
|
||||
to: "/products",
|
||||
items: [
|
||||
{
|
||||
label: t("collections.domain"),
|
||||
to: "/collections",
|
||||
},
|
||||
{
|
||||
label: t("categories.domain"),
|
||||
to: "/categories",
|
||||
},
|
||||
{
|
||||
label: t("giftCards.domain"),
|
||||
to: "/gift-cards",
|
||||
},
|
||||
{
|
||||
label: t("inventory.domain"),
|
||||
to: "/inventory",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Users />,
|
||||
label: t("customers.domain"),
|
||||
to: "/customers",
|
||||
items: [
|
||||
{
|
||||
label: t("customerGroups.domain"),
|
||||
to: "/customer-groups",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <ReceiptPercent />,
|
||||
label: t("discounts.domain"),
|
||||
to: "/discounts",
|
||||
},
|
||||
{
|
||||
icon: <CurrencyDollar />,
|
||||
label: t("pricing.domain"),
|
||||
to: "/pricing",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const CoreRouteSection = () => {
|
||||
const coreRoutes = useCoreRoutes()
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-y-1 py-4">
|
||||
{coreRoutes.map((route) => {
|
||||
return <NavItem key={route.to} {...route} />
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
const ExtensionRouteSection = () => {
|
||||
if (!extensions.links || extensions.links.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Spacer />
|
||||
<div className="flex flex-col gap-y-4 py-4">
|
||||
<Collapsible.Root defaultOpen>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
Extensions
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-1 py-1 pb-4">
|
||||
{extensions.links.map((link) => {
|
||||
return (
|
||||
<NavItem
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
label={link.label}
|
||||
icon={link.icon ? <link.icon /> : <SquaresPlus />}
|
||||
type="extension"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsSection = () => {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<NavItem icon={<CogSixTooth />} label="Settings" to="/settings" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserSection = () => {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallback =
|
||||
user.first_name && user.last_name
|
||||
? `${user.first_name[0]}${user.last_name[0]}`
|
||||
: user.first_name
|
||||
? user.first_name[0]
|
||||
: user.email[0]
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Link
|
||||
to="/settings/profile"
|
||||
className="hover:bg-ui-bg-subtle-hover transition-fg active:bg-ui-bg-subtle-pressed focus:bg-ui-bg-subtle-pressed flex items-center gap-x-3 rounded-md p-1 outline-none"
|
||||
>
|
||||
<Avatar fallback={fallback.toUpperCase()} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
{(user.first_name || user.last_name) && (
|
||||
<Text
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="max-w-[90%] truncate"
|
||||
>{`${user.first_name && `${user.first_name} `}${
|
||||
user.last_name
|
||||
}`}</Text>
|
||||
)}
|
||||
<Text
|
||||
size="xsmall"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle max-w-[90%] truncate"
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Text, clx } from "@medusajs/ui";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
type ItemType = "core" | "extension";
|
||||
|
||||
type NestedItemProps = {
|
||||
label: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type NavItemProps = {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
to: string;
|
||||
items?: NestedItemProps[];
|
||||
type?: ItemType;
|
||||
};
|
||||
|
||||
export const NavItem = ({
|
||||
icon,
|
||||
label,
|
||||
to,
|
||||
items,
|
||||
type = "core",
|
||||
}: NavItemProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
const [open, setOpen] = useState(
|
||||
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
|
||||
location.pathname.startsWith(p)
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(
|
||||
[to, ...(items?.map((i) => i.to) ?? [])].some((p) =>
|
||||
location.pathname.startsWith(p)
|
||||
)
|
||||
);
|
||||
}, [location.pathname, to, items]);
|
||||
|
||||
return (
|
||||
<div className="px-4">
|
||||
<Link
|
||||
to={to}
|
||||
className={clx(
|
||||
"text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
|
||||
{
|
||||
"bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname.startsWith(to),
|
||||
"max-md:hidden": items && items.length > 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} type={type} />
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
{items && items.length > 0 && (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger
|
||||
className={clx(
|
||||
"w-full md:hidden text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover"
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} type={type} />
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="flex flex-col gap-y-1 pt-1">
|
||||
<Link
|
||||
to={to}
|
||||
className={clx(
|
||||
"md:hidden text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
|
||||
{
|
||||
"bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname.startsWith(to),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
<div
|
||||
className={clx(
|
||||
"w-1.5 h-1.5 border-[1.5px] border-ui-fg-muted transition-fg rounded-full",
|
||||
{
|
||||
"border-ui-fg-base border-2": location.pathname === to,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
to={item.to}
|
||||
key={item.to}
|
||||
className={clx(
|
||||
"first-of-type:mt-1 last-of-type:mb-2 text-ui-fg-subtle hover:text-ui-fg-base px-2 py-2.5 md:py-1.5 outline-none flex items-center gap-x-2 transition-fg rounded-md hover:bg-ui-bg-subtle-hover",
|
||||
{
|
||||
"bg-ui-bg-base shadow-elevation-card-rest":
|
||||
location.pathname === item.to,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center">
|
||||
<div
|
||||
className={clx(
|
||||
"w-1.5 h-1.5 border-[1.5px] border-ui-fg-muted transition-fg rounded-full",
|
||||
{
|
||||
"border-ui-fg-base border-2":
|
||||
location.pathname === item.to,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = ({ icon, type }: { icon?: React.ReactNode; type: ItemType }) => {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return type === "extension" ? (
|
||||
<div className="rounded-[4px] w-5 h-5 flex items-center justify-center shadow-borders-base bg-ui-bg-base">
|
||||
<div className="w-4 h-4 rounded-sm overflow-hidden">{icon}</div>
|
||||
</div>
|
||||
) : (
|
||||
icon
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BellAlert } from "@medusajs/icons";
|
||||
import { Drawer, Heading, IconButton } from "@medusajs/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Notifications = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "n" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<Drawer.Trigger asChild>
|
||||
<IconButton variant="transparent" className="text-ui-fg-muted">
|
||||
<BellAlert />
|
||||
</IconButton>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Heading>Notifications</Heading>
|
||||
</Drawer.Header>
|
||||
<Drawer.Body>Notifications will go here</Drawer.Body>
|
||||
</Drawer.Content>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MagnifyingGlass } from "@medusajs/icons"
|
||||
import { IconButton } from "@medusajs/ui"
|
||||
import { useSearch } from "../../../providers/search-provider"
|
||||
|
||||
export const SearchToggle = () => {
|
||||
const { toggleSearch } = useSearch()
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="transparent"
|
||||
onClick={toggleSearch}
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle"
|
||||
>
|
||||
<MagnifyingGlass />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ChevronDownMini, CogSixTooth, MinusMini } from "@medusajs/icons"
|
||||
import { Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { NavItem, NavItemProps } from "./nav-item"
|
||||
import { Spacer } from "./spacer"
|
||||
|
||||
const useSettingRoutes = (): NavItemProps[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("profile.domain"),
|
||||
to: "/settings/profile",
|
||||
},
|
||||
{
|
||||
label: t("store.domain"),
|
||||
to: "/settings/store",
|
||||
},
|
||||
{
|
||||
label: t("users.domain"),
|
||||
to: "/settings/users",
|
||||
},
|
||||
{
|
||||
label: t("regions.domain"),
|
||||
to: "/settings/regions",
|
||||
},
|
||||
{
|
||||
label: t("currencies.domain"),
|
||||
to: "/settings/currencies",
|
||||
},
|
||||
{
|
||||
label: "Taxes",
|
||||
to: "/settings/taxes",
|
||||
},
|
||||
{
|
||||
label: "Locations",
|
||||
to: "/settings/locations",
|
||||
},
|
||||
{
|
||||
label: t("salesChannels.domain"),
|
||||
to: "/settings/sales-channels",
|
||||
},
|
||||
{
|
||||
label: t("apiKeyManagement.domain"),
|
||||
to: "/settings/api-key-management",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsNav = () => {
|
||||
const routes = useSettingRoutes()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="border-ui-border-base box-content flex h-full max-h-screen w-full max-w-[240px] flex-col overflow-hidden border-x max-md:hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex h-10 items-center gap-x-3 p-1">
|
||||
<CogSixTooth className="text-ui-fg-subtle" />
|
||||
<Text leading="compact" weight="plus" size="small">
|
||||
{t("general.settings")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Spacer />
|
||||
<div className="flex flex-1 flex-col gap-y-4 overflow-y-auto py-4">
|
||||
<Collapsible.Root defaultOpen>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
{t("general.general")}
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content asChild>
|
||||
<nav className="flex flex-col gap-y-1 py-1">
|
||||
{routes.map((setting) => (
|
||||
<NavItem key={setting.to} {...setting} />
|
||||
))}
|
||||
</nav>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
<Collapsible.Root>
|
||||
<div className="px-4">
|
||||
<Collapsible.Trigger asChild className="group/trigger">
|
||||
<button className="text-ui-fg-subtle flex w-full items-center justify-between px-2">
|
||||
<Text size="xsmall" weight="plus" leading="compact">
|
||||
{t("general.extensions")}
|
||||
</Text>
|
||||
<div className="text-ui-fg-muted">
|
||||
<ChevronDownMini className="group-data-[state=open]/trigger:hidden" />
|
||||
<MinusMini className="group-data-[state=closed]/trigger:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content asChild>
|
||||
<nav className="flex flex-col gap-y-1 py-1">
|
||||
{routes.map((setting) => (
|
||||
<NavItem key={setting.to} {...setting} />
|
||||
))}
|
||||
</nav>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const Spacer = () => {
|
||||
return (
|
||||
<div className="px-4">
|
||||
<div className="w-full h-px border-b border-dashed border-ui-border-strong" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Sidebar } from "@medusajs/icons"
|
||||
import { Breadcrumbs } from "./breadcrumbs"
|
||||
import { Notifications } from "./notifications"
|
||||
import { SearchToggle } from "./search-toggle"
|
||||
|
||||
export const Topbar = () => {
|
||||
return (
|
||||
<div className="hidden items-center justify-between px-4 py-1 md:flex">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<Sidebar className="text-ui-fg-muted" />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<div className="text-ui-fg-muted flex items-center gap-x-1">
|
||||
<SearchToggle />
|
||||
<Notifications />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./public-layout";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export const PublicLayout = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-6">
|
||||
<div className="bg-ui-bg-base text-ui-fg-subtle w-[520px] px-16 py-20 rounded-[32px] shadow-elevation-modal flex flex-col gap-y-12 items-center">
|
||||
<div className="w-24 h-24 rounded-3xl bg-ui-bg-subtle shadow-elevation-card-hover"></div>
|
||||
<div className="w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Search } from "./search"
|
||||
245
packages/admin-next/dashboard/src/components/search/search.tsx
Normal file
245
packages/admin-next/dashboard/src/components/search/search.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { MagnifyingGlass } from "@medusajs/icons"
|
||||
import { clx } from "@medusajs/ui"
|
||||
import * as Dialog from "@radix-ui/react-dialog"
|
||||
import { Command } from "cmdk"
|
||||
import {
|
||||
ComponentPropsWithoutRef,
|
||||
ElementRef,
|
||||
HTMLAttributes,
|
||||
forwardRef,
|
||||
useMemo,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearch } from "../../providers/search-provider"
|
||||
|
||||
export const Search = () => {
|
||||
const { open, onOpenChange } = useSearch()
|
||||
const links = useLinks()
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Search" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{links.map((group) => {
|
||||
return (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((item) => {
|
||||
return (
|
||||
<CommandItem key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
type CommandItemProps = {
|
||||
label: string
|
||||
}
|
||||
|
||||
type CommandGroupProps = {
|
||||
title: string
|
||||
items: CommandItemProps[]
|
||||
}
|
||||
|
||||
const useLinks = (): CommandGroupProps[] => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Pages",
|
||||
items: [
|
||||
{
|
||||
label: t("products.domain"),
|
||||
},
|
||||
{
|
||||
label: t("categories.domain"),
|
||||
},
|
||||
{
|
||||
label: t("collections.domain"),
|
||||
},
|
||||
{
|
||||
label: t("giftCards.domain"),
|
||||
},
|
||||
{
|
||||
label: t("orders.domain"),
|
||||
},
|
||||
{
|
||||
label: t("draftOrders.domain"),
|
||||
},
|
||||
{
|
||||
label: t("customers.domain"),
|
||||
},
|
||||
{
|
||||
label: t("customerGroups.domain"),
|
||||
},
|
||||
{
|
||||
label: t("discounts.domain"),
|
||||
},
|
||||
{
|
||||
label: t("pricing.domain"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
items: [
|
||||
{
|
||||
label: t("profile.domain"),
|
||||
},
|
||||
{
|
||||
label: t("store.domain"),
|
||||
},
|
||||
{
|
||||
label: t("users.domain"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
|
||||
const CommandPalette = forwardRef<
|
||||
ElementRef<typeof Command>,
|
||||
ComponentPropsWithoutRef<typeof Command>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Command
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = Command.displayName
|
||||
|
||||
interface CommandDialogProps extends Dialog.DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog.Root {...props}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="bg-ui-bg-overlay fixed inset-0" />
|
||||
<Dialog.Content className="bg-ui-bg-subtle shadow-elevation-modal fixed left-[50%] top-[50%] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-xl p-0">
|
||||
<CommandPalette className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</CommandPalette>
|
||||
<div className="border-t px-4 pb-4 pt-3"></div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = forwardRef<
|
||||
ElementRef<typeof Command.Input>,
|
||||
ComponentPropsWithoutRef<typeof Command.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlass className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Command.Input
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = Command.Input.displayName
|
||||
|
||||
const CommandList = forwardRef<
|
||||
ElementRef<typeof Command.List>,
|
||||
ComponentPropsWithoutRef<typeof Command.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Command.List
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"max-h-[300px] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = Command.List.displayName
|
||||
|
||||
const CommandEmpty = forwardRef<
|
||||
ElementRef<typeof Command.Empty>,
|
||||
ComponentPropsWithoutRef<typeof Command.Empty>
|
||||
>((props, ref) => (
|
||||
<Command.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = Command.Empty.displayName
|
||||
|
||||
const CommandGroup = forwardRef<
|
||||
ElementRef<typeof Command.Group>,
|
||||
ComponentPropsWithoutRef<typeof Command.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Command.Group
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"text-ui-fg-base [&_[cmdk-group-heading]]:text-ui-fg-muted [&_[cmdk-group-heading]]:txt-compact-xsmall-plus overflow-hidden px-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:pt-4 [&_[cmdk-item]]:py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = Command.Group.displayName
|
||||
|
||||
const CommandSeparator = forwardRef<
|
||||
ElementRef<typeof Command.Separator>,
|
||||
ComponentPropsWithoutRef<typeof Command.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Command.Separator
|
||||
ref={ref}
|
||||
className={clx("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = Command.Separator.displayName
|
||||
|
||||
const CommandItem = forwardRef<
|
||||
ElementRef<typeof Command.Item>,
|
||||
ComponentPropsWithoutRef<typeof Command.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Command.Item
|
||||
ref={ref}
|
||||
className={clx(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground txt-compact-small relative flex cursor-default select-none items-center rounded-sm p-2 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = Command.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={clx(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
28
packages/admin-next/dashboard/src/i18n/config.ts
Normal file
28
packages/admin-next/dashboard/src/i18n/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import i18n from "i18next"
|
||||
import LanguageDetector from "i18next-browser-languagedetector"
|
||||
import Backend, { type HttpBackendOptions } from "i18next-http-backend"
|
||||
import { initReactI18next } from "react-i18next"
|
||||
|
||||
import { Language } from "./types"
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init<HttpBackendOptions>({
|
||||
fallbackLng: "en",
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export const languages: Language[] = [
|
||||
{
|
||||
code: "en",
|
||||
display_name: "English",
|
||||
ltr: true,
|
||||
},
|
||||
]
|
||||
|
||||
export default i18n
|
||||
13
packages/admin-next/dashboard/src/i18n/types.ts
Normal file
13
packages/admin-next/dashboard/src/i18n/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import en from "../../public/locales/en/translation.json"
|
||||
|
||||
const resources = {
|
||||
translation: en,
|
||||
} as const
|
||||
|
||||
export type Resources = typeof resources
|
||||
|
||||
export type Language = {
|
||||
code: string
|
||||
display_name: string
|
||||
ltr: boolean
|
||||
}
|
||||
7
packages/admin-next/dashboard/src/i18next.d.ts
vendored
Normal file
7
packages/admin-next/dashboard/src/i18next.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Resources } from "./i18n/types";
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
resources: Resources;
|
||||
}
|
||||
}
|
||||
33
packages/admin-next/dashboard/src/index.css
Normal file
33
packages/admin-next/dashboard/src/index.css
Normal file
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-weight: 400;
|
||||
src: url("./assets/fonts/Inter-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-weight: 500;
|
||||
src: url("./assets/fonts/Inter-Medium.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
font-weight: 400;
|
||||
src: url("./assets/fonts/RobotoMono-Regular.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto Mono";
|
||||
font-weight: 500;
|
||||
src: url("./assets/fonts/RobotoMono-Medium.ttf") format("truetype");
|
||||
}
|
||||
|
||||
:root {
|
||||
@apply bg-ui-bg-subtle text-ui-fg-base;
|
||||
}
|
||||
}
|
||||
17
packages/admin-next/dashboard/src/lib/medusa.ts
Normal file
17
packages/admin-next/dashboard/src/lib/medusa.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Medusa from "@medusajs/medusa-js"
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 90000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const medusa = new Medusa({
|
||||
baseUrl: "http://localhost:9000",
|
||||
maxRetries: 3,
|
||||
})
|
||||
11
packages/admin-next/dashboard/src/main.tsx
Normal file
11
packages/admin-next/dashboard/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import App from "./app.js"
|
||||
import "./i18n/config.js"
|
||||
import "./index.css"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
39
packages/admin-next/dashboard/src/module.d.ts
vendored
Normal file
39
packages/admin-next/dashboard/src/module.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
declare module "medusa-admin:widgets/*" {
|
||||
const widgets: { Component: () => JSX.Element }[]
|
||||
|
||||
export default {
|
||||
widgets,
|
||||
}
|
||||
}
|
||||
|
||||
declare module "medusa-admin:routes/links" {
|
||||
const links: { path: string; label: string; icon?: React.ComponentType }[]
|
||||
|
||||
export default {
|
||||
links,
|
||||
}
|
||||
}
|
||||
|
||||
declare module "medusa-admin:routes/pages" {
|
||||
const pages: { path: string; file: string }[]
|
||||
|
||||
export default {
|
||||
pages,
|
||||
}
|
||||
}
|
||||
|
||||
declare module "medusa-admin:settings/cards" {
|
||||
const cards: { path: string; label: string; description: string }[]
|
||||
|
||||
export default {
|
||||
cards,
|
||||
}
|
||||
}
|
||||
|
||||
declare module "medusa-admin:settings/pages" {
|
||||
const pages: { path: string; file: string }[]
|
||||
|
||||
export default {
|
||||
pages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AdminAuthRes, User } from "@medusajs/medusa"
|
||||
import { createContext } from "react"
|
||||
|
||||
type AuthContextValue = {
|
||||
login: (email: string, password: string) => Promise<AdminAuthRes>
|
||||
user: Omit<User, "password_hash"> | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useAdminGetSession, useAdminLogin } from "medusa-react"
|
||||
import { PropsWithChildren } from "react"
|
||||
import { AuthContext } from "./auth-context"
|
||||
|
||||
export const AuthProvider = ({ children }: PropsWithChildren) => {
|
||||
const { mutateAsync: loginMutation } = useAdminLogin()
|
||||
const { user, isLoading } = useAdminGetSession()
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
return await loginMutation({ email, password })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
login,
|
||||
user: user ?? null,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./auth-provider";
|
||||
export * from "./use-auth";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "./auth-context";
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createContext } from "react";
|
||||
import { Feature } from "./types";
|
||||
|
||||
type FeatureContextValue = {
|
||||
isFeatureEnabled: (feature: Feature) => boolean;
|
||||
};
|
||||
|
||||
export const FeatureContext = createContext<FeatureContextValue | null>(null);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useAdminStore } from "medusa-react";
|
||||
import { PropsWithChildren, useEffect, useState } from "react";
|
||||
import { FeatureContext } from "./feature-context";
|
||||
import { Feature } from "./types";
|
||||
|
||||
export const FeatureProvider = ({ children }: PropsWithChildren) => {
|
||||
const { store, isLoading } = useAdminStore();
|
||||
const [features, setFeatures] = useState<Feature[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!store || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flags = store.feature_flags
|
||||
.filter((f) => f.value === true)
|
||||
.map((f) => f.key);
|
||||
const modules = store.modules.map((m) => m.module);
|
||||
const enabled = flags.concat(modules);
|
||||
|
||||
setFeatures(enabled as Feature[]);
|
||||
}, [store, isLoading]);
|
||||
|
||||
function isFeatureEnabled(feature: Feature) {
|
||||
return features.includes(feature);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeatureContext.Provider value={{ isFeatureEnabled }}>
|
||||
{children}
|
||||
</FeatureContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FeatureProvider } from "./feature-provider";
|
||||
export { useFeature } from "./use-feature";
|
||||
@@ -0,0 +1,16 @@
|
||||
const featureFlags = [
|
||||
"analytics",
|
||||
"order_editing",
|
||||
"product_categories",
|
||||
"publishable_api_keys",
|
||||
"sales_channels",
|
||||
"tax_inclusive_pricing",
|
||||
] as const;
|
||||
|
||||
type FeatureFlag = (typeof featureFlags)[number];
|
||||
|
||||
const modules = ["inventory"] as const;
|
||||
|
||||
type Module = (typeof modules)[number];
|
||||
|
||||
export type Feature = FeatureFlag | Module;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { FeatureContext } from "./feature-context";
|
||||
|
||||
export const useFeature = () => {
|
||||
const context = useContext(FeatureContext);
|
||||
if (context === null) {
|
||||
throw new Error("useFeature must be used within a FeatureProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./router-provider";
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
RouterProvider as Provider,
|
||||
RouteObject,
|
||||
createBrowserRouter,
|
||||
} from "react-router-dom"
|
||||
|
||||
import { RequireAuth } from "../../components/authentication/require-auth"
|
||||
import { AppLayout } from "../../components/layout/app-layout"
|
||||
import { PublicLayout } from "../../components/layout/public-layout"
|
||||
|
||||
import { AdminProductsRes } from "@medusajs/medusa"
|
||||
import routes from "medusa-admin:routes/pages"
|
||||
import settings from "medusa-admin:settings/pages"
|
||||
import { ErrorBoundary } from "../../components/error/error-boundary"
|
||||
import { SearchProvider } from "../search-provider"
|
||||
|
||||
const routeExtensions: RouteObject[] = routes.pages.map((ext) => {
|
||||
return {
|
||||
path: ext.path,
|
||||
async lazy() {
|
||||
const { default: Component } = await import(/* @vite-ignore */ ext.file)
|
||||
return { Component }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const settingsExtensions: RouteObject[] = settings.pages.map((ext) => {
|
||||
return {
|
||||
path: `/settings${ext.path}`,
|
||||
async lazy() {
|
||||
const { default: Component } = await import(/* @vite-ignore */ ext.file)
|
||||
return { Component }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <PublicLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/login",
|
||||
lazy: () => import("../../routes/login"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<SearchProvider>
|
||||
<AppLayout />
|
||||
</SearchProvider>
|
||||
</RequireAuth>
|
||||
),
|
||||
errorElement: <ErrorBoundary />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
lazy: () => import("../../routes/home"),
|
||||
},
|
||||
{
|
||||
path: "/orders",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/orders/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/orders/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/draft-orders",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/draft-orders/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/draft-orders/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/products",
|
||||
handle: {
|
||||
crumb: () => "Products",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/products/views/product-list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/products/views/product-details"),
|
||||
handle: {
|
||||
crumb: (data: AdminProductsRes) => data.product.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/categories",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/categories/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/categories/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/collections",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/collections/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/collections/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/customers",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/customers/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/customers/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/customer-groups",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/customer-groups/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/customer-groups/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/gift-cards",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/gift-cards/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/gift-cards/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/inventory",
|
||||
lazy: () => import("../../routes/inventory/list"),
|
||||
},
|
||||
{
|
||||
path: "/discounts",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/discounts/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/discounts/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pricing",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/pricing/list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/pricing/details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
handle: {
|
||||
crumb: () => "Settings",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/settings"),
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
lazy: () => import("../../routes/profile/views/profile-details"),
|
||||
handle: {
|
||||
crumb: () => "Profile",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "store",
|
||||
lazy: () => import("../../routes/store/views/store-details"),
|
||||
handle: {
|
||||
crumb: () => "Store",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "locations",
|
||||
lazy: () => import("../../routes/locations/list"),
|
||||
},
|
||||
{
|
||||
path: "regions",
|
||||
handle: {
|
||||
crumb: () => "Regions",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/regions/views/region-list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/regions/views/region-details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: () => import("../../routes/users"),
|
||||
handle: {
|
||||
crumb: () => "Users",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "currencies",
|
||||
lazy: () =>
|
||||
import("../../routes/currencies/views/currencies-details"),
|
||||
handle: {
|
||||
crumb: () => "Currencies",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "taxes",
|
||||
handle: {
|
||||
crumb: () => "Taxes",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/taxes/views/tax-list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/taxes/views/tax-details"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "sales-channels",
|
||||
handle: {
|
||||
crumb: () => "Sales Channels",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/sales-channels/views/sales-channel-list"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/sales-channels/views/sales-channel-details"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "api-key-management",
|
||||
lazy: () => import("../../routes/api-key-management"),
|
||||
handle: {
|
||||
crumb: () => "API Key Management",
|
||||
},
|
||||
},
|
||||
...settingsExtensions,
|
||||
],
|
||||
},
|
||||
...routeExtensions,
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
lazy: () => import("../../routes/no-match"),
|
||||
},
|
||||
])
|
||||
|
||||
export const RouterProvider = () => {
|
||||
return <Provider router={router} />
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SearchProvider } from "./search-provider"
|
||||
export { useSearch } from "./use-search"
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react"
|
||||
|
||||
type SearchContextValue = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
toggleSearch: () => void
|
||||
}
|
||||
|
||||
export const SearchContext = createContext<SearchContextValue | null>(null)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PropsWithChildren, useEffect, useState } from "react"
|
||||
import { Search } from "../../components/search"
|
||||
import { SearchContext } from "./search-context"
|
||||
|
||||
export const SearchProvider = ({ children }: PropsWithChildren) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const toggleSearch = () => {
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
value={{
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
toggleSearch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Search />
|
||||
</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react"
|
||||
import { SearchContext } from "./search-context"
|
||||
|
||||
export const useSearch = () => {
|
||||
const context = useContext(SearchContext)
|
||||
if (!context) {
|
||||
throw new Error("useSearch must be used within a SearchProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { Theme } from "./theme-context";
|
||||
export * from "./theme-provider";
|
||||
export * from "./use-theme";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PropsWithChildren, useEffect, useState } from "react";
|
||||
import { Theme, ThemeContext } from "./theme-context";
|
||||
|
||||
const THEME_KEY = "medusa_admin_theme";
|
||||
|
||||
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||
const [state, setState] = useState<Theme>(
|
||||
(localStorage?.getItem(THEME_KEY) as Theme) || "light"
|
||||
);
|
||||
|
||||
const setTheme = (theme: Theme) => {
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
setState(theme);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.querySelector("html");
|
||||
if (html) {
|
||||
/**
|
||||
* Temporarily disable transitions to prevent
|
||||
* the theme change from flashing.
|
||||
*/
|
||||
const css = document.createElement("style");
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`* {
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
-ms-transition: none !important;
|
||||
transition: none !important;
|
||||
}`
|
||||
)
|
||||
);
|
||||
document.head.appendChild(css);
|
||||
|
||||
html.classList.remove(state === "light" ? "dark" : "light");
|
||||
html.classList.add(state);
|
||||
|
||||
/**
|
||||
* Re-enable transitions after the theme has been set,
|
||||
* and force the browser to repaint.
|
||||
*/
|
||||
window.getComputedStyle(css).opacity;
|
||||
document.head.removeChild(css);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme: state, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { ThemeContext } from "./theme-context";
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import { InformationCircle } from "@medusajs/icons"
|
||||
import { PublishableApiKey, SalesChannel } from "@medusajs/medusa"
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
FocusModal,
|
||||
Heading,
|
||||
Hint,
|
||||
Input,
|
||||
Label,
|
||||
Table,
|
||||
Text,
|
||||
clx,
|
||||
} from "@medusajs/ui"
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
useAdminCreatePublishableApiKey,
|
||||
useAdminPublishableApiKeys,
|
||||
useAdminSalesChannels,
|
||||
} from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Form } from "../../components/common/form"
|
||||
|
||||
export const ApiKeyManagement = () => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
const { publishable_api_keys, isLoading, isError, error } =
|
||||
useAdminPublishableApiKeys()
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
data: publishable_api_keys || [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// TODO: Move to loading.tsx and set as Suspense fallback for the route
|
||||
if (isLoading) {
|
||||
return <div>Loading</div>
|
||||
}
|
||||
|
||||
// TODO: Move to error.tsx and set as ErrorBoundary for the route
|
||||
if (isError || !publishable_api_keys) {
|
||||
const err = error ? JSON.parse(JSON.stringify(error)) : null
|
||||
return (
|
||||
<div>
|
||||
{(err as Error & { status: number })?.status === 404 ? (
|
||||
<div>Not found</div>
|
||||
) : (
|
||||
<div>Something went wrong!</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasData = publishable_api_keys.length !== 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Container className="p-0">
|
||||
<div className="px-8 py-6 pb-4">
|
||||
<Heading>{t("apiKeyManagement.domain")}</Heading>
|
||||
</div>
|
||||
<div className="border-ui-border-base border-y">
|
||||
{hasData ? (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="flex flex-col items-center py-24">
|
||||
<div className="flex flex-col items-center gap-y-6">
|
||||
<div className="flex flex-col items-center gap-y-2">
|
||||
<InformationCircle />
|
||||
<Text weight="plus" size="small" leading="compact">
|
||||
{t("general.noRecordsFound")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-muted">
|
||||
{t("apiKeyManagement.createAPublishableApiKey")}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateModal(!showCreateModal)}
|
||||
>
|
||||
{t("apiKeyManagement.createKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[72px]"></div>
|
||||
</Container>
|
||||
<CreatePublishableApiKey
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PublishableApiKey>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("title", {
|
||||
header: t("fields.title"),
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}),
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
const CreatePublishableApiKeySchema = zod.object({
|
||||
title: zod.string().min(1),
|
||||
sales_channel_ids: zod.array(zod.string()).min(1),
|
||||
})
|
||||
|
||||
type CreatePublishableApiKeySchema = zod.infer<
|
||||
typeof CreatePublishableApiKeySchema
|
||||
>
|
||||
|
||||
type CreatePublishableApiKeyProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const salesChannelColumnHelper = createColumnHelper<SalesChannel>()
|
||||
|
||||
const useSalesChannelColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
salesChannelColumnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
salesChannelColumnHelper.accessor("name", {
|
||||
header: t("fields.name"),
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}),
|
||||
salesChannelColumnHelper.accessor("description", {
|
||||
header: t("fields.description"),
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
const CreatePublishableApiKey = (props: CreatePublishableApiKeyProps) => {
|
||||
const form = useForm<CreatePublishableApiKeySchema>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
sales_channel_ids: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync } = useAdminCreatePublishableApiKey()
|
||||
|
||||
const { sales_channels, isLoading, isError, error } = useAdminSalesChannels()
|
||||
const columns = useSalesChannelColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
data: sales_channels || [],
|
||||
columns: columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ title, sales_channel_ids }) => {
|
||||
await mutateAsync({
|
||||
title,
|
||||
})
|
||||
})
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FocusModal {...props}>
|
||||
<Form {...form}>
|
||||
<FocusModal.Content>
|
||||
<FocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Button variant="secondary">{t("general.cancel")}</Button>
|
||||
<Button type="submit">Publish API Key</Button>
|
||||
</div>
|
||||
</FocusModal.Header>
|
||||
<FocusModal.Body className="flex flex-col items-center py-16">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading>Create API Key</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Create and manage API keys. API keys are used to limit the
|
||||
scope of requests to specific sales channels.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="grid grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.title")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label weight="plus">Sales Channels</Label>
|
||||
<Hint></Hint>
|
||||
<Container className="overflow-hidden p-0">
|
||||
<div className="px-8 pb-4 pt-6">
|
||||
<Heading level="h2">Sales Channels</Heading>
|
||||
</div>
|
||||
<Table>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className="[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap [&_th]:w-1/3"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0">
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg last-of-type:border-b-0 [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusModal.Body>
|
||||
</FocusModal.Content>
|
||||
</Form>
|
||||
</FocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ApiKeyManagement as Component } from "./api-key-management"
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
import after from "medusa-admin:widgets/product_category/details/after";
|
||||
import before from "medusa-admin:widgets/product_category/details/before";
|
||||
|
||||
export const CategoryDetails = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Container>
|
||||
<Heading>Category</Heading>
|
||||
</Container>
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryDetails as Component } from "./details";
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoriesList as Component } from "./list";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
import after from "medusa-admin:widgets/product_category/list/after";
|
||||
import before from "medusa-admin:widgets/product_category/list/before";
|
||||
|
||||
export const CategoriesList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{before.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Container>
|
||||
<Heading>Categories</Heading>
|
||||
</Container>
|
||||
{after.widgets.map((w, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<w.Component />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
export const CollectionDetails = () => {
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<Heading>Collection</Heading>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CollectionDetails as Component } from "./details";
|
||||
@@ -0,0 +1 @@
|
||||
export { CollectionsList as Component } from "./list";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user