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:
Kasper Fabricius Kristensen
2024-01-08 10:26:46 +01:00
committed by GitHub
parent 479a8b82a9
commit f868775861
491 changed files with 11332 additions and 428 deletions

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
# cli

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env node
function start() {
return import("../dist/cli/index.mjs");
}
start();

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
export { build } from "./api/build.js"
export { bundle } from "./api/bundle.js"
export { dev } from "./api/dev.js"

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

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["./src/index.ts", "./src/cli/index.ts"],
format: ["esm"],
dts: true,
})

View File

@@ -0,0 +1 @@
# shared

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

View 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

View File

@@ -0,0 +1,2 @@
export * from "./constants"
export * from "./types"

View File

@@ -0,0 +1,3 @@
import { injectionZones } from "./constants"
export type InjectionZone = (typeof injectionZones)[number]

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

View 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

View 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

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

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

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

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

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

View 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

View 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

View File

@@ -0,0 +1 @@
export * from "./require-auth";

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./debounced-search"

View File

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

View File

@@ -0,0 +1 @@
export * from "./form";

View File

@@ -0,0 +1 @@
export * from "./json-view";

View File

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

View File

@@ -0,0 +1 @@
export * from "./product-table-cells"

View File

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

View File

@@ -0,0 +1 @@
export * from "./thumbnail";

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { ErrorBoundary } from "./error-boundary"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./app-layout";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./public-layout";

View File

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

View File

@@ -0,0 +1 @@
export { Search } from "./search"

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

View 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

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

View File

@@ -0,0 +1,7 @@
import { Resources } from "./i18n/types";
declare module "i18next" {
interface CustomTypeOptions {
resources: Resources;
}
}

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./auth-provider";
export * from "./use-auth";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { FeatureProvider } from "./feature-provider";
export { useFeature } from "./use-feature";

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from "./router-provider";

View File

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

View File

@@ -0,0 +1,2 @@
export { SearchProvider } from "./search-provider"
export { useSearch } from "./use-search"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export type { Theme } from "./theme-context";
export * from "./theme-provider";
export * from "./use-theme";

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { ApiKeyManagement as Component } from "./api-key-management"

View File

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

View File

@@ -0,0 +1 @@
export { CategoryDetails as Component } from "./details";

View File

@@ -0,0 +1 @@
export { CategoriesList as Component } from "./list";

View File

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

View File

@@ -0,0 +1,11 @@
import { Container, Heading } from "@medusajs/ui";
export const CollectionDetails = () => {
return (
<div>
<Container>
<Heading>Collection</Heading>
</Container>
</div>
);
};

View File

@@ -0,0 +1 @@
export { CollectionDetails as Component } from "./details";

View File

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